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