Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -74,6 +74,7 @@ Body: board description (free Markdown).
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `order` | number | no | Rank among lanes, ascending = left-to-right. Optional (re-ruled 2026-07-31) — missing or unusable reads as append-at-end (Ordering below) |
|
| `order` | number | no | Rank among lanes, ascending = left-to-right. Optional (re-ruled 2026-07-31) — missing or unusable reads as append-at-end (Ordering below) |
|
||||||
| `width` | integer | no | Width multiplier ≥ 1 (default 1); no cap |
|
| `width` | integer | no | Width multiplier ≥ 1 (default 1); no cap |
|
||||||
|
| `collapsed` | boolean | no | `true` folds the lane to a slim strip (03-board-ui.md § Lane ▸ Collapsed lanes). Absent = expanded, and the app **removes** the key to expand rather than writing `false` (the remove-at-default family); a hand-written `false` reads as expanded and is preserved. Lenient: a value with no boolean reading falls back to expanded. `width` is preserved untouched while collapsed |
|
||||||
|
|
||||||
Body: lane description / WIP policy / notes.
|
Body: lane description / WIP policy / notes.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i
|
|||||||
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
- Title bar: leading SF Symbol (the lane's `icon`), title, **card-count badge** (quiet, secondary styling), new-card button. The whole title bar is the lane's drag surface — no separate grip; a plain click (no movement) on it selects the lane (04-interactions.md ▸ Selection). **The lane has one context menu** (settled), invoked on the header or on lane empty space alike — Rename, Style…, the quick-style recents row, the Width stepper, Delete (inventory normative in 11-command-nexus.md ▸ Context menus); a full lane still has its header, so the menu is always reachable. The count reads the search filter like every other surface (04-interactions.md): during a search it shows the visible count, not the total.
|
||||||
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
- Body: vertical card stack (masonry grid when wide — settled, the pathfinder's masonry works), scrolls vertically.
|
||||||
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
- Right-edge **drag-to-resize** between integer widths (1×, 2×, 3×, … — no cap): shadow snaps at the inter-column gap with 10pt release hysteresis; the window grows/shrinks by one standard width per snap so other lanes keep their exact size. **At the screen's visible frame the window stops and the drag degrades to the re-divide** (settled 2026-08-08, superseding the pathfinder's hard stop): further snaps re-divide the now-fixed width across one more unit each — siblings compress, exactly the stepper's mechanism — so a lane keeps growing at the siblings' expense once the screen is spent; a window with no headroom to begin with (already at the edge, or full screen) re-divides from the first snap. The two regimes meet without a pixel jump — the re-divided standard at the screen fit *is* the frozen standard. Shrinking mirrors it: re-divide back down to the screen fit, then window shrinking resumes (and shrinking is always allowed). The rubber-band feedback (the dragged edge gives a fraction of the overshoot and snaps back, signalling the bound) moves to the true end of travel — the strip's own capacity — and the window still never overflows the screen. The header context menu's Width control (stepper, uncapped) remains the precise control: it never touches the window, it **re-divides** the existing width across the new unit total (siblings compress); the drag now reaches those widths too, but the stepper stays the exact, accessible path. The **Increase/Decrease Lane Width menu items (⌥⌘→/⌥⌘← — 11-command-nexus.md) are this stepper's keyboard face** — same re-divide semantics, never the window's size; window-growing behavior belongs to the drag alone — and they **batch over a multi-lane selection** (settled, the styling precedent): each selected lane steps one unit, one gesture, one commit; the context-menu stepper itself stays single-lane by nature. **A width write landing on 1 removes the `width` key** (settled — the remove-at-default family: the empty rename removes `title`, the None well removes `background`): a default lane's frontmatter stays clean, drag, stepper, and menu items alike; a hand-written `width: 1` is legal and preserved until the app itself next edits width. **A failed width commit at drag release rolls the window back** (settled): the failure surfaces as the ordinary one-shot banner and the window animates back by the uncommitted delta — 02-architecture.md's write-failure honesty (the action visibly doesn't happen) applied to the one control that moves the window.
|
||||||
|
- **Collapsed lanes** (settled 2026-08-08): a lane folds to a **fixed slim vertical strip** — its `icon`, its card-count badge, its title rotated a quarter turn **counterclockwise so it reads bottom-up** (the book-spine orientation) and **justified to the top** of the room below that chrome (ruled by the owner 2026-08-08, reversing the top-down reading of the first pass), and its accent band, at a fixed width (~44pt, an em multiple like every other figure) — and **the strip is not part of the width re-division**: the window's width divides across the *expanded* lanes' units alone, with each collapsed strip's fixed width taken off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family (the window never moves and the siblings grow into what the lane gave up). Persistence is a first-class lane frontmatter key, `collapsed: true` — **document state exactly like `width`**, because the files are the board and an agent must be able to fold a lane by editing frontmatter; absent means expanded, expanding **removes** the key (never writes `false` — the remove-at-default family), and the lane's `width` is preserved untouched while collapsed so expanding restores the lane the user had. **Toggling**: the header's trailing collapse chevron (always visible beside the new-card button — the header has no hover chrome to match), the lane context menu's single **Collapse Lane** / **Expand Lane** row (single-lane like the width stepper, not batched), and a plain click anywhere on the collapsed strip, which expands it; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. Rename and the expanded header's double-click behaviours are unchanged. **While collapsed**: no resize handle (the width stepper and ⌥⌘→/⌥⌘← still edit the preserved key), and the lane's cards are **not rendered at all** — which is what excludes them from the marquee sweep, from spatial keyboard navigation, and from the ⌥-arrow jumps (↓ from a folded lane is inert, like an empty one), and what makes ⌘N skip the lane for creation (a selection inside a folded lane falls through to the last-active lane rather than refusing). A drop on the strip **appends at the lane's end**, like an end-of-lane drop, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto-expand in v1 (future work). Lane reorder drag works on the strip, and a folded lane's replica and drop shadow are the strip rather than its units. **Search still matches cards in collapsed lanes** — the badge and the flatten lists count them, since collapse is a presentation fold rather than a filter — but the lane stays folded and shows no match: pointing into a folded lane is not something the current search presentation can do, and the honest minimum is that the query does not fold or unfold anything. The **trash quasi-lane is not collapsible**; multi-select styling and the Style editor are unaffected.
|
||||||
|
|
||||||
## Card face
|
## Card face
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Migrated to 13-native-undo.md (2026-08-08, at this doc\'s retirement) — the ru
|
|||||||
The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function:
|
The pathfinder's message engine carries over as the model — it is what earns the "semantic" in semantic commit messages, and it stays a pure, testable function:
|
||||||
|
|
||||||
- **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
|
- **Pure snapshot diff, no write-site tagging.** Messages compose at commit time from a structural diff of two board snapshots (last-committed vs. current) — never by intercepting operations. Items match by id across the *whole* board, so a lane change is distinguishable from delete+add and a cross-lane move reads as a move. Bookkeeping — `order` changes that preserve sibling sequence (a renumber's rescale — 01-storage-format.md), `modified`/`created`, and an on-touch heal's backfilled `kind` (01-storage-format.md ▸ Validation and healing) — produces no events: a diff touching only those composes nothing. Sequence is what the diff compares, not raw `order` values: an order change that *repositions* an item among its siblings composes Reorder, so a foreign writer's single-file reorder still reads as one. A midpoint-exhaustion renumber batches with the insert or move that triggered it, so its commit reads as that event.
|
||||||
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**.
|
- **Vocabulary**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board, plus **Collapse / Expand** for the lane fold (added 2026-08-08 with collapsed lanes — 03-board-ui.md § Lane ▸ Collapsed lanes: two verbs rather than one, the trash pair's split, because a row has to read back the gesture it undoes), plus Attach / Remove / **Replace** for attachment files ("Move card 'Fix login' to Doing", "Rename lane 'Todo' → 'Doing'"; Replace added 2026-07-31 — a changed file under a card's `attachments/` with an unchanged listing is a content replacement, named from the path alone: "Replace attachment 'photo.png' — card 'X'", never the anonymous path generic), plus **Repair** for the duplicate-id remint ("Repair duplicate of 'Fix login'" — 01-storage-format.md's silent scheduled heal, re-ruled 2026-07-29 from its former banner gate; app-mediated and heal-marked, so its separate heal commit names the remint directly instead of reading the folder swap as Permanently delete + Add), plus the trash pair (settled) — **Restore** and **Permanently delete** — distinguished by diff shape alone, keeping the composer a pure snapshot diff: a move into `.trash/` is Delete, a move out of it is Restore ("Restore card 'X'" — drag-to-restore, cut+paste), and an item *leaving the tree entirely* is Permanently delete ("Permanently delete card 'X'" — the trash's Delete, Empty Trash, and any foreign hard removal, which the shape rule catches and describes accurately for free); the inverse oddity — a card *arriving from outside the tree directly in `.trash/`* — composes "Add card 'X'" with a to-the-trash detail (blessed 2026-07-31: the arrival shape is an Add, the detail names the destination). The trail thereby tells moved-to-trash from gone-forever — the distinction Deleting never forgets (below) asks users to learn. Plural folding applies as usual: Empty Trash reads "Permanently delete 12 cards", cleanly distinct from a multi-select ⌫'s "Delete 12 cards". One commit per debounce window: a single event is the subject (with a detail body where one helps); several events of one kind fold into a plural subject, with shared destinations preserved ("Move 3 cards to Done"); genuinely mixed windows **say so in the subject** (re-ruled 2026-07-31, retiring the bare "Update board" fallback): **"Mixed update — N changes"**, always with a bulleted body naming every event, so the oneline log stays scannable and never dresses a grab-bag as one thing; when every event in the window shares one item — the card-window session flush's usual shape — the subject keeps the name: **"Mixed update — N changes to card '⟨title⟩'"**.
|
||||||
- **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board". **One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent.
|
- **Implied events don't steal the subject**: deleting a lane with five cards reads "Delete lane 'X'" with the card deletions as body bullets — not "Update board". **One level further down, a card's own event swallows its thread entirely** (blessed 2026-07-31): a card moved, deleted, restored, purged, or reminted carries its `comments/` paths silently — the card's event already explains every file under it, and comment bullets trailing "Delete card 'X'" would be the burying this rule exists to prevent.
|
||||||
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
|
- **Non-snapshot files commit too** (settled — the repo tracks more than the model: the agent guide, `CLAUDE.user.md`, the seeded `.gitignore`, and strays at every level): the committer **stages the whole board root** — whatever `git status` shows, `.gitignore` respected, **open Edit sessions still staged around** (settled): the session card's folder stays excluded exactly as in Rules ▸ Auto-commit, whole-root staging widening *what* commits, never overriding the exclusion — and its commit condition is the *tree*, not the snapshot diff, so a stray-only window commits rather than leaving the tree dirty (firing mid-session, it commits the strays and leaves the session folder untouched) (a permanently dirty stray would break branch switch's cannot-fail-dirty guarantee and void flush-before-overwrite for every file the model can't see). The composer's input extends accordingly: beside the snapshot diff it receives the changed-path list, and non-snapshot paths compose **path-shaped events** — `CLAUDE.md` composes "Update agent guide (vN)", the version read from the guide's marker first line (a pure function of file content, *not* write-site tagging — the no-interception rule stands); any other non-snapshot path composes "Update '⟨path⟩'", folding plural ("Update 3 files"). Model events keep the subject when present; non-snapshot changes then ride as body bullets — recorded, never silently absorbed under an unrelated subject. Attribution needs no new rule: the EchoLedger (02-architecture.md) is path-keyed, so the app's guide write classifies app-mediated by its receipt (the mechanism behind the guide-attribution exception below) and a stray edit classifies foreign, the two-commit split applying per file as everywhere else.
|
||||||
- **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. **Heal commits are authored `Lanework Integrity <[email protected]>`** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above.
|
- **Healing mutations commit separately** (ruled 2026-07-29 — 01-storage-format.md ▸ Validation and healing): a debounce window holding a scheduled heal's changes alongside anyone else's splits the heal's paths into their own commit — the two-commit split's mechanism with a third class, keyed by the Writer's heal-marked receipts in the EchoLedger; a three-way window commits **foreign → heal → user** (blessed 2026-07-31 — causal: what was found, the response to it, the newest overwrite); split staging starts by **resetting the shared index to HEAD** (blessed 2026-07-31 — another writer's staged-but-uncommitted picks are discarded, their *content* still committing on the app's classified terms; the exposed race is only the gap between a foreign `add` and its `commit`, since a held `index.lock` already backs the app off, and a private in-memory index beneath the wrapper is the named upgrade if cohabitation friction ever shows) (attribution machinery, like the author split; the composer stays a pure diff reader and names the heal commit from its own diff shape — a loose-file relocation reads as its Attach-shaped event, a legacy migration's move into `.trash/` as Delete, the guide as "Update agent guide (vN)"; that the shape vocabulary doesn't say "healed" is accepted, the banner already told the user). Mostly redundant — each scheduled healer runs its own bracket at the reload tail, normally its own window — but the split makes separation a guarantee rather than a timing accident. **Heal commits are authored `Lanework Integrity <[email protected]>`** (ruled 2026-07-31 — the third pinned synthetic, joining Lanework External and the agent-slug family; strings are API): a heal is a third origin — not the user's gesture, not a foreign writer — and the separation exists for audit, so the trail filters by author like every origin; the committer stays the user (the recorded-by convention above). On-touch and inline heals are structurally exempt: each lives inside a host write or its triggering gesture and rides that commit, the backfilled `kind` composing no event per the bookkeeping rule above.
|
||||||
|
|||||||
@@ -397,6 +397,23 @@ enum ChangeNarrator {
|
|||||||
paths: paths
|
paths: paths
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
// **The fold, in the reading rather than the key** (03-board-ui.md § Lane ▸ Collapsed
|
||||||
|
// lanes): `LaneLayoutMath.isCollapsed` is what the strip draws off, so a `collapsed:
|
||||||
|
// false` appearing beside an absent key changes no lane on screen and composes
|
||||||
|
// nothing — the same "sequence, not raw `order`" discipline the reorder rule follows.
|
||||||
|
//
|
||||||
|
// Two kinds rather than one, on the trash pair's precedent (06 ▸ Commit messages): the
|
||||||
|
// directions are distinguished by diff shape alone, and each says its own word.
|
||||||
|
let collapsed = LaneLayoutMath.isCollapsed(newLane)
|
||||||
|
if LaneLayoutMath.isCollapsed(oldLane) != collapsed {
|
||||||
|
let verb = collapsed ? "Collapse" : "Expand"
|
||||||
|
events.append(Event(
|
||||||
|
kind: collapsed ? .collapseLane : .expandLane,
|
||||||
|
subject: "\(verb) lane \(quotedSubject(new.displayTitle))",
|
||||||
|
bullet: "\(verb) lane \(quoted(new.displayTitle))",
|
||||||
|
paths: paths
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
events += metadataEvents(
|
events += metadataEvents(
|
||||||
previous: old.document,
|
previous: old.document,
|
||||||
@@ -1166,6 +1183,9 @@ enum ChangeNarrator {
|
|||||||
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
|
case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate
|
||||||
|
|
||||||
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
|
case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
|
||||||
|
/// The lane fold's two directions (03-board-ui.md § Lane ▸ Collapsed lanes) — two kinds for
|
||||||
|
/// the trash pair's reason: they are told apart by diff shape alone and each says its own word.
|
||||||
|
case collapseLane, expandLane
|
||||||
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
|
case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes
|
||||||
|
|
||||||
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
|
case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard
|
||||||
@@ -1235,6 +1255,8 @@ enum ChangeNarrator {
|
|||||||
case .editLane: return "Edit \(count) lanes"
|
case .editLane: return "Edit \(count) lanes"
|
||||||
case .restyleLane: return "Restyle \(count) lanes"
|
case .restyleLane: return "Restyle \(count) lanes"
|
||||||
case .resizeLane: return "Resize \(count) lanes"
|
case .resizeLane: return "Resize \(count) lanes"
|
||||||
|
case .collapseLane: return "Collapse \(count) lanes"
|
||||||
|
case .expandLane: return "Expand \(count) lanes"
|
||||||
case .relabelLane: return "Relabel \(count) lanes"
|
case .relabelLane: return "Relabel \(count) lanes"
|
||||||
case .assignLane: return "Assign \(count) lanes"
|
case .assignLane: return "Assign \(count) lanes"
|
||||||
case .dueLane: return "Set due date on \(count) lanes"
|
case .dueLane: return "Set due date on \(count) lanes"
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public enum HistoryPhrase {
|
|||||||
case rename = "Rename"
|
case rename = "Rename"
|
||||||
case restyle = "Restyle"
|
case restyle = "Restyle"
|
||||||
case resize = "Resize"
|
case resize = "Resize"
|
||||||
|
/// A lane folded to its slim strip (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
///
|
||||||
|
/// **Two verbs rather than one**, unlike `resize`, which covers growing and shrinking alike:
|
||||||
|
/// the row has to read back the gesture it undoes, and "Undo Resize Lane" after *Collapse
|
||||||
|
/// Lane* would name a control the user never touched. The trash pair's Delete/Restore split is
|
||||||
|
/// the precedent one level up.
|
||||||
|
case collapse = "Collapse"
|
||||||
|
case expand = "Expand"
|
||||||
/// An Edit session's body save — one step at the Edit→Preview flip (13 ▸ Rules).
|
/// An Edit session's body save — one step at the Edit→Preview flip (13 ▸ Rules).
|
||||||
case edit = "Edit"
|
case edit = "Edit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
/// `width` — the lane resize. `nil` is the one-unit default, whose key the write removes.
|
/// `width` — the lane resize. `nil` is the one-unit default, whose key the write removes.
|
||||||
case width(Int?)
|
case width(Int?)
|
||||||
|
|
||||||
|
/// `collapsed` — the lane fold (03-board-ui.md § Lane ▸ Collapsed lanes). `nil` is the expanded
|
||||||
|
/// default, whose key the write removes, so the pair of after-values a collapse and its inverse
|
||||||
|
/// declare is exactly `true` and `nil` — the app never writes `false`
|
||||||
|
/// (`FrontmatterKeys.collapsed`), and a hand-written one therefore stales the step, which is the
|
||||||
|
/// same reading a malformed value gets: somebody else edited the key.
|
||||||
|
case collapsed(Bool?)
|
||||||
|
|
||||||
/// `background` — the styling gesture's colour dimension.
|
/// `background` — the styling gesture's colour dimension.
|
||||||
case background(String?)
|
case background(String?)
|
||||||
|
|
||||||
@@ -59,6 +66,7 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
case .title: .title
|
case .title: .title
|
||||||
case .order: .order
|
case .order: .order
|
||||||
case .width: .width
|
case .width: .width
|
||||||
|
case .collapsed: .collapsed
|
||||||
case .background: .background
|
case .background: .background
|
||||||
case .backgroundImage: .backgroundImage
|
case .backgroundImage: .backgroundImage
|
||||||
case .icon: .icon
|
case .icon: .icon
|
||||||
@@ -73,6 +81,7 @@ public enum ExpectedField: Sendable, Equatable {
|
|||||||
case title
|
case title
|
||||||
case order
|
case order
|
||||||
case width
|
case width
|
||||||
|
case collapsed
|
||||||
case background
|
case background
|
||||||
case backgroundImage
|
case backgroundImage
|
||||||
case icon
|
case icon
|
||||||
@@ -336,6 +345,7 @@ public enum HistoryStaleness {
|
|||||||
case let .title(expected): equal(document.title, expected)
|
case let .title(expected): equal(document.title, expected)
|
||||||
case let .order(expected): document.order.value == expected
|
case let .order(expected): document.order.value == expected
|
||||||
case let .width(expected): equal(document.width, expected)
|
case let .width(expected): equal(document.width, expected)
|
||||||
|
case let .collapsed(expected): equal(document.collapsed, expected)
|
||||||
case let .background(expected): equal(document.background, expected)
|
case let .background(expected): equal(document.background, expected)
|
||||||
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
||||||
case let .icon(expected): equal(document.icon, expected)
|
case let .icon(expected): equal(document.icon, expected)
|
||||||
|
|||||||
@@ -897,6 +897,14 @@ public final class BannerCenter {
|
|||||||
"Couldn't generate this board's background"
|
"Couldn't generate this board's background"
|
||||||
case let .resize(title):
|
case let .resize(title):
|
||||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||||
|
case let .collapse(title):
|
||||||
|
// **The menu row's own word, in both directions** (03-board-ui.md § Lane ▸ Collapsed
|
||||||
|
// lanes): the user pressed Collapse Lane or Expand Lane, and a shared sentence would tell
|
||||||
|
// half of them about a gesture they did not make. It says "lane" rather than "the item"
|
||||||
|
// when untitled, which is the one place in this switch that can: only a lane has the key.
|
||||||
|
if let title { "Couldn't collapse '\(title)'" } else { "Couldn't collapse the lane" }
|
||||||
|
case let .expand(title):
|
||||||
|
if let title { "Couldn't expand '\(title)'" } else { "Couldn't expand the lane" }
|
||||||
case let .rename(title):
|
case let .rename(title):
|
||||||
// The title here is the item's name *before* the edit — the one the user is still
|
// The title here is the item's name *before* the edit — the one the user is still
|
||||||
// looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
|
// looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
|
||||||
|
|||||||
@@ -1590,6 +1590,118 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lane collapse
|
||||||
|
|
||||||
|
/// Folds a lane to its slim strip, or unfolds it — the one commit point every collapse gesture
|
||||||
|
/// shares (03-board-ui.md § Lane ▸ Collapsed lanes): the header chevron, the context menu's
|
||||||
|
/// Collapse/Expand Lane row, and a click on the collapsed strip's body all land here.
|
||||||
|
///
|
||||||
|
/// **`width`'s write, one key over, deliberately.** Collapse is *document* state exactly as width
|
||||||
|
/// is — the files are the board, so an agent folds a lane by writing `collapsed: true` and the app
|
||||||
|
/// follows on the next reload — so this mirrors `writeLaneWidths` clause for clause: the same
|
||||||
|
/// `updateIndex` bracket, the same stamp behaviour (a collapse rewrites content, so `modified` is
|
||||||
|
/// stamped and `modified-by` cleared — `WriteOperation.rewritesOrderOnly` is `false` for both new
|
||||||
|
/// cases), the same three do-nothing paths, and the same remove-at-default rule.
|
||||||
|
///
|
||||||
|
/// **Expanding removes the key; nothing ever writes `false`** (`FrontmatterKeys.collapsed` — the
|
||||||
|
/// remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None
|
||||||
|
/// well's `background`). A hand-written `collapsed: false` reads as expanded, so asking to expand
|
||||||
|
/// such a lane is an unchanged value and writes nothing at all — the legal hand edit survives
|
||||||
|
/// until the app itself next edits the key, which is `width: 1`'s rule verbatim.
|
||||||
|
///
|
||||||
|
/// **The lane's `width` is untouched**, which is the whole of "expanding restores the lane the user
|
||||||
|
/// had": one key is written, the other is not read.
|
||||||
|
///
|
||||||
|
/// Three ways this does nothing, all `setLaneWidth`'s: an id that is not in the snapshot is ignored
|
||||||
|
/// (the lane vanished under the gesture), a value the lane already reads as writes nothing (a
|
||||||
|
/// double-collapse must not stamp `modified`), and a refusal is the banner's — `performWrite` posts
|
||||||
|
/// every `BoardWriteError` before it rethrows, so the rethrow is swallowed and the lane stays as it
|
||||||
|
/// was, which is the truth.
|
||||||
|
///
|
||||||
|
/// **A card selection inside the lane becomes the lane** (this milestone's call, one of the two
|
||||||
|
/// selection rules a fold needs): the cards stop being rendered, so a selection naming them would
|
||||||
|
/// leave the arrows with no frame to step from and the ⌫ target invisible. Re-selecting the lane is
|
||||||
|
/// the placeholder commit's own move ("Return commits and re-selects the lane") applied to the
|
||||||
|
/// gesture that takes the cards away, and it leaves the strip itself as the selected thing — which
|
||||||
|
/// is what the user just acted on. A *lane* selection is untouched, and expanding never touches the
|
||||||
|
/// selection at all.
|
||||||
|
///
|
||||||
|
/// **Returns whether bytes reached disk**, `setLaneWidth`'s answer for its reason; discardable
|
||||||
|
/// because every caller here is a menu row or a click with nothing to do with it.
|
||||||
|
@discardableResult
|
||||||
|
public func setLaneCollapsed(_ id: ItemID, collapsed: Bool) -> Bool {
|
||||||
|
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
|
||||||
|
LaneLayoutMath.isCollapsed(lane) != collapsed
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let folder = rootURL.appendingPathComponent(id.rawValue)
|
||||||
|
let prior = lane.collapsed
|
||||||
|
let title = lane.title.value
|
||||||
|
|
||||||
|
// The closure's signature is spelled out for `writeLaneWidths`' reason — `try?` widens an
|
||||||
|
// inferred typed `throws` to `any Error`, which `performWrite` will not take.
|
||||||
|
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||||
|
try Self.setCollapsed(collapsed, at: folder)
|
||||||
|
}
|
||||||
|
guard landed != nil else { return false }
|
||||||
|
|
||||||
|
// collapse/expand → the prior reading (13-native-undo.md ▸ Rules). The validated field is
|
||||||
|
// `collapsed`, read as the app reads it: `nil` is the absent key, which is what an expand
|
||||||
|
// leaves and what the inverse of a collapse restores whenever the prior was anything but a
|
||||||
|
// real `true` — a missing key, a hand-written `false`, and a malformed value all inverse to
|
||||||
|
// "remove it", exactly as `restoreCollapsed` writes them.
|
||||||
|
registerStep(
|
||||||
|
HistoryPhrase.name(collapsed ? .collapse : .expand, kind: .lane),
|
||||||
|
subject: title,
|
||||||
|
undoExpects: [.present(folder, .collapsed(collapsed ? true : nil))],
|
||||||
|
redoExpects: [.present(folder, .collapsed(prior.value == true ? true : nil))]
|
||||||
|
) { _ in
|
||||||
|
try BoardWriter.updateIndex(
|
||||||
|
inItemFolder: folder,
|
||||||
|
operation: prior.value == true ? .collapse(title: nil) : .expand(title: nil)
|
||||||
|
) { document in
|
||||||
|
Self.restoreCollapsed(prior, in: &document)
|
||||||
|
}
|
||||||
|
} redo: { _ in
|
||||||
|
try Self.setCollapsed(collapsed, at: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if collapsed, selection.container == .board,
|
||||||
|
lane.cards.contains(where: { selection.ids.contains($0.id) }) {
|
||||||
|
select([id], in: .board, anchor: id, head: id)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The collapse write itself, spelled once so the gesture and its redo cannot drift apart on the
|
||||||
|
/// remove-at-default rule — `setWidth`'s shape, and the operation follows the *direction* so a
|
||||||
|
/// failure is announced in the word the user pressed. The title rides along from `updateIndex`'s
|
||||||
|
/// own enrichment, as it does for every other operation that carries one.
|
||||||
|
private static func setCollapsed(_ collapsed: Bool, at folder: URL) throws(BoardWriteError) {
|
||||||
|
let operation: WriteOperation = collapsed ? .collapse(title: nil) : .expand(title: nil)
|
||||||
|
try BoardWriter.updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||||
|
if collapsed {
|
||||||
|
document.set(FrontmatterKeys.collapsed, to: .bool(true))
|
||||||
|
} else {
|
||||||
|
document.remove(FrontmatterKeys.collapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse's write: the key back as a real `true`, or gone.
|
||||||
|
///
|
||||||
|
/// **A malformed or `false` prior inverses to the removed key**, never to the bytes that were
|
||||||
|
/// there: this app writes `collapsed` in exactly one shape, and reproducing somebody's
|
||||||
|
/// `collapsed: maybe` would be the undo inventing a value. Either way the reading is restored
|
||||||
|
/// exactly — both of those read as expanded — which is what an inverse owes the user.
|
||||||
|
private static func restoreCollapsed(_ prior: FieldValue<Bool>, in document: inout FrontmatterDocument) {
|
||||||
|
if prior.value == true {
|
||||||
|
document.set(FrontmatterKeys.collapsed, to: .bool(true))
|
||||||
|
} else {
|
||||||
|
document.remove(FrontmatterKeys.collapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Styling
|
// MARK: - Styling
|
||||||
|
|
||||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
|
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ enum AgentGuide {
|
|||||||
/// deliberately git-friendly, a board may live in a repository of the user's own, and there an
|
/// deliberately git-friendly, a board may live in a repository of the user's own, and there an
|
||||||
/// agent stages only its own paths, commits its own changes with clear messages, and leaves the
|
/// agent stages only its own paths, commits its own changes with clear messages, and leaves the
|
||||||
/// app-maintained files to the app. The stamping section drops its auto-commit clause the same
|
/// app-maintained files to the app. The stamping section drops its auto-commit clause the same
|
||||||
/// way.
|
/// way. **v12 names the lane's `collapsed` key** (03-board-ui.md § Lane ▸ Collapsed lanes): a lane
|
||||||
static let version = 11
|
/// folded to a slim strip is document state exactly like `width`, so an agent can fold and unfold
|
||||||
|
/// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why
|
||||||
|
/// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the
|
||||||
|
/// remove-to-expand rule stated because writing `false` is the mistake the key invites.
|
||||||
|
static let version = 12
|
||||||
|
|
||||||
// MARK: - The version marker
|
// MARK: - The version marker
|
||||||
|
|
||||||
@@ -399,7 +403,11 @@ enum AgentGuide {
|
|||||||
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
|
`iconColor` (color, tints `icon`). Lanes and cards may set `order` (a number;
|
||||||
floats are fine) — **optional, and the way to control position**: an item
|
floats are fine) — **optional, and the way to control position**: an item
|
||||||
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
|
without one goes last. Lanes may set `width` (integer ≥ 1, multiplier of the
|
||||||
standard lane width).
|
standard lane width) and `collapsed` (`true` folds the lane to a slim strip
|
||||||
|
in the app; its cards are still there, just not drawn). **To expand a lane,
|
||||||
|
remove the `collapsed` key** rather than writing `collapsed: false` — an
|
||||||
|
absent key is the default, and the app removes it too. A lane's `width`
|
||||||
|
rides along untouched while it is folded.
|
||||||
|
|
||||||
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||||||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||||||
|
|||||||
@@ -1052,6 +1052,7 @@ public enum BoardLoader: Sendable {
|
|||||||
iconColor: document.iconColor,
|
iconColor: document.iconColor,
|
||||||
order: order,
|
order: order,
|
||||||
width: document.width,
|
width: document.width,
|
||||||
|
collapsed: document.collapsed,
|
||||||
cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
|
cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) },
|
||||||
document: document
|
document: document
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -204,6 +204,15 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
|||||||
/// failing the load (01-storage-format.md § Frontmatter).
|
/// failing the load (01-storage-format.md § Frontmatter).
|
||||||
public let width: FieldValue<Int>
|
public let width: FieldValue<Int>
|
||||||
|
|
||||||
|
/// **Folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — `width`'s sibling in
|
||||||
|
/// every respect that matters here: lenient (a value with no boolean reading renders as expanded,
|
||||||
|
/// bytes untouched), document state rather than window state, and preserved verbatim beside it —
|
||||||
|
/// a collapsed lane keeps its `width` so expanding restores the lane the user had.
|
||||||
|
///
|
||||||
|
/// The *reading* is `LaneLayoutMath.isCollapsed(_:)`, which is where the rest of the app asks;
|
||||||
|
/// this is the field, and it carries the shape so the coerce tier can report on it.
|
||||||
|
public let collapsed: FieldValue<Bool>
|
||||||
|
|
||||||
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||||
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
|
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
|
||||||
/// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
|
/// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
|
||||||
|
|||||||
@@ -2969,6 +2969,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
/// nobody could confuse for another.
|
/// nobody could confuse for another.
|
||||||
case setBoardBackground
|
case setBoardBackground
|
||||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||||
|
|
||||||
|
/// **A lane folded to its slim strip** — the `collapsed: true` write (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes; `BoardStore.setLaneCollapsed`).
|
||||||
|
///
|
||||||
|
/// **Its own case rather than a fold into `.resize`**, on the vocabulary's standing reasoning: the
|
||||||
|
/// two keys sit side by side in a lane's frontmatter and both change how wide the lane draws, but
|
||||||
|
/// the user pressed *Collapse Lane* — and a banner telling them the app "couldn't resize 'Todo'"
|
||||||
|
/// would name a gesture they never made and a control (the stepper, the edge drag) they never
|
||||||
|
/// touched.
|
||||||
|
///
|
||||||
|
/// **And its own case rather than one shared with `.expand`**, which is `.delete`/`.restore`'s
|
||||||
|
/// split one rung down: the two directions are two menu rows with two words, and neither
|
||||||
|
/// direction's failure may be announced in the other's verb.
|
||||||
|
case collapse(title: String?)
|
||||||
|
|
||||||
|
/// **A collapsed lane unfolded** — the write that *removes* `collapsed` (the remove-at-default
|
||||||
|
/// family; `FrontmatterKeys.collapsed`). See `.collapse` for why the pair is two cases.
|
||||||
|
case expand(title: String?)
|
||||||
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
||||||
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
|
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
|
||||||
/// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
|
/// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
|
||||||
@@ -3223,6 +3241,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .migrateTombstone: .migrateTombstone(title: title)
|
case .migrateTombstone: .migrateTombstone(title: title)
|
||||||
case .style: .style(title: title)
|
case .style: .style(title: title)
|
||||||
case .resize: .resize(title: title)
|
case .resize: .resize(title: title)
|
||||||
|
case .collapse: .collapse(title: title)
|
||||||
|
case .expand: .expand(title: title)
|
||||||
case .rename: .rename(title: title)
|
case .rename: .rename(title: title)
|
||||||
case .duplicateBoard: .duplicateBoard(title: title)
|
case .duplicateBoard: .duplicateBoard(title: title)
|
||||||
case .saveAsTemplate: .saveAsTemplate(title: title)
|
case .saveAsTemplate: .saveAsTemplate(title: title)
|
||||||
@@ -3269,7 +3289,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
||||||
// plain container reason its board-level twin does.
|
// plain container reason its board-level twin does.
|
||||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .paste,
|
||||||
|
.importAttachment,
|
||||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||||
.displaceClaimedName,
|
.displaceClaimedName,
|
||||||
@@ -3300,6 +3321,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case let .style(title): Self.phrase("style", title)
|
case let .style(title): Self.phrase("style", title)
|
||||||
case .setBoardBackground: "set this board's background"
|
case .setBoardBackground: "set this board's background"
|
||||||
case let .resize(title): Self.phrase("resize", title)
|
case let .resize(title): Self.phrase("resize", title)
|
||||||
|
case let .collapse(title): Self.phrase("collapse", title)
|
||||||
|
case let .expand(title): Self.phrase("expand", title)
|
||||||
case let .rename(title): Self.phrase("rename", title)
|
case let .rename(title): Self.phrase("rename", title)
|
||||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||||
case let .saveAsTemplate(title): Self.phrase("save as template", title)
|
case let .saveAsTemplate(title): Self.phrase("save as template", title)
|
||||||
|
|||||||
@@ -560,6 +560,20 @@ public enum FrontmatterKeys {
|
|||||||
public static let title = "title"
|
public static let title = "title"
|
||||||
public static let order = "order"
|
public static let order = "order"
|
||||||
public static let width = "width"
|
public static let width = "width"
|
||||||
|
|
||||||
|
/// **A lane folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes; 01-storage-format.md
|
||||||
|
/// § Frontmatter's lane table) — `collapsed: true`, and nothing else.
|
||||||
|
///
|
||||||
|
/// Document state exactly like `width`, deliberately rather than window state: the files *are* the
|
||||||
|
/// board, so an agent folds a lane away by writing one key and the app follows on the next reload.
|
||||||
|
///
|
||||||
|
/// **Only ever written as `true`, and removed to expand** (the remove-at-default family beside the
|
||||||
|
/// empty rename's `title`, the None well's `background`, and a one-unit `width`): an absent key is
|
||||||
|
/// the default reading, so a `collapsed: false` the app wrote would be noise a default lane has to
|
||||||
|
/// carry. A hand-written `false` is legal, reads as expanded, and is preserved until the app itself
|
||||||
|
/// next edits the key.
|
||||||
|
public static let collapsed = "collapsed"
|
||||||
|
|
||||||
public static let created = "created"
|
public static let created = "created"
|
||||||
public static let modified = "modified"
|
public static let modified = "modified"
|
||||||
public static let modifiedBy = "modified-by"
|
public static let modifiedBy = "modified-by"
|
||||||
@@ -619,7 +633,7 @@ public enum FrontmatterKeys {
|
|||||||
public static let author = "author"
|
public static let author = "author"
|
||||||
|
|
||||||
public static let schemaOwned: Set<String> = [
|
public static let schemaOwned: Set<String> = [
|
||||||
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background,
|
||||||
iconColor, kind,
|
icon, iconColor, kind,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ extension FrontmatterDocument {
|
|||||||
}
|
}
|
||||||
record(FrontmatterKeys.title, title)
|
record(FrontmatterKeys.title, title)
|
||||||
record(FrontmatterKeys.width, width)
|
record(FrontmatterKeys.width, width)
|
||||||
|
record(FrontmatterKeys.collapsed, collapsed)
|
||||||
record(FrontmatterKeys.created, created)
|
record(FrontmatterKeys.created, created)
|
||||||
record(FrontmatterKeys.modified, modified)
|
record(FrontmatterKeys.modified, modified)
|
||||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||||
@@ -215,6 +216,31 @@ extension FrontmatterDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Whether the lane is folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) —
|
||||||
|
/// `width`'s reading one type over, and lenient in exactly its way.
|
||||||
|
///
|
||||||
|
/// A `true`/`false` scalar reads as itself, and so does any of YAML 1.1's other boolean spellings
|
||||||
|
/// (`yes`/`no`, `on`/`off`, `y`/`n`) written **quoted**: unquoted, the parser has already resolved
|
||||||
|
/// them to `.bool` for us, and a quoted one is the same word the author meant — the numeric string
|
||||||
|
/// `width: "2"` coercing to `2` is the precedent, and it is the one thing lenient reading means.
|
||||||
|
/// Everything else — a number, a sequence, a mapping, `collapsed: maybe` — has no boolean reading
|
||||||
|
/// at all and is malformed, **which renders as expanded**: the default is the absent key's, so an
|
||||||
|
/// unreadable value can only ever fold a lane by accident, and it never does.
|
||||||
|
///
|
||||||
|
/// Read-side only, like every other coercion here: the bytes stay exactly as the author wrote them
|
||||||
|
/// until the user (or an agent) changes the key, and `collapsed: false` is a perfectly good way to
|
||||||
|
/// write "expanded" by hand even though the app removes the key instead
|
||||||
|
/// (`FrontmatterKeys.collapsed`).
|
||||||
|
public var collapsed: FieldValue<Bool> {
|
||||||
|
read(FrontmatterKeys.collapsed) { value, _ in
|
||||||
|
switch value {
|
||||||
|
case let .bool(value): value
|
||||||
|
case let .string(text): Self.boolWord(text)
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||||
@@ -300,6 +326,17 @@ extension FrontmatterDocument {
|
|||||||
return value >= 1 ? Int(value) : 1
|
return value >= 1 ? Int(value) : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// YAML 1.1's boolean words, for a **quoted** scalar that spells one — the coercion `collapsed`
|
||||||
|
/// needs and no other field wants. Case-insensitive, because the grammar itself is (`True`, `ON`,
|
||||||
|
/// `Yes` are all booleans unquoted); anything else has no boolean reading.
|
||||||
|
private static func boolWord(_ text: String) -> Bool? {
|
||||||
|
switch text.lowercased() {
|
||||||
|
case "true", "yes", "on", "y": true
|
||||||
|
case "false", "no", "off", "n": false
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar,
|
/// A quoted timestamp reads the same as an unquoted one — same YAML 1.1 timestamp grammar,
|
||||||
/// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible
|
/// a superset of ISO-8601. Anything that isn't a valid timestamp either way has no sensible
|
||||||
/// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a
|
/// date reading and is malformed; per the tombstone rule (`Lane`/`Card.isDeleted`), a
|
||||||
|
|||||||
@@ -87,8 +87,17 @@ enum AccessibilityPhrases {
|
|||||||
/// search filter like the visible badge", so `LaneView` hands the very collection its badge
|
/// search filter like the visible badge", so `LaneView` hands the very collection its badge
|
||||||
/// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with
|
/// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with
|
||||||
/// the masonry.
|
/// the masonry.
|
||||||
static func laneLabel(title: String?, cards count: Int) -> String {
|
/// **A folded lane says so** (03-board-ui.md § Lane ▸ Collapsed lanes; 10-accessibility.md's
|
||||||
"\(displayTitle(title)), lane, \(cardCount(count))"
|
/// never-colour-alone family, read one rung out: a state a sighted user reads off the strip's
|
||||||
|
/// shape has to be a word for everyone else). It sits between the level and the count, which is
|
||||||
|
/// where the container's own qualifier belongs — "Doing, lane, collapsed, 5 cards" — and the count
|
||||||
|
/// stays the lane's *held* cards, since a collapsed lane renders none and "0 cards" would say the
|
||||||
|
/// lane was empty when it is merely shut.
|
||||||
|
///
|
||||||
|
/// Defaulted, so every caller that has no fold to report reads exactly as it did before.
|
||||||
|
static func laneLabel(title: String?, cards count: Int, collapsed: Bool = false) -> String {
|
||||||
|
let state = collapsed ? "collapsed, " : ""
|
||||||
|
return "\(displayTitle(title)), lane, \(state)\(cardCount(count))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lane header's new-card button — "New card in ⟨lane⟩", the one labeled child
|
/// The lane header's new-card button — "New card in ⟨lane⟩", the one labeled child
|
||||||
@@ -97,6 +106,14 @@ enum AccessibilityPhrases {
|
|||||||
"New card in \(displayTitle(title))"
|
"New card in \(displayTitle(title))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The lane header's **collapse chevron** — "Collapse ⟨lane⟩" (03-board-ui.md § Lane ▸ Collapsed
|
||||||
|
/// lanes), the header's second labeled child and the new-card button's twin in every respect: a
|
||||||
|
/// glyph-only control needs a word, and the word names the lane so the label stands on its own out
|
||||||
|
/// of context.
|
||||||
|
static func collapseLaneLabel(lane title: String?) -> String {
|
||||||
|
"Collapse \(displayTitle(title))"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cards
|
// MARK: - Cards
|
||||||
|
|
||||||
/// A card's label: its title, or the untitled placeholder. Named rather than inlined so the
|
/// A card's label: its title, or the untitled placeholder. Named rather than inlined so the
|
||||||
|
|||||||
@@ -308,6 +308,23 @@ struct BoardDropContext {
|
|||||||
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
||||||
let standard: @MainActor () -> CGFloat
|
let standard: @MainActor () -> CGFloat
|
||||||
|
|
||||||
|
/// A collapsed lane's fixed strip width at this board's current zoom (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes) — the one lane width that is not a multiple of `standard`, so every zone list
|
||||||
|
/// and every hit test below needs it to place the lanes at all.
|
||||||
|
///
|
||||||
|
/// Read off the registry rather than captured, for `bodyPointSize`'s own reason: these delegates run
|
||||||
|
/// at event time, outside any body evaluation, and the level is a property of the window.
|
||||||
|
var collapsedWidth: CGFloat {
|
||||||
|
BoardMetrics.collapsedLaneWidth(bodyPointSize: registry.bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lanes' **drawn** widths in board order — a slot width per expanded lane, the fixed strip per
|
||||||
|
/// folded one. The one geometry every strip-level answer here is built from.
|
||||||
|
func drawnWidths(of lanes: [Lane]) -> [CGFloat] {
|
||||||
|
LaneLayoutMath.drawnWidths(
|
||||||
|
of: lanes, standard: standard(), gap: gap, collapsedWidth: collapsedWidth)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether two of these would send a drop to the same place — **the whole of what this value
|
/// Whether two of these would send a drop to the same place — **the whole of what this value
|
||||||
/// contributes to `LaneView.==` and `CardFaceView.==`.**
|
/// contributes to `LaneView.==` and `CardFaceView.==`.**
|
||||||
///
|
///
|
||||||
@@ -400,17 +417,22 @@ struct BoardDropContext {
|
|||||||
guard let cursor = stripCursor() else { return }
|
guard let cursor = stripCursor() else { return }
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
// **Drawn widths, not unit counts**: a folded lane occupies one narrow zone on the strip rather
|
||||||
|
// than the slot its preserved `width` would buy, and a folded lane being dragged contributes its
|
||||||
|
// strip to the run's span cap (03-board-ui.md § Lane ▸ Collapsed lanes). Both readings come off
|
||||||
|
// the one function the strip itself lays out with, so the zones cannot disagree with the picture.
|
||||||
|
let restingWidths = drawnWidths(of: resting)
|
||||||
let slot = DropSlotMath.laneSlot(
|
let slot = DropSlotMath.laneSlot(
|
||||||
cursorX: cursor.x,
|
cursorX: cursor.x,
|
||||||
restingUnits: restingUnits,
|
restingWidths: restingWidths,
|
||||||
draggedUnits: session.laneUnits,
|
draggedWidths: LaneLayoutMath.drawnWidths(
|
||||||
standard: standard(),
|
units: session.laneUnits, collapsed: session.laneCollapsed,
|
||||||
|
standard: standard(), gap: gap, collapsedWidth: collapsedWidth),
|
||||||
gap: gap,
|
gap: gap,
|
||||||
current: session.stripProposal(onBoardRooted: store.rootKey)
|
current: session.stripProposal(onBoardRooted: store.rootKey)
|
||||||
)
|
)
|
||||||
guard let slot else { return } // a dead region: hold the current proposal
|
guard let slot else { return } // a dead region: hold the current proposal
|
||||||
let index = min(max(0, slot), restingUnits.count)
|
let index = min(max(0, slot), restingWidths.count)
|
||||||
session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index))
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .strip, index: index))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +465,26 @@ struct BoardDropContext {
|
|||||||
// by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
|
// by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
|
||||||
// leave the same address behind.
|
// leave the same address behind.
|
||||||
session.noteRetarget(.lane(laneID), registry: registry)
|
session.noteRetarget(.lane(laneID), registry: registry)
|
||||||
|
// **A folded lane is one zone, and the zone means the end of the lane** (03-board-ui.md § Lane ▸
|
||||||
|
// Collapsed lanes: "dropping a drag onto the collapsed strip appends the payload at the lane's
|
||||||
|
// END, like an end-of-lane drop"). It is answered *before* the cursor is read, deliberately, and
|
||||||
|
// the answer says why: a strip draws no cards, so there is no row geometry for a position to be
|
||||||
|
// resolved against and nothing about the pointer's y can change the reading. The strip's own
|
||||||
|
// accent edge is the feedback the missing shadow would have been (`LaneView.collapsedDropStroke`).
|
||||||
|
//
|
||||||
|
// The index is counted in the lane's **resting** layout — its cards with the dragged run lifted
|
||||||
|
// out — because that is the space every proposal in this app is counted in and the space
|
||||||
|
// `BoardStore.moveCards` resolves in.
|
||||||
|
//
|
||||||
|
// No hover-to-auto-expand in v1 (noted as future work): the drop lands, the lane stays folded,
|
||||||
|
// and the badge on its strip goes up by one when the echo reload arrives.
|
||||||
|
if let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
||||||
|
LaneLayoutMath.isCollapsed(lane) {
|
||||||
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
|
let count = lane.cards.count { !hidden.contains($0.id) }
|
||||||
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: count))
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let cursor = globalCursor() else { return }
|
guard let cursor = globalCursor() else { return }
|
||||||
guard let resting = session.restingLayouts.layout(
|
guard let resting = session.restingLayouts.layout(
|
||||||
inLane: laneID,
|
inLane: laneID,
|
||||||
@@ -477,12 +519,8 @@ struct BoardDropContext {
|
|||||||
func retargetCardsFromStrip() {
|
func retargetCardsFromStrip() {
|
||||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||||
let lanes = store.snapshot.lanes
|
let lanes = store.snapshot.lanes
|
||||||
let index = LaneLayoutMath.laneIndex(
|
// Drawn widths, so a folded lane's narrow zone is where it actually is — see `retargetLanes`.
|
||||||
atX: cursor.x,
|
let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
|
||||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
|
||||||
standard: standard(),
|
|
||||||
gap: gap
|
|
||||||
)
|
|
||||||
guard let index, lanes.indices.contains(index) else { return }
|
guard let index, lanes.indices.contains(index) else { return }
|
||||||
retargetCards(inLane: lanes[index].id)
|
retargetCards(inLane: lanes[index].id)
|
||||||
}
|
}
|
||||||
@@ -664,13 +702,28 @@ struct BoardDropContext {
|
|||||||
/// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the
|
/// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the
|
||||||
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
/// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it.
|
||||||
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
guard acceptsFileDrop(info),
|
||||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID }),
|
let lane = store.snapshot.lanes.first(where: { $0.id == laneID })
|
||||||
let grid = registry.grids[laneID]
|
|
||||||
else {
|
else {
|
||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// **A folded lane is one zone, and it creates at the lane's end** — `retargetCards`' collapsed
|
||||||
|
// branch, in the file mode's vocabulary and for its reasons: no card faces means no attach
|
||||||
|
// target and no row geometry, so neither of `FileDropZones.landing`'s first two answers can
|
||||||
|
// apply and the third has nothing to resolve against. The count is the files', as always.
|
||||||
|
if LaneLayoutMath.isCollapsed(lane) {
|
||||||
|
session.proposeFile(FileDropTarget(
|
||||||
|
boardRoot: store.rootKey,
|
||||||
|
landing: .create(laneID: laneID, index: lane.cards.count),
|
||||||
|
fileCount: FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let cursor = globalCursor(), let grid = registry.grids[laneID] else {
|
||||||
|
session.proposeFile(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let rendered = lane.cards
|
let rendered = lane.cards
|
||||||
let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
|
let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight }
|
||||||
@@ -720,12 +773,7 @@ struct BoardDropContext {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let lanes = store.snapshot.lanes
|
let lanes = store.snapshot.lanes
|
||||||
let index = LaneLayoutMath.laneIndex(
|
let index = LaneLayoutMath.laneIndex(atX: cursor.x, widths: drawnWidths(of: lanes), gap: gap)
|
||||||
atX: cursor.x,
|
|
||||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
|
||||||
standard: standard(),
|
|
||||||
gap: gap
|
|
||||||
)
|
|
||||||
guard let index, lanes.indices.contains(index) else {
|
guard let index, lanes.indices.contains(index) else {
|
||||||
session.proposeFile(nil)
|
session.proposeFile(nil)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ enum BoardMetrics {
|
|||||||
em(0.3, bodyPointSize: bodyPointSize)
|
em(0.3, bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **A collapsed lane's slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the one lane
|
||||||
|
/// width in the app that is *not* the window's division: a collapsed lane takes this and nothing
|
||||||
|
/// more, and `LaneLayoutMath.standardWidth` divides what is left among the expanded lanes.
|
||||||
|
///
|
||||||
|
/// 3.4 em — 44pt at the standard 13pt body, which is the figure the ruling asked for (roughly
|
||||||
|
/// 40–48pt) and the same multiple `nominalCardHeight` uses, so the strip is about as wide as a
|
||||||
|
/// default card is tall. Wide enough for the lane's glyph, a rotated title and the count badge
|
||||||
|
/// stacked in a column; narrow enough that a folded lane reads as a spine rather than as a lane.
|
||||||
|
///
|
||||||
|
/// **Font-derived like everything else here**, and it has to be: the strip's whole content is
|
||||||
|
/// type and a badge, so a figure fixed at 44pt would clip its own glyph at a large system text
|
||||||
|
/// size, and 10-accessibility.md's full-relative-scaling rule would quietly stop holding. It moves
|
||||||
|
/// with the board's zoom for the same reason every other figure does.
|
||||||
|
static func collapsedLaneWidth(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
em(3.4, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's
|
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's
|
||||||
/// height.
|
/// height.
|
||||||
static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat {
|
static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
@@ -110,6 +127,25 @@ enum BoardMetrics {
|
|||||||
em(1.7, bodyPointSize: bodyPointSize)
|
em(1.7, bodyPointSize: bodyPointSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The room the header reserves for the **collapse chevron** beside the new-card button
|
||||||
|
/// (03-board-ui.md § Lane ▸ Collapsed lanes) — `newCardButtonReserve`'s twin, and it exists for
|
||||||
|
/// exactly that figure's reason: two glyphs now share the header's trailing edge, and a title has
|
||||||
|
/// to truncate before it reaches either.
|
||||||
|
///
|
||||||
|
/// 1.4 em — 18pt at the standard 13pt body: the chevron is a smaller glyph than the plus and sits
|
||||||
|
/// closer to it than to the title, so it asks for less breathing room than the button it precedes.
|
||||||
|
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
em(1.4, bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the lane header's trailing chrome takes — the chevron plus the new-card button. The
|
||||||
|
/// one figure the header pads by, so "a long title truncates before it collides" stays one rule
|
||||||
|
/// however many controls end up living there.
|
||||||
|
static func laneHeaderTrailingReserve(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
newCardButtonReserve(bodyPointSize: bodyPointSize)
|
||||||
|
+ laneCollapseButtonReserve(bodyPointSize: bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
/// The count badge's capsule inset — horizontal and vertical, which are deliberately different:
|
/// The count badge's capsule inset — horizontal and vertical, which are deliberately different:
|
||||||
/// a capsule around a single digit wants width, not height.
|
/// a capsule around a single digit wants width, not height.
|
||||||
static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
|
|||||||
+115
-49
@@ -131,6 +131,15 @@ struct BoardView: View {
|
|||||||
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
|
||||||
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }
|
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }
|
||||||
|
|
||||||
|
/// A collapsed lane's fixed strip width (03-board-ui.md § Lane ▸ Collapsed lanes) — **the one lane
|
||||||
|
/// width on the strip that is not the window's division**. It is taken off the top and the
|
||||||
|
/// remainder is what the expanded lanes divide (`LaneLayoutMath.standardWidth`), so folding a lane
|
||||||
|
/// is a re-divide trigger of the Show/Hide Trash family: the window never moves and the siblings
|
||||||
|
/// grow into what the lane gave up.
|
||||||
|
private var collapsedWidth: CGFloat {
|
||||||
|
BoardMetrics.collapsedLaneWidth(bodyPointSize: zoom.bodyPointSize)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||||||
// between "the gate did not suppress" and "Observation invalidated the lane directly".
|
// between "the gate did not suppress" and "Observation invalidated the lane directly".
|
||||||
@@ -341,12 +350,16 @@ struct BoardView: View {
|
|||||||
// container" survives a right-to-left layout direction or a lane slot the
|
// container" survives a right-to-left layout direction or a lane slot the
|
||||||
// resize session lifts to `zIndex(1)`.
|
// resize session lifts to `zIndex(1)`.
|
||||||
.accessibilitySortPriority(Double(slots.count - index))
|
.accessibilitySortPriority(Double(slots.count - index))
|
||||||
case let .shadow(_, units):
|
case let .shadow(_, units, collapsed):
|
||||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||||
// will occupy — its units measured against *this* strip's standard, which is
|
// will occupy — its units measured against *this* strip's standard, which is
|
||||||
// what makes the drop land precisely where the shadow shows.
|
// what makes the drop land precisely where the shadow shows. **A folded lane's
|
||||||
|
// shadow is the slim strip**: the drop leaves it folded, so its `width` key is not
|
||||||
|
// what the strip is about to draw (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
|
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
|
||||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
.frame(width: LaneLayoutMath.drawnWidth(
|
||||||
|
units: units, isCollapsed: collapsed,
|
||||||
|
standard: standard, gap: spacing, collapsedWidth: collapsedWidth))
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,7 +598,14 @@ struct BoardView: View {
|
|||||||
// the pre-drag layout for a round trip.
|
// the pre-drag layout for a round trip.
|
||||||
let resizing = resize.governs(lane.id)
|
let resizing = resize.governs(lane.id)
|
||||||
let units = resize.displayUnits(of: lane)
|
let units = resize.displayUnits(of: lane)
|
||||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
// **A folded lane takes the slim strip and nothing else** (03-board-ui.md § Lane ▸ Collapsed
|
||||||
|
// lanes). Its `width` still reads — the key is preserved untouched while collapsed, which is how
|
||||||
|
// expanding restores the lane the user had — but the strip is what it draws, and the division
|
||||||
|
// above has already taken this width off the top rather than dividing it.
|
||||||
|
let collapsed = LaneLayoutMath.isCollapsed(lane)
|
||||||
|
let slotWidth = LaneLayoutMath.drawnWidth(
|
||||||
|
units: units, isCollapsed: collapsed,
|
||||||
|
standard: standard, gap: spacing, collapsedWidth: collapsedWidth)
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if resizing {
|
if resizing {
|
||||||
DragShadow(
|
DragShadow(
|
||||||
@@ -617,26 +637,34 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: slotWidth, alignment: .topLeading)
|
.frame(width: slotWidth, alignment: .topLeading)
|
||||||
.zIndex(resizing ? 1 : 0)
|
.zIndex(resizing ? 1 : 0)
|
||||||
|
// **No grab strip on a folded lane** (03-board-ui.md § Lane ▸ Collapsed lanes: "lane resize
|
||||||
|
// handles don't appear on collapsed lanes"). The strip's width is a constant and its `width` key
|
||||||
|
// is outside the division while it is folded, so a drag there would be a gesture with nothing to
|
||||||
|
// move — and it would grow the *window* per snap for a lane that is not drawing units at all.
|
||||||
|
// The width stepper and ⌥⌘→/⌥⌘← stay available and still edit the preserved key, which is what
|
||||||
|
// the lane will be when it unfolds.
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
LaneResizeHandle(
|
if !collapsed {
|
||||||
store: store,
|
LaneResizeHandle(
|
||||||
session: resize,
|
store: store,
|
||||||
laneID: lane.id,
|
session: resize,
|
||||||
committedUnits: units,
|
laneID: lane.id,
|
||||||
standard: standard,
|
committedUnits: units,
|
||||||
gap: spacing,
|
standard: standard,
|
||||||
totalUnits: stripTotalUnits,
|
gap: spacing,
|
||||||
window: window
|
totalUnits: stripTotalUnits,
|
||||||
)
|
window: window
|
||||||
// The read-only lock disables every mutating gesture, not just the menu items
|
)
|
||||||
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
|
// The read-only lock disables every mutating gesture, not just the menu items
|
||||||
// resizes the *window* on the way, so a refused commit would leave the window grown
|
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
|
||||||
// around a lane that snapped back — and the lock's row is already saying why nothing
|
// resizes the *window* on the way, so a refused commit would leave the window grown
|
||||||
// can be written. The focused-editor rule closes it too, like every board command, and
|
// around a lane that snapped back — and the lock's row is already saying why nothing
|
||||||
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
// can be written. The focused-editor rule closes it too, like every board command, and
|
||||||
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
||||||
// one strip layout is not a state this view has a meaning for.
|
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
||||||
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
// one strip layout is not a state this view has a meaning for.
|
||||||
|
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,12 +761,19 @@ struct BoardView: View {
|
|||||||
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
||||||
/// for them or the shadow would be drawn at a width the lane will not have
|
/// for them or the shadow would be drawn at a width the lane will not have
|
||||||
/// (`arrivingLaneUnits`).
|
/// (`arrivingLaneUnits`).
|
||||||
|
/// - a **collapsed** lane's fixed strip, which is the one contribution that is *subtracted* rather
|
||||||
|
/// than divided (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip is not part of the width
|
||||||
|
/// re-division; collapsed strips consume their fixed width"). Folding a lane therefore re-divides
|
||||||
|
/// exactly as the trash's unit joining and leaving does — the window is untouched and the expanded
|
||||||
|
/// lanes grow into the space the lane gave up.
|
||||||
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
||||||
if resize.isActive { return resize.standard }
|
if resize.isActive { return resize.standard }
|
||||||
return LaneLayoutMath.standardWidth(
|
return LaneLayoutMath.standardWidth(
|
||||||
stripWidth: stripWidth,
|
stripWidth: stripWidth,
|
||||||
totalUnits: stripTotalUnits + arrivingLaneUnits,
|
totalUnits: stripTotalUnits + arrivingLaneUnits,
|
||||||
gap: spacing
|
gap: spacing,
|
||||||
|
collapsedCount: stripCollapsedCount + arrivingCollapsedLanes,
|
||||||
|
collapsedWidth: collapsedWidth
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +789,16 @@ struct BoardView: View {
|
|||||||
LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many of this strip's lanes are drawn as slim strips — `standardWidth`'s subtraction, and the
|
||||||
|
/// other half of the divide beside `stripTotalUnits` (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
///
|
||||||
|
/// Read off the **snapshot** rather than off what the strip is showing, exactly as the unit total is:
|
||||||
|
/// a lane in flight is still a lane on the board, so a within-board drag never re-divides the strip
|
||||||
|
/// (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
|
||||||
|
private var stripCollapsedCount: Int {
|
||||||
|
LaneLayoutMath.collapsedCount(of: boardLanes)
|
||||||
|
}
|
||||||
|
|
||||||
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
||||||
/// zero for a within-board drag, whose lanes are already counted.
|
/// zero for a within-board drag, whose lanes are already counted.
|
||||||
///
|
///
|
||||||
@@ -773,13 +818,34 @@ struct BoardView: View {
|
|||||||
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
|
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
|
||||||
/// so the zones can never disagree with what is on screen.
|
/// so the zones can never disagree with what is on screen.
|
||||||
private var arrivingLaneUnits: Int {
|
private var arrivingLaneUnits: Int {
|
||||||
|
guard isArrivingFromAnotherBoard else { return 0 }
|
||||||
|
let session = appModel.dragSession
|
||||||
|
let folded = session.laneCollapsed
|
||||||
|
// **A folded visitor brings no units, only its strip** — the resting rule read from the
|
||||||
|
// destination's side: what the shadow has to make room for is the width the lane will *draw*,
|
||||||
|
// and a collapsed lane draws the fixed strip `arrivingCollapsedLanes` accounts for.
|
||||||
|
return session.laneUnits.enumerated()
|
||||||
|
.filter { !($0.offset < folded.count && folded[$0.offset]) }
|
||||||
|
.reduce(0) { $0 + $1.element }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many **folded** lanes a cross-board run would add to this strip while its shadow is proposed
|
||||||
|
/// here — `arrivingLaneUnits`' other half, and the subtraction side of the same pre-divide.
|
||||||
|
private var arrivingCollapsedLanes: Int {
|
||||||
|
guard isArrivingFromAnotherBoard else { return 0 }
|
||||||
|
return appModel.dragSession.laneCollapsed.prefix(appModel.dragSession.laneUnits.count).count { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a lane run from **another** board is proposing into this strip right now — the gate both
|
||||||
|
/// pre-divide contributions share, so they can never disagree about whether there is a visitor.
|
||||||
|
private var isArrivingFromAnotherBoard: Bool {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
guard session.isDraggingLanes,
|
guard session.isDraggingLanes,
|
||||||
session.stripProposal(onBoardRooted: store.rootKey) != nil,
|
session.stripProposal(onBoardRooted: store.rootKey) != nil,
|
||||||
let source = session.sourceRoot,
|
let source = session.sourceRoot,
|
||||||
source != store.rootKey
|
source != store.rootKey
|
||||||
else { return 0 }
|
else { return false }
|
||||||
return session.laneUnits.reduce(0, +)
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||||||
@@ -791,14 +857,16 @@ struct BoardView: View {
|
|||||||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||||||
private enum StripSlot: Identifiable {
|
private enum StripSlot: Identifiable {
|
||||||
case lane(Lane)
|
case lane(Lane)
|
||||||
case shadow(index: Int, units: Int)
|
/// A shadow at the arriving lane's **drawn** footprint — its frozen unit count, plus whether it
|
||||||
|
/// arrives folded, which decides between the two (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
case shadow(index: Int, units: Int, collapsed: Bool)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
||||||
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
||||||
// the run animates as a move rather than blinking out and back in.
|
// the run animates as a move rather than blinking out and back in.
|
||||||
case let .shadow(index, _): "shadow:\(index)"
|
case let .shadow(index, _, _): "shadow:\(index)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,7 +879,13 @@ struct BoardView: View {
|
|||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||||
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||||
guard let index = stripProposal else { return slots }
|
guard let index = stripProposal else { return slots }
|
||||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
let folded = session.laneCollapsed
|
||||||
|
let shadows = session.laneUnits.enumerated().map {
|
||||||
|
StripSlot.shadow(
|
||||||
|
index: $0.offset,
|
||||||
|
units: $0.element,
|
||||||
|
collapsed: $0.offset < folded.count && folded[$0.offset])
|
||||||
|
}
|
||||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||||
return slots
|
return slots
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1101,8 @@ struct BoardView: View {
|
|||||||
if mode == .jump, direction == .left || direction == .right {
|
if mode == .jump, direction == .left || direction == .right {
|
||||||
return jumpToEndLane(direction)
|
return jumpToEndLane(direction)
|
||||||
}
|
}
|
||||||
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
|
guard let first = NavigationMath.firstCard(scanning: boardLanes, filter: store.searchFilter)
|
||||||
|
else { return .handled }
|
||||||
replaceSelection(with: first, in: .board)
|
replaceSelection(with: first, in: .board)
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -1168,11 +1243,12 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
let lanes = boardLanes
|
let lanes = boardLanes
|
||||||
let filter = store.searchFilter
|
let filter = store.searchFilter
|
||||||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
// A lane the search emptied is scanned past exactly as an empty one is — and so is a collapsed
|
||||||
// the first lane that is *showing* a card, which is what the user can see.
|
// one (03-board-ui.md § Lane ▸ Collapsed lanes). The jump lands on the first lane that is
|
||||||
|
// *showing* a card, which is what the user can see (`NavigationMath.firstCard`).
|
||||||
let target = direction == .right
|
let target = direction == .right
|
||||||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
? NavigationMath.firstCard(scanning: lanes.reversed(), filter: filter)
|
||||||
: Self.firstCard(scanning: lanes, filter: filter)
|
: NavigationMath.firstCard(scanning: lanes, filter: filter)
|
||||||
guard let target else { return .handled }
|
guard let target else { return .handled }
|
||||||
replaceSelection(with: target, in: .board)
|
replaceSelection(with: target, in: .board)
|
||||||
return .handled
|
return .handled
|
||||||
@@ -1224,6 +1300,11 @@ struct BoardView: View {
|
|||||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
// **A folded lane has nothing to descend into** (03-board-ui.md § Lane ▸ Collapsed lanes):
|
||||||
|
// its cards are not rendered, so ↓ is inert exactly as it is on an empty lane — landing on
|
||||||
|
// a card the strip draws nowhere would leave the arrows with no frame to step from next.
|
||||||
|
// The way in is to expand the lane, which is what the strip's own click does.
|
||||||
|
guard !LaneLayoutMath.isCollapsed(lane) else { return .handled }
|
||||||
let cards = lane.cards
|
let cards = lane.cards
|
||||||
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
||||||
replaceSelection(with: target.id, in: .board)
|
replaceSelection(with: target.id, in: .board)
|
||||||
@@ -1241,19 +1322,4 @@ struct BoardView: View {
|
|||||||
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
||||||
store.select([id], in: container, anchor: id, head: id)
|
store.select([id], in: container, anchor: id, head: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
|
||||||
/// destination shares, run over the lane order forwards or reversed.
|
|
||||||
///
|
|
||||||
/// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned
|
|
||||||
/// past like an empty one — `liveCards(in:filter:)`'s membership, one lane at a time.
|
|
||||||
private static func firstCard(
|
|
||||||
scanning lanes: some Sequence<Lane>,
|
|
||||||
filter: SearchFilter = .inactive
|
|
||||||
) -> ItemID? {
|
|
||||||
for lane in lanes {
|
|
||||||
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,6 +323,19 @@ final class DragSession {
|
|||||||
/// whichever board's standard width it is being proposed into.
|
/// whichever board's standard width it is being proposed into.
|
||||||
@ObservationIgnored private(set) var laneUnits: [Int] = []
|
@ObservationIgnored private(set) var laneUnits: [Int] = []
|
||||||
|
|
||||||
|
/// Which of the dragged lanes are **collapsed**, aligned 1:1 with `laneUnits` — frozen at drag
|
||||||
|
/// start beside them (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
///
|
||||||
|
/// A second array rather than a richer element type, because it is read in exactly the two places
|
||||||
|
/// `laneUnits` is and always together with it: the shadow's width, and the run's span cap
|
||||||
|
/// (`BoardView.stripSlots`, `DropSlotMath.laneSlot`). A folded lane's shadow is its slim strip, not
|
||||||
|
/// the slot its preserved `width` would have bought — the drop leaves it folded, so a wider shadow
|
||||||
|
/// would promise a lane the strip is not about to draw.
|
||||||
|
///
|
||||||
|
/// **Empty is "none of them"**, which is what a card session and a cleared session both hold, and
|
||||||
|
/// what a `beginLanes` caller with no collapse question to ask leaves it as.
|
||||||
|
@ObservationIgnored private(set) var laneCollapsed: [Bool] = []
|
||||||
|
|
||||||
/// The per-lane resting layouts this drag's retargets propose against, built once per snapshot
|
/// The per-lane resting layouts this drag's retargets propose against, built once per snapshot
|
||||||
/// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1
|
/// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1
|
||||||
/// of the re-grounding trio exactly as it was).
|
/// of the re-grounding trio exactly as it was).
|
||||||
@@ -627,16 +640,22 @@ final class DragSession {
|
|||||||
)
|
)
|
||||||
cardHeights = heights
|
cardHeights = heights
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
|
laneCollapsed = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begins a lane session.
|
/// Begins a lane session.
|
||||||
///
|
///
|
||||||
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
|
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
|
||||||
/// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
|
/// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
|
||||||
|
///
|
||||||
|
/// `collapsed` is the run's fold state, aligned with `units` — see `laneCollapsed`. It defaults to
|
||||||
|
/// empty, which reads as "none of them": a trashed lane row has no `collapsed` reading to carry
|
||||||
|
/// (03-board-ui.md § Trash — a trashed lane is an opaque row, never a lane on the board).
|
||||||
func beginLanes(
|
func beginLanes(
|
||||||
_ members: [ItemID],
|
_ members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
units: [Int],
|
units: [Int],
|
||||||
|
collapsed: [Bool] = [],
|
||||||
container: ItemContainer = .board,
|
container: ItemContainer = .board,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool = false,
|
mixesKinds: Bool = false,
|
||||||
@@ -647,6 +666,7 @@ final class DragSession {
|
|||||||
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
||||||
)
|
)
|
||||||
laneUnits = units
|
laneUnits = units
|
||||||
|
laneCollapsed = collapsed
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +791,7 @@ final class DragSession {
|
|||||||
folders = []
|
folders = []
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
|
laneCollapsed = []
|
||||||
restingLayouts.clear()
|
restingLayouts.clear()
|
||||||
proposal = nil
|
proposal = nil
|
||||||
operation = .move
|
operation = .move
|
||||||
|
|||||||
@@ -138,10 +138,21 @@ enum DropSlotMath {
|
|||||||
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
|
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
|
||||||
/// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
|
/// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
|
||||||
static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange<CGFloat>] {
|
static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange<CGFloat>] {
|
||||||
|
laneExtents(
|
||||||
|
widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||||||
|
gap: gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same extents over **drawn** widths, which is what a strip holding collapsed lanes has: a
|
||||||
|
/// slim strip's width is a fixed figure and not a multiple of the standard (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes; `LaneLayoutMath.drawnWidths`).
|
||||||
|
///
|
||||||
|
/// The primitive, with the unit-count version above as its wrapper — one walk, so a folded lane can
|
||||||
|
/// only ever move a zone, never change how zones are built.
|
||||||
|
static func laneExtents(widths: [CGFloat], gap: CGFloat) -> [ClosedRange<CGFloat>] {
|
||||||
var extents: [ClosedRange<CGFloat>] = []
|
var extents: [ClosedRange<CGFloat>] = []
|
||||||
var left = gap
|
var left = gap
|
||||||
for units in unitCounts {
|
for width in widths {
|
||||||
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
|
||||||
extents.append(left...(left + width))
|
extents.append(left...(left + width))
|
||||||
left += width + gap
|
left += width + gap
|
||||||
}
|
}
|
||||||
@@ -152,9 +163,16 @@ enum DropSlotMath {
|
|||||||
/// widths plus the `n − 1` gaps between them. This is the span the trigger regions are capped
|
/// widths plus the `n − 1` gaps between them. This is the span the trigger regions are capped
|
||||||
/// at, and it is exactly the shadow run's future footprint.
|
/// at, and it is exactly the shadow run's future footprint.
|
||||||
static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
|
static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
|
||||||
guard !unitCounts.isEmpty else { return 0 }
|
laneRunSpan(
|
||||||
let widths = unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }
|
widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||||||
return widths.reduce(0, +) + gap * CGFloat(unitCounts.count - 1)
|
gap: gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same span over **drawn** widths — a dragged run that includes a collapsed lane occupies that
|
||||||
|
/// lane's slim strip, not the slot its `width` key would have bought (see `laneExtents(widths:)`).
|
||||||
|
static func laneRunSpan(widths: [CGFloat], gap: CGFloat) -> CGFloat {
|
||||||
|
guard !widths.isEmpty else { return 0 }
|
||||||
|
return widths.reduce(0, +) + gap * CGFloat(widths.count - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a lane drag would land: an index into the ordered live lanes **with the dragged run
|
/// Where a lane drag would land: an index into the ordered live lanes **with the dragged run
|
||||||
@@ -179,12 +197,36 @@ enum DropSlotMath {
|
|||||||
standard: CGFloat,
|
standard: CGFloat,
|
||||||
gap: CGFloat,
|
gap: CGFloat,
|
||||||
current: Int?
|
current: Int?
|
||||||
|
) -> Int? {
|
||||||
|
laneSlot(
|
||||||
|
cursorX: cursorX,
|
||||||
|
restingWidths: restingUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||||||
|
draggedWidths: draggedUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||||||
|
gap: gap,
|
||||||
|
current: current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same answer from **drawn** widths — what the board actually asks, since a collapsed lane's
|
||||||
|
/// slim strip is neither a unit count nor a multiple of the standard (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes).
|
||||||
|
///
|
||||||
|
/// A folded lane is an ordinary member of both lists: it occupies one zone in the resting strip like
|
||||||
|
/// any other lane (so a reorder drags *across* it in one narrow zone rather than several), and a
|
||||||
|
/// folded lane being dragged contributes its strip's width to the run's span cap, so the trigger
|
||||||
|
/// region matches the footprint the drop will actually produce.
|
||||||
|
static func laneSlot(
|
||||||
|
cursorX: CGFloat,
|
||||||
|
restingWidths: [CGFloat],
|
||||||
|
draggedWidths: [CGFloat],
|
||||||
|
gap: CGFloat,
|
||||||
|
current: Int?
|
||||||
) -> Int? {
|
) -> Int? {
|
||||||
slot(
|
slot(
|
||||||
cursor: cursorX,
|
cursor: cursorX,
|
||||||
extents: laneExtents(unitCounts: restingUnits, standard: standard, gap: gap),
|
extents: laneExtents(widths: restingWidths, gap: gap),
|
||||||
gap: gap,
|
gap: gap,
|
||||||
draggedSpan: laneRunSpan(unitCounts: draggedUnits, standard: standard, gap: gap),
|
draggedSpan: laneRunSpan(widths: draggedWidths, gap: gap),
|
||||||
current: current
|
current: current
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,27 @@ enum LaneLayoutMath {
|
|||||||
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
|
/// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
|
||||||
/// is never zero or negative — the pathological input (a strip narrower than its own gaps) must
|
/// is never zero or negative — the pathological input (a strip narrower than its own gaps) must
|
||||||
/// not produce a negative size for SwiftUI to complain about.
|
/// not produce a negative size for SwiftUI to complain about.
|
||||||
static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat {
|
///
|
||||||
|
/// **Collapsed lanes are taken off the top, never divided** (03-board-ui.md § Lane ▸ Collapsed
|
||||||
|
/// lanes): each one consumes a fixed `collapsedWidth` plus the gap that follows it, and the
|
||||||
|
/// remainder is what the expanded lanes' `totalUnits` divide. So a strip of `T` units and `C`
|
||||||
|
/// collapsed strips still fills exactly — `C·collapsedWidth + T·standard + (T + C + 1)·gap` — and
|
||||||
|
/// `collapsedCount * (collapsedWidth + gap)` is that identity rearranged, which is why the
|
||||||
|
/// subtraction carries a gap with it.
|
||||||
|
///
|
||||||
|
/// Both extra arguments default to nothing, so a board with no folded lane reads exactly as it did
|
||||||
|
/// before they existed — and so does every call site that has no collapse question to ask (the
|
||||||
|
/// resize drag's two regimes, whose `startTotalUnits` is already the expanded total).
|
||||||
|
static func standardWidth(
|
||||||
|
stripWidth: CGFloat,
|
||||||
|
totalUnits: Int,
|
||||||
|
gap: CGFloat,
|
||||||
|
collapsedCount: Int = 0,
|
||||||
|
collapsedWidth: CGFloat = 0
|
||||||
|
) -> CGFloat {
|
||||||
let count = CGFloat(max(1, totalUnits))
|
let count = CGFloat(max(1, totalUnits))
|
||||||
return max(1, (stripWidth - gap * (count + 1)) / count)
|
let folded = CGFloat(max(0, collapsedCount)) * (collapsedWidth + gap)
|
||||||
|
return max(1, (stripWidth - folded - gap * (count + 1)) / count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
|
/// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
|
||||||
@@ -70,8 +88,81 @@ enum LaneLayoutMath {
|
|||||||
max(1, lane.width.value ?? 1)
|
max(1, lane.width.value ?? 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unit total a strip of `lanes` divides across — the sum of their display units, never
|
/// **Whether the lane is drawn as a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the
|
||||||
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
|
/// one place the rest of the app asks, so the leniency below is stated once.
|
||||||
|
///
|
||||||
|
/// `collapsed` is a **lenient** field exactly like `width` (01-storage-format.md § Frontmatter): a
|
||||||
|
/// missing key, an explicit `false`, and a value with no boolean reading at all (`.malformed`) are
|
||||||
|
/// one answer here — **expanded** — with the author's bytes left alone either way. Only a real
|
||||||
|
/// `true` folds a lane, which is what makes an unreadable value harmless rather than surprising.
|
||||||
|
///
|
||||||
|
/// A collapsed lane keeps its `width` untouched (`BoardStore.setLaneCollapsed` writes one key and
|
||||||
|
/// only one), so `displayUnits` still answers for it — and is deliberately still asked, by the
|
||||||
|
/// interior masonry the lane will draw again the moment it expands.
|
||||||
|
static func isCollapsed(_ lane: Lane) -> Bool {
|
||||||
|
lane.collapsed.value == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many of `lanes` are drawn as slim strips — the count `standardWidth` takes off the top.
|
||||||
|
static func collapsedCount(of lanes: [Lane]) -> Int {
|
||||||
|
lanes.count { isCollapsed($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lane's **drawn** width: its slot width when expanded, the fixed strip when collapsed. The one
|
||||||
|
/// answer every hit test and every zone list is built from, so the arithmetic can never disagree
|
||||||
|
/// with what the strip laid out (`BoardView.laneSlot` frames each lane with this).
|
||||||
|
static func drawnWidth(
|
||||||
|
units: Int,
|
||||||
|
isCollapsed: Bool,
|
||||||
|
standard: CGFloat,
|
||||||
|
gap: CGFloat,
|
||||||
|
collapsedWidth: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
isCollapsed ? collapsedWidth : slotWidth(units: units, standard: standard, gap: gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `drawnWidth` over a run of lanes, in the order given — the widths list the strip's zones and
|
||||||
|
/// hit tests walk (`DropSlotMath.laneExtents`, `laneIndex(atX:widths:gap:)`).
|
||||||
|
static func drawnWidths(
|
||||||
|
of lanes: [Lane],
|
||||||
|
standard: CGFloat,
|
||||||
|
gap: CGFloat,
|
||||||
|
collapsedWidth: CGFloat
|
||||||
|
) -> [CGFloat] {
|
||||||
|
lanes.map {
|
||||||
|
drawnWidth(
|
||||||
|
units: displayUnits(of: $0),
|
||||||
|
isCollapsed: isCollapsed($0),
|
||||||
|
standard: standard,
|
||||||
|
gap: gap,
|
||||||
|
collapsedWidth: collapsedWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `drawnWidth` over a **dragged** run, whose fold state travels beside its unit counts rather than
|
||||||
|
/// inside a `Lane` (`DragSession.laneUnits` / `laneCollapsed`, both frozen at pickup).
|
||||||
|
///
|
||||||
|
/// A `collapsed` array shorter than `units` reads as expanded past its end, which is what a
|
||||||
|
/// `beginLanes` caller that passed none means — and the trashed-lane restore is exactly that caller.
|
||||||
|
static func drawnWidths(
|
||||||
|
units: [Int],
|
||||||
|
collapsed: [Bool],
|
||||||
|
standard: CGFloat,
|
||||||
|
gap: CGFloat,
|
||||||
|
collapsedWidth: CGFloat
|
||||||
|
) -> [CGFloat] {
|
||||||
|
units.enumerated().map { index, units in
|
||||||
|
drawnWidth(
|
||||||
|
units: units,
|
||||||
|
isCollapsed: index < collapsed.count && collapsed[index],
|
||||||
|
standard: standard,
|
||||||
|
gap: gap,
|
||||||
|
collapsedWidth: collapsedWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit total a strip of `lanes` divides across — the sum of the **expanded** lanes' display
|
||||||
|
/// units, never below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
|
||||||
///
|
///
|
||||||
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
|
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
|
||||||
/// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
|
/// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
|
||||||
@@ -86,8 +177,20 @@ enum LaneLayoutMath {
|
|||||||
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
|
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
|
||||||
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
|
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
|
||||||
/// lane add does (§ Layout — full visibility).
|
/// lane add does (§ Layout — full visibility).
|
||||||
|
///
|
||||||
|
/// **A collapsed lane contributes nothing** (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip
|
||||||
|
/// is not part of the width re-division"): its fixed width is `standardWidth`'s subtraction, not a
|
||||||
|
/// share of the division, so folding a lane away is a re-divide trigger of the same family — the
|
||||||
|
/// window is untouched and the siblings grow into the space the lane gave up.
|
||||||
|
///
|
||||||
|
/// The `max(1,)` therefore covers one more shape than it used to: a board whose **every** lane is
|
||||||
|
/// collapsed has no expanded unit at all, and the 1 it answers is a divisor guard rather than a
|
||||||
|
/// description of anything on screen. Nothing draws with that standard — the strips take their
|
||||||
|
/// fixed width and the leftover is empty board — except a shown trash column, which is a real unit
|
||||||
|
/// in the total and correctly gets the whole remainder.
|
||||||
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
|
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
|
||||||
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
|
let units = lanes.reduce(0) { $0 + (isCollapsed($1) ? 0 : displayUnits(of: $1)) }
|
||||||
|
return max(1, units + max(0, trashUnits))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hit testing
|
// MARK: - Hit testing
|
||||||
@@ -104,9 +207,21 @@ enum LaneLayoutMath {
|
|||||||
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
|
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
|
||||||
/// into logic").
|
/// into logic").
|
||||||
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
|
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
|
||||||
|
laneIndex(
|
||||||
|
atX: x,
|
||||||
|
widths: unitCounts.map { slotWidth(units: $0, standard: standard, gap: gap) },
|
||||||
|
gap: gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same hit test over **drawn** widths — the shape a strip with collapsed lanes in it has to
|
||||||
|
/// ask, since a slim strip's width is a fixed figure rather than a multiple of the standard
|
||||||
|
/// (`drawnWidths(of:standard:gap:collapsedWidth:)`).
|
||||||
|
///
|
||||||
|
/// This is the primitive and the unit-count version above is its wrapper: one walk, one origin
|
||||||
|
/// convention, so a board with no folded lane cannot answer differently from one with.
|
||||||
|
static func laneIndex(atX x: CGFloat, widths: [CGFloat], gap: CGFloat) -> Int? {
|
||||||
var left = gap
|
var left = gap
|
||||||
for (index, units) in unitCounts.enumerated() {
|
for (index, width) in widths.enumerated() {
|
||||||
let width = slotWidth(units: units, standard: standard, gap: gap)
|
|
||||||
if x >= left, x < left + width { return index }
|
if x >= left, x < left + width { return index }
|
||||||
left += width + gap
|
left += width + gap
|
||||||
}
|
}
|
||||||
|
|||||||
+353
-36
@@ -24,6 +24,22 @@ import SwiftUI
|
|||||||
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
||||||
/// Lane row, in its order, complete as of m5.
|
/// Lane row, in its order, complete as of m5.
|
||||||
///
|
///
|
||||||
|
/// ### Collapsed: the slim strip
|
||||||
|
///
|
||||||
|
/// A lane whose `collapsed` reading is `true` (`LaneLayoutMath.isCollapsed`) draws **none** of the
|
||||||
|
/// above: no header, no masonry, no resize handle — a fixed-width vertical strip carrying the lane's
|
||||||
|
/// glyph, the same count badge, and its title rotated to read **bottom-up** and justified to the top of
|
||||||
|
/// the room below them (03-board-ui.md § Lane ▸ Collapsed lanes; the orientation is an owner ruling of
|
||||||
|
/// 2026-08-08/09 — see `collapsedTitle`). The accent band, the plate, the selection treatment and the
|
||||||
|
/// cut dim are unchanged, because a folded lane is still that lane.
|
||||||
|
///
|
||||||
|
/// What the fold takes away it takes away **by not drawing it**, which is how the hidden trash's
|
||||||
|
/// invisibility works and why this needs so little code elsewhere: no card face means no marquee
|
||||||
|
/// registration and no navigation frame, no grid registration means the card zones have nothing to
|
||||||
|
/// resolve against (`BoardDropContext.retargetCards` answers end-of-lane instead), and no header means
|
||||||
|
/// no rename target and no new-card button. The strip keeps exactly three gestures: a plain click
|
||||||
|
/// expands, a modified click selects the lane, and a drag reorders it.
|
||||||
|
///
|
||||||
/// ### The card face
|
/// ### The card face
|
||||||
///
|
///
|
||||||
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
|
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
|
||||||
@@ -122,6 +138,12 @@ struct LaneView: View, Equatable {
|
|||||||
/// is set to.
|
/// is set to.
|
||||||
@State private var measuredHeaderHeight: CGFloat = 0
|
@State private var measuredHeaderHeight: CGFloat = 0
|
||||||
|
|
||||||
|
/// The vertical room the collapsed strip's rotated title has to run in — measured, because it is
|
||||||
|
/// whatever the strip's height leaves after the glyph and the badge, and the title's **truncation
|
||||||
|
/// is stated against it** (`collapsedTitle`). Zero until the strip lays out, which draws no title
|
||||||
|
/// for one frame rather than a badly-placed one.
|
||||||
|
@State private var collapsedTitleTravel: CGFloat = 0
|
||||||
|
|
||||||
/// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
|
/// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
|
||||||
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
|
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
|
||||||
/// can stretch the content to fill it; only an explicit minimum can.
|
/// can stretch the content to fill it; only an explicit minimum can.
|
||||||
@@ -169,14 +191,27 @@ struct LaneView: View, Equatable {
|
|||||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
accentBand
|
accentBand
|
||||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
// **The fold is a different body, not a hidden one** (03-board-ui.md § Lane ▸ Collapsed
|
||||||
header
|
// lanes): the header and the masonry are not built at all, which is what makes every
|
||||||
cardStack
|
// "collapsed lanes are excluded from …" rule below true by construction rather than by a
|
||||||
|
// guard per gesture — nothing registers a frame, a grid, a header rect or a marquee target
|
||||||
|
// because nothing exists to register one.
|
||||||
|
if isCollapsed {
|
||||||
|
collapsedStrip
|
||||||
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||||
|
header
|
||||||
|
cardStack
|
||||||
|
}
|
||||||
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
|
||||||
}
|
}
|
||||||
.background(lanePlate)
|
.background(lanePlate)
|
||||||
.overlay(selectionStroke)
|
.overlay(selectionStroke)
|
||||||
|
// The fold's own drop feedback — see `collapsedDropStroke`. Outside the `if` so it is one
|
||||||
|
// overlay whatever the lane is doing, and inert (clear) whenever there is nothing to say.
|
||||||
|
.overlay(collapsedDropStroke)
|
||||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
||||||
// lane is cut cards and all.
|
// lane is cut cards and all.
|
||||||
.cutTreatment(of: lane.id, in: store)
|
.cutTreatment(of: lane.id, in: store)
|
||||||
@@ -198,7 +233,171 @@ struct LaneView: View, Equatable {
|
|||||||
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
|
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
|
||||||
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
|
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
|
||||||
// by construction rather than by a second rule.
|
// by construction rather than by a second rule.
|
||||||
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
|
.accessibilityLabel(AccessibilityPhrases.laneLabel(
|
||||||
|
title: lane.title.value,
|
||||||
|
// **A collapsed lane speaks its held cards, not its rendered ones** — it renders none, and
|
||||||
|
// "0 cards" would say the lane was empty when it is merely shut. The badge on the strip
|
||||||
|
// shows the same number for the same reason (`collapsedStrip`).
|
||||||
|
cards: isCollapsed ? lane.cards.count : renderedCards.count,
|
||||||
|
collapsed: isCollapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this lane is folded to its slim strip — `LaneLayoutMath.isCollapsed`'s reading, named
|
||||||
|
/// here because this body asks it a dozen times (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
private var isCollapsed: Bool {
|
||||||
|
LaneLayoutMath.isCollapsed(lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The collapsed strip
|
||||||
|
|
||||||
|
/// The fold's whole face: the lane's glyph, its title rotated to read top-to-bottom, and the count
|
||||||
|
/// badge, stacked in a column the width of `BoardMetrics.collapsedLaneWidth` (03-board-ui.md § Lane
|
||||||
|
/// ▸ Collapsed lanes).
|
||||||
|
///
|
||||||
|
/// **The whole strip is one surface with three gestures**, deliberately fewer than the expanded
|
||||||
|
/// lane's two-surface arrangement (header + empty space) because there is nothing to tell apart: a
|
||||||
|
/// plain click **expands** (the fold's own reversal, and the discoverable one — there is no header
|
||||||
|
/// to hang a chevron on), a ⌘/⇧ click enters the ordinary selection grammar so a folded lane can
|
||||||
|
/// still be selected and ranged over by pointer, and a drag reorders the lane exactly as its header
|
||||||
|
/// would (`startLaneDrag`). A double click is a plain click twice: the first expands, and the second
|
||||||
|
/// lands on the lane that is now open — no create, because the strip has no empty space to
|
||||||
|
/// double-click *into*.
|
||||||
|
private var collapsedStrip: some View {
|
||||||
|
VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||||
|
// **The glyph and the badge are the strip's chrome, and they sit at its top** (owner ruling
|
||||||
|
// 2026-08-08/09): the title is top-justified below them, so the two fixed pieces stay
|
||||||
|
// exactly where they are however long the title runs — a badge underneath a
|
||||||
|
// variable-length run would drift with the words above it.
|
||||||
|
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.imageScale(.medium)
|
||||||
|
// **The lane's held count, not its rendered one**: a folded lane renders no card, so the
|
||||||
|
// filter has nothing to narrow here and `0` would be a lie about the lane's contents. The
|
||||||
|
// search's own answer is the *expanded* badge's rule and stays there (`countBadge`).
|
||||||
|
countBadgeText("\(lane.cards.count)")
|
||||||
|
collapsedTitle
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
// The strip is chrome all the way down, so it has to be hit-testable across its whole height —
|
||||||
|
// including the empty stretch under the badge, which is most of it on a tall board.
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
let modifier = ClickModifier.current
|
||||||
|
guard modifier == .plain else {
|
||||||
|
// ⌘/⇧ keep their grammar: the strip is the lane's one pointer surface while folded, so
|
||||||
|
// taking the modifiers away would make a collapsed lane unselectable by mouse.
|
||||||
|
store.click(
|
||||||
|
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||||||
|
modifier: modifier
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expand()
|
||||||
|
}
|
||||||
|
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||||
|
// **The strip is the lane's heading while folded** — the header's role, since there is no
|
||||||
|
// header (10-accessibility.md ▸ Rotor: "lane titles are headings, so the headings rotor jumps
|
||||||
|
// lane-to-lane"). Flattened for the same reason the header is: the glyph and the count are the
|
||||||
|
// container's information, already spoken by its label.
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
|
||||||
|
.accessibilityAddTraits(headerTraits)
|
||||||
|
// The keyboard's way out of the fold, and the pointer's, on one method: VO-Space would
|
||||||
|
// otherwise be the one path that could not undo what it can see.
|
||||||
|
.accessibilityAction(named: "Expand Lane") { expand() }
|
||||||
|
.accessibilityAction { toggleLaneSelection() }
|
||||||
|
.boardTextInk(headerInk)
|
||||||
|
.contextMenu { laneMenu }
|
||||||
|
.accessibilityActions { laneActions }
|
||||||
|
// The Style… popover's anchor while folded — the strip stands in for the header, which is
|
||||||
|
// where an expanded lane's popover hangs (`styleEditorPresentation` decides whether this lane
|
||||||
|
// is the session's anchor at all). Without it a restyle invoked from the strip's own menu would
|
||||||
|
// have nowhere to present from.
|
||||||
|
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .trailing) {
|
||||||
|
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title, turned a quarter turn **counterclockwise** so it reads **bottom-up** — the European
|
||||||
|
/// book-spine orientation — and **justified to the top** of the room below the strip's chrome
|
||||||
|
/// (owner ruling 2026-08-08/09, superseding the top-down reading this shipped with for an hour).
|
||||||
|
///
|
||||||
|
/// Both halves are one alignment plus one sign, and both are worth spelling out because the
|
||||||
|
/// rotation is a *visual* transform over an unrotated layout:
|
||||||
|
///
|
||||||
|
/// - **`-90°`** maps the unrotated `+x` direction (the reading direction) onto `-y` (up), so the
|
||||||
|
/// string's first character lands at the run's bottom and the last at its top: the eye travels
|
||||||
|
/// upward, and the tail ellipsis — plain `.truncationMode(.tail)`, untouched — lands at the top
|
||||||
|
/// end, which is where the string's tail lands by construction.
|
||||||
|
/// - **`alignment: .trailing`** puts a short string against the unrotated box's right edge, and
|
||||||
|
/// right maps to up under the same rotation: the glyphs occupy the *topmost* stretch of the run
|
||||||
|
/// and the slack falls away beneath them. Anchoring the box instead of the glyphs would leave a
|
||||||
|
/// short title floating in the middle of a full-height frame.
|
||||||
|
///
|
||||||
|
/// **It truncates against the strip's height**, which is the whole reason the travel is measured
|
||||||
|
/// rather than derived: a rotated `Text` given no width lays out at its full ideal length and simply
|
||||||
|
/// overhangs the strip's ends. Laying it out at `collapsedTitleTravel` first — the leftover height,
|
||||||
|
/// measured by the clear box it is drawn over — is 03's graceful-truncation rule holding in the one
|
||||||
|
/// place on the board where the axis is vertical.
|
||||||
|
///
|
||||||
|
/// The clear box takes whatever the chrome above it left (`maxHeight: .infinity`), so the leftover
|
||||||
|
/// arithmetic is the layout's rather than this file's.
|
||||||
|
private var collapsedTitle: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { collapsedTitleTravel = $0 }
|
||||||
|
.overlay { rotatedTitle(travel: collapsedTitleTravel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title turned on its side, laid out against `travel` points of vertical room — the strip's and
|
||||||
|
/// its replica's one construction, so the drag image reads and truncates exactly as the lane does.
|
||||||
|
private func rotatedTitle(travel: CGFloat) -> some View {
|
||||||
|
Text(lane.title.value ?? "Untitled")
|
||||||
|
.boardFont(.headline)
|
||||||
|
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(width: travel, alignment: .trailing)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The drop feedback a folded lane can give** (03-board-ui.md § Lane ▸ Collapsed lanes: a drop on
|
||||||
|
/// the strip appends at the lane's end, "with the normal drop feedback").
|
||||||
|
///
|
||||||
|
/// The normal feedback is a shadow opening in the masonry at the landing slot, and a strip has no
|
||||||
|
/// masonry to open one in — so the strip says the same thing the way the card-attach highlight does
|
||||||
|
/// (04-interactions.md ▸ Drag and drop: "each target gets one clear signal, and the card-attach
|
||||||
|
/// highlight exists precisely because that target has no shadow"): an accent edge around the lane
|
||||||
|
/// while the proposal names it. Both session kinds reach it, ours and Finder's, because both land
|
||||||
|
/// at the same place.
|
||||||
|
///
|
||||||
|
/// Clear rather than absent when there is nothing to say, so the overlay never changes identity.
|
||||||
|
@ViewBuilder
|
||||||
|
private var collapsedDropStroke: some View {
|
||||||
|
let proposing = isCollapsed
|
||||||
|
&& (cardProposal != nil
|
||||||
|
|| drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) != nil)
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.strokeBorder(
|
||||||
|
proposing
|
||||||
|
? Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast))
|
||||||
|
: .clear,
|
||||||
|
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||||||
|
)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfolds the lane — the strip's click, its VoiceOver action, and the menu row's expand half, on
|
||||||
|
/// one method so the three cannot mean different things.
|
||||||
|
private func expand() {
|
||||||
|
guard store.acceptsBoardMutations else { return }
|
||||||
|
store.setLaneCollapsed(lane.id, collapsed: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds it — the header chevron's and the menu row's collapse half.
|
||||||
|
private func collapse() {
|
||||||
|
guard store.acceptsBoardMutations else { return }
|
||||||
|
store.setLaneCollapsed(lane.id, collapsed: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
@@ -256,7 +455,15 @@ struct LaneView: View, Equatable {
|
|||||||
measuredHeaderHeight = frame.height
|
measuredHeaderHeight = frame.height
|
||||||
}
|
}
|
||||||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||||
.overlay(alignment: .trailing) { newCardButton }
|
// **The header's trailing chrome, outside the drag region** — the overlay is attached
|
||||||
|
// after `.onDrag` deliberately, which is what makes both controls clickable rather than
|
||||||
|
// grabbable (the new-card button's long-standing arrangement, now shared).
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
collapseButton
|
||||||
|
newCardButton
|
||||||
|
}
|
||||||
|
}
|
||||||
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
|
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
|
||||||
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
|
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
|
||||||
// since the ink they need is the same question and only the *verification* differs
|
// since the ink they need is the same question and only the *verification* differs
|
||||||
@@ -351,6 +558,18 @@ struct LaneView: View, Equatable {
|
|||||||
|
|
||||||
widthControl
|
widthControl
|
||||||
|
|
||||||
|
// **The fold's own row, under the width control it belongs beside** (03-board-ui.md § Lane ▸
|
||||||
|
// Collapsed lanes) — one row whose title says which direction it goes, the Show/Hide Trash
|
||||||
|
// shape rather than a checkmark: a menu row that toggles a *geometry* reads better as the verb
|
||||||
|
// it performs than as a state it reports.
|
||||||
|
//
|
||||||
|
// **Single-lane, like the width stepper above it and unlike Style… and Delete**: the design
|
||||||
|
// gives the width batch to the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu
|
||||||
|
// is open, and the fold has no keyboard face to carry a batch. So this row acts on the clicked
|
||||||
|
// lane outright, which is also what its singular title promises.
|
||||||
|
collapseControl
|
||||||
|
.disabled(!store.acceptsBoardMutations)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
|
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
|
||||||
@@ -373,10 +592,21 @@ struct LaneView: View, Equatable {
|
|||||||
.disabled(!store.acceptsBoardMutations)
|
.disabled(!store.acceptsBoardMutations)
|
||||||
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
|
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
|
||||||
.disabled(!store.acceptsBoardMutations || units <= 1)
|
.disabled(!store.acceptsBoardMutations || units <= 1)
|
||||||
|
collapseControl
|
||||||
|
.disabled(!store.acceptsBoardMutations)
|
||||||
Button("Delete") { deleteTargets() }
|
Button("Delete") { deleteTargets() }
|
||||||
.disabled(!store.acceptsBoardMutations)
|
.disabled(!store.acceptsBoardMutations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fold's one row, in the direction this lane can actually go — the context menu's and its
|
||||||
|
/// VoiceOver twin's, spelled once so the two surfaces cannot drift into meaning different things
|
||||||
|
/// (the `laneActions` rule).
|
||||||
|
private var collapseControl: some View {
|
||||||
|
isCollapsed
|
||||||
|
? Button("Expand Lane") { expand() }
|
||||||
|
: Button("Collapse Lane") { collapse() }
|
||||||
|
}
|
||||||
|
|
||||||
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
|
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
|
||||||
/// (the context menu row and its accessibility twin).
|
/// (the context menu row and its accessibility twin).
|
||||||
private func beginRename() {
|
private func beginRename() {
|
||||||
@@ -456,12 +686,13 @@ struct LaneView: View, Equatable {
|
|||||||
countBadge
|
countBadge
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
// Reserves the trailing chrome's width so a long title truncates before it collides, and keeps
|
||||||
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
|
// those controls out of the gestured region. **Font-derived** rather than a fixed figure: both
|
||||||
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
|
// are `Image`s at a relative image scale, so a fixed reserve would be overrun by the glyphs
|
||||||
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
// themselves at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||||||
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
|
// quietly stop holding (`BoardMetrics.laneHeaderTrailingReserve`, which is the collapse
|
||||||
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
|
// chevron's room plus the new-card button's).
|
||||||
|
.padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
|
||||||
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +740,14 @@ struct LaneView: View, Equatable {
|
|||||||
/// what the body renders, and while an inline rename is open its card is one of the things the
|
/// what the body renders, and while an inline rename is open its card is one of the things the
|
||||||
/// body renders (see `renderedCards`).
|
/// body renders (see `renderedCards`).
|
||||||
private var countBadge: some View {
|
private var countBadge: some View {
|
||||||
Text("\(renderedCards.count)")
|
countBadgeText("\(renderedCards.count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The badge's chrome with the number as a parameter — the header's and the collapsed strip's
|
||||||
|
/// badge are the *same* badge, and the two differ only in which count they are of (see
|
||||||
|
/// `collapsedStrip`).
|
||||||
|
private func countBadgeText(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
.boardFont(.caption)
|
.boardFont(.caption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -518,6 +756,35 @@ struct LaneView: View, Equatable {
|
|||||||
.background(Capsule().fill(.quaternary))
|
.background(Capsule().fill(.quaternary))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The collapse chevron** (03-board-ui.md § Lane ▸ Collapsed lanes) — the pointer's way into the
|
||||||
|
/// fold, beside the new-card button at the header's trailing edge.
|
||||||
|
///
|
||||||
|
/// **Always visible, not hover-revealed**, because the header has no hover chrome to match: the
|
||||||
|
/// new-card button beside it has been unconditional since it existed, and one control that appears
|
||||||
|
/// on hover next to one that does not reads as a glitch rather than as a convention. The cost is
|
||||||
|
/// paid in the header's trailing reserve, which is the figure that keeps the title truncating before
|
||||||
|
/// either glyph (`headerContent`).
|
||||||
|
///
|
||||||
|
/// `chevron.left` because the fold travels that way — the lane's content collapses toward its
|
||||||
|
/// leading edge and leaves a spine — and the strip's reversal is a click on the strip itself, which
|
||||||
|
/// is why there is no `chevron.right` twin drawn over there: the whole strip is the button.
|
||||||
|
///
|
||||||
|
/// Mutating, so it disables exactly where every other write path does — the read-only lock and the
|
||||||
|
/// focused-editor rule (`store.acceptsBoardMutations`, the pointer twin of a disabled menu item).
|
||||||
|
private var collapseButton: some View {
|
||||||
|
Button {
|
||||||
|
collapse()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.imageScale(.small)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(AccessibilityPhrases.collapseLaneLabel(lane: lane.title.value))
|
||||||
|
.disabled(!store.acceptsBoardMutations)
|
||||||
|
}
|
||||||
|
|
||||||
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
||||||
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
||||||
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
||||||
@@ -585,6 +852,9 @@ struct LaneView: View, Equatable {
|
|||||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||||
// (03-board-ui.md § Motion).
|
// (03-board-ui.md § Motion).
|
||||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||||
|
// And which of them are folded, frozen with them: a collapsed lane's shadow is its slim
|
||||||
|
// strip rather than the slot its preserved `width` would buy (`DragSession.laneCollapsed`).
|
||||||
|
collapsed: members.map { LaneLayoutMath.isCollapsed($0) },
|
||||||
source: store,
|
source: store,
|
||||||
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
|
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
|
||||||
// the lanes with the dragged run removed — the strip proposal's own index space. A
|
// the lanes with the dragged run removed — the strip proposal's own index space. A
|
||||||
@@ -617,7 +887,14 @@ struct LaneView: View, Equatable {
|
|||||||
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
||||||
private var dragReplica: some View {
|
private var dragReplica: some View {
|
||||||
let count = max(1, draggedLaneCount)
|
let count = max(1, draggedLaneCount)
|
||||||
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
// **A folded lane needs no anchoring, and must not have any.** The padding exists because the
|
||||||
|
// grab surface — the title bar — is a sliver at the top of a full-height replica, so the image
|
||||||
|
// has to be shifted for the cursor to keep the point it grabbed. The strip *is* the whole lane:
|
||||||
|
// SwiftUI centres the preview on the view the drag started from, which here is already the
|
||||||
|
// thing being carried, so a shift would be the very displacement the anchor exists to undo.
|
||||||
|
let anchor = isCollapsed
|
||||||
|
? DragPreviewAnchor.Padding.none
|
||||||
|
: DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||||||
return ZStack {
|
return ZStack {
|
||||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||||
@@ -648,31 +925,38 @@ struct LaneView: View, Equatable {
|
|||||||
private var replicaFace: some View {
|
private var replicaFace: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
accentBand
|
accentBand
|
||||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
// **A folded lane's replica is its strip** (03-board-ui.md § Motion's faithful-replica
|
||||||
headerContent
|
// rule, read literally): the drop leaves the lane folded, so carrying a squeezed header and
|
||||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
// a stack of card rows would show the user a lane the board is not about to draw.
|
||||||
ForEach(renderedCards.prefix(12)) { card in
|
if isCollapsed {
|
||||||
HStack(alignment: .firstTextBaseline,
|
collapsedReplicaStrip
|
||||||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
} else {
|
||||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||||
.foregroundStyle(.secondary)
|
headerContent
|
||||||
.imageScale(.medium)
|
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||||
Text(card.title.value ?? "Untitled")
|
ForEach(renderedCards.prefix(12)) { card in
|
||||||
.boardFont(.body)
|
HStack(alignment: .firstTextBaseline,
|
||||||
.lineLimit(2)
|
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||||
Spacer(minLength: 0)
|
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.imageScale(.medium)
|
||||||
|
Text(card.title.value ?? "Untitled")
|
||||||
|
.boardFont(.body)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||||
|
.fill(BoardSurface.cardPlate)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
Spacer(minLength: 0)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
|
||||||
.fill(BoardSurface.cardPlate)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
|
||||||
}
|
}
|
||||||
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||||||
// The lane's own wash over an opaque base — the replica floats over whatever the cursor is
|
// The lane's own wash over an opaque base — the replica floats over whatever the cursor is
|
||||||
@@ -684,12 +968,45 @@ struct LaneView: View, Equatable {
|
|||||||
.dragReplicaShadow(zoom: zoom)
|
.dragReplicaShadow(zoom: zoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The collapsed strip as the replica draws it: the same three pieces in the same column, with the
|
||||||
|
/// travel a *fixed* figure rather than a measured one.
|
||||||
|
///
|
||||||
|
/// A drag image is a snapshot — no geometry observers — so the live strip's measured travel is not
|
||||||
|
/// available here, and reusing `collapsedTitleTravel` would let the replica write into the state the
|
||||||
|
/// resting lane truncates by. The replica's own height less its furniture is the honest figure, and
|
||||||
|
/// it is the same arithmetic the live strip's layout performs.
|
||||||
|
private var collapsedReplicaStrip: some View {
|
||||||
|
VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||||
|
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.imageScale(.medium)
|
||||||
|
countBadgeText("\(lane.cards.count)")
|
||||||
|
// The live strip's own travel, **fixed rather than flexible**: a drag image is a snapshot
|
||||||
|
// and must not observe its own geometry (it would write into the state the resting lane
|
||||||
|
// truncates by, from inside a preview). The figure is honest because the replica is drawn at
|
||||||
|
// the lane's own height, so the leftover room is the same leftover room.
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: collapsedTitleTravel)
|
||||||
|
.overlay { rotatedTitle(travel: collapsedTitleTravel) }
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
|
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||||
|
}
|
||||||
|
|
||||||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||||||
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
||||||
/// ask the same question the drawing does, and two derivations of one figure would be two
|
/// ask the same question the drawing does, and two derivations of one figure would be two
|
||||||
/// answers.
|
/// answers.
|
||||||
|
///
|
||||||
|
/// **A folded lane takes the strip's exact width, floor and all**: the floor exists for a lane that
|
||||||
|
/// has not measured itself and so has no width to draw at, and the strip's width is a constant
|
||||||
|
/// nobody has to measure (`BoardMetrics.collapsedLaneWidth`) — applying a 6-em floor to a 3.4-em
|
||||||
|
/// strip would draw a replica almost twice as wide as the thing it is a replica of.
|
||||||
private var replicaWidth: CGFloat {
|
private var replicaWidth: CGFloat {
|
||||||
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
guard !isCollapsed else { return BoardMetrics.collapsedLaneWidth(bodyPointSize: pointSize) }
|
||||||
|
return max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var replicaHeight: CGFloat {
|
private var replicaHeight: CGFloat {
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import CoreGraphics
|
|||||||
///
|
///
|
||||||
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
|
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
|
||||||
/// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no
|
/// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no
|
||||||
/// code of its own.
|
/// code of its own. **A collapsed lane's cards ride the same mechanism** (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes: "cards inside are not rendered"): a lane drawn as a slim strip lays out no card
|
||||||
|
/// faces, so it registers no frames and `nearest` cannot step into one. The `⌥`-jumps are the half
|
||||||
|
/// that *does* need code, because they name an absolute destination rather than a neighbour — see
|
||||||
|
/// `firstCard(scanning:filter:)`.
|
||||||
///
|
///
|
||||||
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
|
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
|
||||||
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
|
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
|
||||||
@@ -85,6 +89,33 @@ public enum NavigationMath {
|
|||||||
}
|
}
|
||||||
return best?.id
|
return best?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The first card the board is actually showing**, scanning `lanes` in the order given — the
|
||||||
|
/// landing every absolute keyboard destination shares: ⌥←/⌥→'s first/last lane, and the seed an
|
||||||
|
/// arrow from an empty selection takes (04-interactions.md ▸ Grammar).
|
||||||
|
///
|
||||||
|
/// Three ways a lane is scanned past, and they are one rule — *the user cannot see into it*:
|
||||||
|
///
|
||||||
|
/// - it holds no cards;
|
||||||
|
/// - the search query hid all of them (04 § Search — "a jump that landed nowhere because the end
|
||||||
|
/// lane happens to be empty would be a dead key", and a lane the query emptied is empty to the
|
||||||
|
/// eye);
|
||||||
|
/// - it is **collapsed** (03-board-ui.md § Lane ▸ Collapsed lanes): the slim strip draws no card
|
||||||
|
/// faces at all, so a jump that landed on one would select something rendered nowhere and leave
|
||||||
|
/// the arrows with no frame to step from. The strip itself stays selectable **as a lane**, which
|
||||||
|
/// is the lane domain's business and not this scan's.
|
||||||
|
///
|
||||||
|
/// Pure, and here rather than in the view for `SortMath`'s reason: the three skips are lines of
|
||||||
|
/// test instead of a board to drive.
|
||||||
|
public static func firstCard(
|
||||||
|
scanning lanes: some Sequence<Lane>,
|
||||||
|
filter: SearchFilter = .inactive
|
||||||
|
) -> ItemID? {
|
||||||
|
for lane in lanes where !LaneLayoutMath.isCollapsed(lane) {
|
||||||
|
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Within-lane sort
|
// MARK: - Within-lane sort
|
||||||
|
|||||||
@@ -53,10 +53,28 @@ enum NewCardTarget {
|
|||||||
lastActiveLaneID: ItemID?,
|
lastActiveLaneID: ItemID?,
|
||||||
snapshot: BoardModel
|
snapshot: BoardModel
|
||||||
) -> Resolution? {
|
) -> Resolution? {
|
||||||
let lanes = snapshot.lanes
|
// **Collapsed lanes are not creation targets** (03-board-ui.md § Lane ▸ Collapsed lanes:
|
||||||
|
// "⌘N new-card targeting skips collapsed lanes"): the placeholder is a pseudo-card drawn in the
|
||||||
|
// lane's masonry (`LaneSlot.placeholder`), and a folded lane draws none — so a card created
|
||||||
|
// there would be a focused text field rendered nowhere, with no way to type a title into it and
|
||||||
|
// no way out but Escape. Skipping is therefore not a preference but the only coherent answer,
|
||||||
|
// and it is applied to every branch below by narrowing the lane list once.
|
||||||
|
//
|
||||||
|
// **A board whose every lane is folded has no target at all**, which is exactly the zero-lane
|
||||||
|
// board's answer and reaches the same place: `nil` is New Card's `disabled` condition as well
|
||||||
|
// as its refusal, so the item greys out rather than doing nothing when pressed.
|
||||||
|
let lanes = snapshot.lanes.filter { !LaneLayoutMath.isCollapsed($0) }
|
||||||
guard !lanes.isEmpty else { return nil }
|
guard !lanes.isEmpty else { return nil }
|
||||||
|
|
||||||
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
|
// **A selection inside a folded lane falls through rather than refusing** — the stale
|
||||||
|
// selection's rule verbatim, for its reason: the user pressed ⌘N and the board has lanes it
|
||||||
|
// can create into. So collapsing a lane with one of its cards selected leaves ⌘N working, at
|
||||||
|
// the last-active lane below. `flattenAnchor` itself is deliberately untouched: it is
|
||||||
|
// **shared with paste** ("the same anchor serves paste"), and a paste into a folded lane is
|
||||||
|
// perfectly coherent — it writes a rank, it renders nothing, and the card is there when the
|
||||||
|
// lane unfolds.
|
||||||
|
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot),
|
||||||
|
lanes.contains(where: { $0.id == anchor.laneID }) {
|
||||||
return anchor
|
return anchor
|
||||||
}
|
}
|
||||||
// Nothing selected, a trash selection, or a stale one — the ids name nothing the board
|
// Nothing selected, a trash selection, or a stale one — the ids name nothing the board
|
||||||
|
|||||||
@@ -50,13 +50,18 @@ private let everyOperation: [WriteOperation] = [
|
|||||||
.purge(title: "Fix login"),
|
.purge(title: "Fix login"),
|
||||||
.style(title: "Fix login"),
|
.style(title: "Fix login"),
|
||||||
.resize(title: "Fix login"),
|
.resize(title: "Fix login"),
|
||||||
|
.collapse(title: "Todo"),
|
||||||
|
.expand(title: "Todo"),
|
||||||
.rename(title: "Fix login"),
|
.rename(title: "Fix login"),
|
||||||
.importAttachment(filename: "photo.png"),
|
.importAttachment(filename: "photo.png"),
|
||||||
.listAttachments,
|
.listAttachments,
|
||||||
.renumberChildren,
|
.renumberChildren,
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one.
|
/// The titled cases whose untitled fallback is the family's kind-free "the item" — the invariant the
|
||||||
|
/// loop below reads. `.collapse` / `.expand` are deliberately absent: only a lane carries `collapsed`,
|
||||||
|
/// so their fallback names the kind outright ("Couldn't collapse the lane") and there is no guess for
|
||||||
|
/// the rule to protect against (see `BannerCenter.actionPhrase(for:)`).
|
||||||
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
|
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
|
||||||
(.move(title: "Fix login"), .move(title: nil)),
|
(.move(title: "Fix login"), .move(title: nil)),
|
||||||
(.reorder(title: "Fix login"), .reorder(title: nil)),
|
(.reorder(title: "Fix login"), .reorder(title: nil)),
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ struct BoardZoomMetricsTests {
|
|||||||
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
||||||
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||||||
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
||||||
|
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
||||||
|
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
||||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ private func baseBoard(_ fixture: WriterFixture) throws {
|
|||||||
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The base board with its first lane already folded — the "before" an Expand needs
|
||||||
|
/// (03-board-ui.md § Lane ▸ Collapsed lanes).
|
||||||
|
private func baseBoardWithFoldedTodo(_ fixture: WriterFixture) throws {
|
||||||
|
try baseBoard(fixture)
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
|
/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
|
||||||
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
|
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
|
||||||
/// would rightly withhold.
|
/// would rightly withhold.
|
||||||
@@ -184,6 +191,36 @@ struct CommitMessageSingleEventTests {
|
|||||||
#expect(message == "Resize lane 'Todo' to 2×")
|
#expect(message == "Resize lane 'Todo' to 2×")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The fold has its own two words** (03-board-ui.md § Lane ▸ Collapsed lanes; 06 ▸ Commit
|
||||||
|
/// messages' vocabulary), told apart by diff shape alone exactly as the trash pair is.
|
||||||
|
@Test("A folded lane is a Collapse, and an unfolded one an Expand")
|
||||||
|
func collapsingALane() throws {
|
||||||
|
let collapsed = try compose { fixture in
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
}
|
||||||
|
#expect(collapsed == "Collapse lane 'Todo'")
|
||||||
|
|
||||||
|
let expanded = try compose(
|
||||||
|
board: { fixture in
|
||||||
|
try baseBoardWithFoldedTodo(fixture)
|
||||||
|
},
|
||||||
|
change: { fixture in
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(expanded == "Expand lane 'Todo'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The narrator composes off the **reading**, not the key: `collapsed: false` beside an absent key
|
||||||
|
/// moves no lane on screen, so it is the "sequence, not raw `order`" discipline one field over.
|
||||||
|
@Test("A `collapsed: false` that changes no lane composes nothing")
|
||||||
|
func anExplicitFalseIsNotAnEvent() throws {
|
||||||
|
let message = try compose { fixture in
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: false\n---\n\n")
|
||||||
|
}
|
||||||
|
#expect(message == ChangeNarrator.unnamedSubject)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("An attachment composes Attach, named by the card it landed on")
|
@Test("An attachment composes Attach, named by the card it landed on")
|
||||||
func attachingAFile() throws {
|
func attachingAFile() throws {
|
||||||
let message = try compose { fixture in
|
let message = try compose { fixture in
|
||||||
|
|||||||
@@ -748,6 +748,52 @@ struct FrontmatterLenientFieldTests {
|
|||||||
#expect(try document("width: banana").width == .malformed(raw: "banana"))
|
#expect(try document("width: banana").width == .malformed(raw: "banana"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **`collapsed` reads like `width`, one type over** (03-board-ui.md § Lane ▸ Collapsed lanes;
|
||||||
|
/// 01-storage-format.md § Frontmatter's lane table): a boolean scalar reads as itself — including
|
||||||
|
/// YAML 1.1's other spellings, which the parser has already resolved — and a *quoted* one of those
|
||||||
|
/// words coerces, the numeric-string precedent applied to a boolean field.
|
||||||
|
@Test func collapsedReadsBooleansAndTheirQuotedSpellings() throws {
|
||||||
|
#expect(try document("collapsed: true").collapsed == .valid(true))
|
||||||
|
#expect(try document("collapsed: false").collapsed == .valid(false))
|
||||||
|
// YAML 1.1 resolves these to booleans unquoted, so they arrive here already typed.
|
||||||
|
#expect(try document("collapsed: yes").collapsed == .valid(true))
|
||||||
|
#expect(try document("collapsed: off").collapsed == .valid(false))
|
||||||
|
// Quoted, they are strings — and a string that spells a boolean word has a sensible reading.
|
||||||
|
#expect(try document("collapsed: \"true\"").collapsed == .valid(true))
|
||||||
|
#expect(try document("collapsed: \"No\"").collapsed == .valid(false))
|
||||||
|
#expect(try document("collapsed: \"ON\"").collapsed == .valid(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Everything else has no boolean reading, and therefore renders as expanded** — the default is
|
||||||
|
/// the absent key's, so an unreadable value can never fold a lane by accident.
|
||||||
|
@Test func collapsedIsMalformedForEveryNonBooleanReading() throws {
|
||||||
|
#expect(try document("schema: 1").collapsed == .missing)
|
||||||
|
#expect(try document("collapsed: null").collapsed == .missing)
|
||||||
|
#expect(try document("collapsed: maybe").collapsed == .malformed(raw: "maybe"))
|
||||||
|
#expect(try document("collapsed: 1").collapsed == .malformed(raw: "1"))
|
||||||
|
#expect(try document("collapsed: [true]").collapsed == .malformed(raw: "[true]"))
|
||||||
|
#expect(try document("collapsed: {a: 1}").collapsed == .malformed(raw: "{a: 1}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lenient field with no reading files a coerce-tier trace and leaves the bytes exactly as
|
||||||
|
/// written — the family's posture, `collapsed` included.
|
||||||
|
@Test func anUnreadableCollapsedFilesATraceAndRoundTrips() throws {
|
||||||
|
let text = "---\nschema: 1\ncollapsed: maybe\nwidth: 2\n---\nbody\n"
|
||||||
|
let parsed = try FrontmatterDocument.parse(text)
|
||||||
|
#expect(parsed.serialized() == text)
|
||||||
|
#expect(parsed.coercedFields == [CoercedField(key: "collapsed", raw: "maybe")])
|
||||||
|
// A readable value is an absence of trace, not a trace of a value.
|
||||||
|
#expect(try document("collapsed: true").coercedFields.isEmpty)
|
||||||
|
#expect(try document("collapsed: false").coercedFields.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key is the schema's, so the card window's Details section does not list it beside a user's
|
||||||
|
/// own overlay keys (`FrontmatterKeys.schemaOwned`).
|
||||||
|
@Test func collapsedIsSchemaOwnedRatherThanAnUnknownKey() throws {
|
||||||
|
#expect(FrontmatterKeys.schemaOwned.contains(FrontmatterKeys.collapsed))
|
||||||
|
#expect(try document("collapsed: true").unknownFields.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func malformedLenientValuesStillRoundTrip() throws {
|
@Test func malformedLenientValuesStillRoundTrip() throws {
|
||||||
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
|
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
|
||||||
let document = try FrontmatterDocument.parse(text)
|
let document = try FrontmatterDocument.parse(text)
|
||||||
|
|||||||
@@ -183,6 +183,47 @@ struct NavigationMathTests {
|
|||||||
// From the row itself, ↓ reaches the card below it: navigation crosses back.
|
// From the row itself, ↓ reaches the card below it: navigation crosses back.
|
||||||
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
|
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **A collapsed lane is scanned past by the absolute destinations** (03-board-ui.md § Lane ▸
|
||||||
|
/// Collapsed lanes: "cards inside are not rendered — they are excluded from … keyboard spatial
|
||||||
|
/// navigation"), which is what ⌥←/⌥→ and the empty-selection seed land through.
|
||||||
|
///
|
||||||
|
/// The *relative* half — a plain or ⇧ arrow — needs nothing: a folded lane registers no card frame,
|
||||||
|
/// so `nearest` has no candidate to reject, exactly as the hidden trash has none.
|
||||||
|
/// `@MainActor` where its neighbours are not, and only because the fixture is: a board on disk is
|
||||||
|
/// the honest way to ask what a *read-side* fold reads as, and `WriterFixture` is main-actor bound.
|
||||||
|
@MainActor
|
||||||
|
@Test("The absolute destinations scan past a folded lane, an empty one, and one the query emptied")
|
||||||
|
func firstCardSkipsFoldedLanes() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
// Lane 1 folded, lane 2 open: the first card the *board* is showing is lane 2's.
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
let lanes = try load(fixture).lanes
|
||||||
|
|
||||||
|
#expect(NavigationMath.firstCard(scanning: lanes) == card5)
|
||||||
|
// Reversed — ⌥→'s scan — the last showing lane is lane 2 as well, since lane 3 is empty and
|
||||||
|
// lane 1 is folded.
|
||||||
|
#expect(NavigationMath.firstCard(scanning: lanes.reversed()) == card5)
|
||||||
|
|
||||||
|
// The filter narrows the same scan: "Sixth" is in the open lane, "First" is in the folded one
|
||||||
|
// and stays unreachable however well it matches.
|
||||||
|
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "Sixth")) == card6)
|
||||||
|
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "First")) == nil)
|
||||||
|
|
||||||
|
// Every lane folded: there is nowhere to land at all, which is the same answer an empty board
|
||||||
|
// gives and leaves the press inert rather than selecting something drawn nowhere.
|
||||||
|
let allFolded = try WriterFixture()
|
||||||
|
defer { allFolded.tearDown() }
|
||||||
|
try allFolded.item("", Item.board)
|
||||||
|
try allFolded.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
try allFolded.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
#expect(NavigationMath.firstCard(scanning: try load(allFolded).lanes) == nil)
|
||||||
|
|
||||||
|
// And unfolding restores it, key removal and all — nothing about the cards changed.
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n\n")
|
||||||
|
#expect(NavigationMath.firstCard(scanning: try load(fixture).lanes) == card1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The registry the arrows and the band read
|
// MARK: - The registry the arrows and the band read
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BoardStore.setLaneCollapsed` — the one commit point every collapse gesture shares (03-board-ui.md
|
||||||
|
/// § Lane ▸ Collapsed lanes): the header chevron, the context menu's Collapse/Expand Lane row, and a
|
||||||
|
/// click on the collapsed strip's body.
|
||||||
|
///
|
||||||
|
/// `LaneWidthWriteTests`' suite, one key over, and deliberately its twin: collapse is document state
|
||||||
|
/// exactly as width is, so the claims worth making are the same claims — the value that lands, the
|
||||||
|
/// stamps that follow it, the remove-at-default rule, and everything else surviving byte-for-byte. Real
|
||||||
|
/// stores over real temp boards, read back as **raw bytes** rather than through the app's own read path.
|
||||||
|
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A lane index carrying an explicit `collapsed:` beside a `width:` — half of what is under test is
|
||||||
|
/// what happens to values that are already there, and the other half is that `width` is not one of them.
|
||||||
|
private func laneText(order: String, title: String, width: String?, collapsed: String?) -> String {
|
||||||
|
let widthLine = width.map { "width: \($0)\n" } ?? ""
|
||||||
|
let collapsedLine = collapsed.map { "collapsed: \($0)\n" } ?? ""
|
||||||
|
return """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
\(widthLine)\(collapsedLine)project: lanework # agent overlay
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
modified: 2026-02-02T09:00:00Z
|
||||||
|
modified-by: claude
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A board with one plain lane, one already folded at 3×, and one whose frontmatter is readable but
|
||||||
|
/// uneditable.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "2", collapsed: nil))
|
||||||
|
try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3", collapsed: "true"))
|
||||||
|
try fixture.item(Ident.lane3, Item.uneditable)
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
|
||||||
|
/// through a fold byte-for-byte, in order. `width:` is deliberately **not** in the exclusion list: the
|
||||||
|
/// whole point is that a collapse leaves it alone.
|
||||||
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
|
// `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("collapsed:") && !$0.hasPrefix("kind:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane {
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
return try #require(model.lanes.first { $0.id.rawValue == id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ lane collapse")
|
||||||
|
struct LaneCollapseWriteTests {
|
||||||
|
|
||||||
|
@Test("A collapse writes `collapsed: true`, stamps modified, clears modified-by, and touches nothing else")
|
||||||
|
func collapseWritesTheKeyAndStamps() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexText(Ident.lane1)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)
|
||||||
|
|
||||||
|
let after = try fixture.indexText(Ident.lane1)
|
||||||
|
#expect(after.contains("collapsed: true"))
|
||||||
|
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||||
|
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
|
||||||
|
|
||||||
|
// Everything the write does not own survives exactly, in order — **`width: 2` included**, which
|
||||||
|
// is the whole of "expanding restores the lane the user had".
|
||||||
|
#expect(untouchedLines(after) == untouchedLines(before))
|
||||||
|
#expect(after.contains("width: 2"))
|
||||||
|
|
||||||
|
let lane = try loadedLane(Ident.lane1, in: fixture)
|
||||||
|
#expect(lane.collapsed == .valid(true))
|
||||||
|
#expect(lane.width == .valid(2), "the fold does not read or rewrite the width key")
|
||||||
|
#expect(LaneLayoutMath.isCollapsed(lane))
|
||||||
|
#expect(lane.modifiedBy.isMissing)
|
||||||
|
let modified = try #require(lane.modified.value)
|
||||||
|
#expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write")
|
||||||
|
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Expanding removes the key rather than writing `false` — the remove-at-default family")
|
||||||
|
func expandRemovesTheKey() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)
|
||||||
|
|
||||||
|
let after = try fixture.indexText(Ident.lane2)
|
||||||
|
#expect(!after.contains("collapsed"), "a default lane's frontmatter stays clean")
|
||||||
|
#expect(!after.contains("false"))
|
||||||
|
let lane = try loadedLane(Ident.lane2, in: fixture)
|
||||||
|
#expect(lane.collapsed.isMissing)
|
||||||
|
#expect(!LaneLayoutMath.isCollapsed(lane))
|
||||||
|
// And the preserved width is what the lane comes back as.
|
||||||
|
#expect(lane.width == .valid(3))
|
||||||
|
#expect(after.contains("width: 3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A fold survives a width change, and a width change survives a fold")
|
||||||
|
func theTwoKeysAreIndependent() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// The stepper still edits the preserved key while the lane is folded — 03's "the width stepper
|
||||||
|
// and ⌥⌘→/⌥⌘← still edit the preserved key, which is what the lane will be when it unfolds".
|
||||||
|
store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 5)
|
||||||
|
let afterResize = try loadedLane(Ident.lane2, in: fixture)
|
||||||
|
#expect(afterResize.width == .valid(5))
|
||||||
|
#expect(afterResize.collapsed == .valid(true), "resizing a folded lane does not unfold it")
|
||||||
|
|
||||||
|
let reopened = try BoardStore(rootURL: fixture.root)
|
||||||
|
reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)
|
||||||
|
#expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Setting the state a lane already reads writes nothing at all")
|
||||||
|
func unchangedStateIsANoOp() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let folded = try fixture.indexData(Ident.lane2)
|
||||||
|
let expanded = try fixture.indexData(Ident.lane1)
|
||||||
|
|
||||||
|
// Neither may stamp `modified`: a second Collapse on a folded lane, and an Expand on a lane
|
||||||
|
// that has no key to remove.
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: true)
|
||||||
|
#expect(try fixture.indexData(Ident.lane2) == folded)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false)
|
||||||
|
#expect(try fixture.indexData(Ident.lane1) == expanded)
|
||||||
|
#expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A hand-written `collapsed: false` reads as expanded and is preserved until the app edits it")
|
||||||
|
func handWrittenFalseIsPreserved() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "false"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexData(Ident.lane1)
|
||||||
|
|
||||||
|
#expect(!LaneLayoutMath.isCollapsed(try loadedLane(Ident.lane1, in: fixture)))
|
||||||
|
// Reads as expanded, asked to expand: the unchanged-state guard fires before the
|
||||||
|
// remove-at-default rule can, so the legal hand edit stays byte-for-byte — `width: 1`'s rule.
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false)
|
||||||
|
#expect(try fixture.indexData(Ident.lane1) == before)
|
||||||
|
|
||||||
|
// A real change replaces it with the one shape the app writes.
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)
|
||||||
|
let after = try fixture.indexText(Ident.lane1)
|
||||||
|
#expect(after.contains("collapsed: true"))
|
||||||
|
#expect(!after.contains("collapsed: false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A value with no boolean reading is replaced on collapse and removed on expand")
|
||||||
|
func aMalformedValueIsOverwritten() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "maybe"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Lenient on the read side — renders expanded with the bytes left alone — so a collapse is a
|
||||||
|
// real change and overwrites it.
|
||||||
|
#expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.collapsed
|
||||||
|
== .malformed(raw: "maybe"))
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)
|
||||||
|
|
||||||
|
let after = try fixture.indexText(Ident.lane1)
|
||||||
|
#expect(after.contains("collapsed: true"))
|
||||||
|
#expect(!after.contains("maybe"))
|
||||||
|
#expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane that is not in the snapshot is a no-op")
|
||||||
|
func unknownLaneIsANoOp() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexData(Ident.lane1)
|
||||||
|
|
||||||
|
// The lane vanished under the gesture (or never existed): the reload that removed it is the
|
||||||
|
// authority, and inventing a file here would be the app disagreeing with disk.
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.indexless), collapsed: true)
|
||||||
|
|
||||||
|
#expect(!fixture.exists(Ident.indexless))
|
||||||
|
#expect(try fixture.indexData(Ident.lane1) == before)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Collapsing a lane whose card is selected selects the lane")
|
||||||
|
func collapsingReSelectsTheLane() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.board()
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
|
||||||
|
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let lane = ItemID(rawValue: Ident.lane1)
|
||||||
|
let card = ItemID(rawValue: Ident.card1)
|
||||||
|
|
||||||
|
store.select([card], in: .board)
|
||||||
|
store.setLaneCollapsed(lane, collapsed: true)
|
||||||
|
|
||||||
|
// The card stops being rendered, so a selection naming it would leave the arrows with no frame
|
||||||
|
// to step from — the lane the user just folded is what they are holding instead.
|
||||||
|
#expect(store.selection.ids == [lane])
|
||||||
|
#expect(store.selection.container == .board)
|
||||||
|
|
||||||
|
// A selection elsewhere is untouched, and so is one of the lane itself.
|
||||||
|
let other = ItemID(rawValue: Ident.lane2)
|
||||||
|
store.select([other], in: .board)
|
||||||
|
store.setLaneCollapsed(lane, collapsed: false)
|
||||||
|
#expect(store.selection.ids == [other], "expanding never touches the selection")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Undo puts the fold back, and each direction names itself")
|
||||||
|
func undoAndRedoRoundTrip() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let history = NativeHistoryProvider()
|
||||||
|
store.history = history
|
||||||
|
let lane = ItemID(rawValue: Ident.lane1)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(lane, collapsed: true)
|
||||||
|
#expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))
|
||||||
|
#expect(history.undoActionName == "Collapse Lane")
|
||||||
|
|
||||||
|
history.undo()
|
||||||
|
#expect(try loadedLane(Ident.lane1, in: fixture).collapsed.isMissing,
|
||||||
|
"the inverse removes the key rather than writing `false`")
|
||||||
|
#expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2),
|
||||||
|
"and it never touched the width")
|
||||||
|
|
||||||
|
history.redo()
|
||||||
|
#expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))
|
||||||
|
|
||||||
|
// The other direction names itself: an expand's row must not read "Collapse".
|
||||||
|
let reopened = try BoardStore(rootURL: fixture.root)
|
||||||
|
let reopenedHistory = NativeHistoryProvider()
|
||||||
|
reopened.history = reopenedHistory
|
||||||
|
reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)
|
||||||
|
#expect(reopenedHistory.undoActionName == "Expand Lane")
|
||||||
|
reopenedHistory.undo()
|
||||||
|
#expect(try loadedLane(Ident.lane2, in: fixture).collapsed == .valid(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A readable-but-uneditable lane refuses the write, banners it in the gesture's own word, and keeps its bytes")
|
||||||
|
func uneditableLaneBannersAndChangesNothing() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexData(Ident.lane3)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane3), collapsed: true)
|
||||||
|
|
||||||
|
#expect(try fixture.indexData(Ident.lane3) == before)
|
||||||
|
#expect(try fixture.indexText(Ident.lane3) == Item.uneditable)
|
||||||
|
|
||||||
|
// `performWrite` posts before it rethrows and the store swallows the rethrow — the banner is the
|
||||||
|
// only thing that says the gesture did not happen, and it must say *collapse* rather than
|
||||||
|
// resize: the user pressed Collapse Lane.
|
||||||
|
#expect(store.banners.oneShots.count == 1)
|
||||||
|
let posted = try #require(store.banners.oneShots.first)
|
||||||
|
#expect(posted.error.operation == .collapse(title: "Odd"),
|
||||||
|
"the title is enriched off the document the write refused")
|
||||||
|
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||||
|
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't collapse 'Odd' — "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A drop on the strip appends at the lane's end** (03-board-ui.md § Lane ▸ Collapsed lanes:
|
||||||
|
/// "dropping a drag onto the collapsed strip appends the payload at the lane's END, like an
|
||||||
|
/// end-of-lane drop").
|
||||||
|
///
|
||||||
|
/// It is asserted with **no window at all**, which is the point rather than a convenience: a folded
|
||||||
|
/// lane draws no cards, so there is no row geometry a position could be resolved against and nothing
|
||||||
|
/// about the pointer can change the answer — the branch is taken before the cursor is ever read.
|
||||||
|
@Test("A card drag over a folded lane proposes the end of its card list, cursor notwithstanding")
|
||||||
|
func dropOnTheStripAppendsAtTheEnd() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.board()
|
||||||
|
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
|
||||||
|
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
|
||||||
|
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second")
|
||||||
|
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
|
||||||
|
// The destination: folded, holding two cards of its own.
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n")
|
||||||
|
try fixture.card(Ident.card4, in: Ident.lane2, order: "1024", title: "Fourth")
|
||||||
|
try fixture.card(Ident.indexless, in: Ident.lane2, order: "2048", title: "Fifth")
|
||||||
|
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let registry = LaneDropRegistry()
|
||||||
|
let drops = BoardDropContext(
|
||||||
|
store: store,
|
||||||
|
session: session,
|
||||||
|
registry: registry,
|
||||||
|
gap: 12,
|
||||||
|
window: { nil },
|
||||||
|
stripFrame: { .zero },
|
||||||
|
standard: { 260 }
|
||||||
|
)
|
||||||
|
let dragged = ItemID(rawValue: Ident.card1)
|
||||||
|
session.beginCards(
|
||||||
|
[dragged],
|
||||||
|
folders: [store.rootURL
|
||||||
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
|
.appendingPathComponent(Ident.card1, isDirectory: true)],
|
||||||
|
heights: [44],
|
||||||
|
container: .board,
|
||||||
|
source: store
|
||||||
|
)
|
||||||
|
|
||||||
|
drops.retargetCards(inLane: ItemID(rawValue: Ident.lane2))
|
||||||
|
let proposal = try #require(session.proposal)
|
||||||
|
#expect(proposal.container == .lane(ItemID(rawValue: Ident.lane2)))
|
||||||
|
#expect(proposal.index == 2, "the end of the folded lane's two cards")
|
||||||
|
|
||||||
|
// Into the lane the run came *from*, the index is counted in the resting layout — the dragged
|
||||||
|
// card lifted out — which is the space every proposal in this app is counted in.
|
||||||
|
drops.retargetCards(inLane: ItemID(rawValue: Ident.lane1))
|
||||||
|
#expect(session.proposal?.index == 2, "three cards minus the one in flight")
|
||||||
|
|
||||||
|
// Expanded, the lane goes back to needing geometry: with no window and no registered grid there
|
||||||
|
// is nothing to resolve, and the standing proposal simply holds (the hysteresis contract).
|
||||||
|
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
|
||||||
|
let reopened = try BoardStore(rootURL: fixture.root)
|
||||||
|
let reopenedDrops = BoardDropContext(
|
||||||
|
store: reopened, session: session, registry: registry, gap: 12,
|
||||||
|
window: { nil }, stripFrame: { .zero }, standard: { 260 }
|
||||||
|
)
|
||||||
|
session.propose(nil)
|
||||||
|
reopenedDrops.retargetCards(inLane: ItemID(rawValue: Ident.lane2))
|
||||||
|
#expect(session.proposal == nil, "an expanded lane answers from its grid, and there is none here")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A read-only board refuses the write without a second banner")
|
||||||
|
func readOnlyBoardRefusesQuietly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
let before = try fixture.indexData(Ident.lane1)
|
||||||
|
|
||||||
|
store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)
|
||||||
|
|
||||||
|
// The lock row is already standing; a refusal per gesture would bury it under echoes of itself.
|
||||||
|
#expect(try fixture.indexData(Ident.lane1) == before)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ private func snapped(_ liveWidth: CGFloat, current: Int, range: ClosedRange<Int>
|
|||||||
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
|
/// leniency is a *read-side* rule (01-storage-format.md § Frontmatter), so the only honest way to
|
||||||
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
|
/// ask "what does a malformed width display as" is to put the malformed bytes on disk and load
|
||||||
/// them.
|
/// them.
|
||||||
private func lanes(widths: [String?]) throws -> [Lane] {
|
/// `collapsed` is the parallel list of `collapsed:` values (`nil` writes no key), padded with `nil`
|
||||||
|
/// when it is shorter than `widths` — a board with no folded lane passes none at all and reads exactly
|
||||||
|
/// as it did before the key existed.
|
||||||
|
private func lanes(widths: [String?], collapsed: [String?] = []) throws -> [Lane] {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("LaneLayoutMathTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
@@ -61,6 +64,9 @@ private func lanes(widths: [String?]) throws -> [Lane] {
|
|||||||
if let width {
|
if let width {
|
||||||
frontmatter += "width: \(width)\n"
|
frontmatter += "width: \(width)\n"
|
||||||
}
|
}
|
||||||
|
if index < collapsed.count, let value = collapsed[index] {
|
||||||
|
frontmatter += "collapsed: \(value)\n"
|
||||||
|
}
|
||||||
frontmatter += "---\n"
|
frontmatter += "---\n"
|
||||||
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
let folder = root.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
@@ -545,3 +551,152 @@ struct LaneRedivideTests {
|
|||||||
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0)
|
#expect(LaneLayoutMath.resizeWindowDelta(from: 4, to: 4, fittingUnits: fit, step: step) == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Collapsed lanes
|
||||||
|
|
||||||
|
/// **The fold's arithmetic** (03-board-ui.md § Lane ▸ Collapsed lanes, settled 2026-08-08): a collapsed
|
||||||
|
/// lane takes a fixed slim strip and is *not* part of the width re-division — its width comes off the
|
||||||
|
/// top and what is left divides among the expanded lanes alone.
|
||||||
|
///
|
||||||
|
/// The exact-fill identity is the property worth pinning rather than any one number, because it is what
|
||||||
|
/// "every lane is always on screen" means arithmetically once two kinds of lane share the strip:
|
||||||
|
/// `C·collapsedWidth + T·standard + (T + C + 1)·gap` is the whole strip, always.
|
||||||
|
@Suite("LaneLayoutMath ▸ collapsed lanes")
|
||||||
|
struct LaneCollapseLayoutTests {
|
||||||
|
|
||||||
|
/// A slim strip in this suite. Deliberately not `BoardMetrics`' figure: the math takes it as a
|
||||||
|
/// parameter, and a test that read the metric would pin the metric rather than the arithmetic.
|
||||||
|
private let strip: CGFloat = 44
|
||||||
|
|
||||||
|
@Test("The reading is lenient: only a real `true` folds a lane")
|
||||||
|
func onlyTrueFolds() throws {
|
||||||
|
#expect(LaneLayoutMath.isCollapsed(try lane(width: nil)) == false)
|
||||||
|
let folded = try lanes(widths: [nil], collapsed: ["true"])
|
||||||
|
#expect(LaneLayoutMath.isCollapsed(try #require(folded.first)))
|
||||||
|
|
||||||
|
// An explicit `false`, a quoted word, and a value with no boolean reading at all — three
|
||||||
|
// shapes, two readings, and the malformed one is expanded because the default is the absent
|
||||||
|
// key's.
|
||||||
|
for (raw, expected) in [("false", false), ("\"yes\"", true), ("maybe", false), ("3", false)] {
|
||||||
|
let loaded = try lanes(widths: [nil], collapsed: [raw])
|
||||||
|
#expect(LaneLayoutMath.isCollapsed(try #require(loaded.first)) == expected,
|
||||||
|
"collapsed: \(raw) should read as \(expected ? "folded" : "expanded")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Collapsed lanes are outside the unit total, so folding one is a re-divide trigger")
|
||||||
|
func collapsedLanesLeaveTheUnitTotal() throws {
|
||||||
|
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||||||
|
// The folded lane keeps its `width` — that is how expanding restores the lane the user had —
|
||||||
|
// and still answers `displayUnits`; it simply contributes none of them to the division.
|
||||||
|
#expect(loaded.map(LaneLayoutMath.displayUnits(of:)) == [2, 3, 1])
|
||||||
|
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 1)
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: loaded) == 3)
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The strip still fills exactly with both kinds of lane in it")
|
||||||
|
func mixedStripsFillExactly() throws {
|
||||||
|
let loaded = try lanes(widths: ["2", "3", nil], collapsed: [nil, "true", nil])
|
||||||
|
let total = LaneLayoutMath.totalUnits(of: loaded)
|
||||||
|
let folded = LaneLayoutMath.collapsedCount(of: loaded)
|
||||||
|
let standard = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 1000, totalUnits: total, gap: gap,
|
||||||
|
collapsedCount: folded, collapsedWidth: strip)
|
||||||
|
|
||||||
|
// 1000 = 3 standards + 1 strip + 5 gaps (4 slots, so 5 gaps counting both margins).
|
||||||
|
#expect(abs(standard - (1000 - strip - gap * 5) / 3) < 0.0001)
|
||||||
|
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||||||
|
#expect(abs(widths.reduce(0, +) + gap * CGFloat(widths.count + 1) - 1000) < 0.0001)
|
||||||
|
// The expanded lanes are their slots; the folded one is the constant, whatever its `width`.
|
||||||
|
#expect(abs(widths[0] - LaneLayoutMath.slotWidth(units: 2, standard: standard, gap: gap)) < 0.0001)
|
||||||
|
#expect(widths[1] == strip)
|
||||||
|
#expect(abs(widths[2] - standard) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Folding a lane widens its siblings without touching the window")
|
||||||
|
func foldingRedividesTheRemainder() throws {
|
||||||
|
let expanded = try lanes(widths: [nil, nil, nil])
|
||||||
|
let folded = try lanes(widths: [nil, nil, nil], collapsed: [nil, nil, "true"])
|
||||||
|
|
||||||
|
let before = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: expanded), gap: gap,
|
||||||
|
collapsedCount: 0, collapsedWidth: strip)
|
||||||
|
let after = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: folded), gap: gap,
|
||||||
|
collapsedCount: LaneLayoutMath.collapsedCount(of: folded), collapsedWidth: strip)
|
||||||
|
#expect(after > before, "the two survivors grow into what the third gave up")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board with every lane folded divides nothing and still answers a positive width")
|
||||||
|
func allCollapsedIsTheDegenerateEdge() throws {
|
||||||
|
let loaded = try lanes(widths: ["2", "3"], collapsed: ["true", "true"])
|
||||||
|
#expect(LaneLayoutMath.collapsedCount(of: loaded) == 2)
|
||||||
|
// No expanded unit exists, so the total is the divisor guard rather than a description of
|
||||||
|
// anything on screen — and nothing draws with the standard it produces.
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: loaded) == 1)
|
||||||
|
let standard = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded), gap: gap,
|
||||||
|
collapsedCount: 2, collapsedWidth: strip)
|
||||||
|
#expect(standard > 0)
|
||||||
|
let widths = LaneLayoutMath.drawnWidths(of: loaded, standard: standard, gap: gap, collapsedWidth: strip)
|
||||||
|
#expect(widths == [strip, strip])
|
||||||
|
|
||||||
|
// A shown trash column *is* a real unit in that total, and correctly takes the remainder.
|
||||||
|
let withTrash = LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 1000, totalUnits: LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1), gap: gap,
|
||||||
|
collapsedCount: 2, collapsedWidth: strip)
|
||||||
|
#expect(abs(withTrash - (1000 - 2 * strip - gap * 4)) < 0.0001)
|
||||||
|
|
||||||
|
// The pathological strip stays positive rather than negative, `standardWidth`'s 1pt floor.
|
||||||
|
#expect(LaneLayoutMath.standardWidth(
|
||||||
|
stripWidth: 10, totalUnits: 1, gap: gap, collapsedCount: 4, collapsedWidth: strip) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A strip with no folded lane reads exactly as it did before the key existed")
|
||||||
|
func theDefaultsAreInert() {
|
||||||
|
#expect(LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap)
|
||||||
|
== LaneLayoutMath.standardWidth(stripWidth: 1000, totalUnits: 3, gap: gap,
|
||||||
|
collapsedCount: 0, collapsedWidth: strip))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Hit testing walks the drawn widths, so a folded lane's zone is where it is drawn")
|
||||||
|
func hitTestingFollowsTheDrawnWidths() throws {
|
||||||
|
let loaded = try lanes(widths: [nil, nil, nil], collapsed: [nil, "true", nil])
|
||||||
|
let localStandard: CGFloat = 100
|
||||||
|
let widths = LaneLayoutMath.drawnWidths(
|
||||||
|
of: loaded, standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||||
|
#expect(widths == [100, strip, 100])
|
||||||
|
|
||||||
|
// Lane 0 spans [12, 112), the strip [124, 168), lane 2 [180, 280) — gaps answer nil.
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 50, widths: widths, gap: gap) == 0)
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 118, widths: widths, gap: gap) == nil)
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 130, widths: widths, gap: gap) == 1)
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 167, widths: widths, gap: gap) == 1)
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 200, widths: widths, gap: gap) == 2)
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 400, widths: widths, gap: gap) == nil)
|
||||||
|
|
||||||
|
// The unit-count entry point is the same walk, so a board with no folded lane cannot answer
|
||||||
|
// differently from one with.
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 130, unitCounts: [1, 1, 1], standard: localStandard, gap: gap)
|
||||||
|
== LaneLayoutMath.laneIndex(atX: 130, widths: [100, 100, 100], gap: gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A dragged run's span is its drawn footprint, folded members included")
|
||||||
|
func draggedRunsMeasureWhatTheyWillDraw() {
|
||||||
|
let localStandard: CGFloat = 100
|
||||||
|
// Two dragged lanes, the second folded: 100 + gap + 44 rather than 100 + gap + 100.
|
||||||
|
let widths = LaneLayoutMath.drawnWidths(
|
||||||
|
units: [1, 3], collapsed: [false, true],
|
||||||
|
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||||
|
#expect(widths == [100, strip])
|
||||||
|
#expect(DropSlotMath.laneRunSpan(widths: widths, gap: gap) == 100 + gap + strip)
|
||||||
|
|
||||||
|
// A shorter `collapsed` list reads as expanded past its end — what a caller with no fold to
|
||||||
|
// report passes (the trashed-lane restore).
|
||||||
|
#expect(LaneLayoutMath.drawnWidths(
|
||||||
|
units: [1, 2], collapsed: [],
|
||||||
|
standard: localStandard, gap: gap, collapsedWidth: strip)
|
||||||
|
== [100, LaneLayoutMath.slotWidth(units: 2, standard: localStandard, gap: gap)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,4 +190,46 @@ struct NewCardTargetTests {
|
|||||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
|
||||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **⌘N skips collapsed lanes** (03-board-ui.md § Lane ▸ Collapsed lanes): the placeholder is a
|
||||||
|
/// pseudo-card drawn in the lane's masonry, and a folded lane draws none — so a creation there would
|
||||||
|
/// be a focused text field rendered nowhere.
|
||||||
|
@Test("A folded lane is never a creation target, in any branch")
|
||||||
|
func foldedLanesAreSkipped() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
|
// The default fall-through skips it: the first *open* lane is the target.
|
||||||
|
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
|
// And so does the last-active lane, which may well be the lane the user just folded.
|
||||||
|
#expect(resolve(snapshot, lastActive: lane1) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
|
|
||||||
|
// A selection inside it **falls through** rather than refusing — the stale selection's rule,
|
||||||
|
// for its reason: the user pressed ⌘N and the board has lanes it can create into.
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
|
||||||
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1], container: .board))
|
||||||
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||||
|
|
||||||
|
// A selection in the open lane is untouched by any of it.
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
|
||||||
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board whose every lane is folded has no target at all — the zero-lane answer")
|
||||||
|
func everyLaneFoldedRefuses() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||||||
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n")
|
||||||
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
|
||||||
|
// `nil` is New Card's `disabled` condition as well as its refusal, so the item greys out
|
||||||
|
// rather than doing nothing when pressed.
|
||||||
|
#expect(resolve(snapshot) == nil)
|
||||||
|
#expect(resolve(snapshot, lastActive: lane1) == nil)
|
||||||
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ struct BoardMetricsSettledFiguresTests {
|
|||||||
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
|
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
|
||||||
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
|
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
|
||||||
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
|
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
|
||||||
|
// The fold's two figures (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's
|
||||||
|
// ~44pt, and the chevron's reserve is 18 — together the header's 40pt trailing budget.
|
||||||
|
#expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44)
|
||||||
|
#expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18)
|
||||||
|
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 40)
|
||||||
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
||||||
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
||||||
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
||||||
@@ -91,6 +96,9 @@ struct BoardMetricsScalingTests {
|
|||||||
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
|
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
|
||||||
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||||||
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
||||||
|
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
|
||||||
|
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
|
||||||
|
("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }),
|
||||||
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
|
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
|
||||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user