Implement game tree navigation #237

Merged
savanni merged 6 commits from savanni/tree-navigation into main 2024-05-23 13:04:25 +00:00
7 changed files with 271 additions and 32 deletions
Showing only changes of commit 7dd531b493 - Show all commits

View File

@ -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,

View File

@ -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<NodeId>,
game: GameRecord,
review_tree: DepthTree,
@ -35,7 +37,7 @@ struct GameReviewViewModelPrivate {
#[derive(Clone)]
pub struct GameReviewViewModel {
imp: Arc<RwLock<GameReviewViewModelPrivate>>,
inner: Arc<RwLock<GameReviewViewModelPrivate>>,
}
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<NodeId> = 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<F>(&self, f: F)
where
F: Fn(NodeRef<'_, SizeNode>, Option<NodeId>),
{
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<F>(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
);
}
}
}
});
}
}

View File

@ -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")

View File

@ -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);
/*

View File

@ -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<RefCell<Option<Goban>>>,
resources: ResourceManager,
view: GameReviewViewModel,
view: Rc<RefCell<GameReviewViewModel>>,
}
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 {

View File

@ -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::*;

View File

@ -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");