diff options
| author | Joe Carstairs <me@joeac.net> | 2026-02-07 11:52:08 +0000 |
|---|---|---|
| committer | Joe Carstairs <me@joeac.net> | 2026-02-07 12:02:27 +0000 |
| commit | 1664e741ad35ce52a01788c34029f0b2ea477e32 (patch) | |
| tree | dd6bd7c83a7e834c75815a786d67b5188cdd1f48 /schist_desktop_gui/src/gui | |
| parent | 179878d12ba10ac242da2044f4c6dfe4d4829e59 (diff) | |
task-001: can navigate file screen options with keyboard
Diffstat (limited to 'schist_desktop_gui/src/gui')
24 files changed, 501 insertions, 268 deletions
diff --git a/schist_desktop_gui/src/gui/components.rs b/schist_desktop_gui/src/gui/components.rs index b0f8faf..bb5590f 100644 --- a/schist_desktop_gui/src/gui/components.rs +++ b/schist_desktop_gui/src/gui/components.rs @@ -3,13 +3,15 @@ pub mod bucket_navigation_entry; pub mod buckets_view; pub mod button; pub mod navigation; +pub mod panel; pub mod text; pub mod transactions_view; pub use balances_view::BalancesView; pub use bucket_navigation_entry::BucketNavigationEntry; pub use buckets_view::BucketsView; -pub use button::{active_button, inactive_button, panel_button}; +pub use button::{button, panel_button}; pub use navigation::Navigation; +pub use panel::Panel; pub use text::Text; pub use transactions_view::TransactionsView; diff --git a/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs b/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs index 1dd5c6f..5661954 100644 --- a/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs +++ b/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs @@ -38,7 +38,7 @@ fn select_prev_bucket(balances_view: &mut BalancesView) -> () { .navigation .update(navigation::Message::SelectPrev) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) | navigation::Action::SelectOption(option) => { balances_view.greeting = make_balances_view_greeting(Some(option)); } navigation::Action::None => {} @@ -50,7 +50,7 @@ fn select_next_bucket(balances_view: &mut BalancesView) -> () { .navigation .update(navigation::Message::SelectNext) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) | navigation::Action::SelectOption(option) => { balances_view.greeting = make_balances_view_greeting(Some(option)); } navigation::Action::None => {} @@ -63,7 +63,7 @@ fn set_buckets(balances_view: &mut BalancesView, buckets: Vec<schist_models::Buc .navigation .update(navigation::Message::SetOptions(view_group_names(buckets))) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) | navigation::Action::SelectOption(option) => { balances_view.greeting = make_balances_view_greeting(Some(option)); } navigation::Action::None => {} @@ -72,7 +72,8 @@ fn set_buckets(balances_view: &mut BalancesView, buckets: Vec<schist_models::Buc fn update_navigation(balances_view: &mut BalancesView, message: navigation::Message<Text>) -> () { match balances_view.navigation.update(message) { - navigation::Action::SelectOption(group_name) => { + navigation::Action::ActivateOption(group_name) + | navigation::Action::SelectOption(group_name) => { balances_view.greeting = make_balances_view_greeting(Some(group_name)); } navigation::Action::None => {} diff --git a/schist_desktop_gui/src/gui/components/bucket_navigation_entry.rs b/schist_desktop_gui/src/gui/components/bucket_navigation_entry.rs index be11178..69665a3 100644 --- a/schist_desktop_gui/src/gui/components/bucket_navigation_entry.rs +++ b/schist_desktop_gui/src/gui/components/bucket_navigation_entry.rs @@ -4,7 +4,7 @@ use iced::Element; use itertools::Either; use schist_models::Bucket; -use crate::gui::components::navigation; +use crate::{gui::components::navigation, traits::Focusable}; mod bucket_group_name; mod bucket_name_and_balance; @@ -66,3 +66,19 @@ impl<'a> From<&'a BucketNavigationEntry> bucket_navigation_entry.0.as_ref().either_into() } } + +impl Focusable for BucketNavigationEntry { + fn focus(&mut self) { + match self.0.as_mut() { + Either::Left(group) => group.focus(), + Either::Right(bucket) => bucket.focus(), + }; + } + + fn unfocus(&mut self) { + match self.0.as_mut() { + Either::Left(group) => group.unfocus(), + Either::Right(bucket) => bucket.unfocus(), + }; + } +} diff --git a/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_group_name.rs b/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_group_name.rs index cbc9521..98902ee 100644 --- a/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_group_name.rs +++ b/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_group_name.rs @@ -3,12 +3,16 @@ use iced::{ Element, Length, }; -use crate::gui::components::{navigation, BucketNavigationEntry}; +use crate::{ + gui::components::{navigation, BucketNavigationEntry}, + impl_focusable, +}; #[derive(Clone, Debug, PartialEq)] pub struct BucketGroupName { pub group: String, pub is_open: bool, + pub is_focused: bool, } impl BucketGroupName { @@ -16,6 +20,7 @@ impl BucketGroupName { Self { group: group.to_string(), is_open, + is_focused: false, } } } @@ -34,3 +39,5 @@ impl<'a> From<&'a BucketGroupName> for Element<'a, navigation::Message<BucketNav row![text(text_content).width(Length::Fill),].into() } } + +impl_focusable!(BucketGroupName); diff --git a/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_name_and_balance.rs b/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_name_and_balance.rs index 7abca57..874540f 100644 --- a/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_name_and_balance.rs +++ b/schist_desktop_gui/src/gui/components/bucket_navigation_entry/bucket_name_and_balance.rs @@ -4,17 +4,22 @@ use iced::{ }; use schist_models::Bucket; -use crate::gui::components::{navigation, BucketNavigationEntry}; +use crate::{ + gui::components::{navigation, BucketNavigationEntry}, + impl_focusable, +}; #[derive(Clone, Debug, PartialEq)] pub struct BucketNameAndBalance { pub bucket: Bucket, + is_focused: bool, } impl BucketNameAndBalance { pub fn new(bucket: &Bucket) -> Self { Self { bucket: bucket.clone(), + is_focused: false, } } } @@ -40,3 +45,5 @@ impl<'a> From<&'a BucketNameAndBalance> .into() } } + +impl_focusable!(BucketNameAndBalance); diff --git a/schist_desktop_gui/src/gui/components/buckets_view.rs b/schist_desktop_gui/src/gui/components/buckets_view.rs index 2b3258a..7f5b218 100644 --- a/schist_desktop_gui/src/gui/components/buckets_view.rs +++ b/schist_desktop_gui/src/gui/components/buckets_view.rs @@ -63,6 +63,43 @@ impl BucketsView { fn update_greeting(&mut self, active_entry: &str) { self.greeting = format!("Hello, {}!", active_entry); } + + fn expand_group(&mut self, group: String, buckets: Vec<Bucket>) -> Action { + match self.navigation.insert_after_first( + |bucket_navigation_entry| { + bucket_navigation_entry.is_group_and(|bucket_group_name| { + !bucket_group_name.is_open && bucket_group_name.group.eq(group.as_str()) + }) + }, + &buckets + .iter() + .map(BucketNavigationEntry::from_bucket) + .collect(), + ) { + InsertOptionResult::InsertedOptions => { + self.navigation.replace_first( + |bucket_navigation_entry| { + bucket_navigation_entry.is_group_and(|g| g.group.eq(group.as_str())) + }, + &vec![BucketNavigationEntry::from_group_open(&group)], + ); + Action::None + } + InsertOptionResult::NoSuchOption => Action::None, + } + } + + fn collapse_group(&mut self, group: String) -> Action { + self.navigation + .remove_options_where(|bucket_navigation_entry| { + bucket_navigation_entry.is_bucket_and(|bucket| bucket.group.eq(&group)) + }); + self.navigation.replace_first( + |bucket_navigation_entry| bucket_navigation_entry.is_group_and(|g| g.group.eq(&group)), + &vec![BucketNavigationEntry::from_group_closed(&group)], + ); + Action::None + } } impl<'a> Viewable<'a, Message> for BucketsView { @@ -97,7 +134,8 @@ impl<'a> Component<'a, Message, Action> for BucketsView { .navigation .update(navigation::Message::SetOptions(groups)) { - navigation::Action::SelectOption(bucket_navigation_entry) => { + navigation::Action::ActivateOption(bucket_navigation_entry) + | navigation::Action::SelectOption(bucket_navigation_entry) => { self.update_greeting(&bucket_navigation_entry.to_string()); Action::None } @@ -106,7 +144,8 @@ impl<'a> Component<'a, Message, Action> for BucketsView { } Message::NavigationMessage(message) => match self.navigation.update(message) { - navigation::Action::SelectOption(bucket_navigation_entry) => { + navigation::Action::ActivateOption(bucket_navigation_entry) + | navigation::Action::SelectOption(bucket_navigation_entry) => { self.update_greeting(&bucket_navigation_entry.to_string()); Action::None } @@ -114,7 +153,8 @@ impl<'a> Component<'a, Message, Action> for BucketsView { }, Message::SelectNext => match self.navigation.update(navigation::Message::SelectNext) { - navigation::Action::SelectOption(bucket_navigation_entry) => { + navigation::Action::ActivateOption(bucket_navigation_entry) + | navigation::Action::SelectOption(bucket_navigation_entry) => { self.update_greeting(&bucket_navigation_entry.to_string()); Action::None } @@ -122,51 +162,17 @@ impl<'a> Component<'a, Message, Action> for BucketsView { }, Message::SelectPrev => match self.navigation.update(navigation::Message::SelectPrev) { - navigation::Action::SelectOption(bucket_navigation_entry) => { + navigation::Action::ActivateOption(bucket_navigation_entry) + | navigation::Action::SelectOption(bucket_navigation_entry) => { self.update_greeting(&bucket_navigation_entry.to_string()); Action::None } navigation::Action::None => Action::None, }, - Message::ExpandGroup(group, buckets) => { - match self.navigation.insert_after_first( - |bucket_navigation_entry| { - bucket_navigation_entry.is_group_and(|bucket_group_name| { - !bucket_group_name.is_open && bucket_group_name.group.eq(group.as_str()) - }) - }, - &buckets - .iter() - .map(BucketNavigationEntry::from_bucket) - .collect(), - ) { - InsertOptionResult::InsertedOptions => { - self.navigation.replace_first( - |bucket_navigation_entry| { - bucket_navigation_entry.is_group_and(|g| g.group.eq(group.as_str())) - }, - &vec![BucketNavigationEntry::from_group_open(&group)], - ); - Action::None - } - InsertOptionResult::NoSuchOption => Action::None, - } - } + Message::ExpandGroup(group, buckets) => self.expand_group(group, buckets), - Message::CollapseGroup(group) => { - self.navigation - .remove_options_where(|bucket_navigation_entry| { - bucket_navigation_entry.is_bucket_and(|bucket| bucket.group.eq(&group)) - }); - self.navigation.replace_first( - |bucket_navigation_entry| { - bucket_navigation_entry.is_group_and(|g| g.group.eq(&group)) - }, - &vec![BucketNavigationEntry::from_group_closed(&group)], - ); - Action::None - } + Message::CollapseGroup(group) => self.collapse_group(group), } } } diff --git a/schist_desktop_gui/src/gui/components/button.rs b/schist_desktop_gui/src/gui/components/button.rs index 6882273..8a33e39 100644 --- a/schist_desktop_gui/src/gui/components/button.rs +++ b/schist_desktop_gui/src/gui/components/button.rs @@ -1,29 +1,58 @@ -use iced::{widget::Button, Element}; +use iced::{widget::Button, Element, Theme}; use crate::style::*; -pub fn active_button<'a, Content, Message>(content: &'a Content) -> Button<'a, Message> +pub fn button<'a, Content, Message>( + content: &'a Content, + message: Message, + focus: bool, +) -> Button<'a, Message> where Element<'a, Message>: From<&'a Content>, { iced::widget::button(content) - .style(|theme: &iced::Theme, _status| iced::widget::button::Style { - background: Some(iced::Background::Color( - theme.extended_palette().primary.base.text, - )), - text_color: theme.extended_palette().primary.base.color, - ..Default::default() + .on_press(message) + .style(move |theme, status| match (status, focus) { + (iced::widget::button::Status::Hovered, _) => hovered_button_style(theme), + (iced::widget::button::Status::Pressed, _) | (_, true) => focused_button_style(theme), + (iced::widget::button::Status::Active, false) => base_button_style(theme), + (iced::widget::button::Status::Disabled, false) => todo!(), }) .width(iced::Length::Fill) } -pub fn inactive_button<'a, Content: Into<Element<'a, Message>>, Message>( - content: Content, - on_press: Message, -) -> Button<'a, Message> { - iced::widget::button(content) - .on_press(on_press) - .width(iced::Length::Fill) +fn hovered_button_style(theme: &Theme) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().background.weak.color, + )), + text_color: theme.extended_palette().primary.base.text, + ..base_button_style(theme) + } +} + +fn focused_button_style(theme: &Theme) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().primary.base.text, + )), + text_color: theme.extended_palette().primary.base.color, + ..base_button_style(theme) + } +} + +fn base_button_style(theme: &Theme) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().primary.base.color, + )), + border: iced::Border { + color: theme.extended_palette().primary.base.text, + ..Default::default() + }, + text_color: theme.extended_palette().primary.base.text, + ..Default::default() + } } pub fn panel_button<'a, Content: Into<Element<'a, Message>>, Message>( diff --git a/schist_desktop_gui/src/gui/components/navigation.rs b/schist_desktop_gui/src/gui/components/navigation.rs index 6fe100d..1780f1a 100644 --- a/schist_desktop_gui/src/gui/components/navigation.rs +++ b/schist_desktop_gui/src/gui/components/navigation.rs @@ -1,4 +1,5 @@ mod navigate_navigation; +mod navigation_find; mod navigation_insert_options; mod navigation_remove_options; mod navigation_replace_options; @@ -22,8 +23,9 @@ pub struct Navigation<TOption> { options_after_active: Vec<TOption>, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum Message<TOption> { + ActivateOption(TOption), SelectNext, SelectOption(TOption), SelectPrev, @@ -31,6 +33,7 @@ pub enum Message<TOption> { } pub enum Action<TOption> { + ActivateOption(TOption), SelectOption(TOption), None, } diff --git a/schist_desktop_gui/src/gui/components/navigation/dummy_navigation_option.rs b/schist_desktop_gui/src/gui/components/navigation/dummy_navigation_option.rs index ae5799f..6875305 100644 --- a/schist_desktop_gui/src/gui/components/navigation/dummy_navigation_option.rs +++ b/schist_desktop_gui/src/gui/components/navigation/dummy_navigation_option.rs @@ -2,17 +2,22 @@ use std::fmt::Display; use iced::Element; -use crate::gui::components::{navigation, Text}; +use crate::{ + gui::components::{navigation, Text}, + impl_focusable, +}; #[derive(Clone, Debug, PartialEq)] pub struct DummyNavigationOption { pub name: String, + is_focused: bool, } impl DummyNavigationOption { pub fn new(name: &str) -> Self { Self { name: String::from(name), + is_focused: false, } } } @@ -30,3 +35,5 @@ impl Display for DummyNavigationOption { f.write_str(&self.name) } } + +impl_focusable!(DummyNavigationOption); diff --git a/schist_desktop_gui/src/gui/components/navigation/navigate_navigation.rs b/schist_desktop_gui/src/gui/components/navigation/navigate_navigation.rs index f399cd0..72bc503 100644 --- a/schist_desktop_gui/src/gui/components/navigation/navigate_navigation.rs +++ b/schist_desktop_gui/src/gui/components/navigation/navigate_navigation.rs @@ -1,24 +1,39 @@ use iced::Element; -use crate::traits::{Navigable, NavigationResult}; +use crate::traits::{Focusable, Navigable, NavigationResult}; use super::{Message, Navigation}; impl<'a, TOption> Navigable<TOption> for Navigation<TOption> where - TOption: Clone + PartialEq + 'a, + TOption: Clone + Focusable + PartialEq + 'a, Element<'a, Message<TOption>>: From<&'a TOption>, { fn move_next(&mut self) -> NavigationResult<TOption> { - if let Some(next) = self.options_after_active.clone().first() { + if let Some(next) = self.options_after_active.clone().first_mut() { self.options_before_active = vec![ self.options_before_active.clone(), - self.active_option.clone().map_or(vec![], |o| vec![o]), + self.active_option.clone().map_or_else(Vec::new, |mut o| { + o.unfocus(); + vec![o] + }), ] .concat(); + next.focus(); self.active_option = Some(next.clone()); - self.options_after_active = self.options_after_active.split_at(1).1.to_vec(); + self.options_after_active.remove(0); NavigationResult::Moved(next.clone()) + } else if let Some(first) = self + .options_before_active + .clone() + .first_mut() + .filter(|_| self.active_option.is_none()) + { + self.options_after_active = self.options_before_active.split_at(1).1.to_vec(); + self.options_before_active = Vec::new(); + first.focus(); + self.active_option = Some(first.clone()); + NavigationResult::Moved(first.clone()) } else { NavigationResult::LastOption } @@ -27,11 +42,15 @@ where fn move_prev(&mut self) -> NavigationResult<TOption> { if let Some(prev) = self.options_before_active.clone().last() { self.options_after_active = vec![ - self.active_option.clone().map_or(vec![], |o| vec![o]), + self.active_option.clone().map_or(vec![], |mut o| { + o.unfocus(); + vec![o] + }), self.options_after_active.clone(), ] .concat(); self.active_option = Some(prev.clone()); + self.active_option.iter_mut().for_each(TOption::focus); self.options_before_active = self .options_before_active .split_last() diff --git a/schist_desktop_gui/src/gui/components/navigation/navigation_find.rs b/schist_desktop_gui/src/gui/components/navigation/navigation_find.rs new file mode 100644 index 0000000..79c8ab0 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation/navigation_find.rs @@ -0,0 +1,16 @@ +impl<'a, TOption> super::Navigation<TOption> +where + TOption: Clone + PartialEq + 'a, +{ + pub fn find<P>(&self, pred: P) -> Option<TOption> + where + P: Fn(&TOption) -> bool, + { + self.options_before_active + .iter() + .find(|o| pred(o)) + .cloned() + .or_else(|| self.active_option.clone().filter(|o| pred(o))) + .or_else(|| self.options_after_active.iter().find(|o| pred(o)).cloned()) + } +} diff --git a/schist_desktop_gui/src/gui/components/navigation/navigation_replace_options.rs b/schist_desktop_gui/src/gui/components/navigation/navigation_replace_options.rs index e24059d..496250d 100644 --- a/schist_desktop_gui/src/gui/components/navigation/navigation_replace_options.rs +++ b/schist_desktop_gui/src/gui/components/navigation/navigation_replace_options.rs @@ -1,6 +1,8 @@ use iced::Element; use itertools::Itertools; +use crate::traits::Focusable; + use super::{Message, Navigation}; #[derive(PartialEq)] @@ -11,7 +13,7 @@ pub enum ReplaceOptionResult { impl<'a, TOption> Navigation<TOption> where - TOption: Clone + PartialEq + 'a, + TOption: Clone + Focusable + PartialEq + 'a, Element<'a, Message<TOption>>: From<&'a TOption>, { pub fn replace_first<P>(&'a mut self, pred: P, new: &'a Vec<TOption>) -> ReplaceOptionResult @@ -92,6 +94,7 @@ where } self.active_option = new.first().cloned(); + self.active_option.iter_mut().for_each(|o| o.focus()); self.options_after_active .splice(0..0, new.split_at(1).1.to_vec()); ReplaceOptionResult::ReplacedOption diff --git a/schist_desktop_gui/src/gui/components/navigation/navigation_select_option.rs b/schist_desktop_gui/src/gui/components/navigation/navigation_select_option.rs index 4192059..5aa96cd 100644 --- a/schist_desktop_gui/src/gui/components/navigation/navigation_select_option.rs +++ b/schist_desktop_gui/src/gui/components/navigation/navigation_select_option.rs @@ -1,5 +1,7 @@ use iced::Element; +use crate::traits::Focusable; + use super::{Message, Navigation}; pub enum SelectOptionResult<TOption> { @@ -10,7 +12,7 @@ pub enum SelectOptionResult<TOption> { impl<'a, TOption> Navigation<TOption> where - TOption: Clone + PartialEq + 'a, + TOption: Clone + Focusable + PartialEq + 'a, Element<'a, Message<TOption>>: From<&'a TOption>, { pub fn select_option(&mut self, option: TOption) -> SelectOptionResult<TOption> @@ -30,15 +32,17 @@ where { let split = self.options_before_active.split_at(index); self.options_after_active = vec![ - self.active_option - .clone() - .map_or(vec![], |active_option| vec![active_option]), + self.active_option.as_mut().map_or(vec![], |active_option| { + active_option.unfocus(); + vec![active_option.clone()] + }), split.1.split_at(1).1.to_vec(), self.options_after_active.clone(), ] .concat(); self.options_before_active = split.0.to_vec(); self.active_option = Some(option.clone()); + self.active_option.iter_mut().for_each(TOption::focus); SelectOptionResult::SelectedExistingOption(option) } else if let Some(index) = self .options_after_active @@ -49,21 +53,25 @@ where self.options_before_active = vec![ self.options_before_active.clone(), split.0.to_vec(), - self.active_option - .clone() - .map_or(vec![], |active_option| vec![active_option]), + self.active_option.as_mut().map_or(vec![], |active_option| { + active_option.unfocus(); + vec![active_option.clone()] + }), ] .concat(); self.active_option = Some(option.clone()); + self.active_option.iter_mut().for_each(TOption::focus); self.options_after_active = split.1.split_at(1).1.to_vec(); SelectOptionResult::SelectedExistingOption(option) } else { - if let Some(active_option) = &self.active_option { + if let Some(active_option) = &mut self.active_option { + active_option.unfocus(); self.options_before_active.push(active_option.clone()); } self.options_before_active .append(&mut self.options_after_active); self.active_option = Some(option.clone()); + self.active_option.iter_mut().for_each(TOption::focus); self.options_after_active = vec![]; SelectOptionResult::SelectedNewOption(option) } diff --git a/schist_desktop_gui/src/gui/components/navigation/navigation_set_options.rs b/schist_desktop_gui/src/gui/components/navigation/navigation_set_options.rs index c2c9952..3eb455c 100644 --- a/schist_desktop_gui/src/gui/components/navigation/navigation_set_options.rs +++ b/schist_desktop_gui/src/gui/components/navigation/navigation_set_options.rs @@ -1,5 +1,7 @@ use iced::Element; +use crate::traits::Focusable; + use super::{Message, Navigation}; pub enum SetOptionsResult<TOption> { @@ -9,10 +11,12 @@ pub enum SetOptionsResult<TOption> { impl<'a, TOption> Navigation<TOption> where - TOption: Clone + PartialEq + 'a, + TOption: Clone + Focusable + PartialEq + 'a, Element<'a, Message<TOption>>: From<&'a TOption>, { - pub fn set_options(&mut self, options: Vec<TOption>) -> SetOptionsResult<TOption> { + pub fn set_options(&mut self, options: &mut Vec<TOption>) -> SetOptionsResult<TOption> { + options.iter_mut().for_each(TOption::unfocus); + let active_option_index = self.active_option .clone() @@ -31,6 +35,7 @@ where .split_last() .map_or_else(Vec::new, |(_last, rest)| rest.to_vec()); self.active_option = options.last().cloned(); + self.active_option.iter_mut().for_each(TOption::focus); options.last().cloned().map_or( SetOptionsResult::SetOptionsAndKeptSelection, SetOptionsResult::SetOptionsAndSetSelectedOption, diff --git a/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs b/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs index 1d5a734..7bcef98 100644 --- a/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs +++ b/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs @@ -4,14 +4,14 @@ use crate::{ gui::components::navigation::{ navigation_select_option::SelectOptionResult, navigation_set_options::SetOptionsResult, }, - traits::{Component, Navigable, NavigationResult}, + traits::{Component, Focusable, Navigable, NavigationResult}, }; use super::{Action, Message, Navigation}; impl<'a, TOption> Component<'a, Message<TOption>, Action<TOption>> for Navigation<TOption> where - TOption: Clone + PartialEq + 'a, + TOption: Clone + Focusable + PartialEq + 'a, Element<'a, Message<TOption>>: From<&'a TOption>, { fn update(&mut self, message: Message<TOption>) -> Action<TOption> { @@ -21,23 +21,22 @@ where | SelectOptionResult::SelectedNewOption(option) => Action::SelectOption(option), SelectOptionResult::AlreadySelected => Action::None, }, - - Message::SetOptions(options) => match self.set_options(options) { + Message::SetOptions(mut options) => match self.set_options(&mut options) { SetOptionsResult::SetOptionsAndKeptSelection => Action::None, SetOptionsResult::SetOptionsAndSetSelectedOption(option) => { Action::SelectOption(option) } }, - Message::SelectNext => match self.move_next() { NavigationResult::Moved(option) => Action::SelectOption(option), NavigationResult::LastOption => Action::None, }, - Message::SelectPrev => match self.move_prev() { NavigationResult::Moved(option) => Action::SelectOption(option), NavigationResult::LastOption => Action::None, }, + + Message::ActivateOption(option) => Action::ActivateOption(option), } } } diff --git a/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs b/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs index d65328c..80d6d59 100644 --- a/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs +++ b/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs @@ -1,6 +1,6 @@ use iced::Element; -use crate::gui::components::{active_button, inactive_button}; +use crate::gui::components::button; use super::{Message, Navigation}; @@ -13,15 +13,10 @@ where &'a self, option: &'a TOption, ) -> Element<'a, Message<TOption>> { - if self + let focus = self .active_option .as_ref() - .is_some_and(|o| TOption::eq(o, option)) - { - active_button(option) - } else { - inactive_button(option, Message::SelectOption(option.clone())) - } - .into() + .is_some_and(|o| TOption::eq(o, option)); + button(option, Message::ActivateOption(option.clone()), focus).into() } } diff --git a/schist_desktop_gui/src/gui/components/panel.rs b/schist_desktop_gui/src/gui/components/panel.rs new file mode 100644 index 0000000..364659b --- /dev/null +++ b/schist_desktop_gui/src/gui/components/panel.rs @@ -0,0 +1,101 @@ +use iced::{ + widget::{button::Status, column}, + Theme, +}; + +use crate::{ + gui::components::{navigation, panel_button, Text}, + traits::Focusable, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Panel<Id> { + pub err: Option<String>, + pub id: Id, + pub small: Option<String>, + pub name: String, + pub is_focused: bool, +} + +impl<Id> Panel<Id> { + pub fn new(id: Id, text: &str, small: Option<String>, err: Option<String>) -> Self { + Self { + err: err.clone(), + id, + small: small.clone(), + name: text.to_string(), + is_focused: false, + } + } +} + +impl<'a, Id> From<&'a Panel<Id>> for iced::Element<'a, navigation::Message<Panel<Id>>> +where + Id: Clone, +{ + fn from(value: &'a Panel<Id>) -> Self { + let mut cols: Vec<iced::Element<'a, navigation::Message<Panel<Id>>>> = Vec::new(); + if let Some(err) = &value.err { + cols.push( + Text::default(err) + .highlight_maybe(value.is_focused) + .small() + .danger() + .into(), + ); + } + if let Some(small) = &value.small { + cols.push( + Text::default(small) + .highlight_maybe(value.is_focused) + .small() + .weak() + .into(), + ); + } + cols.push( + Text::default(&value.name) + .highlight_maybe(value.is_focused) + .into(), + ); + panel_button( + column(cols), + navigation::Message::ActivateOption(value.clone()), + ) + .style(|theme, status| match (status, value.is_focused) { + (Status::Hovered, false) => hovered_style(theme), + (_, true) | (Status::Pressed, _) => focused_style(theme), + (Status::Active, false) | (Status::Disabled, false) => Default::default(), + }) + .into() + } +} + +fn hovered_style(theme: &Theme) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().background.weak.color, + )), + text_color: theme.extended_palette().primary.base.text, + ..Default::default() + } +} + +fn focused_style(theme: &Theme) -> iced::widget::button::Style { + iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().primary.base.text, + )), + text_color: theme.extended_palette().primary.base.color, + ..Default::default() + } +} + +impl<Id> Focusable for Panel<Id> { + fn focus(&mut self) { + self.is_focused = true; + } + fn unfocus(&mut self) { + self.is_focused = false; + } +} diff --git a/schist_desktop_gui/src/gui/components/text.rs b/schist_desktop_gui/src/gui/components/text.rs index a3093ac..d171043 100644 --- a/schist_desktop_gui/src/gui/components/text.rs +++ b/schist_desktop_gui/src/gui/components/text.rs @@ -1,6 +1,6 @@ use std::borrow::Borrow; -use crate::style::*; +use crate::{impl_focusable, style::*}; #[derive(Clone, Debug, PartialEq)] pub struct Text { @@ -8,12 +8,14 @@ pub struct Text { colour: Option<Colour>, size: Option<Size>, strength: Option<Strength>, + is_focused: bool, } #[derive(Clone, Debug, PartialEq)] enum Colour { Danger, Primary, + Highlight, } #[derive(Clone, Debug, PartialEq)] @@ -35,6 +37,7 @@ impl Text { colour: Some(Colour::Primary), size: Some(Size::Base), strength: Some(Strength::Base), + is_focused: false, } } @@ -44,6 +47,7 @@ impl Text { colour: None, size: None, strength: None, + is_focused: false, } } @@ -71,6 +75,17 @@ impl Text { ..self.clone() } } + + pub fn highlight_maybe(&self, do_highlight: bool) -> Self { + if do_highlight { + Self { + colour: Some(Colour::Highlight), + ..self.clone() + } + } else { + self.clone() + } + } } impl<'a, Message> From<Text> for iced::Element<'a, Message> { @@ -111,8 +126,16 @@ where (Some(Colour::Primary), Some(Strength::Weak)) => { Some(theme.extended_palette().primary.weak.text) } + (Some(Colour::Highlight), Some(Strength::Base) | None) => { + Some(theme.extended_palette().primary.base.color) + } + (Some(Colour::Highlight), Some(Strength::Weak)) => { + Some(theme.extended_palette().primary.weak.color) + } (None, _) => None, }, }) .into() } + +impl_focusable!(Text); diff --git a/schist_desktop_gui/src/gui/components/transactions_view.rs b/schist_desktop_gui/src/gui/components/transactions_view.rs index 1956d38..1cb569c 100644 --- a/schist_desktop_gui/src/gui/components/transactions_view.rs +++ b/schist_desktop_gui/src/gui/components/transactions_view.rs @@ -62,7 +62,8 @@ impl<'a> Component<'a, Message, Action> for TransactionsView { match message { Message::NavigationMessage(message) => { match self.navigation.update(message) { - navigation::Action::SelectOption(group_name) => { + navigation::Action::ActivateOption(group_name) + | navigation::Action::SelectOption(group_name) => { self.greeting = make_greeting(group_name); Action::None } @@ -77,7 +78,8 @@ impl<'a> Component<'a, Message, Action> for TransactionsView { .update(navigation::Message::SetOptions(view_account_names( accounts, ))) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) + | navigation::Action::SelectOption(option) => { self.greeting = make_greeting(option); Action::None } @@ -86,7 +88,8 @@ impl<'a> Component<'a, Message, Action> for TransactionsView { } Message::SelectNextTransaction => { match self.navigation.update(navigation::Message::SelectNext) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) + | navigation::Action::SelectOption(option) => { self.greeting = make_greeting(option); Action::None } @@ -95,7 +98,8 @@ impl<'a> Component<'a, Message, Action> for TransactionsView { } Message::SelectPrevTransaction => { match self.navigation.update(navigation::Message::SelectPrev) { - navigation::Action::SelectOption(option) => { + navigation::Action::ActivateOption(option) + | navigation::Action::SelectOption(option) => { self.greeting = make_greeting(option); Action::None } diff --git a/schist_desktop_gui/src/gui/screens/files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen.rs index 68a9556..373b54f 100644 --- a/schist_desktop_gui/src/gui/screens/files_screen.rs +++ b/schist_desktop_gui/src/gui/screens/files_screen.rs @@ -3,20 +3,28 @@ mod view_files_screen; use std::{fmt::Debug, path::PathBuf}; -use crate::DatabaseConnection; +use crate::{ + gui::components::{navigation, Navigation, Panel}, + DatabaseConnection, +}; pub struct FilesScreen { - new_file_err: Option<(PathBuf, String)>, - file_paths: Vec<PathBuf>, - open_file_err: Option<(PathBuf, String)>, + options: Navigation<Panel<OptionId>>, } -#[derive(Clone, Debug)] -pub enum Message { - ImportFromActualbudget, +#[derive(Clone, Debug, PartialEq)] +pub enum OptionId { NewFile, - OpenFile(std::path::PathBuf), PickFile, + ImportFromActualbudget, + OpenFile(std::path::PathBuf), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Message { + NavigationMessage(navigation::Message<Panel<OptionId>>), + NextOption, + PrevOption, } #[derive(Debug)] @@ -27,10 +35,38 @@ pub enum Action { impl<'a> FilesScreen { pub fn new(file_paths: &'a [PathBuf]) -> Self { + let mut options = vec![ + Panel::new(OptionId::NewFile, "New file", None, None), + Panel::new(OptionId::PickFile, "Open file...", None, None), + Panel::new( + OptionId::ImportFromActualbudget, + "Import from Actualbudget", + None, + None, + ), + ]; + options.extend( + file_paths + .iter() + .map(|file_path| file_panel(file_path, None)), + ); + Self { - file_paths: file_paths.to_vec(), - new_file_err: None, - open_file_err: None, + options: Navigation::new(None, options), } } } + +fn file_panel<'a>(file_path: &'a PathBuf, err: Option<String>) -> Panel<OptionId> { + let file_name = file_path + .file_name() + .map(|os_str| os_str.to_str().unwrap_or("[invalid Unicode]")) + .unwrap_or("[invalid filename]"); + + Panel::new( + OptionId::OpenFile(file_path.clone()), + file_name, + file_path.to_str().map(String::from), + err.map(|err| format!("Failed to open new file {:#?}: {:#?}", file_path, err)), + ) +} diff --git a/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs index 9c45d6e..82ca7b6 100644 --- a/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs +++ b/schist_desktop_gui/src/gui/screens/files_screen/update_files_screen.rs @@ -4,34 +4,20 @@ use itertools::Itertools; use crate::{ database_connection::DatabaseConnectionResult, dialog::{import_from_actualbudget_dialog, new_file_dialog, pick_file_dialog}, + gui::components::{navigation, Panel}, paths::get_schist_database_directory, traits::Component, DatabaseConnection, }; -use super::{Action, FilesScreen, Message}; +use super::{Action, FilesScreen, Message, OptionId}; impl<'a> Component<'a, Message, Action> for FilesScreen { fn update(&mut self, message: Message) -> Action { match message { - Message::NewFile => match self.new_file() { - Some(Ok(file)) => Action::OpenedFile(file), - Some(Err(_)) | None => Action::None, - }, - - Message::OpenFile(file_path) => self - .open_file(file_path) - .map_or(Action::None, Action::OpenedFile), - - Message::PickFile => match self.pick_file() { - Some(Ok(file)) => Action::OpenedFile(file), - Some(Err(_)) | None => Action::None, - }, - - Message::ImportFromActualbudget => match self.import_from_actualbudget() { - Some(Ok(file)) => Action::OpenedFile(file), - Some(Err(_)) | None => Action::None, - }, + Message::NavigationMessage(message) => self.update_options(message), + Message::NextOption => self.update_options(navigation::Message::SelectNext), + Message::PrevOption => self.update_options(navigation::Message::SelectPrev), } } } @@ -41,13 +27,12 @@ impl FilesScreen { &mut self, ) -> Option<Result<DatabaseConnection, (std::path::PathBuf, anyhow::Error)>> { pick_file_dialog(get_schist_database_directory().ok()).map(|db_connection_result| { - db_connection_result.ok().inspect_err(|(path, err)| { - self.open_file_err = Some((path.clone(), err.to_string())); - rfd::MessageDialog::new() - .set_title("Failed to import from Actualbudget") - .set_level(rfd::MessageLevel::Error) - .set_description(format!("{}", err.chain().join("\n | "))) - .show(); + db_connection_result.ok().inspect_err(|(_path, err)| { + self.report_err( + super::OptionId::PickFile, + format!("{}", err.chain().join("\n | ")).as_str(), + "Failed to open file", + ); }) }) } @@ -59,12 +44,11 @@ impl FilesScreen { DatabaseConnection::establish(file_path.clone()) .ok() .inspect_err(|(_path, err)| { - self.open_file_err = Some((file_path, err.to_string())); - rfd::MessageDialog::new() - .set_title("Failed to import from Actualbudget") - .set_level(rfd::MessageLevel::Error) - .set_description(format!("{}", err.chain().join("\n | "))) - .show(); + self.report_err( + OptionId::OpenFile(file_path), + format!("{}", err.chain().join("\n | ")).as_str(), + "Failed to open file", + ); }) } @@ -72,13 +56,12 @@ impl FilesScreen { &mut self, ) -> Option<Result<DatabaseConnection, (std::path::PathBuf, anyhow::Error)>> { new_file_dialog(get_schist_database_directory().ok()).map(|result| { - result.ok().inspect_err(|(path, err)| { - self.new_file_err = Some((path.clone(), err.to_string())); - rfd::MessageDialog::new() - .set_title("Failed to import from Actualbudget") - .set_level(rfd::MessageLevel::Error) - .set_description(format!("{}", err.chain().join("\n | "))) - .show(); + result.ok().inspect_err(|(_path, err)| { + self.report_err( + OptionId::NewFile, + "Failed to create new file", + format!("{}", err.chain().join("\n | ")).as_str(), + ); }) }) } @@ -86,13 +69,12 @@ impl FilesScreen { fn import_from_actualbudget(&mut self) -> Option<anyhow::Result<DatabaseConnection>> { match import_from_actualbudget_dialog() { Some(Ok(state)) => self.new_file_with_state(&state), - Some(Err((path, err))) => Some({ - self.open_file_err = Some((path, err.to_string())); - rfd::MessageDialog::new() - .set_title("Failed to import from Actualbudget") - .set_level(rfd::MessageLevel::Error) - .set_description(format!("{}", err.chain().join("\n | "))) - .show(); + Some(Err((_path, err))) => Some({ + self.report_err( + OptionId::ImportFromActualbudget, + "Failed to import from Actualbudget", + format!("{}", err.chain().join("\n | ")).as_str(), + ); Err(err) }), None => None, @@ -110,16 +92,71 @@ impl FilesScreen { export_schist_state(state, &mut file.connection).ok()?; Ok(file) }), - Some(Err((path, err))) => Some({ - self.new_file_err = Some((path, err.to_string())); - rfd::MessageDialog::new() - .set_title("Failed to import from Actualbudget") - .set_level(rfd::MessageLevel::Error) - .set_description(format!("{}", err.chain().join("\n | "))) - .show(); + Some(Err((_path, err))) => Some({ + self.report_err( + OptionId::NewFile, + format!("{}", err.chain().join("\n | ")).as_str(), + "Failed to create new file", + ); Err(err) }), None => None, } } + + fn report_err(&mut self, id: OptionId, err: &str, dialog_title: &str) { + let old_panel = self.options.find(|o| o.id == id); + let file_panel_with_err = Panel::new( + id.clone(), + old_panel.as_ref().map(|p| p.name.as_str()).unwrap_or(""), + old_panel.as_ref().map(|p| p.small.clone()).flatten(), + Some(err.to_string()), + ); + self.options + .replace_first(|o| o.id == id, &vec![file_panel_with_err]); + + rfd::MessageDialog::new() + .set_title(dialog_title) + .set_level(rfd::MessageLevel::Error) + .set_description(err) + .show(); + } + + fn update_options(&mut self, message: navigation::Message<Panel<OptionId>>) -> Action { + match self.options.update(message) { + navigation::Action::ActivateOption(option) => match option { + Panel { + id: OptionId::NewFile, + .. + } => match self.new_file() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + + Panel { + id: OptionId::OpenFile(file_path), + .. + } => self + .open_file(file_path.clone()) + .map_or(Action::None, Action::OpenedFile), + + Panel { + id: OptionId::PickFile, + .. + } => match self.pick_file() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + + Panel { + id: OptionId::ImportFromActualbudget, + .. + } => match self.import_from_actualbudget() { + Some(Ok(file)) => Action::OpenedFile(file), + Some(Err(_)) | None => Action::None, + }, + }, + navigation::Action::SelectOption(_) | navigation::Action::None => Action::None, + } + } } diff --git a/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs b/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs index 3cbd939..8ff9f63 100644 --- a/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs +++ b/schist_desktop_gui/src/gui/screens/files_screen/view_files_screen.rs @@ -1,106 +1,9 @@ -use std::path::PathBuf; - -use iced::widget::column; - -use crate::{ - gui::components::{panel_button, Text}, - style::*, - traits::Viewable, -}; +use crate::traits::Viewable; use super::{FilesScreen, Message}; impl<'a> Viewable<'a, Message> for FilesScreen { fn view(&'a self) -> iced::Element<'a, Message> { - let mut cols = Vec::new(); - if let Some(col) = self.view_new_file_err() { - cols.push(col); - }; - if let Some(col) = self.view_open_file_err() { - cols.push(col); - }; - cols.push(self.view_new_file_button()); - cols.push(self.view_open_file_button()); - cols.push(self.view_import_from_actualbudget_button()); - cols.append(&mut self.view_files()); - column(cols).padding(SPACING_LG).spacing(SPACING_MD).into() - } -} - -impl<'a> FilesScreen { - fn view_new_file_err(&'a self) -> Option<iced::Element<'a, Message>> { - self.new_file_err.as_ref().map(|(path, err)| { - let msg = format!("Failed to open new file {:#?}: {:#?}", path, err); - Text::new(&msg).danger().into() - }) - } - - fn view_open_file_err(&'a self) -> Option<iced::Element<'a, Message>> { - self.open_file_err - .as_ref() - .filter(|(file_path, _err)| !self.file_paths.iter().any(|fp| *fp == *file_path)) - .map(|(file_path, err)| { - let msg = format!("Failed to open new file {:#?}: {:#?}", file_path, err); - Text::new(&msg).danger().into() - }) - } - - fn view_files(&'a self) -> Vec<iced::Element<'a, Message>> { - self.file_paths - .iter() - .map(|f| { - view_file( - f, - self.open_file_err - .clone() - .filter(|(file, _err)| *file == *f) - .map(|(_file, err)| err), - ) - }) - .collect() - } - - fn view_new_file_button(&'a self) -> iced::Element<'a, Message> { - panel_button::<Text, Message>(Text::default("New file").into(), Message::NewFile).into() - } - - fn view_open_file_button(&'a self) -> iced::Element<'a, Message> { - panel_button::<Text, Message>(Text::default("Open file").into(), Message::PickFile).into() - } - - fn view_import_from_actualbudget_button(&'a self) -> iced::Element<'a, Message> { - panel_button::<Text, Message>( - Text::default("Import from Actualbudget").into(), - Message::ImportFromActualbudget, - ) - .into() + self.options.view().map(Message::NavigationMessage) } } - -fn view_file<'a>(file_path: &'a PathBuf, err: Option<String>) -> iced::Element<'a, Message> { - let mut cols: Vec<iced::Element<'a, Message>> = err - .map(|err| { - let msg = format!("Failed to open new file {:#?}: {:#?}", file_path, err); - vec![Text::new(&msg).danger().small().into()] - }) - .unwrap_or_else(Vec::new); - - cols.push( - Text::default(file_path.to_str().unwrap_or("")) - .small() - .weak() - .into(), - ); - - cols.push( - Text::default( - file_path - .file_name() - .map(|os_str| os_str.to_str().unwrap_or("[invalid Unicode]")) - .unwrap_or("[invalid filename]"), - ) - .into(), - ); - - panel_button(column(cols), Message::OpenFile(file_path.clone())).into() -} diff --git a/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs index cddec32..5ed4b30 100644 --- a/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs +++ b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs @@ -95,7 +95,8 @@ impl<'a> Component<'a, Message, Action> for MainScreen { } Message::ViewNavigationMessage(message) => match self.view_navigation.update(message) { - navigation::Action::SelectOption(view) => { + navigation::Action::ActivateOption(view) + | navigation::Action::SelectOption(view) => { self.active_view = view; Action::None } diff --git a/schist_desktop_gui/src/gui/screens/main_screen/view.rs b/schist_desktop_gui/src/gui/screens/main_screen/view.rs index 5f2df4e..2ff5753 100644 --- a/schist_desktop_gui/src/gui/screens/main_screen/view.rs +++ b/schist_desktop_gui/src/gui/screens/main_screen/view.rs @@ -1,6 +1,6 @@ use iced::{widget::Text, Element}; -use crate::gui::components::navigation; +use crate::{gui::components::navigation, traits::Focusable}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum View { @@ -46,3 +46,8 @@ impl<'a> From<&'a View> for Element<'a, navigation::Message<View>> { Text::new(val.name()).into() } } + +impl Focusable for View { + fn focus(&mut self) {} + fn unfocus(&mut self) {} +} |
