Set up the settings user interface #225

Merged
savanni merged 5 commits from kifu/main-menu into main 2024-03-21 21:11:03 +00:00
16 changed files with 552 additions and 163 deletions

View File

@ -1,13 +1,17 @@
use crate::{ use crate::{
database::Database, database::Database,
types::{AppState, Config, ConfigOption, DatabasePath, GameState, Player, Rank}, types::{AppState, Config, ConfigOption, GameState, LibraryPath, Player, Rank},
ui::{configuration, home, playing_field, ConfigurationView, HomeView, PlayingFieldView}, };
use async_std::{
channel::{Receiver, Sender},
stream,
task::spawn,
}; };
use async_std::channel::{Receiver, Sender};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
future::Future,
path::PathBuf, path::PathBuf,
sync::{Arc, RwLock}, sync::{Arc, RwLock, RwLockReadGuard},
}; };
pub trait Observable<T> { pub trait Observable<T> {
@ -62,6 +66,7 @@ impl From<HotseatPlayerRequest> for Player {
} }
} }
/*
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CoreResponse { pub enum CoreResponse {
ConfigurationView(ConfigurationView), ConfigurationView(ConfigurationView),
@ -69,34 +74,55 @@ pub enum CoreResponse {
PlayingFieldView(PlayingFieldView), PlayingFieldView(PlayingFieldView),
UpdatedConfigurationView(ConfigurationView), UpdatedConfigurationView(ConfigurationView),
} }
*/
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug)]
pub enum CoreNotification { pub enum CoreNotification {
ConfigurationUpdated(Config),
BoardUpdated, BoardUpdated,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Core { pub struct Core {
// config: Arc<RwLock<Config>>, config: Arc<RwLock<Config>>,
// state: Arc<RwLock<AppState>>, // state: Arc<RwLock<AppState>>,
database: Arc<RwLock<Option<Database>>>, library: Arc<RwLock<Option<Database>>>,
subscribers: Arc<RwLock<Vec<Sender<CoreNotification>>>>, subscribers: Arc<RwLock<Vec<Sender<CoreNotification>>>>,
} }
impl Core { impl Core {
pub fn new(_config: Config) -> Self { pub fn new(config: Config) -> Self {
// let config = Config::from_path(config_path).expect("configuration to open");
// let state = Arc::new(RwLock::new(AppState::new(db_path)));
Self { Self {
// config: Arc::new(RwLock::new(config)), config: Arc::new(RwLock::new(config)),
// state, // state,
database: Arc::new(RwLock::new(None)), library: Arc::new(RwLock::new(None)),
subscribers: Arc::new(RwLock::new(vec![])), subscribers: Arc::new(RwLock::new(vec![])),
} }
} }
pub fn get_config(&self) -> Config {
self.config.read().unwrap().clone()
}
/// Change the configuration of the Core. This function will update any relevant core
/// functions, especially the contents of the library, and it will notify any subscribed objects
/// that the configuration has changed.
///
/// It will not handle persisting the new configuration, as the backing store for the
/// configuration is not a decision for the core library.
pub async fn set_config(&self, config: Config) {
*self.config.write().unwrap() = config.clone();
let subscribers = self.subscribers.read().unwrap().clone();
for subscriber in subscribers {
let subscriber = subscriber.clone();
let _ = subscriber.send(CoreNotification::ConfigurationUpdated(config.clone())).await;
}
}
pub fn library<'a>(&'a self) -> RwLockReadGuard<'_, Option<Database>> {
self.library.read().unwrap()
}
/* /*
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse { pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
match request { match request {

View File

@ -1,10 +1,7 @@
extern crate config_derive; extern crate config_derive;
mod api; mod api;
pub use api::{ pub use api::{Core, CoreNotification, Observable};
ChangeSettingRequest, Core, CoreNotification, CoreRequest, CoreResponse, CreateGameRequest,
HotseatPlayerRequest, Observable, PlayerInfoRequest,
};
mod board; mod board;
pub use board::*; pub use board::*;
@ -12,6 +9,5 @@ pub use board::*;
mod database; mod database;
mod types; mod types;
pub use types::{BoardError, Color, Config, ConfigOption, DatabasePath, Player, Rank, Size}; pub use types::{BoardError, Color, Config, ConfigOption, LibraryPath, Player, Rank, Size};
pub mod ui;

View File

@ -10,21 +10,21 @@ use std::{path::PathBuf, time::Duration};
use thiserror::Error; use thiserror::Error;
define_config! { define_config! {
DatabasePath(DatabasePath), LibraryPath(LibraryPath),
Me(Me), Me(Me),
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
pub struct DatabasePath(pub PathBuf); pub struct LibraryPath(pub PathBuf);
impl std::ops::Deref for DatabasePath { impl std::ops::Deref for LibraryPath {
type Target = PathBuf; type Target = PathBuf;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.0
} }
} }
impl From<String> for DatabasePath { impl From<String> for LibraryPath {
fn from(s: String) -> Self { fn from(s: String) -> Self {
Self(PathBuf::from(s)) Self(PathBuf::from(s))
} }
@ -78,7 +78,7 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn new(database_path: DatabasePath) -> Self { pub fn new(database_path: LibraryPath) -> Self {
Self { Self {
game: Some(GameState::default()), game: Some(GameState::default()),
database: Database::open_path(database_path.to_path_buf()).unwrap(), database: Database::open_path(database_path.to_path_buf()).unwrap(),

View File

@ -1,22 +0,0 @@
use crate::{
types::{Config, DatabasePath},
ui::Field,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
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

@ -9,13 +9,13 @@ 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.5", package = "libadwaita", features = [ "v1_2" ] } adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
async-channel = { version = "2" } async-channel = { version = "2" }
async-std = { version = "1" } async-std = { version = "1" }
cairo-rs = { version = "0.18" } cairo-rs = { version = "0.18" }
gio = { version = "0.18" } gio = { version = "0.18" }
glib = { version = "0.18" } glib = { version = "0.18" }
gtk = { version = "0.7", package = "gtk4", features = [ "v4_8" ] } gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
image = { version = "0.24" } image = { version = "0.24" }
kifu-core = { path = "../core" } kifu-core = { path = "../core" }
pango = { version = "*" } pango = { version = "*" }

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<schemalist> <schemalist>
<schema id="com.luminescent-dreams.kifu-gtk.dev" path="/com/luminescent-dreams/kifu-gtk/dev/"> <schema id="com.luminescent-dreams.kifu-gtk.dev" path="/com/luminescent-dreams/kifu-gtk/dev/">
<key name="database-path" type="s"> <key name="library-path" type="s">
<default>""</default> <default>""</default>
<summary>Path to the directory of games</summary> <summary>Path to the directory of games</summary>
</key> </key>
@ -10,4 +10,4 @@
<summary>Language override, use system settings if empty</summary> <summary>Language override, use system settings if empty</summary>
</key> </key>
</schema> </schema>
</schemalist> </schemalist>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<schemalist> <schemalist>
<schema id="com.luminescent-dreams.kifu-gtk" path="/com/luminescent-dreams/kifu-gtk/"> <schema id="com.luminescent-dreams.kifu-gtk" path="/com/luminescent-dreams/kifu-gtk/">
<key name="database-path" type="s"> <key name="library-path" type="s">
<default>""</default> <default>""</default>
<summary>Path to the directory of games</summary> <summary>Path to the directory of games</summary>
</key> </key>
@ -10,4 +10,4 @@
<summary>Language override, use system settings if empty</summary> <summary>Language override, use system settings if empty</summary>
</key> </key>
</schema> </schema>
</schemalist> </schemalist>

View File

@ -1,3 +1,17 @@
.content { .content {
padding: 8px; padding: 8px;
} }
.settings-view {
margin: 8px;
padding: 4px;
background-color: @view_bg_color;
}
.settings-view {
padding: 8px;
}
.preference-item > suffixes {
margin: 4px;
}

175
kifu/gtk/src/app_window.rs Normal file
View File

@ -0,0 +1,175 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
use adw::prelude::*;
/*
use gio::resources_lookup_data;
use glib::IsA;
use gtk::STYLE_PROVIDER_PRIORITY_USER;
*/
use kifu_core::{Config, Core};
use std::sync::{Arc, RwLock};
use crate::{view_models::HomeViewModel, view_models::SettingsViewModel};
#[derive(Clone)]
enum AppView {
Settings(SettingsViewModel),
Home(HomeViewModel),
}
// An application window should generally contain
// - an overlay widget
// - the main content in a stack on the bottom panel of the overlay
// - the settings and the about page in bins atop the overlay
#[derive(Clone)]
pub struct AppWindow {
pub window: adw::ApplicationWindow,
header: adw::HeaderBar,
// content is a stack which contains the view models for the application. These are the main
// elements that users want to interact with: the home page, the game library, a review, a game
// itself, perhaps also chat rooms and player lists on other networks. stack contains the
// widgets that need to be rendered. The two of these work together in order to ensure that
// we can maintain the state of previous views. Since the two of these work together, they are
// a candidate for extraction into a new widget or a new struct.
stack: adw::NavigationView,
content: Vec<AppView>,
// Overlays are for transient content, such as about and settings, which can be accessed from
// anywhere but shouldn't be part of the main application flow.
panel_overlay: gtk::Overlay,
core: Core,
// Not liking this, but I have to keep track of the settings view model separately from
// anything else. I'll have to look into this later.
settings_view_model: Arc<RwLock<Option<SettingsViewModel>>>,
}
impl AppWindow {
pub fn new(app: &adw::Application, core: Core) -> Self {
let window = Self::setup_window(app);
let header = Self::setup_header();
let panel_overlay = Self::setup_panel_overlay();
let (stack, content) = Self::setup_content(core.clone());
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
layout.append(&header);
layout.append(&panel_overlay);
panel_overlay.set_child(Some(&stack));
window.set_content(Some(&layout));
Self {
window,
header,
stack,
content,
panel_overlay,
core,
settings_view_model: Default::default(),
}
}
pub fn open_settings(&self) {
let view_model = SettingsViewModel::new(&self.window, self.core.clone(), {
let s = self.clone();
move || {
s.close_overlay();
}
});
self.panel_overlay.add_overlay(&view_model.widget);
*self.settings_view_model.write().unwrap() = Some(view_model);
}
pub fn close_overlay(&self) {
let mut vm = self.settings_view_model.write().unwrap();
match *vm {
Some(ref mut view_model) => {
self.panel_overlay.remove_overlay(&view_model.widget);
*vm = None;
}
None => {}
}
}
fn setup_window(app: &adw::Application) -> adw::ApplicationWindow {
let window = adw::ApplicationWindow::builder()
.application(app)
.width_request(800)
.height_request(500)
.build();
window
}
fn setup_header() -> adw::HeaderBar {
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_settings"));
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);
header
}
fn setup_panel_overlay() -> gtk::Overlay {
gtk::Overlay::new()
}
fn setup_content(core: Core) -> (adw::NavigationView, Vec<AppView>) {
let stack = adw::NavigationView::new();
let mut content = Vec::new();
let nothing_page = adw::StatusPage::builder().title("Nothing here").build();
let _ = stack.push(
&adw::NavigationPage::builder()
.can_pop(false)
.title("Kifu")
.child(&nothing_page)
.build(),
);
content.push(AppView::Home(HomeViewModel::new(core.clone())));
/*
match *core.library() {
Some(_) => {
}
None => {
let settings_vm = SettingsViewModel::new(core.clone());
let _ = stack.push(&adw::NavigationPage::new(&settings_vm.widget, "Settings"));
content.push(AppView::Settings(settings_vm));
}
}
*/
(stack, content)
}
// pub fn set_content(content: &impl IsA<gtk::Widget>) -> adw::ViewStack {
// self.content.set_child(Some(content));
// }
}

View File

@ -1,10 +1,29 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod ui; pub mod ui;
mod app_window;
pub use app_window::AppWindow;
mod view_models; mod view_models;
mod views; mod views;
use async_std::task::yield_now; use async_std::task::yield_now;
use kifu_core::{Core, CoreRequest, CoreResponse, Observable}; use kifu_core::{Core, Observable};
use std::{rc::Rc, sync::Arc}; use std::{rc::Rc, sync::Arc};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@ -15,6 +34,7 @@ pub struct CoreApi {
} }
impl CoreApi { impl CoreApi {
/*
pub fn dispatch(&self, request: CoreRequest) { pub fn dispatch(&self, request: CoreRequest) {
/* /*
spawn({ spawn({
@ -26,6 +46,7 @@ impl CoreApi {
}); });
*/ */
} }
*/
} }
pub fn perftrace<F, A>(trace_name: &str, f: F) -> A pub fn perftrace<F, A>(trace_name: &str, f: F) -> A

