From 64240632508af70f1d6dc88067d8dcda0ebae60d Mon Sep 17 00:00:00 2001 From: markichnich Date: Fri, 25 Aug 2023 22:04:48 +0200 Subject: [PATCH] add undo/redo functionality; organize and comment --- src/main.rs | 9 +- src/state.rs | 270 ++++++++++++++++++++++++++++++++-------- src/sudoku/generator.rs | 16 +-- src/sudoku/mod.rs | 1 + src/sudoku/validator.rs | 4 +- src/ui.rs | 66 +++++++++- 6 files changed, 295 insertions(+), 71 deletions(-) diff --git a/src/main.rs b/src/main.rs index a4949b4..e33eb56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,13 +4,9 @@ use crossterm::event::{poll, read, Event::*, KeyCode::*}; extern crate rand; mod state; -use state::*; - mod sudoku; -use sudoku::*; - mod ui; -use ui::*; +use {state::*, sudoku::*, ui::*}; use std::{io, time::Duration}; @@ -81,6 +77,9 @@ fn main() { _ => state.preselect_num(num as u8 - b'0'), }, + Char('u') | Char('U') => state.undo(), + Char('r') | Char('R') => state.redo(), + Char('q') | Char('Q') => { screen.deinit().or_crash(); break; diff --git a/src/state.rs b/src/state.rs index b2148a4..5ee05ab 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,62 +3,57 @@ use crate::Dir::*; use std::time; -pub enum Dir { - Up, - Down, - Left, - Right, - FarUp, - FarDown, - FarLeft, - FarRight, -} - -#[derive(Default, PartialEq, Clone, Copy)] -pub enum Mode { - #[default] - Edit, - Markup, - Go, -} - +/// the entire game logic state pub struct State { pub board: Board, - pub difficulty: Difficulty, pub modifiable: [[bool; 9]; 9], pub markups: [[[bool; 9]; 9]; 9], - pub start_time: time::Instant, - pub mode: Mode, - pub next_mode: Mode, + pub preselection: u8, pub cur_row: usize, pub cur_col: usize, + + pub mode: Mode, + pub next_mode: Mode, + + pub difficulty: Difficulty, + pub start_time: time::Instant, + + pub undo_stack: Vec, + pub redo_stack: Vec, } impl State { + /// returns a new `State` with a randomly generated + /// sudoku `Board` of the provided `Difficulty` pub fn init(difficulty: Difficulty) -> Self { let board = generate_sudoku(difficulty); - let modifiable = State::get_modifiables(board); + let modifiable = State::init_modifiables(board); Self { board, - difficulty, modifiable, markups: [[[false; 9]; 9]; 9], - start_time: time::Instant::now(), - mode: Mode::default(), - next_mode: Mode::default(), + preselection: 1, cur_row: 4, cur_col: 4, + + mode: Mode::default(), + next_mode: Mode::default(), + + difficulty, + start_time: time::Instant::now(), + + undo_stack: Vec::with_capacity(160), + redo_stack: Vec::with_capacity(80), } } - fn get_time(&self) -> time::Duration { - time::Instant::now() - self.start_time - } - - fn get_modifiables(board: Board) -> [[bool; 9]; 9] { + /// returns a boolean mask of the board, indicating which cells + /// can be modified by the user and which are part of the puzzle constraints. + /// makes only cells that are initialized with the value 0 modifiable + fn init_modifiables(board: Board) -> [[bool; 9]; 9] { let mut modifiable = [[false; 9]; 9]; for (board_row, modifiable_row) in board.iter().zip(modifiable.iter_mut()) { for (board_cell, modifiable_flag) in board_row.iter().zip(modifiable_row.iter_mut()) { @@ -74,6 +69,8 @@ impl State { self.modifiable[self.cur_row][self.cur_col] } + /// returns a mutable reference to the number + /// contained in the current cell. pub fn current_cell(&mut self) -> &mut u8 { &mut self.board[self.cur_row][self.cur_col] } @@ -103,7 +100,7 @@ impl State { pub fn toggle_current_cell(&mut self) { if self.current_cell_is_modifiable() { if *self.current_cell() == self.preselection { - *self.current_cell() = 0; + self.delete_current_cell(); } else { *self.current_cell() = self.preselection; self.delete_colliding_marks(self.preselection, self.cur_row, self.cur_col); @@ -114,15 +111,91 @@ impl State { pub fn delete_current_cell(&mut self) { if self.current_cell_is_modifiable() { *self.current_cell() = 0; + + self.push_to_undo_stack(UndoStep::Replace( + self.preselection, + (self.cur_row as u8, self.cur_col as u8), + )); } } + /// deletes all marks of `num` in it's row, column and block. + /// used to automatically remove marks when placing a number that + /// invalidates those marks. pub fn delete_colliding_marks(&mut self, num: u8, row: usize, col: usize) { + let mut deleted = [None; 27]; + let mut pos = 0; + for i in 0..9 { + if self.markups[i][col][num as usize - 1] { + deleted[pos] = Some((i as u8, col as u8)); + pos += 1; + } + if self.markups[row][i][num as usize - 1] { + deleted[pos] = Some((row as u8, i as u8)); + pos += 1; + } + if self.markups[row / 3 * 3 + i / 3][col / 3 * 3 + i % 3][num as usize - 1] { + deleted[pos] = Some(( + row as u8 / 3 * 3 + i as u8 / 3, + col as u8 / 3 * 3 + i as u8 % 3, + )); + pos += 1; + } + self.markups[i][col][num as usize - 1] = false; self.markups[row][i][num as usize - 1] = false; self.markups[row / 3 * 3 + i / 3][col / 3 * 3 + i % 3][num as usize - 1] = false; } + + self.push_to_undo_stack(UndoStep::Unplace( + self.preselection, + (row as u8, col as u8), + deleted, + )); + } + + pub fn toggle_current_mark(&mut self) { + if self.markups[self.cur_row][self.cur_col][self.preselection as usize - 1] { + self.delete_current_mark(); + } else { + self.set_current_mark(); + } + } + + pub fn delete_current_mark(&mut self) { + if *self.current_cell() != 0 { + return; + } + + self.markups[self.cur_row][self.cur_col][self.preselection as usize - 1] = false; + + self.push_to_undo_stack(UndoStep::Remark( + self.preselection, + (self.cur_row as u8, self.cur_col as u8), + )); + } + + pub fn set_current_mark(&mut self) { + if *self.current_cell() != 0 { + return; + } + + self.markups[self.cur_row][self.cur_col][self.preselection as usize - 1] = true; + + self.push_to_undo_stack(UndoStep::Unmark( + self.preselection, + (self.cur_row as u8, self.cur_col as u8), + )); + } + + pub fn move_cursor_to(&mut self, row: usize, col: usize) { + assert!( + row < 9 && col < 9, + "[!] Error: State::go_to: Can't move to row/column out of bounds." + ); + self.cur_row = row; + self.cur_col = col; } pub fn enter_mode(&mut self, mode: Mode) { @@ -137,6 +210,8 @@ impl State { self.next_mode = self.mode; } + /// enter a mode until one action has been taken, + /// then returning to the previous mode pub fn enter_mode_once(&mut self, mode: Mode) { if self.mode != mode { self.next_mode = self.mode; @@ -148,23 +223,11 @@ impl State { self.mode = self.next_mode; } - pub fn move_cursor_to(&mut self, row: usize, col: usize) { - assert!( - row < 9 && col < 9, - "[!] Error: State::go_to: Can't move to row/column out of bounds." - ); - self.cur_row = row; - self.cur_col = col; - } - - pub fn toggle_current_mark(&mut self) { - self.markups[self.cur_row][self.cur_col][self.preselection as usize - 1] ^= true; - } - - pub fn delete_current_mark(&mut self) { - self.markups[self.cur_row][self.cur_col][self.preselection as usize - 1] = false; + fn get_elapsed_time(&self) -> time::Duration { + time::Instant::now() - self.start_time } + /// returns number of filled cells pub fn get_completion_string(&self) -> String { let mut count = 0; for row in self.board { @@ -178,6 +241,9 @@ impl State { [to_char(count / 10), to_char(count % 10)].iter().collect() } + /// returns how many of the 9 final occurences of the + /// preselected number have been found. + /// returns `'!'` if the user erroneously placed more than 9. pub fn get_preselection_completion_char(&self) -> char { let count = self .board @@ -185,9 +251,14 @@ impl State { .flatten() .filter(|&cell| cell == self.preselection) .count(); - (count.min(9) as u8 + b'0') as char + match count { + 10.. => '!', + _ => (count as u8 + b'0') as char, + } } + /// returns the difficulty string used on the ingame scoreboard. + /// if you want the complete difficulty names use `Difficulty::to_string()` pub fn get_difficulty_string(&self) -> String { match self.difficulty { Difficulty::Easy => String::from("Easy "), @@ -198,8 +269,10 @@ impl State { } } + /// returns (mins, secs) as a pair of strings + /// both are zero-padded to a width of 2 characters pub fn get_timer_strings(&self) -> (String, String) { - let mut n = self.get_time().as_secs(); + let mut n = self.get_elapsed_time().as_secs(); let mut mins = String::with_capacity(2); let mut secs = String::with_capacity(2); @@ -218,6 +291,7 @@ impl State { (mins, secs) } + /// returns timer as string in `mm:ss` format pub fn get_timer_string(&self) -> String { let (mins, secs) = self.get_timer_strings(); let mut timer = String::with_capacity(5); @@ -226,4 +300,98 @@ impl State { timer.push_str(&secs); timer } + + /// undo an action that has been taken + pub fn undo(&mut self) { + use UndoStep::*; + + if let Some(undo_step) = self.undo_stack.pop() { + self.redo_stack.push(undo_step); + match undo_step { + Unplace(num, (row, col), deleted_marks) => { + self.board[row as usize][col as usize] = 0; + for (r, c) in deleted_marks.iter().take_while(|x| x.is_some()).flatten() { + self.markups[*r as usize][*c as usize][num as usize - 1] = true; + } + } + Replace(num, (row, col)) => { + self.board[row as usize][col as usize] = num; + } + Remark(num, (row, col)) => { + self.markups[row as usize][col as usize][num as usize - 1] = true; + } + Unmark(num, (row, col)) => { + self.markups[row as usize][col as usize][num as usize - 1] = false; + } + } + } + } + + /// redo an action if one was taken and undone. + pub fn redo(&mut self) { + use UndoStep::*; + + if let Some(redo_step) = self.redo_stack.pop() { + self.undo_stack.push(redo_step); + match redo_step { + Unplace(num, (row, col), deleted_marks) => { + self.board[row as usize][col as usize] = num; + for (r, c) in deleted_marks.iter().take_while(|x| x.is_some()).flatten() { + self.markups[*r as usize][*c as usize][num as usize - 1] = false; + } + } + Replace(_, (row, col)) => { + self.board[row as usize][col as usize] = 0; + } + Remark(num, (row, col)) => { + self.markups[row as usize][col as usize][num as usize - 1] = false; + } + Unmark(num, (row, col)) => { + self.markups[row as usize][col as usize][num as usize - 1] = true; + } + } + } + } + + /// pushes a move done by the player to the undo stack + /// this additionally invalidates the redo stack + pub fn push_to_undo_stack(&mut self, undo_step: UndoStep) { + self.undo_stack.push(undo_step); + self.redo_stack.clear(); + } +} + +pub enum Dir { + Up, + Down, + Left, + Right, + FarUp, + FarDown, + FarLeft, + FarRight, +} + +#[derive(Default, PartialEq, Clone, Copy)] +pub enum Mode { + #[default] + Edit, + Markup, + Go, +} + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum UndoStep { + /// unplace num at (row, col) and re-mark + /// (row, col) if there were removed marks + Unplace(u8, (u8, u8), [Option<(u8, u8)>; 27]), + + /// re-place num at (row, col) + Replace(u8, (u8, u8)), + + /// unmark num at (row, col) + Unmark(u8, (u8, u8)), + + /// re-mark num at (row, col) + Remark(u8, (u8, u8)), } diff --git a/src/sudoku/generator.rs b/src/sudoku/generator.rs index 219d37b..b35d797 100644 --- a/src/sudoku/generator.rs +++ b/src/sudoku/generator.rs @@ -1,6 +1,3 @@ -// TODO: remove when done -#![cfg_attr(debug_assertions, allow(unused))] - use crate::generator::Difficulty::*; use crate::rand::{seq::SliceRandom, thread_rng}; use crate::sudoku::{Board, New}; @@ -9,6 +6,7 @@ use std::fmt; /// categories of difficulty, indicating how many /// empty spaces will be on a sudoku board. +/// (see `Difficulty::removal_count()` for values) #[allow(unused)] #[derive(Default, Copy, Clone, PartialEq)] pub enum Difficulty { @@ -47,6 +45,8 @@ impl fmt::Display for Difficulty { } /// generate a random, unsolved sudoku board with a given `Difficulty`. +/// solves a empty sudoku with random cell order and then removes +/// some number of cells depending on the difficulty. pub fn generate_sudoku(difficulty: Difficulty) -> Board { let mut board = Board::new(); @@ -65,13 +65,15 @@ pub fn generate_sudoku(difficulty: Difficulty) -> Board { board } +/// solves a sudoku, randomizing which empty cell of equal +/// mrv to choose next. +/// this is used to generate random, fully solvable sudokus. +/// +/// NOTE: this does not guarantee a single-solution sudoku. +/// /// TODO: currently empty cells needs to be recreated /// on each recursive call, and are randomized anew /// each time, which is an unnecessary overhead. -/// -/// solves a sudoku, randomizing which empty cell of equal -/// mrv () to choose next. -/// this is used to generate random, fully solvable sudokus. fn solve_random(board: &mut Board) -> Result<(), ()> { let mut empty_cells: Vec<(usize, usize)> = Vec::new(); diff --git a/src/sudoku/mod.rs b/src/sudoku/mod.rs index 1fa4966..0107c78 100644 --- a/src/sudoku/mod.rs +++ b/src/sudoku/mod.rs @@ -11,6 +11,7 @@ trait New { } impl New for Board { + /// return an empty `Board` fn new() -> Self { [[0u8; 9]; 9] } diff --git a/src/sudoku/validator.rs b/src/sudoku/validator.rs index b5fa24e..256e9f2 100644 --- a/src/sudoku/validator.rs +++ b/src/sudoku/validator.rs @@ -1,8 +1,6 @@ -// TODO: remove when done -#![cfg_attr(debug_assertions, allow(unused))] - use crate::sudoku::Board; +/// returns true if the provided sudoku `Board` is in a solved state pub fn is_solution(sudoku: &Board) -> bool { for i in 0..9 { let mut row_set = 0u16; diff --git a/src/ui.rs b/src/ui.rs index ebb689b..a357977 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,7 +16,8 @@ use crossterm::{ }, }; -pub struct Screen +/// state of the user interface +pub struct Ui where T: io::Write, { @@ -27,10 +28,15 @@ where pub height: usize, } -impl Screen +impl Ui where T: io::Write, { + /// returns a new `Ui` using `ostream` as its' output stream + /// and sets up terminal state: + /// - remembers the current cursor position + /// - enters alternate screen buffer + /// - enables raw mode pub fn init(ostream: T) -> Self { let (width, height) = size().unwrap(); let (width, height) = (width as usize, height as usize); @@ -38,7 +44,7 @@ where let presel_color_pair = (Color::Black, Color::Cyan); let markup_color_background = Color::Cyan; - let mut screen = Screen { + let mut ui = Ui { presel_color_pair, markup_color_background, ostream, @@ -47,22 +53,29 @@ where }; queue!( - screen.ostream, + ui.ostream, SavePosition, EnterAlternateScreen, Clear(All) ) .expect("[-]: Error: ui::init: Failed to enter alternate screen."); enable_raw_mode().expect("[-]: Error: ui::init: Failed to enable raw mode."); - screen + ui } + /// resets terminal state that `Ui::init()` sets: + /// - disables raw mode + /// - leaves alternate screen buffer + /// - restores cursor position pub fn deinit(&mut self) -> io::Result<()> { disable_raw_mode()?; execute!(self.ostream, LeaveAlternateScreen, RestorePosition)?; Ok(()) } + /// updates `width` and `height`. + /// clears the screen and redraws the board if the dimensions changed. + /// returns an error if the new dimensions are too small to fit the ui. pub fn update_dimensions(&mut self) -> io::Result<()> { let old_dimensions = (self.width, self.height); @@ -90,6 +103,15 @@ where queue!(self.ostream, MoveToColumn(0)) } + /// draws the entire ui, watching for possibly changed screen dimensions. + /// all other `draw_...()` functions just `queue!(...)` the actions to draw + /// their respective ui element. + /// this function chains all of these calls in order and then flushes the + /// changes to `self.ostream`. + /// + /// NOTE: this function does not draw the static elements of the ui + /// using `Ui::draw_board()`. for efficiency these are only + /// redrawn on screen dimensions changes. pub fn draw(&mut self, state: &State) -> io::Result<()> { self.update_dimensions()?; @@ -100,6 +122,12 @@ where self.ostream.flush() } + /// `queue!(...)`s the drawing of the unchanging ui elements + /// such as the board and scoreboards outlines. + /// uses the lines provided by `board_template()`. + /// + /// NOTE: this function itself does not flush to `self.ostream` + /// in order to only have to flush once per frame. pub fn draw_board(&mut self) -> io::Result<()> { self.init_cursor_offset()?; queue!(self.ostream, SetForegroundColor(Color::Reset))?; @@ -114,6 +142,10 @@ where Ok(()) } + /// `queue!(...)`s the drawing of the numbers in the cells. + /// + /// NOTE: this function itself does not flush to `self.ostream` + /// in order to only have to flush once per frame. fn draw_numbers(&mut self, state: &State) -> io::Result<()> { self.init_cursor_offset()?; queue!(self.ostream, SetForegroundColor(Color::Reset))?; @@ -174,6 +206,10 @@ where Ok(()) } + /// `queue!(...)`s the drawing of variable scoreboard content + /// + /// NOTE: this function itself does not flush to `self.ostream` + /// in order to only have to flush once per frame. fn draw_scoreboard(&mut self, state: &State) -> io::Result<()> { self.init_cursor_offset()?; queue!(self.ostream, SetForegroundColor(Color::Reset))?; @@ -225,6 +261,10 @@ where Ok(()) } + /// `queue!(...)`s the placement of the cursor on the selected cell. + /// + /// NOTE: this function itself does not flush to `self.ostream` + /// in order to only have to flush once per frame. fn draw_cursor(&mut self, state: &State) -> io::Result<()> { let (row, col) = (state.cur_row, state.cur_col); let (x, y) = ((self.width / 2 - 14) as u16, (self.height / 2 - 6) as u16); @@ -243,6 +283,12 @@ where queue!(self.ostream, MoveTo(x, y), SetCursorStyle::SteadyBlock) } + /// `queue!(...)`s the movement of the cursor by (`x`, `y`). + /// positive `x` values correspond to movement right by `x` columns. + /// positive `y` values correspond to movement down by `y` rows. + /// + /// NOTE: this function itself does not flush to `self.ostream` + /// in order to only have to flush once per frame. fn move_cursor_by(&mut self, x: isize, y: isize) -> io::Result<()> { match x.cmp(&0) { Equal => Ok(()), @@ -257,6 +303,10 @@ where Ok(()) } + /// `queue!(...)`s the movement of the cursor to the top-left + /// of the inner ui. + /// this simplifies handling the padding in other drawing functions + /// since the padding depends on the screen dimensions. fn init_cursor_offset(&mut self) -> io::Result<()> { let lft_pad = (self.width / 2 - 14) as u16; let top_pad = (self.height / 2 - 6) as u16; @@ -264,6 +314,8 @@ where } } +/// returns a template for the parts of the board that +/// are always the same. fn board_template() -> [String; 13] { [ String::from("┌────────┬────────┬────────┐ ┌───────┐"), @@ -287,6 +339,10 @@ pub trait UiCrash { } impl UiCrash for io::Result { + /// ends the process with exit code 1 + /// if called on `Err(_)` variant. + /// if called on `Ok(_)` variant, this is a no-op + /// that discards the contained value. fn or_crash(&self) { if self.is_err() { std::process::exit(1);