Compare commits

...

40 Commits

Author SHA1 Message Date
Savanni D'Gerinel b9425af234 Fix merge errors 2023-08-29 23:05:37 -04:00
Savanni D'Gerinel 4bd6388913 Tweak the configuration 2023-08-29 23:03:36 -04:00
Savanni D'Gerinel 8dad470440 Recreate a game from SGF 2023-08-29 23:03:09 -04:00
Savanni D'Gerinel e0fb7714d0 Cleanups 2023-08-29 23:03:09 -04:00
Savanni D'Gerinel b866249c9d Overhaul the sgf representation 2023-08-29 23:03:06 -04:00
Savanni D'Gerinel 2a6a5de5e1 Added the build scripts for kifu-gtk 2023-08-29 23:01:29 -04:00
Savanni D'Gerinel 1489121877 Get the width of the application back under control 2023-08-25 00:07:29 -04:00
Savanni D'Gerinel 562d4871a1 Create padding within the content view 2023-08-24 22:33:36 -04:00
Savanni D'Gerinel 16c8dcb682 Add a CSS stylesheet 2023-08-24 22:10:05 -04:00
Savanni D'Gerinel cc828c417a Change the layout/app_window to an ordinary object with necessary objects 2023-08-24 21:56:03 -04:00
Savanni D'Gerinel 784f3ff7f4 Be able to update the library path in the core 2023-08-24 20:52:27 -04:00
Savanni D'Gerinel 5439e2ac04 Set up a configuration UI 2023-08-24 20:24:41 -04:00
Savanni D'Gerinel 0bf6e079a2 Set up the configuration action 2023-08-23 17:51:51 -04:00
Savanni D'Gerinel 3998538e88 Set up the hamburger menu 2023-08-23 17:31:34 -04:00
Savanni D'Gerinel 793cd67218 Add a header bar and content field for applications 2023-08-23 15:57:09 -04:00
Savanni D'Gerinel ff13ff3c0e Update to a libadwaita app 2023-08-20 22:12:00 -04:00
Savanni D'Gerinel cc3ad372e6 Flip totally to a libadwaita program 2023-08-20 21:37:40 -04:00
Savanni D'Gerinel 3c063af525 Add the game result to the list of visible games 2023-08-20 13:17:54 -04:00
Savanni D'Gerinel aa64bf4c7e Remove the launch screen 2023-08-20 12:58:58 -04:00
Savanni D'Gerinel f75e0d4d65 Remove the library_view 2023-08-20 12:56:42 -04:00
Savanni D'Gerinel d8534a08eb Show the name of the game, and create one if it doesn't exist 2023-08-20 12:53:14 -04:00
Savanni D'Gerinel e5d0b7d20f Improve formatting. Rename GameDatabase to Library 2023-08-20 12:40:46 -04:00
Savanni D'Gerinel e9ffab1187 Construct a game preview component and render basic information into it 2023-08-20 12:31:44 -04:00
Savanni D'Gerinel a584fb4de3 Get the scrollbar to expand with the window 2023-08-19 23:45:19 -04:00
Savanni D'Gerinel e3f4ca246d Create the list of games 2023-08-19 23:24:01 -04:00
Savanni D'Gerinel 07b7351501 Flatten configuration by one level 2023-08-19 20:46:43 -04:00
Savanni D'Gerinel 70a295d4b1 Start combining the new game and library views 2023-08-19 20:09:50 -04:00
Savanni D'Gerinel 5478d388cb Set up a reflowing layout for the cards 2023-08-19 19:52:01 -04:00
Savanni D'Gerinel e203b17c8b Try to set up a title bar 2023-08-19 19:52:01 -04:00
Savanni D'Gerinel 69583dfd64 Create placeholder elements for each playlist card 2023-08-19 19:52:01 -04:00
Savanni D'Gerinel d59c2585db Set up configuration 2023-08-19 19:52:01 -04:00
Savanni D'Gerinel a6fcbfac71 Make the main app window appear, start working on config 2023-08-19 19:52:01 -04:00
Savanni D'Gerinel 4f940099da Added the build scripts for kifu-gtk 2023-08-19 19:49:17 -04:00
Savanni D'Gerinel f4735dd16b Add some imports that the configuration library needs 2023-08-17 23:28:49 -04:00
Savanni D'Gerinel b662ac519a Start on a configuration library 2023-08-17 23:28:49 -04:00
Savanni D'Gerinel efec8dfe5a Convert the kifu config to the config crate 2023-08-17 23:28:49 -04:00
Savanni D'Gerinel 0765d94a5e Create a type-safe configuration library 2023-08-17 23:28:49 -04:00
Savanni D'Gerinel 40b33797f3 Start on a configuration library 2023-08-17 23:28:49 -04:00
Savanni D'Gerinel 24e88da8e2 Add a desktop file and a bundler 2023-08-17 20:54:05 -04:00
Savanni D'Gerinel 456d872b40 Fix end of month handling 2023-08-12 21:35:14 -04:00
52 changed files with 2099 additions and 860 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ target
node_modules node_modules
dist dist
result result
*.tgz

595
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,21 @@
[workspace] [workspace]
members = [ members = [
"changeset",
"config",
"config-derive",
"coordinates",
"cyberpunk-splash", "cyberpunk-splash",
"dashboard", "dashboard",
"emseries",
"flow",
"fluent-ergonomics", "fluent-ergonomics",
"geo-types", "geo-types",
"gm-control-panel",
"hex-grid",
"ifc", "ifc",
"kifu/core",
"kifu/gtk",
"memorycache", "memorycache",
"screenplay", "screenplay",
"emseries",
"coordinates",
"flow",
"sgf", "sgf",
"changeset",
"hex-grid",
] ]

View File

@ -3,19 +3,24 @@
set -euo pipefail set -euo pipefail
RUST_ALL_TARGETS=( RUST_ALL_TARGETS=(
"dashboard"
"ifc"
"memorycache"
"geo-types"
"fluent-ergonomics"
"cyberpunk-splash"
"screenplay"
"emseries"
"coordinates"
"flow"
"sgf"
"changeset" "changeset"
"config"
"config-derive"
"coordinates"
"cyberpunk-splash"
"dashboard"
"emseries"
"flow"
"fluent-ergonomics"
"geo-types"
"gm-control-panel"
"hex-grid" "hex-grid"
"ifc"
"kifu-core"
"kifu-gtk"
"memorycache"
"screenplay"
"sgf"
) )
build_rust_targets() { build_rust_targets() {
@ -27,6 +32,16 @@ build_rust_targets() {
done done
} }
build_dist() {
local TARGETS=${@/$CMD}
for target in $TARGETS; do
if [ -f $target/dist.sh ]; then
cd $target && ./dist.sh
fi
done
}
export CARGO=`which cargo` export CARGO=`which cargo`
if [ -z "${TARGET-}" ]; then if [ -z "${TARGET-}" ]; then
@ -43,7 +58,9 @@ if [ "${CMD}" == "clean" ]; then
fi fi
for cmd in $CMD; do for cmd in $CMD; do
if [ "${TARGET}" == "all" ]; then if [ "${CMD}" == "dist" ]; then
build_dist $TARGET
elif [ "${TARGET}" == "all" ]; then
build_rust_targets $cmd ${RUST_ALL_TARGETS[*]} build_rust_targets $cmd ${RUST_ALL_TARGETS[*]}
else else
build_rust_targets $cmd $TARGET build_rust_targets $cmd $TARGET

14
config-derive/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "config-derive"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
proc-macro = true
[dependencies]
quote = { version = "1" }
syn = { version = "1", features = [ "extra-traits" ] }

23
config-derive/src/lib.rs Normal file
View File

@ -0,0 +1,23 @@
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(ConfigOption)]
pub fn derive(input: TokenStream) -> TokenStream {
let DeriveInput { ident, .. } = parse_macro_input!(input as DeriveInput);
let result = quote! {
impl From<&Config> for Option<#ident> {
fn from(config: &Config) -> Self {
match config.values.get(&ConfigName::#ident) {
Some(ConfigOption::#ident(val)) => Some(val.clone()),
_ => None,
}
}
}
};
result.into()
}

16
config/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "config"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
config-derive = { path = "../config-derive" }
serde_json = { version = "1" }
serde = { version = "1", features = [ "derive" ] }
thiserror = { version = "1" }
[dev-dependencies]
cool_asserts = { version = "2" }

160
config/src/lib.rs Normal file
View File

@ -0,0 +1,160 @@
/*
use std::{
collections::HashMap,
fs::File,
hash::Hash,
io::{ErrorKind, Read},
path::PathBuf,
};
*/
pub use config_derive::ConfigOption;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigReadError {
#[error("Cannot read the configuration file: {0}")]
CannotRead(std::io::Error),
#[error("Cannot open the configuration file for reading: {0}")]
CannotOpen(std::io::Error),
#[error("Invalid json data found in the configurationfile: {0}")]
InvalidJSON(serde_json::Error),
}
#[macro_export]
macro_rules! define_config {
($($name:ident($struct:ident),)+) => (
#[derive(Clone, Debug, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ConfigName {
$($name),+
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum ConfigOption {
$($name($struct)),+
}
#[derive(Clone, Debug)]
pub struct Config {
values: std::collections::HashMap<ConfigName, ConfigOption>,
}
impl Config {
pub fn new() -> Self {
Self {
values: std::collections::HashMap::new(),
}
}
pub fn from_path(config_path: std::path::PathBuf) -> Result<Self, $crate::ConfigReadError> {
let mut settings = config_path.clone();
settings.push("config");
match std::fs::File::open(settings) {
Ok(mut file) => {
let mut buf = String::new();
std::io::Read::read_to_string(&mut file, &mut buf)
.map_err(|err| $crate::ConfigReadError::CannotRead(err))?;
let values = serde_json::from_str(buf.as_ref())
.map_err(|err| $crate::ConfigReadError::InvalidJSON(err))?;
Ok(Self {
values,
})
}
Err(io_err) => {
match io_err.kind() {
std::io::ErrorKind::NotFound => {
/* create the path and an empty file */
Ok(Self {
values: std::collections::HashMap::new(),
})
}
_ => Err($crate::ConfigReadError::CannotOpen(io_err)),
}
}
}
}
pub fn set(&mut self, val: ConfigOption) {
let _ = match val {
$(ConfigOption::$struct(_) => self.values.insert(ConfigName::$name, val)),+
};
}
pub fn get<'a, T>(&'a self) -> Option<T>
where
Option<T>: From<&'a Self>,
{
self.into()
}
}
)
}
#[cfg(test)]
mod test {
use super::*;
use cool_asserts::assert_matches;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
define_config! {
DatabasePath(DatabasePath),
Me(Me),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(PathBuf);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
enum Rank {
Kyu(i8),
Dan(i8),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ConfigOption)]
pub struct Me {
name: String,
rank: Option<Rank>,
}
#[test]
fn it_can_set_and_get_options() {
let mut config: Config = Config::new();
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
"./fixtures/five_games",
))));
assert_eq!(
Some(DatabasePath(PathBuf::from("./fixtures/five_games"))),
config.get()
);
}
#[test]
fn it_can_serialize_and_deserialize() {
let mut config = Config::new();
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
"fixtures/five_games",
))));
config.set(ConfigOption::Me(Me {
name: "Savanni".to_owned(),
rank: Some(Rank::Kyu(10)),
}));
let s = serde_json::to_string(&config.values).unwrap();
println!("{}", s);
let values: HashMap<ConfigName, ConfigOption> = serde_json::from_str(s.as_ref()).unwrap();
println!("options: {:?}", values);
assert_matches!(values.get(&ConfigName::DatabasePath),
Some(ConfigOption::DatabasePath(ref db_path)) =>
assert_eq!(Some(db_path.clone()), config.get())
);
assert_matches!(values.get(&ConfigName::Me), Some(ConfigOption::Me(val)) =>
assert_eq!(Some(val.clone()), config.get())
);
}
}

