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}