diff --git a/fitnesstrax/app/src/components/day.rs b/fitnesstrax/app/src/components/day.rs index 4e92180..15eb9ce 100644 --- a/fitnesstrax/app/src/components/day.rs +++ b/fitnesstrax/app/src/components/day.rs @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with Fit // use chrono::NaiveDate; // use ft_core::TraxRecord; use crate::{ - components::{steps_editor, weight_editor, ActionGroup, Steps, WeightLabel}, + components::{steps_editor, weight_field, ActionGroup, Steps, WeightLabel}, view_models::DayDetailViewModel, }; use glib::Object; @@ -283,10 +283,11 @@ impl DayEdit { .orientation(gtk::Orientation::Horizontal) .build(); top_row.append( - &weight_editor(view_model.weight(), { + &weight_field(view_model.weight(), { let view_model = view_model.clone(); - move |w| { - view_model.set_weight(w); + move |w| match w { + Some(w) => view_model.set_weight(w), + None => eprintln!("have not implemented record delete"), } }) .widget(), @@ -295,7 +296,10 @@ impl DayEdit { top_row.append( &steps_editor(view_model.steps(), { let view_model = view_model.clone(); - move |s| view_model.set_steps(s) + move |s| match s { + Some(s) => view_model.set_steps(s), + None => eprintln!("have not implemented record delete"), + } }) .widget(), ); diff --git a/fitnesstrax/app/src/components/mod.rs b/fitnesstrax/app/src/components/mod.rs index 8b2fff5..b6c0fe3 100644 --- a/fitnesstrax/app/src/components/mod.rs +++ b/fitnesstrax/app/src/components/mod.rs @@ -27,13 +27,13 @@ mod steps; pub use steps::{steps_editor, Steps}; mod text_entry; -pub use text_entry::TextEntry; +pub use text_entry::{weight_field, TextEntry}; mod time_distance; pub use time_distance::TimeDistanceView; mod weight; -pub use weight::{weight_editor, WeightLabel}; +pub use weight::WeightLabel; use glib::Object; use gtk::{prelude::*, subclass::prelude::*}; diff --git a/fitnesstrax/app/src/components/steps.rs b/fitnesstrax/app/src/components/steps.rs index 3860ca9..391b43a 100644 --- a/fitnesstrax/app/src/components/steps.rs +++ b/fitnesstrax/app/src/components/steps.rs @@ -44,18 +44,13 @@ impl Steps { pub fn steps_editor(value: Option, on_update: OnUpdate) -> TextEntry where - OnUpdate: Fn(u32) + 'static, + OnUpdate: Fn(Option) + 'static, { TextEntry::new( "0", value, |v| format!("{}", v), - move |v| match v.parse::() { - Ok(val) => { - on_update(val); - Ok(val) - } - Err(_) => Err(ParseError), - }, + move |v| v.parse::().map_err(|_| ParseError), + on_update, ) } diff --git a/fitnesstrax/app/src/components/text_entry.rs b/fitnesstrax/app/src/components/text_entry.rs index 53702cb..ff8c206 100644 --- a/fitnesstrax/app/src/components/text_entry.rs +++ b/fitnesstrax/app/src/components/text_entry.rs @@ -14,20 +14,22 @@ 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::types::ParseError; +use crate::types::{ + DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter, +}; use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; -type Renderer = dyn Fn(&T) -> String; type Parser = dyn Fn(&str) -> Result; +type OnUpdate = dyn Fn(Option); #[derive(Clone)] pub struct TextEntry { value: Rc>>, + widget: gtk::Entry, - #[allow(unused)] - renderer: Rc>, parser: Rc>, + on_update: Rc>, } impl std::fmt::Debug for TextEntry { @@ -42,10 +44,17 @@ impl std::fmt::Debug for TextEntry { // I do not understand why the data should be 'static. impl TextEntry { - pub fn new(placeholder: &str, value: Option, renderer: R, parser: V) -> Self + pub fn new( + placeholder: &str, + value: Option, + renderer: R, + parser: V, + on_update: U, + ) -> Self where R: Fn(&T) -> String + 'static, V: Fn(&str) -> Result + 'static, + U: Fn(Option) + 'static, { let widget = gtk::Entry::builder().placeholder_text(placeholder).build(); if let Some(ref v) = value { @@ -55,8 +64,8 @@ impl TextEntry { let s = Self { value: Rc::new(RefCell::new(value)), widget, - renderer: Rc::new(renderer), parser: Rc::new(parser), + on_update: Rc::new(on_update), }; s.widget.buffer().connect_text_notify({ @@ -71,12 +80,14 @@ impl TextEntry { if buffer.text().is_empty() { *self.value.borrow_mut() = None; self.widget.remove_css_class("error"); + (self.on_update)(None); return; } match (self.parser)(buffer.text().as_str()) { Ok(v) => { - *self.value.borrow_mut() = Some(v); + *self.value.borrow_mut() = Some(v.clone()); self.widget.remove_css_class("error"); + (self.on_update)(Some(v)); } // need to change the border to provide a visual indicator of an error Err(_) => { @@ -85,25 +96,147 @@ impl TextEntry { } } - #[allow(unused)] - pub fn value(&self) -> Option { - let v = self.value.borrow().clone(); - self.value.borrow().clone() - } - - pub fn set_value(&self, value: Option) { - if let Some(ref v) = value { - self.widget.set_text(&(self.renderer)(v)) - } - *self.value.borrow_mut() = value; - } - - #[allow(unused)] - pub fn grab_focus(&self) { - self.widget.grab_focus(); - } - pub fn widget(&self) -> gtk::Widget { self.widget.clone().upcast::() } + + #[cfg(test)] + fn has_parse_error(&self) -> bool { + self.widget.has_css_class("error") + } +} + +pub fn time_field( + value: Option, + on_update: OnUpdate, +) -> TextEntry +where + OnUpdate: Fn(Option) + 'static, +{ + TextEntry::new( + "HH:MM", + value, + |val| val.format(FormatOption::Abbreviated), + TimeFormatter::parse, + on_update, + ) +} + +pub fn distance_field( + value: Option, + on_update: OnUpdate, +) -> TextEntry +where + OnUpdate: Fn(Option) + 'static, +{ + TextEntry::new( + "0 km", + value, + |val| val.format(FormatOption::Abbreviated), + DistanceFormatter::parse, + on_update, + ) +} + +pub fn duration_field( + value: Option, + on_update: OnUpdate, +) -> TextEntry +where + OnUpdate: Fn(Option) + 'static, +{ + TextEntry::new( + "0 m", + value, + |val| val.format(FormatOption::Abbreviated), + DurationFormatter::parse, + on_update, + ) +} +pub fn weight_field( + weight: Option, + on_update: OnUpdate, +) -> TextEntry +where + OnUpdate: Fn(Option) + 'static, +{ + TextEntry::new( + "0 kg", + weight, + |val| val.format(FormatOption::Abbreviated), + WeightFormatter::parse, + on_update, + ) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::gtk_init::gtk_init; + + fn setup_u32_entry() -> (Rc>>, TextEntry) { + let current_value = Rc::new(RefCell::new(None)); + + let entry = TextEntry::new( + "step count", + None, + |steps| format!("{}", steps), + |v| v.parse::().map_err(|_| ParseError), + { + let current_value = current_value.clone(); + move |v| *current_value.borrow_mut() = v + }, + ); + + (current_value, entry) + } + + #[test] + fn it_responds_to_field_changes() { + gtk_init(); + let (current_value, entry) = setup_u32_entry(); + let buffer = entry.widget.buffer(); + + buffer.set_text("1"); + assert_eq!(*current_value.borrow(), Some(1)); + + buffer.set_text("15"); + assert_eq!(*current_value.borrow(), Some(15)); + } + + #[test] + fn it_preserves_last_value_in_parse_error() { + crate::gtk_init::gtk_init(); + let (current_value, entry) = setup_u32_entry(); + let buffer = entry.widget.buffer(); + + buffer.set_text("1"); + assert_eq!(*current_value.borrow(), Some(1)); + + buffer.set_text("a5"); + assert_eq!(*current_value.borrow(), Some(1)); + assert!(entry.has_parse_error()); + } + + #[test] + fn it_update_on_empty_strings() { + gtk_init(); + let (current_value, entry) = setup_u32_entry(); + let buffer = entry.widget.buffer(); + + buffer.set_text("1"); + assert_eq!(*current_value.borrow(), Some(1)); + buffer.set_text(""); + assert_eq!(*current_value.borrow(), None); + + buffer.set_text("1"); + assert_eq!(*current_value.borrow(), Some(1)); + buffer.set_text("1a"); + assert_eq!(*current_value.borrow(), Some(1)); + assert!(entry.has_parse_error()); + + buffer.set_text(""); + assert_eq!(*current_value.borrow(), None); + assert!(!entry.has_parse_error()); + } } diff --git a/fitnesstrax/app/src/components/weight.rs b/fitnesstrax/app/src/components/weight.rs index 2afc259..23ab48f 100644 --- a/fitnesstrax/app/src/components/weight.rs +++ b/fitnesstrax/app/src/components/weight.rs @@ -43,27 +43,3 @@ impl WeightLabel { self.label.clone().upcast() } } - -pub fn weight_editor( - weight: Option, - on_update: OnUpdate, -) -> TextEntry -where - OnUpdate: Fn(WeightFormatter) + 'static, -{ - TextEntry::new( - "0 kg", - weight, - |val: &WeightFormatter| val.format(FormatOption::Abbreviated), - move |v: &str| { - let new_weight = WeightFormatter::parse(v); - match new_weight { - Ok(w) => { - on_update(w); - Ok(w) - } - Err(err) => Err(err), - } - }, - ) -} diff --git a/fitnesstrax/app/src/gtk_init.rs b/fitnesstrax/app/src/gtk_init.rs new file mode 100644 index 0000000..2ac6662 --- /dev/null +++ b/fitnesstrax/app/src/gtk_init.rs @@ -0,0 +1,10 @@ +use std::sync::Once; + +static INITIALIZED: Once = Once::new(); + +pub fn gtk_init() { + INITIALIZED.call_once(|| { + eprintln!("initializing GTK"); + let _ = gtk::init(); + }); +} diff --git a/fitnesstrax/app/src/main.rs b/fitnesstrax/app/src/main.rs index 1e86d60..316bfa6 100644 --- a/fitnesstrax/app/src/main.rs +++ b/fitnesstrax/app/src/main.rs @@ -17,6 +17,8 @@ You should have received a copy of the GNU General Public License along with Fit mod app; mod app_window; mod components; +#[cfg(test)] +mod gtk_init; mod types; mod view_models; mod views; diff --git a/fitnesstrax/app/src/types.rs b/fitnesstrax/app/src/types.rs index 0411596..5172add 100644 --- a/fitnesstrax/app/src/types.rs +++ b/fitnesstrax/app/src/types.rs @@ -60,7 +60,7 @@ pub enum FormatOption { pub struct TimeFormatter(chrono::NaiveTime); impl TimeFormatter { - fn format(&self, option: FormatOption) -> String { + pub fn format(&self, option: FormatOption) -> String { match option { FormatOption::Abbreviated => self.0.format("%H:%M"), FormatOption::Full => self.0.format("%H:%M:%S"), @@ -68,7 +68,7 @@ impl TimeFormatter { .to_string() } - fn parse(s: &str) -> Result { + pub fn parse(s: &str) -> Result { let parts = s .split(':') .map(|part| part.parse::().map_err(|_| ParseError))