Skip to main content

ravenrs_widgets/grid/
model.rs

1//! Data grid data model: values, columns and the data source.
2
3use std::borrow::Cow;
4use std::cmp::Ordering;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_reactive::State;
9
10use crate::AnyView;
11use crate::date::Date;
12
13/// A cell value.
14#[derive(Clone, Debug, Default, PartialEq)]
15#[non_exhaustive]
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
17pub enum Value {
18    /// No value; shown as an empty cell.
19    #[default]
20    Empty,
21    /// A string.
22    Text(String),
23    /// A number.
24    Number(f64),
25    /// A yes/no value.
26    Bool(bool),
27    /// A calendar date.
28    Date(Date),
29}
30
31#[cfg_attr(feature = "live", ravenrs_live::expose)]
32impl Value {
33    /// The numeric value: numbers as is, booleans as 0.0 or 1.0, otherwise `None`.
34    pub fn as_f64(&self) -> Option<f64> {
35        match self {
36            Value::Number(n) => Some(*n),
37            Value::Bool(b) => Some(f64::from(u8::from(*b))),
38            _ => None,
39        }
40    }
41
42    /// The date, for a [`Value::Date`].
43    pub fn as_date(&self) -> Option<Date> {
44        if let Value::Date(date) = self { Some(*date) } else { None }
45    }
46
47    /// The text that identifies a row by this value, in
48    /// [`DataGrid::key_column`](crate::DataGrid::key_column).
49    pub fn key_text(&self) -> String {
50        match self {
51            Value::Empty => String::new(),
52            Value::Text(text) => text.clone(),
53            Value::Number(n) => n.to_string(),
54            Value::Bool(b) => b.to_string(),
55            Value::Date(date) => date.to_string(),
56        }
57    }
58
59    /// The value is [`Value::Empty`].
60    pub fn is_empty(&self) -> bool {
61        matches!(self, Value::Empty)
62    }
63}
64
65impl From<Date> for Value {
66    fn from(date: Date) -> Self {
67        Value::Date(date)
68    }
69}
70impl From<&str> for Value {
71    fn from(s: &str) -> Self {
72        Value::Text(s.to_owned())
73    }
74}
75impl From<String> for Value {
76    fn from(s: String) -> Self {
77        Value::Text(s)
78    }
79}
80impl From<f64> for Value {
81    fn from(n: f64) -> Self {
82        Value::Number(n)
83    }
84}
85impl From<f32> for Value {
86    fn from(n: f32) -> Self {
87        Value::Number(f64::from(n))
88    }
89}
90impl From<i64> for Value {
91    fn from(n: i64) -> Self {
92        Value::Number(n as f64)
93    }
94}
95impl From<i32> for Value {
96    fn from(n: i32) -> Self {
97        Value::Number(f64::from(n))
98    }
99}
100impl From<u32> for Value {
101    fn from(n: u32) -> Self {
102        Value::Number(f64::from(n))
103    }
104}
105impl From<usize> for Value {
106    fn from(n: usize) -> Self {
107        Value::Number(n as f64)
108    }
109}
110impl From<bool> for Value {
111    fn from(b: bool) -> Self {
112        Value::Bool(b)
113    }
114}
115
116/// One row: values in column order.
117pub type Row = Vec<Value>;
118
119/// Order of a sorted column.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
121#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
122pub enum SortDirection {
123    /// Smallest first.
124    Ascending,
125    /// Largest first.
126    Descending,
127}
128
129#[cfg_attr(feature = "live", ravenrs_live::expose)]
130impl SortDirection {
131    pub(crate) fn apply(self, o: Ordering) -> Ordering {
132        match self {
133            SortDirection::Ascending => o,
134            SortDirection::Descending => o.reverse(),
135        }
136    }
137}
138
139/// An aggregate shown in group rows and in the footer.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141#[non_exhaustive]
142#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
143pub enum Summary {
144    /// Number of non-empty cells.
145    Count,
146    /// Sum of the numeric values.
147    Sum,
148    /// Mean of the numeric values.
149    Average,
150    /// Smallest numeric value.
151    Min,
152    /// Largest numeric value.
153    Max,
154}
155
156#[cfg_attr(feature = "live", ravenrs_live::expose)]
157impl Summary {
158    /// Short caption shown before the aggregate ("Sum", "Avg"…).
159    pub fn label(self) -> &'static str {
160        match self {
161            Summary::Count => "Count",
162            Summary::Sum => "Sum",
163            Summary::Average => "Avg",
164            Summary::Min => "Min",
165            Summary::Max => "Max",
166        }
167    }
168}
169
170/// Horizontal alignment of a column's cells.
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
172#[non_exhaustive]
173#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
174pub enum CellAlign {
175    /// Left-aligned (text default).
176    #[default]
177    Start,
178    /// Centered.
179    Center,
180    /// Right-aligned (number default).
181    End,
182}
183
184/// How numbers are written: decimals, affixes and separators.
185#[derive(Clone, Debug, PartialEq)]
186#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
187pub struct NumberFormat {
188    /// Digits after the decimal separator.
189    pub decimals: usize,
190    /// Text before the number (e.g. a currency symbol).
191    pub prefix: String,
192    /// Text after the number (e.g. `"%"`).
193    pub suffix: String,
194    /// Thousands separator.
195    pub thousands: char,
196    /// Decimal separator.
197    pub decimal: char,
198}
199
200impl Default for NumberFormat {
201    fn default() -> Self {
202        Self { decimals: 0, prefix: String::new(), suffix: String::new(), thousands: ',', decimal: '.' }
203    }
204}
205
206#[cfg_attr(feature = "live", ravenrs_live::expose)]
207impl NumberFormat {
208    /// Formats `n`; non-finite numbers give an empty string.
209    pub fn format(&self, n: f64) -> String {
210        if !n.is_finite() {
211            return String::new();
212        }
213        ravenrs_criteria::NumberStyle {
214            decimals: self.decimals,
215            group: Some(self.thousands),
216            decimal: self.decimal,
217            prefix: &self.prefix,
218            suffix: &self.suffix,
219        }
220        .format(n)
221    }
222}
223
224/// Turns a cell value into its display text.
225pub type CellFormat = Rc<dyn Fn(&Value) -> String>;
226/// Builds a custom view for a cell value.
227pub type CellTemplate = Rc<dyn Fn(&Value) -> AnyView>;
228
229/// A data grid column: title, width, alignment, formatting and capabilities.
230#[derive(Clone)]
231#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
232pub struct GridColumn {
233    pub(crate) title: String,
234    pub(crate) field: Option<String>,
235    pub(crate) width: f32,
236    pub(crate) min_width: f32,
237    pub(crate) align: CellAlign,
238    pub(crate) number: Option<NumberFormat>,
239    pub(crate) summary: Option<Summary>,
240    pub(crate) sortable: bool,
241    pub(crate) filterable: bool,
242    pub(crate) groupable: bool,
243    pub(crate) editable: bool,
244    pub(crate) visible: bool,
245    pub(crate) fill: bool,
246    pub(crate) pinned: bool,
247    pub(crate) resizable: bool,
248    pub(crate) formula: Option<String>,
249    pub(crate) format: Option<CellFormat>,
250    pub(crate) template: Option<CellTemplate>,
251}
252
253impl fmt::Debug for GridColumn {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        f.debug_struct("GridColumn")
256            .field("title", &self.title)
257            .field("width", &self.width)
258            .finish_non_exhaustive()
259    }
260}
261
262#[cfg_attr(feature = "live", ravenrs_live::expose)]
263impl GridColumn {
264    /// A text column.
265    pub fn text(title: impl Into<String>) -> Self {
266        Self {
267            title: title.into(),
268            field: None,
269            width: 160.0,
270            min_width: 60.0,
271            align: CellAlign::Start,
272            number: None,
273            summary: None,
274            sortable: true,
275            filterable: true,
276            groupable: true,
277            editable: false,
278            visible: true,
279            fill: false,
280            pinned: false,
281            resizable: true,
282            formula: None,
283            format: None,
284            template: None,
285        }
286    }
287
288    /// A right-aligned number column with thousands separators.
289    pub fn number(title: impl Into<String>) -> Self {
290        Self {
291            align: CellAlign::End,
292            number: Some(NumberFormat::default()),
293            width: 120.0,
294            ..Self::text(title)
295        }
296    }
297
298    /// A number column formatted as money: `currency("MRR", "R$ ", 2)`.
299    pub fn currency(title: impl Into<String>, prefix: &str, decimals: usize) -> Self {
300        let format = NumberFormat { decimals, prefix: prefix.to_owned(), ..NumberFormat::default() };
301        Self { number: Some(format), ..Self::number(title) }
302    }
303
304    /// A yes/no column.
305    pub fn boolean(title: impl Into<String>) -> Self {
306        Self { align: CellAlign::Center, width: 100.0, ..Self::text(title) }
307    }
308
309    /// A date column, written `2026-11-02` unless [`format`](Self::format) says otherwise.
310    pub fn date(title: impl Into<String>) -> Self {
311        Self { width: 120.0, ..Self::text(title) }
312    }
313
314    /// The name of the field this column shows, which stays when the title is translated.
315    pub fn field(mut self, field: impl Into<String>) -> Self {
316        self.field = Some(field.into());
317        self
318    }
319
320    /// The name given by [`field`](Self::field).
321    pub fn field_name(&self) -> Option<&str> {
322        self.field.as_deref()
323    }
324
325    /// Sets the width in logical pixels, never below the column's minimum.
326    pub fn width(mut self, width: f32) -> Self {
327        self.width = width.max(self.min_width);
328        self
329    }
330
331    /// Whether people can change the width, by the handle or the column's menu (they can
332    /// by default). With `false`, it keeps the width it has been given, or what a
333    /// [`Self::fill`] column gets from the room left.
334    pub fn resizable(mut self, resizable: bool) -> Self {
335        self.resizable = resizable;
336        self
337    }
338
339    /// Sets the cell alignment.
340    pub fn align(mut self, align: CellAlign) -> Self {
341        self.align = align;
342        self
343    }
344
345    /// Decimal places and separators for number columns.
346    pub fn number_format(mut self, format: NumberFormat) -> Self {
347        self.number = Some(format);
348        self
349    }
350
351    /// Shows this aggregate in group rows and the footer.
352    pub fn summary(mut self, summary: Summary) -> Self {
353        self.summary = Some(summary);
354        self
355    }
356
357    /// Whether clicking the header sorts by this column (default true).
358    pub fn sortable(mut self, sortable: bool) -> Self {
359        self.sortable = sortable;
360        self
361    }
362
363    /// Whether the filter row has a field for this column (default true).
364    pub fn filterable(mut self, filterable: bool) -> Self {
365        self.filterable = filterable;
366        self
367    }
368
369    /// Whether rows can be grouped by this column (default true).
370    pub fn groupable(mut self, groupable: bool) -> Self {
371        self.groupable = groupable;
372        self
373    }
374
375    /// Cells can be edited (double-click, Enter or F2).
376    pub fn editable(mut self) -> Self {
377        self.editable = true;
378        self
379    }
380
381    /// Works out the summary of the group rows and the footer with a formula over their
382    /// rows, in place of [`summary`](Self::summary), such as
383    /// `Sum([Seats]) / Count()`. Columns go by their titles.
384    pub fn summary_formula(mut self, formula: impl Into<String>) -> Self {
385        self.formula = Some(formula.into());
386        self
387    }
388
389    /// Stays in place at the left while the others scroll sideways.
390    pub fn pinned(mut self, pinned: bool) -> Self {
391        self.pinned = pinned;
392        self
393    }
394
395    /// Takes the width the columns leave over, so a wide window has no empty stretch
396    /// at the end of the rows. Its [`Self::width`] is what it never goes below.
397    pub fn fill(mut self) -> Self {
398        self.fill = true;
399        self
400    }
401
402    /// Starts hidden; users can show it from the column chooser.
403    pub fn hidden(mut self) -> Self {
404        self.visible = false;
405        self
406    }
407
408    /// Custom text for each value (also used by filtering, search and export).
409    pub fn format(mut self, f: impl Fn(&Value) -> String + 'static) -> Self {
410        self.format = Some(Rc::new(f));
411        self
412    }
413
414    /// Custom view for each cell (a badge, an avatar...).
415    pub fn template(mut self, f: impl Fn(&Value) -> AnyView + 'static) -> Self {
416        self.template = Some(Rc::new(f));
417        self
418    }
419
420    /// The header title.
421    pub fn title(&self) -> &str {
422        &self.title
423    }
424
425    /// The text shown for `value`.
426    pub fn display(&self, value: &Value) -> String {
427        self.display_cow(value).into_owned()
428    }
429
430    /// [`GridColumn::display`] without copying text that is shown as stored.
431    pub(crate) fn display_cow<'a>(&self, value: &'a Value) -> Cow<'a, str> {
432        if let Some(f) = &self.format {
433            return Cow::Owned(f(value));
434        }
435        match value {
436            Value::Empty => Cow::Borrowed(""),
437            Value::Text(s) => Cow::Borrowed(s),
438            Value::Number(n) => {
439                Cow::Owned(self.number.as_ref().map_or_else(|| n.to_string(), |f| f.format(*n)))
440            }
441            Value::Bool(b) => Cow::Borrowed(if *b { "Yes" } else { "No" }),
442            Value::Date(date) => Cow::Owned(date.to_string()),
443        }
444    }
445
446    /// Formats an aggregate of this column.
447    pub fn display_summary(&self, summary: Summary, value: f64) -> String {
448        match summary {
449            Summary::Count => NumberFormat::default().format(value),
450            _ => match &self.number {
451                Some(f) => f.format(value),
452                None => NumberFormat { decimals: 2, ..NumberFormat::default() }.format(value),
453            },
454        }
455    }
456
457    /// Parses edited text into a value of this column's kind.
458    pub fn parse(&self, text: &str, previous: &Value) -> Value {
459        let t = text.trim();
460        match previous {
461            Value::Number(_) | Value::Empty if self.number.is_some() => {
462                let f = self.number.clone().unwrap_or_default();
463                let cleaned: String = t
464                    .trim_start_matches(f.prefix.as_str())
465                    .trim_end_matches(f.suffix.as_str())
466                    .chars()
467                    .filter(|c| *c != f.thousands)
468                    .map(|c| if c == f.decimal { '.' } else { c })
469                    .collect();
470                cleaned.trim().parse::<f64>().map_or(previous.clone(), Value::Number)
471            }
472            Value::Date(_) if !t.is_empty() => parse_date(t).map_or(previous.clone(), Value::Date),
473            Value::Bool(_) => {
474                Value::Bool(matches!(t.to_lowercase().as_str(), "yes" | "true" | "1" | "y" | "sim"))
475            }
476            _ if t.is_empty() => Value::Empty,
477            _ => Value::Text(t.to_owned()),
478        }
479    }
480}
481
482/// Reads `2026-11-02` or `02/11/2026`.
483fn parse_date(text: &str) -> Option<Date> {
484    let number = |part: &str| part.trim().parse::<u32>().ok();
485    if let Some((year, rest)) = text.split_once('-') {
486        let (month, day) = rest.split_once('-')?;
487        return Date::new(year.parse().ok()?, number(month)? as u8, number(day)? as u8);
488    }
489    let (day, rest) = text.split_once('/')?;
490    let (month, year) = rest.split_once('/')?;
491    Date::new(year.trim().parse().ok()?, number(month)? as u8, number(day)? as u8)
492}
493
494/// The rows behind a data grid. Cloning shares the same data; editing replaces
495/// the row list copy-on-write, so query caches stay valid until a change.
496#[derive(Clone, Debug)]
497#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
498pub struct GridData {
499    rows: State<Rc<Vec<Row>>>,
500}
501
502#[cfg_attr(feature = "live", ravenrs_live::expose)]
503impl GridData {
504    /// Data over these rows.
505    pub fn new(rows: Vec<Row>) -> Self {
506        Self { rows: State::new(Rc::new(rows)) }
507    }
508
509    /// The current rows (tracked: views reading it rebuild on change).
510    pub fn rows(&self) -> Rc<Vec<Row>> {
511        self.rows.get()
512    }
513
514    /// Number of rows (untracked).
515    pub fn len(&self) -> usize {
516        self.rows.peek().len()
517    }
518
519    /// There are no rows (untracked).
520    pub fn is_empty(&self) -> bool {
521        self.len() == 0
522    }
523
524    /// Replaces one cell; out-of-range indices are ignored.
525    pub fn set_cell(&self, row: usize, column: usize, value: Value) {
526        self.rows.update(|rows| {
527            if let Some(cell) = Rc::make_mut(rows).get_mut(row).and_then(|r| r.get_mut(column)) {
528                *cell = value;
529            }
530        });
531    }
532
533    /// Appends a row.
534    pub fn push(&self, row: Row) {
535        self.rows.update(|rows| Rc::make_mut(rows).push(row));
536    }
537
538    /// Inserts a row at `index`, or at the end when it is past the end.
539    pub fn insert(&self, index: usize, row: Row) {
540        self.rows.update(|rows| {
541            let rows = Rc::make_mut(rows);
542            rows.insert(index.min(rows.len()), row);
543        });
544    }
545
546    /// Removes a row and returns it; an out-of-range index does nothing.
547    pub fn remove(&self, index: usize) -> Option<Row> {
548        let mut removed = None;
549        self.rows.update(|rows| {
550            let rows = Rc::make_mut(rows);
551            if index < rows.len() {
552                removed = Some(rows.remove(index));
553            }
554        });
555        removed
556    }
557
558    /// Replaces all rows.
559    pub fn replace(&self, rows: Vec<Row>) {
560        self.rows.set(Rc::new(rows));
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn number_format_groups_and_rounds() {
570        let f = NumberFormat {
571            decimals: 2,
572            prefix: "R$ ".into(),
573            thousands: '.',
574            decimal: ',',
575            ..Default::default()
576        };
577        assert_eq!(f.format(1234567.891), "R$ 1.234.567,89");
578        assert_eq!(f.format(-12.5), "-R$ 12,50");
579        assert_eq!(NumberFormat::default().format(999.0), "999");
580        assert_eq!(NumberFormat::default().format(1000.0), "1,000");
581        assert_eq!(NumberFormat::default().format(-0.2), "0");
582    }
583
584    #[test]
585    fn parse_round_trips_formatted_numbers() {
586        let col = GridColumn::currency("MRR", "$", 2);
587        assert_eq!(col.parse("$1,234.50", &Value::Number(0.0)), Value::Number(1234.5));
588        assert_eq!(
589            col.parse("oops", &Value::Number(7.0)),
590            Value::Number(7.0),
591            "invalid input keeps the old value"
592        );
593        let due = GridColumn::date("Due");
594        let day = Date::new(2026, 11, 2).map(Value::Date).unwrap_or_default();
595        assert_eq!(due.parse("2026-11-02", &Value::Date(Date::from_days(0))), day);
596        assert_eq!(due.parse("02/11/2026", &Value::Date(Date::from_days(0))), day);
597        assert_eq!(due.parse("soon", &day), day, "invalid input keeps the old value");
598        assert_eq!(due.display(&day), "2026-11-02");
599        assert_eq!(GridColumn::boolean("Active").parse("yes", &Value::Bool(false)), Value::Bool(true));
600    }
601}