Skip to main content

flower_core/
model.rs

1//! The frontend-neutral editor model and its structural operations.
2//!
3//! `Model` is generic over a [`Backend`]: it builds path-addressed [`EditOp`]s,
4//! applies them through the backend, and re-derives its view from
5//! [`Backend::to_value`] after each change. It owns no editor, no format, no
6//! filesystem, and no terminal — the backend owns the document; the embedder
7//! owns file I/O and rendering.
8
9use std::collections::{HashMap, HashSet};
10
11use anyhow::Result;
12use fig::Value;
13
14use crate::backend::{Backend, EditOp};
15use crate::page::{self, Page, PageItem};
16use crate::schema::{FieldRule, Schema};
17use crate::tree::{self, Row, Seg};
18use fig_schema::{Issue, SegPat, Validation};
19
20/// Which projection the frontend is navigating: the whole-document
21/// [`tree`](crate::tree), or one [`page`](crate::page) at a time.
22///
23/// The document is unaffected — both are views over the same `Value`, and every
24/// edit is path-addressed, so switching mid-session changes what you can see and
25/// nothing about what you can do.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ViewMode {
28    /// Every visible node at once, indented by depth. Best when the whole
29    /// document fits on a screen and you want to read it as a document.
30    #[default]
31    Tree,
32    /// One container at a time, pushed and popped. Best when it doesn't.
33    Pages,
34}
35
36/// Interaction mode: normal navigation, or editing a scalar's text.
37pub enum Mode {
38    Normal,
39    Editing {
40        buffer: String,
41        /// The scalar being edited. Held here rather than re-read from the
42        /// selection on commit, so an edit belongs to a *node* and not to
43        /// whichever list the cursor happens to be in — the two projections
44        /// index differently, and a commit must not care which one opened it.
45        path: Vec<Seg>,
46    },
47}
48
49pub struct Model<B> {
50    backend: B,
51
52    /// Derived view state, rebuilt from `backend.to_value()` after every edit.
53    value: Value,
54    pub rows: Vec<Row>,
55    collapsed: HashSet<Vec<Seg>>,
56    /// Top-level mapping keys to hide from the row projection (but keep in the
57    /// document). Empty for a standalone config; a prov/diaryx embedder passes the
58    /// managed-key set so those fields stay lossless and out of view.
59    hidden: HashSet<String>,
60    /// Top-level mapping keys the *workspace* maintains: shown, but not editable.
61    ///
62    /// The complement of [`hidden`](Self::hidden), for the other kind of managed
63    /// field. A hidden key is edited through some other affordance (a title bar,
64    /// a link view) and would only clutter the list; a derived key — a recomputed
65    /// timestamp, a content hash — has no other affordance because *nothing*
66    /// edits it by hand: the workspace overwrites it on the next write. Hiding
67    /// those two alike leaves a user wondering where a field they can see in the
68    /// file went, so a derived key keeps its row and declines edits instead.
69    derived: HashSet<String>,
70    /// Top-level mapping keys the page projection lists *below* the rest — a
71    /// page's "advanced" section (see [`page::PageItem::demoted`]).
72    ///
73    /// The third answer to "who edits this field?", after `hidden` (something
74    /// else does, and its row would only clutter) and `derived` (nothing does).
75    /// A demoted key is edited here like any other; it is just not what the
76    /// reader came for. Relations, identity, a title the title bar owns: real
77    /// fields, worth showing, worth showing last.
78    ///
79    /// Holds the union with [`derived`](Self::derived), maintained by
80    /// [`set_demoted`](Self::set_demoted) — a key nothing can meaningfully edit
81    /// is the clearest case there is for sinking it below the ones you can.
82    demoted: HashSet<String>,
83    /// The schema governing this document, if any — from the backend
84    /// ([`Backend::schema`]) or injected by the embedder ([`Model::set_schema`]).
85    /// Drives type-directed parsing and commit-time value validation; absent, the
86    /// model behaves exactly as before.
87    schema: Option<Schema>,
88
89    /// The selected row of the **tree** projection — an index into
90    /// [`rows`](Self::rows), and meaningless against a page.
91    ///
92    /// Private, and the one piece of cursor state that is. A row index only says
93    /// what it means in the projection it was read from, and a public field
94    /// cannot check which projection a caller is in — so writing it goes through
95    /// [`select_row`](Self::select_row), which can.
96    selected: usize,
97    pub mode: Mode,
98    /// The last thing that happened worth saying out loud — almost always a
99    /// refusal (`rejected: ...`, `only mapping keys can be renamed`).
100    ///
101    /// Empty until something happens. A frontend draws this in whatever it uses
102    /// for a status line, and an empty string is what lets it draw *nothing*:
103    /// a bar that opens holding a word nobody asked for teaches the reader to
104    /// stop reading it, which is the one thing a refusal channel cannot afford.
105    pub status: String,
106    pub dirty: bool,
107
108    // ── page view ─────────────────────────────────────────────────────────
109    /// Which projection is being navigated. Both are kept live: the model has no
110    /// idea how much width the frontend has, and rebuilding the unused one costs
111    /// a walk of a tree that was just rebuilt anyway.
112    view: ViewMode,
113    /// The container the page view is currently listing. Empty is the root.
114    focus: Vec<Seg>,
115    /// The page at [`focus`](Self::focus).
116    page: Page,
117    /// The root's page. Kept for the "is there anything to navigate at all?"
118    /// question ([`pages_would_degenerate`](Self::pages_would_degenerate)), which
119    /// is about the document rather than about where you are in it.
120    root_page: Page,
121    /// The page one level out from [`focus`](Self::focus) — the list you were
122    /// looking at when you opened the current one.
123    ///
124    /// A two-pane frontend shows this on the left, so the pair of panes is a
125    /// window sliding along the lineage rather than a fixed sidebar: the left is
126    /// always the page the right came out of, at every depth.
127    parent_page: Page,
128    /// The selected item on [`page`](Self::page).
129    page_selected: usize,
130    /// Where the cursor was on each page we have left, so popping back restores
131    /// it rather than dumping you at the top.
132    ///
133    /// Only a fallback: coming back normally re-finds the child you drilled into,
134    /// which survives edits that shift indices. This is what answers when that
135    /// child is *gone* — you opened a key and deleted it — and the cursor would
136    /// otherwise have nothing to return to.
137    page_memory: HashMap<Vec<Seg>, usize>,
138}
139
140impl<B: Backend> Model<B> {
141    /// Build a model over `backend`.
142    pub fn new(backend: B) -> Result<Self> {
143        Self::with_hidden(backend, Vec::new())
144    }
145
146    /// Build a model that hides the given **top-level** mapping keys from the row
147    /// projection while keeping them in the document (see
148    /// [`tree::build_rows`](crate::tree::build_rows)). For an embedder whose
149    /// format reserves some top-level keys (prov/diaryx-managed frontmatter).
150    pub fn with_hidden(backend: B, hidden: Vec<String>) -> Result<Self> {
151        Self::with_managed(backend, hidden, Vec::new())
152    }
153
154    /// Build a model over `backend` distinguishing the two kinds of managed key:
155    /// `hidden` ones produce no row (edited through another affordance), while
156    /// `derived` ones keep their row but decline every edit (the workspace
157    /// maintains them — see [`derived`](Self::derived)).
158    ///
159    /// A key in both is hidden: no row means nothing to mark read-only.
160    pub fn with_managed(backend: B, hidden: Vec<String>, derived: Vec<String>) -> Result<Self> {
161        Self::with_collapsed(backend, hidden, derived, Vec::new())
162    }
163
164    /// Build a model whose containers at `collapsed` arrive **shut**, before the
165    /// first row list is ever built.
166    ///
167    /// A document can have one field nobody reads as a list: an index document's
168    /// `contents` is one row per child — ninety-five of them in a year index,
169    /// ahead of the four fields anyone types by hand. Such a section wants to open
170    /// as a summary, not a wall you scroll past. Toggling it afterwards through
171    /// [`activate`](Self::activate) would work, but that is the *interactive*
172    /// door: it moves the selection and rebuilds the row list once per container.
173    /// Seeding the set here costs neither — the paths are in place before
174    /// `reload`, so the opening frame is already correct.
175    ///
176    /// A path that names a scalar (or nothing at all) is inert rather than an
177    /// error, so a caller can name the keys it *wants* collapsed without first
178    /// checking which of them turned out to be containers.
179    pub fn with_collapsed(
180        backend: B,
181        hidden: Vec<String>,
182        derived: Vec<String>,
183        collapsed: Vec<Vec<Seg>>,
184    ) -> Result<Self> {
185        // The backend supplies the schema when it knows one (a prov backend);
186        // otherwise it stays `None` until an embedder injects one.
187        let schema = backend.schema();
188        let mut model = Model {
189            backend,
190            value: Value::Null,
191            rows: Vec::new(),
192            collapsed: collapsed.into_iter().collect(),
193            hidden: hidden.into_iter().collect(),
194            // Every derived key starts demoted; `set_demoted` adds the
195            // embedder's own to that floor rather than replacing it.
196            demoted: derived.iter().cloned().collect(),
197            derived: derived.into_iter().collect(),
198            schema,
199            selected: 0,
200            mode: Mode::Normal,
201            // Nothing has happened yet, so there is nothing to report. See
202            // `status`.
203            status: String::new(),
204            dirty: false,
205            view: ViewMode::default(),
206            focus: Vec::new(),
207            page: Page::default(),
208            root_page: Page::default(),
209            parent_page: Page::default(),
210            page_selected: 0,
211            page_memory: HashMap::new(),
212        };
213        model.reload()?;
214        Ok(model)
215    }
216
217    /// Name the top-level keys the page projection sinks below the rest.
218    ///
219    /// Out-of-band like [`set_schema`](Self::set_schema), and for the same
220    /// reason: it is presentation the *embedder* knows and the document does
221    /// not. A diaryx host knows `part_of` is drawn by the sidebar and `id` by
222    /// nothing at all; the fig-backed model reading the same frontmatter has no
223    /// way to tell either from a field somebody typed.
224    ///
225    /// Adds to the derived keys already demoted rather than replacing them, so a
226    /// caller names only what the constructor did not. Rebuilds the pages, so
227    /// the next [`page`](Self::page) already reflects it.
228    ///
229    /// Root keys, matched exactly. A path is demoted when its *first* segment is
230    /// one of these, so naming a container demotes everything under it.
231    pub fn set_demoted(&mut self, keys: Vec<String>) {
232        self.demoted.extend(keys);
233        self.rebuild_pages();
234    }
235
236    /// Whether the node at `path` sits under a demoted top-level key — the
237    /// page projection's own [`is_derived`](Self::is_derived).
238    pub fn is_demoted(&self, path: &[Seg]) -> bool {
239        matches!(path.first(), Some(Seg::Key(k)) if self.demoted.contains(k))
240    }
241
242    /// Inject a schema out-of-band — the embedder precedent, mirroring
243    /// [`with_hidden`](Self::with_hidden). For a host whose backend does not
244    /// supply one but that *knows* the governing schema (a diaryx host feeding a
245    /// fig-backed frontmatter block plus its resolved workspace config).
246    pub fn set_schema(&mut self, schema: Schema) {
247        self.schema = Some(schema);
248    }
249
250    /// The schema governing the document, if any.
251    pub fn schema(&self) -> Option<&Schema> {
252        self.schema.as_ref()
253    }
254
255    /// The schema rule governing the node at `path`, if any — for a frontend
256    /// deciding a widget (a picker for an enum field) or presentation.
257    pub fn rule_at(&self, path: &[Seg]) -> Option<&FieldRule> {
258        self.schema.as_ref().and_then(|s| s.rule_for(path))
259    }
260
261    /// The kind of the document root, for a frontend deciding how to add a
262    /// top-level entry: `"map"`, `"seq"`, or `"scalar"`.
263    pub fn root_kind(&self) -> &'static str {
264        match self.value {
265            Value::Map(_) => "map",
266            Value::Seq(_) => "seq",
267            _ => "scalar",
268        }
269    }
270
271    /// How many of the hidden top-level keys are actually present in the document
272    /// — for a "N managed fields" affordance.
273    pub fn hidden_present(&self) -> usize {
274        match &self.value {
275            Value::Map(entries) => entries
276                .iter()
277                .filter(|(k, _)| matches!(k, Value::Str(s) if self.hidden.contains(s)))
278                .count(),
279            _ => 0,
280        }
281    }
282
283    /// Whether the node at `path` sits under a workspace-maintained (derived)
284    /// top-level key — for a frontend rendering it read-only rather than as an
285    /// editable control. Edits to it are declined at the commit funnel regardless.
286    pub fn is_derived(&self, path: &[Seg]) -> bool {
287        matches!(path.first(), Some(Seg::Key(k)) if self.derived.contains(k))
288    }
289
290    /// The schema-declared top-level fields the document does **not** yet carry
291    /// — what an "add field" affordance offers, so a declared field is reachable
292    /// before it exists.
293    ///
294    /// Rows are projected from the *document*
295    /// ([`build_rows`](crate::tree::build_rows)), so a field the schema declares
296    /// but the document omits has no row and is otherwise unreachable: the user
297    /// would have to know the key and type it exactly. This closes that gap —
298    /// it is the schema's half of the row list, and the reason a declared type
299    /// is worth writing down for a field that is empty.
300    ///
301    /// Only a rule addressing exactly one top-level key names an addable field:
302    /// an each-item or subtree rule governs *within* a field rather than naming
303    /// one. Hidden (managed) keys are never offered — the embedder reserves
304    /// those. Order follows the schema's own rule order, so a caller can present
305    /// them as declared.
306    pub fn addable_fields(&self) -> Vec<&FieldRule> {
307        let Some(schema) = &self.schema else {
308            return Vec::new();
309        };
310        // Only a map root can take a top-level key at all.
311        let Value::Map(entries) = &self.value else {
312            return Vec::new();
313        };
314        let present: HashSet<&str> = entries
315            .iter()
316            .filter_map(|(k, _)| match k {
317                Value::Str(s) => Some(s.as_str()),
318                _ => None,
319            })
320            .collect();
321        let mut seen = HashSet::new();
322        schema
323            .rules()
324            .iter()
325            .filter(|rule| {
326                let [SegPat::Key(name)] = rule.at.0.as_slice() else {
327                    return false;
328                };
329                !present.contains(name.as_str())
330                    && !self.hidden.contains(name)
331                    && seen.insert(name.as_str())
332            })
333            .collect()
334    }
335
336    /// The canonical serialized document — what the embedder writes on save.
337    pub fn source_snapshot(&self) -> String {
338        self.backend.source().unwrap_or_default()
339    }
340
341    /// The backend, for backend-specific reads (e.g. a prov backend's body).
342    pub fn backend(&self) -> &B {
343        &self.backend
344    }
345
346    /// The backend, for backend-specific operations that do **not** change the
347    /// metadata tree flower renders (e.g. replacing a prov document's prose
348    /// body). An op that *does* change the metadata leaves the view stale — go
349    /// through the model's own edit methods for those.
350    pub fn backend_mut(&mut self) -> &mut B {
351        &mut self.backend
352    }
353
354    pub fn set_status(&mut self, s: impl Into<String>) {
355        self.status = s.into();
356    }
357
358    /// Clear the dirty flag after the embedder has persisted the source.
359    pub fn mark_saved(&mut self) {
360        self.dirty = false;
361    }
362
363    // ── view derivation ───────────────────────────────────────────────────────
364
365    /// Re-derive `value` + `rows` from the backend's current tree.
366    fn reload(&mut self) -> Result<()> {
367        self.value = self
368            .backend
369            .to_value()
370            .map_err(|e| anyhow::anyhow!("reading value tree: {e}"))?;
371        self.rebuild_rows();
372        self.rebuild_pages();
373        Ok(())
374    }
375
376    fn rebuild_rows(&mut self) {
377        self.rows = tree::build_rows(&self.value, &self.collapsed, &self.hidden);
378        if self.selected >= self.rows.len() {
379            self.selected = self.rows.len().saturating_sub(1);
380        }
381    }
382
383    /// Re-derive the focused page and the root page from `value`.
384    ///
385    /// Runs on every reload, whichever view is active: see
386    /// [`view`](Self::view) for why both projections are kept live.
387    fn rebuild_pages(&mut self) {
388        self.reanchor_focus();
389        self.root_page = page::build_page(&self.value, &[], &self.hidden, &self.demoted);
390        self.page = if self.focus.is_empty() {
391            self.root_page.clone()
392        } else {
393            page::build_page(&self.value, &self.focus, &self.hidden, &self.demoted)
394        };
395        self.parent_page = if self.focus.is_empty() {
396            Page::default()
397        } else {
398            // The pane you came out of is the pane you *actually* came out of.
399            //
400            // One level out is the wrong answer once a row can compress: opening
401            // `exports › journal` skips the `exports` page precisely because it
402            // holds nothing but that one row, and drawing it on the left would
403            // spend half a wide layout on the page the compression existed to
404            // spare you. So walk out past every level a row compressed past, and
405            // stop at the page that actually lists the row that was tapped.
406            let mut parent = &self.focus[..self.focus.len() - 1];
407            while !parent.is_empty() && page::is_compressed_past(&self.value, parent, &self.hidden)
408            {
409                parent = &parent[..parent.len() - 1];
410            }
411            page::build_page(&self.value, parent, &self.hidden, &self.demoted)
412        };
413        if self.page_selected >= self.page.items.len() {
414            self.page_selected = self.page.items.len().saturating_sub(1);
415        }
416    }
417
418    /// Walk `focus` back to the nearest ancestor that is still a container.
419    ///
420    /// The focus is the one piece of page state the document can invalidate from
421    /// underneath: delete the key you are standing inside, or replace it with a
422    /// scalar, and the page has nothing to list. Popping to the nearest surviving
423    /// ancestor is what a settings menu does when a section disappears — you end
424    /// up one level out, rather than on a blank page or back at the root.
425    fn reanchor_focus(&mut self) {
426        while !self.focus.is_empty()
427            && !tree::value_at(&self.value, &self.focus).is_some_and(page::is_container)
428        {
429            self.focus.pop();
430        }
431    }
432
433    fn selected_row(&self) -> Option<&Row> {
434        self.rows.get(self.selected)
435    }
436
437    /// The path of whatever is selected in the **active** view.
438    ///
439    /// The seam that lets one set of edit operations serve both projections: an
440    /// edit is a path plus a value, and which list the user picked that path from
441    /// is not something [`commit`](Self::commit) should have to know.
442    pub fn selected_path(&self) -> Option<Vec<Seg>> {
443        match self.view {
444            ViewMode::Tree => self.selected_row().map(|r| r.path.clone()),
445            ViewMode::Pages => self.page_item().map(|i| i.path.clone()),
446        }
447    }
448
449    /// Re-anchor selection onto `path` after a rebuild, or clamp if it's gone.
450    ///
451    /// Re-anchors *both* projections, because an edit made from either one moves
452    /// the node in both, and the view the user is not currently looking at is the
453    /// one they will switch to expecting their cursor to still be somewhere sane.
454    fn select_path(&mut self, path: &[Seg]) {
455        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
456            self.selected = i;
457        } else if self.selected >= self.rows.len() {
458            self.selected = self.rows.len().saturating_sub(1);
459        }
460        // A path off the current page (an edit by path elsewhere in the document,
461        // or the anchor of a delete that was the page's own container) leaves the
462        // page cursor where it was, clamped by `rebuild_pages`.
463        if let Some(i) = self.page.position_of(path) {
464            self.page_selected = i;
465        }
466    }
467
468    // ── navigation ────────────────────────────────────────────────────────────
469
470    /// The selected row of the tree projection — an index into
471    /// [`rows`](Self::rows).
472    pub fn selected(&self) -> usize {
473        self.selected
474    }
475
476    /// Put the tree cursor on `index`, clamped to the row list.
477    ///
478    /// **Switches to the tree projection first**, and that is the point of the
479    /// method rather than a side effect. A row index is a coordinate in the row
480    /// list a caller last rendered; it names nothing on a page. A host driving
481    /// both surfaces — a metadata pane beside a settings page — would otherwise
482    /// hand a row index to a model still standing in the page projection, where
483    /// the very next [`delete_selected`](Self::delete_selected) reads the *page*
484    /// cursor and quietly removes a different node.
485    ///
486    /// So the vocabularies assert. Every method that establishes a cursor names
487    /// the projection its coordinates belong to ([`page_enter`](Self::page_enter)
488    /// and the rest do the same for pages), and the methods that merely *read* a
489    /// cursor stay neutral — a delete deletes what is selected, in whichever view
490    /// the user is actually looking at.
491    ///
492    /// A no-op when the model is already in the tree.
493    pub fn select_row(&mut self, index: usize) {
494        self.set_view(ViewMode::Tree);
495        self.selected = if self.rows.is_empty() {
496            0
497        } else {
498            index.min(self.rows.len() - 1)
499        };
500    }
501
502    pub fn move_down(&mut self) {
503        // Tree vocabulary: assert the projection these coordinates belong to.
504        self.set_view(ViewMode::Tree);
505        if self.selected + 1 < self.rows.len() {
506            self.selected += 1;
507        }
508    }
509
510    pub fn move_up(&mut self) {
511        // Tree vocabulary: assert the projection these coordinates belong to.
512        self.set_view(ViewMode::Tree);
513        self.selected = self.selected.saturating_sub(1);
514    }
515
516    /// `l`: expand a collapsed container, else step into its first child.
517    pub fn expand_or_enter(&mut self) {
518        // Tree vocabulary: assert the projection these coordinates belong to.
519        self.set_view(ViewMode::Tree);
520        let Some(row) = self.selected_row() else {
521            return;
522        };
523        if row.is_container() {
524            if !row.expanded {
525                let path = row.path.clone();
526                self.collapsed.remove(&path);
527                self.rebuild_rows();
528                self.select_path(&path);
529            } else if self.selected + 1 < self.rows.len()
530                && self.rows[self.selected + 1].depth > row.depth
531            {
532                self.selected += 1;
533            }
534        }
535    }
536
537    /// `h`: collapse an expanded container, else step out to the parent row.
538    pub fn collapse_or_leave(&mut self) {
539        // Tree vocabulary: assert the projection these coordinates belong to.
540        self.set_view(ViewMode::Tree);
541        let Some(row) = self.selected_row() else {
542            return;
543        };
544        if row.is_container() && row.expanded {
545            let path = row.path.clone();
546            self.collapsed.insert(path.clone());
547            self.rebuild_rows();
548            self.select_path(&path);
549            return;
550        }
551        // Step out: the nearest earlier row at a shallower depth is the parent.
552        let depth = row.depth;
553        if depth == 0 {
554            return;
555        }
556        for i in (0..self.selected).rev() {
557            if self.rows[i].depth < depth {
558                self.selected = i;
559                return;
560            }
561        }
562    }
563
564    // ── page view ─────────────────────────────────────────────────────────
565
566    /// Which projection is active.
567    pub fn view(&self) -> ViewMode {
568        self.view
569    }
570
571    /// Switch projection, carrying the cursor across so the node you were on in
572    /// one view is the node you are on in the other.
573    ///
574    /// Without that, switching would be a jump cut: you fold down to one key in
575    /// the tree, switch to pages, and land at the top of the root page with no
576    /// idea where your key went. Carrying the selection makes the two views two
577    /// ways of looking at one position, which is the only reading under which
578    /// having both is worth it.
579    pub fn set_view(&mut self, view: ViewMode) {
580        if view == self.view {
581            return;
582        }
583        let was = self.selected_path();
584        self.view = view;
585        if let Some(path) = was {
586            match view {
587                ViewMode::Pages => self.focus_on(&path),
588                // The tree may have the node folded away inside a shut ancestor;
589                // open the lineage so there is a row to land on.
590                ViewMode::Tree => {
591                    for i in 0..path.len() {
592                        self.collapsed.remove(&path[..i]);
593                    }
594                    self.rebuild_rows();
595                    self.select_path(&path);
596                }
597            }
598        }
599    }
600
601    /// Toggle between the tree and the page view.
602    pub fn toggle_view(&mut self) {
603        self.set_view(match self.view {
604            ViewMode::Tree => ViewMode::Pages,
605            ViewMode::Pages => ViewMode::Tree,
606        });
607    }
608
609    /// The page currently being listed.
610    pub fn page(&self) -> &Page {
611        &self.page
612    }
613
614    /// The root's page.
615    pub fn root_page(&self) -> &Page {
616        &self.root_page
617    }
618
619    /// The page one level out — what a two-pane frontend draws on the left. Empty
620    /// when [`focus`](Self::focus) is the root, which has no parent.
621    pub fn parent_page(&self) -> &Page {
622        &self.parent_page
623    }
624
625    /// The container the page view is listing. Empty is the document root.
626    pub fn focus(&self) -> &[Seg] {
627        &self.focus
628    }
629
630    /// The index of the selected item on [`page`](Self::page).
631    pub fn page_selected(&self) -> usize {
632        self.page_selected
633    }
634
635    /// The selected page item, if the page has any.
636    pub fn page_item(&self) -> Option<&PageItem> {
637        self.page.items.get(self.page_selected)
638    }
639
640    /// Whether a two-pane layout would waste one pane on this document.
641    ///
642    /// A document whose root has nothing to drill into — a flat list of keys, a
643    /// sequence of scalars — has no navigation to put in a sidebar, and splitting
644    /// the width for it would cost half the room and buy nothing. A frontend
645    /// checks this to fall back to a single full-width pane.
646    pub fn pages_would_degenerate(&self) -> bool {
647        !self.root_page.has_drills()
648    }
649
650    /// Point the page view at whichever page *lists* `path`, with the cursor on
651    /// it — the by-path counterpart to drilling, and how a view switch carries
652    /// the selection across.
653    ///
654    /// It searches from the root outward rather than from `path` inward, because
655    /// more than one page can contain a node and the outermost is the right one:
656    /// an inlined group's member is listed on the grandparent's page (that is what
657    /// inlining means), and also on the group's own page, which is a place page
658    /// navigation would never have left you. A path that doesn't resolve is inert.
659    pub fn focus_on(&mut self, path: &[Seg]) {
660        // Page vocabulary, like the rest. Re-entrant from `set_view`, which calls
661        // this to carry the cursor across — but by then `view` is already
662        // `Pages`, so the call below returns immediately rather than recursing.
663        self.set_view(ViewMode::Pages);
664        if tree::value_at(&self.value, path).is_none() {
665            return;
666        }
667        let mut focus: Vec<Seg> = Vec::new();
668        while focus.len() < path.len()
669            && page::build_page(&self.value, &focus, &self.hidden, &self.demoted)
670                .position_of(path)
671                .is_none()
672        {
673            focus.push(path[focus.len()].clone());
674        }
675        self.focus = focus;
676        self.rebuild_pages();
677        self.page_selected = self.page.position_of(path).unwrap_or(0);
678    }
679
680    /// The page listing the container at `path`, without going there.
681    ///
682    /// [`page`](Self::page) is where the user *is*; this is any other level, built
683    /// on demand and thrown away. A frontend whose navigation is a stack needs it:
684    /// the OS asks "what is the screen for this path element?" for levels the
685    /// model is not focused on, and answering by moving the focus would make
686    /// rendering a screen a navigation.
687    ///
688    /// Total, like [`build_page`](crate::page::build_page): a path that doesn't
689    /// resolve, or that names a scalar, yields an empty page.
690    pub fn page_at(&self, path: &[Seg]) -> Page {
691        page::build_page(&self.value, path, &self.hidden, &self.demoted)
692    }
693
694    /// The page the selected item *would* open.
695    ///
696    /// A two-pane frontend showing the root's categories on the left has nothing
697    /// to put on the right until you have drilled into something — and an empty
698    /// half-screen is a poor advertisement for splitting the width. Previewing
699    /// the selected category's page fills it with the thing you are about to open
700    /// anyway, which is what a settings sidebar does. `None` for a scalar, which
701    /// has no page.
702    pub fn peek_page(&self) -> Option<Page> {
703        let item = self.page_item()?;
704        if !item.is_drill() {
705            return None;
706        }
707        // The page it would *open*, which for a compressed row is the far end of
708        // the chain — previewing the single-row page in between would put the
709        // pane's whole purpose (showing what you are about to open) to work
710        // showing the name you are pointing at.
711        Some(self.page_at(&item.descend_to))
712    }
713
714    /// `j` in the page view.
715    pub fn page_move_down(&mut self) {
716        // Page vocabulary: assert the projection this cursor belongs to.
717        self.set_view(ViewMode::Pages);
718        if self.page_selected + 1 < self.page.items.len() {
719            self.page_selected += 1;
720        }
721    }
722
723    /// `k` in the page view.
724    pub fn page_move_up(&mut self) {
725        // Page vocabulary: assert the projection this cursor belongs to.
726        self.set_view(ViewMode::Pages);
727        self.page_selected = self.page_selected.saturating_sub(1);
728    }
729
730    /// `l`/`Enter` in the page view: open the selected container as a page, or
731    /// begin editing the selected scalar.
732    ///
733    /// A group header opens too. Its members are already on screen, so opening it
734    /// shows nothing new — but it is the door to operating on the group as a
735    /// container (append, insert, reorder) rather than on the members, and a
736    /// container that is visible but cannot be entered is a worse surprise than a
737    /// page that repeats what you could already see.
738    pub fn page_enter(&mut self) {
739        // Page vocabulary: assert the projection this cursor belongs to.
740        self.set_view(ViewMode::Pages);
741        let Some(item) = self.page_item() else {
742            return;
743        };
744        if item.is_scalar() {
745            self.begin_edit();
746            return;
747        }
748        // A group header opens nothing (see `PageItem::is_drill`), so `l` on one
749        // does the next most useful thing and steps onto its first member — the
750        // same "into its children" this key means everywhere else.
751        if !item.is_drill() {
752            if let Some(first) = self.page.items[self.page_selected + 1..]
753                .iter()
754                .position(|i| i.inset > 0)
755            {
756                self.page_selected += 1 + first;
757            }
758            return;
759        }
760        // `descend_to`, not `path`: a compressed row names a chain of containers
761        // that hold only each other, and opening it lands on the far end — the
762        // first page with more on it than the name you just tapped. They are the
763        // same path for every other row.
764        let target = item.descend_to.clone();
765        self.page_memory
766            .insert(self.focus.clone(), self.page_selected);
767        self.focus = target;
768        self.page_selected = 0;
769        self.rebuild_pages();
770    }
771
772    /// `h`/`Esc` in the page view: pop back to the page that *listed* the row you
773    /// opened, restoring the cursor to it.
774    ///
775    /// One level out is the wrong answer once a row can compress, for the same
776    /// reason it is the wrong left pane
777    /// ([`rebuild_pages`](Self::rebuild_pages)): opening `exports › journal`
778    /// deliberately skips the `exports` page because it holds nothing but that
779    /// one row, and handing it back on the way out makes leaving cost two steps
780    /// where arriving cost one — on a page whose only row is the name of the
781    /// place you just left. So this walks out past every level a row compressed
782    /// past, and lands where the row was tapped.
783    ///
784    /// Nothing becomes unreachable by it. A compressed row's
785    /// [`path`](PageItem::path) is the outermost container, so renaming,
786    /// deleting, reordering and adding to `exports` are all still that row's ops
787    /// on the page this lands on — the skipped page never held anything else.
788    pub fn page_back(&mut self) {
789        // Page vocabulary: assert the projection this cursor belongs to.
790        self.set_view(ViewMode::Pages);
791        if self.focus.is_empty() {
792            self.status = "already at the top".to_string();
793            return;
794        }
795        let child = std::mem::take(&mut self.focus);
796        let mut parent = &child[..child.len() - 1];
797        while !parent.is_empty() && page::is_compressed_past(&self.value, parent, &self.hidden) {
798            parent = &parent[..parent.len() - 1];
799        }
800        self.focus = parent.to_vec();
801        self.rebuild_pages();
802        // Prefer re-finding the child: an index it holds is correct after edits
803        // that shifted the page, which a remembered index would not be. The
804        // memory answers only when the child is gone — see `page_memory`.
805        self.page_selected = self
806            .page
807            .position_of(&child)
808            .or_else(|| {
809                self.page_memory
810                    .get(&self.focus)
811                    .copied()
812                    .filter(|i| *i < self.page.items.len())
813            })
814            .unwrap_or(0);
815    }
816
817    /// Whether the container at `path` is collapsed. Answers for a node with no
818    /// row too (one nested inside another collapsed container), which
819    /// [`Row::expanded`](crate::Row) cannot.
820    pub fn is_collapsed(&self, path: &[Seg]) -> bool {
821        self.collapsed.contains(path)
822    }
823
824    /// Collapse or expand the container at `path`, leaving the selection where the
825    /// user put it — the by-path, non-interactive counterpart to
826    /// [`activate`](Self::activate).
827    ///
828    /// `activate` folds *the selected row*, so driving it from a path means moving
829    /// the selection first and putting it back after. This doesn't: it re-anchors
830    /// onto whatever was selected before, and only falls back to `path` itself when
831    /// the selection was a descendant that the fold just took off screen.
832    ///
833    /// A path naming a scalar (or nothing) is inert — see
834    /// [`with_collapsed`](Self::with_collapsed).
835    pub fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
836        let changed = if collapsed {
837            self.collapsed.insert(path.to_vec())
838        } else {
839            self.collapsed.remove(path)
840        };
841        if !changed {
842            return;
843        }
844        let was = self.selected_row().map(|r| r.path.clone());
845        self.rebuild_rows();
846        if let Some(was) = was {
847            // A row swallowed by the fold has no path to return to; its nearest
848            // surviving ancestor is the container the user just shut.
849            if collapsed && was.len() > path.len() && was.starts_with(path) {
850                self.select_path(path);
851            } else {
852                self.select_path(&was);
853            }
854        }
855    }
856
857    /// `Enter`/`Space`: toggle a container's expansion, or edit a scalar.
858    pub fn activate(&mut self) {
859        let Some(row) = self.selected_row() else {
860            return;
861        };
862        if row.is_container() {
863            let path = row.path.clone();
864            if row.expanded {
865                self.collapsed.insert(path.clone());
866            } else {
867                self.collapsed.remove(&path);
868            }
869            self.rebuild_rows();
870            self.select_path(&path);
871        } else {
872            self.begin_edit();
873        }
874    }
875
876    // ── editing ───────────────────────────────────────────────────────────────
877
878    pub fn begin_edit(&mut self) {
879        let Some(path) = self.selected_path() else {
880            return;
881        };
882        let Some(value) = self.value_at(&path) else {
883            return;
884        };
885        if page::is_container(value) {
886            self.status = "can only edit scalar values".to_string();
887            return;
888        }
889        let seed = tree::edit_seed(value);
890        self.mode = Mode::Editing { buffer: seed, path };
891    }
892
893    pub fn edit_push(&mut self, c: char) {
894        if let Mode::Editing { buffer, .. } = &mut self.mode {
895            buffer.push(c);
896        }
897    }
898
899    pub fn edit_backspace(&mut self) {
900        if let Mode::Editing { buffer, .. } = &mut self.mode {
901            buffer.pop();
902        }
903    }
904
905    pub fn edit_cancel(&mut self) {
906        self.mode = Mode::Normal;
907        self.status = "edit cancelled".to_string();
908    }
909
910    pub fn edit_commit(&mut self) {
911        let Mode::Editing { buffer, path } = &mut self.mode else {
912            return;
913        };
914        let buffer = std::mem::take(buffer);
915        let path = std::mem::take(path);
916        self.mode = Mode::Normal;
917
918        let value = self.coerce_text(&path, &buffer);
919        self.commit(
920            EditOp::ReplaceValue {
921                path: path.clone(),
922                value,
923            },
924            path,
925            "value updated",
926        );
927    }
928
929    /// Programmatically replace the value at `path` (any depth), refreshing the
930    /// view. The non-interactive counterpart to [`edit_commit`](Self::edit_commit)
931    /// — for an embedder or FFI that edits by path rather than through the
932    /// selection.
933    pub fn set_value_at(&mut self, path: &[Seg], value: Value) {
934        self.commit(
935            EditOp::ReplaceValue {
936                path: path.to_vec(),
937                value,
938            },
939            path.to_vec(),
940            "value updated",
941        );
942    }
943
944    /// Set the scalar at `path` from an edit-buffer `text`, coercing by the
945    /// schema's expected type when known (a `str` field keeps `"123"` a string)
946    /// and otherwise guessing by literal shape — the by-path, schema-aware analog
947    /// of [`edit_commit`](Self::edit_commit). Validation (closed-vocabulary
948    /// rejection) still happens at the commit funnel.
949    pub fn set_scalar_text(&mut self, path: &[Seg], text: &str) {
950        let value = self.coerce_text(path, text);
951        self.set_value_at(path, value);
952    }
953
954    /// Turn edit-buffer `text` into the value that belongs at `path`: the type the
955    /// schema declares for that path when it declares one, and otherwise a guess
956    /// from the literal's shape.
957    ///
958    /// The single rule behind [`edit_commit`](Self::edit_commit),
959    /// [`set_scalar_text`](Self::set_scalar_text),
960    /// [`insert_key_text`](Self::insert_key_text) and
961    /// [`append_item_text`](Self::append_item_text). It is keyed on the path of the
962    /// value being *written*, not of its container — that is what lets an
963    /// each-item rule type a list's items independently of the list.
964    fn coerce_text(&self, path: &[Seg], text: &str) -> Value {
965        match self.rule_at(path).and_then(|r| r.ty) {
966            Some(ty) => ty.coerce(text),
967            None => tree::parse_scalar(text),
968        }
969    }
970
971    /// Rename the mapping entry at `path` to `new_key`, keeping its value and
972    /// re-anchoring the selection onto the renamed entry. A no-op (with a status
973    /// hint) when `path` doesn't end in a key — a sequence item has no key. The
974    /// backend rejects a name that collides with an existing sibling key.
975    pub fn rename_key(&mut self, path: &[Seg], new_key: &str) {
976        match path.last() {
977            Some(Seg::Key(_)) => {
978                let mut anchor = path[..path.len() - 1].to_vec();
979                anchor.push(Seg::Key(new_key.to_string()));
980                self.commit(
981                    EditOp::RenameKey {
982                        path: path.to_vec(),
983                        new_key: new_key.to_string(),
984                    },
985                    anchor,
986                    "renamed",
987                );
988            }
989            _ => self.status = "only mapping keys can be renamed".to_string(),
990        }
991    }
992
993    /// Insert `key = value` into the mapping at `map_path`, selecting the new
994    /// entry. A frontend offers this on a map container; the backend rejects a
995    /// duplicate key or a non-mapping target, leaving the document untouched.
996    pub fn insert_key(&mut self, map_path: &[Seg], key: &str, value: Value) {
997        let mut anchor = map_path.to_vec();
998        anchor.push(Seg::Key(key.to_string()));
999        self.commit(
1000            EditOp::InsertKey {
1001                map_path: map_path.to_vec(),
1002                key: key.to_string(),
1003                value,
1004            },
1005            anchor,
1006            "inserted",
1007        );
1008    }
1009
1010    /// Insert `key = text` into the mapping at `map_path`, coercing `text` by the
1011    /// type the schema declares for the new entry and otherwise guessing by literal
1012    /// shape — the insert-shaped analog of
1013    /// [`set_scalar_text`](Self::set_scalar_text).
1014    ///
1015    /// Prefer this to [`insert_key`](Self::insert_key) whenever the value comes
1016    /// from a user's text: a caller that shape-guesses on its own writes `2026` as
1017    /// an integer into a field the schema declares `str`, and gets no say from the
1018    /// schema it is otherwise honoring everywhere else.
1019    pub fn insert_key_text(&mut self, map_path: &[Seg], key: &str, text: &str) {
1020        let mut target = map_path.to_vec();
1021        target.push(Seg::Key(key.to_string()));
1022        let value = self.coerce_text(&target, text);
1023        self.insert_key(map_path, key, value);
1024    }
1025
1026    /// Append `value` to the sequence at `seq_path`, selecting the new item.
1027    pub fn append_item(&mut self, seq_path: &[Seg], value: Value) {
1028        let idx = self.seq_len(seq_path);
1029        let mut anchor = seq_path.to_vec();
1030        anchor.push(Seg::Index(idx));
1031        self.commit(
1032            EditOp::AppendItem {
1033                seq_path: seq_path.to_vec(),
1034                value,
1035            },
1036            anchor,
1037            "appended",
1038        );
1039    }
1040
1041    /// Append `text` to the sequence at `seq_path`, coercing it by the type the
1042    /// schema declares for the sequence's *items* and otherwise guessing by literal
1043    /// shape — the append-shaped analog of
1044    /// [`set_scalar_text`](Self::set_scalar_text).
1045    ///
1046    /// The item's type comes from the rule matching the item path (an each-item or
1047    /// subtree rule), not from the rule on the list itself: `tags` is a `seq`, its
1048    /// items are `str`.
1049    pub fn append_item_text(&mut self, seq_path: &[Seg], text: &str) {
1050        let mut target = seq_path.to_vec();
1051        target.push(Seg::Index(self.seq_len(seq_path)));
1052        let value = self.coerce_text(&target, text);
1053        self.append_item(seq_path, value);
1054    }
1055
1056    /// Move the selected row one place earlier among its siblings — a sequence
1057    /// item via fig's array-move, a mapping entry via a one-swap reorder.
1058    pub fn move_selected_up(&mut self) {
1059        self.reorder_selected(-1);
1060    }
1061
1062    /// Move the selected row one place later among its siblings.
1063    pub fn move_selected_down(&mut self) {
1064        self.reorder_selected(1);
1065    }
1066
1067    /// The shared body of [`move_selected_up`](Self::move_selected_up) /
1068    /// [`move_selected_down`](Self::move_selected_down): shift the selected row by
1069    /// `delta` positions within its parent container.
1070    fn reorder_selected(&mut self, delta: isize) {
1071        let Some(path) = self.selected_path() else {
1072            return;
1073        };
1074        let Some(last) = path.last().cloned() else {
1075            self.status = "cannot move the document root".to_string();
1076            return;
1077        };
1078        let parent = path[..path.len() - 1].to_vec();
1079        match last {
1080            Seg::Index(i) => {
1081                let len = self.seq_len(&parent);
1082                let to = i as isize + delta;
1083                if to < 0 || to as usize >= len {
1084                    self.status = "already at the edge".to_string();
1085                    return;
1086                }
1087                let to = to as usize;
1088                let mut anchor = parent.clone();
1089                anchor.push(Seg::Index(to));
1090                self.commit(
1091                    EditOp::MoveItem {
1092                        seq_path: parent,
1093                        from: i,
1094                        to,
1095                    },
1096                    anchor,
1097                    "moved",
1098                );
1099            }
1100            Seg::Key(k) => {
1101                let keys = self.map_keys(&parent);
1102                let Some(pos) = keys.iter().position(|x| *x == k) else {
1103                    return;
1104                };
1105                let target = pos as isize + delta;
1106                if target < 0 || target as usize >= keys.len() {
1107                    self.status = "already at the edge".to_string();
1108                    return;
1109                }
1110                let mut order = keys;
1111                order.swap(pos, target as usize);
1112                self.commit(
1113                    EditOp::ReorderKeys {
1114                        map_path: parent,
1115                        keys: order,
1116                    },
1117                    path,
1118                    "moved",
1119                );
1120            }
1121        }
1122    }
1123
1124    /// The value the document currently holds at `path` (the whole tree for the
1125    /// empty path), or `None` when the path doesn't resolve — for a frontend
1126    /// reading a row's value without reaching for the backend.
1127    pub fn value_at(&self, path: &[Seg]) -> Option<&Value> {
1128        tree::value_at(&self.value, path)
1129    }
1130
1131    /// The mapping keys at `path`, in document order (empty for a non-mapping).
1132    fn map_keys(&self, path: &[Seg]) -> Vec<String> {
1133        tree::map_keys(&self.value, path).unwrap_or_default()
1134    }
1135
1136    /// The length of the sequence at `path` (0 for a non-sequence) — the index an
1137    /// append will land at.
1138    pub fn seq_len(&self, path: &[Seg]) -> usize {
1139        tree::seq_len(&self.value, path).unwrap_or(0)
1140    }
1141
1142    /// `x`: delete the selected mapping entry or sequence item.
1143    pub fn delete_selected(&mut self) {
1144        let Some(path) = self.selected_path() else {
1145            return;
1146        };
1147        let (op, anchor) = match path.last() {
1148            Some(Seg::Index(i)) => {
1149                let seq_path = path[..path.len() - 1].to_vec();
1150                (
1151                    EditOp::RemoveItem {
1152                        seq_path: seq_path.clone(),
1153                        index: *i,
1154                    },
1155                    seq_path,
1156                )
1157            }
1158            Some(Seg::Key(_)) => (
1159                EditOp::DeleteKey { path: path.clone() },
1160                path[..path.len() - 1].to_vec(),
1161            ),
1162            None => {
1163                self.status = "cannot delete the document root".to_string();
1164                return;
1165            }
1166        };
1167        self.commit(op, anchor, "deleted");
1168    }
1169
1170    /// Apply one edit through the backend, then refresh the view (or report the
1171    /// rollback). The single path every mutation funnels through — and the choke
1172    /// point where the schema validates values: a closed vocabulary rejects an
1173    /// unknown value here, before it reaches the backend; an open one applies but
1174    /// surfaces a soft warning. fig's reparse stays the last-resort backstop.
1175    fn commit(&mut self, op: EditOp, anchor: Vec<Seg>, msg: &str) {
1176        // A workspace-maintained field declines every mutation, not just a value
1177        // edit: renaming or deleting one would be undone on the next write just
1178        // as surely as retyping it.
1179        if let Some(key) = op_root_key(&op)
1180            && self.derived.contains(key)
1181        {
1182            self.status = format!("rejected: `{key}` is maintained by the workspace");
1183            return;
1184        }
1185        let mut warn: Option<Issue> = None;
1186        if let Some((path, value)) = op_target(&op)
1187            && let Some(rule) = self.rule_at(&path)
1188        {
1189            match rule.validate(value) {
1190                Validation::Reject(why) => {
1191                    self.status = format!("rejected: {why}");
1192                    return;
1193                }
1194                Validation::Warn(why) => warn = Some(why),
1195                Validation::Ok => {}
1196            }
1197        }
1198        match self.backend.apply(op) {
1199            Ok(()) => {
1200                self.after_edit(&anchor, msg);
1201                // A soft-warn overrides the success status so the user sees it.
1202                if let Some(why) = warn {
1203                    self.status = why.to_string();
1204                }
1205            }
1206            // The backend rolled back / declined; the document is untouched.
1207            Err(e) => self.status = format!("rejected: {e}"),
1208        }
1209    }
1210
1211    /// Shared tail of a successful mutation: refresh the view, re-anchor
1212    /// selection, mark dirty, set the status line.
1213    fn after_edit(&mut self, anchor: &[Seg], msg: &str) {
1214        if let Err(e) = self.reload() {
1215            self.status = format!("view refresh failed: {e}");
1216            return;
1217        }
1218        self.select_path(anchor);
1219        self.dirty = true;
1220        self.status = msg.to_string();
1221    }
1222}
1223
1224/// The (target path, value) a value-bearing [`EditOp`] writes — what schema
1225/// validation checks. An append's item index isn't known here, so a placeholder
1226/// `Index(0)` stands in; it only serves to match an `EachItem` rule pattern, which
1227/// is index-agnostic. Structural ops (delete, move, reorder, rename) carry no new
1228/// value and return `None`.
1229/// The top-level mapping key an op would change, if any — the unit at which a
1230/// document's managed fields are declared, so an edit anywhere beneath one
1231/// (an item of a managed list, a nested key) is caught along with the field
1232/// itself.
1233fn op_root_key(op: &EditOp) -> Option<&str> {
1234    fn first_key(path: &[Seg]) -> Option<&str> {
1235        match path.first() {
1236            Some(Seg::Key(k)) => Some(k.as_str()),
1237            _ => None,
1238        }
1239    }
1240    match op {
1241        EditOp::ReplaceValue { path, .. }
1242        | EditOp::DeleteKey { path }
1243        | EditOp::RenameKey { path, .. } => first_key(path),
1244        EditOp::RemoveItem { seq_path, .. }
1245        | EditOp::AppendItem { seq_path, .. }
1246        | EditOp::MoveItem { seq_path, .. } => first_key(seq_path),
1247        // An insert *at the root* names the new top-level key itself; deeper, the
1248        // container it lands in is what matters.
1249        EditOp::InsertKey { map_path, key, .. } => match map_path.first() {
1250            None => Some(key.as_str()),
1251            _ => first_key(map_path),
1252        },
1253        // Reordering the root's own keys moves no field's value.
1254        EditOp::ReorderKeys { map_path, .. } => first_key(map_path),
1255    }
1256}
1257
1258fn op_target(op: &EditOp) -> Option<(Vec<Seg>, &Value)> {
1259    match op {
1260        EditOp::ReplaceValue { path, value } => Some((path.clone(), value)),
1261        EditOp::InsertKey {
1262            map_path,
1263            key,
1264            value,
1265        } => {
1266            let mut p = map_path.clone();
1267            p.push(Seg::Key(key.clone()));
1268            Some((p, value))
1269        }
1270        EditOp::AppendItem { seq_path, value } => {
1271            let mut p = seq_path.clone();
1272            p.push(Seg::Index(0));
1273            Some((p, value))
1274        }
1275        _ => None,
1276    }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use crate::backend::FigBackend;
1283    use fig::Format;
1284
1285    const SAMPLE: &str = "\
1286# flower sample config — comments and formatting below should survive edits
1287title = \"flower\"
1288version = 1
1289enabled = true
1290
1291# the server block
1292[server]
1293host = \"localhost\"
1294port = 8080
1295tags = [\"alpha\", \"beta\"]
1296
1297[server.limits]
1298max_connections = 100
1299timeout = 30.5
1300";
1301
1302    fn sample_model() -> Model<FigBackend> {
1303        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open backend");
1304        Model::new(backend).expect("build model")
1305    }
1306
1307    fn select(model: &mut Model<FigBackend>, path: &[Seg]) {
1308        model.selected = model
1309            .rows
1310            .iter()
1311            .position(|r| r.path == path)
1312            .unwrap_or_else(|| panic!("no row for {path:?}"));
1313    }
1314
1315    fn type_value(model: &mut Model<FigBackend>, text: &str) {
1316        if let Mode::Editing { buffer, .. } = &mut model.mode {
1317            buffer.clear();
1318        }
1319        for c in text.chars() {
1320            model.edit_push(c);
1321        }
1322        model.edit_commit();
1323    }
1324
1325    #[test]
1326    fn a_fresh_model_has_nothing_to_report() {
1327        // The status line carries refusals. A model that has refused nothing
1328        // has nothing for it, and a frontend reads the empty string as "draw no
1329        // bar" rather than having to know which openings words are noise.
1330        assert!(
1331            sample_model().status.is_empty(),
1332            "status: {}",
1333            sample_model().status
1334        );
1335    }
1336
1337    #[test]
1338    fn edits_a_scalar_losslessly() {
1339        let mut model = sample_model();
1340
1341        select(&mut model, &[Seg::Key("version".into())]);
1342        model.begin_edit();
1343        type_value(&mut model, "2");
1344
1345        let src = model.source_snapshot();
1346        assert!(src.contains("version = 2"), "value changed:\n{src}");
1347        assert!(
1348            src.contains("# the server block"),
1349            "comment preserved:\n{src}"
1350        );
1351        assert!(
1352            src.contains("# flower sample config"),
1353            "header preserved:\n{src}"
1354        );
1355        assert!(model.dirty);
1356    }
1357
1358    #[test]
1359    fn edits_a_nested_string() {
1360        let mut model = sample_model();
1361
1362        select(
1363            &mut model,
1364            &[Seg::Key("server".into()), Seg::Key("host".into())],
1365        );
1366        model.begin_edit();
1367        type_value(&mut model, "example.com");
1368
1369        let src = model.source_snapshot();
1370        assert!(
1371            src.contains("host = \"example.com\""),
1372            "nested edit:\n{src}"
1373        );
1374        assert!(src.contains("port = 8080"), "sibling untouched:\n{src}");
1375    }
1376
1377    #[test]
1378    fn deletes_a_key() {
1379        let mut model = sample_model();
1380
1381        select(&mut model, &[Seg::Key("enabled".into())]);
1382        model.delete_selected();
1383
1384        let src = model.source_snapshot();
1385        assert!(!src.contains("enabled = true"), "key removed:\n{src}");
1386        assert!(src.contains("title = \"flower\""), "siblings kept:\n{src}");
1387    }
1388
1389    #[test]
1390    fn appends_a_sequence_item() {
1391        let mut model = sample_model();
1392        let tags = vec![Seg::Key("server".into()), Seg::Key("tags".into())];
1393        model.append_item(&tags, Value::Str("gamma".into()));
1394
1395        let src = model.source_snapshot();
1396        assert!(src.contains("gamma"), "item appended:\n{src}");
1397        assert!(
1398            src.contains("alpha") && src.contains("beta"),
1399            "siblings kept"
1400        );
1401        assert!(model.dirty);
1402    }
1403
1404    #[test]
1405    fn inserts_a_mapping_key() {
1406        let mut model = sample_model();
1407        let server = vec![Seg::Key("server".into())];
1408        model.insert_key(&server, "scheme", Value::Str("https".into()));
1409
1410        let src = model.source_snapshot();
1411        // fig may quote the inserted key (`"scheme" = …`); both are valid TOML.
1412        assert!(
1413            src.contains("scheme") && src.contains("= \"https\""),
1414            "key inserted:\n{src}"
1415        );
1416        assert!(src.contains("host = \"localhost\""), "siblings kept");
1417    }
1418
1419    #[test]
1420    fn moves_a_sequence_item_and_reorders_keys() {
1421        let mut model = sample_model();
1422
1423        // Move the second tag ("beta", index 1) up to index 0.
1424        select(
1425            &mut model,
1426            &[
1427                Seg::Key("server".into()),
1428                Seg::Key("tags".into()),
1429                Seg::Index(1),
1430            ],
1431        );
1432        model.move_selected_up();
1433        let src = model.source_snapshot();
1434        let a = src.find("alpha").unwrap();
1435        let b = src.find("beta").unwrap();
1436        assert!(b < a, "beta now precedes alpha:\n{src}");
1437
1438        // Move a top-level mapping entry down: title should follow version.
1439        select(&mut model, &[Seg::Key("title".into())]);
1440        model.move_selected_down();
1441        let src = model.source_snapshot();
1442        assert!(
1443            src.find("version").unwrap() < src.find("title").unwrap(),
1444            "version now precedes title:\n{src}"
1445        );
1446    }
1447
1448    #[test]
1449    fn hidden_top_level_keys_are_projected_out_but_kept_lossless() {
1450        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
1451        let mut model =
1452            Model::with_hidden(backend, vec!["title".into(), "enabled".into()]).expect("model");
1453
1454        // Hidden keys produce no rows…
1455        assert!(
1456            !model
1457                .rows
1458                .iter()
1459                .any(|r| r.path == [Seg::Key("title".into())])
1460        );
1461        assert!(
1462            !model
1463                .rows
1464                .iter()
1465                .any(|r| r.path == [Seg::Key("enabled".into())])
1466        );
1467        // …but a visible sibling is still there,
1468        assert!(
1469            model
1470                .rows
1471                .iter()
1472                .any(|r| r.path == [Seg::Key("version".into())])
1473        );
1474        // …and the hidden keys remain in the document bytes.
1475        assert!(model.source_snapshot().contains("title = \"flower\""));
1476        assert!(model.source_snapshot().contains("enabled = true"));
1477
1478        // Editing a visible key doesn't disturb the hidden ones.
1479        select(&mut model, &[Seg::Key("version".into())]);
1480        model.begin_edit();
1481        type_value(&mut model, "9");
1482        let src = model.source_snapshot();
1483        assert!(src.contains("version = 9"));
1484        assert!(src.contains("title = \"flower\"") && src.contains("enabled = true"));
1485    }
1486
1487    #[test]
1488    fn reorder_leaves_hidden_keys_in_place() {
1489        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
1490        let mut model = Model::with_hidden(backend, vec!["title".into()]).expect("model");
1491
1492        // Move a visible top-level key; the hidden `title` must keep its position.
1493        select(&mut model, &[Seg::Key("enabled".into())]);
1494        model.move_selected_up(); // enabled moves above version
1495        let src = model.source_snapshot();
1496        // title stays first (it was declared before version/enabled).
1497        let title = src.find("title").unwrap();
1498        let version = src.find("version").unwrap();
1499        let enabled = src.find("enabled").unwrap();
1500        assert!(
1501            title < version && title < enabled,
1502            "title stayed put:\n{src}"
1503        );
1504        assert!(enabled < version, "enabled moved above version:\n{src}");
1505    }
1506
1507    #[test]
1508    fn inserts_a_root_level_key() {
1509        let mut model = sample_model();
1510        model.insert_key(&[], "root_flag", Value::Bool(true));
1511        let src = model.source_snapshot();
1512        assert!(src.contains("root_flag"), "root key inserted:\n{src}");
1513        assert!(src.contains("title = \"flower\""), "existing kept");
1514    }
1515
1516    #[test]
1517    fn renames_a_key_losslessly() {
1518        let mut model = sample_model();
1519        select(&mut model, &[Seg::Key("version".into())]);
1520        model.rename_key(&[Seg::Key("version".into())], "revision");
1521        let src = model.source_snapshot();
1522        // fig may quote the new key (`"revision" = 1`); both are valid TOML.
1523        assert!(
1524            src.contains("revision") && src.contains("= 1"),
1525            "renamed with value kept:\n{src}"
1526        );
1527        assert!(!src.contains("version = 1"), "old key gone");
1528        // Selection re-anchored onto the renamed entry.
1529        assert_eq!(
1530            model.rows[model.selected].path,
1531            [Seg::Key("revision".into())]
1532        );
1533    }
1534
1535    #[test]
1536    fn rename_rejects_a_sequence_item() {
1537        let mut model = sample_model();
1538        model.rename_key(
1539            &[
1540                Seg::Key("server".into()),
1541                Seg::Key("tags".into()),
1542                Seg::Index(0),
1543            ],
1544            "nope",
1545        );
1546        assert!(model.status.contains("mapping keys"));
1547    }
1548
1549    #[test]
1550    fn schema_closed_vocabulary_rejects_an_unknown_edit() {
1551        use crate::schema::{Constraint, FieldRule};
1552        use fig_schema::{FieldType, PathPat, Term};
1553        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
1554        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1555        let mut model = Model::new(backend).expect("model");
1556        model.set_schema(crate::schema::Schema::new(vec![
1557            FieldRule::new(PathPat::each_item_of("audience"))
1558                .ty(FieldType::Str)
1559                .constraint(Constraint::Enum {
1560                    values: vec![Term::value("public"), Term::value("private")],
1561                    closed: true,
1562                }),
1563        ]));
1564
1565        // An unknown value is rejected at the commit funnel; the document is
1566        // untouched (fig never sees the edit).
1567        select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
1568        model.begin_edit();
1569        type_value(&mut model, "familly");
1570        assert!(
1571            model.status.contains("rejected"),
1572            "status: {}",
1573            model.status
1574        );
1575        assert!(
1576            model.source_snapshot().contains("public"),
1577            "document unchanged:\n{}",
1578            model.source_snapshot()
1579        );
1580
1581        // A known value commits normally.
1582        model.begin_edit();
1583        type_value(&mut model, "private");
1584        let out = model.source_snapshot();
1585        assert!(out.contains("private"), "known value applied:\n{out}");
1586        assert!(!out.contains("public"), "old value replaced:\n{out}");
1587    }
1588
1589    /// A declared field the document omits is otherwise unreachable — it has no
1590    /// row, because rows come from the document. This is what lets a frontend
1591    /// offer it.
1592    #[test]
1593    fn addable_fields_are_the_declared_keys_the_document_lacks() {
1594        use crate::schema::{Constraint, FieldRule};
1595        use fig_schema::{FieldType, PathPat, Term};
1596        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
1597        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1598        let mut model =
1599            Model::with_hidden(backend, vec!["title".into(), "updated".into()]).expect("model");
1600        model.set_schema(crate::schema::Schema::new(vec![
1601            // Present in the document — already reachable, so never offered.
1602            FieldRule::new(PathPat::key("audience")).ty(FieldType::Str),
1603            // An each-item rule governs *within* a field; it names none.
1604            FieldRule::new(PathPat::each_item_of("audience"))
1605                .ty(FieldType::Str)
1606                .constraint(Constraint::Enum {
1607                    values: vec![Term::value("public")],
1608                    closed: true,
1609                }),
1610            // Declared, absent, not managed — the one to offer.
1611            FieldRule::new(PathPat::key("created")).ty(FieldType::Str),
1612            // Declared and absent, but the embedder manages it.
1613            FieldRule::new(PathPat::key("updated")).ty(FieldType::Str),
1614        ]));
1615
1616        let offered: Vec<_> = model
1617            .addable_fields()
1618            .iter()
1619            .map(|r| match r.at.0.as_slice() {
1620                [SegPat::Key(k)] => k.clone(),
1621                _ => unreachable!("only single-key rules are offered"),
1622            })
1623            .collect();
1624        assert_eq!(offered, vec!["created".to_string()]);
1625
1626        // Once added it is a real row, so it stops being offered.
1627        model.insert_key(&[], "created", Value::Str("2026-07-24".into()));
1628        assert!(model.addable_fields().is_empty());
1629    }
1630
1631    /// A derived field keeps its row — unlike a hidden one — but declines every
1632    /// mutation, because the workspace rewrites it on the next save regardless.
1633    #[test]
1634    fn a_derived_field_is_visible_but_declines_edits() {
1635        let src = "title = \"note\"\nupdated = \"2026-07-01\"\ncreated = \"2026-06-01\"\n";
1636        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1637        let mut model = Model::with_managed(backend, vec!["title".into()], vec!["updated".into()])
1638            .expect("model");
1639
1640        // Hidden means no row; derived means a row that is marked.
1641        let labels: Vec<&str> = model.rows.iter().map(|r| r.label.as_str()).collect();
1642        assert_eq!(labels, vec!["updated", "created"]);
1643        assert!(model.is_derived(&[Seg::Key("updated".into())]));
1644        assert!(!model.is_derived(&[Seg::Key("created".into())]));
1645
1646        // Every shape of mutation is declined, and the document is untouched.
1647        model.set_scalar_text(&[Seg::Key("updated".into())], "2026-01-01");
1648        assert!(model.status.contains("maintained by the workspace"));
1649        model.rename_key(&[Seg::Key("updated".into())], "modified");
1650        assert!(model.status.contains("maintained by the workspace"));
1651        model.selected = 0;
1652        model.delete_selected();
1653        assert!(model.status.contains("maintained by the workspace"));
1654        let out = model.source_snapshot();
1655        assert!(
1656            out.contains("updated = \"2026-07-01\""),
1657            "unchanged:\n{out}"
1658        );
1659
1660        // A neighbouring ordinary field still edits normally.
1661        model.set_scalar_text(&[Seg::Key("created".into())], "2026-06-15");
1662        assert!(model.source_snapshot().contains("2026-06-15"));
1663    }
1664
1665    /// Without a schema there is nothing to declare, so nothing is offered —
1666    /// a standalone config keeps the free-text add path.
1667    #[test]
1668    fn addable_fields_are_empty_without_a_schema() {
1669        let src = "title = \"note\"\n";
1670        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1671        let model = Model::new(backend).expect("model");
1672        assert!(model.addable_fields().is_empty());
1673    }
1674
1675    #[test]
1676    fn schema_typed_field_keeps_a_numeric_string_as_text() {
1677        use crate::schema::FieldRule;
1678        use fig_schema::{FieldType, PathPat};
1679        let src = "code = \"x\"\n";
1680        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1681        let mut model = Model::new(backend).expect("model");
1682        model.set_schema(crate::schema::Schema::new(vec![
1683            FieldRule::new(PathPat::key("code")).ty(FieldType::Str),
1684        ]));
1685
1686        select(&mut model, &[Seg::Key("code".into())]);
1687        model.begin_edit();
1688        type_value(&mut model, "123");
1689        // Schema says `str`, so the buffer stays a quoted string rather than being
1690        // coerced to an integer the way the shape-guessing heuristic would.
1691        let out = model.source_snapshot();
1692        assert!(out.contains("code = \"123\""), "kept as string:\n{out}");
1693    }
1694
1695    /// The point of a default-collapsed set: the *opening* frame is already
1696    /// folded, without a toggle pass that walks the selection across the document.
1697    #[test]
1698    fn containers_can_arrive_collapsed() {
1699        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
1700        let model = Model::with_collapsed(
1701            backend,
1702            Vec::new(),
1703            Vec::new(),
1704            vec![
1705                vec![Seg::Key("server".into())],
1706                // Naming a scalar is inert, not an error — a caller collapses the
1707                // keys it means to without first sorting containers from scalars.
1708                vec![Seg::Key("title".into())],
1709            ],
1710        )
1711        .expect("model");
1712
1713        let server = model
1714            .rows
1715            .iter()
1716            .find(|r| r.path == [Seg::Key("server".into())])
1717            .expect("server row");
1718        assert!(!server.expanded, "collapsed before the first frame");
1719        assert!(
1720            !model.rows.iter().any(|r| r.path.len() > 1),
1721            "no descendant rows: {:?}",
1722            model.rows.iter().map(|r| &r.label).collect::<Vec<_>>()
1723        );
1724        // The inert scalar path didn't cost `title` its row.
1725        assert!(
1726            model
1727                .rows
1728                .iter()
1729                .any(|r| r.path == [Seg::Key("title".into())])
1730        );
1731        assert_eq!(model.selected, 0, "selection untouched");
1732    }
1733
1734    /// Unlike `activate`, folding by path is not a selection move — that is the
1735    /// whole reason a caller reaches for it.
1736    #[test]
1737    fn set_collapsed_folds_by_path_without_moving_the_selection() {
1738        let mut model = sample_model();
1739        select(&mut model, &[Seg::Key("title".into())]);
1740
1741        model.set_collapsed(&[Seg::Key("server".into())], true);
1742        assert!(model.is_collapsed(&[Seg::Key("server".into())]));
1743        assert!(
1744            !model.rows.iter().any(|r| r.path.len() > 1),
1745            "children hidden"
1746        );
1747        assert_eq!(
1748            model.rows[model.selected].path,
1749            [Seg::Key("title".into())],
1750            "selection stayed on title"
1751        );
1752
1753        model.set_collapsed(&[Seg::Key("server".into())], false);
1754        assert!(!model.is_collapsed(&[Seg::Key("server".into())]));
1755        assert!(
1756            model
1757                .rows
1758                .iter()
1759                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())])
1760        );
1761        assert_eq!(model.rows[model.selected].path, [Seg::Key("title".into())]);
1762    }
1763
1764    /// The one case where the selection *must* move: it was inside the fold.
1765    #[test]
1766    fn set_collapsed_reanchors_a_selection_it_swallowed() {
1767        let mut model = sample_model();
1768        select(
1769            &mut model,
1770            &[Seg::Key("server".into()), Seg::Key("host".into())],
1771        );
1772        model.set_collapsed(&[Seg::Key("server".into())], true);
1773        assert_eq!(
1774            model.rows[model.selected].path,
1775            [Seg::Key("server".into())],
1776            "landed on the container that swallowed it"
1777        );
1778    }
1779
1780    /// The insert/append counterparts of the type-directed scalar edit: without
1781    /// them a caller shape-guesses, and `2026` lands in a `str` list as an integer.
1782    #[test]
1783    fn insert_and_append_are_type_directed_by_the_schema() {
1784        use crate::schema::FieldRule;
1785        use fig_schema::{FieldType, PathPat};
1786        let src = "tags = [\"alpha\"]\n\n[meta]\nk = \"v\"\n";
1787        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
1788        let mut model = Model::new(backend).expect("model");
1789        model.set_schema(crate::schema::Schema::new(vec![
1790            // The *items* of `tags` are strings — the list itself is a seq.
1791            FieldRule::new(PathPat::each_item_of("tags")).ty(FieldType::Str),
1792            FieldRule::new(PathPat::key("year")).ty(FieldType::Str),
1793            FieldRule::new(PathPat(vec![
1794                fig_schema::SegPat::Key("meta".into()),
1795                fig_schema::SegPat::Key("code".into()),
1796            ]))
1797            .ty(FieldType::Str),
1798        ]));
1799
1800        model.append_item_text(&[Seg::Key("tags".into())], "2026");
1801        model.insert_key_text(&[], "year", "2026");
1802        // The nested case flower-ffi and Diaryx both shape-guessed.
1803        model.insert_key_text(&[Seg::Key("meta".into())], "code", "2026");
1804
1805        let out = model.source_snapshot();
1806        assert!(
1807            !out.contains("2026,") && !out.contains("[2026]") && !out.contains("= 2026"),
1808            "no bare integers survived the schema:\n{out}"
1809        );
1810        assert_eq!(
1811            model.value_at(&[Seg::Key("tags".into()), Seg::Index(1)]),
1812            Some(&Value::Str("2026".into())),
1813            "list item took the each-item type:\n{out}"
1814        );
1815        assert_eq!(
1816            model.value_at(&[Seg::Key("year".into())]),
1817            Some(&Value::Str("2026".into()))
1818        );
1819        assert_eq!(
1820            model.value_at(&[Seg::Key("meta".into()), Seg::Key("code".into())]),
1821            Some(&Value::Str("2026".into()))
1822        );
1823    }
1824
1825    /// With no rule to consult they fall back to the same shape-guessing the raw
1826    /// `insert_key`/`append_item` callers do today, so a standalone config is
1827    /// unaffected.
1828    #[test]
1829    fn insert_and_append_text_shape_guess_without_a_schema() {
1830        let mut model = sample_model();
1831        model.append_item_text(&[Seg::Key("server".into()), Seg::Key("tags".into())], "42");
1832        model.insert_key_text(&[], "count", "7");
1833        assert_eq!(
1834            model.value_at(&[
1835                Seg::Key("server".into()),
1836                Seg::Key("tags".into()),
1837                Seg::Index(2)
1838            ]),
1839            Some(&Value::Int(42))
1840        );
1841        assert_eq!(
1842            model.value_at(&[Seg::Key("count".into())]),
1843            Some(&Value::Int(7))
1844        );
1845    }
1846
1847    /// The walkers a backend needs, over a plain `Value` — no `Model` in reach.
1848    #[test]
1849    fn tree_walkers_resolve_paths_and_reject_mismatches() {
1850        let model = sample_model();
1851        let root = model.value_at(&[]).expect("root");
1852
1853        assert_eq!(
1854            tree::value_at(root, &[Seg::Key("server".into()), Seg::Key("port".into())]),
1855            Some(&Value::Int(8080))
1856        );
1857        assert_eq!(
1858            tree::seq_len(root, &[Seg::Key("server".into()), Seg::Key("tags".into())]),
1859            Some(2)
1860        );
1861        // Not a sequence, versus not there at all — both `None`, and neither is a
1862        // length of zero a caller could mistake for an empty list.
1863        assert_eq!(tree::seq_len(root, &[Seg::Key("title".into())]), None);
1864        assert_eq!(tree::seq_len(root, &[Seg::Key("absent".into())]), None);
1865        assert_eq!(
1866            tree::map_keys(root, &[Seg::Key("server".into())]),
1867            Some(vec![
1868                "host".to_string(),
1869                "port".to_string(),
1870                "tags".to_string(),
1871                "limits".to_string()
1872            ])
1873        );
1874        assert_eq!(tree::map_keys(root, &[Seg::Key("title".into())]), None);
1875        // A key step into a sequence resolves to nothing rather than guessing.
1876        assert_eq!(
1877            tree::value_at(
1878                root,
1879                &[
1880                    Seg::Key("server".into()),
1881                    Seg::Key("tags".into()),
1882                    Seg::Key("0".into())
1883                ]
1884            ),
1885            None
1886        );
1887    }
1888
1889    #[test]
1890    fn navigation_folds_and_reanchors() {
1891        let mut model = sample_model();
1892
1893        select(&mut model, &[Seg::Key("server".into())]);
1894        model.collapse_or_leave();
1895        assert!(
1896            !model
1897                .rows
1898                .iter()
1899                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())]),
1900            "collapsed children hidden"
1901        );
1902        assert_eq!(model.rows[model.selected].path, [Seg::Key("server".into())]);
1903    }
1904
1905    // ── the page projection ───────────────────────────────────────────────
1906
1907    fn key(k: &str) -> Seg {
1908        Seg::Key(k.to_string())
1909    }
1910
1911    /// A model in the page view, cursor on the root page.
1912    fn paged_model() -> Model<FigBackend> {
1913        let mut model = sample_model();
1914        model.set_view(ViewMode::Pages);
1915        model
1916    }
1917
1918    fn page_labels(model: &Model<FigBackend>) -> Vec<String> {
1919        model.page().items.iter().map(|i| i.label.clone()).collect()
1920    }
1921
1922    fn selected_label(model: &Model<FigBackend>) -> String {
1923        model.page_item().expect("a selected item").label.clone()
1924    }
1925
1926    #[test]
1927    fn drilling_opens_a_page_and_backing_out_returns_the_cursor_to_it() {
1928        let mut model = paged_model();
1929        assert!(model.focus().is_empty());
1930
1931        // Down to `server`, then in.
1932        for _ in 0..3 {
1933            model.page_move_down();
1934        }
1935        assert_eq!(selected_label(&model), "server");
1936        model.page_enter();
1937
1938        assert_eq!(model.focus(), &[key("server")]);
1939        assert_eq!(selected_label(&model), "host");
1940
1941        model.page_back();
1942        assert!(model.focus().is_empty());
1943        assert_eq!(selected_label(&model), "server");
1944    }
1945
1946    #[test]
1947    fn depth_costs_a_page_not_a_column() {
1948        let mut model = paged_model();
1949        // Two levels down, and the page is still four items of one rank plus the
1950        // members of the groups inlined into it — never an indentation ladder.
1951        model.focus_on(&[key("server"), key("limits")]);
1952        assert_eq!(model.focus(), &[key("server")]);
1953        assert!(model.page().items.iter().all(|i| i.inset <= 1));
1954        assert_eq!(selected_label(&model), "limits");
1955
1956        // A group header opens nothing — its members are already here — so `l`
1957        // steps onto the first of them instead.
1958        model.page_enter();
1959        assert_eq!(model.focus(), &[key("server")]);
1960        assert_eq!(selected_label(&model), "max_connections");
1961    }
1962
1963    #[test]
1964    fn a_group_header_never_opens_a_page_that_repeats_it() {
1965        let mut model = paged_model();
1966        model.focus_on(&[key("server")]);
1967        model.page_enter();
1968        for header in ["tags", "limits"] {
1969            let at = model
1970                .page()
1971                .items
1972                .iter()
1973                .position(|i| i.label == header)
1974                .expect("the group header");
1975            assert!(!model.page().items[at].is_drill());
1976            // Whatever the cursor does, the focused page never becomes the group's.
1977            model.page_enter();
1978            assert_eq!(model.focus(), &[key("server")]);
1979        }
1980    }
1981
1982    #[test]
1983    fn an_edit_made_from_a_page_is_lossless() {
1984        let mut model = paged_model();
1985        // An inlined member, two ranks below the page's focus — the case where the
1986        // page's layout and the document's shape disagree most.
1987        model.focus_on(&[key("server"), key("limits"), key("timeout")]);
1988        assert_eq!(model.focus(), &[key("server")]);
1989        assert_eq!(selected_label(&model), "timeout");
1990
1991        model.begin_edit();
1992        type_value(&mut model, "45.5");
1993
1994        let src = model.source_snapshot();
1995        assert_eq!(src, SAMPLE.replace("timeout = 30.5", "timeout = 45.5"));
1996        assert!(model.dirty);
1997        // The cursor stayed on the field that was edited, in both projections.
1998        assert_eq!(selected_label(&model), "timeout");
1999        assert_eq!(
2000            model.rows[model.selected].path,
2001            vec![key("server"), key("limits"), key("timeout")]
2002        );
2003    }
2004
2005    #[test]
2006    fn losing_the_container_you_are_standing_in_pops_you_out() {
2007        // `b` nests a container, so it is a real drill rather than an inlined
2008        // group — the only kind of row a page can be opened from.
2009        let backend =
2010            FigBackend::open(br#"{"a": {"b": {"c": {"d": 1}}}}"#, Format::Json).expect("open");
2011        let mut model = Model::new(backend).expect("model");
2012        model.set_view(ViewMode::Pages);
2013        model.focus_on(&[key("a"), key("b")]);
2014        model.page_enter();
2015        assert_eq!(model.focus(), &[key("a"), key("b")]);
2016
2017        // Replace the container the page is listing with a scalar: the focus now
2018        // names something that cannot be listed at all.
2019        model.set_value_at(&[key("a"), key("b")], Value::Int(1));
2020
2021        assert_eq!(model.focus(), &[key("a")]);
2022        assert_eq!(page_labels(&model), vec!["b"]);
2023    }
2024
2025    #[test]
2026    fn switching_views_carries_the_selection_both_ways() {
2027        let mut model = sample_model();
2028        select(&mut model, &[key("server"), key("limits"), key("timeout")]);
2029
2030        model.set_view(ViewMode::Pages);
2031        // The page that *lists* an inlined member is its grandparent's.
2032        assert_eq!(model.focus(), &[key("server")]);
2033        assert_eq!(selected_label(&model), "timeout");
2034
2035        // Move within the page, and the tree lands where the page left off.
2036        model.page_move_up();
2037        assert_eq!(selected_label(&model), "max_connections");
2038        model.set_view(ViewMode::Tree);
2039        assert_eq!(
2040            model.rows[model.selected].path,
2041            vec![key("server"), key("limits"), key("max_connections")]
2042        );
2043    }
2044
2045    #[test]
2046    fn switching_to_the_tree_opens_the_lineage_of_a_folded_selection() {
2047        let mut model = sample_model();
2048        model.set_collapsed(&[key("server")], true);
2049        model.set_view(ViewMode::Pages);
2050        model.focus_on(&[key("server"), key("host")]);
2051
2052        model.set_view(ViewMode::Tree);
2053        // `server` was shut, so `host` had no row to land on until it was opened.
2054        assert!(!model.is_collapsed(&[key("server")]));
2055        assert_eq!(
2056            model.rows[model.selected].path,
2057            vec![key("server"), key("host")]
2058        );
2059    }
2060
2061    #[test]
2062    fn a_flat_document_would_waste_a_second_pane() {
2063        let flat = FigBackend::open(
2064            b"a = 1
2065b = 2
2066",
2067            Format::Toml,
2068        )
2069        .expect("open");
2070        let flat = Model::new(flat).expect("model");
2071        assert!(flat.pages_would_degenerate());
2072        assert!(!sample_model().pages_would_degenerate());
2073    }
2074
2075    #[test]
2076    fn the_root_page_previews_what_the_cursor_would_open() {
2077        let mut model = paged_model();
2078        assert_eq!(selected_label(&model), "title");
2079        assert!(model.peek_page().is_none(), "a scalar has no page");
2080
2081        for _ in 0..3 {
2082            model.page_move_down();
2083        }
2084        let peek = model.peek_page().expect("server's page");
2085        assert_eq!(peek.focus, vec![key("server")]);
2086        assert_eq!(peek.breadcrumb("‹document›"), "server");
2087    }
2088
2089    #[test]
2090    fn opening_a_compressed_row_lands_past_the_pages_that_say_nothing() {
2091        let backend = FigBackend::open(
2092            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
2093            Format::Json,
2094        )
2095        .expect("open");
2096        let mut model = Model::new(backend).expect("model");
2097        model.set_view(ViewMode::Pages);
2098
2099        model.page_enter();
2100        // One step, two levels: the `exports` page held nothing but `journal`.
2101        assert_eq!(model.focus(), &[key("exports"), key("journal")]);
2102        assert_eq!(
2103            model.page().breadcrumb("‹document›"),
2104            "exports › journal",
2105            "the trail still shows what was skipped"
2106        );
2107
2108        // Backing out retraces the step: one tap in was two levels, so one tap
2109        // out is two levels, and it lands on the page that listed the row rather
2110        // than on the page the compression existed to skip.
2111        model.page_back();
2112        assert!(model.focus().is_empty());
2113        // The cursor is on the row that was opened, which still addresses
2114        // `exports` and still renames it.
2115        assert_eq!(
2116            model.page_item().map(|i| i.label.clone()),
2117            Some("exports".into())
2118        );
2119        assert!(model.page_item().unwrap().can_rename());
2120    }
2121
2122    #[test]
2123    fn the_left_pane_is_the_page_that_listed_the_row_not_the_level_above() {
2124        let backend = FigBackend::open(
2125            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
2126            Format::Json,
2127        )
2128        .expect("open");
2129        let mut model = Model::new(backend).expect("model");
2130        model.set_view(ViewMode::Pages);
2131        model.page_enter();
2132        assert_eq!(model.focus(), &[key("exports"), key("journal")]);
2133
2134        // One level out is `exports`, whose page holds nothing but the row that
2135        // was tapped — the page the compression exists to skip. The left pane
2136        // walks past it to the page that actually listed the row.
2137        assert!(
2138            model.parent_page().focus.is_empty(),
2139            "the root, not `exports`"
2140        );
2141        // And it can still mark what was opened: the compressed row answers for
2142        // its whole chain.
2143        let marked = model
2144            .parent_page()
2145            .position_of(model.focus())
2146            .expect("marked");
2147        assert_eq!(model.parent_page().items[marked].label, "exports");
2148
2149        // And backing out agrees with the pane: `exports` is skipped both ways,
2150        // so the page on the left is the page you land on.
2151        model.page_back();
2152        assert!(model.focus().is_empty());
2153        assert_eq!(model.focus(), model.parent_page().focus);
2154    }
2155
2156    #[test]
2157    fn a_compressed_row_still_answers_ops_as_its_outermost_node() {
2158        let backend = FigBackend::open(
2159            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
2160            Format::Json,
2161        )
2162        .expect("open");
2163        let mut model = Model::new(backend).expect("model");
2164        model.set_view(ViewMode::Pages);
2165
2166        // Deleting a row reading `exports › journal` takes the whole chain, so
2167        // no empty `exports: {}` is left behind to delete separately.
2168        model.delete_selected();
2169        assert!(!model.source_snapshot().contains("exports"));
2170        assert!(!model.source_snapshot().contains("journal"));
2171        assert!(model.source_snapshot().contains('z'));
2172    }
2173
2174    /// A host driving both surfaces — a metadata pane beside a settings page —
2175    /// hands a *row* index to a model left standing in the page projection. The
2176    /// index means nothing there, and before `select_row` asserted the tree the
2177    /// delete that followed read the page cursor and removed a different node.
2178    #[test]
2179    fn a_row_index_deletes_the_row_it_names_even_from_the_page_projection() {
2180        let backend = FigBackend::open(
2181            br#"{"alpha": 1, "beta": 2, "gamma": {"inner": 3}}"#,
2182            Format::Json,
2183        )
2184        .expect("open");
2185        let mut model = Model::new(backend).expect("model");
2186
2187        // Go and stand somewhere in the page projection, with its cursor on a
2188        // different node than the row index below names.
2189        model.set_view(ViewMode::Pages);
2190        model.page_move_down();
2191        assert_eq!(
2192            model.page_item().map(|i| i.label.clone()),
2193            Some("beta".into())
2194        );
2195
2196        // Now the other surface speaks, in its own coordinates, without first
2197        // announcing a switch.
2198        model.select_row(0);
2199        model.delete_selected();
2200
2201        assert!(
2202            !model.source_snapshot().contains("alpha"),
2203            "row 0 was `alpha`"
2204        );
2205        assert!(
2206            model.source_snapshot().contains("beta"),
2207            "the page cursor was not the target"
2208        );
2209    }
2210
2211    /// The mirror: page vocabulary asserts pages, so a page op after tree work
2212    /// acts on the page cursor rather than on whatever row was last selected.
2213    #[test]
2214    fn a_page_op_acts_on_the_page_cursor_even_from_the_tree_projection() {
2215        // `gamma` holds a container *and* a scalar, so it neither inlines into
2216        // the root page nor compresses into a chain — it is a plain drill row.
2217        let backend = FigBackend::open(
2218            br#"{"alpha": 1, "beta": 2, "gamma": {"inner": {"deep": 3}, "flag": true}}"#,
2219            Format::Json,
2220        )
2221        .expect("open");
2222        let mut model = Model::new(backend).expect("model");
2223
2224        model.select_row(0);
2225        assert_eq!(model.view(), ViewMode::Tree);
2226
2227        // `page_enter` is page vocabulary; it must not be read against the tree.
2228        model.page_move_down();
2229        model.page_move_down();
2230        model.page_enter();
2231        assert_eq!(model.view(), ViewMode::Pages);
2232        assert_eq!(model.focus(), &[key("gamma")]);
2233    }
2234
2235    #[test]
2236    fn backing_out_past_the_root_is_inert() {
2237        let mut model = paged_model();
2238        model.page_back();
2239        assert!(model.focus().is_empty());
2240        assert_eq!(model.page_selected(), 0);
2241    }
2242
2243    /// Arriving and leaving cost the same number of steps.
2244    ///
2245    /// `views` holds only `date`, so its row compresses and `page_enter` lands
2246    /// straight on `views.date`. Popping one raw segment would put you on the
2247    /// `views` page — one row, named `date`, which is the page compression
2248    /// exists to skip — and make the way out twice as long as the way in.
2249    #[test]
2250    fn backing_out_retraces_what_entering_skipped() {
2251        let backend = FigBackend::open(
2252            br#"{"views": {"date": {"icon": "calendar", "group": ["created"], "by": "year"}}, "fixity": "all"}"#,
2253            Format::Json,
2254        )
2255        .expect("open");
2256        let mut model = Model::new(backend).expect("model");
2257        model.set_view(ViewMode::Pages);
2258
2259        // One step in, past `views`, to the first page with more on it than the
2260        // name that was tapped.
2261        model.page_enter();
2262        assert_eq!(model.focus(), &[key("views"), key("date")]);
2263
2264        // One step out, to the page that listed the row — not to `views`.
2265        model.page_back();
2266        assert!(model.focus().is_empty());
2267        // And the cursor is back on the row that was opened: a compressed row
2268        // answers for its whole chain, so the child path finds it.
2269        assert_eq!(model.page_selected(), 0);
2270    }
2271
2272    /// The skipped page held nothing but the chain, so skipping it takes no
2273    /// operation away: the row on the page we land on still addresses `views`.
2274    #[test]
2275    fn the_skipped_level_is_still_operable_from_the_row() {
2276        let backend = FigBackend::open(
2277            br#"{"views": {"date": {"icon": "calendar", "group": ["created"], "by": "year"}}, "fixity": "all"}"#,
2278            Format::Json,
2279        )
2280        .expect("open");
2281        let mut model = Model::new(backend).expect("model");
2282        model.set_view(ViewMode::Pages);
2283
2284        model.page_enter();
2285        model.page_back();
2286        let row = model.page_item().expect("a row under the cursor");
2287        assert_eq!(row.path, vec![key("views")]);
2288        assert_eq!(row.descend_to, vec![key("views"), key("date")]);
2289    }
2290
2291    #[test]
2292    fn the_two_panes_are_consecutive_levels_of_one_lineage() {
2293        // Every level here holds two things, so no row compresses and each
2294        // `page_enter` moves exactly one level — which is what this is about.
2295        let backend = FigBackend::open(
2296            br#"{"jobs": {"plan": {"steps": {"a": 1, "b": {"c": 2}}, "id": 3}, "name": "x"}}"#,
2297            Format::Json,
2298        )
2299        .expect("open");
2300        let mut model = Model::new(backend).expect("model");
2301        model.set_view(ViewMode::Pages);
2302
2303        // At the root there is no parent to show on the left.
2304        assert!(model.parent_page().is_empty());
2305
2306        model.page_enter(); // jobs
2307        assert_eq!(model.parent_page().focus, Vec::<Seg>::new());
2308        model.page_enter(); // jobs.plan
2309        assert_eq!(model.parent_page().focus, vec![key("jobs")]);
2310        model.page_enter(); // jobs.plan.steps
2311        assert_eq!(model.parent_page().focus, vec![key("jobs"), key("plan")]);
2312
2313        // The left pane can always mark the row the right one was opened from.
2314        assert!(model.parent_page().position_of(model.focus()).is_some());
2315    }
2316}