View File

@ -0,0 +1,6 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=dashboard
Comment=My personal system dashboard
Exec=dashboard

11
dashboard/dist.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
set -x
mkdir -p dist
cp dashboard.desktop dist
cp ../target/release/dashboard dist
strip dist/dashboard
tar -cf dashboard.tgz dist/

View File

@ -0,0 +1,23 @@
[package]
name = "gm-control-panel"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2", "gtk_v4_6" ] }
config = { path = "../config" }
config-derive = { path = "../config-derive" }
futures = { version = "0.3" }
gio = { version = "0.17" }
glib = { version = "0.17" }
gdk = { version = "0.6", package = "gdk4" }
gtk = { version = "0.6", package = "gtk4", features = [ "v4_6" ] }
serde = { version = "1" }
serde_json = { version = "*" }
tokio = { version = "1", features = ["full"] }
[build-dependencies]
glib-build-tools = "0.16"

View File

@ -0,0 +1,7 @@
fn main() {
glib_build_tools::compile_resources(
"resources",
"resources/gresources.xml",
"com.luminescent-dreams.gm-control-panel.gresource",
);
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/com/luminescent-dreams/gm-control-panel/">
<file>style.css</file>
</gresource>
</gresources>

View File

@ -0,0 +1,6 @@
.playlist-card {
margin: 8px;
padding: 8px;
min-width: 100px;
min-height: 100px;
}

View File

@ -0,0 +1,64 @@
use crate::PlaylistCard;
use adw::prelude::AdwApplicationWindowExt;
use gio::resources_lookup_data;
use gtk::{prelude::*, STYLE_PROVIDER_PRIORITY_USER};
use std::iter::Iterator;
#[derive(Clone)]
pub struct ApplicationWindow {
pub window: adw::ApplicationWindow,
pub layout: gtk::FlowBox,
pub playlists: Vec<PlaylistCard>,
}
impl ApplicationWindow {
pub fn new(app: &adw::Application) -> Self {
let window = adw::ApplicationWindow::builder()
.application(app)
.title("GM-control-panel")
.width_request(500)
.build();
let stylesheet = String::from_utf8(
resources_lookup_data(
"/com/luminescent-dreams/gm-control-panel/style.css",
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet should just be available")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
let context = window.style_context();
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let layout = gtk::FlowBox::new();
let playlists: Vec<PlaylistCard> = vec![
"Creepy Cathedral",
"Joyful Tavern",
"Exploring",
"Out on the streets",
"The North Abbey",
]
.into_iter()
.map(|name| {
let playlist = PlaylistCard::new();
playlist.set_name(name);
playlist
})
.collect();
playlists.iter().for_each(|card| layout.append(card));
window.set_content(Some(&layout));
Self {
window,
layout,
playlists,
}
}
}

View File

@ -0,0 +1,40 @@
use config::define_config;
use config_derive::ConfigOption;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
define_config! {
Language(Language),
MusicPath(MusicPath),
PlaylistDatabasePath(PlaylistDatabasePath),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct Language(String);
impl std::ops::Deref for Language {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct MusicPath(PathBuf);
impl std::ops::Deref for MusicPath {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct PlaylistDatabasePath(PathBuf);
impl std::ops::Deref for PlaylistDatabasePath {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@ -0,0 +1,59 @@
use glib::{Continue, Sender};
use gtk::prelude::*;
use std::{
env,
sync::{Arc, RwLock},
};
mod app_window;
use app_window::ApplicationWindow;
mod config;
mod playlist_card;
use playlist_card::PlaylistCard;
mod types;
use types::PlaybackState;
#[derive(Clone, Debug)]
pub enum Message {}
#[derive(Clone)]
pub struct Core {
tx: Arc<RwLock<Option<Sender<Message>>>>,
}
pub fn main() {
gio::resources_register_include!("com.luminescent-dreams.gm-control-panel.gresource")
.expect("Failed to register resource");
let app = adw::Application::builder()
.application_id("com.luminescent-dreams.gm-control-panel")
.build();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let core = Core {
tx: Arc::new(RwLock::new(None)),
};
app.connect_activate(move |app| {
let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
*core.tx.write().unwrap() = Some(gtk_tx);
let window = ApplicationWindow::new(app);
window.window.present();
gtk_rx.attach(None, move |_msg| Continue(true));
});
let args: Vec<String> = env::args().collect();
ApplicationExtManual::run_with_args(&app, &args);
runtime.shutdown_background();
}

View File

@ -0,0 +1,54 @@
use crate::PlaybackState;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
pub struct PlaylistCardPrivate {
name: gtk::Label,
playing: gtk::Label,
}
impl Default for PlaylistCardPrivate {
fn default() -> Self {
Self {
name: gtk::Label::new(None),
playing: gtk::Label::new(Some("Stopped")),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistCardPrivate {
const NAME: &'static str = "PlaylistCard";
type Type = PlaylistCard;
type ParentType = gtk::Box;
}
impl ObjectImpl for PlaylistCardPrivate {}
impl WidgetImpl for PlaylistCardPrivate {}
impl BoxImpl for PlaylistCardPrivate {}
glib::wrapper! {
pub struct PlaylistCard(ObjectSubclass<PlaylistCardPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl PlaylistCard {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.add_css_class("playlist-card");
s.add_css_class("card");
s.append(&s.imp().name);
s.append(&s.imp().playing);
s
}
pub fn set_name(&self, s: &str) {
self.imp().name.set_text(s);
}
pub fn set_playback(&self, s: PlaybackState) {
self.imp().playing.set_text(&format!("{}", s))
}
}

View File

@ -0,0 +1,16 @@
use std::fmt;
#[derive(Clone, Debug)]
pub enum PlaybackState {
Stopped,
Playing,
}
impl fmt::Display for PlaybackState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Stopped => write!(f, "Stopped"),
Self::Playing => write!(f, "Playing"),
}
}
}

View File

@ -250,8 +250,12 @@ impl From<chrono::NaiveDate> for IFC {
{ {
days = days - 1; days = days - 1;
} }
let month: u8 = (days / 28).try_into().unwrap(); let mut month: u8 = (days / 28).try_into().unwrap();
let day: u8 = (days % 28).try_into().unwrap(); let mut day: u8 = (days % 28).try_into().unwrap();
if day == 0 {
month = month - 1;
day = 28;
}
Self::Day(Day { Self::Day(Day {
year: date.year(), year: date.year(),
month: month + 1, month: month + 1,
@ -268,167 +272,6 @@ impl From<IFC> for chrono::NaiveDate {
} }
} }
/*
impl IFC {
pub fn year_day(year: u32) -> Self {
Self {
year,
ordinal: if is_leap_year(year) { 366 } else { 365 }
}
}
}
*/
/*
impl From<chrono::Date<chrono::Utc>> for IFC {
fn from(d: chrono::Date<chrono::Utc>) -> IFC {
IFC::from(d.naive_utc())
}
}
impl From<chrono::Date<chrono_tz::Tz>> for IFC {
fn from(d: chrono::Date<chrono_tz::Tz>) -> IFC {
IFC::from(d.naive_utc())
}
}
impl From<chrono::NaiveDate> for IFC {
fn from(d: NaiveDate) -> IFC {
//println!("d: {} [{}]", d.format("%Y-%m-%d"), d.ordinal());
IFC {
year: (d.year() + 10000) as u32,
ordinal: d.ordinal0(),
leap_year: is_leap_year(d.year()),
}
}
}
*/
/*
impl Datelike for IFC {
fn year(&self) -> i32 {
// self.year as i32
unimplemented!()
}
fn month(&self) -> u32 {
// self.month0() + 1
unimplemented!()
}
fn month0(&self) -> u32 {
/*
if self.leap_year && self.ordinal == 365 {
12
} else if self.leap_year && self.ordinal == 168 {
5
} else {
self.ordinal / 28
}
*/
unimplemented!()
}
fn day(&self) -> u32 {
// self.day0() + 1
unimplemented!()
}
fn day0(&self) -> u32 {
/*
if self.leap_year {
if self.ordinal == 365 {
28
} else if self.ordinal == 168 {
28
} else if self.ordinal > 168 {
(self.ordinal - 1).rem_euclid(28) as u32
} else {
self.ordinal.rem_euclid(28) as u32
}
} else {
if self.ordinal == 364 {
28
} else {
self.ordinal.rem_euclid(28) as u32
}
}
*/
unimplemented!()
}
fn ordinal(&self) -> u32 {
// self.ordinal + 1
unimplemented!()
}
fn ordinal0(&self) -> u32 {
// self.ordinal
unimplemented!()
}
fn weekday(&self) -> chrono::Weekday {
if self.day0() == 28 {
chrono::Weekday::Sat
} else {
match self.day0().rem_euclid(7) {
0 => chrono::Weekday::Sun,
1 => chrono::Weekday::Mon,
2 => chrono::Weekday::Tue,
3 => chrono::Weekday::Wed,
4 => chrono::Weekday::Thu,
5 => chrono::Weekday::Fri,
6 => chrono::Weekday::Sat,
_ => panic!("rem_euclid should not return anything outside the 0..6 range"),
}
}
}
fn iso_week(&self) -> chrono::IsoWeek {
panic!("iso_week is not implemented because chrono does not expose any constructors for IsoWeek!");
}
fn with_year(&self, year: i32) -> Option<IFC> {
/*
Some(IFC {
year: (year as u32) + 10000,
ordinal: self.ordinal,
leap_year: is_leap_year(year),
})
*/
unimplemented!()
}
fn with_month(&self, month: u32) -> Option<IFC> {
// Some(IFC::ymd(self.year, month as u8, self.day() as u8))
unimplemented!()
}
fn with_month0(&self, month: u32) -> Option<IFC> {
// Some(IFC::ymd(self.year, month as u8 + 1, self.day() as u8))
unimplemented!()
}
fn with_day(&self, day: u32) -> Option<IFC> {
// Some(IFC::ymd(self.year, self.month() as u8, day as u8))
unimplemented!()
}
fn with_day0(&self, day: u32) -> Option<IFC> {
// Some(IFC::ymd(self.year, self.month() as u8, day as u8 + 1))
unimplemented!()
}
fn with_ordinal(&self, ordinal: u32) -> Option<IFC> {
/*
Some(IFC {
year: self.year,
ordinal,
leap_year: self.leap_year,
})
*/
unimplemented!()
}
fn with_ordinal0(&self, ordinal: u32) -> Option<IFC> {
/*
Some(IFC {
year: self.year,
ordinal: ordinal + 1,
leap_year: self.leap_year,
})
*/
unimplemented!()
}
}
*/
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -612,10 +455,34 @@ mod tests {
IFC::from(NaiveDate::from_ymd_opt(12022, 1, 1).unwrap()), IFC::from(NaiveDate::from_ymd_opt(12022, 1, 1).unwrap()),
IFC::ymd(12022, 1, 1).unwrap() IFC::ymd(12022, 1, 1).unwrap()
); );
assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 1, 2).unwrap()),
IFC::ymd(12022, 1, 2).unwrap()
);
assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 1, 3).unwrap()),
IFC::ymd(12022, 1, 3).unwrap()
);
assert_eq!(
IFC::from(chrono::NaiveDate::from_ymd_opt(2023, 01, 26).unwrap()),
IFC::ymd(2023, 1, 26).unwrap()
);
assert_eq!(
IFC::from(chrono::NaiveDate::from_ymd_opt(2023, 01, 27).unwrap()),
IFC::ymd(2023, 1, 27).unwrap()
);
assert_eq!(
IFC::from(chrono::NaiveDate::from_ymd_opt(2023, 01, 28).unwrap()),
IFC::ymd(2023, 1, 28).unwrap()
);
assert_eq!( assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 1, 29).unwrap()), IFC::from(NaiveDate::from_ymd_opt(12022, 1, 29).unwrap()),
IFC::ymd(12022, 2, 1).unwrap() IFC::ymd(12022, 2, 1).unwrap()
); );
assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 1, 30).unwrap()),
IFC::ymd(12022, 2, 2).unwrap()
);
assert_eq!( assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 2, 26).unwrap()), IFC::from(NaiveDate::from_ymd_opt(12022, 2, 26).unwrap()),
IFC::ymd(12022, 3, 1).unwrap() IFC::ymd(12022, 3, 1).unwrap()
@ -644,6 +511,10 @@ mod tests {
IFC::from(NaiveDate::from_ymd_opt(12022, 8, 13).unwrap()), IFC::from(NaiveDate::from_ymd_opt(12022, 8, 13).unwrap()),
IFC::ymd(12022, 9, 1).unwrap() IFC::ymd(12022, 9, 1).unwrap()
); );
assert_eq!(
IFC::from(chrono::NaiveDate::from_ymd_opt(2023, 08, 12).unwrap()),
IFC::ymd(2023, 8, 28).unwrap()
);
assert_eq!( assert_eq!(
IFC::from(NaiveDate::from_ymd_opt(12022, 9, 10).unwrap()), IFC::from(NaiveDate::from_ymd_opt(12022, 9, 10).unwrap()),
IFC::ymd(12022, 10, 1).unwrap() IFC::ymd(12022, 10, 1).unwrap()

View File

@ -7,6 +7,8 @@ edition = "2021"
[dependencies] [dependencies]
chrono = { version = "0.4" } chrono = { version = "0.4" }
config = { path = "../../config" }
config-derive = { path = "../../config-derive" }
sgf = { path = "../../sgf" } sgf = { path = "../../sgf" }
grid = { version = "0.9" } grid = { version = "0.9" }
serde_json = { version = "1" } serde_json = { version = "1" }

View File

@ -7,8 +7,7 @@
;W[fd] ;W[fd]
;B[qf] ;B[qf]
;W[qh] ;W[qh]
(;B[pf] (;B[of]
)(;B[of]
;W[nd] ;W[nd]
;B[mf] ;B[mf]
;W[pk] ;W[pk]
@ -221,4 +220,5 @@
;B[ai] ;B[ai]
;W[qg] ;W[qg]
;B[pf] ;B[pf]
)(;B[pf]
)) ))