View File

@ -1,8 +1,12 @@
use adw::prelude::*; use adw::prelude::*;
use kifu_core::{Config, ConfigOption, Core, CoreRequest, CoreResponse, DatabasePath}; use async_std::channel::Receiver;
use async_std::task::spawn;
use gio::ActionEntry;
use kifu_core::{Config, ConfigOption, Core, CoreNotification, LibraryPath, Observable};
use kifu_gtk::{ use kifu_gtk::{
perftrace, perftrace,
ui::{AppWindow, ConfigurationPage, Home, PlayingField}, // ui::{ConfigurationPage, Home, PlayingField},
AppWindow,
CoreApi, CoreApi,
}; };
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@ -12,6 +16,30 @@ const APP_ID_PROD: &str = "com.luminescent-dreams.kifu-gtk";
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/kifu-gtk/"; const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/kifu-gtk/";
async fn handler(notifications: Receiver<CoreNotification>, app_id: String) {
loop {
let msg = notifications.recv().await;
match msg {
Ok(CoreNotification::ConfigurationUpdated(cfg)) => {
println!("commiting configuration");
let settings = gio::Settings::new(&app_id);
if let Some(LibraryPath(library_path)) = cfg.get() {
let _ = settings.set_string(
"library-path",
&library_path.into_os_string().into_string().unwrap(),
);
}
}
Ok(_) => println!("discarding message"),
Err(err) => {
println!("shutting down handler with error: {:?}", err);
return;
}
}
}
}
/*
fn handle_response(api: CoreApi, app_window: &AppWindow, 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 {
@ -48,6 +76,26 @@ fn handle_response(api: CoreApi, app_window: &AppWindow, message: CoreResponse)
}), }),
} }
} }
*/
fn load_config(app_id: &str) -> Config {
let settings = gio::Settings::new(app_id);
let lib_path: String = settings.string("library-path").into();
let mut config = Config::new();
config.set(ConfigOption::LibraryPath(lib_path.into()));
config
}
fn setup_app_configuration_action(app: &adw::Application, app_window: AppWindow) {
println!("setup_app_configuration_action");
let action = ActionEntry::builder("show_settings")
.activate(move |_app: &adw::Application, _, _| {
app_window.open_settings();
})
.build();
app.add_action_entries([action]);
println!("setup_app_configuration_action complete");
}
fn main() { fn main() {
gio::resources_register_include!("com.luminescent-dreams.kifu-gtk.gresource") gio::resources_register_include!("com.luminescent-dreams.kifu-gtk.gresource")
@ -59,10 +107,7 @@ fn main() {
APP_ID_PROD APP_ID_PROD
}; };
let settings = gio::Settings::new(app_id); let config = load_config(&app_id);
let db_path: String = settings.string("database-path").into();
let mut config = Config::new();
config.set(ConfigOption::DatabasePath(db_path.into()));
let runtime = Arc::new( let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread() tokio::runtime::Builder::new_multi_thread()
@ -87,6 +132,12 @@ fn main() {
let core = Core::new(config); let core = Core::new(config);
spawn({
let notifier = core.subscribe();
let app_id = app_id.to_owned();
handler(notifier, app_id)
});
/* /*
let core_handle = runtime.spawn({ let core_handle = runtime.spawn({
let core = core.clone(); let core = core.clone();
@ -104,13 +155,23 @@ fn main() {
app.connect_activate({ app.connect_activate({
let runtime = runtime.clone(); let runtime = runtime.clone();
move |app| { move |app| {
let app_window = AppWindow::new(app); let mut app_window = AppWindow::new(app, core.clone());
match *core.library() {
Some(_) => {}
None => app_window.open_settings(),
}
setup_app_configuration_action(app, app_window.clone());
/*
let api = CoreApi { let api = CoreApi {
rt: runtime.clone(), rt: runtime.clone(),
core: core.clone(), core: core.clone(),
}; };
*/
/*
let action_config = gio::SimpleAction::new("show-config", None); let action_config = gio::SimpleAction::new("show-config", None);
action_config.connect_activate({ action_config.connect_activate({
let api = api.clone(); let api = api.clone();
@ -119,6 +180,7 @@ fn main() {
} }
}); });
app.add_action(&action_config); app.add_action(&action_config);
*/
app_window.window.present(); app_window.window.present();
@ -134,7 +196,7 @@ fn main() {
}); });
*/ */
api.dispatch(CoreRequest::Home); // api.dispatch(CoreRequest::Home);
} }
}); });

