Skip to main content

oxiui_table/
persistence.rs

1//! COOLJAPAN ecosystem integration — table state persistence via `oxicode`.
2//!
3//! Provides [`TableState`], a serialisable snapshot of the mutable UI state of
4//! a [`Table`](crate::table::Table): column widths, column order, active sort,
5//! per-column filter text, current page, pinned columns, and zebra-striping
6//! flag.
7//!
8//! `TableState` implements `oxicode::Encode` + `oxicode::Decode` so it can be
9//! serialised to the COOLJAPAN binary codec and persisted to disk, sent over
10//! the network, or stored in user preferences.
11//!
12//! # COOLJAPAN policies
13//!
14//! - **No `bincode`**: all serialisation uses `oxicode` (the COOLJAPAN binary
15//!   codec).
16//! - **No `zip`/`flate2`/`zstd`**: compressed export must use `oxiarc-*`.
17//! - **No CSV crate**: CSV export already uses the manual RFC-4180 builder in
18//!   `crate::csv`.
19//! - **Pure Rust default features**: the `persist-table` feature gates the
20//!   `oxicode` dependency so downstream crates without it pay zero overhead.
21//!
22//! # Example
23//!
24//! ```rust
25//! # #[cfg(feature = "persist-table")]
26//! # {
27//! use oxiui_table::persistence::TableState;
28//! # use oxicode::{Encode, Decode};
29//!
30//! let state = TableState {
31//!     column_widths: vec![120.0, 80.0, 200.0],
32//!     column_order: vec![0, 2, 1],
33//!     sort_column: Some(0),
34//!     sort_ascending: true,
35//!     column_filters: vec!["".into(), "".into(), "Alice".into()],
36//!     current_page: 0,
37//!     page_size: 25,
38//!     pinned_columns: 1,
39//!     zebra_striping: true,
40//! };
41//!
42//! let bytes = state.encode_to_vec().expect("encode must not fail");
43//! let restored = TableState::decode_from_slice(&bytes).expect("decode must not fail");
44//! assert_eq!(state.column_order, restored.column_order);
45//! # }
46//! ```
47//!
48//! # Feature flag
49//!
50//! `TableState::encode_to_vec` and `TableState::decode_from_slice` are only
51//! available when the `persist-table` feature is enabled.  The struct and
52//! builder conversions always compile.
53
54// ── TableState ────────────────────────────────────────────────────────────────
55
56/// Serialisable snapshot of the mutable UI state of a `Table`.
57///
58/// Only the *view* state is captured — not the data source itself.  This is
59/// deliberately scoped to what a user would expect to have restored between
60/// sessions: column layout, sort, filters, pagination, and display flags.
61#[derive(Debug, Clone, PartialEq)]
62#[cfg_attr(feature = "persist-table", derive(oxicode::Encode, oxicode::Decode))]
63pub struct TableState {
64    /// Runtime column widths (logical pixels) in logical column order.
65    pub column_widths: Vec<f32>,
66    /// Column render order: `column_order[i]` is the logical column index at
67    /// render position `i`.
68    pub column_order: Vec<usize>,
69    /// Active sort column index, or `None` for no sort.
70    pub sort_column: Option<usize>,
71    /// `true` = ascending, `false` = descending.  Ignored when `sort_column`
72    /// is `None`.
73    pub sort_ascending: bool,
74    /// Per-column filter text strings (empty = no filter).  Indexed by logical
75    /// column index.
76    pub column_filters: Vec<String>,
77    /// Current page (0-based) in paginated mode.
78    pub current_page: usize,
79    /// Rows per page.  `0` disables pagination.
80    pub page_size: usize,
81    /// Number of leftmost columns pinned (frozen) during horizontal scrolling.
82    pub pinned_columns: usize,
83    /// Whether alternate rows are rendered with a different background colour.
84    pub zebra_striping: bool,
85}
86
87impl Default for TableState {
88    fn default() -> Self {
89        Self {
90            column_widths: Vec::new(),
91            column_order: Vec::new(),
92            sort_column: None,
93            sort_ascending: true,
94            column_filters: Vec::new(),
95            current_page: 0,
96            page_size: 50,
97            pinned_columns: 0,
98            zebra_striping: false,
99        }
100    }
101}
102
103impl TableState {
104    /// Create a `TableState` from the current settings of a
105    /// [`Table`](crate::table::Table).
106    ///
107    /// This is a non-generic helper that accepts the individual fields directly
108    /// to avoid a trait bound cycle between this module and `table.rs`.
109    #[allow(clippy::too_many_arguments)]
110    pub fn from_table_fields(
111        column_widths: Vec<f32>,
112        column_order: Vec<usize>,
113        sort_column: Option<usize>,
114        sort_ascending: bool,
115        column_filters: Vec<String>,
116        current_page: usize,
117        page_size: usize,
118        pinned_columns: usize,
119        zebra_striping: bool,
120    ) -> Self {
121        Self {
122            column_widths,
123            column_order,
124            sort_column,
125            sort_ascending,
126            column_filters,
127            current_page,
128            page_size,
129            pinned_columns,
130            zebra_striping,
131        }
132    }
133
134    /// Serialise this state to a `Vec<u8>` using `oxicode`.
135    ///
136    /// # Errors
137    ///
138    /// Returns a `String` error if encoding fails.
139    ///
140    /// Only available when the `persist-table` feature is enabled.
141    #[cfg(feature = "persist-table")]
142    pub fn encode_to_vec(&self) -> Result<Vec<u8>, String> {
143        oxicode::encode_to_vec(self).map_err(|e| e.to_string())
144    }
145
146    /// Deserialise a `TableState` from a byte slice using `oxicode`.
147    ///
148    /// # Errors
149    ///
150    /// Returns a `String` error if the bytes are invalid.
151    ///
152    /// Only available when the `persist-table` feature is enabled.
153    #[cfg(feature = "persist-table")]
154    pub fn decode_from_slice(bytes: &[u8]) -> Result<Self, String> {
155        let (state, _consumed) =
156            oxicode::decode_from_slice::<Self>(bytes).map_err(|e| e.to_string())?;
157        Ok(state)
158    }
159}
160
161// ── Diff / apply helpers ──────────────────────────────────────────────────────
162
163/// The result of comparing two [`TableState`] snapshots.
164///
165/// Useful for efficiently propagating only the changed fields when restoring
166/// state across sessions or syncing distributed views.
167#[derive(Debug, Clone, PartialEq, Default)]
168pub struct TableStateDiff {
169    /// New column widths if they changed.
170    pub column_widths: Option<Vec<f32>>,
171    /// New column order if it changed.
172    pub column_order: Option<Vec<usize>>,
173    /// New sort column if it changed (or `Some(None)` to clear).
174    pub sort_column: Option<Option<usize>>,
175    /// New ascending flag if it changed.
176    pub sort_ascending: Option<bool>,
177    /// New filter strings if any changed.
178    pub column_filters: Option<Vec<String>>,
179    /// New page number if it changed.
180    pub current_page: Option<usize>,
181    /// New page size if it changed.
182    pub page_size: Option<usize>,
183    /// New pinned-column count if it changed.
184    pub pinned_columns: Option<usize>,
185    /// New zebra-striping flag if it changed.
186    pub zebra_striping: Option<bool>,
187}
188
189/// Compute the diff from `old` to `new`.
190///
191/// Only fields that differ are set in the returned [`TableStateDiff`].
192pub fn diff(old: &TableState, new: &TableState) -> TableStateDiff {
193    TableStateDiff {
194        column_widths: if old.column_widths != new.column_widths {
195            Some(new.column_widths.clone())
196        } else {
197            None
198        },
199        column_order: if old.column_order != new.column_order {
200            Some(new.column_order.clone())
201        } else {
202            None
203        },
204        sort_column: if old.sort_column != new.sort_column {
205            Some(new.sort_column)
206        } else {
207            None
208        },
209        sort_ascending: if old.sort_ascending != new.sort_ascending {
210            Some(new.sort_ascending)
211        } else {
212            None
213        },
214        column_filters: if old.column_filters != new.column_filters {
215            Some(new.column_filters.clone())
216        } else {
217            None
218        },
219        current_page: if old.current_page != new.current_page {
220            Some(new.current_page)
221        } else {
222            None
223        },
224        page_size: if old.page_size != new.page_size {
225            Some(new.page_size)
226        } else {
227            None
228        },
229        pinned_columns: if old.pinned_columns != new.pinned_columns {
230            Some(new.pinned_columns)
231        } else {
232            None
233        },
234        zebra_striping: if old.zebra_striping != new.zebra_striping {
235            Some(new.zebra_striping)
236        } else {
237            None
238        },
239    }
240}
241
242/// Apply a [`TableStateDiff`] to a [`TableState`], mutating it in place.
243///
244/// Fields that are `None` in the diff are left unchanged.
245pub fn apply_diff(state: &mut TableState, d: &TableStateDiff) {
246    if let Some(ref v) = d.column_widths {
247        state.column_widths = v.clone();
248    }
249    if let Some(ref v) = d.column_order {
250        state.column_order = v.clone();
251    }
252    if let Some(v) = d.sort_column {
253        state.sort_column = v;
254    }
255    if let Some(v) = d.sort_ascending {
256        state.sort_ascending = v;
257    }
258    if let Some(ref v) = d.column_filters {
259        state.column_filters = v.clone();
260    }
261    if let Some(v) = d.current_page {
262        state.current_page = v;
263    }
264    if let Some(v) = d.page_size {
265        state.page_size = v;
266    }
267    if let Some(v) = d.pinned_columns {
268        state.pinned_columns = v;
269    }
270    if let Some(v) = d.zebra_striping {
271        state.zebra_striping = v;
272    }
273}
274
275// ── Tests ─────────────────────────────────────────────────────────────────────
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn sample_state() -> TableState {
282        TableState {
283            column_widths: vec![120.0, 80.0, 200.0],
284            column_order: vec![0, 2, 1],
285            sort_column: Some(0),
286            sort_ascending: true,
287            column_filters: vec!["".into(), "".into(), "Alice".into()],
288            current_page: 2,
289            page_size: 25,
290            pinned_columns: 1,
291            zebra_striping: true,
292        }
293    }
294
295    #[test]
296    fn default_state_has_sensible_values() {
297        let state = TableState::default();
298        assert!(state.column_widths.is_empty());
299        assert!(state.column_order.is_empty());
300        assert!(state.sort_column.is_none());
301        assert!(state.sort_ascending); // default ascending
302        assert_eq!(state.page_size, 50);
303        assert!(!state.zebra_striping);
304    }
305
306    #[test]
307    fn from_table_fields_round_trips() {
308        let state = TableState::from_table_fields(
309            vec![100.0, 200.0],
310            vec![1, 0],
311            Some(1),
312            false,
313            vec!["filter".into(), "".into()],
314            3,
315            10,
316            2,
317            true,
318        );
319        assert_eq!(state.column_widths, vec![100.0, 200.0]);
320        assert_eq!(state.column_order, vec![1, 0]);
321        assert_eq!(state.sort_column, Some(1));
322        assert!(!state.sort_ascending);
323        assert_eq!(state.column_filters[0], "filter");
324        assert_eq!(state.current_page, 3);
325        assert_eq!(state.page_size, 10);
326        assert_eq!(state.pinned_columns, 2);
327        assert!(state.zebra_striping);
328    }
329
330    #[test]
331    fn diff_identical_states_is_empty() {
332        let a = sample_state();
333        let b = a.clone();
334        let d = diff(&a, &b);
335        assert_eq!(d, TableStateDiff::default());
336    }
337
338    #[test]
339    fn diff_sort_column_changed() {
340        let a = sample_state();
341        let mut b = a.clone();
342        b.sort_column = Some(2);
343        let d = diff(&a, &b);
344        assert_eq!(d.sort_column, Some(Some(2)));
345        assert!(d.column_widths.is_none(), "column_widths must be unchanged");
346    }
347
348    #[test]
349    fn diff_column_widths_changed() {
350        let a = sample_state();
351        let mut b = a.clone();
352        b.column_widths = vec![150.0, 80.0, 200.0];
353        let d = diff(&a, &b);
354        assert_eq!(
355            d.column_widths.as_deref(),
356            Some(&[150.0_f32, 80.0, 200.0][..])
357        );
358    }
359
360    #[test]
361    fn apply_diff_modifies_state() {
362        let mut state = sample_state();
363        let d = TableStateDiff {
364            sort_column: Some(Some(2)),
365            sort_ascending: Some(false),
366            zebra_striping: Some(false),
367            ..Default::default()
368        };
369        apply_diff(&mut state, &d);
370        assert_eq!(state.sort_column, Some(2));
371        assert!(!state.sort_ascending);
372        assert!(!state.zebra_striping);
373        // Unchanged fields must survive.
374        assert_eq!(state.column_order, vec![0, 2, 1]);
375    }
376
377    #[test]
378    fn apply_diff_none_fields_unchanged() {
379        let original = sample_state();
380        let mut state = original.clone();
381        apply_diff(&mut state, &TableStateDiff::default());
382        assert_eq!(state, original);
383    }
384
385    #[test]
386    fn diff_apply_roundtrip() {
387        let old = sample_state();
388        let mut new = old.clone();
389        new.sort_column = None;
390        new.page_size = 100;
391        new.zebra_striping = false;
392
393        let d = diff(&old, &new);
394        let mut reconstructed = old.clone();
395        apply_diff(&mut reconstructed, &d);
396        assert_eq!(reconstructed, new);
397    }
398
399    #[cfg(feature = "persist-table")]
400    #[test]
401    fn encode_decode_roundtrip() {
402        let state = sample_state();
403        let bytes = state.encode_to_vec().expect("encode must succeed");
404        assert!(!bytes.is_empty(), "encoded bytes must not be empty");
405        let decoded = TableState::decode_from_slice(&bytes).expect("decode must succeed");
406        assert_eq!(decoded, state);
407    }
408
409    #[cfg(feature = "persist-table")]
410    #[test]
411    fn encode_decode_default_state() {
412        let state = TableState::default();
413        let bytes = state.encode_to_vec().expect("encode");
414        let decoded = TableState::decode_from_slice(&bytes).expect("decode");
415        assert_eq!(decoded, state);
416    }
417
418    #[cfg(feature = "persist-table")]
419    #[test]
420    fn decode_invalid_bytes_returns_err() {
421        let result = TableState::decode_from_slice(&[0xFF, 0x00, 0xAB]);
422        assert!(result.is_err(), "invalid bytes must return Err");
423    }
424
425    #[cfg(feature = "persist-table")]
426    #[test]
427    fn encode_produces_non_trivial_bytes() {
428        let state = sample_state();
429        let bytes = state.encode_to_vec().expect("encode");
430        // At minimum, 3 f32 column widths × 4 bytes = 12 bytes for widths alone.
431        assert!(
432            bytes.len() >= 12,
433            "encoded bytes too short: {}",
434            bytes.len()
435        );
436    }
437
438    #[test]
439    fn diff_clear_sort_column() {
440        let mut a = sample_state();
441        a.sort_column = Some(0);
442        let mut b = a.clone();
443        b.sort_column = None;
444        let d = diff(&a, &b);
445        assert_eq!(
446            d.sort_column,
447            Some(None),
448            "clearing sort should produce Some(None)"
449        );
450    }
451
452    #[test]
453    fn diff_filter_change() {
454        let a = sample_state();
455        let mut b = a.clone();
456        b.column_filters = vec!["new".into(), "".into(), "Alice".into()];
457        let d = diff(&a, &b);
458        assert!(d.column_filters.is_some());
459        assert_eq!(d.column_filters.as_ref().unwrap()[0], "new");
460    }
461}