From ce536b01765e102eef777aeaa23c68f556d96962 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 27 May 2026 18:00:57 -0700 Subject: [PATCH] refactor(engine): audit and rationalize platform cfg gates (closes #49) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solitaire_engine/src/card_plugin.rs | 38 ++--- solitaire_engine/src/core_game_plugin.rs | 13 +- solitaire_engine/src/game_plugin.rs | 11 +- solitaire_engine/src/help_plugin.rs | 56 ++++---- solitaire_engine/src/home_plugin.rs | 53 +++---- solitaire_engine/src/hud_plugin.rs | 154 +++++++++------------ solitaire_engine/src/onboarding_plugin.rs | 13 +- solitaire_engine/src/platform/clipboard.rs | 72 ++++++++++ solitaire_engine/src/platform/mod.rs | 22 ++- solitaire_engine/src/replay_overlay.rs | 29 ++-- solitaire_engine/src/stats_plugin.rs | 65 +++------ solitaire_engine/src/sync_setup_plugin.rs | 16 ++- solitaire_engine/src/ui_modal.rs | 3 +- 13 files changed, 302 insertions(+), 243 deletions(-) create mode 100644 solitaire_engine/src/platform/clipboard.rs diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index c86728e..029624c 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -14,9 +14,8 @@ use std::collections::{HashMap, HashSet}; use bevy::color::Color; use bevy::prelude::*; -use bevy::window::WindowResized; -#[cfg(target_os = "android")] use bevy::sprite::Anchor; +use bevy::window::WindowResized; use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::pile::PileType; @@ -26,13 +25,14 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT}; use crate::card_animation::CardAnimation; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; +use crate::font_plugin::FontResource; use crate::game_plugin::GameMutation; use crate::layout::{Layout, LayoutResource, LayoutSystem}; use crate::pause_plugin::PausedResource; +use crate::platform::USE_TOUCH_UI_LAYOUT; use crate::resources::{DragState, GameStateResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR}; -use crate::font_plugin::FontResource; use crate::ui_theme::{ CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z, CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG, @@ -73,10 +73,9 @@ pub const STACK_FAN_FRAC: f32 = 0.025; /// Font size as a fraction of card width. const FONT_SIZE_FRAC: f32 = 0.28; -/// Font-size fraction for the large-print readability overlay on Android. +/// Font-size fraction for the large-print readability overlay on touch HUD layouts. /// Spawned on top of PNG face cards to make the rank+suit legible at phone /// scale, where the baked-in PNG corner text is only ~10 px physical. -#[cfg(target_os = "android")] const FONT_SIZE_FRAC_MOBILE: f32 = 0.35; /// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED). @@ -177,13 +176,12 @@ pub struct CardEntity { #[derive(Component, Debug)] pub struct CardLabel; -/// Marker for the large-print rank+suit corner overlay on Android. +/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts. /// /// Spawned on top of PNG face cards (face-up only) at font size /// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are /// readable at phone scale. Only exists when `CardImageSet` is present /// (the fallback solid-colour path uses a plain `CardLabel` instead). -#[cfg(target_os = "android")] #[derive(Component, Debug, Clone)] struct AndroidCornerLabel(pub String); @@ -192,10 +190,11 @@ struct AndroidCornerLabel(pub String); /// Covers the card art's own small corner rank/suit text so only the /// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived /// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face. -#[cfg(target_os = "android")] #[derive(Component, Debug, Clone, Copy)] struct AndroidCornerBg; +type AndroidCornerBgFilter = (With, Without); + /// Marker component indicating the card is currently highlighted as a hint. /// `remaining` counts down in real seconds; the highlight is removed when it /// reaches zero and the card sprite colour is restored to its normal value. @@ -465,7 +464,6 @@ impl Plugin for CardPlugin { ), ); - #[cfg(target_os = "android")] app.add_systems(Update, resize_android_corner_labels); } } @@ -920,15 +918,11 @@ fn spawn_card_entity( )); }); } - #[cfg(target_os = "android")] - if card_images.is_some() { + if USE_TOUCH_UI_LAYOUT && card_images.is_some() { entity.with_children(|b| { add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); }); } - // Suppress unused-variable warning when not building for Android. - #[cfg(not(target_os = "android"))] - let _ = font_handle; entity_id } @@ -1013,15 +1007,11 @@ fn update_card_entity( )); }); } - #[cfg(target_os = "android")] - if card_images.is_some() { + if USE_TOUCH_UI_LAYOUT && card_images.is_some() { commands.entity(entity).with_children(|b| { add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle); }); } - // Suppress unused-variable warning when not building for Android. - #[cfg(not(target_os = "android"))] - let _ = font_handle; } fn label_for(card: &Card) -> String { @@ -1094,9 +1084,8 @@ fn label_visibility(card: &Card) -> Visibility { } } -/// Rank+suit string for the Android readability overlay. +/// Rank+suit string for the readability overlay on touch HUD layouts. /// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono). -#[cfg(target_os = "android")] fn mobile_label_for(card: &Card) -> String { let rank = match card.rank { Rank::Ace => "A", @@ -1132,7 +1121,6 @@ fn mobile_label_for(card: &Card) -> String { /// those glyphs, causing a coloured missing-glyph rectangle to appear in /// the text colour — the root cause of the "red square on face-down cards" /// visual bug (the box bleeds through near the card edge at z=0.02). -#[cfg(target_os = "android")] fn add_android_corner_label( parent: &mut ChildSpawnerCommands, card: &Card, @@ -2146,15 +2134,11 @@ fn resize_cards_in_place( /// change or any window resize). The full despawn/respawn path in /// `update_card_entity` already handles game-state changes; this system /// covers the resize-only path where children are mutated in place. -#[cfg(target_os = "android")] fn resize_android_corner_labels( layout: Res, card_images: Option>, mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>, - mut bg_query: Query< - (&mut Sprite, &mut Transform), - (With, Without), - >, + mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>, ) { if !layout.is_changed() || card_images.is_none() { return; diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index 7b5116f..223a93b 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -8,7 +8,10 @@ use std::sync::Mutex; use bevy::prelude::*; -use crate::platform::{StorageBackendResource, default_storage_backend}; +use crate::platform::{ + ClipboardBackendResource, StorageBackendResource, default_clipboard_backend, + default_storage_backend, +}; use crate::{ AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, @@ -53,6 +56,14 @@ impl Plugin for CoreGamePlugin { warn!("storage: failed to initialize platform backend: {err}"); } } + match default_clipboard_backend() { + Ok(clipboard) => { + app.insert_resource(ClipboardBackendResource(clipboard)); + } + Err(err) => { + warn!("clipboard: failed to initialize platform backend: {err}"); + } + } app.add_plugins(AssetSourcesPlugin) .add_plugins(ThemePlugin) diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin.rs index fdb5fce..08d4a21 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin.rs @@ -28,6 +28,11 @@ use crate::events::{ CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, }; + +#[cfg(target_os = "android")] +const NO_MOVES_MSG: &str = "No moves available — tap the stock to draw or start a new game"; +#[cfg(not(target_os = "android"))] +const NO_MOVES_MSG: &str = "No moves available — press D to draw or N for a new game"; use crate::font_plugin::FontResource; use crate::resources::{DragState, GameStateResource, SyncStatusResource}; use crate::ui_modal::{ @@ -1107,11 +1112,7 @@ fn check_no_moves( } if !moves_ok && !*already_fired { - #[cfg(target_os = "android")] - let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game"; - #[cfg(not(target_os = "android"))] - let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game"; - toast.write(InfoToastEvent(no_moves_msg.to_string())); + toast.write(InfoToastEvent(NO_MOVES_MSG.to_string())); *already_fired = true; // Only spawn the overlay if one does not already exist, and no other // modal scrim is currently open (global ModalScrim guard). diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 1c85b5a..90b752d 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -11,13 +11,15 @@ use crate::events::HelpRequestEvent; use crate::font_plugin::FontResource; #[cfg(target_os = "android")] use crate::hud_plugin::ANDROID_HINT_LABEL; +use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, ModalScrim, ScrimDismissible, }; -use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL}; -#[cfg(not(target_os = "android"))] -use crate::ui_theme::{BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TYPE_CAPTION, VAL_SPACE_1}; +use crate::ui_theme::{ + BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, + TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, +}; /// Marker on the help overlay root node. #[derive(Component, Debug)] @@ -246,7 +248,6 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { ..default() }; let font_row = font_section.clone(); - #[cfg(not(target_os = "android"))] let font_kbd = TextFont { font: font_handle, font_size: TYPE_CAPTION, @@ -291,29 +292,30 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { ..default() }) .with_children(|line| { - // Keyboard chip — suppressed on Android (no keyboard). - #[cfg(not(target_os = "android"))] - line.spawn(( - Node { - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), - min_width: Val::Px(64.0), - justify_content: JustifyContent::Center, - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), - ..default() - }, - BorderColor::all(BORDER_SUBTLE), - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|chip| { - chip.spawn(( - Text::new(row.keys), - font_kbd.clone(), - TextColor(TEXT_PRIMARY), - )); - }); - line.spawn(( - Text::new(row.description), + // Keyboard chip — suppressed on touch-first Android builds. + if SHOW_KEYBOARD_ACCELERATORS { + line.spawn(( + Node { + padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), + min_width: Val::Px(64.0), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|chip| { + chip.spawn(( + Text::new(row.keys), + font_kbd.clone(), + TextColor(TEXT_PRIMARY), + )); + }); + } + + line.spawn(( Text::new(row.description), font_row.clone(), TextColor(TEXT_PRIMARY), )); diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 912f1e8..07202b4 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -174,17 +174,17 @@ impl HomeMode { } /// The keyboard accelerator that dispatches the same launch event, - /// shown in a small chip on the card. - #[cfg(not(target_os = "android"))] - fn hotkey(self) -> &'static str { - match self { + /// shown in a small chip on desktop cards. + fn hotkey(self) -> Option<&'static str> { + let key = match self { HomeMode::Classic => "N", HomeMode::Daily => "C", HomeMode::Zen => "Z", HomeMode::Challenge => "X", HomeMode::TimeAttack => "T", HomeMode::PlayBySeed => "6", - } + }; + crate::platform::SHOW_KEYBOARD_ACCELERATORS.then_some(key) } /// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`. @@ -1392,27 +1392,28 @@ fn spawn_mode_card( )); if unlocked { - // Hotkey chip — suppressed on Android (touch builds have no keyboard). - #[cfg(not(target_os = "android"))] - row.spawn(( - Node { - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), - min_width: Val::Px(32.0), - justify_content: JustifyContent::Center, - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), - ..default() - }, - BorderColor::all(BORDER_SUBTLE), - HighContrastBorder::with_default(BORDER_SUBTLE), - )) - .with_children(|chip| { - chip.spawn(( - Text::new(mode.hotkey().to_string()), - font_chip.clone(), - TextColor(TEXT_SECONDARY), - )); - }); + // Hotkey chip — suppressed on touch-first Android builds. + if let Some(hotkey) = mode.hotkey() { + row.spawn(( + Node { + padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), + min_width: Val::Px(32.0), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + HighContrastBorder::with_default(BORDER_SUBTLE), + )) + .with_children(|chip| { + chip.spawn(( + Text::new(hotkey), + font_chip.clone(), + TextColor(TEXT_SECONDARY), + )); + }); + } } else { // Lock icon stand-in — text glyph keeps the layout // dependency-free (no asset loader required) and diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index 0bdd6c0..ecf580c 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -41,6 +41,7 @@ use crate::game_plugin::GameMutation; #[cfg(target_os = "android")] use crate::input_plugin::TouchDragSet; use crate::layout::LayoutSystem; +use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT}; #[cfg(target_os = "android")] use crate::pause_plugin::PausedResource; use crate::resources::GameStateResource; @@ -140,9 +141,8 @@ pub struct HudColumn; #[derive(Component, Debug)] pub struct HudActionBar; -/// Marker on the text node inside each action-bar button (Android only). +/// Marker on the text node inside each touch-layout action-bar button. /// Used by `resize_action_bar_labels` to update font size on window resize. -#[cfg(target_os = "android")] #[derive(Component, Debug)] struct ActionButtonLabel; @@ -309,6 +309,23 @@ pub struct HintButton; #[cfg(target_os = "android")] pub(crate) const ANDROID_HINT_LABEL: &str = "!"; +#[cfg(target_os = "android")] +const ACTION_BAR_LABELS: [&str; 7] = ["\u{2261}", "\u{2190}", "||", "?", ANDROID_HINT_LABEL, "M", "+"]; +#[cfg(not(target_os = "android"))] +const ACTION_BAR_LABELS: [&str; 7] = ["Menu \u{2193}", "Undo", "Pause", "Help", "Hint", "Modes \u{2193}", "New Game"]; +#[cfg(target_os = "android")] +const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0); +#[cfg(not(target_os = "android"))] +const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2; +#[cfg(target_os = "android")] +const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0; +#[cfg(not(target_os = "android"))] +const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0; +#[cfg(target_os = "android")] +const HINT_WON_MSG: &str = "Game won! Tap New Game to play again"; +#[cfg(not(target_os = "android"))] +const HINT_WON_MSG: &str = "Game won! Press N for a new game"; + /// Marker on the "Modes" action button. Click toggles the [`ModesPopover`] /// (a small dropdown panel) below the action bar. Each popover row starts /// the corresponding game mode. @@ -857,53 +874,13 @@ fn spawn_action_buttons( windows: Query<&Window>, mut commands: Commands, ) { - // On Android the glyph labels must scale with the viewport so they remain - // legible on any screen density. Use the window width at startup; the - // resize_action_bar_labels system keeps this current on window changes. - #[cfg(target_os = "android")] - let action_font_size = { - let w = windows.iter().next().map_or(900.0, |win| win.width()); - action_bar_font_size(w) - }; - #[cfg(not(target_os = "android"))] - let action_font_size = TYPE_BODY; - #[cfg(not(target_os = "android"))] - let _windows = windows; - + let action_font_size = action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width())); let font = TextFont { font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font_size: action_font_size, ..default() }; - // On Android, compact Unicode symbols fit all 7 buttons in one row. - // On desktop, keep the descriptive text labels. - #[cfg(target_os = "android")] - let col_gap = Val::Px(4.0); - #[cfg(not(target_os = "android"))] - let col_gap = VAL_SPACE_2; - - #[cfg(target_os = "android")] - let labels = ( - /* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono) - /* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono) - /* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono - /* help */ "?", - /* hint */ ANDROID_HINT_LABEL, - /* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono - /* new */ "+", - ); - #[cfg(not(target_os = "android"))] - let labels = ( - "Menu \u{2193}", - "Undo", - "Pause", - "Help", - "Hint", - "Modes \u{2193}", - "New Game", - ); - // Bottom bar: full-width, centered, sits above the gesture-navigation zone. // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once // Android reports it (frames 1-3); initial value is 0.0. @@ -917,7 +894,7 @@ fn spawn_action_buttons( flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, justify_content: JustifyContent::Center, - column_gap: col_gap, + column_gap: ACTION_BAR_COLUMN_GAP, row_gap: VAL_SPACE_2, align_items: AlignItems::Center, padding: UiRect { @@ -938,13 +915,13 @@ fn spawn_action_buttons( // so Tab cycles the action bar in visual reading order. // Undo and Pause are the primary gameplay actions — full brightness. // Menu, Help, Hint, Modes, New are navigation/utility — dimmed. - spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY); - spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY); - spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY); - spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY); - spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY); - spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY); - spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY); + spawn_action_button(row, MenuButton, ACTION_BAR_LABELS[0], None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY); + spawn_action_button(row, UndoButton, ACTION_BAR_LABELS[1], Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY); + spawn_action_button(row, PauseButton, ACTION_BAR_LABELS[2], Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY); + spawn_action_button(row, HelpButton, ACTION_BAR_LABELS[3], Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY); + spawn_action_button(row, HintButton, ACTION_BAR_LABELS[4], Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY); + spawn_action_button(row, ModesButton, ACTION_BAR_LABELS[5], None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY); + spawn_action_button(row, NewGameButton, ACTION_BAR_LABELS[6], Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY); }); } @@ -973,25 +950,16 @@ fn spawn_action_button( ) { // Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a // touch device — the button itself is the affordance — and they - // visibly clutter the narrow-viewport action row. Force the hint - // off on Android; the chevrons on Menu/Modes remain because they - // indicate dropdown behaviour and still apply on touch. - let hotkey = if cfg!(target_os = "android") { None } else { hotkey }; + // visibly clutter the narrow-viewport action row. The chevrons on + // Menu/Modes remain because they indicate dropdown behaviour. + let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None }; let hotkey_font = TextFont { font: font.font.clone(), font_size: TYPE_CAPTION, ..default() }; - // On Android, use tighter padding and a slightly smaller min-size so all - // 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥ - // Apple's minimum touch target; padding of 4 dp each side keeps the icon - // centred with room to breathe. On desktop, keep the comfortable 48 dp - // floor and 8 dp side padding. - #[cfg(target_os = "android")] - let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0)); - #[cfg(not(target_os = "android"))] - let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0)); + let (pad, min_w, min_h) = action_button_metrics(); row.spawn(( marker, @@ -1017,10 +985,7 @@ fn spawn_action_button( HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { - #[cfg(target_os = "android")] - b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color))); - #[cfg(not(target_os = "android"))] - b.spawn((Text::new(label), font.clone(), TextColor(text_color))); + spawn_action_button_label(b, label, font, text_color); if let Some(key) = hotkey { // Hotkey hint rendered as a dim caption next to the label — // keeps the keyboard accelerator discoverable without @@ -1096,11 +1061,7 @@ fn handle_hint_button( } let Some(ref g) = game else { return }; if g.0.is_won { - #[cfg(target_os = "android")] - let won_msg = "Game won! Tap New Game to play again"; - #[cfg(not(target_os = "android"))] - let won_msg = "Game won! Press N for a new game"; - info_toast.write(InfoToastEvent(won_msg.to_string())); + info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); return; } if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { @@ -1195,10 +1156,7 @@ fn spawn_modes_popover( // Popover opens upward from just above the bottom action bar. // Use a platform-aware offset that clears the bar height + safe-area // gesture zone on Android, and the flat bar height on desktop. - #[cfg(target_os = "android")] - let popover_bottom = Val::Px(200.0); - #[cfg(not(target_os = "android"))] - let popover_bottom = Val::Px(80.0); + let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX); commands .spawn(( @@ -1393,10 +1351,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) ]; // Same upward-opening placement as ModesPopover. - #[cfg(target_os = "android")] - let popover_bottom = Val::Px(200.0); - #[cfg(not(target_os = "android"))] - let popover_bottom = Val::Px(80.0); + let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX); commands .spawn(( @@ -2511,14 +2466,37 @@ fn restore_hud_on_modal( } } -/// Returns the action-bar glyph font size for a given logical window width. -/// Scales linearly so glyphs remain legible at any phone density. -#[cfg(target_os = "android")] +/// Returns the action-bar label font size for a given logical window width. fn action_bar_font_size(window_width: f32) -> f32 { - // ~1/40 of the window width gives ~22 px on a 900 logical-px phone. - // Clamped so it never goes too tiny on narrow viewports or too large - // on landscape tablets. - (window_width / 40.0).clamp(16.0, 30.0) + if USE_TOUCH_UI_LAYOUT { + // ~1/40 of the window width gives ~22 px on a 900 logical-px phone. + // Clamped so it never goes too tiny on narrow viewports or too large + // on landscape tablets. + (window_width / 40.0).clamp(16.0, 30.0) + } else { + TYPE_BODY + } +} + +fn action_button_metrics() -> (UiRect, Val, Val) { + if USE_TOUCH_UI_LAYOUT { + (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0)) + } else { + (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0)) + } +} + +fn spawn_action_button_label( + parent: &mut ChildSpawnerCommands, + label: &str, + font: &TextFont, + text_color: Color, +) { + if USE_TOUCH_UI_LAYOUT { + parent.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color))); + } else { + parent.spawn((Text::new(label), font.clone(), TextColor(text_color))); + } } /// Resizes the glyph text inside every [`ActionButtonLabel`] to match the diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index 98391f9..fbda79d 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -287,12 +287,21 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc 0 => spawn_slide_welcome(commands, font_res), 1 => spawn_slide_how_to_play(commands, font_res), // Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard. - #[cfg(not(target_os = "android"))] - 2 => spawn_slide_hotkeys(commands, font_res), + 2 => spawn_slide_hotkeys_if_available(commands, font_res), _ => spawn_slide_welcome(commands, font_res), } } +#[cfg(not(target_os = "android"))] +fn spawn_slide_hotkeys_if_available(commands: &mut Commands, font_res: Option<&FontResource>) { + spawn_slide_hotkeys(commands, font_res); +} + +#[cfg(target_os = "android")] +fn spawn_slide_hotkeys_if_available(commands: &mut Commands, font_res: Option<&FontResource>) { + spawn_slide_welcome(commands, font_res); +} + /// Slide 1 — Welcome. fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { diff --git a/solitaire_engine/src/platform/clipboard.rs b/solitaire_engine/src/platform/clipboard.rs new file mode 100644 index 0000000..ac9338b --- /dev/null +++ b/solitaire_engine/src/platform/clipboard.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use bevy::prelude::Resource; +use thiserror::Error; + +/// Abstracts platform-specific clipboard access for gameplay UI systems. +pub trait ClipboardBackend: Send + Sync + 'static { + /// Write plain text to the active OS clipboard. + fn set_text(&self, text: &str) -> Result<(), ClipboardError>; +} + +/// Bevy resource that exposes the active clipboard backend. +#[derive(Resource, Clone)] +pub struct ClipboardBackendResource(pub Arc); + +/// Errors surfaced by platform clipboard backends. +#[derive(Debug, Error)] +pub enum ClipboardError { + #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] + #[error(transparent)] + Native(#[from] arboard::Error), + #[cfg(target_os = "android")] + #[error("android clipboard failed: {0}")] + Android(String), + #[cfg(all(target_arch = "wasm32", not(target_os = "android")))] + #[error("clipboard backend unavailable on wasm32")] + Unsupported, +} + +/// Construct the default clipboard backend for the current platform. +pub fn default_clipboard_backend() -> Result, ClipboardError> { + #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] + { + Ok(Arc::new(NativeClipboardBackend)) + } + + #[cfg(target_os = "android")] + { + Ok(Arc::new(AndroidClipboardBackend)) + } + + #[cfg(all(target_arch = "wasm32", not(target_os = "android")))] + { + Err(ClipboardError::Unsupported) + } +} + +/// `arboard`-backed clipboard bridge for desktop targets. +#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] +#[derive(Debug, Default, Clone, Copy)] +pub struct NativeClipboardBackend; + +#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] +impl ClipboardBackend for NativeClipboardBackend { + fn set_text(&self, text: &str) -> Result<(), ClipboardError> { + let mut clipboard = arboard::Clipboard::new()?; + clipboard.set_text(text.to_string())?; + Ok(()) + } +} + +/// JNI-backed clipboard bridge for Android targets. +#[cfg(target_os = "android")] +#[derive(Debug, Default, Clone, Copy)] +pub struct AndroidClipboardBackend; + +#[cfg(target_os = "android")] +impl ClipboardBackend for AndroidClipboardBackend { + fn set_text(&self, text: &str) -> Result<(), ClipboardError> { + crate::android_clipboard::set_text(text).map_err(ClipboardError::Android) + } +} diff --git a/solitaire_engine/src/platform/mod.rs b/solitaire_engine/src/platform/mod.rs index b9cafa0..b7f271e 100644 --- a/solitaire_engine/src/platform/mod.rs +++ b/solitaire_engine/src/platform/mod.rs @@ -1,12 +1,28 @@ //! Platform abstraction layer. //! -//! Traits defined here are implemented per target: -//! - native builds use filesystem-backed storage -//! - browser builds use `localStorage` +//! Target-specific implementations live here so gameplay and rendering systems +//! can depend on stable engine-facing abstractions instead of sprinkling +//! `#[cfg(...)]` branches through UI code. +pub mod clipboard; pub mod storage; pub mod time; +#[cfg(target_os = "android")] +/// `false` on touch-first Android builds, where UI buttons replace keyboard chips. +pub const SHOW_KEYBOARD_ACCELERATORS: bool = false; +#[cfg(not(target_os = "android"))] +/// `true` on desktop builds, where keyboard chips should be rendered. +pub const SHOW_KEYBOARD_ACCELERATORS: bool = true; + +#[cfg(target_os = "android")] +/// `true` when the engine should prefer touch-optimised HUD affordances. +pub const USE_TOUCH_UI_LAYOUT: bool = true; +#[cfg(not(target_os = "android"))] +/// `false` when the engine should prefer desktop HUD affordances. +pub const USE_TOUCH_UI_LAYOUT: bool = false; + +pub use clipboard::{ClipboardBackend, ClipboardBackendResource, default_clipboard_backend}; #[cfg(not(target_arch = "wasm32"))] pub use storage::NativeStorage; #[cfg(target_arch = "wasm32")] diff --git a/solitaire_engine/src/replay_overlay.rs b/solitaire_engine/src/replay_overlay.rs index 0b9db90..a350919 100644 --- a/solitaire_engine/src/replay_overlay.rs +++ b/solitaire_engine/src/replay_overlay.rs @@ -28,6 +28,7 @@ use chrono::Datelike; use crate::font_plugin::FontResource; use crate::layout::LayoutResource; +use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::replay_playback::{ step_backwards_replay_playback, step_replay_playback, stop_replay_playback, @@ -971,16 +972,17 @@ fn spawn_overlay( }, TextColor(TEXT_SECONDARY), )); - #[cfg(not(target_os = "android"))] - footer.spawn(( - Text::new(keybind_footer_hint_text()), - TextFont { - font: font_handle_for_labels.clone(), - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - )); + if SHOW_KEYBOARD_ACCELERATORS { + footer.spawn(( + Text::new(keybind_footer_hint_text()), + TextFont { + font: font_handle_for_labels.clone(), + font_size: TYPE_CAPTION, + ..default() + }, + TextColor(TEXT_SECONDARY), + )); + } }); }); @@ -1256,9 +1258,12 @@ fn keybind_footer_mode_text() -> &'static str { /// pause/resume, the ESC accelerator for stop, and the ← / → /// accelerators for paused single-move stepping. The footer never /// lists unimplemented keybinds (would lie to users). -#[cfg(not(target_os = "android"))] fn keybind_footer_hint_text() -> &'static str { - "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator + if SHOW_KEYBOARD_ACCELERATORS { + "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator + } else { + "" + } } /// Pure helper — returns the WIN MOVE marker's left-edge position as diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 73334f9..05c8e65 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -23,6 +23,7 @@ use crate::events::{ WinStreakMilestoneEvent, }; use crate::game_plugin::GameMutation; +use crate::platform::ClipboardBackendResource; use crate::progress_plugin::ProgressResource; use crate::font_plugin::FontResource; use crate::resources::GameStateResource; @@ -77,8 +78,8 @@ pub struct ReplayHistoryResource(pub ReplayHistory); /// Marker on the "Copy share link" button inside the Stats modal. /// Click reads the share URL from the currently-selected replay -/// (`history.0.replays[selected.0].share_url`) and writes it to the -/// OS clipboard via `arboard`, surfacing a confirmation toast. The +/// (`history.0.replays[selected.0].share_url`) and writes it through the +/// active platform clipboard backend, surfacing a confirmation toast. The /// share URL is populated by `sync_plugin::poll_replay_upload_result` /// when the corresponding win's upload completes and is persisted to /// `replays.json` so it survives a restart. @@ -309,19 +310,19 @@ fn refresh_replay_history_on_win( /// resets the live game to the recorded deal and ticks through the /// move list via [`crate::replay_playback`]; the /// [`crate::replay_overlay`] banner surfaces while playback runs. -/// Copies the currently-selected replay's `share_url` to the OS -/// clipboard via `arboard` and surfaces a confirmation toast. When no -/// URL is in hand on the selected entry (replay never uploaded — the -/// player won on a local-only backend, the upload failed, or the +/// Copies the currently-selected replay's `share_url` through the +/// active platform clipboard backend and surfaces a confirmation toast. +/// When no URL is in hand on the selected entry (replay never uploaded +/// — the player won on a local-only backend, the upload failed, or the /// replay pre-dates v0.19.0 share-link persistence) the button still /// acknowledges the click but explains why the clipboard wasn't -/// written. `arboard::Clipboard::new()` failures are logged + surfaced -/// as a generic "couldn't reach the clipboard" toast rather than -/// swallowed — they're rare but worth diagnosing. +/// written. Backend failures are logged and fall back to surfacing the +/// share URL directly in a toast. fn handle_copy_share_link_button( buttons: Query<&Interaction, (With, Changed)>, history: Res, selected: Res, + clipboard: Option>, mut toast: MessageWriter, ) { if !buttons.iter().any(|i| *i == Interaction::Pressed) { @@ -339,42 +340,18 @@ fn handle_copy_share_link_button( return; }; - // Desktop: `arboard` writes the URL to the OS clipboard. - // Android: `arboard` has no platform backend (would fail to - // compile, so the dependency is target-gated in - // solitaire_engine/Cargo.toml). The button still spawns and - // resolves to a meaningful toast instead — when we wire the - // Android Phase, this becomes a JNI call into ClipboardManager. - #[cfg(not(target_os = "android"))] - { - match arboard::Clipboard::new() { - Ok(mut cb) => match cb.set_text(url.clone()) { - Ok(()) => { - toast.write(InfoToastEvent(format!("Copied: {url}"))); - } - Err(e) => { - warn!("clipboard write failed: {e}"); - toast.write(InfoToastEvent( - "Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(), - )); - } - }, - Err(e) => { - warn!("clipboard init failed: {e}"); - toast.write(InfoToastEvent( - "Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(), - )); - } + let Some(clipboard) = clipboard else { + toast.write(InfoToastEvent(format!("Share link: {url}"))); + return; + }; + + match clipboard.0.set_text(url) { + Ok(()) => { + toast.write(InfoToastEvent(format!("Copied: {url}"))); } - } - #[cfg(target_os = "android")] - { - match crate::android_clipboard::set_text(&url) { - Ok(()) => { toast.write(InfoToastEvent(format!("Copied: {url}"))); } - Err(e) => { - warn!("android clipboard failed: {e}"); - toast.write(InfoToastEvent(format!("Share link: {url}"))); - } + Err(e) => { + warn!("clipboard write failed: {e}"); + toast.write(InfoToastEvent(format!("Share link: {url}"))); } } } diff --git a/solitaire_engine/src/sync_setup_plugin.rs b/solitaire_engine/src/sync_setup_plugin.rs index 5e94723..54c464e 100644 --- a/solitaire_engine/src/sync_setup_plugin.rs +++ b/solitaire_engine/src/sync_setup_plugin.rs @@ -52,6 +52,7 @@ use crate::events::{ SyncLogoutRequestEvent, }; use crate::font_plugin::FontResource; +use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath}; use crate::resources::TokioRuntimeResource; use crate::sync_plugin::SyncProviderResource; @@ -723,13 +724,14 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc )); }); - // Tab hint — desktop only; no Tab key on Android. - #[cfg(not(target_os = "android"))] - body.spawn(( - Text::new("Tab = next field"), - make_font(font_res, TYPE_CAPTION), - TextColor(TEXT_DISABLED), - )); + // Tab hint — desktop only; no Tab key on touch-first Android builds. + if SHOW_KEYBOARD_ACCELERATORS { + body.spawn(( + Text::new("Tab = next field"), + make_font(font_res, TYPE_CAPTION), + TextColor(TEXT_DISABLED), + )); + } }); // Action row. diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 5dd80c5..3ed1796 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -55,6 +55,7 @@ use bevy::window::PrimaryWindow; use solitaire_data::AnimSpeed; use crate::font_plugin::FontResource; +use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, @@ -342,7 +343,7 @@ pub fn spawn_modal_button( variant: ButtonVariant, font_res: Option<&FontResource>, ) { - let hotkey = if cfg!(target_os = "android") { None } else { hotkey }; + let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None }; let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(),