Set up a configuration UI #66

Merged
savanni merged 7 commits from kifu/configuration into main 2023-08-25 01:08:33 +00:00
17 changed files with 275 additions and 47 deletions

1
Cargo.lock generated
View File

@ -1348,6 +1348,7 @@ dependencies = [
"gtk4", "gtk4",
"image", "image",
"kifu-core", "kifu-core",
"libadwaita",
"sgf", "sgf",
"tokio", "tokio",
] ]

View File

@ -1,22 +1,34 @@
use crate::{ use crate::{
types::{AppState, Config, DatabasePath, 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},
}; };
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 {
@ -57,13 +69,15 @@ 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>>,
} }
@ -74,25 +88,23 @@ impl CoreApp {
let db_path: DatabasePath = config.get().unwrap(); 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)));
Self { config, state } Self {
config: Arc::new(RwLock::new(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 = {
@ -116,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

@ -39,13 +39,18 @@ 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) {
Ok(sgfs) => {
for sgf in sgfs {
match sgf { match sgf {
Game::Go(game) => games.push(game), Game::Go(game) => games.push(game),
Game::Unsupported(_) => {} Game::Unsupported(_) => {}
} }
} }
} }
Err(err) => println!("Error parsing {:?}: {:?}", entry.path(), err),
}
}
} }
Err(err) => println!("failed entry: {:?}", err), Err(err) => println!("failed entry: {:?}", err),
} }

View File

@ -3,7 +3,8 @@ 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;

View File

@ -16,7 +16,7 @@ define_config! {
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(PathBuf); pub struct DatabasePath(pub PathBuf);
impl std::ops::Deref for DatabasePath { impl std::ops::Deref for DatabasePath {
type Target = PathBuf; type Target = PathBuf;

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

@ -50,6 +50,7 @@ impl GamePreviewElement {
Some(GameResult::Draw) => "Draw".to_owned(), Some(GameResult::Draw) => "Draw".to_owned(),
Some(GameResult::Black(ref win)) => format!("Black by {}", format_win(win)), Some(GameResult::Black(ref win)) => format!("Black by {}", format_win(win)),
Some(GameResult::White(ref win)) => format!("White 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(), None => "".to_owned(),
}; };

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,5 +1,8 @@
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};

View File

@ -9,6 +9,7 @@ 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" }

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::{ConfigurationPage, Home, Layout, 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, layout: Layout, 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 home = Home::new(api, view); let home = Home::new(api, view);
window.set_child(Some(&home)); layout.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)); layout.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({
@ -81,14 +93,31 @@ fn main() {
core: core.clone(), core: core.clone(),
}; };
let window = gtk::ApplicationWindow::new(app); let action_config = gio::SimpleAction::new("show-config", None);
action_config.connect_activate({
let api = api.clone();
move |_, _| {
api.dispatch(CoreRequest::OpenConfiguration);
}
});
app.add_action(&action_config);
let window = adw::ApplicationWindow::builder()
.application(app)
.width_request(800)
.height_request(500)
.build();
let layout = Layout::new();
window.set_content(Some(&layout));
window.present(); 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(), layout.clone(), 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

@ -31,7 +31,7 @@ impl 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_homogeneous(true);
s.set_hexpand(true); s.set_hexpand(false);
s.append(&s.imp().date); s.append(&s.imp().date);
s.append(&s.imp().title); s.append(&s.imp().title);

71
kifu/gtk/src/ui/layout.rs Normal file
View File

@ -0,0 +1,71 @@
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use std::{cell::RefCell, rc::Rc};
// Deprecated even from day 1. We want to use ToolbarView as soon as it's available in the versions
// of Libadwaita available in NixOS.
pub struct LayoutPrivate {
pub header: adw::HeaderBar,
pub content: Rc<RefCell<gtk::Widget>>,
}
impl Default for LayoutPrivate {
fn default() -> Self {
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::StatusPage::builder().title("Nothing here").build();
Self {
header,
content: Rc::new(RefCell::new(content.into())),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for LayoutPrivate {
const NAME: &'static str = "Layout";
type Type = Layout;
type ParentType = gtk::Box;
}
impl ObjectImpl for LayoutPrivate {}
impl WidgetImpl for LayoutPrivate {}
impl BoxImpl for LayoutPrivate {}
glib::wrapper! {
pub struct Layout(ObjectSubclass<LayoutPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl Layout {
pub fn new() -> Self {
let s: Self = Object::builder().build();
s.set_orientation(gtk::Orientation::Vertical);
s.set_homogeneous(false);
s.append(&s.imp().header);
s.append(&*s.imp().content.borrow());
s
}
pub fn set_content(&self, content: &impl IsA<gtk::Widget>) {
let mut widget = self.imp().content.borrow_mut();
self.remove(&*widget);
*widget = content.clone().upcast::<gtk::Widget>();
self.append(&*widget);
}
}

View File

@ -1,12 +1,18 @@
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; mod library;
pub use library::Library; pub use library::Library;
mod layout;
pub use layout::Layout;
mod player_card; mod player_card;
pub use player_card::PlayerCard; pub use player_card::PlayerCard;

View File

@ -242,6 +242,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 {
@ -256,7 +257,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)),