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