Set up the settings user interface #225
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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: (),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 = "*" }
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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(>k::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(¬hing_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));
|
||||||
|
// }
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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(>k::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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
mod settings;
|
||||||
|
pub use settings::SettingsView;
|
|
@ -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(>k::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<>k::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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue