From f19090311bb8808c85c14f40bc41a1891164a524 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 22 Dec 2023 14:54:38 -0500 Subject: [PATCH] Extract all of the UI components into dedicated files --- fitnesstrax/app/src/app_window.rs | 153 +++++++++ fitnesstrax/app/src/{ui => components}/mod.rs | 0 fitnesstrax/app/src/main.rs | 301 +----------------- fitnesstrax/app/src/views/historical_view.rs | 54 ++++ fitnesstrax/app/src/views/mod.rs | 47 +++ fitnesstrax/app/src/views/placeholder_view.rs | 46 +++ fitnesstrax/app/src/views/welcome_view.rs | 98 ++++++ 7 files changed, 405 insertions(+), 294 deletions(-) create mode 100644 fitnesstrax/app/src/app_window.rs rename fitnesstrax/app/src/{ui => components}/mod.rs (100%) create mode 100644 fitnesstrax/app/src/views/historical_view.rs create mode 100644 fitnesstrax/app/src/views/mod.rs create mode 100644 fitnesstrax/app/src/views/placeholder_view.rs create mode 100644 fitnesstrax/app/src/views/welcome_view.rs diff --git a/fitnesstrax/app/src/app_window.rs b/fitnesstrax/app/src/app_window.rs new file mode 100644 index 0000000..1d20043 --- /dev/null +++ b/fitnesstrax/app/src/app_window.rs @@ -0,0 +1,153 @@ +/* +Copyright 2023, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax 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. + +FitnessTrax 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 FitnessTrax. If not, see . +*/ + +use crate::{ + views::{HistoricalView, PlaceholderView, View, ViewName, WelcomeView}, + AppInvocation, AppResponse, +}; +use adw::prelude::*; +use async_channel::Sender; +use gio::resources_lookup_data; +use glib::Object; +use gtk::STYLE_PROVIDER_PRIORITY_USER; +use gtk::{prelude::*, subclass::prelude::*}; +use std::path::PathBuf; +use std::{cell::RefCell, rc::Rc}; + +/// The application window, or the main window, is the main user interface for the app. Almost +/// everything occurs here. +#[derive(Clone)] +pub struct AppWindow { + app_tx: Sender, + window: adw::ApplicationWindow, + layout: gtk::Box, + current_view: Rc>, + settings: gio::Settings, +} + +impl AppWindow { + /// Construct a new App Window. + /// + /// adw_app is an Adwaita application. Application windows need to have access to this, but + /// otherwise we don't use this. + /// + /// app is a core [App] object which encapsulates all of the basic logic. + pub fn new( + app_id: &str, + resource_path: &str, + adw_app: &adw::Application, + app_tx: Sender, + ) -> AppWindow { + let window = adw::ApplicationWindow::builder() + .application(adw_app) + .width_request(800) + .height_request(600) + .build(); + + let stylesheet = String::from_utf8( + resources_lookup_data( + &format!("{}style.css", resource_path), + gio::ResourceLookupFlags::NONE, + ) + .expect("stylesheet must be available in the resources") + .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("FitnessTrax"))) + .build(); + + let layout = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + + let initial_view = View::Placeholder(PlaceholderView::new().upcast()); + + layout.append(&header); + layout.append(initial_view.widget()); + + window.set_content(Some(&layout)); + window.present(); + + let s = Self { + app_tx, + window, + layout, + current_view: Rc::new(RefCell::new(initial_view)), + settings: gio::Settings::new(app_id), + }; + + s + } + + pub fn change_view(&self, view: ViewName) { + self.swap_main(self.construct_view(view)); + } + + pub fn process_response(&self, response: AppResponse) { + match response { + AppResponse::DatabaseChanged(db_path) => { + self.settings + .set_string("series-path", db_path.to_str().unwrap()) + .unwrap(); + self.change_view(ViewName::Historical); + } + AppResponse::NoDatabase => { + self.change_view(ViewName::Welcome); + } + AppResponse::Records => { + self.change_view(ViewName::Historical); + } + } + } + + // Switch views. + // + // This function only replaces the old view with the one which matches the current view state. + // It is responsible for ensuring that the new view goes into the layout in the correct + // position. + fn swap_main(&self, view: View) { + let mut current_widget = self.current_view.borrow_mut(); + self.layout.remove(&*current_widget.widget()); + *current_widget = view; + self.layout.append(&*current_widget.widget()); + } + + fn construct_view(&self, view: ViewName) -> View { + match view { + ViewName::Placeholder => View::Placeholder(PlaceholderView::new().upcast()), + ViewName::Welcome => View::Welcome( + WelcomeView::new({ + let s = self.clone(); + Box::new(move |path: PathBuf| { + s.app_tx + .send_blocking(AppInvocation::OpenDatabase(path)) + .unwrap(); + }) + }) + .upcast(), + ), + ViewName::Historical => View::Historical(HistoricalView::new().upcast()), + } + } +} diff --git a/fitnesstrax/app/src/ui/mod.rs b/fitnesstrax/app/src/components/mod.rs similarity index 100% rename from fitnesstrax/app/src/ui/mod.rs rename to fitnesstrax/app/src/components/mod.rs diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index c78a69b..edb752a 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -14,23 +14,21 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see . */ -mod ui; +mod app_window; +mod components; +mod views; use adw::prelude::*; +use app_window::AppWindow; use async_channel::{Receiver, Sender}; use emseries::{EmseriesReadError, Series}; use ft_core::TraxRecord; -use gio::resources_lookup_data; -use glib::Object; -use gtk::{subclass::prelude::*, STYLE_PROVIDER_PRIORITY_USER}; use std::{ - cell::RefCell, env, path::{Path, PathBuf}, rc::Rc, sync::{Arc, RwLock}, }; -use ui::FileChooserRow; const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev"; const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax"; @@ -39,7 +37,7 @@ const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/"; /// Invocations are how parts of the application, primarily the UI, will send requests to the core. #[derive(Debug)] -enum AppInvocation { +pub enum AppInvocation { /// Tell the core to try to open a database. OpenDatabase(PathBuf), @@ -55,7 +53,7 @@ enum AppInvocation { /// A typical use would be for the UI to send an [AppInvocation::RequestRecords] request and /// receive [AppResponse::Records]. #[derive(Debug)] -enum AppResponse { +pub enum AppResponse { /// No database is available. The UI should typically display a placeholder, such as the /// welcome view. NoDatabase, @@ -111,291 +109,6 @@ impl App { } } -pub struct PlaceholderViewPrivate {} - -#[glib::object_subclass] -impl ObjectSubclass for PlaceholderViewPrivate { - const NAME: &'static str = "PlaceholderView"; - type Type = PlaceholderView; - type ParentType = gtk::Box; - - fn new() -> Self { - Self {} - } -} - -impl ObjectImpl for PlaceholderViewPrivate {} -impl WidgetImpl for PlaceholderViewPrivate {} -impl BoxImpl for PlaceholderViewPrivate {} - -glib::wrapper! { - pub struct PlaceholderView(ObjectSubclass) @extends gtk::Box, gtk::Widget; -} - -impl PlaceholderView { - pub fn new() -> Self { - let s: Self = Object::builder().build(); - s - } -} - -/// This is the view to show if the application has not yet been configured. It will walk the user -/// through the most critical setup steps so that we can move on to the other views in the app. -pub struct WelcomeViewPrivate {} - -#[glib::object_subclass] -impl ObjectSubclass for WelcomeViewPrivate { - const NAME: &'static str = "WelcomeView"; - type Type = WelcomeView; - type ParentType = gtk::Box; - - fn new() -> Self { - Self {} - } -} - -impl ObjectImpl for WelcomeViewPrivate {} -impl WidgetImpl for WelcomeViewPrivate {} -impl BoxImpl for WelcomeViewPrivate {} - -glib::wrapper! { - pub struct WelcomeView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; -} - -impl WelcomeView { - pub fn new(on_save: Box) -> Self - where - F: Fn(PathBuf) + 'static, - { - let s: Self = Object::builder().build(); - s.set_orientation(gtk::Orientation::Vertical); - s.set_css_classes(&["welcome"]); - - // Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page - // branch. - let title = gtk::Label::builder() - .label("Welcome to FitnessTrax") - .css_classes(["welcome-title"]) - .build(); - - let content = gtk::Box::builder() - .css_classes(["model-content"]) - .orientation(gtk::Orientation::Vertical) - .vexpand(true) - .build(); - - let save_button = gtk::Button::builder() - .label("Save Settings") - .sensitive(false) - .build(); - - // The database selection row should be a box that shows a default database path, along with a - // button that triggers a file chooser dialog. Once the dialog returns, the box should be - // updated to reflect the chosen path. - let db_row = FileChooserRow::new({ - let save_button = save_button.clone(); - move |_| save_button.set_sensitive(true) - }); - - content.append(>k::Label::new(Some("Welcome to FitnessTrax. The application has not yet been configured, so I will walk you through that. Let's start out by selecting your database."))); - content.append(&db_row); - - let on_save = on_save; - save_button.connect_clicked({ - move |_| { - if let Some(path) = db_row.path() { - on_save(path) - } - } - }); - - s.append(&title); - s.append(&content); - s.append(&save_button); - - s - } -} - -/// The historical view will show a window into the main database. It will show some version of -/// daily summaries, daily details, and will provide all functions the user may need for editing -/// records. -pub struct HistoricalViewPrivate {} - -#[glib::object_subclass] -impl ObjectSubclass for HistoricalViewPrivate { - const NAME: &'static str = "HistoricalView"; - type Type = HistoricalView; - type ParentType = gtk::Box; - - fn new() -> Self { - Self {} - } -} - -impl ObjectImpl for HistoricalViewPrivate {} -impl WidgetImpl for HistoricalViewPrivate {} -impl BoxImpl for HistoricalViewPrivate {} - -glib::wrapper! { - pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget; -} - -impl HistoricalView { - pub fn new() -> Self { - let s: Self = Object::builder().build(); - - let label = gtk::Label::builder() - .label("Database has been configured and now it is time to show data") - .build(); - s.append(&label); - s - } -} - -#[derive(Clone, Debug, PartialEq)] -enum ViewName { - Placeholder, - Welcome, - Historical, -} - -enum View { - Placeholder(gtk::Widget), - Welcome(gtk::Widget), - Historical(gtk::Widget), -} - -impl View { - fn widget<'a>(&'a self) -> &'a gtk::Widget { - match self { - View::Placeholder(widget) => widget, - View::Welcome(widget) => widget, - View::Historical(widget) => widget, - } - } -} - -/// The application window, or the main window, is the main user interface for the app. Almost -/// everything occurs here. -#[derive(Clone)] -struct AppWindow { - app_tx: Sender, - window: adw::ApplicationWindow, - layout: gtk::Box, - current_view: Rc>, - settings: gio::Settings, -} - -impl AppWindow { - /// Construct a new App Window. - /// - /// adw_app is an Adwaita application. Application windows need to have access to this, but - /// otherwise we don't use this. - /// - /// app is a core [App] object which encapsulates all of the basic logic. - fn new(app_id: &str, adw_app: &adw::Application, app_tx: Sender) -> AppWindow { - let window = adw::ApplicationWindow::builder() - .application(adw_app) - .width_request(800) - .height_request(600) - .build(); - - let stylesheet = String::from_utf8( - resources_lookup_data( - &format!("{}style.css", RESOURCE_BASE_PATH), - gio::ResourceLookupFlags::NONE, - ) - .expect("stylesheet must be available in the resources") - .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("FitnessTrax"))) - .build(); - - let layout = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - - let initial_view = View::Placeholder(PlaceholderView::new().upcast()); - - layout.append(&header); - layout.append(initial_view.widget()); - - window.set_content(Some(&layout)); - window.present(); - - let s = Self { - app_tx, - window, - layout, - current_view: Rc::new(RefCell::new(initial_view)), - settings: gio::Settings::new(app_id), - }; - - s - } - - pub fn change_view(&self, view: ViewName) { - self.swap_main(self.construct_view(view)); - } - - pub fn process_response(&self, response: AppResponse) { - match response { - AppResponse::DatabaseChanged(db_path) => { - self.settings - .set_string("series-path", db_path.to_str().unwrap()) - .unwrap(); - self.change_view(ViewName::Historical); - } - AppResponse::NoDatabase => { - self.change_view(ViewName::Welcome); - } - AppResponse::Records => { - self.change_view(ViewName::Historical); - } - } - } - - // Switch views. - // - // This function only replaces the old view with the one which matches the current view state. - // It is responsible for ensuring that the new view goes into the layout in the correct - // position. - fn swap_main(&self, view: View) { - let mut current_widget = self.current_view.borrow_mut(); - self.layout.remove(&*current_widget.widget()); - *current_widget = view; - self.layout.append(&*current_widget.widget()); - } - - fn construct_view(&self, view: ViewName) -> View { - match view { - ViewName::Placeholder => View::Placeholder(PlaceholderView::new().upcast()), - ViewName::Welcome => View::Welcome( - WelcomeView::new({ - let s = self.clone(); - Box::new(move |path: PathBuf| { - s.app_tx - .send_blocking(AppInvocation::OpenDatabase(path)) - .unwrap(); - }) - }) - .upcast(), - ), - ViewName::Historical => View::Historical(HistoricalView::new().upcast()), - } - } -} - fn main() { // I still don't fully understand gio resources. resources_register_include! is convenient // because I don't have to deal with filesystem locations at runtime. However, I think other @@ -446,7 +159,7 @@ fn main() { // UI. let (app_tx, app_rx) = async_channel::unbounded::(); - let window = AppWindow::new(app_id, adw_app, app_tx.clone()); + let window = AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, app_tx.clone()); // Spawn a future where the UI will receive messages for the app window. Previously, this // would have been done by creating a glib::MainContext::channel(), but that has been diff --git a/fitnesstrax/app/src/views/historical_view.rs b/fitnesstrax/app/src/views/historical_view.rs new file mode 100644 index 0000000..b69e960 --- /dev/null +++ b/fitnesstrax/app/src/views/historical_view.rs @@ -0,0 +1,54 @@ +/* +Copyright 2023, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax 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. + +FitnessTrax 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 FitnessTrax. If not, see . +*/ + +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; + +/// The historical view will show a window into the main database. It will show some version of +/// daily summaries, daily details, and will provide all functions the user may need for editing +/// records. +pub struct HistoricalViewPrivate {} + +#[glib::object_subclass] +impl ObjectSubclass for HistoricalViewPrivate { + const NAME: &'static str = "HistoricalView"; + type Type = HistoricalView; + type ParentType = gtk::Box; + + fn new() -> Self { + Self {} + } +} + +impl ObjectImpl for HistoricalViewPrivate {} +impl WidgetImpl for HistoricalViewPrivate {} +impl BoxImpl for HistoricalViewPrivate {} + +glib::wrapper! { + pub struct HistoricalView(ObjectSubclass) @extends gtk::Box, gtk::Widget; +} + +impl HistoricalView { + pub fn new() -> Self { + let s: Self = Object::builder().build(); + + let label = gtk::Label::builder() + .label("Database has been configured and now it is time to show data") + .build(); + s.append(&label); + s + } +} diff --git a/fitnesstrax/app/src/views/mod.rs b/fitnesstrax/app/src/views/mod.rs new file mode 100644 index 0000000..ecbdea3 --- /dev/null +++ b/fitnesstrax/app/src/views/mod.rs @@ -0,0 +1,47 @@ +/* +Copyright 2023, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax 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. + +FitnessTrax 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 FitnessTrax. If not, see . +*/ + +mod historical_view; +pub use historical_view::HistoricalView; + +mod placeholder_view; +pub use placeholder_view::PlaceholderView; + +mod welcome_view; +pub use welcome_view::WelcomeView; + +#[derive(Clone, Debug, PartialEq)] +pub enum ViewName { + Placeholder, + Welcome, + Historical, +} + +pub enum View { + Placeholder(gtk::Widget), + Welcome(gtk::Widget), + Historical(gtk::Widget), +} + +impl View { + pub fn widget<'a>(&'a self) -> &'a gtk::Widget { + match self { + View::Placeholder(widget) => widget, + View::Welcome(widget) => widget, + View::Historical(widget) => widget, + } + } +} diff --git a/fitnesstrax/app/src/views/placeholder_view.rs b/fitnesstrax/app/src/views/placeholder_view.rs new file mode 100644 index 0000000..8ce2804 --- /dev/null +++ b/fitnesstrax/app/src/views/placeholder_view.rs @@ -0,0 +1,46 @@ +/* +Copyright 2023, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax 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. + +FitnessTrax 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 FitnessTrax. If not, see . +*/ + +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; + +pub struct PlaceholderViewPrivate {} + +#[glib::object_subclass] +impl ObjectSubclass for PlaceholderViewPrivate { + const NAME: &'static str = "PlaceholderView"; + type Type = PlaceholderView; + type ParentType = gtk::Box; + + fn new() -> Self { + Self {} + } +} + +impl ObjectImpl for PlaceholderViewPrivate {} +impl WidgetImpl for PlaceholderViewPrivate {} +impl BoxImpl for PlaceholderViewPrivate {} + +glib::wrapper! { + pub struct PlaceholderView(ObjectSubclass) @extends gtk::Box, gtk::Widget; +} + +impl PlaceholderView { + pub fn new() -> Self { + let s: Self = Object::builder().build(); + s + } +} diff --git a/fitnesstrax/app/src/views/welcome_view.rs b/fitnesstrax/app/src/views/welcome_view.rs new file mode 100644 index 0000000..5156c9c --- /dev/null +++ b/fitnesstrax/app/src/views/welcome_view.rs @@ -0,0 +1,98 @@ +/* +Copyright 2023, Savanni D'Gerinel + +This file is part of FitnessTrax. + +FitnessTrax 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. + +FitnessTrax 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 FitnessTrax. If not, see . +*/ + +use crate::components::FileChooserRow; +use glib::Object; +use gtk::{prelude::*, subclass::prelude::*}; +use std::path::PathBuf; + +/// This is the view to show if the application has not yet been configured. It will walk the user +/// through the most critical setup steps so that we can move on to the other views in the app. +pub struct WelcomeViewPrivate {} + +#[glib::object_subclass] +impl ObjectSubclass for WelcomeViewPrivate { + const NAME: &'static str = "WelcomeView"; + type Type = WelcomeView; + type ParentType = gtk::Box; + + fn new() -> Self { + Self {} + } +} + +impl ObjectImpl for WelcomeViewPrivate {} +impl WidgetImpl for WelcomeViewPrivate {} +impl BoxImpl for WelcomeViewPrivate {} + +glib::wrapper! { + pub struct WelcomeView(ObjectSubclass) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable; +} + +impl WelcomeView { + pub fn new(on_save: Box) -> Self + where + F: Fn(PathBuf) + 'static, + { + let s: Self = Object::builder().build(); + s.set_orientation(gtk::Orientation::Vertical); + s.set_css_classes(&["welcome"]); + + // Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page + // branch. + let title = gtk::Label::builder() + .label("Welcome to FitnessTrax") + .css_classes(["welcome-title"]) + .build(); + + let content = gtk::Box::builder() + .css_classes(["model-content"]) + .orientation(gtk::Orientation::Vertical) + .vexpand(true) + .build(); + + let save_button = gtk::Button::builder() + .label("Save Settings") + .sensitive(false) + .build(); + + // The database selection row should be a box that shows a default database path, along with a + // button that triggers a file chooser dialog. Once the dialog returns, the box should be + // updated to reflect the chosen path. + let db_row = FileChooserRow::new({ + let save_button = save_button.clone(); + move |_| save_button.set_sensitive(true) + }); + + content.append(>k::Label::new(Some("Welcome to FitnessTrax. The application has not yet been configured, so I will walk you through that. Let's start out by selecting your database."))); + content.append(&db_row); + + let on_save = on_save; + save_button.connect_clicked({ + move |_| { + if let Some(path) = db_row.path() { + on_save(path) + } + } + }); + + s.append(&title); + s.append(&content); + s.append(&save_button); + + s + } +}