Skip to main content

gpui_kit_grid/
persist.rs

1//! Saved grid layouts: the data, its text format, how a saved layout meets
2//! the current schema, and named layouts per view.
3//!
4//! No GPUI types: widths are logical pixels as `f32` and identifiers are
5//! strings, so the module can move to `gpui-base` with `model` and `engine`.
6//! The grid applies a layout and validates the result; nothing here performs
7//! I/O.
8
9use std::{collections::BTreeMap, fmt};
10
11use rustc_hash::FxHashSet;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::{
15    engine::{ExpandDepth, SortDirection},
16    model::{ColumnId, OwnedValue},
17    state::ColumnZone,
18};
19
20/// A saved grid layout: the columns by [`ColumnId`] in display order, each
21/// with its width, visibility and zone, the sort, and the grouping with the
22/// expansion of its groups.
23///
24/// Plain data without a GPUI entity and without the application's column
25/// type, so one value fits every grid of the same view. Serialize it with
26/// serde or a [`LayoutCodec`]. The grid resolves it against its schema when
27/// it is applied: unknown columns are ignored and new ones are added.
28///
29/// The format is version 1 ([`GridLayout::VERSION`]). Defaults are not
30/// written: no `view_id` when there is none, no `visible` for a visible
31/// column, no `frozen` for a scrolling one, no `sort` or `group_by` when it
32/// is empty, and no `expansion` when every group is expanded and none is
33/// toggled. Reading ignores unknown fields, so a newer build can add an
34/// optional field without a new version, as `group_by` and `expansion` were
35/// added; an older build drops that field when it saves again.
36///
37/// Deserializing with serde keeps the version the data carries. The codec
38/// refuses a version this build does not read, and so does the grid when it
39/// applies a layout.
40///
41/// ```
42/// use gpui_kit_grid::{
43///     ColumnZone, GridLayout, JsonLayoutCodec, LayoutCodec, LayoutColumn, LayoutSort,
44///     SortDirection,
45/// };
46///
47/// let layout = GridLayout::new()
48///     .with_view_id("trades")
49///     .with_column(LayoutColumn::new("id", 80.).with_zone(ColumnZone::FrozenLeft))
50///     .with_column(LayoutColumn::new("price", 100.))
51///     .with_column(LayoutColumn::new("fee", 90.).with_visible(false))
52///     .with_sort([LayoutSort::new("price", SortDirection::Descending)]);
53///
54/// let codec = JsonLayoutCodec::default();
55/// let text = codec.encode(&layout)?;
56/// assert_eq!(
57///     text,
58///     concat!(
59///         r#"{"version":1,"view_id":"trades","columns":["#,
60///         r#"{"id":"id","width":80.0,"frozen":"left"},"#,
61///         r#"{"id":"price","width":100.0},"#,
62///         r#"{"id":"fee","width":90.0,"visible":false}],"#,
63///         r#""sort":[{"column":"price","direction":"descending"}]}"#,
64///     )
65/// );
66/// assert_eq!(codec.decode(&text)?, layout);
67/// # Ok::<(), gpui_kit_grid::LayoutError>(())
68/// ```
69///
70/// # Grouping and expansion
71///
72/// `group_by` names the grouping levels by column, the top level first. A
73/// layout without levels has no grouping: applying it removes the grid's
74/// grouping. `expansion` keeps the default depth and the groups the user
75/// toggled against it, each as its key values from the top level down; a
76/// value is never an index, and the levels of the same layout name its
77/// columns.
78///
79/// ```
80/// use gpui_kit_grid::{
81///     ExpandDepth, GridLayout, JsonLayoutCodec, LayoutCodec, LayoutExpansion, LayoutGroup,
82///     OwnedValue, SortDirection,
83/// };
84///
85/// let aapl = OwnedValue::Text("AAPL".into());
86/// let layout = GridLayout::new()
87///     .with_group_by([
88///         LayoutGroup::new("instrument", SortDirection::Ascending),
89///         LayoutGroup::new("side", SortDirection::Descending),
90///     ])
91///     .with_expansion(LayoutExpansion::new(ExpandDepth::All).with_toggled([aapl]));
92///
93/// let codec = JsonLayoutCodec::default();
94/// let text = codec.encode(&layout)?;
95/// assert_eq!(
96///     text,
97///     concat!(
98///         r#"{"version":1,"columns":[],"group_by":["#,
99///         r#"{"column":"instrument","direction":"ascending"},"#,
100///         r#"{"column":"side","direction":"descending"}],"#,
101///         r#""expansion":{"depth":"all","toggled":[["AAPL"]]}}"#,
102///     )
103/// );
104/// assert_eq!(codec.decode(&text)?, layout);
105/// # Ok::<(), gpui_kit_grid::LayoutError>(())
106/// ```
107///
108/// # Saving a grid in a dock panel
109///
110/// The grid does no I/O and does not depend on the dock. A panel hands its
111/// grid's layout to the dock in `Panel::dump`, and the panel's builder in the
112/// registry (`register_panel`) gives it back to `with_layout`.
113/// `LiveGridState::layout` reads state only, so a panel in a tab that was
114/// never shown saves its layout too. The complete, tested version is
115/// `crates/grid/tests/dock.rs`.
116///
117/// ```ignore
118/// const TRADES: &str = "trades";
119///
120/// impl TradesPanel {
121///     fn new(layout: Option<GridLayout>, cx: &mut Context<Self>) -> Self {
122///         let grid = cx.new(|cx| {
123///             let grid = LiveGridState::new_standalone(schema(), Trades, StoreOptions::default(), cx)
124///                 .with_view_id(TRADES);
125///             match layout {
126///                 Some(layout) => grid.with_layout(layout),
127///                 None => grid,
128///             }
129///         });
130///         Self { grid, focus_handle: cx.focus_handle() }
131///     }
132/// }
133///
134/// impl BasePanel for TradesPanel {
135///     fn panel_name(&self) -> &'static str {
136///         TRADES
137///     }
138///
139///     /// The dock pulls the layout when it saves; the grid needs no render.
140///     fn dump(&self, cx: &App) -> PanelState {
141///         let mut state = PanelState::new(TRADES);
142///         let layout = self.grid.read(cx).layout();
143///         state.info = PanelInfo::panel(serde_json::json!({ "grid": layout }));
144///         state
145///     }
146/// }
147///
148/// // At startup, before `DockArea::load`. A bad layout opens the panel with
149/// // the schema's layout instead of failing the load; `with_layout` ignores
150/// // a layout of a newer version.
151/// register_panel(cx, TRADES, |context, _window, cx| {
152///     let layout = match context.info() {
153///         PanelInfo::Panel(value) => value
154///             .get("grid")
155///             .and_then(|grid| serde_json::from_value::<GridLayout>(grid.clone()).ok()),
156///         _ => None,
157///     };
158///     panel_handle(cx.new(|cx| TradesPanel::new(layout, cx)))
159/// });
160/// ```
161///
162/// The dock saves on its own edits only (`DockEvent::LayoutChanged`), so
163/// changes inside a panel must trigger a save too. They are the grid's
164/// `LayoutChange`, `SortChange`, `GroupChange` and `ExpansionChange` events,
165/// which come at once, once per finished gesture or call. The application
166/// delays the write (0.5–1 s), since groups are expanded and collapsed as
167/// often as a chevron is clicked, and also writes when it quits.
168///
169/// ```ignore
170/// cx.subscribe(&grid, |app, _, event: &LiveGridEvent<u64, TradeColumn>, cx| {
171///     if matches!(
172///         event,
173///         LiveGridEvent::LayoutChange { .. }
174///             | LiveGridEvent::SortChange { .. }
175///             | LiveGridEvent::GroupChange { .. }
176///             | LiveGridEvent::ExpansionChange { .. }
177///     ) {
178///         app.schedule_save(cx);
179///     }
180/// })
181/// .detach();
182/// ```
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184#[non_exhaustive]
185pub struct GridLayout {
186    version: u32,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    view_id: Option<String>,
189    columns: Vec<LayoutColumn>,
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    sort: Vec<LayoutSort>,
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    group_by: Vec<LayoutGroup>,
194    #[serde(default, skip_serializing_if = "LayoutExpansion::is_unwritten")]
195    expansion: LayoutExpansion,
196}
197
198impl GridLayout {
199    /// The format version this build writes and the newest it reads.
200    pub const VERSION: u32 = 1;
201
202    /// An empty layout of the current version, without a view id.
203    pub fn new() -> Self {
204        Self {
205            version: Self::VERSION,
206            view_id: None,
207            columns: Vec::new(),
208            sort: Vec::new(),
209            group_by: Vec::new(),
210            expansion: LayoutExpansion::default(),
211        }
212    }
213
214    /// Names the view the layout belongs to ("trades"). A grid with another
215    /// view id refuses the layout.
216    pub fn with_view_id(mut self, view_id: impl Into<String>) -> Self {
217        self.view_id = Some(view_id.into());
218        self
219    }
220
221    /// Appends a column; the order of columns is the display order.
222    pub fn with_column(mut self, column: LayoutColumn) -> Self {
223        self.columns.push(column);
224        self
225    }
226
227    /// Replaces the sort; the first key sorts first.
228    pub fn with_sort(mut self, sort: impl IntoIterator<Item = LayoutSort>) -> Self {
229        self.sort = sort.into_iter().collect();
230        self
231    }
232
233    /// Replaces the grouping levels; the first level groups first. No levels
234    /// is no grouping.
235    pub fn with_group_by(mut self, levels: impl IntoIterator<Item = LayoutGroup>) -> Self {
236        self.group_by = levels.into_iter().collect();
237        self
238    }
239
240    /// Replaces the expansion of groups. The default, every group expanded
241    /// and none toggled, is not written.
242    pub fn with_expansion(mut self, expansion: LayoutExpansion) -> Self {
243        self.expansion = expansion;
244        self
245    }
246
247    /// The format version the layout was written in.
248    pub fn version(&self) -> u32 {
249        self.version
250    }
251
252    /// The view the layout belongs to, if it names one.
253    pub fn view_id(&self) -> Option<&str> {
254        self.view_id.as_deref()
255    }
256
257    /// The columns in display order, as saved: they may include columns the
258    /// schema no longer has, and repeats.
259    pub fn columns(&self) -> &[LayoutColumn] {
260        &self.columns
261    }
262
263    /// The first saved column with this id.
264    pub fn column(&self, id: &str) -> Option<&LayoutColumn> {
265        self.columns.iter().find(|column| column.id.as_str() == id)
266    }
267
268    /// The sort keys, first key first.
269    pub fn sort(&self) -> &[LayoutSort] {
270        &self.sort
271    }
272
273    /// The grouping levels, the top level first, as saved: they may name
274    /// columns the schema no longer has or cannot group, and repeats.
275    pub fn group_by(&self) -> &[LayoutGroup] {
276        &self.group_by
277    }
278
279    /// The expansion of groups; `None` when the layout keeps the default:
280    /// every group expanded and none toggled.
281    pub fn expansion(&self) -> Option<&LayoutExpansion> {
282        (!self.expansion.is_default()).then_some(&self.expansion)
283    }
284}
285
286/// One column of a saved layout.
287#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
288#[non_exhaustive]
289pub struct LayoutColumn {
290    id: ColumnId,
291    #[serde(deserialize_with = "deserialize_width")]
292    width: f32,
293    #[serde(default = "default_visible", skip_serializing_if = "is_visible")]
294    visible: bool,
295    #[serde(
296        rename = "frozen",
297        default,
298        skip_serializing_if = "is_scrolling",
299        with = "frozen"
300    )]
301    zone: ColumnZone,
302}
303
304impl LayoutColumn {
305    /// A visible, scrolling column. `width` is in logical pixels, so a
306    /// layout saved on a Retina screen opens with the same widths on a 1×
307    /// screen; it accepts `f32` or GPUI `Pixels`.
308    pub fn new(id: impl Into<ColumnId>, width: impl Into<f32>) -> Self {
309        Self {
310            id: id.into(),
311            width: width.into(),
312            visible: true,
313            zone: ColumnZone::Scrolling,
314        }
315    }
316
317    /// Sets the width in logical pixels. The grid clamps it to the column's
318    /// limits and replaces a width that is not a positive number.
319    pub fn with_width(mut self, width: impl Into<f32>) -> Self {
320        self.width = width.into();
321        self
322    }
323
324    /// Shows or hides the column. Default: visible.
325    pub fn with_visible(mut self, visible: bool) -> Self {
326        self.visible = visible;
327        self
328    }
329
330    /// Sets the zone. Default: [`ColumnZone::Scrolling`].
331    pub fn with_zone(mut self, zone: ColumnZone) -> Self {
332        self.zone = zone;
333        self
334    }
335
336    /// The column's identifier.
337    pub fn id(&self) -> &ColumnId {
338        &self.id
339    }
340
341    /// The width in logical pixels, as saved.
342    pub fn width(&self) -> f32 {
343        self.width
344    }
345
346    /// Returns true if the column is shown.
347    pub fn is_visible(&self) -> bool {
348        self.visible
349    }
350
351    /// The zone the column is shown in.
352    pub fn zone(&self) -> ColumnZone {
353        self.zone
354    }
355}
356
357fn default_visible() -> bool {
358    true
359}
360
361fn is_visible(visible: &bool) -> bool {
362    *visible
363}
364
365fn is_scrolling(zone: &ColumnZone) -> bool {
366    *zone == ColumnZone::Scrolling
367}
368
369/// JSON has no NaN or infinity, and serde_json writes them as `null`. Read
370/// `null` back as NaN, which the grid replaces with the schema width, so a
371/// written layout always reads back.
372fn deserialize_width<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f32, D::Error> {
373    Ok(Option::<f32>::deserialize(deserializer)?.unwrap_or(f32::NAN))
374}
375
376/// The zone as the `frozen` field: `"left"`, `"right"`, or absent for a
377/// scrolling column. The format lives here rather than in the names of the
378/// `ColumnZone` variants, so renaming a variant cannot change saved layouts.
379mod frozen {
380    use serde::{Deserialize, Deserializer, Serialize, Serializer};
381
382    use crate::state::ColumnZone;
383
384    #[derive(Serialize, Deserialize)]
385    #[serde(rename_all = "lowercase")]
386    enum FrozenSide {
387        Left,
388        Right,
389    }
390
391    pub(super) fn serialize<S: Serializer>(
392        zone: &ColumnZone,
393        serializer: S,
394    ) -> Result<S::Ok, S::Error> {
395        match zone {
396            ColumnZone::FrozenLeft => Some(FrozenSide::Left),
397            ColumnZone::Scrolling => None,
398            ColumnZone::FrozenRight => Some(FrozenSide::Right),
399        }
400        .serialize(serializer)
401    }
402
403    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
404        deserializer: D,
405    ) -> Result<ColumnZone, D::Error> {
406        Ok(match Option::<FrozenSide>::deserialize(deserializer)? {
407            Some(FrozenSide::Left) => ColumnZone::FrozenLeft,
408            None => ColumnZone::Scrolling,
409            Some(FrozenSide::Right) => ColumnZone::FrozenRight,
410        })
411    }
412}
413
414/// One sort key of a saved layout.
415#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416pub struct LayoutSort {
417    column: ColumnId,
418    direction: SortDirection,
419}
420
421impl LayoutSort {
422    /// A sort key on a column.
423    pub fn new(column: impl Into<ColumnId>, direction: SortDirection) -> Self {
424        Self {
425            column: column.into(),
426            direction,
427        }
428    }
429
430    /// The sorted column.
431    pub fn column(&self) -> &ColumnId {
432        &self.column
433    }
434
435    /// The direction.
436    pub fn direction(&self) -> SortDirection {
437        self.direction
438    }
439}
440
441/// One grouping level of a saved layout: a column and the order of its
442/// groups. Serialized as a sort key is:
443/// `{"column": "instrument", "direction": "ascending"}`.
444///
445/// A level that groups by a function of the row (`GroupKey`) has no column
446/// and is not saved: a function cannot be read back from a file.
447#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
448#[non_exhaustive]
449pub struct LayoutGroup {
450    column: ColumnId,
451    direction: SortDirection,
452}
453
454impl LayoutGroup {
455    /// A grouping level on a column, its groups in `direction`.
456    pub fn new(column: impl Into<ColumnId>, direction: SortDirection) -> Self {
457        Self {
458            column: column.into(),
459            direction,
460        }
461    }
462
463    /// The grouped column.
464    pub fn column(&self) -> &ColumnId {
465        &self.column
466    }
467
468    /// The order of the level's groups.
469    pub fn direction(&self) -> SortDirection {
470        self.direction
471    }
472}
473
474/// The saved expansion of groups: the default depth, and the groups the
475/// user toggled against it. A toggled group is its key values from the top
476/// level down, one per level of the layout's `group_by`; the levels name the
477/// columns, so a path holds values only.
478///
479/// Serialized as `{"depth": "all", "toggled": [["AAPL"], ["MSFT", "Sell"]]}`.
480/// `depth` is `"all"`, `"captions"` or the number of expanded levels
481/// ([`ExpandDepth`]); a value is written as an [`OwnedValue`] is, so `12`
482/// reads back as an integer and `12.0` as a float. `toggled` is not written
483/// when it is empty. JSON has no NaN or infinity: a path with one is not
484/// written, and that group's toggle is not saved.
485///
486/// Reading drops a path with a value this build cannot hold, such as a
487/// decimal without the `decimal` feature or a kind of value a newer build
488/// added, and keeps the rest of the layout; the grid's
489/// [`LayoutReport::dropped_expansion`] counts it. Tree rows are not saved:
490/// their keys are the application's own type.
491#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
492#[non_exhaustive]
493pub struct LayoutExpansion {
494    #[serde(default)]
495    depth: ExpandDepth,
496    #[serde(default, skip_serializing_if = "TogglePaths::is_unwritten")]
497    toggled: TogglePaths,
498}
499
500impl LayoutExpansion {
501    /// `depth`, with no group toggled.
502    pub fn new(depth: ExpandDepth) -> Self {
503        Self {
504            depth,
505            toggled: TogglePaths::default(),
506        }
507    }
508
509    /// Adds a group toggled against the depth: its key values, the top level
510    /// first.
511    pub fn with_toggled(mut self, path: impl IntoIterator<Item = OwnedValue>) -> Self {
512        self.toggled.paths.push(path.into_iter().collect());
513        self
514    }
515
516    /// The default depth.
517    pub fn depth(&self) -> ExpandDepth {
518        self.depth
519    }
520
521    /// The toggled groups in saved order, each as its key values from the
522    /// top level down.
523    pub fn toggled(&self) -> &[Vec<OwnedValue>] {
524        &self.toggled.paths
525    }
526
527    /// Returns true for the default: every group expanded, none toggled.
528    pub fn is_default(&self) -> bool {
529        self.depth == ExpandDepth::All && self.toggled.paths.is_empty()
530    }
531
532    /// Returns true if writing would give the default: `ExpandDepth::All`
533    /// and no path that JSON can hold.
534    fn is_unwritten(&self) -> bool {
535        self.depth == ExpandDepth::All && self.toggled.is_unwritten()
536    }
537}
538
539/// The toggled groups of a [`LayoutExpansion`], and the number of saved
540/// paths that could not be read.
541#[derive(Clone, Debug, Default, PartialEq, Eq)]
542struct TogglePaths {
543    paths: Vec<Vec<OwnedValue>>,
544    /// Saved paths with a value this build cannot hold: dropped when read,
545    /// counted by the report, never written.
546    unreadable: usize,
547}
548
549impl TogglePaths {
550    fn is_unwritten(&self) -> bool {
551        !self.paths.iter().any(|path| is_writable(path))
552    }
553}
554
555/// JSON has no NaN or infinity, so a path with one is not written.
556fn is_writable(path: &[OwnedValue]) -> bool {
557    path.iter()
558        .all(|value| !matches!(value, OwnedValue::Float(float) if !float.is_finite()))
559}
560
561impl Serialize for TogglePaths {
562    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
563        serializer.collect_seq(self.paths.iter().filter(|path| is_writable(path)))
564    }
565}
566
567impl<'de> Deserialize<'de> for TogglePaths {
568    /// A list of lists, or the layout is not read. Each value is read as
569    /// JSON first, so a value this build cannot hold drops its path only.
570    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
571        let saved = Vec::<Vec<serde_json::Value>>::deserialize(deserializer)?;
572        let mut toggled = Self::default();
573        for path in saved {
574            match path
575                .into_iter()
576                .map(OwnedValue::deserialize)
577                .collect::<Result<Vec<_>, _>>()
578            {
579                Ok(path) => toggled.paths.push(path),
580                Err(_) => toggled.unreadable += 1,
581            }
582        }
583        Ok(toggled)
584    }
585}
586
587/// Why a layout was not read, written or applied. A grid keeps its current
588/// layout.
589#[derive(Clone, Debug, PartialEq, Eq)]
590#[non_exhaustive]
591pub enum LayoutError {
592    /// Written by a newer build (`found > GridLayout::VERSION`) or malformed
593    /// (`found == 0`).
594    UnsupportedVersion {
595        /// The version the layout carries; `u32::MAX` if it is larger.
596        found: u32,
597    },
598    /// Saved for another grid.
599    ViewMismatch {
600        /// The grid's view id.
601        expected: String,
602        /// The layout's view id.
603        found: String,
604    },
605    /// [`LayoutLibrary::insert`] needs a layout with a view id.
606    MissingViewId,
607    /// The text is not a layout in this codec's format.
608    Decode(String),
609    /// The codec could not write the layout.
610    Encode(String),
611}
612
613impl fmt::Display for LayoutError {
614    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
615        match self {
616            Self::UnsupportedVersion { found } => write!(
617                f,
618                "layout version {found} is not supported; this build reads 1 to {}",
619                GridLayout::VERSION
620            ),
621            Self::ViewMismatch { expected, found } => {
622                write!(f, "the layout is for view \"{found}\", not \"{expected}\"")
623            }
624            Self::MissingViewId => f.write_str("the layout has no view id"),
625            Self::Decode(message) => write!(f, "the layout could not be read: {message}"),
626            Self::Encode(message) => write!(f, "the layout could not be written: {message}"),
627        }
628    }
629}
630
631impl std::error::Error for LayoutError {}
632
633/// Checks that this build reads a layout version: `1..=GridLayout::VERSION`.
634pub(crate) fn check_version(found: u32) -> Result<(), LayoutError> {
635    if (1..=GridLayout::VERSION).contains(&found) {
636        Ok(())
637    } else {
638        Err(LayoutError::UnsupportedVersion { found })
639    }
640}
641
642/// What applying a saved layout had to adjust to fit the current schema.
643///
644/// The grid computes it from the schema and the saved layout, whatever the
645/// [`LayoutMerge`] returns.
646#[derive(Clone, Debug, Default, PartialEq, Eq)]
647pub struct LayoutReport {
648    ignored_columns: Vec<ColumnId>,
649    added_columns: Vec<ColumnId>,
650    dropped_sort: Vec<ColumnId>,
651    dropped_group_by: Vec<ColumnId>,
652    dropped_expansion: usize,
653}
654
655impl LayoutReport {
656    /// Compares a saved layout with the schema in `context`: saved columns
657    /// the schema does not have, schema columns the layout does not have,
658    /// sort keys that are unknown or repeat an earlier key, and the grouping
659    /// and expansion that do not fit (`ResolvedGrouping`). Each list is in
660    /// the order of its source and names an id once.
661    pub(crate) fn resolve(context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Self {
662        let mut ignored_columns = Vec::new();
663        for column in saved.columns() {
664            if !context.contains(column.id().as_str()) && !ignored_columns.contains(column.id()) {
665                ignored_columns.push(column.id().clone());
666            }
667        }
668        let added_columns = context
669            .columns()
670            .iter()
671            .filter(|column| saved.column(column.id().as_str()).is_none())
672            .map(|column| column.id().clone())
673            .collect();
674        let mut sorted = FxHashSet::default();
675        let mut dropped_sort = Vec::new();
676        for key in saved.sort() {
677            let id = key.column();
678            let kept = context.contains(id.as_str()) && sorted.insert(id.as_str());
679            if !kept && !dropped_sort.contains(id) {
680                dropped_sort.push(id.clone());
681            }
682        }
683        let grouping = ResolvedGrouping::new(context, saved);
684        Self {
685            ignored_columns,
686            added_columns,
687            dropped_sort,
688            dropped_group_by: grouping.dropped_group_by,
689            dropped_expansion: grouping.dropped_expansion,
690        }
691    }
692
693    /// Saved columns that the schema no longer has; ignored.
694    pub fn ignored_columns(&self) -> &[ColumnId] {
695        &self.ignored_columns
696    }
697
698    /// Schema columns the layout did not have; added with schema defaults.
699    pub fn added_columns(&self) -> &[ColumnId] {
700        &self.added_columns
701    }
702
703    /// Sort keys on unknown columns and repeated keys; dropped.
704    pub fn dropped_sort(&self) -> &[ColumnId] {
705        &self.dropped_sort
706    }
707
708    /// Grouping levels on columns the schema does not have or cannot group,
709    /// and repeated levels; dropped, and the levels below move up. In saved
710    /// order, each id once. A column can be in [`Self::dropped_sort`] too.
711    pub fn dropped_group_by(&self) -> &[ColumnId] {
712        &self.dropped_group_by
713    }
714
715    /// The number of saved toggled groups that do not fit the restored
716    /// grouping: empty, deeper than its levels, or with a value this build
717    /// cannot hold; dropped.
718    pub fn dropped_expansion(&self) -> usize {
719        self.dropped_expansion
720    }
721
722    /// Returns true if every saved column, sort key, grouping level and
723    /// toggled group matched the schema and every schema column was saved.
724    /// Widths clamped to a column's limits do not count as adjustments.
725    pub fn is_exact(&self) -> bool {
726        self.ignored_columns.is_empty()
727            && self.added_columns.is_empty()
728            && self.dropped_sort.is_empty()
729            && self.dropped_group_by.is_empty()
730            && self.dropped_expansion == 0
731    }
732}
733
734/// A saved layout's grouping and expansion resolved against the schema: the
735/// levels and toggled groups the grid restores, and what it drops.
736/// [`LayoutReport::resolve`] takes the drops from here, so the report names
737/// exactly what restoring leaves out.
738pub(crate) struct ResolvedGrouping<'a> {
739    levels: Vec<&'a LayoutGroup>,
740    toggled: Vec<&'a [OwnedValue]>,
741    depth: ExpandDepth,
742    dropped_group_by: Vec<ColumnId>,
743    dropped_expansion: usize,
744}
745
746impl<'a> ResolvedGrouping<'a> {
747    /// Keeps the levels on schema columns that can be grouped, each column
748    /// once (the first level wins); the levels below a dropped one move up.
749    /// Keeps the toggled groups that are not empty and no deeper than the
750    /// kept levels: their values apply to the kept levels as they are, the
751    /// top level first, and are not matched again. A layout without levels
752    /// keeps its depth but no toggled group, since no level names a column
753    /// for its values. The depth stays as saved, even deeper than the
754    /// levels: it then expands as `ExpandDepth::All` does.
755    pub(crate) fn new(context: &LayoutMergeContext<'_>, saved: &'a GridLayout) -> Self {
756        let mut grouped = FxHashSet::default();
757        let mut levels = Vec::with_capacity(saved.group_by.len());
758        let mut dropped_group_by = Vec::new();
759        for level in &saved.group_by {
760            let id = level.column();
761            if context.is_groupable(id.as_str()) && grouped.insert(id.as_str()) {
762                levels.push(level);
763            } else if !dropped_group_by.contains(id) {
764                dropped_group_by.push(id.clone());
765            }
766        }
767        let expansion = &saved.expansion;
768        let toggled: Vec<&[OwnedValue]> = expansion
769            .toggled()
770            .iter()
771            .map(Vec::as_slice)
772            .filter(|path| (1..=levels.len()).contains(&path.len()))
773            .collect();
774        let dropped_expansion =
775            expansion.toggled.unreadable + (expansion.toggled().len() - toggled.len());
776        Self {
777            levels,
778            toggled,
779            depth: expansion.depth(),
780            dropped_group_by,
781            dropped_expansion,
782        }
783    }
784
785    /// The kept levels, the top level first.
786    pub(crate) fn levels(&self) -> &[&'a LayoutGroup] {
787        &self.levels
788    }
789
790    /// The kept toggled groups in saved order, each as its key values for
791    /// the kept levels from the top down.
792    pub(crate) fn toggled(&self) -> &[&'a [OwnedValue]] {
793        &self.toggled
794    }
795
796    /// The default depth, as saved.
797    pub(crate) fn depth(&self) -> ExpandDepth {
798        self.depth
799    }
800}
801
802/// Turns a layout into text and back. Called once per import or export.
803///
804/// The default is [`JsonLayoutCodec`]. Another codec can read a foreign
805/// format, such as a LiveGrid or Syncfusion XML layout.
806pub trait LayoutCodec: 'static {
807    /// Writes a layout.
808    fn encode(&self, layout: &GridLayout) -> Result<String, LayoutError>;
809
810    /// Reads a layout. Also migrates older versions of its own format, and
811    /// refuses versions it does not read with
812    /// [`LayoutError::UnsupportedVersion`].
813    fn decode(&self, text: &str) -> Result<GridLayout, LayoutError>;
814}
815
816/// The JSON format of [`GridLayout`], compact by default.
817#[derive(Clone, Copy, Debug, Default)]
818pub struct JsonLayoutCodec {
819    pretty: bool,
820}
821
822impl JsonLayoutCodec {
823    /// Writes indented JSON, one field per line, instead of one line.
824    pub fn pretty(mut self, pretty: bool) -> Self {
825        self.pretty = pretty;
826        self
827    }
828
829    /// Returns true if the codec writes indented JSON.
830    pub fn is_pretty(&self) -> bool {
831        self.pretty
832    }
833}
834
835impl LayoutCodec for JsonLayoutCodec {
836    fn encode(&self, layout: &GridLayout) -> Result<String, LayoutError> {
837        if self.pretty {
838            serde_json::to_string_pretty(layout)
839        } else {
840            serde_json::to_string(layout)
841        }
842        .map_err(|error| LayoutError::Encode(error.to_string()))
843    }
844
845    fn decode(&self, text: &str) -> Result<GridLayout, LayoutError> {
846        let decode_error = |error: serde_json::Error| LayoutError::Decode(error.to_string());
847        let value: serde_json::Value = serde_json::from_str(text).map_err(decode_error)?;
848        let Some(object) = value.as_object() else {
849            return Err(LayoutError::Decode("expected a JSON object".into()));
850        };
851        let found = match object.get("version") {
852            None => return Err(LayoutError::Decode("missing field `version`".into())),
853            Some(version) => version.as_u64().ok_or_else(|| {
854                LayoutError::Decode(format!("`version` is not a whole number: {version}"))
855            })?,
856        };
857        check_version(u32::try_from(found).unwrap_or(u32::MAX))?;
858        // A new version migrates the older ones here, by editing `value`
859        // before it is parsed.
860        serde_json::from_value(value).map_err(decode_error)
861    }
862}
863
864/// The schema as a [`LayoutMerge`] sees it: every column with its default
865/// width, visibility and zone, and whether it can be grouped.
866#[derive(Clone, Copy, Debug)]
867pub struct LayoutMergeContext<'a> {
868    columns: &'a [LayoutColumn],
869    /// Schema columns the grid may not group by; none by default.
870    ungroupable: &'a [ColumnId],
871}
872
873impl<'a> LayoutMergeContext<'a> {
874    /// The schema's columns in schema order, each with its defaults; every
875    /// column can be grouped.
876    pub(crate) fn new(columns: &'a [LayoutColumn]) -> Self {
877        Self {
878            columns,
879            ungroupable: &[],
880        }
881    }
882
883    /// Names the schema columns the grid may not group by.
884    pub(crate) fn with_ungroupable(mut self, ids: &'a [ColumnId]) -> Self {
885        self.ungroupable = ids;
886        self
887    }
888
889    /// Schema columns in schema order, with default width, zone and
890    /// visibility.
891    pub fn columns(&self) -> &'a [LayoutColumn] {
892        self.columns
893    }
894
895    /// The schema column with this id and its defaults.
896    pub fn column(&self, id: &str) -> Option<&'a LayoutColumn> {
897        self.columns
898            .iter()
899            .find(|column| column.id().as_str() == id)
900    }
901
902    /// Returns true if the schema has a column with this id.
903    pub fn contains(&self, id: &str) -> bool {
904        self.column(id).is_some()
905    }
906
907    /// Returns true if the schema has a column with this id and the grid may
908    /// group by it.
909    pub fn is_groupable(&self, id: &str) -> bool {
910        self.contains(id) && !self.ungroupable.iter().any(|column| column.as_str() == id)
911    }
912}
913
914/// Resolves a saved layout against the current schema. Called once per
915/// import.
916///
917/// The default is [`AppendNewColumns`]. An alternative could place a new
918/// column next to its neighbour in the schema, or keep unknown columns for a
919/// schema that depends on the user's permissions.
920pub trait LayoutMerge: 'static {
921    /// One entry per schema column, in display order. The grid validates
922    /// the result whatever the implementation returns: it adds missing
923    /// columns, drops unknown ones and repeats, clamps widths, splits the
924    /// order by zone and keeps one column visible.
925    fn merge(&self, context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Vec<LayoutColumn>;
926}
927
928/// The default [`LayoutMerge`]: saved columns keep their order, width,
929/// visibility and zone; columns the schema no longer has are ignored, and a
930/// repeated id keeps its first entry. Schema columns the layout does not
931/// have go to the end of their zone, in schema order, with schema defaults.
932#[derive(Clone, Copy, Debug, Default)]
933pub struct AppendNewColumns;
934
935impl LayoutMerge for AppendNewColumns {
936    fn merge(&self, context: &LayoutMergeContext<'_>, saved: &GridLayout) -> Vec<LayoutColumn> {
937        fn zone_ix(zone: ColumnZone) -> usize {
938            match zone {
939                ColumnZone::FrozenLeft => 0,
940                ColumnZone::Scrolling => 1,
941                ColumnZone::FrozenRight => 2,
942            }
943        }
944
945        let mut merged = FxHashSet::default();
946        let mut zones: [Vec<LayoutColumn>; 3] = Default::default();
947        for column in saved.columns() {
948            let id = column.id().as_str();
949            if context.contains(id) && merged.insert(id) {
950                zones[zone_ix(column.zone())].push(column.clone());
951            }
952        }
953        for column in context.columns() {
954            if !merged.contains(column.id().as_str()) {
955                zones[zone_ix(column.zone())].push(column.clone());
956            }
957        }
958        zones.into_iter().flatten().collect()
959    }
960}
961
962/// Named layouts per view id, for "Save Layout As…" and a layout picker,
963/// plus the layout each view opens with.
964///
965/// Plain data: the application stores it where it keeps its settings and
966/// serializes it with serde. A view id names the kind of grid ("trades"),
967/// not a panel: every panel of a view shares its layouts. Layouts are kept
968/// as given; the grid checks their version when it applies one.
969#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
970pub struct LayoutLibrary {
971    #[serde(default)]
972    views: BTreeMap<String, ViewLayouts>,
973}
974
975#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
976struct ViewLayouts {
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    default: Option<String>,
979    #[serde(default)]
980    layouts: BTreeMap<String, GridLayout>,
981}
982
983impl LayoutLibrary {
984    /// An empty library.
985    pub fn new() -> Self {
986        Self::default()
987    }
988
989    /// Stores `layout` under its view id and `name`, replacing a layout with
990    /// that name. Fails with [`LayoutError::MissingViewId`] if the layout
991    /// has no view id.
992    pub fn insert(
993        &mut self,
994        name: impl Into<String>,
995        layout: GridLayout,
996    ) -> Result<(), LayoutError> {
997        let view_id = layout.view_id().ok_or(LayoutError::MissingViewId)?;
998        self.views
999            .entry(view_id.to_owned())
1000            .or_default()
1001            .layouts
1002            .insert(name.into(), layout);
1003        Ok(())
1004    }
1005
1006    /// The layout of a view with this name.
1007    pub fn get(&self, view_id: &str, name: &str) -> Option<&GridLayout> {
1008        self.views.get(view_id)?.layouts.get(name)
1009    }
1010
1011    /// The names of a view's layouts, sorted by their text (byte order).
1012    pub fn names<'a>(&'a self, view_id: &str) -> impl Iterator<Item = &'a str> + use<'a> {
1013        self.views
1014            .get(view_id)
1015            .into_iter()
1016            .flat_map(|view| view.layouts.keys().map(String::as_str))
1017    }
1018
1019    /// Removes a layout. If it was the view's default, the view has no
1020    /// default afterwards.
1021    pub fn remove(&mut self, view_id: &str, name: &str) -> Option<GridLayout> {
1022        let view = self.views.get_mut(view_id)?;
1023        let layout = view.layouts.remove(name)?;
1024        if view.default.as_deref() == Some(name) {
1025            view.default = None;
1026        }
1027        if view.layouts.is_empty() {
1028            self.views.remove(view_id);
1029        }
1030        Some(layout)
1031    }
1032
1033    /// Renames a layout; the default follows it. Returns false, and changes
1034    /// nothing, if there is no layout `from` or another layout is named
1035    /// `to`.
1036    pub fn rename(&mut self, view_id: &str, from: &str, to: impl Into<String>) -> bool {
1037        let to = to.into();
1038        let Some(view) = self.views.get_mut(view_id) else {
1039            return false;
1040        };
1041        if to != from && view.layouts.contains_key(&to) {
1042            return false;
1043        }
1044        let Some(layout) = view.layouts.remove(from) else {
1045            return false;
1046        };
1047        if view.default.as_deref() == Some(from) {
1048            view.default = Some(to.clone());
1049        }
1050        view.layouts.insert(to, layout);
1051        true
1052    }
1053
1054    /// Sets the layout a view opens with when nothing else is restored, or
1055    /// clears it with `None`. Returns false, and changes nothing, if the
1056    /// view has no layout with that name.
1057    pub fn set_default(&mut self, view_id: &str, name: Option<&str>) -> bool {
1058        let view = self.views.get_mut(view_id);
1059        match (view, name) {
1060            (Some(view), Some(name)) if view.layouts.contains_key(name) => {
1061                view.default = Some(name.to_owned());
1062                true
1063            }
1064            (_, Some(_)) => false,
1065            (Some(view), None) => {
1066                view.default = None;
1067                true
1068            }
1069            (None, None) => true,
1070        }
1071    }
1072
1073    /// The name and layout a view opens with when nothing else is restored.
1074    pub fn default_layout(&self, view_id: &str) -> Option<(&str, &GridLayout)> {
1075        let view = self.views.get(view_id)?;
1076        let (name, layout) = view.layouts.get_key_value(view.default.as_deref()?)?;
1077        Some((name.as_str(), layout))
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    /// The reference layout behind `tests/fixtures/layout-v1.json`: every
1086    /// zone, hidden columns in two zones, a fractional width and two sort
1087    /// keys.
1088    fn reference_layout() -> GridLayout {
1089        GridLayout::new()
1090            .with_view_id("trades")
1091            .with_column(LayoutColumn::new("id", 80.).with_zone(ColumnZone::FrozenLeft))
1092            .with_column(LayoutColumn::new("instrument", 140.))
1093            .with_column(LayoutColumn::new("time", 92.5))
1094            .with_column(LayoutColumn::new("fee", 90.).with_visible(false))
1095            .with_column(LayoutColumn::new("price", 100.))
1096            .with_column(LayoutColumn::new("side", 60.).with_zone(ColumnZone::FrozenRight))
1097            .with_column(
1098                LayoutColumn::new("venue", 72.)
1099                    .with_visible(false)
1100                    .with_zone(ColumnZone::FrozenRight),
1101            )
1102            .with_sort([
1103                LayoutSort::new("time", SortDirection::Descending),
1104                LayoutSort::new("instrument", SortDirection::Ascending),
1105            ])
1106    }
1107
1108    #[test]
1109    fn test_layout_builder() {
1110        let empty = GridLayout::new();
1111        assert_eq!(empty.version(), GridLayout::VERSION);
1112        assert_eq!(empty.view_id(), None);
1113        assert!(empty.columns().is_empty() && empty.sort().is_empty());
1114
1115        let column = LayoutColumn::new("price", 100.);
1116        assert_eq!(column.id().as_str(), "price");
1117        assert_eq!(column.width(), 100.);
1118        assert!(column.is_visible());
1119        assert_eq!(column.zone(), ColumnZone::Scrolling);
1120
1121        let layout = GridLayout::new()
1122            .with_view_id("orders")
1123            .with_column(
1124                LayoutColumn::new("price", 100.)
1125                    .with_width(gpui_kit::px(120.))
1126                    .with_visible(false)
1127                    .with_zone(ColumnZone::FrozenRight),
1128            )
1129            .with_column(LayoutColumn::new(String::from("qty"), 60.))
1130            .with_column(LayoutColumn::new("price", 30.))
1131            .with_sort([LayoutSort::new("qty", SortDirection::Ascending)])
1132            .with_sort([LayoutSort::new("price", SortDirection::Descending)]);
1133        assert_eq!(layout.view_id(), Some("orders"));
1134        let ids: Vec<_> = layout.columns().iter().map(|c| c.id().as_str()).collect();
1135        assert_eq!(ids, ["price", "qty", "price"]);
1136        // `column` finds the first entry of a repeated id.
1137        let price = layout.column("price").unwrap();
1138        assert_eq!(price.width(), 120.);
1139        assert!(!price.is_visible());
1140        assert_eq!(price.zone(), ColumnZone::FrozenRight);
1141        assert_eq!(layout.column("fee"), None);
1142        // `with_sort` replaces the sort.
1143        assert_eq!(layout.sort().len(), 1);
1144        assert_eq!(layout.sort()[0].column().as_str(), "price");
1145        assert_eq!(layout.sort()[0].direction(), SortDirection::Descending);
1146
1147        assert!(!JsonLayoutCodec::default().is_pretty());
1148        assert!(JsonLayoutCodec::default().pretty(true).is_pretty());
1149        assert_eq!(
1150            LayoutError::UnsupportedVersion { found: 2 }.to_string(),
1151            "layout version 2 is not supported; this build reads 1 to 1"
1152        );
1153    }
1154
1155    #[test]
1156    fn test_layout_json_matches_fixture() {
1157        // Git may check the fixture out with CRLF line endings on Windows.
1158        let fixture = include_str!("../tests/fixtures/layout-v1.json").replace("\r\n", "\n");
1159        let layout = reference_layout();
1160
1161        let pretty = JsonLayoutCodec::default().pretty(true);
1162        assert_eq!(format!("{}\n", pretty.encode(&layout).unwrap()), fixture);
1163        assert_eq!(pretty.decode(&fixture).unwrap(), layout);
1164
1165        // Without grouping the layout is written byte for byte as before the
1166        // grouping fields, and it reads back without them.
1167        let ungrouped = layout
1168            .clone()
1169            .with_group_by(Vec::new())
1170            .with_expansion(LayoutExpansion::default());
1171        assert_eq!(ungrouped, layout);
1172        assert_eq!(format!("{}\n", pretty.encode(&ungrouped).unwrap()), fixture);
1173        let read = pretty.decode(&fixture).unwrap();
1174        assert!(read.group_by().is_empty());
1175        assert_eq!(read.expansion(), None);
1176
1177        // The compact form and plain serde (the dock recipe's path) read the
1178        // same layout.
1179        let codec = JsonLayoutCodec::default();
1180        let compact = codec.encode(&layout).unwrap();
1181        assert!(!compact.contains('\n'));
1182        assert_eq!(codec.decode(&compact).unwrap(), layout);
1183        assert_eq!(
1184            serde_json::from_str::<GridLayout>(&fixture).unwrap(),
1185            layout
1186        );
1187    }
1188
1189    #[test]
1190    fn test_layout_decode_defaults_and_unknown_fields() {
1191        let codec = JsonLayoutCodec::default();
1192        let layout = codec
1193            .decode(
1194                r#"{
1195                    "version": 1,
1196                    "view_id": null,
1197                    "columns": [
1198                        { "id": "a", "width": 50 },
1199                        { "id": "b", "width": 60.5, "visible": true, "frozen": null, "tint": "red" },
1200                        { "id": "c", "width": null, "visible": false, "frozen": "right" }
1201                    ],
1202                    "filters": ["a"],
1203                    "density": { "rows": "compact" }
1204                }"#,
1205            )
1206            .unwrap();
1207        assert_eq!(layout.version(), 1);
1208        assert_eq!(layout.view_id(), None);
1209        assert!(layout.sort().is_empty());
1210        let a = layout.column("a").unwrap();
1211        assert_eq!(a.width(), 50.);
1212        assert!(a.is_visible());
1213        assert_eq!(a.zone(), ColumnZone::Scrolling);
1214        let b = layout.column("b").unwrap();
1215        assert_eq!(
1216            (b.width(), b.is_visible(), b.zone()),
1217            (60.5, true, ColumnZone::Scrolling)
1218        );
1219        // A `null` width (how JSON writes NaN) reads as NaN; the grid replaces
1220        // it with the schema width.
1221        let c = layout.column("c").unwrap();
1222        assert!(c.width().is_nan());
1223        assert_eq!((c.is_visible(), c.zone()), (false, ColumnZone::FrozenRight));
1224
1225        // Saving again drops the unknown fields and writes no defaults.
1226        assert_eq!(
1227            codec.encode(&layout).unwrap(),
1228            concat!(
1229                r#"{"version":1,"columns":[{"id":"a","width":50.0},{"id":"b","width":60.5},"#,
1230                r#"{"id":"c","width":null,"visible":false,"frozen":"right"}]}"#,
1231            )
1232        );
1233
1234        // Unknown values of known fields are not a layout.
1235        for text in [
1236            r#"{"version":1,"columns":[{"id":"a","width":50,"frozen":"top"}]}"#,
1237            r#"{"version":1,"columns":[{"id":"a","width":"wide"}]}"#,
1238            r#"{"version":1,"columns":[{"width":50}]}"#,
1239            r#"{"version":1,"columns":[],"sort":[{"column":"a","direction":"up"}]}"#,
1240            r#"{"version":1,"columns":[],"group_by":[{"column":"a","direction":"up"}]}"#,
1241            r#"{"version":1,"columns":[],"group_by":[{"direction":"ascending"}]}"#,
1242            r#"{"version":1,"columns":[],"group_by":["a"]}"#,
1243            r#"{"version":1,"columns":[],"expansion":{"depth":"deep"}}"#,
1244            r#"{"version":1,"columns":[],"expansion":"all"}"#,
1245            r#"{"version":1,"columns":[],"expansion":{"toggled":"AAPL"}}"#,
1246            r#"{"version":1,"columns":[],"expansion":{"toggled":["AAPL"]}}"#,
1247        ] {
1248            assert!(
1249                matches!(codec.decode(text), Err(LayoutError::Decode(_))),
1250                "{text}"
1251            );
1252        }
1253    }
1254
1255    fn text(value: &str) -> OwnedValue {
1256        OwnedValue::Text(value.into())
1257    }
1258
1259    /// The reference layout behind `tests/fixtures/layout-v1-groups.json`:
1260    /// two grouping levels, the default depth and toggled groups of every
1261    /// kind of value, `12` and `12.0` apart. The decimal path needs the
1262    /// `decimal` feature.
1263    fn grouped_layout() -> GridLayout {
1264        let expansion = LayoutExpansion::new(ExpandDepth::All)
1265            .with_toggled([text("AAPL")])
1266            .with_toggled([text("MSFT"), text("Sell")])
1267            .with_toggled([OwnedValue::Empty])
1268            .with_toggled([OwnedValue::Bool(true)])
1269            .with_toggled([OwnedValue::Int(12)])
1270            .with_toggled([OwnedValue::Float(12.0)]);
1271        #[cfg(feature = "decimal")]
1272        let expansion =
1273            expansion.with_toggled([OwnedValue::Decimal(rust_decimal::Decimal::new(1020, 2))]);
1274        GridLayout::new()
1275            .with_view_id("trades")
1276            .with_column(LayoutColumn::new("instrument", 140.))
1277            .with_column(LayoutColumn::new("side", 60.))
1278            .with_column(LayoutColumn::new("price", 100.))
1279            .with_sort([LayoutSort::new("price", SortDirection::Descending)])
1280            .with_group_by([
1281                LayoutGroup::new("instrument", SortDirection::Ascending),
1282                LayoutGroup::new("side", SortDirection::Descending),
1283            ])
1284            .with_expansion(expansion)
1285    }
1286
1287    fn ids(ids: &[ColumnId]) -> Vec<&str> {
1288        ids.iter().map(ColumnId::as_str).collect()
1289    }
1290
1291    #[test]
1292    fn test_layout_groups_builder() {
1293        let level = LayoutGroup::new("side", SortDirection::Descending);
1294        assert_eq!(level.column().as_str(), "side");
1295        assert_eq!(level.direction(), SortDirection::Descending);
1296
1297        let expansion = LayoutExpansion::new(ExpandDepth::Levels(1))
1298            .with_toggled([text("AAPL")])
1299            .with_toggled(Vec::new());
1300        assert_eq!(expansion.depth(), ExpandDepth::Levels(1));
1301        assert_eq!(expansion.toggled(), [vec![text("AAPL")], Vec::new()]);
1302        assert!(!expansion.is_default());
1303        assert!(LayoutExpansion::default().is_default());
1304        assert!(!LayoutExpansion::new(ExpandDepth::Captions).is_default());
1305        assert!(
1306            !LayoutExpansion::default()
1307                .with_toggled([text("a")])
1308                .is_default()
1309        );
1310
1311        let layout = GridLayout::new()
1312            .with_group_by([level.clone()])
1313            .with_group_by([level.clone(), level.clone()])
1314            .with_expansion(LayoutExpansion::new(ExpandDepth::Captions))
1315            .with_expansion(expansion.clone());
1316        // Both builders replace.
1317        assert_eq!(layout.group_by(), [level.clone(), level]);
1318        assert_eq!(layout.expansion(), Some(&expansion));
1319        assert!(GridLayout::new().group_by().is_empty());
1320        assert_eq!(GridLayout::new().expansion(), None);
1321        assert_eq!(
1322            layout
1323                .with_expansion(LayoutExpansion::default())
1324                .expansion(),
1325            None
1326        );
1327    }
1328
1329    #[test]
1330    fn test_layout_groups_fixture() {
1331        use std::mem::discriminant;
1332
1333        let fixture = include_str!("../tests/fixtures/layout-v1-groups.json").replace("\r\n", "\n");
1334        let layout = grouped_layout();
1335        let pretty = JsonLayoutCodec::default().pretty(true);
1336        let read = pretty.decode(&fixture).unwrap();
1337        // Plain serde (the dock recipe's path) reads the same layout.
1338        assert_eq!(serde_json::from_str::<GridLayout>(&fixture).unwrap(), read);
1339
1340        // `12` reads as an integer and `12.0` as a float.
1341        let toggled = read.expansion().unwrap().toggled();
1342        assert_eq!(
1343            discriminant(&toggled[4][0]),
1344            discriminant(&OwnedValue::Int(12))
1345        );
1346        assert_eq!(
1347            discriminant(&toggled[5][0]),
1348            discriminant(&OwnedValue::Float(12.0))
1349        );
1350
1351        let schema = [
1352            LayoutColumn::new("instrument", 100.),
1353            LayoutColumn::new("side", 100.),
1354            LayoutColumn::new("price", 100.),
1355        ];
1356        let context = LayoutMergeContext::new(&schema);
1357        let report = LayoutReport::resolve(&context, &read);
1358        assert!(report.dropped_group_by().is_empty());
1359
1360        #[cfg(feature = "decimal")]
1361        {
1362            assert_eq!(format!("{}\n", pretty.encode(&layout).unwrap()), fixture);
1363            assert_eq!(read, layout);
1364            assert!(
1365                matches!(&toggled[6][0], OwnedValue::Decimal(value) if value.to_string() == "10.20")
1366            );
1367            assert!(report.is_exact());
1368        }
1369        #[cfg(not(feature = "decimal"))]
1370        {
1371            // Without the feature a decimal cannot be a key: the layout
1372            // reads, the path is dropped and the report counts it.
1373            assert_eq!(read.group_by(), layout.group_by());
1374            assert_eq!(toggled, layout.expansion().unwrap().toggled());
1375            assert_eq!(report.dropped_expansion(), 1);
1376            assert!(!report.is_exact());
1377            // Saving again writes the rest.
1378            assert_eq!(
1379                pretty.encode(&read).unwrap(),
1380                pretty.encode(&layout).unwrap()
1381            );
1382        }
1383        // Writing again gives the same text.
1384        let compact = JsonLayoutCodec::default();
1385        let text = compact.encode(&read).unwrap();
1386        assert_eq!(
1387            compact.encode(&compact.decode(&text).unwrap()).unwrap(),
1388            text
1389        );
1390    }
1391
1392    #[test]
1393    fn test_expansion_default_omitted() {
1394        let codec = JsonLayoutCodec::default();
1395        let empty = r#"{"version":1,"columns":[]}"#;
1396        // No grouping and the default expansion are not written; nor is a
1397        // path with a NaN or an infinity, which JSON cannot hold.
1398        for layout in [
1399            GridLayout::new().with_expansion(LayoutExpansion::new(ExpandDepth::All)),
1400            GridLayout::new()
1401                .with_group_by(Vec::new())
1402                .with_expansion(LayoutExpansion::default()),
1403            GridLayout::new().with_expansion(
1404                LayoutExpansion::default()
1405                    .with_toggled([text("a"), OwnedValue::Float(f64::NAN)])
1406                    .with_toggled([OwnedValue::Float(f64::NEG_INFINITY)]),
1407            ),
1408        ] {
1409            assert_eq!(codec.encode(&layout).unwrap(), empty);
1410        }
1411        let read = codec.decode(empty).unwrap();
1412        assert_eq!(read, GridLayout::new());
1413        assert_eq!(read.expansion(), None);
1414
1415        // Any other depth is written, without an empty `toggled`.
1416        for (depth, json) in [
1417            (ExpandDepth::Captions, r#""captions""#),
1418            (ExpandDepth::Levels(0), "0"),
1419            (ExpandDepth::Levels(3), "3"),
1420        ] {
1421            let layout = GridLayout::new().with_expansion(LayoutExpansion::new(depth));
1422            let text = codec.encode(&layout).unwrap();
1423            assert_eq!(
1424                text,
1425                format!(r#"{{"version":1,"columns":[],"expansion":{{"depth":{json}}}}}"#)
1426            );
1427            assert_eq!(codec.decode(&text).unwrap(), layout);
1428            assert_eq!(layout.expansion().map(LayoutExpansion::depth), Some(depth));
1429        }
1430
1431        // Only the paths JSON can hold are written.
1432        let mixed = GridLayout::new().with_expansion(
1433            LayoutExpansion::new(ExpandDepth::Levels(1))
1434                .with_toggled([text("a"), OwnedValue::Float(f64::INFINITY)])
1435                .with_toggled([text("b")]),
1436        );
1437        assert_eq!(
1438            codec.encode(&mixed).unwrap(),
1439            r#"{"version":1,"columns":[],"expansion":{"depth":1,"toggled":[["b"]]}}"#
1440        );
1441
1442        // `depth` reads "all", "captions" and whole numbers, and is "all"
1443        // when missing.
1444        for (json, depth) in [
1445            (r#"{"depth":"all"}"#, ExpandDepth::All),
1446            (r#"{"depth":"captions"}"#, ExpandDepth::Captions),
1447            (r#"{"depth":0}"#, ExpandDepth::Levels(0)),
1448            (r#"{"depth":2,"rows":[[1]]}"#, ExpandDepth::Levels(2)),
1449            (r#"{}"#, ExpandDepth::All),
1450            (r#"{"toggled":[]}"#, ExpandDepth::All),
1451        ] {
1452            let text = format!(r#"{{"version":1,"columns":[],"expansion":{json}}}"#);
1453            let layout = codec.decode(&text).unwrap();
1454            assert_eq!(
1455                layout
1456                    .expansion()
1457                    .map_or(ExpandDepth::All, LayoutExpansion::depth),
1458                depth,
1459                "{json}"
1460            );
1461        }
1462        for json in [r#""deep""#, r#""All""#, "-1", "1.5", "65536", "null", "[1]"] {
1463            let text = format!(r#"{{"version":1,"columns":[],"expansion":{{"depth":{json}}}}}"#);
1464            assert!(
1465                matches!(codec.decode(&text), Err(LayoutError::Decode(_))),
1466                "{json}"
1467            );
1468        }
1469    }
1470
1471    #[test]
1472    fn test_layout_report_groups() {
1473        // Five schema columns; `e` cannot be grouped.
1474        let schema = ["a", "b", "c", "d", "e"].map(|id| LayoutColumn::new(id, 50.));
1475        let ungroupable = [ColumnId::from("e")];
1476        let context = LayoutMergeContext::new(&schema).with_ungroupable(&ungroupable);
1477        assert!(context.is_groupable("a") && context.contains("e"));
1478        assert!(!context.is_groupable("e") && !context.is_groupable("gone"));
1479        assert!(
1480            LayoutMergeContext::new(&schema).is_groupable("e"),
1481            "every column can be grouped by default"
1482        );
1483
1484        let saved = JsonLayoutCodec::default()
1485            .decode(
1486                r#"{
1487                    "version": 1,
1488                    "columns": [
1489                        { "id": "a", "width": 50 }, { "id": "b", "width": 50 },
1490                        { "id": "c", "width": 50 }, { "id": "d", "width": 50 },
1491                        { "id": "e", "width": 50 }
1492                    ],
1493                    "sort": [
1494                        { "column": "gone", "direction": "ascending" },
1495                        { "column": "e", "direction": "ascending" }
1496                    ],
1497                    "group_by": [
1498                        { "column": "a", "direction": "ascending" },
1499                        { "column": "gone", "direction": "ascending" },
1500                        { "column": "b", "direction": "descending" },
1501                        { "column": "a", "direction": "descending" },
1502                        { "column": "e", "direction": "ascending" },
1503                        { "column": "gone", "direction": "descending" },
1504                        { "column": "c", "direction": "ascending" }
1505                    ],
1506                    "expansion": {
1507                        "depth": 9,
1508                        "toggled": [
1509                            ["x"], [], [1, 2, 3], [1, 2, 3, 4], ["x", "y"],
1510                            [{ "date": "2026-11-04" }], ["x", [1]], [18446744073709551615]
1511                        ]
1512                    }
1513                }"#,
1514            )
1515            .unwrap();
1516        // A value this build cannot hold drops its path when read.
1517        assert_eq!(saved.expansion().unwrap().toggled().len(), 5);
1518
1519        let report = LayoutReport::resolve(&context, &saved);
1520        // Unknown, repeated and ungroupable levels, in saved order, once.
1521        assert_eq!(ids(report.dropped_group_by()), ["gone", "a", "e"]);
1522        // Empty and too deep paths, and the three unreadable ones.
1523        assert_eq!(report.dropped_expansion(), 5);
1524        // A column can leave the sort and the grouping alike.
1525        assert_eq!(ids(report.dropped_sort()), ["gone"]);
1526        assert!(report.ignored_columns().is_empty() && report.added_columns().is_empty());
1527        assert!(!report.is_exact());
1528
1529        // The lower levels move up; paths apply to them as they are.
1530        let resolved = ResolvedGrouping::new(&context, &saved);
1531        let levels: Vec<_> = resolved
1532            .levels()
1533            .iter()
1534            .map(|level| (level.column().as_str(), level.direction()))
1535            .collect();
1536        assert_eq!(
1537            levels,
1538            [
1539                ("a", SortDirection::Ascending),
1540                ("b", SortDirection::Descending),
1541                ("c", SortDirection::Ascending),
1542            ]
1543        );
1544        let int = OwnedValue::Int;
1545        assert_eq!(
1546            resolved.toggled(),
1547            [
1548                &[text("x")][..],
1549                &[int(1), int(2), int(3)],
1550                &[text("x"), text("y")],
1551            ]
1552        );
1553        // The depth stays as saved, deeper than the levels.
1554        assert_eq!(resolved.depth(), ExpandDepth::Levels(9));
1555
1556        // When every level is dropped, so is every path; the depth stays.
1557        let gone = GridLayout::new()
1558            .with_group_by([LayoutGroup::new("gone", SortDirection::Ascending)])
1559            .with_expansion(LayoutExpansion::new(ExpandDepth::Captions).with_toggled([text("x")]));
1560        let report = LayoutReport::resolve(&context, &gone);
1561        assert_eq!(ids(report.dropped_group_by()), ["gone"]);
1562        assert_eq!(report.dropped_expansion(), 1);
1563        let resolved = ResolvedGrouping::new(&context, &gone);
1564        assert!(resolved.levels().is_empty() && resolved.toggled().is_empty());
1565        assert_eq!(resolved.depth(), ExpandDepth::Captions);
1566
1567        // Without grouping a depth alone is exact; a path names no column.
1568        let depth_only = |expansion: LayoutExpansion| {
1569            schema
1570                .iter()
1571                .cloned()
1572                .fold(GridLayout::new(), GridLayout::with_column)
1573                .with_expansion(expansion)
1574        };
1575        let report = LayoutReport::resolve(
1576            &context,
1577            &depth_only(LayoutExpansion::new(ExpandDepth::Levels(0))),
1578        );
1579        assert!(report.is_exact());
1580        let report = LayoutReport::resolve(
1581            &context,
1582            &depth_only(LayoutExpansion::new(ExpandDepth::Levels(0)).with_toggled([text("x")])),
1583        );
1584        assert_eq!(report.dropped_expansion(), 1);
1585
1586        // A layout that fits is exact.
1587        let fits = depth_only(
1588            LayoutExpansion::new(ExpandDepth::All)
1589                .with_toggled([text("x")])
1590                .with_toggled([text("x"), OwnedValue::Empty]),
1591        )
1592        .with_group_by([
1593            LayoutGroup::new("d", SortDirection::Descending),
1594            LayoutGroup::new("a", SortDirection::Ascending),
1595        ]);
1596        assert!(LayoutReport::resolve(&context, &fits).is_exact());
1597        assert_eq!(ResolvedGrouping::new(&context, &fits).toggled().len(), 2);
1598    }
1599
1600    #[test]
1601    fn test_old_build_reads_new_layout() {
1602        /// `GridLayout` as a build without grouping declares it.
1603        #[derive(Debug, PartialEq, Serialize, Deserialize)]
1604        struct OldLayout {
1605            version: u32,
1606            #[serde(default, skip_serializing_if = "Option::is_none")]
1607            view_id: Option<String>,
1608            columns: Vec<LayoutColumn>,
1609            #[serde(default, skip_serializing_if = "Vec::is_empty")]
1610            sort: Vec<LayoutSort>,
1611        }
1612
1613        // The old build reads the grouped fixture and keeps the rest.
1614        let fixture = include_str!("../tests/fixtures/layout-v1-groups.json");
1615        let old: OldLayout = serde_json::from_str(fixture).unwrap();
1616        let layout = grouped_layout();
1617        assert_eq!(old.version, GridLayout::VERSION);
1618        assert_eq!(old.view_id.as_deref(), layout.view_id());
1619        assert_eq!(old.columns, layout.columns());
1620        assert_eq!(old.sort, layout.sort());
1621
1622        // Saving there drops the grouping; this build reads that as a layout
1623        // without grouping.
1624        let saved_again = serde_json::to_string(&old).unwrap();
1625        let read = JsonLayoutCodec::default().decode(&saved_again).unwrap();
1626        assert_eq!(
1627            read,
1628            layout
1629                .with_group_by(Vec::new())
1630                .with_expansion(LayoutExpansion::default())
1631        );
1632
1633        // Both builds write a layout without grouping alike.
1634        let reference = reference_layout();
1635        let old = OldLayout {
1636            version: reference.version(),
1637            view_id: reference.view_id().map(str::to_owned),
1638            columns: reference.columns().to_vec(),
1639            sort: reference.sort().to_vec(),
1640        };
1641        assert_eq!(
1642            serde_json::to_string(&old).unwrap(),
1643            JsonLayoutCodec::default().encode(&reference).unwrap()
1644        );
1645    }
1646
1647    #[test]
1648    fn test_layout_round_trip_property() {
1649        const IDS: [&str; 10] = ["id", "a", "b", "c", "d", "e", "f", "g", "Цена", "量"];
1650        const PIECES: [&str; 10] = ["a", "Я", "✓", "😀", "\"", "\\", "\n", " ", "é", "中"];
1651
1652        fn value(rng: &mut fastrand::Rng) -> OwnedValue {
1653            match rng.u8(..7) {
1654                0 => OwnedValue::Empty,
1655                1 => OwnedValue::Bool(rng.bool()),
1656                2 => OwnedValue::Int(rng.i64(..)),
1657                // Any finite float, bit for bit.
1658                3 => loop {
1659                    let float = f64::from_bits(rng.u64(..));
1660                    if float.is_finite() {
1661                        break OwnedValue::Float(float);
1662                    }
1663                },
1664                // Prices, integral floats and zeros of both signs.
1665                4 => OwnedValue::Float(match rng.u8(..4) {
1666                    0 => (rng.f64() - 0.5) * 1e6,
1667                    1 => rng.i32(..) as f64,
1668                    2 => 0.,
1669                    _ => -0.,
1670                }),
1671                _ => OwnedValue::Text(
1672                    (0..rng.usize(..6))
1673                        .map(|_| PIECES[rng.usize(..PIECES.len())])
1674                        .collect::<String>()
1675                        .into(),
1676                ),
1677            }
1678        }
1679
1680        fn direction(rng: &mut fastrand::Rng) -> SortDirection {
1681            if rng.bool() {
1682                SortDirection::Ascending
1683            } else {
1684                SortDirection::Descending
1685            }
1686        }
1687
1688        let zones = [
1689            ColumnZone::FrozenLeft,
1690            ColumnZone::Scrolling,
1691            ColumnZone::FrozenRight,
1692        ];
1693        for seed in 0..300 {
1694            let mut rng = fastrand::Rng::with_seed(seed);
1695            let mut layout = GridLayout::new();
1696            if rng.bool() {
1697                layout = layout.with_view_id(PIECES[rng.usize(..PIECES.len())]);
1698            }
1699            for _ in 0..rng.usize(..=8) {
1700                layout = layout.with_column(
1701                    LayoutColumn::new(IDS[rng.usize(..IDS.len())], rng.u16(1..800) as f32 / 2.)
1702                        .with_visible(rng.bool())
1703                        .with_zone(zones[rng.usize(..3)]),
1704                );
1705            }
1706            let id = |rng: &mut fastrand::Rng| IDS[rng.usize(..IDS.len())];
1707            let sort: Vec<_> = (0..rng.usize(..=3))
1708                .map(|_| LayoutSort::new(id(&mut rng), direction(&mut rng)))
1709                .collect();
1710            let levels: Vec<_> = (0..rng.usize(..=3))
1711                .map(|_| LayoutGroup::new(id(&mut rng), direction(&mut rng)))
1712                .collect();
1713            let depth = match rng.u8(..4) {
1714                0 => ExpandDepth::All,
1715                1 => ExpandDepth::Captions,
1716                _ => ExpandDepth::Levels(rng.u16(..5)),
1717            };
1718            let mut expansion = LayoutExpansion::new(depth);
1719            for _ in 0..rng.usize(..=10) {
1720                let path: Vec<_> = (0..rng.usize(1..=3)).map(|_| value(&mut rng)).collect();
1721                expansion = expansion.with_toggled(path);
1722            }
1723            let layout = layout
1724                .with_sort(sort)
1725                .with_group_by(levels)
1726                .with_expansion(expansion);
1727
1728            for codec in [
1729                JsonLayoutCodec::default(),
1730                JsonLayoutCodec::default().pretty(true),
1731            ] {
1732                let text = codec.encode(&layout).unwrap();
1733                let read = codec.decode(&text).unwrap();
1734                assert_eq!(read, layout, "seed {seed}: {text}");
1735                // Integers stay integers and floats floats, bit for bit.
1736                assert_eq!(codec.encode(&read).unwrap(), text, "seed {seed}");
1737            }
1738        }
1739    }
1740
1741    /// Microseconds to write and to read a layout of 128 columns, three
1742    /// grouping levels and ten toggled groups. Run with `cargo test
1743    /// --release -p gpui-kit-grid --lib -- --ignored --nocapture
1744    /// test_layout_codec_timing`.
1745    #[test]
1746    #[ignore = "timing; run in release with --ignored --nocapture"]
1747    #[cfg(not(target_family = "wasm"))]
1748    fn test_layout_codec_timing() {
1749        use std::{hint::black_box, time::Instant};
1750
1751        let names: Vec<String> = (0..128).map(|ix| format!("column_{ix}")).collect();
1752        let mut expansion = LayoutExpansion::new(ExpandDepth::Levels(1));
1753        for ix in 0..10 {
1754            expansion = expansion.with_toggled([
1755                text(&format!("instrument {ix}")),
1756                text("Buy"),
1757                OwnedValue::Float(100.25 + f64::from(ix)),
1758            ]);
1759        }
1760        let layout = names
1761            .iter()
1762            .map(|name| LayoutColumn::new(name.clone(), 96.5))
1763            .fold(
1764                GridLayout::new().with_view_id("trades"),
1765                GridLayout::with_column,
1766            )
1767            .with_sort([LayoutSort::new("column_3", SortDirection::Descending)])
1768            .with_group_by(
1769                names[..3]
1770                    .iter()
1771                    .map(|name| LayoutGroup::new(name.clone(), SortDirection::Ascending)),
1772            )
1773            .with_expansion(expansion);
1774
1775        let codec = JsonLayoutCodec::default();
1776        let text = codec.encode(&layout).unwrap();
1777        let micros = |run: &dyn Fn()| {
1778            (0..5)
1779                .map(|_| {
1780                    let started = Instant::now();
1781                    for _ in 0..1_000 {
1782                        run();
1783                    }
1784                    started.elapsed().as_secs_f64() * 1e6 / 1_000.
1785                })
1786                .fold(f64::MAX, f64::min)
1787        };
1788        let encode = micros(&|| {
1789            black_box(codec.encode(black_box(&layout)).unwrap());
1790        });
1791        let decode = micros(&|| {
1792            black_box(codec.decode(black_box(&text)).unwrap());
1793        });
1794        println!(
1795            "{} bytes: encode {encode:.1} µs, decode {decode:.1} µs",
1796            text.len()
1797        );
1798    }
1799
1800    #[test]
1801    fn test_layout_version_rules() {
1802        let codec = JsonLayoutCodec::default();
1803        for (text, found) in [
1804            (r#"{"version":2,"columns":[]}"#, 2),
1805            (r#"{"version":0,"columns":[]}"#, 0),
1806            // The version is checked before the rest: a newer build may have
1807            // changed what the fields mean.
1808            (r#"{"version":99,"columns":"by id"}"#, 99),
1809            (r#"{"version":4294967296,"columns":[]}"#, u32::MAX),
1810        ] {
1811            assert_eq!(
1812                codec.decode(text),
1813                Err(LayoutError::UnsupportedVersion { found }),
1814                "{text}"
1815            );
1816        }
1817        for text in [
1818            r#"{"columns":[]}"#,
1819            r#"{"version":"1","columns":[]}"#,
1820            r#"{"version":-1,"columns":[]}"#,
1821            r#"{"version":1.5,"columns":[]}"#,
1822            r#"{"version":1}"#,
1823            r#"[{"version":1,"columns":[]}]"#,
1824            "not json",
1825            "",
1826        ] {
1827            assert!(
1828                matches!(codec.decode(text), Err(LayoutError::Decode(_))),
1829                "{text}"
1830            );
1831        }
1832
1833        // Plain serde keeps the version it reads, so the grid can refuse a
1834        // layout that did not come through a codec.
1835        let newer: GridLayout = serde_json::from_str(r#"{"version":2,"columns":[]}"#).unwrap();
1836        assert_eq!(newer.version(), 2);
1837        assert_eq!(
1838            check_version(newer.version()),
1839            Err(LayoutError::UnsupportedVersion { found: 2 })
1840        );
1841        assert_eq!(check_version(GridLayout::VERSION), Ok(()));
1842    }
1843
1844    #[test]
1845    fn test_append_new_columns() {
1846        // Schema order with defaults; `fee`, `flag` and `note` are new.
1847        let schema = [
1848            LayoutColumn::new("id", 80.).with_zone(ColumnZone::FrozenLeft),
1849            LayoutColumn::new("name", 120.),
1850            LayoutColumn::new("fee", 60.),
1851            LayoutColumn::new("price", 90.),
1852            LayoutColumn::new("flag", 40.).with_zone(ColumnZone::FrozenLeft),
1853            LayoutColumn::new("qty", 70.).with_visible(false),
1854            LayoutColumn::new("note", 200.).with_visible(false),
1855            LayoutColumn::new("side", 50.).with_zone(ColumnZone::FrozenRight),
1856        ];
1857        let context = LayoutMergeContext::new(&schema);
1858        assert!(context.contains("fee") && !context.contains("gone"));
1859        assert_eq!(context.column("qty"), Some(&schema[5]));
1860
1861        let saved = GridLayout::new()
1862            .with_column(LayoutColumn::new("price", 101.))
1863            .with_column(LayoutColumn::new("gone", 55.))
1864            .with_column(LayoutColumn::new("name", 133.).with_visible(false))
1865            .with_column(LayoutColumn::new("price", 999.).with_zone(ColumnZone::FrozenLeft))
1866            .with_column(LayoutColumn::new("qty", 71.).with_zone(ColumnZone::FrozenRight))
1867            .with_column(LayoutColumn::new("id", 81.).with_zone(ColumnZone::FrozenLeft))
1868            .with_column(LayoutColumn::new("side", 51.).with_zone(ColumnZone::FrozenRight))
1869            .with_column(LayoutColumn::new("gone", 56.))
1870            .with_sort([
1871                LayoutSort::new("gone", SortDirection::Descending),
1872                LayoutSort::new("price", SortDirection::Ascending),
1873                LayoutSort::new("price", SortDirection::Descending),
1874                LayoutSort::new("side", SortDirection::Ascending),
1875                LayoutSort::new("zz", SortDirection::Ascending),
1876            ]);
1877
1878        // Saved columns keep their width, visibility and zone; the first
1879        // `price` wins; new columns end their zone in schema order with
1880        // schema defaults.
1881        let merged = AppendNewColumns.merge(&context, &saved);
1882        let expected = [
1883            LayoutColumn::new("id", 81.).with_zone(ColumnZone::FrozenLeft),
1884            LayoutColumn::new("flag", 40.).with_zone(ColumnZone::FrozenLeft),
1885            LayoutColumn::new("price", 101.),
1886            LayoutColumn::new("name", 133.).with_visible(false),
1887            LayoutColumn::new("fee", 60.),
1888            LayoutColumn::new("note", 200.).with_visible(false),
1889            LayoutColumn::new("qty", 71.).with_zone(ColumnZone::FrozenRight),
1890            LayoutColumn::new("side", 51.).with_zone(ColumnZone::FrozenRight),
1891        ];
1892        assert_eq!(merged, expected);
1893
1894        // The report comes from the schema and the saved layout, not from the
1895        // merge.
1896        let report = LayoutReport::resolve(&context, &saved);
1897        assert_eq!(ids(report.ignored_columns()), ["gone"]);
1898        assert_eq!(ids(report.added_columns()), ["fee", "flag", "note"]);
1899        assert_eq!(ids(report.dropped_sort()), ["gone", "price", "zz"]);
1900        assert!(!report.is_exact());
1901
1902        // Without saved columns the merge is the schema split by zone.
1903        let schema_only = AppendNewColumns.merge(&context, &GridLayout::new());
1904        let order: Vec<_> = schema_only.iter().map(|c| c.id().as_str()).collect();
1905        assert_eq!(
1906            order,
1907            ["id", "flag", "name", "fee", "price", "qty", "note", "side"]
1908        );
1909
1910        // Applying the merged layout again is exact and changes nothing.
1911        let round_trip = merged
1912            .iter()
1913            .cloned()
1914            .fold(GridLayout::new(), GridLayout::with_column)
1915            .with_sort([LayoutSort::new("price", SortDirection::Ascending)]);
1916        assert!(LayoutReport::resolve(&context, &round_trip).is_exact());
1917        assert_eq!(AppendNewColumns.merge(&context, &round_trip), merged);
1918    }
1919
1920    #[test]
1921    fn test_layout_library() {
1922        let trades = |width: f32| {
1923            GridLayout::new()
1924                .with_view_id("trades")
1925                .with_column(LayoutColumn::new("price", width))
1926        };
1927        let names = |library: &LayoutLibrary, view_id: &str| -> Vec<String> {
1928            library.names(view_id).map(str::to_owned).collect()
1929        };
1930
1931        let mut library = LayoutLibrary::new();
1932        assert_eq!(
1933            library.insert("Wide", GridLayout::new()),
1934            Err(LayoutError::MissingViewId)
1935        );
1936        library.insert("Wide", trades(200.)).unwrap();
1937        library.insert("Compact", trades(60.)).unwrap();
1938        library.insert("Wide", trades(240.)).unwrap();
1939        library
1940            .insert("Default", GridLayout::new().with_view_id("orders"))
1941            .unwrap();
1942        assert_eq!(names(&library, "trades"), ["Compact", "Wide"]);
1943        assert_eq!(names(&library, "orders"), ["Default"]);
1944        assert_eq!(names(&library, "positions"), Vec::<String>::new());
1945        assert_eq!(library.get("trades", "Wide"), Some(&trades(240.)));
1946        assert_eq!(library.get("orders", "Wide"), None);
1947
1948        assert_eq!(library.default_layout("trades"), None);
1949        assert!(!library.set_default("trades", Some("Missing")));
1950        assert!(!library.set_default("positions", Some("Wide")));
1951        assert!(library.set_default("trades", Some("Compact")));
1952        assert_eq!(
1953            library.default_layout("trades"),
1954            Some(("Compact", &trades(60.)))
1955        );
1956
1957        // Renaming keeps the default and never replaces another layout.
1958        assert!(!library.rename("trades", "Compact", "Wide"));
1959        assert!(!library.rename("trades", "Missing", "Other"));
1960        assert!(!library.rename("positions", "Compact", "Other"));
1961        assert!(library.rename("trades", "Compact", "Compact"));
1962        assert!(library.rename("trades", "Compact", "Narrow"));
1963        assert_eq!(names(&library, "trades"), ["Narrow", "Wide"]);
1964        assert_eq!(
1965            library.default_layout("trades"),
1966            Some(("Narrow", &trades(60.)))
1967        );
1968
1969        // The library is plain serde data.
1970        let text = serde_json::to_string(&library).unwrap();
1971        assert_eq!(
1972            serde_json::from_str::<LayoutLibrary>(&text).unwrap(),
1973            library
1974        );
1975        let mut orders_only = LayoutLibrary::new();
1976        orders_only
1977            .insert("Default", GridLayout::new().with_view_id("orders"))
1978            .unwrap();
1979        assert_eq!(
1980            serde_json::to_string(&orders_only).unwrap(),
1981            r#"{"views":{"orders":{"layouts":{"Default":{"version":1,"view_id":"orders","columns":[]}}}}}"#
1982        );
1983
1984        // Removing the default clears it; the last removal drops the view.
1985        assert_eq!(library.remove("trades", "Narrow"), Some(trades(60.)));
1986        assert_eq!(library.remove("trades", "Narrow"), None);
1987        assert_eq!(library.default_layout("trades"), None);
1988        assert!(library.set_default("trades", Some("Wide")));
1989        assert!(library.set_default("trades", None));
1990        assert_eq!(library.default_layout("trades"), None);
1991        assert_eq!(library.remove("trades", "Wide"), Some(trades(240.)));
1992        assert_eq!(names(&library, "trades"), Vec::<String>::new());
1993        assert!(library.set_default("trades", None));
1994        assert_eq!(
1995            library.get("orders", "Default").map(GridLayout::version),
1996            Some(1)
1997        );
1998    }
1999}