From de05e0c91c84d47ad572eba4da77a3904d7d1af1 Mon Sep 17 00:00:00 2001 From: William Raendchen <105014007+epic-64@users.noreply.github.com> Date: Sat, 22 Nov 2025 15:24:40 +0100 Subject: [PATCH] cleanup: remove obsolete code, configure rustfmt and clippy (#7) * chore: remove unnecessary needs_render checks from game logic * chore: remove needs_render flag and related logic from game state * chore: add rustfmt configuration file for consistent formatting * chore: clean up imports and formatting in app.rs, binary_numbers.rs, main_screen_widget.rs, rustfmt.toml, and utils.rs * chore: remove outdated comment from binary_numbers.rs * chore: remove outdated comments from binary_numbers.rs * chore: extract game over rendering logic into a separate function * run clippy fix * chore: add Clippy configuration files for linting thresholds * run clippy fix * chore: allow clippy warnings in selected places. check fix later * docs: add command docs for linting and formatting * docs: add command docs for linting and formatting * split BinaryNumbersPuzzle.render_ref into several sub functions for rendering different areas. * chore: adjust clippy lint levels to allow certain patterns * chore: add CI configuration with testing, clippy, and formatting jobs * cargo fmt * chore: simplify clippy and formatting commands in CI configuration * chore: consolidate cargo caching in CI configuration * chore: replace cargo caching with rust-cache action in CI configuration * chore: use is_multiple_of for streak check in score calculation * chore: simplify game over check in render logic --- .github/workflows/ci.yml | 70 ++++++ Cargo.toml | 29 +++ README.md | 22 +- clippy.toml | 31 +++ rustfmt.toml | 65 +++++ src/app.rs | 72 ++---- src/binary_numbers.rs | 506 +++++++++++++++++++++++++++----------- src/keybinds.rs | 12 +- src/main_screen_widget.rs | 2 +- src/utils.rs | 38 ++- 10 files changed, 618 insertions(+), 229 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 clippy.toml create mode 100644 rustfmt.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..418b599 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + rust: [stable] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.rust }} + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: cargo test --verbose + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Run clippy + run: cargo clippy + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --check + diff --git a/Cargo.toml b/Cargo.toml index 40515fd..53b459c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,32 @@ ratatui = "0.29.0" indoc = "2.0.7" color-eyre = "0.6.3" rand = "0.9.1" + +[lints.rust] +unsafe_code = "forbid" +unused_must_use = "warn" +unused_imports = "warn" +dead_code = "warn" + +[lints.clippy] +# Lint groups - enable comprehensive checking +pedantic = { level = "allow", priority = -1 } +nursery = { level = "allow", priority = -1 } +correctness = { level = "deny", priority = -1 } +all = { level = "warn", priority = -1 } + +# Allow certain common patterns +match_same_arms = "allow" + +# Complexity warnings (thresholds in clippy.toml) +cognitive_complexity = "warn" +too_many_arguments = "warn" +too_many_lines = "warn" +type_complexity = "warn" +struct_excessive_bools = "warn" +fn_params_excessive_bools = "warn" + +# Style preferences - encourage safer code +enum_glob_use = "warn" +unwrap_used = "warn" +expect_used = "warn" diff --git a/README.md b/README.md index 2c08c2a..f4d5e61 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![CI](https://github.com/epic-64/binbreak/workflows/CI/badge.svg)](https://github.com/epic-64/binbreak/actions) +[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/) + https://github.com/user-attachments/assets/4413fe8d-9a3f-4c00-9c1a-b9ca01a946fc Guess the correct number (from binary to decimal) before time runs out! @@ -55,12 +58,25 @@ You may be inclined to not run binaries from the internet, and want to build fro cargo run --release ``` +# Contributing + +All pull requests are automatically checked by GitHub Actions CI, which runs tests, +clippy, and formatting checks on Linux, Windows, and macOS. + ## Test ```bash cargo test ``` -## License -MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) +## Lint +```bash +cargo clippy +``` -[![Built With Ratatui](https://ratatui.rs/built-with-ratatui/badge.svg)](https://ratatui.rs/) +## Format +```bash +cargo fmt +``` + +## License +MIT license ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT) diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..c639e29 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,31 @@ +# ============================================================================ +# Clippy Threshold Configuration +# ============================================================================ +# This file configures clippy threshold values that cannot be set in Cargo.toml +# Lint levels (warn/deny/allow) are configured in Cargo.toml [lints.clippy] +# https://doc.rust-lang.org/clippy/lint_configuration.html +# ============================================================================ + +# Complexity thresholds +cognitive-complexity-threshold = 30 # default: 25 +too-many-arguments-threshold = 8 # default: 7 +too-many-lines-threshold = 80 # default: 100 +type-complexity-threshold = 250 # default: 500 + +# Boolean usage limits +max-struct-bools = 4 # default: 3 +max-fn-params-bools = 3 # default: 3 + +# Variable naming +single-char-binding-names-threshold = 4 # default: 4 +allowed-idents-below-min-chars = [ + "x", "y", "dx", "dy", "dt", # coordinates and delta time + "id", "ui", "io", # common abbreviations + "hs", # high scores (project-specific) +] + +# Other thresholds +vec-box-size-threshold = 4096 # default: 4096 +max-trait-bounds = 3 # default: 3 + + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..dd5be92 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,65 @@ +# Rustfmt configuration for binbreak project + +# Edition +edition = "2024" + +# Maximum width of each line +max_width = 100 + +# Maximum width of the args of a function call before falling back to vertical formatting +fn_call_width = 80 + +# Maximum width of the args of a function-like attributes before falling back to vertical formatting +attr_fn_like_width = 80 + +# Maximum width in the body of a struct lit before falling back to vertical formatting +struct_lit_width = 90 + +# Maximum width in the body of a struct variant before falling back to vertical formatting +struct_variant_width = 90 + +# Maximum width of an array literal before falling back to vertical formatting +array_width = 90 + +# Maximum width of a chain to fit on a single line +chain_width = 100 + +# Maximum line length for single line if-else expressions +single_line_if_else_max_width = 60 + +# How to indent in files +hard_tabs = false + +# Number of spaces per tab +tab_spaces = 4 + +# Remove nested parens +remove_nested_parens = true + +# Reorder imports +reorder_imports = true + +# Reorder modules +reorder_modules = true + +# Use field init shorthand if possible +use_field_init_shorthand = true + +# Use try shorthand +use_try_shorthand = true + +# Force explicit types in let statements +force_explicit_abi = true + +# Newline style +newline_style = "Unix" + +# Merge derives +merge_derives = true + +# Use small heuristics (Off, Max, or Default) +# Max preserves more single-line expressions +use_small_heuristics = "Max" + +# Match block trailing comma +match_block_trailing_comma = true diff --git a/src/app.rs b/src/app.rs index e5cab3b..8229747 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,16 +4,16 @@ use crate::main_screen_widget::MainScreenWidget; use crate::utils::{AsciiArtWidget, AsciiCells}; use crossterm::event; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use indoc::indoc; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::{Color, Modifier, Span, Style, Widget}; use ratatui::widgets::{List, ListItem, ListState}; use std::cmp; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Instant; -use indoc::indoc; -use std::sync::atomic::{AtomicUsize, Ordering}; static LAST_SELECTED_INDEX: AtomicUsize = AtomicUsize::new(4); @@ -46,9 +46,9 @@ fn handle_start_input(state: &mut StartMenuState, key: KeyEvent) -> Option return Some(AppState::Exit), - _ => {} + _ => {}, } None } @@ -62,13 +62,11 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) let selected = state.selected_index(); let upper_labels: Vec = state.items.iter().map(|(l, _)| l.to_uppercase()).collect(); - let max_len = upper_labels - .iter() - .map(|s| s.len() as u16) - .max() - .unwrap_or(0); + #[allow(clippy::cast_possible_truncation)] + let max_len = upper_labels.iter().map(|s| s.len() as u16).max().unwrap_or(0); let list_width = 2 + max_len; // marker + space + label + #[allow(clippy::cast_possible_truncation)] let list_height = upper_labels.len() as u16; // Vertical spacing between ASCII art and list @@ -83,12 +81,8 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) let list_y = ascii_y + ascii_height + spacing; // Define rects (clamp to area) - let ascii_area = Rect::new( - ascii_x, - ascii_y, - ascii_width.min(area.width), - ascii_height.min(area.height), - ); + let ascii_area = + Rect::new(ascii_x, ascii_y, ascii_width.min(area.width), ascii_height.min(area.height)); let list_area = Rect::new( list_x, list_y, @@ -115,10 +109,9 @@ fn render_start_screen(state: &mut StartMenuState, area: Rect, buf: &mut Buffer) .map(|(i, label)| { let marker = if i == selected { '»' } else { ' ' }; let padded = format!("{: color_eyre::Result<()> { { match key.code { // global exit via Ctrl+C - KeyCode::Char('c' | 'C') - if key.modifiers == KeyModifiers::CONTROL => - { + KeyCode::Char('c' | 'C') if key.modifiers == KeyModifiers::CONTROL => { *app_state = AppState::Exit; - } + }, // state-specific input handling _ => { *app_state = match std::mem::replace(app_state, AppState::Exit) { AppState::Start(mut menu) => { - handle_start_input(&mut menu, key) - .unwrap_or(AppState::Start(menu)) - } + handle_start_input(&mut menu, key).unwrap_or(AppState::Start(menu)) + }, AppState::Playing(mut game) => { game.handle_input(key); AppState::Playing(game) - } + }, AppState::Exit => AppState::Exit, } - } + }, } } Ok(()) @@ -161,11 +151,9 @@ fn handle_crossterm_events(app_state: &mut AppState) -> color_eyre::Result<()> { /// Determine the appropriate FPS mode based on the current game state fn get_fps_mode(game: &BinaryNumbersGame) -> FpsMode { if game.is_active() { - FpsMode::RealTime // Timer running, needs continuous updates - } else if game.needs_render() { - FpsMode::RealTime // One frame after state transition + FpsMode::RealTime // Timer running, needs continuous updates } else { - FpsMode::Performance // All other cases, block for minimal CPU + FpsMode::Performance // All other cases, block for minimal CPU } } @@ -191,15 +179,9 @@ pub fn run_app(terminal: &mut ratatui::DefaultTerminal) -> color_eyre::Result<() terminal.draw(|f| match &mut app_state { AppState::Start(menu) => render_start_screen(menu, f.area(), f.buffer_mut()), AppState::Playing(game) => f.render_widget(&mut *game, f.area()), - AppState::Exit => {} + AppState::Exit => {}, })?; - // Clear needs_render flag after frame is rendered - // State transitions will set this flag again as needed, in performance mode - if let AppState::Playing(game) = &mut app_state { - game.clear_needs_render(); - } - // handle input if let AppState::Playing(game) = &app_state { if get_fps_mode(game) == FpsMode::RealTime { @@ -265,12 +247,7 @@ fn ascii_art_cells() -> AsciiCells { ]); let default_color = Color::LightBlue; - AsciiCells::from( - art.to_string(), - colors.to_string(), - &color_map, - default_color, - ) + AsciiCells::from(art, colors, &color_map, default_color) } // Start menu state @@ -295,10 +272,7 @@ impl StartMenuState { ("insane (16 bits)".to_string(), Bits::Sixteen), ]; - Self { - items, - list_state: ListState::default().with_selected(Some(selected_index)), - } + Self { items, list_state: ListState::default().with_selected(Some(selected_index)) } } fn selected_index(&self) -> usize { diff --git a/src/binary_numbers.rs b/src/binary_numbers.rs index 724679b..7c3791b 100644 --- a/src/binary_numbers.rs +++ b/src/binary_numbers.rs @@ -1,9 +1,9 @@ use crate::keybinds; use crate::main_screen_widget::{MainScreenWidget, WidgetRef}; -use crate::utils::{center, When}; +use crate::utils::{When, center}; use crossterm::event::{KeyCode, KeyEvent}; -use rand::prelude::SliceRandom; use rand::Rng; +use rand::prelude::SliceRandom; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; use ratatui::prelude::Alignment::Center; @@ -13,7 +13,8 @@ use ratatui::text::Span; use ratatui::widgets::BorderType::Double; use ratatui::widgets::{Block, BorderType, Paragraph}; use std::collections::HashMap; -use std::fs::{File}; +use std::fmt::Write as _; +use std::fs::File; use std::io::{Read, Write}; struct StatsSnapshot { @@ -42,9 +43,8 @@ impl WidgetRef for BinaryNumbersGame { impl WidgetRef for BinaryNumbersPuzzle { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [middle] = Layout::horizontal([Constraint::Percentage(100)]) - .flex(Flex::Center) - .areas(area); + let [middle] = + Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area); let [stats_area, current_number_area, suggestions_area, progress_bar_area, result_area] = Layout::vertical([ @@ -58,10 +58,32 @@ impl WidgetRef for BinaryNumbersPuzzle { .horizontal_margin(0) .areas(middle); - Block::bordered() - .title_alignment(Center) - .dark_gray() - .render(stats_area, buf); + self.render_stats_area(stats_area, buf); + + if let Some(stats) = &self.stats_snapshot + && stats.game_state == GameState::GameOver + { + render_game_over( + stats, + current_number_area, + suggestions_area, + progress_bar_area, + result_area, + buf, + ); + return; + } + + self.render_current_number(current_number_area, buf); + self.render_suggestions(suggestions_area, buf); + self.render_status_and_timer(progress_bar_area, buf); + self.render_instructions(result_area, buf); + } +} + +impl BinaryNumbersPuzzle { + fn render_stats_area(&self, area: Rect, buf: &mut Buffer) { + Block::bordered().title_alignment(Center).dark_gray().render(area, buf); if let Some(stats) = &self.stats_snapshot { let high_label = if stats.new_high_score { @@ -73,56 +95,44 @@ impl WidgetRef for BinaryNumbersPuzzle { }; let line1 = Line::from(vec![ - Span::styled(format!("Mode: {} ", stats.bits.label()), Style::default().fg(Color::Yellow)), + Span::styled( + format!("Mode: {} ", stats.bits.label()), + Style::default().fg(Color::Yellow), + ), high_label, ]); let line2 = Line::from(vec![ - Span::styled(format!("Score: {} ", stats.score), Style::default().fg(Color::Green)), - Span::styled(format!("Streak: {} ", stats.streak), Style::default().fg(Color::Cyan)), - Span::styled(format!("Max: {} ", stats.max_streak), Style::default().fg(Color::Blue)), - Span::styled(format!("Rounds: {} ", stats.rounds), Style::default().fg(Color::Magenta)), + Span::styled( + format!("Score: {} ", stats.score), + Style::default().fg(Color::Green), + ), + Span::styled( + format!("Streak: {} ", stats.streak), + Style::default().fg(Color::Cyan), + ), + Span::styled( + format!("Max: {} ", stats.max_streak), + Style::default().fg(Color::Blue), + ), + Span::styled( + format!("Rounds: {} ", stats.rounds), + Style::default().fg(Color::Magenta), + ), Span::styled(format!("Lives: {} ", stats.hearts), Style::default().fg(Color::Red)), ]); + #[allow(clippy::cast_possible_truncation)] let widest = line1.width().max(line2.width()) as u16; Paragraph::new(vec![line1, line2]) .alignment(Center) - .render(center(stats_area, Constraint::Length(widest)), buf); - - // If game over, render game over block occupying the remaining area and return early - if stats.game_state == GameState::GameOver { - let combined_rect = Rect { x: current_number_area.x, y: current_number_area.y, width: current_number_area.width, height: current_number_area.height + suggestions_area.height + progress_bar_area.height + result_area.height }; - let block = Block::bordered() - .title("Game Over") - .title_alignment(Center) - .border_type(Double) - .title_style(Style::default().fg(Color::Red)); - block.render(combined_rect, buf); - let mut lines = vec![ - Line::from(Span::styled(format!("Final Score: {}", stats.score), Style::default().fg(Color::Green))), - Line::from(Span::styled(format!("Previous High: {}", stats.prev_high_score), Style::default().fg(Color::Yellow))), - Line::from(Span::styled(format!("Rounds Played: {}", stats.rounds), Style::default().fg(Color::Magenta))), - Line::from(Span::styled(format!("Max Streak: {}", stats.max_streak), Style::default().fg(Color::Cyan))), - ]; - if stats.new_high_score { - lines.insert(1, Line::from(Span::styled("NEW HIGH SCORE!", Style::default().fg(Color::LightGreen).bold()))); - } - if stats.lives == 0 { - lines.push(Line::from(Span::styled("You lost all your lives.", Style::default().fg(Color::Red)))); - } - lines.push(Line::from(Span::styled("Press Enter to restart or Esc to exit", Style::default().fg(Color::Yellow)))); - Paragraph::new(lines) - .alignment(Center) - .render(center(combined_rect, Constraint::Length(48)), buf); - return; - } + .render(center(area, Constraint::Length(widest)), buf); } + } - // Existing puzzle rendering now uses updated area references - let [inner] = Layout::horizontal([Constraint::Percentage(100)]) - .flex(Flex::Center) - .areas(current_number_area); + fn render_current_number(&self, area: Rect, buf: &mut Buffer) { + let [inner] = + Layout::horizontal([Constraint::Percentage(100)]).flex(Flex::Center).areas(area); Block::bordered() .border_type(Double) @@ -134,26 +144,38 @@ impl WidgetRef for BinaryNumbersPuzzle { Bits::FourShift4 => Some(" x16"), Bits::FourShift8 => Some(" x256"), Bits::FourShift12 => Some(" x4096"), - _ => None + _ => None, }; - let mut spans = vec![Span::raw(binary_string.clone())]; - if let Some(sfx) = scale_suffix { spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); } - let total_width = spans.iter().map(|s| s.width()).sum::() as u16; + let mut spans = vec![Span::raw(binary_string)]; + if let Some(sfx) = scale_suffix { + spans.push(Span::styled(sfx, Style::default().fg(Color::DarkGray))); + } + #[allow(clippy::cast_possible_truncation)] + let total_width = spans.iter().map(ratatui::prelude::Span::width).sum::() as u16; let lines: Vec = vec![Line::from(spans)]; - Paragraph::new(lines).alignment(Center).render(center(inner, Constraint::Length(total_width)), buf); + Paragraph::new(lines) + .alignment(Center) + .render(center(inner, Constraint::Length(total_width)), buf); + } + fn render_suggestions(&self, area: Rect, buf: &mut Buffer) { let suggestions = self.suggestions(); let suggestions_layout = Layout::default() .direction(Direction::Horizontal) .constraints(vec![Constraint::Min(6); suggestions.len()]) - .split(suggestions_area); + .split(area); + for (i, suggestion) in suggestions.iter().enumerate() { let item_is_selected = self.selected_suggestion == Some(*suggestion); let show_correct_number = self.guess_result.is_some(); let is_correct_number = self.is_correct_guess(*suggestion); let area = suggestions_layout[i]; - let border_type = if item_is_selected { BorderType::Double } else { BorderType::Plain }; + let border_type = if item_is_selected { + BorderType::Double + } else { + BorderType::Plain + }; let border_color = if item_is_selected { match self.guess_result { @@ -169,19 +191,33 @@ impl WidgetRef for BinaryNumbersPuzzle { Block::bordered().border_type(border_type).fg(border_color).render(area, buf); let suggestion_str = format!("{suggestion}"); + + #[allow(clippy::cast_possible_truncation)] Paragraph::new(suggestion_str.to_string()) .white() .when(show_correct_number && is_correct_number, |p| p.light_green().underlined()) .alignment(Center) .render(center(area, Constraint::Length(suggestion_str.len() as u16)), buf); } + } + fn render_status_and_timer(&self, area: Rect, buf: &mut Buffer) { let [left, right] = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .areas(progress_bar_area); + .areas(area); - Block::bordered().dark_gray().title("Status").title_alignment(Center).title_style(Style::default().white()).render(left, buf); + self.render_status(left, buf); + self.render_timer(right, buf); + } + + fn render_status(&self, area: Rect, buf: &mut Buffer) { + Block::bordered() + .dark_gray() + .title("Status") + .title_alignment(Center) + .title_style(Style::default().white()) + .render(area, buf); if let Some(result) = &self.guess_result { let (icon, line1_text, color) = match result { @@ -197,16 +233,19 @@ impl WidgetRef for BinaryNumbersPuzzle { }; let text = vec![ - Line::from(format!("{} {}", icon, line1_text).fg(color)), + Line::from(format!("{icon} {line1_text}").fg(color)), Line::from(gained_line.fg(color)), ]; - let widest = text.iter().map(|l| l.width()).max().unwrap_or(0) as u16; + #[allow(clippy::cast_possible_truncation)] + let widest = text.iter().map(Line::width).max().unwrap_or(0) as u16; Paragraph::new(text) .alignment(Center) .style(Style::default().fg(color)) - .render(center(left, Constraint::Length(widest)), buf); + .render(center(area, Constraint::Length(widest)), buf); } + } + fn render_timer(&self, area: Rect, buf: &mut Buffer) { let ratio = self.time_left / self.time_total; let gauge_color = if ratio > 0.6 { Color::Green @@ -216,20 +255,16 @@ impl WidgetRef for BinaryNumbersPuzzle { Color::Red }; - // Replace previous split layout: keep everything inside a single bordered block and remove percent label let time_block = Block::bordered() .dark_gray() .title("Time Remaining") .title_style(Style::default().white()) .title_alignment(Center); - let inner_time = time_block.inner(right); - time_block.render(right, buf); + let inner_time = time_block.inner(area); + time_block.render(area, buf); - // Vertical layout inside the time block interior: gauge line + text line (2 lines total) - let [gauge_line, time_line] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1) - ]).areas(inner_time); + let [gauge_line, time_line] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner_time); render_ascii_gauge(gauge_line, buf, ratio, gauge_color); @@ -239,19 +274,25 @@ impl WidgetRef for BinaryNumbersPuzzle { ))) .alignment(Center) .render(time_line, buf); + } - Block::bordered().dark_gray().render(result_area, buf); + fn render_instructions(&self, area: Rect, buf: &mut Buffer) { + Block::bordered().dark_gray().render(area, buf); let instruction_spans: Vec = [ hotkey_span("Left Right", "select "), hotkey_span("Enter", "confirm "), hotkey_span("S", "skip "), hotkey_span("Esc", "exit"), - ].iter().flatten().cloned().collect(); + ] + .iter() + .flatten() + .cloned() + .collect(); Paragraph::new(vec![Line::from(instruction_spans)]) .alignment(Center) - .render(center(result_area, Constraint::Length(65)), buf); + .render(center(area, Constraint::Length(65)), buf); } } @@ -259,10 +300,71 @@ fn hotkey_span<'a>(key: &'a str, description: &str) -> Vec> { vec![ Span::styled("<", Style::default().fg(Color::White)), Span::styled(key, Style::default().fg(Color::LightCyan)), - Span::styled(format!("> {}", description), Style::default().fg(Color::White)), + Span::styled(format!("> {description}"), Style::default().fg(Color::White)), ] } +fn render_game_over( + stats: &StatsSnapshot, + current_number_area: Rect, + suggestions_area: Rect, + progress_bar_area: Rect, + result_area: Rect, + buf: &mut Buffer, +) { + let combined_rect = Rect { + x: current_number_area.x, + y: current_number_area.y, + width: current_number_area.width, + height: current_number_area.height + + suggestions_area.height + + progress_bar_area.height + + result_area.height, + }; + Block::bordered().border_style(Style::default().fg(Color::DarkGray)).render(combined_rect, buf); + + let mut lines = vec![ + Line::from(Span::styled( + format!("Final Score: {}", stats.score), + Style::default().fg(Color::Green), + )), + Line::from(Span::styled( + format!("Previous High: {}", stats.prev_high_score), + Style::default().fg(Color::Yellow), + )), + Line::from(Span::styled( + format!("Rounds Played: {}", stats.rounds), + Style::default().fg(Color::Magenta), + )), + Line::from(Span::styled( + format!("Max Streak: {}", stats.max_streak), + Style::default().fg(Color::Cyan), + )), + ]; + if stats.new_high_score { + lines.insert( + 1, + Line::from(Span::styled( + "NEW HIGH SCORE!", + Style::default().fg(Color::LightGreen).bold(), + )), + ); + } + if stats.lives == 0 { + lines.push(Line::from(Span::styled( + "You lost all your lives.", + Style::default().fg(Color::Red), + ))); + } + lines.push(Line::from(Span::styled( + "Press Enter to restart or Esc to exit", + Style::default().fg(Color::Yellow), + ))); + Paragraph::new(lines) + .alignment(Center) + .render(center(combined_rect, Constraint::Length(48)), buf); +} + pub struct BinaryNumbersGame { puzzle: BinaryNumbersPuzzle, bits: Bits, @@ -278,34 +380,47 @@ pub struct BinaryNumbersGame { high_scores: HighScores, prev_high_score_for_display: u32, new_high_score_reached: bool, - needs_render: bool, // Flag to render one frame after state transition } - #[derive(Copy, Clone, PartialEq, Debug)] -enum GameState { Active, Result, PendingGameOver, GameOver } +enum GameState { + Active, + Result, + PendingGameOver, + GameOver, +} impl MainScreenWidget for BinaryNumbersGame { fn run(&mut self, dt: f64) { self.refresh_stats_snapshot(); - if self.game_state == GameState::GameOver { return; } + if self.game_state == GameState::GameOver { + return; + } self.puzzle.run(dt); - if self.puzzle.guess_result.is_some() && !self.puzzle_resolved { self.finalize_round(); } + if self.puzzle.guess_result.is_some() && !self.puzzle_resolved { + self.finalize_round(); + } self.refresh_stats_snapshot(); } - fn handle_input(&mut self, input: KeyEvent) { self.handle_game_input(input); } - fn is_exit_intended(&self) -> bool { self.exit_intended } + fn handle_input(&mut self, input: KeyEvent) { + self.handle_game_input(input); + } + fn is_exit_intended(&self) -> bool { + self.exit_intended + } } impl BinaryNumbersGame { - pub fn new(bits: Bits) -> Self { Self::new_with_max_lives(bits, 3) } + pub fn new(bits: Bits) -> Self { + Self::new_with_max_lives(bits, 3) + } pub fn new_with_max_lives(bits: Bits, max_lives: u32) -> Self { let hs = HighScores::load(); let starting_prev = hs.get(bits.high_score_key()); let mut game = Self { bits: bits.clone(), - puzzle: Self::init_puzzle(bits.clone(), 0), + puzzle: Self::init_puzzle(bits, 0), exit_intended: false, score: 0, streak: 0, @@ -318,7 +433,6 @@ impl BinaryNumbersGame { high_scores: hs, prev_high_score_for_display: starting_prev, new_high_score_reached: false, - needs_render: true, // Need to render initial state }; // Initialize stats snapshot immediately so stats display on first render game.refresh_stats_snapshot(); @@ -329,20 +443,9 @@ impl BinaryNumbersGame { BinaryNumbersPuzzle::new(bits, streak) } - /// Check if the game is in Active state (timer running) pub fn is_active(&self) -> bool { self.game_state == GameState::Active } - - /// Check if we need to render one frame (e.g., after state transition) - pub fn needs_render(&self) -> bool { - self.needs_render - } - - /// Clear the needs_render flag after rendering - pub fn clear_needs_render(&mut self) { - self.needs_render = false; - } } impl BinaryNumbersGame { @@ -351,7 +454,7 @@ impl BinaryNumbersGame { let full = "♥".repeat(full_count); let empty_count = self.max_lives.saturating_sub(self.lives) as usize; let empty = "·".repeat(empty_count); - format!("{}{}", full, empty) + format!("{full}{empty}") } fn finalize_round(&mut self) { @@ -360,24 +463,32 @@ impl BinaryNumbersGame { match result { GuessResult::Correct => { self.streak += 1; - if self.streak > self.max_streak { self.max_streak = self.streak; } + if self.streak > self.max_streak { + self.max_streak = self.streak; + } let streak_bonus = (self.streak - 1) * 2; let points = 10 + streak_bonus; self.score += points; self.puzzle.last_points_awarded = points; - if self.streak % 5 == 0 && self.lives < self.max_lives { self.lives += 1; } - } + if self.streak.is_multiple_of(5) && self.lives < self.max_lives { + self.lives += 1; + } + }, GuessResult::Incorrect | GuessResult::Timeout => { self.streak = 0; self.puzzle.last_points_awarded = 0; - if self.lives > 0 { self.lives -= 1; } - } + if self.lives > 0 { + self.lives -= 1; + } + }, } // high score update let bits_key = self.bits.high_score_key(); let prev = self.high_scores.get(bits_key); if self.score > prev { - if !self.new_high_score_reached { self.prev_high_score_for_display = prev; } + if !self.new_high_score_reached { + self.prev_high_score_for_display = prev; + } self.high_scores.update(bits_key, self.score); self.new_high_score_reached = true; let _ = self.high_scores.save(); @@ -385,19 +496,23 @@ impl BinaryNumbersGame { // set state after round resolution if self.lives == 0 { self.game_state = GameState::PendingGameOver; // defer summary until Enter - self.needs_render = true; // Need to render result before blocking } else { self.game_state = GameState::Result; - self.needs_render = true; // Need to render result before blocking } self.puzzle_resolved = true; } } pub fn handle_game_input(&mut self, input: KeyEvent) { - if keybinds::is_exit(input) { self.exit_intended = true; return; } + if keybinds::is_exit(input) { + self.exit_intended = true; + return; + } - if self.game_state == GameState::GameOver { self.handle_game_over_input(input); return; } + if self.game_state == GameState::GameOver { + self.handle_game_over_input(input); + return; + } match self.puzzle.guess_result { None => self.handle_no_result_yet(input), Some(_) => self.handle_result_available(input), @@ -406,9 +521,13 @@ impl BinaryNumbersGame { fn handle_game_over_input(&mut self, key: KeyEvent) { match key { - x if keybinds::is_select(x) => { self.reset_game_state(); } - x if keybinds::is_exit(x) => { self.exit_intended = true; } - _ => {} + x if keybinds::is_select(x) => { + self.reset_game_state(); + }, + x if keybinds::is_exit(x) => { + self.exit_intended = true; + }, + _ => {}, } } @@ -440,8 +559,8 @@ impl BinaryNumbersGame { // if no suggestion is selected, select the first one self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[0]); } - } - x if keybinds::is_left(x) => { + }, + x if keybinds::is_left(x) => { // select the previous suggestion if let Some(selected) = self.puzzle.selected_suggestion { let current_index = self.puzzle.suggestions.iter().position(|&x| x == selected); @@ -454,7 +573,7 @@ impl BinaryNumbersGame { self.puzzle.selected_suggestion = Some(self.puzzle.suggestions[prev_index]); } } - } + }, x if keybinds::is_select(x) => { if let Some(selected) = self.puzzle.selected_suggestion { if self.puzzle.is_correct_guess(selected) { @@ -464,13 +583,13 @@ impl BinaryNumbersGame { } self.finalize_round(); } - } - KeyEvent { code: KeyCode::Char('s' | 'S'), .. } => { + }, + KeyEvent { code: KeyCode::Char('s' | 'S'), .. } => { // Skip puzzle counts as timeout self.puzzle.guess_result = Some(GuessResult::Timeout); self.finalize_round(); - } - _ => {} + }, + _ => {}, } } @@ -481,19 +600,19 @@ impl BinaryNumbersGame { GameState::PendingGameOver => { // reveal summary self.game_state = GameState::GameOver; - } + }, GameState::Result => { // start next puzzle self.puzzle = Self::init_puzzle(self.bits.clone(), self.streak); self.puzzle_resolved = false; self.game_state = GameState::Active; - } - GameState::GameOver => { /* handled elsewhere */ } - GameState::Active => { /* shouldn't be here */ } + }, + GameState::GameOver => { /* handled elsewhere */ }, + GameState::Active => { /* shouldn't be here */ }, } - } + }, x if keybinds::is_exit(x) => self.exit_intended = true, - _ => {} + _ => {}, } } @@ -521,20 +640,74 @@ enum GuessResult { } #[derive(Clone)] -pub enum Bits { Four, FourShift4, FourShift8, FourShift12, Eight, Twelve, Sixteen, } +pub enum Bits { + Four, + FourShift4, + FourShift8, + FourShift12, + Eight, + Twelve, + Sixteen, +} impl Bits { - pub fn to_int(&self) -> u32 { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 4, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } } - pub fn scale_factor(&self) -> u32 { match self { Bits::Four => 1, Bits::FourShift4 => 16, Bits::FourShift8 => 256, Bits::FourShift12 => 4096, Bits::Eight => 1, Bits::Twelve => 1, Bits::Sixteen => 1 } } - pub fn high_score_key(&self) -> u32 { match self { Bits::Four => 4, Bits::FourShift4 => 44, Bits::FourShift8 => 48, Bits::FourShift12 => 412, Bits::Eight => 8, Bits::Twelve => 12, Bits::Sixteen => 16 } } - pub fn upper_bound(&self) -> u32 { (u32::pow(2, self.to_int()) - 1) * self.scale_factor() } - pub fn suggestion_count(&self) -> usize { match self { Bits::Four | Bits::FourShift4 | Bits::FourShift8 | Bits::FourShift12 => 3, Bits::Eight => 4, Bits::Twelve => 5, Bits::Sixteen => 6 } } - pub fn label(&self) -> &'static str { match self { Bits::Four => "4 bits", Bits::FourShift4 => "4 bits*16", Bits::FourShift8 => "4 bits*256", Bits::FourShift12 => "4 bits*4096", Bits::Eight => "8 bits", Bits::Twelve => "12 bits", Bits::Sixteen => "16 bits" } } + pub const fn to_int(&self) -> u32 { + match self { + Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 4, + Self::Eight => 8, + Self::Twelve => 12, + Self::Sixteen => 16, + } + } + pub const fn scale_factor(&self) -> u32 { + match self { + Self::Four => 1, + Self::FourShift4 => 16, + Self::FourShift8 => 256, + Self::FourShift12 => 4096, + Self::Eight => 1, + Self::Twelve => 1, + Self::Sixteen => 1, + } + } + pub const fn high_score_key(&self) -> u32 { + match self { + Self::Four => 4, + Self::FourShift4 => 44, + Self::FourShift8 => 48, + Self::FourShift12 => 412, + Self::Eight => 8, + Self::Twelve => 12, + Self::Sixteen => 16, + } + } + pub const fn upper_bound(&self) -> u32 { + (u32::pow(2, self.to_int()) - 1) * self.scale_factor() + } + pub const fn suggestion_count(&self) -> usize { + match self { + Self::Four | Self::FourShift4 | Self::FourShift8 | Self::FourShift12 => 3, + Self::Eight => 4, + Self::Twelve => 5, + Self::Sixteen => 6, + } + } + pub const fn label(&self) -> &'static str { + match self { + Self::Four => "4 bits", + Self::FourShift4 => "4 bits*16", + Self::FourShift8 => "4 bits*256", + Self::FourShift12 => "4 bits*4096", + Self::Eight => "8 bits", + Self::Twelve => "12 bits", + Self::Sixteen => "16 bits", + } + } } pub struct BinaryNumbersPuzzle { bits: Bits, - current_number: u32, // scaled value used for suggestions matching + current_number: u32, // scaled value used for suggestions matching raw_current_number: u32, // raw bit value (unscaled) for display suggestions: Vec, selected_suggestion: Option, @@ -553,9 +726,11 @@ impl BinaryNumbersPuzzle { let mut suggestions = Vec::new(); let scale = bits.scale_factor(); while suggestions.len() < bits.suggestion_count() { - let raw = rng.random_range(0..=u32::pow(2, bits.to_int()) - 1); + let raw = rng.random_range(0..u32::pow(2, bits.to_int())); let num = raw * scale; - if !suggestions.contains(&num) { suggestions.push(num); } + if !suggestions.contains(&num) { + suggestions.push(num); + } } let current_number = suggestions[0]; // scaled value @@ -569,7 +744,7 @@ impl BinaryNumbersPuzzle { Bits::Twelve => 16.0, Bits::Sixteen => 20.0, }; - let penalty = (streak as f64) * 0.5; // 0.5s less per streak + let penalty = f64::from(streak) * 0.5; // 0.5s less per streak let time_total = (base_time - penalty).max(5.0); let time_left = time_total; let selected_suggestion = Some(suggestions[0]); @@ -591,8 +766,12 @@ impl BinaryNumbersPuzzle { } } - pub fn suggestions(&self) -> &[u32] { &self.suggestions } - pub fn is_correct_guess(&self, guess: u32) -> bool { guess == self.current_number } + pub fn suggestions(&self) -> &[u32] { + &self.suggestions + } + pub const fn is_correct_guess(&self, guess: u32) -> bool { + guess == self.current_number + } pub fn current_to_binary_string(&self) -> String { let width = self.bits.to_int() as usize; @@ -633,8 +812,15 @@ impl Widget for &mut BinaryNumbersGame { // Simple ASCII gauge renderer to avoid variable glyph heights from Unicode block elements fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) { - let fill_width = ((area.width as f64) * ratio.clamp(0.0, 1.0)).round().min(area.width as f64) as u16; - if area.height == 0 { return; } + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + let fill_width = + (f64::from(area.width) * ratio.clamp(0.0, 1.0)).round().min(f64::from(area.width)) as u16; + + if area.height == 0 { + return; + } + for x in 0..area.width { let filled = x < fill_width; let symbol = if filled { "=" } else { " " }; @@ -651,12 +837,16 @@ fn render_ascii_gauge(area: Rect, buf: &mut Buffer, ratio: f64, color: Color) { } } -struct HighScores { scores: HashMap, } +struct HighScores { + scores: HashMap, +} impl HighScores { const FILE: &'static str = "binbreak_highscores.txt"; - fn empty() -> Self { Self { scores: HashMap::new() } } + fn empty() -> Self { + Self { scores: HashMap::new() } + } fn load() -> Self { let mut hs = Self::empty(); @@ -664,7 +854,7 @@ impl HighScores { let mut contents = String::new(); if file.read_to_string(&mut contents).is_ok() { for line in contents.lines() { - if let Some((k,v)) = line.split_once('=') + if let Some((k, v)) = line.split_once('=') && let Ok(bits) = k.trim().parse::() && let Ok(score) = v.trim().parse::() { @@ -678,9 +868,9 @@ impl HighScores { fn save(&self) -> std::io::Result<()> { let mut data = String::new(); - for key in [4u32,44u32,48u32,412u32,8u32,12u32,16u32] { + for key in [4u32, 44u32, 48u32, 412u32, 8u32, 12u32, 16u32] { let val = self.get(key); - data.push_str(&format!("{}={}\n", key, val)); + let _ = writeln!(data, "{key}={val}"); } let mut file = File::create(Self::FILE)?; file.write_all(data.as_bytes()) @@ -698,9 +888,9 @@ impl HighScores { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyModifiers, KeyEventKind, KeyEventState}; - use std::sync::Mutex; + use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers}; use std::fs; + use std::sync::Mutex; static HS_LOCK: Mutex<()> = Mutex::new(()); @@ -710,8 +900,12 @@ mod tests { f(); // restore match original { - Some(data) => { let _ = fs::write(HighScores::FILE, data); } - None => { let _ = fs::remove_file(HighScores::FILE); } + Some(data) => { + let _ = fs::write(HighScores::FILE, data); + }, + None => { + let _ = fs::remove_file(HighScores::FILE); + }, } } @@ -739,10 +933,14 @@ mod tests { assert_eq!(p.suggestions().len(), Bits::FourShift4.suggestion_count()); // uniqueness let mut sorted = p.suggestions().to_vec(); - sorted.sort(); - for pair in sorted.windows(2) { assert_ne!(pair[0], pair[1]); } + sorted.sort_unstable(); + for pair in sorted.windows(2) { + assert_ne!(pair[0], pair[1]); + } // scaling property - for &s in p.suggestions() { assert_eq!(s % scale, 0); } + for &s in p.suggestions() { + assert_eq!(s % scale, 0); + } // current number must be one of suggestions and raw_current_number * scale == current_number assert!(p.suggestions().contains(&p.current_number)); assert_eq!(p.raw_current_number * scale, p.current_number); @@ -853,11 +1051,21 @@ mod tests { let mut g = BinaryNumbersGame::new(Bits::Four); let initial = g.puzzle.selected_suggestion; // Simulate Right key - let right_event = KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE }; + let right_event = KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; g.handle_game_input(right_event); assert_ne!(g.puzzle.selected_suggestion, initial); // Simulate Left key should cycle back - let left_event = KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty(), kind: KeyEventKind::Press, state: KeyEventState::NONE }; + let left_event = KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; g.handle_game_input(left_event); assert!(g.puzzle.selected_suggestion.is_some()); } diff --git a/src/keybinds.rs b/src/keybinds.rs index 88a4599..125594b 100644 --- a/src/keybinds.rs +++ b/src/keybinds.rs @@ -1,25 +1,25 @@ use crossterm::event::{KeyCode, KeyEvent}; -pub(crate) fn is_up(key: KeyEvent) -> bool { +pub const fn is_up(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Up | KeyCode::Char('k')) } -pub(crate) fn is_down(key: KeyEvent) -> bool { +pub const fn is_down(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Down | KeyCode::Char('j')) } -pub(crate) fn is_left(key: KeyEvent) -> bool { +pub const fn is_left(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Left | KeyCode::Char('h')) } -pub(crate) fn is_right(key: KeyEvent) -> bool { +pub const fn is_right(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Right | KeyCode::Char('l')) } -pub(crate) fn is_select(key: KeyEvent) -> bool { +pub const fn is_select(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Enter) } -pub(crate) fn is_exit(key: KeyEvent) -> bool { +pub const fn is_exit(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | 'Q')) } diff --git a/src/main_screen_widget.rs b/src/main_screen_widget.rs index dbc0872..a43dd26 100644 --- a/src/main_screen_widget.rs +++ b/src/main_screen_widget.rs @@ -10,4 +10,4 @@ pub trait MainScreenWidget: WidgetRef { fn run(&mut self, dt: f64) -> (); fn handle_input(&mut self, input: KeyEvent) -> (); fn is_exit_intended(&self) -> bool; -} \ No newline at end of file +} diff --git a/src/utils.rs b/src/utils.rs index f0d0b04..878f9e1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,14 +9,16 @@ pub struct AsciiCell { pub color: Color, } +#[allow(clippy::cast_possible_truncation)] pub fn parse_ascii_art( - art: String, - color_map_str: String, + art: &str, + color_map_str: &str, color_map: &HashMap, default_color: Color, ) -> Vec { let art_lines: Vec> = art.lines().map(|line| line.chars().collect()).collect(); - let color_lines: Vec> = color_map_str.lines().map(|line| line.chars().collect()).collect(); + let color_lines: Vec> = + color_map_str.lines().map(|line| line.chars().collect()).collect(); assert_eq!(art_lines.len(), color_lines.len(), "Art and color string must have same height"); @@ -26,13 +28,8 @@ pub fn parse_ascii_art( assert_eq!(art_row.len(), color_row.len(), "Mismatched line lengths"); for (x, (&ch, &color_ch)) in art_row.iter().zip(color_row.iter()).enumerate() { - let color = color_map.get(&color_ch).cloned().unwrap_or(default_color); - pixels.push(AsciiCell { - ch, - x: x as u16, - y: y as u16, - color, - }); + let color = color_map.get(&color_ch).copied().unwrap_or(default_color); + pixels.push(AsciiCell { ch, x: x as u16, y: y as u16, color }); } } @@ -45,8 +42,8 @@ pub struct AsciiCells { impl AsciiCells { pub fn from( - art: String, - color_map_str: String, + art: &str, + color_map_str: &str, color_map: &HashMap, default_color: Color, ) -> Self { @@ -67,7 +64,7 @@ pub struct AsciiArtWidget { } impl AsciiArtWidget { - pub fn new(collection: AsciiCells) -> Self { + pub const fn new(collection: AsciiCells) -> Self { Self { collection } } } @@ -78,6 +75,7 @@ impl Widget for AsciiArtWidget { let position = Position::new(pixel.x + area.x, pixel.y + area.y); if area.contains(position) { + #[allow(clippy::expect_used)] buf.cell_mut(position) .expect("Failed to get cell at position") .set_char(pixel.ch) @@ -89,7 +87,7 @@ impl Widget for AsciiArtWidget { pub fn center(area: Rect, horizontal: Constraint) -> Rect { let [area] = Layout::horizontal([horizontal]).flex(Flex::Center).areas(area); - + vertically_center(area) } @@ -100,15 +98,13 @@ pub fn vertically_center(area: Rect) -> Rect { } pub trait When { - fn when(self, condition: bool, action: impl FnOnce(Self) -> Self) -> Self where Self: Sized; + fn when(self, condition: bool, action: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized; } impl When for T { fn when(self, condition: bool, action: impl FnOnce(T) -> T) -> Self { - if condition { - action(self) - } else { - self - } + if condition { action(self) } else { self } } -} \ No newline at end of file +}