View File

@ -1,23 +1,34 @@
use crate::{ use crate::{
types::{AppState, GameState, Player, Rank}, types::{AppState, Config, ConfigOption, DatabasePath, GameState, Player, Rank},
ui::{home, playing_field, HomeView, PlayingFieldView}, ui::{configuration, home, playing_field, ConfigurationView, HomeView, PlayingFieldView},
Config, DatabasePath,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock}; use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use typeshare::typeshare; use typeshare::typeshare;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare] #[typeshare]
#[serde(tag = "type", content = "content")] #[serde(tag = "type", content = "content")]
pub enum CoreRequest { pub enum CoreRequest {
ChangeSetting(ChangeSettingRequest),
CreateGame(CreateGameRequest), CreateGame(CreateGameRequest),
Home, Home,
OpenConfiguration,
PlayingField, PlayingField,
PlayStone(PlayStoneRequest), PlayStone(PlayStoneRequest),
StartGame, StartGame,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum ChangeSettingRequest {
LibraryPath(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare] #[typeshare]
pub struct PlayStoneRequest { pub struct PlayStoneRequest {
@ -58,46 +69,42 @@ impl From<HotseatPlayerRequest> for Player {
#[typeshare] #[typeshare]
#[serde(tag = "type", content = "content")] #[serde(tag = "type", content = "content")]
pub enum CoreResponse { pub enum CoreResponse {
ConfigurationView(ConfigurationView),
HomeView(HomeView), HomeView(HomeView),
PlayingFieldView(PlayingFieldView), PlayingFieldView(PlayingFieldView),
UpdatedConfigurationView(ConfigurationView),
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CoreApp { pub struct CoreApp {
config: Config, config: Arc<RwLock<Config>>,
state: Arc<RwLock<AppState>>, state: Arc<RwLock<AppState>>,
} }
impl CoreApp { impl CoreApp {
pub fn new(config_path: std::path::PathBuf) -> Self { pub fn new(config_path: std::path::PathBuf) -> Self {
println!("config_path: {:?}", config_path);
let config = Config::from_path(config_path).expect("configuration to open"); let config = Config::from_path(config_path).expect("configuration to open");
let db_path: DatabasePath = config.get(); let db_path: DatabasePath = config.get().unwrap();
let state = Arc::new(RwLock::new(AppState::new(db_path))); let state = Arc::new(RwLock::new(AppState::new(db_path)));
println!("config: {:?}", config); Self {
println!("games database: {:?}", state.read().unwrap().database.len()); config: Arc::new(RwLock::new(config)),
state,
Self { config, state } }
} }
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse { pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
match request { match request {
/* CoreRequest::ChangeSetting(request) => match request {
CoreRequest::LaunchScreen => { ChangeSettingRequest::LibraryPath(path) => {
let app_state = self.state.read().unwrap(); let mut config = self.config.write().unwrap();
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
At launch, I want to either show a list of games in progress, the current game, or the game creation screen. path,
- if a live game is in progress, immmediately go to that game. Such a game will be classified at game creation, so it should be persisted to the state. ))));
- if no live games are in progress, but there are slow games in progress, show a list of the slow games and let the player choose which one to jump into. CoreResponse::UpdatedConfigurationView(configuration(&config))
- if no games are in progress, show only the game creation screen }
- game creation menu should be present both when there are only slow games and when there are no games },
- the UI returned here will always be available in other places, such as when the user is viewing a game and wants to return to this page
For the initial version, I want only to show the game creation screen. Then I will backtrack record application state so that the only decisions can be made.
}
*/
CoreRequest::CreateGame(create_request) => { CoreRequest::CreateGame(create_request) => {
let mut app_state = self.state.write().unwrap(); let mut app_state = self.state.write().unwrap();
let white_player = { let white_player = {
@ -121,6 +128,9 @@ impl CoreApp {
CoreRequest::Home => { CoreRequest::Home => {
CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games())) CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
} }
CoreRequest::OpenConfiguration => {
CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
}
CoreRequest::PlayingField => { CoreRequest::PlayingField => {
let app_state = self.state.read().unwrap(); let app_state = self.state.read().unwrap();
let game = app_state.game.as_ref().unwrap(); let game = app_state.game.as_ref().unwrap();

View File

@ -1,3 +1,5 @@
use sgf::{go::Game, parse_sgf};
use crate::{BoardError, Color, Size}; use crate::{BoardError, Color, Size};
use std::collections::HashSet; use std::collections::HashSet;
@ -77,6 +79,41 @@ pub struct Coordinate {
pub row: u8, pub row: u8,
} }
impl Coordinate {
fn from_sgf(s: &str) -> Self {
fn parse(s: char) -> u8 {
match s {
'a' => 0,
'b' => 1,
'c' => 2,
'd' => 3,
'e' => 4,
'f' => 5,
'g' => 6,
'h' => 7,
'i' => 8,
'j' => 9,
'k' => 10,
'l' => 11,
'm' => 12,
'n' => 13,
'o' => 14,
'p' => 15,
'q' => 16,
'r' => 17,
's' => 18,
_ => panic!("invalid character in the SGF coordinates"),
}
}
let s = s.chars().collect::<Vec<char>>();
Coordinate {
column: parse(s[0]),
row: parse(s[1]),
}
}
}
impl Board { impl Board {
pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> { pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> {
if let Some(_) = self.stone(&coordinate) { if let Some(_) = self.stone(&coordinate) {
@ -123,17 +160,17 @@ impl Board {
.map(|g| g.color) .map(|g| g.color)
} }
pub fn group(&self, coordinate: &Coordinate) -> Option<&Group> { fn group(&self, coordinate: &Coordinate) -> Option<&Group> {
self.groups self.groups
.iter() .iter()
.find(|g| g.coordinates.contains(coordinate)) .find(|g| g.coordinates.contains(coordinate))
} }
pub fn remove_group(&mut self, group: &Group) { fn remove_group(&mut self, group: &Group) {
self.groups.retain(|g| g != group); self.groups.retain(|g| g != group);
} }
pub fn adjacent_groups(&self, group: &Group) -> Vec<Group> { fn adjacent_groups(&self, group: &Group) -> Vec<Group> {
let adjacent_spaces = self.group_halo(group).into_iter(); let adjacent_spaces = self.group_halo(group).into_iter();
let mut grps: Vec<Group> = Vec::new(); let mut grps: Vec<Group> = Vec::new();
@ -153,7 +190,7 @@ impl Board {
grps grps
} }
pub fn group_halo(&self, group: &Group) -> HashSet<Coordinate> { fn group_halo(&self, group: &Group) -> HashSet<Coordinate> {
group group
.coordinates .coordinates
.iter() .iter()
@ -162,14 +199,14 @@ impl Board {
.collect::<HashSet<Coordinate>>() .collect::<HashSet<Coordinate>>()
} }
pub fn liberties(&self, group: &Group) -> usize { fn liberties(&self, group: &Group) -> usize {
self.group_halo(group) self.group_halo(group)
.into_iter() .into_iter()
.filter(|c| self.stone(&c) == None) .filter(|c| self.stone(&c) == None)
.count() .count()
} }
pub fn adjacencies(&self, coordinate: &Coordinate) -> Vec<Coordinate> { fn adjacencies(&self, coordinate: &Coordinate) -> Vec<Coordinate> {
let mut v = Vec::new(); let mut v = Vec::new();
if coordinate.column > 0 { if coordinate.column > 0 {
v.push(Coordinate { v.push(Coordinate {
@ -194,7 +231,7 @@ impl Board {
v.into_iter().filter(|c| self.within_board(c)).collect() v.into_iter().filter(|c| self.within_board(c)).collect()
} }
pub fn within_board(&self, coordinate: &Coordinate) -> bool { fn within_board(&self, coordinate: &Coordinate) -> bool {
coordinate.column < self.size.width && coordinate.row < self.size.height coordinate.column < self.size.width && coordinate.row < self.size.height
} }
} }
@ -211,9 +248,37 @@ impl Group {
} }
} }
impl TryFrom<&Game> for Board {
type Error = BoardError;
fn try_from(record: &Game) -> Result<Self, Self::Error> {
let mut board = Board::new();
let mut root = Some(&record.root);
while let Some(node) = root {
match (node.find_prop("B"), node.find_prop("W")) {
(Some(prop), _) => {
let coordinate = Coordinate::from_sgf(prop.values[0].as_ref());
board = board.place_stone(coordinate, Color::Black)?;
}
(None, Some(prop)) => {
let coordinate = Coordinate::from_sgf(prop.values[0].as_ref());
board = board.place_stone(coordinate, Color::White)?;
}
(None, None) => (),
};
root = node.next()
}
Ok(board)
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::{fs::File, io::Read};
use super::*; use super::*;
use cool_asserts::assert_matches;
use sgf::{parse_sgf, Game};
/* Two players (Black and White) take turns and Black plays first /* Two players (Black and White) take turns and Black plays first
* Stones are placed on the line intersections and not moved. * Stones are placed on the line intersections and not moved.
@ -225,6 +290,25 @@ mod test {
* A stone placed in a suicidal position is legal if it captures other stones first. * A stone placed in a suicidal position is legal if it captures other stones first.
*/ */
fn with_text(text: &str, f: impl FnOnce(Vec<sgf::go::Game>)) {
let games = parse_sgf(text)
.unwrap()
.into_iter()
.filter_map(|game| match game {
Game::Go(g) => Some(g),
Game::Unsupported(_) => None,
})
.collect::<Vec<sgf::go::Game>>();
f(games);
}
fn with_file(path: &std::path::Path, f: impl FnOnce(Vec<sgf::go::Game>)) {
let mut file = File::open(path).unwrap();
let mut text = String::new();
let _ = file.read_to_string(&mut text);
with_text(&text, f);
}
fn with_example_board(test: impl FnOnce(Board)) { fn with_example_board(test: impl FnOnce(Board)) {
let board = Board::from_coordinates( let board = Board::from_coordinates(
vec![ vec![
@ -630,4 +714,46 @@ mod test {
assert_eq!(board, b2); assert_eq!(board, b2);
} }
#[test]
fn loads_board_from_sgf() {
with_file(
std::path::Path::new("fixtures/five_games/2022.10.05.sgf"),
|trees| {
let game = &trees[0];
let board = Board::try_from(game).expect("game to be valid");
assert_eq!(
board.stone(&Coordinate { column: 14, row: 5 }),
Some(Color::Black)
);
/* This game has a fork in the game tree here. This block verifies that we have
* reached the move right before the fork. */
let mut node = &game.root;
for _ in 0..8 {
node = node.next().unwrap();
}
let prop = assert_matches!(node.find_prop("W"), Some(prop) => prop);
assert_eq!(
prop,
sgf::Property {
ident: "W".to_owned(),
values: vec!["qh".to_owned()]
}
);
/* And now we verify that we have gone down the leftmost side of the fork, which is
* traditionally the mainline of the game. */
let node = node.next().unwrap();
let prop = assert_matches!(node.find_prop("B"), Some(prop) => prop);
assert_eq!(
prop,
sgf::Property {
ident: "B".to_owned(),
values: vec!["of".to_owned()]
}
);
},
);
}
} }

View File

@ -1,198 +0,0 @@
use crate::types::Player;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs::File,
io::{ErrorKind, Read},
path::PathBuf,
};
use thiserror::Error;
/*
pub trait ConfigOption {
type Value;
}
pub struct DatabasePath(PathBuf);
impl ConfigOption for DatabasePath {
type Value = PathBuf;
}
impl ConfigOption for Player {
type Value = Player;
}
pub trait Config {
// fn set_option(option: ConfigOption);
fn get_option<N, C: ConfigOption>(name: Name) -> C<Name = Name>
}
*/
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
enum OptionNames {
DatabasePath,
Me,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConfigOption {
DatabasePath(DatabasePath),
Me(Me),
}
#[derive(Debug, Error)]
pub enum ConfigReadError {
#[error("Cannot read the configuration file: {0}")]
CannotRead(std::io::Error),
#[error("Cannot open the configuration file for reading: {0}")]
CannotOpen(std::io::Error),
#[error("Invalid json data found in the configurationfile: {0}")]
InvalidJSON(serde_json::Error),
}
#[derive(Clone, Debug)]
pub struct Config {
config_path: PathBuf,
values: HashMap<OptionNames, ConfigOption>,
}
impl Config {
pub fn new(config_path: PathBuf) -> Self {
Self {
config_path,
values: HashMap::new(),
}
}
pub fn from_path(config_path: PathBuf) -> Result<Self, ConfigReadError> {
let mut settings = config_path.clone();
settings.push("config");
match File::open(settings) {
Ok(mut file) => {
let mut buf = String::new();
file.read_to_string(&mut buf)
.map_err(|err| ConfigReadError::CannotRead(err))?;
let values = serde_json::from_str(buf.as_ref())
.map_err(|err| ConfigReadError::InvalidJSON(err))?;
Ok(Self {
config_path,
values,
})
}
Err(io_err) => {
match io_err.kind() {
ErrorKind::NotFound => {
/* create the path and an empty file */
Ok(Self {
config_path,
values: HashMap::new(),
})
}
_ => Err(ConfigReadError::CannotOpen(io_err)),
}
}
}
}
pub fn set(&mut self, val: ConfigOption) {
let _ = match val {
ConfigOption::DatabasePath(_) => self.values.insert(OptionNames::DatabasePath, val),
ConfigOption::Me(_) => self.values.insert(OptionNames::Me, val),
};
}
pub fn get<'a, T>(&'a self) -> T
where
T: From<&'a Self>,
{
self.into()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DatabasePath(PathBuf);
impl std::ops::Deref for DatabasePath {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&Config> for DatabasePath {
fn from(config: &Config) -> Self {
match config.values.get(&OptionNames::DatabasePath) {
Some(ConfigOption::DatabasePath(path)) => path.clone(),
_ => DatabasePath(config.config_path.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Me(Player);
impl From<&Config> for Option<Me> {
fn from(config: &Config) -> Self {
config
.values
.get(&OptionNames::Me)
.and_then(|val| match val {
ConfigOption::Me(me) => Some(me.clone()),
_ => None,
})
}
}
impl std::ops::Deref for Me {
type Target = Player;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::types::Rank;
use cool_asserts::assert_matches;
#[test]
fn it_can_set_and_get_options() {
let mut config = Config::new(PathBuf::from("."));
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
"fixtures/five_games",
))));
config.set(ConfigOption::Me(Me(Player {
name: "Savanni".to_owned(),
rank: Some(Rank::Kyu(10)),
})));
}
#[test]
fn it_can_serialize_and_deserialize() {
let mut config = Config::new(PathBuf::from("."));
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
"fixtures/five_games",
))));
config.set(ConfigOption::Me(Me(Player {
name: "Savanni".to_owned(),
rank: Some(Rank::Kyu(10)),
})));
let s = serde_json::to_string(&config.values).unwrap();
println!("{}", s);
let values: HashMap<OptionNames, ConfigOption> = serde_json::from_str(s.as_ref()).unwrap();
println!("options: {:?}", values);
assert_matches!(values.get(&OptionNames::DatabasePath),
Some(ConfigOption::DatabasePath(db_path)) =>
assert_eq!(*db_path, config.get())
);
assert_matches!(values.get(&OptionNames::Me), Some(ConfigOption::Me(val)) =>
assert_eq!(Some(val.clone()), config.get())
);
}
}

View File

@ -39,11 +39,16 @@ impl Database {
.unwrap() .unwrap()
.read_to_string(&mut buffer) .read_to_string(&mut buffer)
.unwrap(); .unwrap();
for sgf in parse_sgf(&buffer).unwrap() { match parse_sgf(&buffer) {
match sgf { Ok(sgfs) => {
Game::Go(game) => games.push(game), for sgf in sgfs {
Game::Unsupported(_) => {} match sgf {
Game::Go(game) => games.push(game),
Game::Unsupported(_) => {}
}
}
} }
Err(err) => println!("Error parsing {:?}: {:?}", entry.path(), err),
} }
} }
} }
@ -88,7 +93,7 @@ mod test {
assert_eq!(game.info.black_player, Some("Steve".to_owned())); assert_eq!(game.info.black_player, Some("Steve".to_owned()));
assert_eq!(game.info.white_player, Some("Savanni".to_owned())); assert_eq!(game.info.white_player, Some("Savanni".to_owned()));
assert_eq!(game.info.date, vec![Date::Date(chrono::NaiveDate::from_ymd_opt(2023, 4, 19).unwrap())]); assert_eq!(game.info.date, vec![Date::Date(chrono::NaiveDate::from_ymd_opt(2023, 4, 19).unwrap())]);
assert_eq!(game.info.komi, Some(6.5)); // assert_eq!(game.info.komi, Some(6.5));
} }
); );
} }

View File

@ -1,13 +1,19 @@
#[macro_use]
extern crate config_derive;
mod api; mod api;
pub use api::{ pub use api::{
CoreApp, CoreRequest, CoreResponse, CreateGameRequest, HotseatPlayerRequest, PlayerInfoRequest, ChangeSettingRequest, CoreApp, CoreRequest, CoreResponse, CreateGameRequest,
HotseatPlayerRequest, PlayerInfoRequest,
}; };
mod board; mod board;
pub use board::*; pub use board::*;
/*
mod config; mod config;
pub use config::*; pub use config::*;
*/
mod database; mod database;

View File

@ -1,14 +1,40 @@
use crate::{ use crate::{
api::PlayStoneRequest, api::PlayStoneRequest,
board::{Board, Coordinate}, board::{Board, Coordinate},
config::DatabasePath,
database::Database, database::Database,
}; };
use config::define_config;
use config_derive::ConfigOption;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
use thiserror::Error; use thiserror::Error;
use typeshare::typeshare; use typeshare::typeshare;
define_config! {
DatabasePath(DatabasePath),
Me(Me),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(pub PathBuf);
impl std::ops::Deref for DatabasePath {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct Me(Player);
impl std::ops::Deref for Me {
type Target = Player;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, PartialEq, Error)] #[derive(Debug, PartialEq, Error)]
pub enum BoardError { pub enum BoardError {
#[error("Position is invalid")] #[error("Position is invalid")]

View File

@ -0,0 +1,24 @@
use crate::{
types::{Config, DatabasePath},
ui::Field,
};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct ConfigurationView {
pub library: Field<()>,
}
pub fn configuration(config: &Config) -> ConfigurationView {
let path: Option<DatabasePath> = config.get();
ConfigurationView {
library: Field {
id: "library-path-field".to_owned(),
label: "Library".to_owned(),
value: path.map(|path| path.to_string_lossy().into_owned()),
action: (),
},
}
}

View File

@ -1,10 +0,0 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Action<A> {
pub id: String,
pub label: String,
pub action: A,
}

View File

@ -1,36 +1,70 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sgf::{ use sgf::go::{Game, GameResult, Win};
go::{Game, Rank},
Date,
};
use typeshare::typeshare; use typeshare::typeshare;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[typeshare] #[typeshare]
pub struct GamePreviewElement { pub struct GamePreviewElement {
pub date: Vec<Date>, pub date: String,
pub name: String,
pub black_player: String, pub black_player: String,
pub black_rank: Option<Rank>,
pub white_player: String, pub white_player: String,
pub white_rank: Option<Rank>, pub result: String,
} }
impl GamePreviewElement { impl GamePreviewElement {
pub fn new(game: &Game) -> GamePreviewElement { pub fn new(game: &Game) -> GamePreviewElement {
let black_player = match game.info.black_player {
Some(ref black_player) => black_player.clone(),
None => "unknown".to_owned(),
};
let white_player = match game.info.white_player {
Some(ref white_player) => white_player.clone(),
None => "unknown".to_owned(),
};
let black_player = match game.info.black_rank {
Some(rank) => format!("{} ({})", black_player, rank.to_string()),
None => black_player,
};
let white_player = match game.info.white_rank {
Some(rank) => format!("{} ({})", white_player, rank.to_string()),
None => white_player,
};
let name = match game.info.game_name {
Some(ref name) => name.clone(),
None => format!("{} vs. {}", black_player, white_player),
};
let format_win = |win: &Win| match win {
Win::Resignation => "Resignation".to_owned(),
Win::Time => "Timeout".to_owned(),
Win::Forfeit => "Forfeit".to_owned(),
Win::Score(score) => format!("{:.1}", score),
};
let result = match game.info.result {
Some(GameResult::Annulled) => "Annulled".to_owned(),
Some(GameResult::Draw) => "Draw".to_owned(),
Some(GameResult::Black(ref win)) => format!("Black by {}", format_win(win)),
Some(GameResult::White(ref win)) => format!("White by {}", format_win(win)),
Some(GameResult::Unknown(ref text)) => format!("Unknown: {}", text),
None => "".to_owned(),
};
GamePreviewElement { GamePreviewElement {
date: game.info.date.clone(), date: game
black_player: game
.info .info
.black_player .date
.clone() .first()
.unwrap_or("black_player".to_owned()), .map(|dt| dt.to_string())
black_rank: game.info.black_rank.clone(), .unwrap_or("".to_owned()),
white_player: game name,
.info black_player,
.white_player white_player,
.clone() result,
.unwrap_or("white_player".to_owned()),
white_rank: game.info.white_rank.clone(),
} }
} }
} }

View File

@ -1,3 +1,31 @@
pub mod action; use serde::{Deserialize, Serialize};
use typeshare::typeshare;
pub mod game_preview; pub mod game_preview;
pub mod menu; pub mod menu;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Action<A> {
pub id: String,
pub label: String,
pub action: A,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Toggle<A> {
pub id: String,
pub label: String,
pub value: bool,
pub action: A,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[typeshare]
pub struct Field<A> {
pub id: String,
pub label: String,
pub value: Option<String>,
pub action: A,
}

View File

@ -1,14 +0,0 @@
use crate::{
ui::types;
};
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum LaunchScreenView {
CreateGame(CreateGameView)
}
// This will be called when the Kifu application starts.
pub fn launch_screen() -> LaunchScreenView {
}

View File

@ -1,12 +1,12 @@
mod configuration;
pub use configuration::{configuration, ConfigurationView};
mod elements; mod elements;
pub use elements::{action::Action, game_preview::GamePreviewElement, menu::Menu}; pub use elements::{game_preview::GamePreviewElement, menu::Menu, Action, Field, Toggle};
mod playing_field; mod playing_field;
pub use playing_field::{playing_field, PlayingFieldView}; pub use playing_field::{playing_field, PlayingFieldView};
// mod launch_screen;
// pub use launch_screen::{launch_screen, LaunchScreenView};
mod home; mod home;
pub use home::{home, HomeView, HotseatPlayerElement, PlayerElement}; pub use home::{home, HomeView, HotseatPlayerElement, PlayerElement};

View File

@ -9,24 +9,26 @@ screenplay = []
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.17" } cairo-rs = { version = "0.17" }
gio = { version = "0.17" } gio = { version = "0.17" }
glib = { version = "0.17" } glib = { version = "0.17" }
gtk = { version = "0.6", package = "gtk4" } gtk = { version = "0.6", package = "gtk4", features = [ "v4_8" ] }
image = { version = "0.24" } image = { version = "0.24" }
kifu-core = { path = "../core" } kifu-core = { path = "../core" }
pango = { version = "*" }
sgf = { path = "../../sgf" }
tokio = { version = "1.26", features = [ "full" ] } tokio = { version = "1.26", features = [ "full" ] }
screenplay = { path = "../../screenplay" }
[build-dependencies] [build-dependencies]
glib-build-tools = "0.17" glib-build-tools = "0.17"
[[bin]] # [[bin]]
name = "kifu-gtk" # name = "kifu-gtk"
path = "src/main.rs" # path = "src/main.rs"
[[bin]] # [[bin]]
name = "screenplay" # name = "screenplay"
path = "src/bin/screenplay.rs" # path = "src/bin/screenplay.rs"
required-features = [ "screenplay" ] # required-features = [ "screenplay" ]

View File

@ -1,7 +1,7 @@
fn main() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(
&["resources"], &["resources"],
"resources/resources.gresources.xml", "resources/gresources.xml",
"com.luminescent-dreams.kifu-gtk.gresource", "com.luminescent-dreams.kifu-gtk.gresource",
); );
} }

View File

@ -1 +1,8 @@
{"Me":{"name":"Savanni","rank":{"Kyu":10}},"DatabasePath":"../core/fixtures/five_games"} <<<<<<< HEAD
{
"Me":{
"name":"Savanni",
"rank":{"Kyu":10}
},
"DatabasePath": "kifu/core/fixtures/five_games/"
}

View File

@ -2,5 +2,6 @@
<gresources> <gresources>
<gresource prefix="/com/luminescent-dreams/kifu-gtk/"> <gresource prefix="/com/luminescent-dreams/kifu-gtk/">
<file>wood_texture.jpg</file> <file>wood_texture.jpg</file>
<file>style.css</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -0,0 +1,3 @@
.content {
padding: 8px;
}

View File

@ -1,20 +1,28 @@
use gtk::prelude::*; use adw::prelude::*;
use kifu_core::{CoreApp, CoreRequest, CoreResponse}; use kifu_core::{CoreApp, CoreRequest, CoreResponse};
use kifu_gtk::{ use kifu_gtk::{
perftrace, perftrace,
ui::{Home, PlayingField}, ui::{AppWindow, ConfigurationPage, Home, PlayingField},
CoreApi, CoreApi,
}; };
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
fn handle_response(api: CoreApi, window: gtk::ApplicationWindow, message: CoreResponse) { fn handle_response(api: CoreApi, app_window: &AppWindow, message: CoreResponse) {
let playing_field = Arc::new(RwLock::new(None)); let playing_field = Arc::new(RwLock::new(None));
match message { match message {
CoreResponse::ConfigurationView(view) => perftrace("ConfigurationView", || {
let config_page = ConfigurationPage::new(api, view);
let window = adw::PreferencesWindow::new();
window.add(&config_page);
window.set_visible_page(&config_page);
window.present();
}),
CoreResponse::HomeView(view) => perftrace("HomeView", || { CoreResponse::HomeView(view) => perftrace("HomeView", || {
let api = api.clone(); let api = api.clone();
let new_game = Home::new(api, view); let home = Home::new(api, view);
window.set_child(Some(&new_game)); app_window.set_content(&home);
}), }),
CoreResponse::PlayingFieldView(view) => perftrace("PlayingFieldView", || { CoreResponse::PlayingFieldView(view) => perftrace("PlayingFieldView", || {
let api = api.clone(); let api = api.clone();
@ -23,13 +31,16 @@ fn handle_response(api: CoreApi, window: gtk::ApplicationWindow, message: CoreRe
if playing_field.is_none() { if playing_field.is_none() {
perftrace("creating a new playing field", || { perftrace("creating a new playing field", || {
let field = PlayingField::new(api, view); let field = PlayingField::new(api, view);
window.set_child(Some(&field)); app_window.set_content(&field);
*playing_field = Some(field); *playing_field = Some(field);
}) })
} else { } else {
playing_field.as_ref().map(|field| field.update_view(view)); playing_field.as_ref().map(|field| field.update_view(view));
} }
}), }),
CoreResponse::UpdatedConfigurationView(view) => perftrace("UpdatedConfiguration", || {
println!("updated configuration: {:?}", view);
}),
} }
} }
@ -65,8 +76,9 @@ fn main() {
} }
}); });
let app = gtk::Application::builder() let app = adw::Application::builder()
.application_id("com.luminescent-dreams.kifu-gtk") .application_id("com.luminescent-dreams.kifu-gtk")
.resource_base_path("/com/luminescent-dreams/kifu-gtk")
.build(); .build();
app.connect_activate({ app.connect_activate({
@ -75,20 +87,30 @@ fn main() {
let (gtk_tx, gtk_rx) = let (gtk_tx, gtk_rx) =
gtk::glib::MainContext::channel::<CoreResponse>(gtk::glib::PRIORITY_DEFAULT); gtk::glib::MainContext::channel::<CoreResponse>(gtk::glib::PRIORITY_DEFAULT);
let app_window = AppWindow::new(&app);
let api = CoreApi { let api = CoreApi {
gtk_tx, gtk_tx,
rt: runtime.clone(), rt: runtime.clone(),
core: core.clone(), core: core.clone(),
}; };
let window = gtk::ApplicationWindow::new(app); let action_config = gio::SimpleAction::new("show-config", None);
window.present(); action_config.connect_activate({
let api = api.clone();
move |_, _| {
api.dispatch(CoreRequest::OpenConfiguration);
}
});
app.add_action(&action_config);
app_window.window.present();
gtk_rx.attach(None, { gtk_rx.attach(None, {
let api = api.clone(); let api = api.clone();
move |message| { move |message| {
perftrace("handle_response", || { perftrace("handle_response", || {
handle_response(api.clone(), window.clone(), message) handle_response(api.clone(), &app_window, message)
}); });
Continue(true) Continue(true)
} }

52
kifu/gtk/src/ui/config.rs Normal file
View File

@ -0,0 +1,52 @@
use crate::CoreApi;
use adw::{prelude::*, subclass::prelude::*};
use glib::Object;
use kifu_core::{ui::ConfigurationView, ChangeSettingRequest, CoreRequest};
#[derive(Default)]
pub struct ConfigurationPagePrivate {}
#[glib::object_subclass]
impl ObjectSubclass for ConfigurationPagePrivate {
const NAME: &'static str = "Configuration";
type Type = ConfigurationPage;
type ParentType = adw::PreferencesPage;
}
impl ObjectImpl for ConfigurationPagePrivate {}
impl WidgetImpl for ConfigurationPagePrivate {}
impl PreferencesPageImpl for ConfigurationPagePrivate {}
glib::wrapper! {
pub struct ConfigurationPage(ObjectSubclass<ConfigurationPagePrivate>)
@extends adw::PreferencesPage, gtk::Widget,
@implements gtk::Orientable;
}
impl ConfigurationPage {
pub fn new(api: CoreApi, view: ConfigurationView) -> Self {
let s: Self = Object::builder().build();
let group = adw::PreferencesGroup::builder().build();
let library_entry = &adw::EntryRow::builder()
.name("library-path")
.title(view.library.label)
.show_apply_button(true)
.build();
if let Some(path) = view.library.value {
library_entry.set_text(&path);
}
library_entry.connect_apply(move |entry| {
api.dispatch(CoreRequest::ChangeSetting(
ChangeSettingRequest::LibraryPath(entry.text().into()),
));
});
group.add(library_entry);
s.add(&group);
s
}
}

View File

@ -3,7 +3,13 @@ use gtk::{glib, prelude::*, subclass::prelude::*};
use kifu_core::ui::GamePreviewElement; use kifu_core::ui::GamePreviewElement;
#[derive(Default)] #[derive(Default)]
pub struct GamePreviewPrivate; pub struct GamePreviewPrivate {
date: gtk::Label,
title: gtk::Label,
black_player: gtk::Label,
white_player: gtk::Label,
result: gtk::Label,
}
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for GamePreviewPrivate { impl ObjectSubclass for GamePreviewPrivate {
@ -21,22 +27,26 @@ glib::wrapper! {
} }
impl GamePreview { impl GamePreview {
pub fn new(element: GamePreviewElement) -> GamePreview { pub fn new() -> GamePreview {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Horizontal); s.set_orientation(gtk::Orientation::Horizontal);
s.set_homogeneous(true);
s.set_hexpand(false);
println!("game_preview: {:?}", element); s.append(&s.imp().date);
let black_player = match element.black_rank { s.append(&s.imp().title);
Some(rank) => format!("{} ({})", element.black_player, rank.to_string()), s.append(&s.imp().black_player);
None => element.black_player, s.append(&s.imp().white_player);
}; s.append(&s.imp().result);
let white_player = match element.white_rank {
Some(rank) => format!("{} ({})", element.white_player, rank.to_string()),
None => element.white_player,
};
s.append(&gtk::Label::new(Some(&black_player)));
s.append(&gtk::Label::new(Some(&white_player)));
s s
} }
pub fn set_game(&self, element: GamePreviewElement) {
self.imp().black_player.set_text(&element.black_player);
self.imp().white_player.set_text(&element.white_player);
self.imp().title.set_text(&element.name);
self.imp().date.set_text(&element.date);
self.imp().result.set_text(&element.result);
}
} }

View File

@ -1,5 +1,4 @@
use crate::ui::GamePreview; use crate::{ui::Library, CoreApi};
use crate::CoreApi;
use glib::Object; use glib::Object;
use gtk::{glib, prelude::*, subclass::prelude::*}; use gtk::{glib, prelude::*, subclass::prelude::*};
use kifu_core::{ use kifu_core::{
@ -101,31 +100,54 @@ impl Default for HomePrivate {
impl ObjectSubclass for HomePrivate { impl ObjectSubclass for HomePrivate {
const NAME: &'static str = "Home"; const NAME: &'static str = "Home";
type Type = Home; type Type = Home;
type ParentType = gtk::Grid; type ParentType = gtk::Box;
} }
impl ObjectImpl for HomePrivate {} impl ObjectImpl for HomePrivate {}
impl WidgetImpl for HomePrivate {} impl WidgetImpl for HomePrivate {}
impl GridImpl for HomePrivate {} impl BoxImpl for HomePrivate {}
glib::wrapper! { glib::wrapper! {
pub struct Home(ObjectSubclass<HomePrivate>) @extends gtk::Grid, gtk::Widget; pub struct Home(ObjectSubclass<HomePrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
} }
impl Home { impl Home {
pub fn new(api: CoreApi, view: HomeView) -> Home { pub fn new(api: CoreApi, view: HomeView) -> Home {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
s.set_spacing(4);
s.set_homogeneous(false);
s.set_orientation(gtk::Orientation::Vertical);
let players = gtk::Box::builder()
.spacing(4)
.orientation(gtk::Orientation::Horizontal)
.build();
s.append(&players);
let black_player = PlayerDataEntry::new(view.black_player); let black_player = PlayerDataEntry::new(view.black_player);
s.attach(&black_player, 1, 1, 1, 1); players.append(&black_player);
*s.imp().black_player.borrow_mut() = Some(black_player.clone()); *s.imp().black_player.borrow_mut() = Some(black_player.clone());
let white_player = PlayerDataEntry::new(view.white_player); let white_player = PlayerDataEntry::new(view.white_player);
s.attach(&white_player, 2, 1, 1, 1); players.append(&white_player);
*s.imp().white_player.borrow_mut() = Some(white_player.clone()); *s.imp().white_player.borrow_mut() = Some(white_player.clone());
let new_game_button = gtk::Button::builder().label(&view.start_game.label).build(); let new_game_button = gtk::Button::builder()
s.attach(&new_game_button, 2, 2, 1, 1); .css_classes(vec!["suggested-action"])
.label(&view.start_game.label)
.build();
s.append(&new_game_button);
let library = Library::new();
let library_view = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.min_content_width(360)
.vexpand(true)
.hexpand(true)
.child(&library)
.build();
s.append(&library_view);
library.set_games(view.games);
new_game_button.connect_clicked({ new_game_button.connect_clicked({
move |_| { move |_| {
@ -139,12 +161,6 @@ impl Home {
} }
}); });
let game_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
s.attach(&game_list, 1, 3, 2, 1);
view.games
.iter()
.for_each(|game_preview| game_list.append(&GamePreview::new(game_preview.clone())));
s s
} }
} }

166
kifu/gtk/src/ui/library.rs Normal file
View File

@ -0,0 +1,166 @@
use crate::ui::GamePreview;
use adw::{prelude::*, subclass::prelude::*};
use glib::Object;
use gtk::{glib, prelude::*, subclass::prelude::*};
use kifu_core::ui::GamePreviewElement;
use std::{cell::RefCell, rc::Rc};
#[derive(Default)]
pub struct GameObjectPrivate {
game: Rc<RefCell<Option<GamePreviewElement>>>,
}
#[glib::object_subclass]
impl ObjectSubclass for GameObjectPrivate {
const NAME: &'static str = "GameObject";
type Type = GameObject;
}
impl ObjectImpl for GameObjectPrivate {}
glib::wrapper! {
pub struct GameObject(ObjectSubclass<GameObjectPrivate>);
}
impl GameObject {
pub fn new(game: GamePreviewElement) -> Self {
let s: Self = Object::builder().build();
*s.imp().game.borrow_mut() = Some(game);
s
}
pub fn game(&self) -> Option<GamePreviewElement> {
self.imp().game.borrow().clone()
}
}
pub struct LibraryPrivate {
model: gio::ListStore,
list_view: gtk::ColumnView,
}
impl Default for LibraryPrivate {
fn default() -> Self {
let vector: Vec<GameObject> = vec![];
let model = gio::ListStore::new(glib::types::Type::OBJECT);
model.extend_from_slice(&vector);
/*
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |_, list_item| {
let preview = GamePreview::new();
list_item
.downcast_ref::<gtk::ListItem>()
.expect("Needs to be a ListItem")
.set_child(Some(&preview));
});
factory.connect_bind(move |_, list_item| {
let game_element = list_item
.downcast_ref::<gtk::ListItem>()
.expect("Needs to be ListItem")
.item()
.and_downcast::<GameObject>()
.expect("The item has to be a GameObject.");
let preview = list_item
.downcast_ref::<gtk::ListItem>()
.expect("Needs to be ListItem")
.child()
.and_downcast::<GamePreview>()
.expect("The child has to be a GamePreview object.");
match game_element.game() {
Some(game) => preview.set_game(game),
None => (),
};
});
*/
let selection_model = gtk::NoSelection::new(Some(model.clone()));
let list_view = gtk::ColumnView::builder().model(&selection_model).build();
fn make_factory<F>(bind: F) -> gtk::SignalListItemFactory
where
F: Fn(GamePreviewElement) -> String + 'static,
{
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, list_item| {
list_item
.downcast_ref::<gtk::ListItem>()
.unwrap()
.set_child(Some(
&gtk::Label::builder()
.halign(gtk::Align::Start)
.ellipsize(pango::EllipsizeMode::End)
.build(),
))
});
factory.connect_bind(move |_, list_item| {
let list_item = list_item.downcast_ref::<gtk::ListItem>().unwrap();
let game = list_item.item().and_downcast::<GameObject>().unwrap();
let preview = list_item.child().and_downcast::<gtk::Label>().unwrap();
match game.game() {
Some(game) => preview.set_text(&bind(game)),
None => (),
};
});
factory
}
list_view.append_column(&gtk::ColumnViewColumn::new(
Some("date"),
Some(make_factory(|g| g.date)),
));
list_view.append_column(&gtk::ColumnViewColumn::new(
Some("title"),
Some(make_factory(|g| g.name)),
));
list_view.append_column(&gtk::ColumnViewColumn::new(
Some("black"),
Some(make_factory(|g| g.black_player)),
));
list_view.append_column(&gtk::ColumnViewColumn::new(
Some("white"),
Some(make_factory(|g| g.white_player)),
));
list_view.append_column(&gtk::ColumnViewColumn::new(
Some("result"),
Some(make_factory(|g| g.result)),
));
Self { model, list_view }
}
}
#[glib::object_subclass]
impl ObjectSubclass for LibraryPrivate {
const NAME: &'static str = "Library";
type Type = Library;
type ParentType = adw::Bin;
}
impl ObjectImpl for LibraryPrivate {}
impl WidgetImpl for LibraryPrivate {}
impl BinImpl for LibraryPrivate {}
glib::wrapper! {
pub struct Library(ObjectSubclass<LibraryPrivate>) @extends adw::Bin, gtk::Widget;
}
impl Library {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_child(Some(&s.imp().list_view));
s
}
pub fn set_games(&self, games: Vec<GamePreviewElement>) {
let games = games
.into_iter()
.map(|g| GameObject::new(g))
.collect::<Vec<GameObject>>();
self.imp().model.extend_from_slice(&games);
}
}

View File

@ -1,9 +1,20 @@
use adw::prelude::*;
use gio::resources_lookup_data;
use glib::IsA;
use gtk::{prelude::*, STYLE_PROVIDER_PRIORITY_USER};
mod chat; mod chat;
pub use chat::Chat; pub use chat::Chat;
mod config;
pub use config::ConfigurationPage;
mod game_preview; mod game_preview;
pub use game_preview::GamePreview; pub use game_preview::GamePreview;
mod library;
pub use library::Library;
mod player_card; mod player_card;
pub use player_card::PlayerCard; pub use player_card::PlayerCard;
@ -18,3 +29,72 @@ pub use board::Board;
#[cfg(feature = "screenplay")] #[cfg(feature = "screenplay")]
pub use playing_field::playing_field_view; pub use playing_field::playing_field_view;
pub struct AppWindow {
pub window: adw::ApplicationWindow,
pub header: adw::HeaderBar,
pub content: adw::Bin,
}
impl AppWindow {
pub fn new(app: &adw::Application) -> Self {
let window = adw::ApplicationWindow::builder()
.application(app)
.width_request(800)
.height_request(500)
.build();
let stylesheet = String::from_utf8(
resources_lookup_data(
"/com/luminescent-dreams/kifu-gtk/style.css",
gio::ResourceLookupFlags::NONE,
)
.expect("stylesheet should just be available")
.to_vec(),
)
.expect("to parse stylesheet");
let provider = gtk::CssProvider::new();
provider.load_from_data(&stylesheet);
let context = window.style_context();
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
let header = adw::HeaderBar::builder()
.title_widget(&gtk::Label::new(Some("Kifu")))
.build();
let app_menu = gio::Menu::new();
let menu_item = gio::MenuItem::new(Some("Configuration"), Some("app.show-config"));
app_menu.append_item(&menu_item);
let hamburger = gtk::MenuButton::builder()
.icon_name("open-menu-symbolic")
.build();
hamburger.set_menu_model(Some(&app_menu));
header.pack_end(&hamburger);
let content = adw::Bin::builder().css_classes(vec!["content"]).build();
content.set_child(Some(
&adw::StatusPage::builder().title("Nothing here").build(),
));
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
layout.append(&header);
layout.append(&content);
window.set_content(Some(&layout));
Self {
window,
header,
content,
}
}
pub fn set_content(&self, content: &impl IsA<gtk::Widget>) {
self.content.set_child(Some(content));
}
}

View File

@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.68.2" channel = "1.71.1"
targets = [ "wasm32-unknown-unknown" ] targets = [ "wasm32-unknown-unknown" ]

View File

@ -11,3 +11,6 @@ nom = { version = "7" }
serde = { version = "1", features = [ "derive" ] } serde = { version = "1", features = [ "derive" ] }
thiserror = { version = "1"} thiserror = { version = "1"}
typeshare = { version = "1" } typeshare = { version = "1" }
[dev-dependencies]
cool_asserts = { version = "2" }

View File

@ -24,6 +24,16 @@ pub enum Date {
Date(chrono::NaiveDate), Date(chrono::NaiveDate),
} }
impl Date {
pub fn to_string(&self) -> String {
match self {
Date::Year(y) => format!("{}", y),
Date::YearMonth(y, m) => format!("{}-{}", y, m),
Date::Date(date) => format!("{}-{}-{}", date.year(), date.month(), date.day()),
}
}
}
/* /*
impl TryFrom<&str> for Date { impl TryFrom<&str> for Date {
type Error = String; type Error = String;

View File

@ -74,6 +74,7 @@ use crate::{
Error, Error,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::ops::Deref;
use typeshare::typeshare; use typeshare::typeshare;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -84,11 +85,18 @@ pub struct Game {
pub tree: Tree, pub tree: Tree,
} }
impl Deref for Game {
type Target = Tree;
fn deref(&self) -> &Self::Target {
&self.tree
}
}
impl TryFrom<Tree> for Game { impl TryFrom<Tree> for Game {
type Error = Error; type Error = Error;
fn try_from(tree: Tree) -> Result<Self, Self::Error> { fn try_from(tree: Tree) -> Result<Self, Self::Error> {
let board_size = match tree.sequence[0].find_prop("SZ") { let board_size = match tree.root.find_prop("SZ") {
Some(prop) => Size::try_from(prop.values[0].as_str())?, Some(prop) => Size::try_from(prop.values[0].as_str())?,
None => Size { None => Size {
width: 19, width: 19,
@ -96,35 +104,37 @@ impl TryFrom<Tree> for Game {
}, },
}; };
let mut info = GameInfo::default(); let mut info = GameInfo::default();
info.app_name = tree.sequence[0] info.app_name = tree.root.find_prop("AP").map(|prop| prop.values[0].clone());
.find_prop("AP")
.map(|prop| prop.values[0].clone());
info.black_player = tree.sequence[0]
.find_prop("PB")
.map(|prop| prop.values.join(", "));
info.black_rank = tree.sequence[0] info.game_name = tree.root.find_prop("GN").map(|prop| prop.values[0].clone());
info.black_player = tree.root.find_prop("PB").map(|prop| prop.values.join(", "));
info.black_rank = tree
.root
.find_prop("BR") .find_prop("BR")
.and_then(|prop| Rank::try_from(prop.values[0].as_str()).ok()); .and_then(|prop| Rank::try_from(prop.values[0].as_str()).ok());
info.white_player = tree.sequence[0] info.white_player = tree.root.find_prop("PW").map(|prop| prop.values.join(", "));
.find_prop("PW")
.map(|prop| prop.values.join(", "));
info.white_rank = tree.sequence[0] info.white_rank = tree
.root
.find_prop("WR") .find_prop("WR")
.and_then(|prop| Rank::try_from(prop.values[0].as_str()).ok()); .and_then(|prop| Rank::try_from(prop.values[0].as_str()).ok());
info.result = tree.sequence[0] info.result = tree
.root
.find_prop("RE") .find_prop("RE")
.and_then(|prop| GameResult::try_from(prop.values[0].as_str()).ok()); .and_then(|prop| GameResult::try_from(prop.values[0].as_str()).ok());
info.time_limits = tree.sequence[0] info.time_limits = tree
.root
.find_prop("TM") .find_prop("TM")
.and_then(|prop| prop.values[0].parse::<u64>().ok()) .and_then(|prop| prop.values[0].parse::<u64>().ok())
.and_then(|seconds| Some(std::time::Duration::from_secs(seconds))); .and_then(|seconds| Some(std::time::Duration::from_secs(seconds)));
info.date = tree.sequence[0] info.date = tree
.root
.find_prop("DT") .find_prop("DT")
.and_then(|prop| { .and_then(|prop| {
let v = prop let v = prop
@ -144,21 +154,13 @@ impl TryFrom<Tree> for Game {
}) })
.unwrap_or(vec![]); .unwrap_or(vec![]);
info.event = tree.sequence[0] info.event = tree.root.find_prop("EV").map(|prop| prop.values.join(", "));
.find_prop("EV")
.map(|prop| prop.values.join(", "));
info.round = tree.sequence[0] info.round = tree.root.find_prop("RO").map(|prop| prop.values.join(", "));
.find_prop("RO")
.map(|prop| prop.values.join(", "));
info.source = tree.sequence[0] info.source = tree.root.find_prop("SO").map(|prop| prop.values.join(", "));
.find_prop("SO")
.map(|prop| prop.values.join(", "));
info.game_keeper = tree.sequence[0] info.game_keeper = tree.root.find_prop("US").map(|prop| prop.values.join(", "));
.find_prop("US")
.map(|prop| prop.values.join(", "));
Ok(Game { Ok(Game {
board_size, board_size,
@ -237,6 +239,7 @@ pub enum GameResult {
Draw, Draw,
Black(Win), Black(Win),
White(Win), White(Win),
Unknown(String),
} }
impl TryFrom<&str> for GameResult { impl TryFrom<&str> for GameResult {
@ -251,7 +254,7 @@ impl TryFrom<&str> for GameResult {
let res = match parts[0].to_ascii_lowercase().as_str() { let res = match parts[0].to_ascii_lowercase().as_str() {
"b" => GameResult::Black, "b" => GameResult::Black,
"w" => GameResult::White, "w" => GameResult::White,
_ => panic!("unknown result format"), _ => return Ok(GameResult::Unknown(parts[0].to_owned())),
}; };
match parts[1].to_ascii_lowercase().as_str() { match parts[1].to_ascii_lowercase().as_str() {
"r" | "resign" => Ok(res(Win::Resignation)), "r" | "resign" => Ok(res(Win::Resignation)),
@ -301,7 +304,7 @@ mod tests {
use super::*; use super::*;
use crate::{ use crate::{
date::Date, date::Date,
tree::{parse_collection, Size}, tree::{parse_collection, Property, Size},
}; };
use std::fs::File; use std::fs::File;
use std::io::Read; use std::io::Read;
@ -384,4 +387,123 @@ mod tests {
assert_eq!(tree.info.game_keeper, Some("Arno Hollosi".to_owned())); assert_eq!(tree.info.game_keeper, Some("Arno Hollosi".to_owned()));
}); });
} }
#[test]
fn it_presents_the_mainline_of_game_without_branches() {
with_file(
std::path::Path::new("test_data/2020 USGO DDK, Round 1.sgf"),
|trees| {
assert_eq!(trees.len(), 1);
let tree = &trees[0];
let node = &tree.root;
assert_eq!(node.properties.len(), 16);
let expected_properties = vec![
Property {
ident: "GM".to_owned(),
values: vec!["1".to_owned()],
},
Property {
ident: "FF".to_owned(),
values: vec!["4".to_owned()],
},
Property {
ident: "CA".to_owned(),
values: vec!["UTF-8".to_owned()],
},
Property {
ident: "AP".to_owned(),
values: vec!["CGoban:3".to_owned()],
},
Property {
ident: "ST".to_owned(),
values: vec!["2".to_owned()],
},
Property {
ident: "RU".to_owned(),
values: vec!["AGA".to_owned()],
},
Property {
ident: "SZ".to_owned(),
values: vec!["19".to_owned()],
},
Property {
ident: "KM".to_owned(),
values: vec!["7.50".to_owned()],
},
Property {
ident: "TM".to_owned(),
values: vec!["1800".to_owned()],
},
Property {
ident: "OT".to_owned(),
values: vec!["5x30 byo-yomi".to_owned()],
},
Property {
ident: "PW".to_owned(),
values: vec!["Geckoz".to_owned()],
},
Property {
ident: "PB".to_owned(),
values: vec!["savanni".to_owned()],
},
Property {
ident: "BR".to_owned(),
values: vec!["23k".to_owned()],
},
Property {
ident: "DT".to_owned(),
values: vec!["2020-08-05".to_owned()],
},
Property {
ident: "PC".to_owned(),
values: vec!["The KGS Go Server at http://www.gokgs.com/".to_owned()],
},
Property {
ident: "RE".to_owned(),
values: vec!["W+17.50".to_owned()],
},
];
for i in 0..16 {
assert_eq!(node.properties[i], expected_properties[i]);
}
let node = node.next().unwrap();
let expected_properties = vec![
Property {
ident: "B".to_owned(),
values: vec!["pp".to_owned()],
},
Property {
ident: "BL".to_owned(),
values: vec!["1795.449".to_owned()],
},
Property {
ident: "C".to_owned(),
values: vec!["Geckoz [?]: Good game\nsavanni [23k?]: There we go! This UI is... tough.\nsavanni [23k?]: Have fun! Talk to you at the end.\nGeckoz [?]: Yeah, OGS is much better; I'm a UX professional\n".to_owned()],
}
];
for i in 0..3 {
assert_eq!(node.properties[i], expected_properties[i]);
}
let node = node.next().unwrap();
let expected_properties = vec![
Property {
ident: "W".to_owned(),
values: vec!["dp".to_owned()],
},
Property {
ident: "WL".to_owned(),
values: vec!["1765.099".to_owned()],
},
];
for i in 0..2 {
assert_eq!(node.properties[i], expected_properties[i]);
}
},
);
}
} }

View File

@ -5,6 +5,7 @@ pub mod go;
mod tree; mod tree;
use tree::parse_collection; use tree::parse_collection;
pub use tree::Property;
use thiserror::Error; use thiserror::Error;
@ -65,7 +66,7 @@ pub fn parse_sgf(input: &str) -> Result<Vec<Game>, Error> {
let (_, trees) = parse_collection::<nom::error::VerboseError<&str>>(input)?; let (_, trees) = parse_collection::<nom::error::VerboseError<&str>>(input)?;
Ok(trees Ok(trees
.into_iter() .into_iter()
.map(|t| match t.sequence[0].find_prop("GM") { .map(|t| match t.root.find_prop("GM") {
Some(prop) if prop.values == vec!["1".to_owned()] => { Some(prop) if prop.values == vec!["1".to_owned()] => {
Game::Go(go::Game::try_from(t).expect("properly structured game tree")) Game::Go(go::Game::try_from(t).expect("properly structured game tree"))
} }

View File

@ -5,7 +5,6 @@ use nom::{
character::complete::{alpha1, digit1, multispace0, multispace1, none_of}, character::complete::{alpha1, digit1, multispace0, multispace1, none_of},
combinator::{opt, value}, combinator::{opt, value},
multi::{many0, many1, separated_list1}, multi::{many0, many1, separated_list1},
sequence::delimited,
IResult, IResult,
}; };
use std::num::ParseIntError; use std::num::ParseIntError;
@ -54,29 +53,19 @@ impl TryFrom<&str> for Size {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Tree { pub struct Tree {
pub sequence: Vec<Node>, pub root: Node,
pub sub_sequences: Vec<Tree>,
} }
impl ToString for Tree { impl ToString for Tree {
fn to_string(&self) -> String { fn to_string(&self) -> String {
let sequence = self format!("({})", self.root.to_string())
.sequence
.iter()
.map(|node| node.to_string())
.collect::<String>();
let subsequences = self
.sub_sequences
.iter()
.map(|seq| seq.to_string())
.collect::<String>();
format!("({}{})", sequence, subsequences)
} }
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Node { pub struct Node {
pub properties: Vec<Property>, pub properties: Vec<Property>,
pub next: Vec<Node>,
} }
impl ToString for Node { impl ToString for Node {
@ -86,7 +75,21 @@ impl ToString for Node {
.iter() .iter()
.map(|prop| prop.to_string()) .map(|prop| prop.to_string())
.collect::<String>(); .collect::<String>();
format!(";{}", props)
let next = if self.next.len() == 1 {
self.next
.iter()
.map(|node| node.to_string())
.collect::<Vec<String>>()
.join("")
} else {
self.next
.iter()
.map(|node| format!("({})", node.to_string()))
.collect::<Vec<String>>()
.join("")
};
format!(";{}{}", props, next)
} }
} }
@ -97,6 +100,10 @@ impl Node {
.find(|prop| prop.ident == ident) .find(|prop| prop.ident == ident)
.cloned() .cloned()
} }
pub fn next<'a>(&'a self) -> Option<&'a Node> {
self.next.get(0)
}
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@ -119,40 +126,40 @@ impl ToString for Property {
pub fn parse_collection<'a, E: nom::error::ParseError<&'a str>>( pub fn parse_collection<'a, E: nom::error::ParseError<&'a str>>(
input: &'a str, input: &'a str,
) -> IResult<&'a str, Vec<Tree>, E> { ) -> IResult<&'a str, Vec<Tree>, E> {
separated_list1(multispace1, parse_tree)(input) let (input, roots) = separated_list1(multispace1, parse_tree)(input)?;
let trees = roots
.into_iter()
.map(|root| Tree { root })
.collect::<Vec<Tree>>();
Ok((input, trees))
} }
// note: must preserve unknown properties // note: must preserve unknown properties
// note: must fix or preserve illegally formatted game-info properties // note: must fix or preserve illegally formatted game-info properties
// note: must correct or delete illegally foramtted properties, but display a warning // note: must correct or delete illegally foramtted properties, but display a warning
fn parse_tree<'a, E: nom::error::ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, Tree, E> { fn parse_tree<'a, E: nom::error::ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, Node, E> {
let (input, _) = multispace0(input)?; let (input, _) = multispace0(input)?;
delimited(tag("("), parse_sequence, tag(")"))(input) let (input, _) = tag("(")(input)?;
} let (input, node) = parse_node(input)?;
let (input, _) = multispace0(input)?;
let (input, _) = tag(")")(input)?;
fn parse_sequence<'a, E: nom::error::ParseError<&'a str>>( Ok((input, node))
input: &'a str,
) -> IResult<&'a str, Tree, E> {
let (input, _) = multispace0(input)?;
let (input, nodes) = many1(parse_node)(input)?;
let (input, _) = multispace0(input)?;
let (input, sub_sequences) = many0(parse_tree)(input)?;
let (input, _) = multispace0(input)?;
Ok((
input,
Tree {
sequence: nodes,
sub_sequences,
},
))
} }
fn parse_node<'a, E: nom::error::ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, Node, E> { fn parse_node<'a, E: nom::error::ParseError<&'a str>>(input: &'a str) -> IResult<&'a str, Node, E> {
let (input, _) = multispace0(input)?; let (input, _) = multispace0(input)?;
let (input, _) = tag(";")(input)?; let (input, _) = opt(tag(";"))(input)?;
let (input, properties) = many1(parse_property)(input)?; let (input, properties) = many1(parse_property)(input)?;
Ok((input, Node { properties }))
let (input, next) = opt(parse_node)(input)?;
let (input, mut next_seq) = many0(parse_tree)(input)?;
let mut next = next.map(|n| vec![n]).unwrap_or(vec![]);
next.append(&mut next_seq);
Ok((input, Node { properties, next }))
} }
fn parse_property<'a, E: nom::error::ParseError<&'a str>>( fn parse_property<'a, E: nom::error::ParseError<&'a str>>(
@ -219,8 +226,6 @@ pub fn parse_size<'a, E: nom::error::ParseError<&'a str>>(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::{fs::File, io::Read};
use super::*; use super::*;
const EXAMPLE: &'static str = "(;FF[4]C[root](;C[a];C[b](;C[c]) const EXAMPLE: &'static str = "(;FF[4]C[root](;C[a];C[b](;C[c])
@ -259,7 +264,8 @@ mod test {
properties: vec![Property { properties: vec![Property {
ident: "B".to_owned(), ident: "B".to_owned(),
values: vec!["ab".to_owned()] values: vec!["ab".to_owned()]
}] }],
next: vec![]
} }
); );
@ -273,6 +279,25 @@ mod test {
properties: vec![Property { properties: vec![Property {
ident: "B".to_owned(), ident: "B".to_owned(),
values: vec!["ab".to_owned()] values: vec!["ab".to_owned()]
}],
next: vec![Node {
properties: vec![Property {
ident: "W".to_owned(),
values: vec!["dp".to_owned()]
}],
next: vec![Node {
properties: vec![
Property {
ident: "B".to_owned(),
values: vec!["pq".to_owned()]
},
Property {
ident: "C".to_owned(),
values: vec!["some comments".to_owned()]
}
],
next: vec![],
}]
}] }]
} }
); );
@ -286,21 +311,17 @@ mod test {
assert_eq!( assert_eq!(
sequence, sequence,
Tree { Node {
sequence: vec![ properties: vec![Property {
Node { ident: "B".to_owned(),
properties: vec![Property { values: vec!["ab".to_owned()]
ident: "B".to_owned(), }],
values: vec!["ab".to_owned()] next: vec![Node {
}] properties: vec![Property {
}, ident: "W".to_owned(),
Node { values: vec!["dp".to_owned()]
properties: vec![Property { }],
ident: "W".to_owned(), next: vec![Node {
values: vec!["dp".to_owned()]
}]
},
Node {
properties: vec![ properties: vec![
Property { Property {
ident: "B".to_owned(), ident: "B".to_owned(),
@ -310,114 +331,158 @@ mod test {
ident: "C".to_owned(), ident: "C".to_owned(),
values: vec!["some comments".to_owned()] values: vec!["some comments".to_owned()]
} }
] ],
} next: vec![],
], }]
sub_sequences: vec![], }],
} },
); );
} }
#[test] #[test]
fn it_can_parse_a_sequence_with_subsequences() { fn it_can_parse_a_branching_sequence() {
let text = "(;C[a];C[b](;C[c])(;C[d];C[e]))"; let text = "(;C[a];C[b](;C[c])(;C[d];C[e]))";
let (_, sequence) = parse_tree::<nom::error::VerboseError<&str>>(text).unwrap(); let (_, tree) = parse_tree::<nom::error::VerboseError<&str>>(text).unwrap();
let main_sequence = vec![ let expected = Node {
Node { properties: vec![Property {
properties: vec![Property { ident: "C".to_owned(),
ident: "C".to_owned(), values: vec!["a".to_owned()],
values: vec!["a".to_owned()], }],
}], next: vec![Node {
},
Node {
properties: vec![Property { properties: vec![Property {
ident: "C".to_owned(), ident: "C".to_owned(),
values: vec!["b".to_owned()], values: vec!["b".to_owned()],
}], }],
}, next: vec![
]; Node {
let subsequence_1 = Tree { properties: vec![Property {
sequence: vec![Node { ident: "C".to_owned(),
properties: vec![Property { values: vec!["c".to_owned()],
ident: "C".to_owned(), }],
values: vec!["c".to_owned()], next: vec![],
}], },
Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["d".to_owned()],
}],
next: vec![Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["e".to_owned()],
}],
next: vec![],
}],
},
],
}], }],
sub_sequences: vec![],
};
let subsequence_2 = Tree {
sequence: vec![
Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["d".to_owned()],
}],
},
Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["e".to_owned()],
}],
},
],
sub_sequences: vec![],
}; };
assert_eq!( assert_eq!(tree, expected);
sequence,
Tree {
sequence: main_sequence,
sub_sequences: vec![subsequence_1, subsequence_2],
}
);
} }
#[test] #[test]
fn it_can_parse_example_1() { fn it_can_parse_example_1() {
let (_, ex_tree) = parse_tree::<nom::error::VerboseError<&str>>(EXAMPLE).unwrap(); let (_, tree) = parse_tree::<nom::error::VerboseError<&str>>(EXAMPLE).unwrap();
assert_eq!(ex_tree.sequence.len(), 1);
assert_eq!(ex_tree.sequence[0].properties.len(), 2); let j = Node {
assert_eq!( properties: vec![Property {
ex_tree.sequence[0].properties[0], ident: "C".to_owned(),
Property { values: vec!["j".to_owned()],
ident: "FF".to_owned(), }],
values: vec!["4".to_owned()] next: vec![],
} };
); let i = Node {
assert_eq!(ex_tree.sub_sequences.len(), 2); properties: vec![Property {
ident: "C".to_owned(),
values: vec!["i".to_owned()],
}],
next: vec![],
};
let h = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["h".to_owned()],
}],
next: vec![i],
};
let g = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["g".to_owned()],
}],
next: vec![h],
};
let f = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["f".to_owned()],
}],
next: vec![g, j],
};
let e = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["e".to_owned()],
}],
next: vec![],
};
let d = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["d".to_owned()],
}],
next: vec![e],
};
let c = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["c".to_owned()],
}],
next: vec![],
};
let b = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["b".to_owned()],
}],
next: vec![c, d],
};
let a = Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["a".to_owned()],
}],
next: vec![b],
};
let expected = Node {
properties: vec![
Property {
ident: "FF".to_owned(),
values: vec!["4".to_owned()],
},
Property {
ident: "C".to_owned(),
values: vec!["root".to_owned()],
},
],
next: vec![a, f],
};
assert_eq!(ex_tree.sub_sequences[0].sequence.len(), 2); assert_eq!(tree, expected);
assert_eq!(
ex_tree.sub_sequences[0].sequence,
vec![
Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["a".to_owned()]
}]
},
Node {
properties: vec![Property {
ident: "C".to_owned(),
values: vec!["b".to_owned()]
}]
},
]
);
assert_eq!(ex_tree.sub_sequences[0].sub_sequences.len(), 2);
} }
#[test] #[test]
fn it_can_regenerate_the_tree() { fn it_can_regenerate_the_tree() {
let (_, tree1) = parse_tree::<nom::error::VerboseError<&str>>(EXAMPLE).unwrap(); let (_, tree1) = parse_tree::<nom::error::VerboseError<&str>>(EXAMPLE).unwrap();
let tree1 = Tree { root: tree1 };
assert_eq!( assert_eq!(
tree1.to_string(), tree1.to_string(),
"(;FF[4]C[root](;C[a];C[b](;C[c])(;C[d];C[e]))(;C[f](;C[g];C[h];C[i])(;C[j])))" "(;FF[4]C[root](;C[a];C[b](;C[c])(;C[d];C[e]))(;C[f](;C[g];C[h];C[i])(;C[j])))"
); );
let (_, tree2) = parse_tree::<nom::error::VerboseError<&str>>(&tree1.to_string()).unwrap(); let (_, tree2) = parse_tree::<nom::error::VerboseError<&str>>(&tree1.to_string()).unwrap();
assert_eq!(tree1, tree2); assert_eq!(tree1, Tree { root: tree2 });
} }
#[test] #[test]