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