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",
"image",
"kifu-core",
"libadwaita",
"sgf",
"tokio",
]

View File

@ -1,22 +1,34 @@
use crate::{
types::{AppState, Config, DatabasePath, GameState, Player, Rank},
ui::{home, playing_field, HomeView, PlayingFieldView},
types::{AppState, Config, ConfigOption, DatabasePath, GameState, Player, Rank},
ui::{configuration, home, playing_field, ConfigurationView, HomeView, PlayingFieldView},
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use std::{
path::PathBuf,
sync::{Arc, RwLock},
};
use typeshare::typeshare;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum CoreRequest {
ChangeSetting(ChangeSettingRequest),
CreateGame(CreateGameRequest),
Home,
OpenConfiguration,
PlayingField,
PlayStone(PlayStoneRequest),
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)]
#[typeshare]
pub struct PlayStoneRequest {
@ -57,13 +69,15 @@ impl From<HotseatPlayerRequest> for Player {
#[typeshare]
#[serde(tag = "type", content = "content")]
pub enum CoreResponse {
ConfigurationView(ConfigurationView),
HomeView(HomeView),
PlayingFieldView(PlayingFieldView),
UpdatedConfigurationView(ConfigurationView),
}
#[derive(Clone, Debug)]
pub struct CoreApp {
config: Config,
config: Arc<RwLock<Config>>,
state: Arc<RwLock<AppState>>,
}
@ -74,25 +88,23 @@ impl CoreApp {
let db_path: DatabasePath = config.get().unwrap();
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 {
match request {
/*
CoreRequest::LaunchScreen => {
let app_state = self.state.read().unwrap();
At launch, I want to either show a list of games in progress, the current game, or the game creation screen.
- 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.
- 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::ChangeSetting(request) => match request {
ChangeSettingRequest::LibraryPath(path) => {
let mut config = self.config.write().unwrap();
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
path,
))));
CoreResponse::UpdatedConfigurationView(configuration(&config))
}
},
CoreRequest::CreateGame(create_request) => {
let mut app_state = self.state.write().unwrap();
let white_player = {
@ -116,6 +128,9 @@ impl CoreApp {
CoreRequest::Home => {
CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
}
CoreRequest::OpenConfiguration => {
CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
}
CoreRequest::PlayingField => {
let app_state = self.state.read().unwrap();
let game = app_state.game.as_ref().unwrap();

View File

@ -39,11 +39,16 @@ impl Database {
.unwrap()
.read_to_string(&mut buffer)
.unwrap();
for sgf in parse_sgf(&buffer).unwrap() {
match sgf {
Game::Go(game) => games.push(game),
Game::Unsupported(_) => {}
match parse_sgf(&buffer) {
Ok(sgfs) => {
for sgf in sgfs {
match sgf {
Game::Go(game) => games.push(game),
Game::Unsupported(_) => {}
}
}
}
Err(err) => println!("Error parsing {:?}: {:?}", entry.path(), err),
}
}
}

View File

@ -3,7 +3,8 @@ extern crate config_derive;
mod api;
pub use api::{
CoreApp, CoreRequest, CoreResponse, CreateGameRequest, HotseatPlayerRequest, PlayerInfoRequest,
ChangeSettingRequest, CoreApp, CoreRequest, CoreResponse, CreateGameRequest,
HotseatPlayerRequest, PlayerInfoRequest,
};
mod board;

View File

@ -16,7 +16,7 @@ define_config! {
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(PathBuf);
pub struct DatabasePath(pub PathBuf);
impl std::ops::Deref for DatabasePath {
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::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(),
};

View File

@ -1,3 +1,31 @@
pub mod action;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
pub mod game_preview;
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;
pub use elements::{action::Action, game_preview::GamePreviewElement, menu::Menu};
pub use elements::{game_preview::GamePreviewElement, menu::Menu, Action, Field, Toggle};
mod playing_field;
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
[dependencies]
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
cairo-rs = { version = "0.17" }
gio = { 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_gtk::{
perftrace,
ui::{Home, PlayingField},
ui::{ConfigurationPage, Home, Layout, PlayingField},
CoreApi,
};
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));
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", || {
let api = api.clone();
let home = Home::new(api, view);
window.set_child(Some(&home));
layout.set_content(&home);
}),
CoreResponse::PlayingFieldView(view) => perftrace("PlayingFieldView", || {
let api = api.clone();
@ -23,13 +31,16 @@ fn handle_response(api: CoreApi, window: gtk::ApplicationWindow, message: CoreRe
if playing_field.is_none() {
perftrace("creating a new playing field", || {
let field = PlayingField::new(api, view);
window.set_child(Some(&field));
layout.set_content(&field);
*playing_field = Some(field);
})
} else {
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")
.resource_base_path("/com/luminescent-dreams/kifu-gtk")
.build();
app.connect_activate({
@ -81,14 +93,31 @@ fn main() {
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();
gtk_rx.attach(None, {
let api = api.clone();
move |message| {
perftrace("handle_response", || {
handle_response(api.clone(), window.clone(), message)
handle_response(api.clone(), layout.clone(), message)
});
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();
s.set_orientation(gtk::Orientation::Horizontal);
s.set_homogeneous(true);
s.set_hexpand(true);
s.set_hexpand(false);
s.append(&s.imp().date);
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;
pub use chat::Chat;
mod config;
pub use config::ConfigurationPage;
mod game_preview;
pub use game_preview::GamePreview;
mod library;
pub use library::Library;
mod layout;
pub use layout::Layout;
mod player_card;
pub use player_card::PlayerCard;

View File

@ -242,6 +242,7 @@ pub enum GameResult {
Draw,
Black(Win),
White(Win),
Unknown(String),
}
impl TryFrom<&str> for GameResult {
@ -256,7 +257,7 @@ impl TryFrom<&str> for GameResult {
let res = match parts[0].to_ascii_lowercase().as_str() {
"b" => GameResult::Black,
"w" => GameResult::White,
_ => panic!("unknown result format"),
_ => return Ok(GameResult::Unknown(parts[0].to_owned())),
};
match parts[1].to_ascii_lowercase().as_str() {
"r" | "resign" => Ok(res(Win::Resignation)),