use super::ReplayPlaybackState; use chrono::Datelike; use solitaire_core::game_state::GameState; use solitaire_core::{Card, Rank, Suit}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau}; /// Pure helper — formats the `GAME #YYYY-DDD` caption for the given /// state. Returns `None` for `Inactive` / `Completed` (the replay is /// consumed when transitioning out of `Playing`, so the identifier /// isn't recoverable from state in those branches); spawn-time /// callers fall back to an empty string. /// /// Year + chrono ordinal (`{year}-{ordinal:03}`) gives a compact /// monotonically-increasing identifier shaped like `2026-127` — same /// shape as the mockup's `GAME #2024-127` motif. pub(crate) fn format_game_caption(state: &ReplayPlaybackState) -> Option { match state { ReplayPlaybackState::Playing { replay, .. } => Some(format!( "GAME #{}-{:03}", replay.recorded_at.year(), replay.recorded_at.ordinal() )), ReplayPlaybackState::Inactive | ReplayPlaybackState::Completed => None, } } /// Pure helper — formats the centre progress readout for the given state. /// Exposed at module scope so the spawn path and the per-frame update /// path produce the exact same string. pub(crate) fn format_progress(state: &ReplayPlaybackState) -> String { match state.progress() { // `MOVE N/M` (uppercase + slash) reads as a Terminal output // line and matches the floating-chip motif in the mockup at // `docs/ui-mockups/replay-overlay-mobile.html`. Some((cursor, total)) => format!("MOVE {cursor}/{total}"), None if state.is_completed() => "REPLAY COMPLETE".to_string(), None => String::new(), } } /// Pure helper — formats a [`KlondikePile`] as a short, lowercase, /// 1-indexed display string for the move-log row. `Foundation(2)` /// renders as `"foundation 3"` rather than `"foundation 2"` so /// players see human-friendly numbers; the underlying enum /// remains 0-indexed. /// /// Returns `String` rather than `&'static str` because the /// `Foundation` / `Tableau` variants need formatting; the static /// variants (`Stock`, `Waste`) still allocate but the cost is /// trivial against the per-frame update cadence. pub(crate) fn format_pile(p: &KlondikePile) -> String { match p { KlondikePile::Stock => "waste".to_string(), KlondikePile::Foundation(foundation) => { format!("foundation {}", foundation_number(*foundation)) } KlondikePile::Tableau(tableau) => format!("tableau {}", tableau_number(*tableau)), } } fn foundation_number(foundation: Foundation) -> u8 { match foundation { Foundation::Foundation1 => 1, Foundation::Foundation2 => 2, Foundation::Foundation3 => 3, Foundation::Foundation4 => 4, } } fn tableau_number(tableau: Tableau) -> u8 { match tableau { Tableau::Tableau1 => 1, Tableau::Tableau2 => 2, Tableau::Tableau3 => 3, Tableau::Tableau4 => 4, Tableau::Tableau5 => 5, Tableau::Tableau6 => 6, Tableau::Tableau7 => 7, } } /// Pure helper — formats a [`KlondikeInstruction`] as the body of a /// move-log row. `RotateStock` reads as `"stock cycle"`; a `Dst*` /// instruction reads as `"{from} → {to}"` using [`format_pile`] for /// each nameable endpoint. The card count is omitted from the row /// body — at row scale it adds visual noise without meaningful /// information for the typical 1-card moves. /// /// The destination pile is always recoverable directly from the /// instruction. The source pile is shown when it is statically /// nameable (a `DstFoundation` carries a [`KlondikePile`] source); /// a `DstTableau`'s source is the runtime-only `KlondikePileStack` /// type — not re-exported across the `solitaire_core` boundary and so /// not pattern-matchable here — so its row renders `"→ {to}"` without /// a leading source label. Faithful full-coordinate decoding lives in /// [`GameState::instruction_to_piles`] on the playback path; the /// move-log is a display-only digest. pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String { match instruction { KlondikeInstruction::RotateStock => "stock cycle".to_string(), KlondikeInstruction::DstFoundation(dst) => { format!( "{} \u{2192} {}", format_pile(&dst.src), format_pile(&KlondikePile::Foundation(dst.foundation)) ) } KlondikeInstruction::DstTableau(dst) => { format!( "\u{2192} {}", format_pile(&KlondikePile::Tableau(dst.tableau)) ) } } } /// Pure helper — formats the move-log panel's header text. Reads /// `▌ MOVE LOG · N/M` while playing, where `N` is the count of /// moves applied so far and `M` is the total in the replay. The /// cursor-block prefix (`▌`) matches the splash and replay-banner /// motifs. Empty in `Inactive` (no replay attached); reads /// `▌ MOVE LOG · COMPLETE` in `Completed`. pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String { match state { ReplayPlaybackState::Playing { replay, cursor, .. } => { format!( "\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, replay.moves.len() ) } ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(), ReplayPlaybackState::Inactive => String::new(), } } /// Pure helper — formats the kth-most-recently-applied move's row /// text. `k = 1` is the active row (`replay.moves[cursor - 1]`, /// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above /// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`), /// and so on. /// /// Returns the empty string in any of these cases: /// - State isn't `Playing` (no replay attached). /// - `k == 0` (no kth-most-recent for k=0; the active is k=1). /// - `k > cursor` (not enough history — e.g. cursor=2 has rows /// for k=1 and k=2 only, k=3 returns empty). /// - The move list is shorter than expected (defensive guard). pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { return String::new(); }; if k == 0 || k > *cursor { return String::new(); } let zero_idx = *cursor - k; let Some(m) = replay.moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor - k + 1; format!("{} \u{2502} {}", display_idx, format_move_body(m)) } /// Pure helper — formats the kth-NEXT move's row text. `k = 1` /// is the move that will apply next (`replay.moves[cursor]`, /// displayed as `cursor + 1`); `k = 2` is the move after that, /// and so on. /// /// Returns the empty string in any of these cases: /// - State isn't `Playing` (no replay attached). /// - `k == 0` (degenerate; the active is k=1 of *recent*, not /// *next*). /// - `cursor + k - 1 >= moves.len()` (not enough remaining /// replay — late in the move list, the trailing next rows /// stay empty). pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String { let ReplayPlaybackState::Playing { replay, cursor, .. } = state else { return String::new(); }; if k == 0 { return String::new(); } let zero_idx = *cursor + k - 1; let Some(m) = replay.moves.get(zero_idx) else { return String::new(); }; let display_idx = *cursor + k; format!("{} \u{2502} {}", display_idx, format_move_body(m)) } /// Pure helper — formats the active-row text for the move-log /// panel. Wraps [`format_kth_recent_row`] with `k=1` and prepends /// a `▶` focus marker so the active row reads visually distinct /// from prev rows even before the highlight background lands. /// Returns empty when there's no row to render (cursor=0 or /// non-`Playing` state) — never `"▶ "` alone, which would paint /// a stray prefix. pub(crate) fn format_active_move_row(state: &ReplayPlaybackState) -> String { let body = format_kth_recent_row(state, 1); if body.is_empty() { return String::new(); } format!("\u{25B6} {body}") // ▶ } // --------------------------------------------------------------------------- // Mini-tableau format helpers and update system // --------------------------------------------------------------------------- /// Pure helper — short rank symbol. Single character for all ranks /// except Ten which uses "T" (keeps every card a consistent 2-char /// wide render: rank-char + suit-glyph). Players familiar with /// solitaire shorthand read "T" instantly; the suit glyph immediately /// follows and disambiguates from an ambiguous "T". pub(crate) fn format_rank_short(rank: Rank) -> &'static str { match rank { Rank::Ace => "A", Rank::Two => "2", Rank::Three => "3", Rank::Four => "4", Rank::Five => "5", Rank::Six => "6", Rank::Seven => "7", Rank::Eight => "8", Rank::Nine => "9", Rank::Ten => "T", Rank::Jack => "J", Rank::Queen => "Q", Rank::King => "K", } } /// Pure helper — Unicode suit glyph from FiraMono's covered range /// (U+2660–U+2666). These four code points are confirmed present in /// the bundled FiraMono on Android (verified on Pixel 7 / API 34). pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str { match suit { Suit::Spades => "\u{2660}", // ♠ Suit::Hearts => "\u{2665}", // ♥ Suit::Diamonds => "\u{2666}", // ♦ Suit::Clubs => "\u{2663}", // ♣ } } /// Pure helper — compact 2-char card label (`rank + suit glyph`) for a /// known card, or `"--"` for an absent top card (empty pile). pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String { match card { Some((c, _)) => format!( "{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit()) ), None => "--".to_string(), } } /// Pure helper — one-line summary of the four foundation tops. /// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot. /// Foundation slots are displayed in their natural 0-3 order /// (matching the visual left-to-right order on screen). pub(crate) fn format_foundations_row(game: &GameState) -> String { let slots = [ Foundation::Foundation1, Foundation::Foundation2, Foundation::Foundation3, Foundation::Foundation4, ] .map(|foundation| { let cards = game.pile(KlondikePile::Foundation(foundation)); format_card_short(cards.last()) }); format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3]) } /// Pure helper — one-line stock / waste summary. /// Renders as `STK:N WST:X♠` where N is the stock card count and /// X♠ is the top waste card (or `--` when the waste pile is empty). pub(crate) fn format_stock_waste_row(game: &GameState) -> String { let stock_cards = game.stock_cards(); let waste_cards = game.waste_cards(); let stock_count = stock_cards.len(); let waste_top = waste_cards.last(); format!("STK:{} WST:{}", stock_count, format_card_short(waste_top)) }