diff --git a/otg/core/src/goban.rs b/otg/core/src/goban.rs index edff8b1..04d659b 100644 --- a/otg/core/src/goban.rs +++ b/otg/core/src/goban.rs @@ -213,7 +213,7 @@ impl Goban { /// /// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 3 }), Some(Color::Black)); /// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 15 }), Some(Color::White)); - /// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 15 }), Some(Color::Black)); + /// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 3 }), Some(Color::Black)); /// ``` pub fn apply_moves<'a>( self, diff --git a/otg/core/src/view_models/game_review.rs b/otg/core/src/view_models/game_review.rs index 7cdf068..a598b2a 100644 --- a/otg/core/src/view_models/game_review.rs +++ b/otg/core/src/view_models/game_review.rs @@ -28,6 +28,8 @@ use sgf::{GameRecord, Player}; use std::sync::{Arc, RwLock}; struct GameReviewViewModelPrivate { + // This is the ID of the current position in the game. The ID is specific to the GameRecord, + // not the ReviewTree. current_position: Option, game: GameRecord, review_tree: DepthTree, @@ -35,7 +37,7 @@ struct GameReviewViewModelPrivate { #[derive(Clone)] pub struct GameReviewViewModel { - imp: Arc>, + inner: Arc>, } impl GameReviewViewModel { @@ -49,7 +51,7 @@ impl GameReviewViewModel { }; Self { - imp: Arc::new(RwLock::new(GameReviewViewModelPrivate { + inner: Arc::new(RwLock::new(GameReviewViewModelPrivate { current_position, game, review_tree, @@ -58,52 +60,222 @@ impl GameReviewViewModel { } pub fn black_player(&self) -> Player { - self.imp.read().unwrap().game.black_player.clone() + self.inner.read().unwrap().game.black_player.clone() } pub fn white_player(&self) -> Player { - self.imp.read().unwrap().game.white_player.clone() + self.inner.read().unwrap().game.white_player.clone() } pub fn game_view(&self) -> Goban { - let imp = self.imp.read().unwrap(); - let mainline = imp.game.mainline(); - match mainline { - Some(mainline) => Goban::default() - .apply_moves(mainline.map(|nr| nr.data())) - .unwrap(), - None => Goban::default(), + let inner = self.inner.read().unwrap(); + + let mut path: Vec = vec![]; + let mut current_id = inner.current_position; + while current_id.is_some() { + let current = current_id.unwrap(); + path.push(current); + current_id = inner.game.trees[0] + .get(current) + .unwrap() + .parent() + .map(|parent| parent.node_id()); } + + path.reverse(); + Goban::default() + .apply_moves( + path.into_iter() + .map(|node_id| inner.game.trees[0].get(node_id).unwrap().data()), + ) + .unwrap() + + /* + if let Some(start) = inner.current_position { + let mut current_id = start; + let mut path = vec![current_id.clone()]; + + while let + /* + let mut current_node = inner.game.trees[0].get(current_position).unwrap(); + let mut path = vec![current_node.data()]; + while let Some(parent) = current_node.parent() { + path.push(parent.data()); + current_node = parent; + } + */ + + path.reverse(); + Goban::default().apply_moves(path).unwrap() + } else { + Goban::default() + } + */ } pub fn map_tree(&self, f: F) where F: Fn(NodeRef<'_, SizeNode>, Option), { - let imp = self.imp.read().unwrap(); + let inner = self.inner.read().unwrap(); - for node in imp.review_tree.bfs_iter() { - f(node, imp.current_position); + for node in inner.review_tree.bfs_iter() { + f(node, inner.current_position); } } pub fn tree_max_depth(&self) -> usize { - self.imp.read().unwrap().review_tree.max_depth() + self.inner.read().unwrap().review_tree.max_depth() } - pub fn move_forward(&self) { - unimplemented!() + // When moving forward on the tree, I grab the first child by default. I can then just advance + // the board state by applying the child. + pub fn next_move(&self) { + let mut inner = self.inner.write().unwrap(); + let current_position = inner.current_position.clone(); + match current_position { + Some(current_position) => { + let current_id = current_position.clone(); + let node = inner.game.trees[0].get(current_id).unwrap(); + if let Some(next_id) = node.first_child().map(|child| child.node_id()) { + inner.current_position = Some(next_id); + } + } + None => { + inner.current_position = inner.game.trees[0].root().map(|node| node.node_id()); + } + } } - pub fn move_backward(&self) { - unimplemented!() + // When moving backwards, I jump up to the parent. I'll then rebuild the board state from the + // root. + pub fn previous_move(&mut self) { + let mut inner = self.inner.write().unwrap(); + if let Some(current_position) = inner.current_position { + let current_node = inner.game.trees[0] + .get(current_position) + .expect("current_position should always correspond to a node in the tree"); + if let Some(parent_node) = current_node.parent() { + inner.current_position = Some(parent_node.node_id()); + } + } } pub fn next_variant(&self) { - unimplemented!() + println!("move to the next variant amongst the options available"); } pub fn previous_variant(&self) { - unimplemented!() + println!("move to the previous variant amongst the options available"); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{Color, Coordinate}; + use std::path::Path; + + fn with_game_record(test: F) + where + F: FnOnce(GameReviewViewModel), + { + let records = sgf::parse_sgf_file(&Path::new("../../sgf/test_data/branch_test.sgf")) + .expect("to successfully load the test file"); + let record = records[0] + .as_ref() + .expect("to have successfully loaded the test record"); + + let view_model = GameReviewViewModel::new(record.clone()); + + test(view_model); + } + + #[test] + fn it_generates_a_mainline_board() { + with_game_record(|view| { + let goban = view.game_view(); + + for row in 0..18 { + for column in 0..18 { + if row == 3 && column == 3 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White)); + } else if row == 15 && column == 3 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White)); + } else if row == 3 && column == 15 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else if row == 15 && column == 14 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else { + assert_eq!( + goban.stone(&Coordinate { row, column }), + None, + "{} {}", + row, + column + ); + } + } + } + }); + } + + #[test] + fn it_moves_to_the_previous_mainline_move() { + with_game_record(|mut view| { + view.previous_move(); + let goban = view.game_view(); + + for row in 0..18 { + for column in 0..18 { + if row == 3 && column == 3 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White)); + } else if row == 3 && column == 15 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else if row == 15 && column == 14 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else { + assert_eq!( + goban.stone(&Coordinate { row, column }), + None, + "{} {}", + row, + column + ); + } + } + } + }); + } + + #[test] + fn it_moves_to_the_next_node() { + with_game_record(|mut view| { + view.previous_move(); + view.next_move(); + let goban = view.game_view(); + + for row in 0..18 { + for column in 0..18 { + if row == 3 && column == 3 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White)); + } else if row == 15 && column == 3 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White)); + } else if row == 3 && column == 15 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else if row == 15 && column == 14 { + assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black)); + } else { + assert_eq!( + goban.stone(&Coordinate { row, column }), + None, + "{} {}", + row, + column + ); + } + } + } + }); } } diff --git a/otg/gtk/src/app_window.rs b/otg/gtk/src/app_window.rs index 8526cd6..ca92ac6 100644 --- a/otg/gtk/src/app_window.rs +++ b/otg/gtk/src/app_window.rs @@ -17,6 +17,8 @@ You should have received a copy of the GNU General Public License along with On use crate::{CoreApi, ResourceManager}; use adw::prelude::*; +use glib::Propagation; +use gtk::{gdk::Key, EventControllerKey}; use otg_core::{ settings::{SettingsRequest, SettingsResponse}, CoreRequest, CoreResponse, GameReviewViewModel, @@ -102,6 +104,22 @@ impl AppWindow { layout.append(&header); layout.append(&game_review.widget()); + // This controller ensures that navigational keypresses get sent to the game review so that + // they're not changing the cursor focus in the app. + let keypress_controller = EventControllerKey::new(); + keypress_controller.connect_key_pressed({ + move |s, key, _, _| { + println!("layout keypress: {}", key); + if s.forward(&game_review.widget()) { + Propagation::Stop + } else { + Propagation::Proceed + } + } + }); + + layout.add_controller(keypress_controller); + let page = adw::NavigationPage::builder() .can_pop(true) .title("Game Review") diff --git a/otg/gtk/src/components/goban.rs b/otg/gtk/src/components/goban.rs index 5b2e524..a68b2d2 100644 --- a/otg/gtk/src/components/goban.rs +++ b/otg/gtk/src/components/goban.rs @@ -101,6 +101,12 @@ impl Goban { s } + pub fn set_board_state(&mut self, board_state: otg_core::Goban) { + println!("updating board state"); + *self.imp().board_state.borrow_mut() = board_state; + self.queue_draw(); + } + fn redraw(&self, ctx: &cairo::Context, width: i32, height: i32) { println!("{} x {}", width, height); /* diff --git a/otg/gtk/src/views/game_review.rs b/otg/gtk/src/views/game_review.rs index 510e586..54f6e86 100644 --- a/otg/gtk/src/views/game_review.rs +++ b/otg/gtk/src/views/game_review.rs @@ -22,8 +22,14 @@ You should have received a copy of the GNU General Public License along with On // I'll get all of the information about the game from the core, and then render everything in the // UI. So this will be a heavy lift on the UI side. -use crate::{components::{Goban, PlayerCard, ReviewTree}, ResourceManager}; -use gtk::{prelude::*}; +use std::{cell::RefCell, rc::Rc}; + +use crate::{ + components::{Goban, PlayerCard, ReviewTree}, + ResourceManager, +}; +use glib::Propagation; +use gtk::{gdk::Key, prelude::*, EventControllerKey}; use otg_core::{Color, GameReviewViewModel}; /* @@ -57,23 +63,54 @@ impl GameReview { } */ +#[derive(Clone)] pub struct GameReview { widget: gtk::Box, + goban: Rc>>, resources: ResourceManager, - view: GameReviewViewModel, + view: Rc>, } impl GameReview { pub fn new(view: GameReviewViewModel, resources: ResourceManager) -> Self { let widget = gtk::Box::builder().build(); + let view = Rc::new(RefCell::new(view)); + let s = Self { widget, + goban: Rc::new(RefCell::new(None)), resources, view, }; + let keypress_controller = EventControllerKey::new(); + keypress_controller.connect_key_pressed({ + let s = s.clone(); + move |_, key, _, _| { + println!("keystroke: {}", key); + let mut view = s.view.borrow_mut(); + match key { + Key::Down => view.next_move(), + Key::Up => view.previous_move(), + Key::Left => view.previous_variant(), + Key::Right => view.next_variant(), + _ => { + return Propagation::Proceed; + } + } + + match *s.goban.borrow_mut() { + Some(ref mut goban) => goban.set_board_state(view.game_view()), + None => {} + }; + Propagation::Stop + } + }); + + s.widget.add_controller(keypress_controller); + s.render(); s @@ -83,7 +120,7 @@ impl GameReview { // It's actually really bad to be just throwing away errors. Panics make everyone unhappy. // This is not a fatal error, so I'll replace this `unwrap` call with something that // renders the board and notifies the user of a problem that cannot be resolved. - let board_repr = self.view.game_view(); + let board_repr = self.view.borrow().game_view(); let board = Goban::new(board_repr, self.resources.clone()); /* @@ -101,7 +138,7 @@ impl GameReview { // The review tree needs to know the record for being able to render all of the nodes. Once // keyboard input is being handled, the tree will have to be updated on each keystroke in // order to show the user where they are within the game record. - let review_tree = ReviewTree::new(self.view.clone()); + let review_tree = ReviewTree::new(self.view.borrow().clone()); // I think most keyboard focus is going to end up being handled here in GameReview, as // keystrokes need to affect both the goban and the review tree simultanesouly. Possibly @@ -114,14 +151,19 @@ impl GameReview { .build(); player_information_section - .append(&PlayerCard::new(Color::Black, &self.view.black_player())); + .append(&PlayerCard::new(Color::Black, &self.view.borrow().black_player())); player_information_section - .append(&PlayerCard::new(Color::White, &self.view.white_player())); + .append(&PlayerCard::new(Color::White, &self.view.borrow().white_player())); self.widget.append(&board); sidebar.append(&player_information_section); sidebar.append(&review_tree.widget()); self.widget.append(&sidebar); + + *self.goban.borrow_mut() = Some(board); + } + + fn redraw(&self) { } pub fn widget(&self) -> gtk::Widget { diff --git a/sgf/src/lib.rs b/sgf/src/lib.rs index 9fc15ca..b49a060 100644 --- a/sgf/src/lib.rs +++ b/sgf/src/lib.rs @@ -4,7 +4,7 @@ mod game; pub use game::{GameNode, GameRecord, GameTree, MoveNode, Player}; mod parser; -pub use parser::{parse_collection, Move}; +pub use parser::{parse_collection, Move, Size}; mod types; pub use types::*; diff --git a/sgf/src/parser.rs b/sgf/src/parser.rs index d395dd1..b3dbd25 100644 --- a/sgf/src/parser.rs +++ b/sgf/src/parser.rs @@ -302,10 +302,11 @@ impl Move { Move::Move(s) => { if s.len() == 2 { let mut parts = s.chars(); - let row_char = parts.next().unwrap(); - let row = row_char as u8 - b'a'; let column_char = parts.next().unwrap(); let column = column_char as u8 - b'a'; + let row_char = parts.next().unwrap(); + let row = row_char as u8 - b'a'; + println!("[{}] {} [{}] {}", row_char, row, column_char, column); Some((row, column)) } else { unimplemented!("moves must contain exactly two characters");