diff --git a/Cargo.lock b/Cargo.lock index f673557..12e3b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -84,6 +90,21 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.4" @@ -114,6 +135,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -149,6 +184,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -159,6 +219,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -198,6 +292,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -285,6 +385,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -448,12 +554,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -690,6 +813,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -718,7 +847,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", ] [[package]] @@ -802,6 +972,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.3" @@ -821,6 +1000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -933,6 +1113,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1014,6 +1200,27 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1127,7 +1334,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1310,6 +1517,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1348,6 +1576,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1426,7 +1688,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1698,6 +1960,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1814,6 +2105,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1893,6 +2206,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index e90eab9..459f9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ unic-langid = { version = "0.9", features = ["macros"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = ["full"] } +ratatui = "0.29" +crossterm = "0.28" [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { version = "1", features = ["sync", "macros", "rt"] } diff --git a/src/lib.rs b/src/lib.rs index 4e98b0c..0aba4c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,9 @@ pub mod time; #[cfg(not(feature = "wasm"))] pub mod api_client; +#[cfg(not(feature = "wasm"))] +pub mod tui; + pub mod config; pub mod difficulty; pub mod errors; diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..ae6b9bf --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,570 @@ +//! Terminal UI layer built on `ratatui` and `crossterm`. +//! +//! Provides a rich terminal interface with bordered panels, colour-coded +//! difficulty indicators, progress bars, and keyboard shortcut display. +//! The existing CLI module is preserved as a `--no-tui` fallback. + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleState; +use ratatui::style::Color; + +/// Game status for colour coding. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GameStatus { + InProgress, + Solved, + Failed, +} + +impl GameStatus { + /// Returns the colour associated with this status. + pub fn color(&self) -> Color { + match self { + GameStatus::InProgress => Color::Yellow, + GameStatus::Solved => Color::Green, + GameStatus::Failed => Color::Red, + } + } + + /// Derive game status from a puzzle state. + pub fn from_puzzle_state(state: &PuzzleState) -> Self { + match state { + PuzzleState::Unsolved | PuzzleState::InProgress => GameStatus::InProgress, + PuzzleState::Solved => GameStatus::Solved, + } + } +} + +/// Data extracted from a puzzle for the TUI view. +#[derive(Debug, Clone)] +pub struct PuzzleViewData { + pub title: String, + pub description: String, + pub difficulty: String, + pub remaining_hints: u32, + pub total_hints: u32, + pub status: GameStatus, +} + +impl PuzzleViewData { + /// Creates view data from puzzle components. + pub fn new( + title: impl Into, + description: impl Into, + difficulty: &Difficulty, + remaining_hints: u32, + total_hints: u32, + status: GameStatus, + ) -> Self { + Self { + title: title.into(), + description: description.into(), + difficulty: difficulty_label(difficulty), + remaining_hints, + total_hints, + status, + } + } +} + +/// State for the countdown timer progress bar. +#[derive(Debug, Clone)] +pub struct TimerState { + pub total_seconds: u64, + pub elapsed_seconds: u64, +} + +impl TimerState { + pub fn new(total_seconds: u64) -> Self { + Self { + total_seconds, + elapsed_seconds: 0, + } + } + + /// Returns remaining seconds. + pub fn remaining(&self) -> u64 { + self.total_seconds.saturating_sub(self.elapsed_seconds) + } + + /// Returns progress as a ratio (0.0 to 1.0) of time *used*. + pub fn progress_ratio(&self) -> f64 { + if self.total_seconds == 0 { + return 1.0; + } + (self.elapsed_seconds as f64 / self.total_seconds as f64).min(1.0) + } + + /// Returns true if the timer has expired. + pub fn is_expired(&self) -> bool { + self.elapsed_seconds >= self.total_seconds + } + + /// Advance the timer by one second. + pub fn tick(&mut self) { + if self.elapsed_seconds < self.total_seconds { + self.elapsed_seconds += 1; + } + } +} + +/// Score display state. +#[derive(Debug, Clone)] +pub struct ScoreState { + pub current_score: u64, + pub status: GameStatus, +} + +impl ScoreState { + pub fn new(current_score: u64, status: GameStatus) -> Self { + Self { + current_score, + status, + } + } + + /// Returns the colour for the score based on game status. + pub fn color(&self) -> Color { + self.status.color() + } +} + +/// Keyboard shortcut entry for the footer bar. +#[derive(Debug, Clone)] +pub struct Shortcut { + pub key: String, + pub action: String, +} + +/// Returns the default keyboard shortcuts displayed in the footer. +pub fn default_shortcuts() -> Vec { + vec![ + Shortcut { + key: "h".to_string(), + action: "Hint".to_string(), + }, + Shortcut { + key: "Enter".to_string(), + action: "Submit".to_string(), + }, + Shortcut { + key: "q".to_string(), + action: "Quit".to_string(), + }, + Shortcut { + key: "?".to_string(), + action: "Help".to_string(), + }, + ] +} + +/// Formats keyboard shortcuts as a single display string. +pub fn format_shortcuts(shortcuts: &[Shortcut]) -> String { + shortcuts + .iter() + .map(|s| format!("[{}] {}", s.key, s.action)) + .collect::>() + .join(" | ") +} + +/// Returns a human-readable difficulty label. +pub fn difficulty_label(difficulty: &Difficulty) -> String { + match difficulty { + Difficulty::Easy => "Easy".to_string(), + Difficulty::Medium => "Medium".to_string(), + Difficulty::Hard => "Hard".to_string(), + } +} + +/// Returns the colour for a difficulty badge. +pub fn difficulty_color(difficulty: &Difficulty) -> Color { + match difficulty { + Difficulty::Easy => Color::Green, + Difficulty::Medium => Color::Yellow, + Difficulty::Hard => Color::Red, + } +} + +/// Full application state for the TUI. +#[derive(Debug, Clone)] +pub struct TuiApp { + pub puzzle: Option, + pub timer: TimerState, + pub score: ScoreState, + pub status: GameStatus, +} + +impl TuiApp { + /// Creates a new TUI application state. + pub fn new(total_time_seconds: u64) -> Self { + Self { + puzzle: None, + timer: TimerState::new(total_time_seconds), + score: ScoreState::new(0, GameStatus::InProgress), + status: GameStatus::InProgress, + } + } + + /// Sets the active puzzle for display. + pub fn set_puzzle(&mut self, data: PuzzleViewData) { + self.status = data.status; + self.score.status = data.status; + self.puzzle = Some(data); + } + + /// Updates the score. + pub fn update_score(&mut self, score: u64) { + self.score.current_score = score; + } + + /// Marks the game as solved. + pub fn mark_solved(&mut self) { + self.status = GameStatus::Solved; + self.score.status = GameStatus::Solved; + if let Some(ref mut p) = self.puzzle { + p.status = GameStatus::Solved; + } + } + + /// Marks the game as failed. + pub fn mark_failed(&mut self) { + self.status = GameStatus::Failed; + self.score.status = GameStatus::Failed; + if let Some(ref mut p) = self.puzzle { + p.status = GameStatus::Failed; + } + } + + /// Advances the timer by one second. + pub fn tick(&mut self) { + self.timer.tick(); + if self.timer.is_expired() && self.status == GameStatus::InProgress { + self.mark_failed(); + } + } +} + +/// Entry point for the TUI rendering mode. +/// This initializes the terminal, runs the main render loop, and restores +/// the terminal on exit. +pub fn run_tui() -> Result<(), Box> { + use crossterm::{ + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + }; + use ratatui::Terminal; + use ratatui::backend::CrosstermBackend; + + // Set up terminal + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Draw a single demo frame to verify the TUI works + terminal.draw(|frame| { + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::{Modifier, Style}; + use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), // Puzzle info + Constraint::Length(3), // Timer + Constraint::Length(3), // Score + Constraint::Min(1), // Content area + Constraint::Length(1), // Footer + ]) + .split(frame.area()); + + // Puzzle panel + let puzzle_block = Block::default() + .title(" Puzzle ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let puzzle_text = + Paragraph::new("Welcome to Quest! Use --no-tui for headless mode.").block(puzzle_block); + frame.render_widget(puzzle_text, chunks[0]); + + // Timer bar + let timer_block = Block::default() + .title(" Timer ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + let timer_gauge = Gauge::default() + .block(timer_block) + .gauge_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .ratio(0.0) + .label("Ready"); + frame.render_widget(timer_gauge, chunks[1]); + + // Score display + let score_block = Block::default() + .title(" Score ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + let score_text = Paragraph::new("Score: 0").block(score_block); + frame.render_widget(score_text, chunks[2]); + + // Footer with shortcuts + let shortcuts = format_shortcuts(&default_shortcuts()); + let footer = Paragraph::new(shortcuts).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(footer, chunks[4]); + })?; + + // Wait briefly then restore terminal + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_game_status_colors() { + assert_eq!(GameStatus::InProgress.color(), Color::Yellow); + assert_eq!(GameStatus::Solved.color(), Color::Green); + assert_eq!(GameStatus::Failed.color(), Color::Red); + } + + #[test] + fn test_game_status_from_puzzle_state() { + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::Unsolved), + GameStatus::InProgress + ); + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::InProgress), + GameStatus::InProgress + ); + assert_eq!( + GameStatus::from_puzzle_state(&PuzzleState::Solved), + GameStatus::Solved + ); + } + + #[test] + fn test_timer_state_new() { + let timer = TimerState::new(60); + assert_eq!(timer.total_seconds, 60); + assert_eq!(timer.elapsed_seconds, 0); + assert_eq!(timer.remaining(), 60); + assert!(!timer.is_expired()); + } + + #[test] + fn test_timer_progress_ratio() { + let mut timer = TimerState::new(100); + assert!((timer.progress_ratio() - 0.0).abs() < f64::EPSILON); + + timer.elapsed_seconds = 50; + assert!((timer.progress_ratio() - 0.5).abs() < f64::EPSILON); + + timer.elapsed_seconds = 100; + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + + // Over-elapsed should cap at 1.0 + timer.elapsed_seconds = 150; + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_timer_zero_total() { + let timer = TimerState::new(0); + assert!((timer.progress_ratio() - 1.0).abs() < f64::EPSILON); + assert!(timer.is_expired()); + assert_eq!(timer.remaining(), 0); + } + + #[test] + fn test_timer_tick() { + let mut timer = TimerState::new(3); + assert_eq!(timer.remaining(), 3); + + timer.tick(); + assert_eq!(timer.elapsed_seconds, 1); + assert_eq!(timer.remaining(), 2); + assert!(!timer.is_expired()); + + timer.tick(); + timer.tick(); + assert_eq!(timer.elapsed_seconds, 3); + assert_eq!(timer.remaining(), 0); + assert!(timer.is_expired()); + + // Tick past expiry should not go beyond total + timer.tick(); + assert_eq!(timer.elapsed_seconds, 3); + } + + #[test] + fn test_score_state() { + let score = ScoreState::new(100, GameStatus::InProgress); + assert_eq!(score.current_score, 100); + assert_eq!(score.color(), Color::Yellow); + + let score = ScoreState::new(200, GameStatus::Solved); + assert_eq!(score.color(), Color::Green); + + let score = ScoreState::new(50, GameStatus::Failed); + assert_eq!(score.color(), Color::Red); + } + + #[test] + fn test_difficulty_label() { + assert_eq!(difficulty_label(&Difficulty::Easy), "Easy"); + assert_eq!(difficulty_label(&Difficulty::Medium), "Medium"); + assert_eq!(difficulty_label(&Difficulty::Hard), "Hard"); + } + + #[test] + fn test_difficulty_color() { + assert_eq!(difficulty_color(&Difficulty::Easy), Color::Green); + assert_eq!(difficulty_color(&Difficulty::Medium), Color::Yellow); + assert_eq!(difficulty_color(&Difficulty::Hard), Color::Red); + } + + #[test] + fn test_default_shortcuts() { + let shortcuts = default_shortcuts(); + assert_eq!(shortcuts.len(), 4); + assert_eq!(shortcuts[0].key, "h"); + assert_eq!(shortcuts[0].action, "Hint"); + assert_eq!(shortcuts[2].key, "q"); + assert_eq!(shortcuts[2].action, "Quit"); + } + + #[test] + fn test_format_shortcuts() { + let shortcuts = vec![ + Shortcut { + key: "a".to_string(), + action: "Action1".to_string(), + }, + Shortcut { + key: "b".to_string(), + action: "Action2".to_string(), + }, + ]; + assert_eq!(format_shortcuts(&shortcuts), "[a] Action1 | [b] Action2"); + } + + #[test] + fn test_format_shortcuts_empty() { + let shortcuts: Vec = vec![]; + assert_eq!(format_shortcuts(&shortcuts), ""); + } + + #[test] + fn test_puzzle_view_data() { + let data = PuzzleViewData::new( + "Test Puzzle", + "Solve the riddle", + &Difficulty::Hard, + 2, + 3, + GameStatus::InProgress, + ); + assert_eq!(data.title, "Test Puzzle"); + assert_eq!(data.description, "Solve the riddle"); + assert_eq!(data.difficulty, "Hard"); + assert_eq!(data.remaining_hints, 2); + assert_eq!(data.total_hints, 3); + assert_eq!(data.status, GameStatus::InProgress); + } + + #[test] + fn test_tui_app_new() { + let app = TuiApp::new(120); + assert!(app.puzzle.is_none()); + assert_eq!(app.timer.total_seconds, 120); + assert_eq!(app.score.current_score, 0); + assert_eq!(app.status, GameStatus::InProgress); + } + + #[test] + fn test_tui_app_set_puzzle() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new( + "Puzzle 1", + "Description", + &Difficulty::Easy, + 3, + 3, + GameStatus::InProgress, + ); + app.set_puzzle(data); + + assert!(app.puzzle.is_some()); + assert_eq!(app.puzzle.as_ref().unwrap().title, "Puzzle 1"); + } + + #[test] + fn test_tui_app_update_score() { + let mut app = TuiApp::new(60); + app.update_score(500); + assert_eq!(app.score.current_score, 500); + } + + #[test] + fn test_tui_app_mark_solved() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new("P", "D", &Difficulty::Easy, 1, 3, GameStatus::InProgress); + app.set_puzzle(data); + app.mark_solved(); + + assert_eq!(app.status, GameStatus::Solved); + assert_eq!(app.score.status, GameStatus::Solved); + assert_eq!(app.puzzle.as_ref().unwrap().status, GameStatus::Solved); + } + + #[test] + fn test_tui_app_mark_failed() { + let mut app = TuiApp::new(60); + let data = PuzzleViewData::new("P", "D", &Difficulty::Medium, 0, 3, GameStatus::InProgress); + app.set_puzzle(data); + app.mark_failed(); + + assert_eq!(app.status, GameStatus::Failed); + assert_eq!(app.score.status, GameStatus::Failed); + assert_eq!(app.puzzle.as_ref().unwrap().status, GameStatus::Failed); + } + + #[test] + fn test_tui_app_tick_causes_failure_on_expiry() { + let mut app = TuiApp::new(2); + assert_eq!(app.status, GameStatus::InProgress); + + app.tick(); + assert_eq!(app.status, GameStatus::InProgress); + assert_eq!(app.timer.remaining(), 1); + + app.tick(); + assert_eq!(app.status, GameStatus::Failed); + assert_eq!(app.timer.remaining(), 0); + } + + #[test] + fn test_tui_app_tick_does_not_overwrite_solved() { + let mut app = TuiApp::new(2); + app.mark_solved(); + + app.tick(); + app.tick(); + // Should remain solved even though timer expired + assert_eq!(app.status, GameStatus::Solved); + } +}