374 lines
11 KiB
Rust
374 lines
11 KiB
Rust
/*
|
|
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());
|
|
}
|
|
}
|