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