View File

@ -1,100 +1,27 @@
use adw::prelude::*; // mod chat;
use gio::resources_lookup_data; // pub use chat::Chat;
use glib::IsA;
use gtk::STYLE_PROVIDER_PRIORITY_USER;
mod chat; // mod config;
pub use chat::Chat; // pub use config::ConfigurationPage;
mod config; // mod game_preview;
pub use config::ConfigurationPage; // pub use game_preview::GamePreview;
mod game_preview; // mod library;
pub use game_preview::GamePreview; // pub use library::Library;
mod library; // mod player_card;
pub use library::Library; // pub use player_card::PlayerCard;
mod player_card; // mod playing_field;
pub use player_card::PlayerCard; // pub use playing_field::PlayingField;
mod playing_field; // mod home;
pub use playing_field::PlayingField; // pub use home::Home;
mod home; // mod board;
pub use home::Home; // pub use board::Board;
mod board;
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

@ -16,23 +16,25 @@ You should have received a copy of the GNU General Public License along with Kif
use crate::LocalObserver; use crate::LocalObserver;
use kifu_core::{Core, CoreNotification}; use kifu_core::{Core, CoreNotification};
use std::sync::Arc;
/// Home controls the view that the user sees when starting the application if there are no games in progress. It provides a window into the database, showing a list of recently recorded games. It also provides the UI for starting a new game. This will render an empty database view if the user hasn't configured a database yet. /// Home controls the view that the user sees when starting the application if there are no games in progress. It provides a window into the database, showing a list of recently recorded games. It also provides the UI for starting a new game. This will render an empty database view if the user hasn't configured a database yet.
#[derive(Clone)]
pub struct HomeViewModel { pub struct HomeViewModel {
core: Core, core: Core,
notification_observer: LocalObserver<CoreNotification>, notification_observer: Arc<LocalObserver<CoreNotification>>,
widget: gtk::Box, widget: gtk::Box,
} }
impl HomeViewModel { impl HomeViewModel {
fn new(core: Core) -> Self { pub fn new(core: Core) -> Self {
let notification_observer = LocalObserver::new(&core, |msg| { let notification_observer = LocalObserver::new(&core, |msg| {
println!("DatabaseViewModelHandler called with message: {:?}", msg) println!("HomeViewModel handler called with message: {:?}", msg)
}); });
Self { Self {
core, core,
notification_observer, notification_observer: Arc::new(notification_observer),
widget: gtk::Box::new(gtk::Orientation::Horizontal, 0), widget: gtk::Box::new(gtk::Orientation::Horizontal, 0),
} }
} }

View File

@ -14,25 +14,60 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::LocalObserver; use crate::{views, views::SettingsView, LocalObserver};
use kifu_core::{Core, CoreNotification}; use async_std::task::spawn;
use gtk::prelude::*;
use kifu_core::{Config, Core, CoreNotification};
use std::{sync::Arc, rc::Rc};
/// SettingsViewModel
///
/// Listens for messages from the core, and serves as intermediary between the Settings UI and the
/// core. Because it needs to respond to events from the core, it owns the widget, which allows it
/// to tell the widget to update after certain events.
#[derive(Clone)]
pub struct SettingsViewModel { pub struct SettingsViewModel {
core: Core, core: Core,
notification_observer: LocalObserver<CoreNotification>, // Technically, Settings doesn't care about any events from Core. We will keep this around for
widget: gtk::Box, // now as reference, until something which does care shows up.
notification_observer: Arc<LocalObserver<CoreNotification>>,
pub widget: views::SettingsView,
} }
impl SettingsViewModel { impl SettingsViewModel {
fn new(core: Core) -> Self { pub fn new(parent: &impl IsA<gtk::Window>, core: Core, on_close: impl Fn() + 'static) -> Self {
let on_close = Arc::new(on_close);
let notification_observer = LocalObserver::new(&core, |msg| { let notification_observer = LocalObserver::new(&core, |msg| {
println!("SettingsViewModel called with message: {:?}", msg) println!("SettingsViewModel called with message: {:?}", msg)
}); });
let config = core.get_config();
let widget = SettingsView::new(
parent,
config,
{
let core = core.clone();
let on_close = on_close.clone();
move |new_config| {
spawn({
let core = core.clone();
on_close();
async move {
println!("running set_config in the background");
core.set_config(new_config).await;
}
});
}
},
move || on_close(),
);
Self { Self {
core, core: core.clone(),
notification_observer, notification_observer: Arc::new(notification_observer),
widget: gtk::Box::new(gtk::Orientation::Horizontal, 0), widget,
} }
} }
} }

View File

@ -0,0 +1,2 @@
mod settings;
pub use settings::SettingsView;

View File

@ -0,0 +1,151 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{cell::RefCell, path::Path, rc::Rc, borrow::Cow};
use adw::prelude::*;
use glib::Object;
use gtk::{prelude::*, subclass::prelude::*};
use kifu_core::{Config, ConfigOption, LibraryPath};
fn library_chooser_row(
parent: &impl IsA<gtk::Window>,
library_path: Option<LibraryPath>,
on_library_chosen: Rc<impl Fn(ConfigOption) + 'static>,
) -> adw::ActionRow {
let dialog = gtk::FileDialog::builder().build();
let dialog_button = gtk::Button::builder()
.child(&gtk::Label::new(Some("Select Library")))
.valign(gtk::Align::Center)
.build();
let parent = parent.clone();
let library_row = adw::ActionRow::builder()
.title("Library Path")
.subtitle(library_path.map(|LibraryPath(path)| path.to_string_lossy().into_owned()).unwrap_or("No library set".to_owned()))
.css_classes(["preference-item"])
.build();
dialog_button.connect_clicked({
let library_row = library_row.clone();
move |_| {
let no_parent: Option<&gtk::Window> = None;
let not_cancellable: Option<&gio::Cancellable> = None;
let on_library_chosen = on_library_chosen.clone();
dialog.select_folder(no_parent, not_cancellable, {
let library_row = library_row.clone();
move |result| match result {
Ok(path) => {
let path_str: String =
path.path().unwrap().into_os_string().into_string().unwrap();
library_row.set_subtitle(&path_str);
on_library_chosen(ConfigOption::LibraryPath(LibraryPath(
path.path().unwrap(),
)))
}
Err(err) => println!("Error choosing a library: {:?}", err),
}
});
}
});
library_row.add_suffix(&dialog_button);
library_row
}
pub struct SettingsPrivate {}
#[glib::object_subclass]
impl ObjectSubclass for SettingsPrivate {
const NAME: &'static str = "Settings";
type Type = SettingsView;
type ParentType = gtk::Frame;
fn new() -> Self {
Self {}
}
}
impl ObjectImpl for SettingsPrivate {}
impl WidgetImpl for SettingsPrivate {}
#[allow(deprecated)]
impl FrameImpl for SettingsPrivate {}
glib::wrapper! {
pub struct SettingsView(ObjectSubclass<SettingsPrivate>) @extends gtk::Frame, gtk::Widget, @implements gtk::Accessible, gtk::Orientable;
}
impl SettingsView {
pub fn new(
parent: &impl IsA<gtk::Window>,
config: Config,
on_save: impl Fn(Config) + 'static,
on_cancel: impl Fn() + 'static,
) -> Self {
let s: Self = Object::builder().build();
let config = Rc::new(RefCell::new(config));
let group = adw::PreferencesGroup::builder().build();
let library_row = library_chooser_row(
parent,
config.borrow().get(),
Rc::new({
let config = config.clone();
move |library_path| {
config.borrow_mut().set(library_path);
}
}),
);
group.add(&library_row);
let cancel_button = gtk::Button::builder().label("Cancel").build();
cancel_button.connect_clicked(move |_| on_cancel());
let save_button = gtk::Button::builder().label("Save").build();
save_button.connect_clicked({
let config = config.clone();
move |_| on_save(config.borrow().clone())
});
let action_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::End)
.valign(gtk::Align::End)
.build();
action_row.append(&cancel_button);
action_row.append(&save_button);
let preferences_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
preferences_box.append(&group);
let layout = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
layout.append(&preferences_box);
layout.append(&action_row);
s.set_child(Some(&layout));
s.set_css_classes(&["settings-view"]);
s
}
}