12789529a1
Every button spawned via spawn_modal_button is now keyboard-navigable. Tab/Shift-Tab cycles focus within the active modal, Enter activates the focused button via the same Interaction::Pressed signal mouse clicks use, and the primary action auto-focuses on modal open. Mouse clicks transfer focus so the two input modes stay in sync. The visual indicator is a single overlay entity that's reparented above the topmost modal scrim and tracks the focused button's GlobalTransform + ComputedNode each frame. Sitting outside the modal-card subtree means the ring isn't affected by the open animation's 0.96→1.0 scale, and sitting outside any scroll container means it can't be clipped by Settings' Overflow::scroll_y. Z-order sits one rung above Z_MODAL_TOP via the new Z_FOCUS_RING token. Existing 11 modals (Help, Stats, Achievements, Settings, Profile, Leaderboard, Pause, Forfeit confirm, GameOver, Confirm new game, Onboarding, Home) get focus support without any call-site changes — attach_focusable_to_modal_buttons walks the ancestry of any ModalButton lacking Focusable to find its scrim and tags it automatically. selection_plugin's Tab handler keeps working when no modal is open; when one is, focus consumes Tab/Enter before the selection system sees them. Phase 1 scope only — HUD action bar, Home mode cards, and Settings bespoke buttons (icon, swatch, toggle) come in Phase 2/3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.7 KiB
Rust
135 lines
5.7 KiB
Rust
use std::fs::OpenOptions;
|
|
use std::io::Write;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::window::{MonitorSelection, WindowPosition};
|
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
|
use solitaire_engine::{
|
|
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
|
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
|
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
|
StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin,
|
|
WeeklyGoalsPlugin, WinSummaryPlugin,
|
|
};
|
|
|
|
fn main() {
|
|
// Install a panic hook that writes a crash log next to the save files
|
|
// before re-running the default hook (so stderr still gets the message
|
|
// and any debugger attached still sees the panic).
|
|
install_crash_log_hook();
|
|
|
|
// Initialise the platform keyring store before any token operations.
|
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
|
// If the platform has no OS keyring (e.g. a headless CI box), keyring
|
|
// operations will fail gracefully with TokenError::KeychainUnavailable.
|
|
if let Err(e) = keyring::use_native_store(true) {
|
|
eprintln!(
|
|
"warn: could not initialise OS keyring ({e}); \
|
|
server sync login will be unavailable"
|
|
);
|
|
}
|
|
|
|
// Load settings before building the app so we can construct the right
|
|
// sync provider. Falls back to defaults if no settings file exists yet.
|
|
let settings: Settings = settings_file_path()
|
|
.map(|p| load_settings_from(&p))
|
|
.unwrap_or_default();
|
|
let sync_provider = provider_for_backend(&settings.sync_backend);
|
|
|
|
App::new()
|
|
.add_plugins(
|
|
DefaultPlugins
|
|
.set(WindowPlugin {
|
|
primary_window: Some(Window {
|
|
title: "Solitaire Quest".into(),
|
|
// X11/Wayland WM_CLASS so taskbar managers group
|
|
// multiple windows of this app correctly.
|
|
name: Some("solitaire-quest".into()),
|
|
resolution: (1280u32, 800u32).into(),
|
|
position: WindowPosition::Centered(MonitorSelection::Primary),
|
|
resize_constraints: bevy::window::WindowResizeConstraints {
|
|
min_width: 800.0,
|
|
min_height: 600.0,
|
|
..default()
|
|
},
|
|
..default()
|
|
}),
|
|
..default()
|
|
})
|
|
// The `assets/` directory lives at the workspace root, but
|
|
// Bevy resolves `AssetPlugin::file_path` relative to the
|
|
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
|
// Point one level up so `cargo run -p solitaire_app` finds
|
|
// card faces, backs, backgrounds, and the UI font.
|
|
.set(bevy::asset::AssetPlugin {
|
|
file_path: "../assets".to_string(),
|
|
..default()
|
|
}),
|
|
)
|
|
.add_plugins(FontPlugin)
|
|
.add_plugins(GamePlugin)
|
|
.add_plugins(TablePlugin)
|
|
.add_plugins(CardPlugin)
|
|
.add_plugins(CursorPlugin)
|
|
.add_plugins(InputPlugin)
|
|
.add_plugins(SelectionPlugin)
|
|
.add_plugins(AnimationPlugin)
|
|
.add_plugins(FeedbackAnimPlugin)
|
|
.add_plugins(CardAnimationPlugin)
|
|
.add_plugins(AutoCompletePlugin)
|
|
.add_plugins(StatsPlugin::default())
|
|
.add_plugins(ProgressPlugin::default())
|
|
.add_plugins(AchievementPlugin::default())
|
|
.add_plugins(DailyChallengePlugin)
|
|
.add_plugins(WeeklyGoalsPlugin)
|
|
.add_plugins(ChallengePlugin)
|
|
.add_plugins(TimeAttackPlugin)
|
|
.add_plugins(HudPlugin)
|
|
.add_plugins(HelpPlugin)
|
|
.add_plugins(HomePlugin)
|
|
.add_plugins(ProfilePlugin)
|
|
.add_plugins(PausePlugin)
|
|
.add_plugins(SettingsPlugin::default())
|
|
.add_plugins(AudioPlugin)
|
|
.add_plugins(OnboardingPlugin)
|
|
.add_plugins(SyncPlugin::new(sync_provider))
|
|
.add_plugins(LeaderboardPlugin)
|
|
.add_plugins(WinSummaryPlugin)
|
|
.add_plugins(UiModalPlugin)
|
|
.add_plugins(UiFocusPlugin)
|
|
.run();
|
|
}
|
|
|
|
/// Wraps the default panic hook with one that also appends a crash log
|
|
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
|
|
/// still runs afterwards, so stderr output and debugger integration are
|
|
/// unchanged. If the data directory is unavailable, the wrapper silently
|
|
/// falls through — the default hook handles output either way.
|
|
fn install_crash_log_hook() {
|
|
let crash_log_path = settings_file_path().and_then(|p| {
|
|
p.parent()
|
|
.map(|parent| parent.join("crash.log"))
|
|
});
|
|
let default_hook = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
if let Some(path) = crash_log_path.as_ref()
|
|
&& let Ok(mut file) = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(path)
|
|
{
|
|
// Plain unix-seconds timestamp keeps the format trivially
|
|
// parseable and avoids pulling in chrono just for this.
|
|
let secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let _ = writeln!(file, "----- t={secs} -----\n{info}\n");
|
|
}
|
|
default_hook(info);
|
|
}));
|
|
}
|