/* Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com> 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 <https://www.gnu.org/licenses/>. */ use crate::types::{ DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter, }; use gtk::prelude::*; use std::{cell::RefCell, rc::Rc}; pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>; pub type OnUpdate<T> = dyn Fn(Option<T>); // TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget. #[derive(Clone)] pub struct TextEntry<T: Clone + std::fmt::Debug> { value: Rc<RefCell<Option<T>>>, widget: gtk::Entry, renderer: Rc<dyn Fn(&T) -> String>, parser: Rc<Parser<T>>, on_update: Rc<OnUpdate<T>>, } impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { write!( f, "{{ value: {:?}, widget: {:?} }}", self.value, self.widget ) } } // I do not understand why the data should be 'static. impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> { fn from_builder(builder: TextEntryBuilder<T>) -> TextEntry<T> { let widget = gtk::Entry::builder() .placeholder_text(builder.placeholder) .build(); if let Some(ref v) = builder.value { widget.set_text(&(builder.renderer)(v)) } let s = Self { value: Rc::new(RefCell::new(builder.value)), widget, renderer: Rc::new(builder.renderer), parser: Rc::new(builder.parser), on_update: Rc::new(builder.on_update), }; s.widget.buffer().connect_text_notify({ let s = s.clone(); move |buffer| s.handle_text_change(buffer) }); if let Some(length) = builder.length { s.widget.set_max_length(length.try_into().unwrap()); } // let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect(); let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect(); s.widget.set_css_classes(&classes); s } pub fn builder() -> TextEntryBuilder<T> { TextEntryBuilder::default() } pub fn set_value(&self, val: Option<T>) { if let Some(ref v) = val { self.widget.set_text(&(self.renderer)(v)); } } fn handle_text_change(&self, buffer: >k::EntryBuffer) { 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.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(_) => { self.widget.add_css_class("error"); } } } pub fn widget(&self) -> gtk::Widget { self.widget.clone().upcast::<gtk::Widget>() } #[cfg(test)] fn has_parse_error(&self) -> bool { self.widget.has_css_class("error") } } pub struct TextEntryBuilder<T: Clone + std::fmt::Debug + 'static> { placeholder: String, value: Option<T>, length: Option<usize>, css_classes: Vec<String>, renderer: Box<dyn Fn(&T) -> String>, parser: Box<Parser<T>>, on_update: Box<OnUpdate<T>>, } impl<T: Clone + std::fmt::Debug + 'static> Default for TextEntryBuilder<T> { fn default() -> TextEntryBuilder<T> { TextEntryBuilder { placeholder: "".to_owned(), value: None, length: None, css_classes: vec![], renderer: Box::new(|_| "".to_owned()), parser: Box::new(|_| Err(ParseError)), on_update: Box::new(|_| {}), } } } impl<T: Clone + std::fmt::Debug + 'static> TextEntryBuilder<T> { pub fn build(self) -> TextEntry<T> { TextEntry::from_builder(self) } pub fn with_placeholder(self, placeholder: String) -> Self { Self { placeholder, ..self } } pub fn with_value(self, value: T) -> Self { Self { value: Some(value), ..self } } pub fn with_length(self, length: usize) -> Self { Self { length: Some(length), ..self } } pub fn with_css_classes(self, classes: Vec<String>) -> Self { Self { css_classes: classes, ..self } } pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self { Self { renderer: Box::new(renderer), ..self } } pub fn with_parser(self, parser: impl Fn(&str) -> Result<T, ParseError> + 'static) -> Self { Self { parser: Box::new(parser), ..self } } pub fn with_on_update(self, on_update: impl Fn(Option<T>) + 'static) -> Self { Self { on_update: Box::new(on_update), ..self } } } pub fn time_field<OnUpdate>( value: Option<TimeFormatter>, on_update: OnUpdate, ) -> TextEntry<TimeFormatter> where OnUpdate: Fn(Option<TimeFormatter>) + 'static, { let text_entry = TextEntry::builder() .with_placeholder("HH:MM".to_owned()) .with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated)) .with_parser(TimeFormatter::parse) .with_on_update(on_update); if let Some(time) = value { text_entry.with_value(time) } else { text_entry } .build() } pub fn distance_field<OnUpdate>( value: Option<DistanceFormatter>, on_update: OnUpdate, ) -> TextEntry<DistanceFormatter> where OnUpdate: Fn(Option<DistanceFormatter>) + 'static, { let text_entry = TextEntry::builder() .with_placeholder("0 km".to_owned()) .with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated)) .with_parser(DistanceFormatter::parse) .with_on_update(on_update); if let Some(distance) = value { text_entry.with_value(distance) } else { text_entry } .build() } pub fn duration_field<OnUpdate>( value: Option<DurationFormatter>, on_update: OnUpdate, ) -> TextEntry<DurationFormatter> where OnUpdate: Fn(Option<DurationFormatter>) + 'static, { let text_entry = TextEntry::builder() .with_placeholder("0 m".to_owned()) .with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated)) .with_parser(DurationFormatter::parse) .with_on_update(on_update); if let Some(duration) = value { text_entry.with_value(duration) } else { text_entry } .build() } pub fn weight_field<OnUpdate>( weight: Option<WeightFormatter>, on_update: OnUpdate, ) -> TextEntry<WeightFormatter> where OnUpdate: Fn(Option<WeightFormatter>) + 'static, { let text_entry = TextEntry::builder() .with_placeholder("0 kg".to_owned()) .with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated)) .with_parser(WeightFormatter::parse) .with_on_update(on_update); if let Some(weight) = weight { text_entry.with_value(weight) } else { text_entry } .build() } pub fn i32_field_builder() -> TextEntryBuilder<i32> { TextEntry::builder() .with_placeholder("0".to_owned()) .with_renderer(|val| format!("{}", val)) .with_parser(|v| v.parse::<i32>().map_err(|_| ParseError)) } pub fn month_field_builder() -> TextEntryBuilder<u32> { TextEntry::builder() .with_placeholder("0".to_owned()) .with_renderer(|val| format!("{}", val)) .with_parser(|v| { let val = v.parse::<u32>().map_err(|_| ParseError)?; if val == 0 || val > 12 { return Err(ParseError); } Ok(val) }) } #[cfg(test)] mod test { use super::*; use crate::gtk_init::gtk_init; fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) { let current_value = Rc::new(RefCell::new(None)); let entry = TextEntry::builder() .with_placeholder("step count".to_owned()) .with_renderer(|steps| format!("{}", steps)) .with_parser(|v| v.parse::<u32>().map_err(|_| ParseError)) .with_on_update({ let current_value = current_value.clone(); move |v| *current_value.borrow_mut() = v }) .build(); (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()); } }