Merge main, and fold #173's a37 findings into the CI-coverage lane
Integrates githubsucks/main @cf54270(terminal config Stage 1, #173). Both conflicts were additive appends to the same lists — the handoff preamble and the ledger's "Closed since the last snapshot" — so both sides are kept and the preamble now leads with #173. Also records what gating #173 measured about a37, because it changes this lane's proposed fix rather than merely annotating it: a37 reports `ok` without running whenever `pmacs-gpu` is absent from the target directory. A fresh worktree reports the Stage 3 suite 9/9 in 0.17 s having never executed the arc's only real-daemon/real-PTY/real- wgpu path; a genuine run takes about four seconds. Only PMACS_REQUIRE_GPU=1 promotes that skip to a failure, and the standing gate list applies that flag to `cargo test -p pmacs-gpu`, a different package. So fix-shape part 2 must state the flag as a requirement of the gpu-render job: a crdt leg added to the plain `test` job would run a37 vacuously and report green. a37 is also load-sensitive — it passed atd152120and failed at that same commit twenty minutes later under machine contention — which makes a red first CI run ambiguous by construction. The lane now says to re-run on the merge base before believing a failure, and to prefer serialized execution over retry-until-green. The vterm audit's "only 3 of 9 Stage 3 tests drive a real daemon" is corrected: without the frontend binary the honest number is 2. Ledger: terminal config Stage 1 flipped from IN REVIEW to MERGED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
This commit is contained in:
commit
23486fd572
81
COHERENCE.md
81
COHERENCE.md
|
|
@ -119,9 +119,10 @@ detailed in §1.1–§1.3: **substrate without surface**, **the silence
|
|||
asymmetry**, and **per-arc coherence debt**.
|
||||
|
||||
Coherence-shaped work already in flight at audit time: find-file /
|
||||
dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom
|
||||
panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the
|
||||
config registry foundation (merged #127).
|
||||
dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
|
||||
Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155),
|
||||
multi-root LSP affinity (merged #161), the config registry foundation
|
||||
(merged #127).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -191,9 +192,13 @@ working, unreachable capability:
|
|||
is Lua-bound; no builtin command opens `*lsp*` (§2, §9).
|
||||
- **Interactive file opening.** `pmacs.buffer.find_or_open`
|
||||
(`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit
|
||||
time; a complete 1,384-line dired exists as a frozen test fixture
|
||||
(`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage
|
||||
0 (PR #162).
|
||||
time; a complete 1,384-line dired existed only as a frozen test
|
||||
fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
|
||||
Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the
|
||||
browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture
|
||||
stays frozen — its `install_local` + `require` routing *is* the M8
|
||||
package-universality proof (Q#DR1) — and shrinking it is scheduled
|
||||
after Stage 3.
|
||||
|
||||
The strategic consequence: **most coherence gaps in pmacs are doors,
|
||||
not engines** — deliberately deferred surface, not design error. That is
|
||||
|
|
@ -358,12 +363,12 @@ Full verdict table:
|
|||
|---|---|---|---|
|
||||
| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime |
|
||||
| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on |
|
||||
| 3 | Open real project | **Missing** | `pmacs .` exits 1 (above). No directory handling anywhere |
|
||||
| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface |
|
||||
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
|
||||
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
|
||||
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
|
||||
| 7 | Find symbol / file | **File: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
|
||||
| 8 | Open terminal | **Works but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
|
||||
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
|
||||
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173). Named limitation: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
|
||||
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
|
||||
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
|
||||
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
|
||||
|
|
@ -374,6 +379,13 @@ A journey observation worth keeping verbatim from the audit:
|
|||
C-M-s` opens all folds, while opening a file, opening a terminal, and
|
||||
running a build have no bindings at all.
|
||||
|
||||
Two of that observation's three examples have since been answered —
|
||||
opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t`
|
||||
(#173). **Running a build still has no binding**, and the underlying
|
||||
inversion is a standing bias in how new work gets bound, not three
|
||||
isolated omissions: the quote stays as written because it names the
|
||||
pattern, and the pattern is not retired until step 9 is.
|
||||
|
||||
---
|
||||
|
||||
## 3. A Strong Zero-Configuration State
|
||||
|
|
@ -455,7 +467,8 @@ level is the one missing. Audited level-by-level:
|
|||
**Beginner** (should see: files, buffers, search, diagnostics, terminal,
|
||||
build actions, menus, missing-tool guidance):
|
||||
|
||||
- files ✗ (no find-file at audit; PR #162 in flight) · buffers ✓ (`C-x
|
||||
- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses;
|
||||
neither is advertised anywhere but the keymap) · buffers ✓ (`C-x
|
||||
b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search
|
||||
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
|
||||
M-x-only · build ✓ but M-x-only with empty prompt · menus △
|
||||
|
|
@ -633,7 +646,7 @@ Everything funnels through one function: `EditorInstance::dispatch_key`
|
|||
| 3 | query-replace | `editor.rs:945` | `QueryReplaceKey::from_chord` (`editor.rs:2967`) | **full shadow** |
|
||||
| 4 | Minibuffer | `editor.rs:951` | `MinibufferAction::from_chord` (`src/minibuffer.rs:468`) | **full shadow** |
|
||||
| 5 | Completion popup | `editor.rs:958-971` | `CompletionPopupKey::from_chord` (`editor.rs:3056`) | **partial shadow** (control chords only; skipped while a multi-key prefix is pending) |
|
||||
| 6 | Terminal transport + `C-c` escape | `editor.rs:973-1010` | `is_terminal_escape_chord` (`editor.rs:4355`) | **partial, transport-level** |
|
||||
| 6 | Terminal transport + configurable escape | `editor.rs:973-1010` | `EditorState::terminal_escape_chord` → `TerminalManager::escape_chord` (`src/terminal/session.rs`) | **partial, transport-level** |
|
||||
| 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer |
|
||||
|
||||
Facts that define the gap:
|
||||
|
|
@ -641,8 +654,10 @@ Facts that define the gap:
|
|||
- **Full shadows eat every key**, including unrecognized ones (each
|
||||
decoder has an `Ignore`/`Dismiss` fallback arm). While a terminal
|
||||
buffer is focused and unescaped, *all* keys encode to the child —
|
||||
`C-c`-leading user bindings are **structurally unreachable** in a
|
||||
terminal buffer.
|
||||
bindings led by the escape chord are **structurally unreachable** in
|
||||
a terminal buffer. Since #173 that chord is `terminal.escape-key`
|
||||
rather than a hardcoded `C-c`, so a user can *move* which prefix is
|
||||
eaten; they cannot make the shadow stop eating one.
|
||||
- **No transient-keymap mechanism exists to migrate to.** `KeymapStack`
|
||||
has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`,
|
||||
`Global` (`src/keymap_stack.rs:37-44`); resolution order buffer →
|
||||
|
|
@ -1007,20 +1022,29 @@ layering, provenance, and adoption have not followed.**
|
|||
`ConfigValue`s; `describe-setting`'s "Source:" names where `define()`
|
||||
ran. The inspection view sketched above is currently impossible to
|
||||
render.
|
||||
- **Adoption is five settings**: `editing.auto-pair` (pair.lua),
|
||||
- **Adoption is eight settings**: `editing.auto-pair` (pair.lua),
|
||||
`editing.trim-on-save` (editops.lua), `autosave.interval-ms`
|
||||
(autosave.lua), `window.panel-height` + `window.min-height`
|
||||
(window.lua). Everything else a user might set — theme, fonts, LSP
|
||||
(window.lua), and `terminal.default-profile` +
|
||||
`terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua,
|
||||
#173). Everything else a user might set — theme, fonts, LSP
|
||||
server config, killring size, recentf/saveplace/desktop enables,
|
||||
pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua
|
||||
outside the registry and is therefore invisible to `describe-setting`
|
||||
and any future settings UI. The migration list is already written:
|
||||
`docs/config-registry-framing.md` "named deferrals" (table-valued
|
||||
settings are the hard prerequisite for LSP/pair/comment tables).
|
||||
- **The table-valued gap now has a named, shipped instance.**
|
||||
`pmacs.terminal.profiles` (#173) is a raw Lua table sitting beside
|
||||
three registered scalars *for the same feature*, because a profile is
|
||||
inherently `{ command, args, cwd, env }` and the registry stores four
|
||||
scalars. It is the clearest evidence yet that table-valued settings
|
||||
are the blocking prerequisite: the terminal is now half-registered,
|
||||
and no settings UI can render the half that matters most.
|
||||
- **No persistence**: settings changed at runtime do not survive
|
||||
restart (the `custom-file` split-brain question is a named deferral).
|
||||
- The three-level separation holds in principle today (registry /
|
||||
hooks+keymaps / packages), but with five settings registered, level 1
|
||||
hooks+keymaps / packages), but with eight settings registered, level 1
|
||||
is effectively empty — users need executable Lua for nearly every
|
||||
ordinary preference, which is the exact failure the section warns
|
||||
about.
|
||||
|
|
@ -1177,7 +1201,11 @@ Primitive-by-primitive against the list above:
|
|||
hierarchy, package dependency graph, worker trees, git status) will
|
||||
each need it; building it once *before* dired's directory view and
|
||||
the workers tree harden their own conventions is exactly this
|
||||
section's point.
|
||||
section's point. Dired Stage 1 (PR #165) landed **without** inventing
|
||||
one: its listing is flat (Emacs parity), and the recursive
|
||||
in-buffer case — `i` insert-subdirectory — is a named deferral in
|
||||
`docs/dired-framing.md` §13, which is where a shared tree primitive
|
||||
would land.
|
||||
- **Structured table / inspector / diff view** ✗ — none. (`describe.*`
|
||||
tables are the inspector's data model without a view; the
|
||||
wire-declared `ResourceOffer` family was reserved for diff/blame
|
||||
|
|
@ -1262,9 +1290,16 @@ its asks are already practiced.**
|
|||
exclusive by construction; every other per-frontend-kind pair in the
|
||||
dispatcher remains two adjacent `if`s that a reader must notice are
|
||||
alternatives.
|
||||
- The GPU frontend exceeds the TUI (minimap, squiggles, typography)
|
||||
without the TUI losing the model — the "no privileged frontend" rule
|
||||
is holding under real divergence pressure.
|
||||
- The GPU frontend exceeds the TUI (minimap, squiggles, typography,
|
||||
and since #158 rendered inline math) without the TUI losing the
|
||||
model — the "no privileged frontend" rule is holding under real
|
||||
divergence pressure. Inline math is the sharpest case so far: the
|
||||
GPU shapes `$…$` spans through a bundled MATH-table font while the
|
||||
TUI shows the LaTeX source unchanged, and the TUI's distinct-face
|
||||
fallback is a **named deferral rather than an oversight**. What
|
||||
keeps it inside the rule is that the slice reserves no protocol
|
||||
version and adds no wire surface — the divergence is presentational
|
||||
only, and the semantic model both frontends read is identical.
|
||||
|
||||
Remaining, honestly small relative to the section's ambition: capability
|
||||
negotiation is per-bit rather than a first-class declared capability
|
||||
|
|
@ -1392,8 +1427,10 @@ missing runtime entity — a real arc).
|
|||
|
||||
Establish the end-to-end workflow; treat regressions as release
|
||||
blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually
|
||||
cheap:** directory-argument handling; a find-file surface (in flight,
|
||||
PR #162); surfacing the LSP spawn failure with guidance (§1.2); a
|
||||
cheap:** directory-argument handling (the remaining half of step 3 —
|
||||
dired Stage 1 landed the buffer it should resolve to); a find-file
|
||||
surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the
|
||||
LSP spawn failure with guidance (§1.2); a
|
||||
compile keybinding + `cargo build`/`test` default from the existing
|
||||
`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The
|
||||
journey acceptance suite (§19) is the ratchet that keeps it fixed.
|
||||
|
|
|
|||
|
|
@ -2630,6 +2630,7 @@ dependencies = [
|
|||
"pollster",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"ttf-parser",
|
||||
"unicode-width",
|
||||
"wgpu",
|
||||
"winit",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,895 @@
|
|||
-- dired.lua --- the directory view (dired arc Stage 1).
|
||||
--
|
||||
-- Dired is not a convenience rider on an existing file surface: until
|
||||
-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at
|
||||
-- all, and browsing is the half a user reaches for when they do NOT
|
||||
-- already know the path. So this is a primary surface, and the one
|
||||
-- thing it may never do is refuse to render a listing --- hence the
|
||||
-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust
|
||||
-- this stage needed besides exposing the path normalizer.
|
||||
--
|
||||
-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view:
|
||||
-- listing, navigation, sort, revert, quit. Marks and operations are
|
||||
-- Stage 2; the editable wdired layer is Stage 3.
|
||||
--
|
||||
-- Public surface:
|
||||
--
|
||||
-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async
|
||||
-- opts.display = "current" | "panel" (Q#BP11b, default "current")
|
||||
-- opts.select_name = "<basename>" -- seat the cursor on it
|
||||
--
|
||||
-- M-x dired / C-x d -- prompt for a directory
|
||||
-- M-x dired-jump / C-x C-j -- dired on this file's directory
|
||||
--
|
||||
-- In a dired buffer (mode-scoped keys, Q#DR8):
|
||||
-- RET, f visit (directory -> descend, file -> display_file)
|
||||
-- ^ parent directory
|
||||
-- n / p move by line (<down> / <up> too)
|
||||
-- g revert (re-read, preserving the cursor's entry)
|
||||
-- q quit (restore the previous buffer, or window.quit in a panel)
|
||||
-- s cycle sort mode (name -> mtime -> size)
|
||||
--
|
||||
-- Three structural decisions worth knowing before editing this file:
|
||||
--
|
||||
-- 1. ONE BUFFER PER DIRECTORY, named `*dired:<canonical path>*`
|
||||
-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates
|
||||
-- the current one. That is Emacs behavior, and it is also the only
|
||||
-- way to keep the name honest --- there is no
|
||||
-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint
|
||||
-- leaves a buffer named after a directory it no longer shows.
|
||||
--
|
||||
-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it
|
||||
-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself).
|
||||
-- Dired's name-dedup and `display_file`'s `find_buffer_for_path`
|
||||
-- dedup have to agree; two implementations that disagree on `//tmp`
|
||||
-- or a `..` at root would mint two buffers for one directory with no
|
||||
-- error anywhere.
|
||||
--
|
||||
-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched,
|
||||
-- so each command spawns a coroutine and the work after the first
|
||||
-- `:await()` resumes on a later tick --- outside interactive
|
||||
-- dispatch. Three consequences:
|
||||
--
|
||||
-- * Errors MUST be `pcall`ed and reported here, and that is
|
||||
-- load-bearing rather than tidy. An uncaught raise inside a
|
||||
-- `pmacs.async` coroutine reaches `step()`, which reports through
|
||||
-- `pmacs.error` --- a channel that **is never defined in
|
||||
-- production** (`COHERENCE.md` §1.1) --- and so falls through to a
|
||||
-- bare `error()` inside `pmacs._async.tick()`, whose result
|
||||
-- `EditorState::tick_async` discards with `let _ =`. The failure
|
||||
-- would not reach the status line, the `*errors*` buffer, or a log:
|
||||
-- it would reach nowhere, and dired would look like it silently did
|
||||
-- nothing.
|
||||
-- * Reporting therefore goes through `pmacs.editor.set_status`, which
|
||||
-- exists and which the acceptance suite observes --- the corollary
|
||||
-- COHERENCE draws from that dead channel: report through a surface
|
||||
-- a test can see, or the guard is indistinguishable from the
|
||||
-- silence it was meant to fix.
|
||||
-- * `pmacs.window.*` calls made after the await act for the *ambient*
|
||||
-- active frontend, since interactive origin does not survive the
|
||||
-- tick boundary; and `pmacs.editor.move_to_line` acts on the
|
||||
-- ambient *buffer*, which is why every post-await re-seat is
|
||||
-- guarded (see `seat_cursor`).
|
||||
|
||||
-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting
|
||||
-- rather than a hardcoded policy: buffer-per-directory accumulates
|
||||
-- buffers when walking a deep tree, and Emacs users differ on whether
|
||||
-- that is a feature.
|
||||
pmacs.config.define {
|
||||
name = "dired.kill-when-opening",
|
||||
description = "Kill the dired buffer being left when descending or ascending.",
|
||||
type = "boolean",
|
||||
default = false,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Layout
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- The mark column is column 0 (Q#DR4), so every other column sits two
|
||||
-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it
|
||||
-- blank: filling it in is Stage 2's job, but reserving it now means
|
||||
-- Stage 2 does not have to move every column, and Stage 3's
|
||||
-- column-classifying intercept can be written against constants that
|
||||
-- did not shift under it. Offsets are computed from the widths for the
|
||||
-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for
|
||||
-- it in every wdired test.
|
||||
|
||||
local MARK_BYTES = 2
|
||||
local KIND_BYTES = 1
|
||||
local PERMS_BYTES = 9
|
||||
local SIZE_BYTES = 10
|
||||
local MTIME_BYTES = 16
|
||||
|
||||
local MARK_START = 0
|
||||
local KIND_START = MARK_START + MARK_BYTES -- 2
|
||||
local PERMS_START = KIND_START + KIND_BYTES -- 3
|
||||
local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive)
|
||||
local SIZE_START = PERMS_END + 1 -- 13
|
||||
local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24
|
||||
local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41
|
||||
|
||||
local BLANK_MARK = string.rep(" ", MARK_BYTES)
|
||||
|
||||
local SORT_MODES = { "name", "mtime", "size" }
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Per-buffer state
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- handles: array of { buf, path, entries, errors, sort_mode, prev }.
|
||||
--
|
||||
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table
|
||||
-- key: two BufferIdLua values for the same buffer are distinct
|
||||
-- userdata, so a `handles[buf]` lookup would miss. The scan is over a
|
||||
-- handful of dired buffers. Dead buffers are compacted out first, so a
|
||||
-- command in a removed dired buffer sees "not in dired" rather than
|
||||
-- operating on dead state (the M8.2 fixture's `find_handle` lesson).
|
||||
|
||||
local handles = {}
|
||||
|
||||
local function live_handles()
|
||||
local live = {}
|
||||
for _, h in ipairs(handles) do
|
||||
local ok, valid = pcall(h.buf.is_valid, h.buf)
|
||||
if ok and valid then live[#live + 1] = h end
|
||||
end
|
||||
handles = live
|
||||
return live
|
||||
end
|
||||
|
||||
local function handle_for_buffer(buf)
|
||||
if buf == nil then return nil end
|
||||
for _, h in ipairs(live_handles()) do
|
||||
if h.buf == buf then return h end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function handle_for_path(path)
|
||||
for _, h in ipairs(live_handles()) do
|
||||
if h.path == path then return h end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function active_handle()
|
||||
return handle_for_buffer(pmacs.window.buffer())
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Paths and names
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local canonicalize = pmacs.path.canonicalize
|
||||
|
||||
local function join_path(dir, name)
|
||||
if dir:sub(-1) == "/" then return dir .. name end
|
||||
return dir .. "/" .. name
|
||||
end
|
||||
|
||||
-- Parent of a canonical directory, through the same normalizer: `..`
|
||||
-- against the root folds away, so `/` is its own parent and no separate
|
||||
-- root special case can drift out of agreement with the canonical form.
|
||||
local function parent_path(path)
|
||||
return canonicalize(join_path(path, ".."))
|
||||
end
|
||||
|
||||
local function basename(path)
|
||||
return path:match("([^/]+)/*$")
|
||||
end
|
||||
|
||||
local function dirname(path)
|
||||
local dir = path:match("^(.*)/[^/]*$")
|
||||
if dir == nil then return nil end
|
||||
if dir == "" then return "/" end
|
||||
return dir
|
||||
end
|
||||
|
||||
local function buffer_name(path)
|
||||
return "*dired:" .. path .. "*"
|
||||
end
|
||||
|
||||
local function buffer_named(name)
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
local ok, described = pcall(pmacs.describe.buffer, id)
|
||||
if ok and described and described.name == name then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The directory a prompt or a jump should start from: the active
|
||||
-- buffer's own directory, else the process cwd (which the normalizer
|
||||
-- yields for a bare "." because it absolutizes against it).
|
||||
local function current_directory()
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf ~= nil then
|
||||
local ok, path = pcall(function() return buf:path() end)
|
||||
if ok and path then
|
||||
local dir = dirname(path)
|
||||
if dir then return canonicalize(dir) end
|
||||
end
|
||||
local h = handle_for_buffer(buf)
|
||||
if h then return h.path end
|
||||
end
|
||||
return canonicalize(".")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Failure reporting
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- `Handle:await()` raises structured tables (R45), so `tostring` on a
|
||||
-- failure yields "table: 0x...". Every user-visible dired failure goes
|
||||
-- through here.
|
||||
local function failure_message(err)
|
||||
if type(err) == "table" then
|
||||
return tostring(err.message or err.tag or "error")
|
||||
end
|
||||
return tostring(err)
|
||||
end
|
||||
|
||||
local function report(where, err)
|
||||
pmacs.editor.set_status(where .. ": " .. failure_message(err))
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Rendering
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- `rwxr-xr-x`, without the leading kind char (rendered separately so a
|
||||
-- symlink shows `l` and a directory `d`). Arithmetic rather than bit
|
||||
-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4.
|
||||
--
|
||||
-- The nine basic bits only: setuid / setgid / sticky are deliberately
|
||||
-- not surfaced as Emacs's `s` / `t`, matching the M8.3 fixture's
|
||||
-- `parse_perm_string`, which edits exactly these nine. Rendering a bit
|
||||
-- Stage 3 could not accept back would be worse than omitting it.
|
||||
local function fmt_perms(mode)
|
||||
local function tri(bits)
|
||||
local r = (bits >= 4) and "r" or "-"
|
||||
local w = ((bits % 4) >= 2) and "w" or "-"
|
||||
local x = ((bits % 2) >= 1) and "x" or "-"
|
||||
return r .. w .. x
|
||||
end
|
||||
return tri(math.floor(mode / 64) % 8)
|
||||
.. tri(math.floor(mode / 8) % 8)
|
||||
.. tri(mode % 8)
|
||||
end
|
||||
|
||||
local function kind_char(kind)
|
||||
if kind == "dir" then return "d"
|
||||
elseif kind == "symlink" then return "l"
|
||||
elseif kind == "file" then return "-"
|
||||
else return "?" -- device, fifo, socket
|
||||
end
|
||||
end
|
||||
|
||||
-- Exact bytes while they fit the column; a magnitude past that.
|
||||
--
|
||||
-- `%10d` holds ten digits, so a file of 10 GB or more (VM images, core
|
||||
-- dumps --- ordinary things) widens the field and shifts mtime and name
|
||||
-- right on that line alone. That is only cosmetic today, but
|
||||
-- `_layout.NAME_START` is exported as a contract and Stage 3's
|
||||
-- column-classifying intercept is planned against these constants, so a
|
||||
-- line that violates them now is a Stage 3 trap. Same discipline as
|
||||
-- `fmt_mtime`: the width is the invariant, and precision yields to it.
|
||||
--
|
||||
-- This is NOT the deferred human-readable size column (§13): the exact
|
||||
-- byte count is still what a listing shows, right up to the point where
|
||||
-- it cannot be shown at all.
|
||||
local SIZE_UNITS = { "K", "M", "G", "T", "P", "E" }
|
||||
|
||||
local function fmt_size(n)
|
||||
local exact = string.format("%" .. SIZE_BYTES .. "d", n)
|
||||
if #exact <= SIZE_BYTES then return exact end
|
||||
local value, unit = n, SIZE_UNITS[#SIZE_UNITS]
|
||||
for _, suffix in ipairs(SIZE_UNITS) do
|
||||
value = value / 1024
|
||||
unit = suffix
|
||||
if value < 1024 then break end
|
||||
end
|
||||
local scaled = string.format("%.1f%s", value, unit)
|
||||
if #scaled > SIZE_BYTES then scaled = scaled:sub(1, SIZE_BYTES) end
|
||||
return string.rep(" ", SIZE_BYTES - #scaled) .. scaled
|
||||
end
|
||||
|
||||
local function fmt_mtime(secs)
|
||||
-- Explicit format string, so the width is fixed and the result does
|
||||
-- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s
|
||||
-- behavior on a negative time is platform-dependent, so a
|
||||
-- non-conforming result degrades to a fixed-width placeholder rather
|
||||
-- than shifting every column right of it.
|
||||
local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs)
|
||||
if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then
|
||||
return formatted
|
||||
end
|
||||
return string.rep("?", MTIME_BYTES)
|
||||
end
|
||||
|
||||
-- POSIX permits any byte but `/` and NUL in a filename, including `\n`.
|
||||
-- Rendering one verbatim would break the one-line-per-entry invariant
|
||||
-- that cursor-line -> entry resolution rests on (and that Stage 3's
|
||||
-- intercept will rest on harder), so control bytes are escaped. The
|
||||
-- backslash goes first, which is what makes the encoding invertible ---
|
||||
-- Stage 3 needs the exact inverse so a no-op commit cannot fire a
|
||||
-- spurious rename. Carried over from the M8.2 fixture as decided
|
||||
-- design, not re-litigated.
|
||||
local function escape_displayable(s)
|
||||
if s == nil then return "" end
|
||||
s = s:gsub("\\", "\\\\")
|
||||
s = s:gsub("\n", "\\n")
|
||||
s = s:gsub("\r", "\\r")
|
||||
s = s:gsub("\t", "\\t")
|
||||
-- NUL is deliberately absent from the class: the kernel forbids it in
|
||||
-- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern
|
||||
-- syntax) was covering a case that cannot occur.
|
||||
s = s:gsub("[\1-\8\11\12\14-\31]", function(ch)
|
||||
return string.format("\\x%02X", string.byte(ch))
|
||||
end)
|
||||
return s
|
||||
end
|
||||
|
||||
local function render_entry(entry)
|
||||
local target = ""
|
||||
if entry.symlink_target then
|
||||
target = " -> " .. escape_displayable(entry.symlink_target)
|
||||
elseif entry.kind == "symlink" then
|
||||
-- A tolerant listing keeps a symlink whose target could not be
|
||||
-- represented (non-UTF-8) or read; say so rather than rendering a
|
||||
-- bare `l` line that looks like a complete entry.
|
||||
target = " -> ?"
|
||||
end
|
||||
return string.format(
|
||||
"%s%s%s %s %s %s%s",
|
||||
BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode),
|
||||
fmt_size(entry.size), fmt_mtime(entry.mtime),
|
||||
escape_displayable(entry.name), target)
|
||||
end
|
||||
|
||||
-- Header (line 0) + one line per entry + the unreadable-count footer.
|
||||
-- The footer exists because a tolerant listing that silently dropped
|
||||
-- entries is worse than one that failed: the user has to know the view
|
||||
-- is incomplete (and Stage 3's wdired refuses to open on one).
|
||||
local function render_text(handle)
|
||||
local lines = { handle.path .. ":" }
|
||||
for _, entry in ipairs(handle.entries) do
|
||||
lines[#lines + 1] = render_entry(entry)
|
||||
end
|
||||
local unreadable = #handle.errors
|
||||
if unreadable > 0 then
|
||||
lines[#lines + 1] = string.format("%d entries unreadable", unreadable)
|
||||
end
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
-- Dired's own writes are the only ones that reach the buffer: the
|
||||
-- read-only intercept rejects everything else, and this bypasses it.
|
||||
local function paint(handle)
|
||||
local text = render_text(handle)
|
||||
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Cursor
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Entry i renders on line i (line 0 is the header), so the entry under
|
||||
-- the cursor is `entries[cursor_line()]`.
|
||||
|
||||
local function entry_at_cursor(handle)
|
||||
local line = pmacs.editor.cursor_line()
|
||||
if line < 1 then return nil end
|
||||
return handle.entries[line], line
|
||||
end
|
||||
|
||||
local function index_of_name(handle, name)
|
||||
if name == nil then return nil end
|
||||
for i, entry in ipairs(handle.entries) do
|
||||
if entry.name == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving
|
||||
-- line. Every repaint is wholesale, so without this a revert, a sort,
|
||||
-- or any Stage 2 operation would drop the cursor to the header.
|
||||
--
|
||||
-- `move_to_line` is AMBIENT --- it moves the active window's cursor, not
|
||||
-- `handle.buf`'s --- so every caller that can run after an `:await()`
|
||||
-- has to check that dired is still the active buffer first. Painting is
|
||||
-- safe either way (it names the buffer); seating is not. Callers that
|
||||
-- activate the buffer themselves (an open, which displays first) are
|
||||
-- unconditionally in the right place.
|
||||
local function seat_cursor(handle, name, fallback_line)
|
||||
local count = #handle.entries
|
||||
if count == 0 then
|
||||
pmacs.editor.move_to_line(0)
|
||||
return
|
||||
end
|
||||
local target = index_of_name(handle, name)
|
||||
if target == nil then
|
||||
target = math.max(1, math.min(fallback_line or 1, count))
|
||||
end
|
||||
pmacs.editor.move_to_line(target)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sorting
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function sort_entries(entries, mode)
|
||||
if mode == "name" then
|
||||
table.sort(entries, function(a, b) return a.name < b.name end)
|
||||
elseif mode == "mtime" then
|
||||
-- Newest first, name as a stable tiebreak so a directory of
|
||||
-- same-second files renders deterministically.
|
||||
table.sort(entries, function(a, b)
|
||||
if a.mtime ~= b.mtime then return a.mtime > b.mtime end
|
||||
return a.name < b.name
|
||||
end)
|
||||
elseif mode == "size" then
|
||||
table.sort(entries, function(a, b)
|
||||
if a.size ~= b.size then return a.size > b.size end
|
||||
return a.name < b.name
|
||||
end)
|
||||
else
|
||||
error("dired: unknown sort mode: " .. tostring(mode))
|
||||
end
|
||||
end
|
||||
|
||||
local function next_sort_mode(mode)
|
||||
for i, candidate in ipairs(SORT_MODES) do
|
||||
if candidate == mode then
|
||||
return SORT_MODES[(i % #SORT_MODES) + 1]
|
||||
end
|
||||
end
|
||||
return SORT_MODES[1]
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Reading
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Read and sort one directory without touching editor state, so a
|
||||
-- failure happens before any side effect is committed (acceptance 15).
|
||||
-- Must run inside `pmacs.async`.
|
||||
--
|
||||
-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not
|
||||
-- fail because one child was unlinked between `readdir` and `lstat`.
|
||||
-- Parent-level failures and non-UTF-8 *names* still raise.
|
||||
local function read_listing(path, sort_mode)
|
||||
local listing = pmacs.fs.read_dir(path, { tolerant = true }):await()
|
||||
local entries = listing.entries
|
||||
sort_entries(entries, sort_mode)
|
||||
return entries, listing.errors
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Buffer ownership
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
|
||||
local NAME_VARIANT_LIMIT = 99
|
||||
|
||||
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign
|
||||
-- buffer may already be called `*dired:/tmp*`. Painting into it through
|
||||
-- `bypass_intercept` would clobber a user's data, so found-by-name is
|
||||
-- NOT adoption: ownership means "this buffer is in dired's own handle
|
||||
-- table" (F7).
|
||||
--
|
||||
-- That is deliberately narrower than the framing's "in the handle table
|
||||
-- OR major_mode == dired": a foreign buffer that also carries the mode
|
||||
-- is precisely the case the check exists to refuse, and a builtin's
|
||||
-- handle table cannot be lost the way a reloadable package's can.
|
||||
local function claim_handle(path)
|
||||
local existing = handle_for_path(path)
|
||||
if existing then return existing end
|
||||
|
||||
local name = buffer_name(path)
|
||||
if buffer_named(name) then
|
||||
local unique = nil
|
||||
for i = 2, NAME_VARIANT_LIMIT do
|
||||
local candidate = string.format("%s<%d>", name, i)
|
||||
if buffer_named(candidate) == nil then
|
||||
unique = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
if unique == nil then
|
||||
error(string.format("dired: %s is taken and no free variant remains", name))
|
||||
end
|
||||
name = unique
|
||||
end
|
||||
|
||||
local buf = pmacs.buffer.create(name)
|
||||
-- Read-only by the listview idiom (Q#DR3): every non-bypass edit is
|
||||
-- rejected, and the intercept lives as long as the buffer.
|
||||
pmacs.buffer.add_intercept(buf, function()
|
||||
error(name .. " is read-only")
|
||||
end)
|
||||
-- Q#DR3/Q#P6: while this buffer is active a semantic frontend must
|
||||
-- round-trip keys, or optimistic apply would swallow the single-key
|
||||
-- bindings (`g` would insert a `g` into a CRDT mirror instead of
|
||||
-- reverting) and bypass the intercept entirely.
|
||||
pmacs.buffer.set_round_trip_input(buf, true)
|
||||
-- Q#DR8: the mode is what carries the keymap, and dired is #129's
|
||||
-- first consumer of mode-scoped keys outside language detection.
|
||||
pmacs.buffer.set_major_mode(buf, "dired")
|
||||
|
||||
local handle = {
|
||||
buf = buf,
|
||||
path = path,
|
||||
entries = {},
|
||||
errors = {},
|
||||
sort_mode = SORT_MODES[1],
|
||||
prev = nil,
|
||||
}
|
||||
handles[#handles + 1] = handle
|
||||
return handle
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Display
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function drop_handle(handle)
|
||||
for i, candidate in ipairs(handles) do
|
||||
if candidate == handle then
|
||||
table.remove(handles, i)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Kill the dired buffer being left, when the user asked for it.
|
||||
-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill`
|
||||
-- redirects windows showing the doomed buffer, and doing that first
|
||||
-- would fight the display we are about to perform.
|
||||
local function kill_departed(departed, arriving)
|
||||
if departed == nil or departed == arriving then return end
|
||||
if not pmacs.config.get("dired.kill-when-opening") then return end
|
||||
local ok, err = pcall(pmacs.buffer.kill, departed.buf)
|
||||
if ok then
|
||||
drop_handle(departed)
|
||||
else
|
||||
-- A buffer that could not be killed keeps its handle: dropping it
|
||||
-- would leave a live dired buffer no command recognizes.
|
||||
report("dired", err)
|
||||
end
|
||||
end
|
||||
|
||||
-- Where a dired buffer goes.
|
||||
--
|
||||
-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or
|
||||
-- "current" is the raw switch every other adopter defaults to in
|
||||
-- Stages 1-2, "panel" is the bottom side window.
|
||||
--
|
||||
-- Navigation (`departed ~= nil`) instead reuses the window dired
|
||||
-- already occupies, which is the opposite routing from a file visit and
|
||||
-- deliberately so (Q#DR10): the next directory is the same kind of
|
||||
-- thing as the current one and belongs in the same slot, while a file
|
||||
-- is not a dired buffer and belongs in the document area.
|
||||
local function display(handle, opts, departed)
|
||||
local side = nil
|
||||
if departed ~= nil then
|
||||
-- Dired's own window, not the request's: walking a tree in a side
|
||||
-- window keeps the side window.
|
||||
local params = pmacs.window.params()
|
||||
side = params and params.side
|
||||
elseif opts and opts.display == "panel" then
|
||||
side = "bottom"
|
||||
end
|
||||
if side ~= nil then
|
||||
-- A side slot DEDICATED to another buffer refuses the replacement
|
||||
-- and this falls back to the document window (Q#BP3 2.iii). That is
|
||||
-- both the substrate's documented policy and Emacs's, so dired does
|
||||
-- not try to unpin the user's panel.
|
||||
pmacs.window.display(handle.buf, { side = side, select = true })
|
||||
else
|
||||
pmacs.window.switch_buffer(handle.buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Public: open a directory
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
pmacs.dired = pmacs.dired or {}
|
||||
|
||||
local OPEN_OPTS = { display = true, select_name = true }
|
||||
|
||||
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
|
||||
-- window it occupies when this is a navigation rather than a fresh
|
||||
-- open. Awaits, so it must run inside `pmacs.async`; raises on a read
|
||||
-- failure, having changed nothing. Returns the buffer.
|
||||
local function open_directory(path, opts, departed)
|
||||
if type(path) ~= "string" then
|
||||
error("pmacs.dired.open: path must be a string, got " .. type(path))
|
||||
end
|
||||
opts = opts or {}
|
||||
-- Validated up front, before the read and before any buffer exists,
|
||||
-- so a bad opt leaves nothing to roll back (the
|
||||
-- `parse_adopter_placement` discipline).
|
||||
for key in pairs(opts) do
|
||||
if not OPEN_OPTS[key] then
|
||||
error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key)))
|
||||
end
|
||||
end
|
||||
local wanted = opts.display
|
||||
if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then
|
||||
error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")',
|
||||
tostring(wanted)))
|
||||
end
|
||||
local canonical = canonicalize(path)
|
||||
|
||||
-- Read first: a failure must leave no buffer, no window change, and
|
||||
-- no handle behind.
|
||||
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
|
||||
local entries, errors = read_listing(canonical, sort_mode)
|
||||
|
||||
local handle = claim_handle(canonical)
|
||||
handle.entries = entries
|
||||
handle.errors = errors
|
||||
handle.sort_mode = sort_mode
|
||||
|
||||
-- `q` returns to the buffer you came from, never to another dired
|
||||
-- buffer (which would trap `q` walking back down the tree); on a
|
||||
-- descent the arriving buffer inherits the departing one's origin.
|
||||
if departed ~= nil then
|
||||
handle.prev = departed.prev
|
||||
else
|
||||
local active = pmacs.window.buffer()
|
||||
if active ~= nil and handle_for_buffer(active) == nil then
|
||||
handle.prev = active
|
||||
end
|
||||
end
|
||||
|
||||
paint(handle)
|
||||
display(handle, opts, departed)
|
||||
-- Seating happens after the display: `switch_buffer` zeroes the
|
||||
-- window cursor, so an earlier seat would be discarded.
|
||||
seat_cursor(handle, opts.select_name, 1)
|
||||
kill_departed(departed, handle)
|
||||
return handle.buf
|
||||
end
|
||||
|
||||
function pmacs.dired.open(path, opts)
|
||||
return open_directory(path, opts, nil)
|
||||
end
|
||||
|
||||
-- Every interactive entry point funnels through here: spawn the
|
||||
-- coroutine the await needs, and turn a failure into a status message
|
||||
-- rather than an uncaught raise inside `pmacs.async` (which would land
|
||||
-- in *errors* and leave the user with a silent no-op).
|
||||
local function open_async(path, opts, departed, where)
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(open_directory, path, opts, departed)
|
||||
if not ok then report(where or "dired", err) end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Commands
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired",
|
||||
description = "Open a directory listing (dired).",
|
||||
fn = function()
|
||||
local root = current_directory()
|
||||
-- No completion source, deliberately. `source = "files"` would make
|
||||
-- RET-on-empty open whatever sorts first (the minibuffer selects
|
||||
-- candidate 0 whenever the list is non-empty, and a selected
|
||||
-- candidate shadows typed text --- S0-1/S0-4), and RET-on-the-
|
||||
-- default-directory is exactly the gesture `C-x d` exists for. The
|
||||
-- field is prefilled instead, which is Emacs's own shape here.
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Dired: ",
|
||||
initial = root,
|
||||
history = "dired",
|
||||
on_accept = function(value)
|
||||
if value == nil or value == "" then return end
|
||||
open_async(value, nil, nil, "dired")
|
||||
end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired-jump",
|
||||
description = "Open dired on the current file's directory, cursor on that file.",
|
||||
fn = function()
|
||||
local buf = pmacs.window.buffer()
|
||||
local path = nil
|
||||
if buf ~= nil then
|
||||
local ok, value = pcall(function() return buf:path() end)
|
||||
if ok then path = value end
|
||||
end
|
||||
if path == nil then
|
||||
pmacs.editor.set_status("dired-jump: this buffer has no file")
|
||||
return
|
||||
end
|
||||
local dir = dirname(path)
|
||||
if dir == nil then
|
||||
pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path)
|
||||
return
|
||||
end
|
||||
open_async(dir, { select_name = basename(path) }, nil, "dired-jump")
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired.visit",
|
||||
description = "Visit the entry under the cursor (descend a directory, open a file).",
|
||||
fn = function()
|
||||
local handle = active_handle()
|
||||
if handle == nil then return end
|
||||
local entry = entry_at_cursor(handle)
|
||||
-- The header and the unreadable-count footer are not entries.
|
||||
if entry == nil then return end
|
||||
local target = join_path(handle.path, entry.name)
|
||||
if entry.kind == "dir" then
|
||||
open_async(target, nil, handle, "dired")
|
||||
return
|
||||
end
|
||||
if entry.kind == "symlink" then
|
||||
-- `read_dir` and `stat` are both lstat-based, so nothing in the
|
||||
-- entry says whether the link points at a directory --- the only
|
||||
-- way to find out is to try to list it. A symlinked directory is
|
||||
-- an ordinary thing to walk into, so try the descent and fall back
|
||||
-- to a file visit.
|
||||
--
|
||||
-- `open_directory` is the try: it reads before touching any editor
|
||||
-- state and raises having changed nothing (acceptance 15), so its
|
||||
-- failure IS the "not a directory" answer. An explicit probe
|
||||
-- followed by the real open would list the whole directory TWICE
|
||||
-- --- opendir plus one lstat per child, each time.
|
||||
pmacs.async(function()
|
||||
local descended = pcall(open_directory, target, nil, handle)
|
||||
if descended then return end
|
||||
local visited, err = pcall(pmacs.window.display_file, target, { select = true })
|
||||
if not visited then report("dired", err) end
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- Q#DR10: `display_file`, never `find_or_open`, which switches the
|
||||
-- active window in both branches before firing hooks --- in a
|
||||
-- panel-displayed dired that would replace the panel with the
|
||||
-- visited file, i.e. the panel swallows itself.
|
||||
local ok, err = pcall(pmacs.window.display_file, target, { select = true })
|
||||
if not ok then report("dired", err) end
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired.parent",
|
||||
description = "Open the parent directory.",
|
||||
fn = function()
|
||||
local handle = active_handle()
|
||||
if handle == nil then return end
|
||||
local parent = parent_path(handle.path)
|
||||
if parent == handle.path then
|
||||
pmacs.editor.set_status("dired: already at the filesystem root")
|
||||
return
|
||||
end
|
||||
-- Seat on the directory we came from, the way Emacs's `^` does.
|
||||
open_async(parent, { select_name = basename(handle.path) }, handle, "dired")
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired.revert",
|
||||
description = "Re-read the directory, keeping the cursor on its entry.",
|
||||
fn = function()
|
||||
local handle = active_handle()
|
||||
if handle == nil then return end
|
||||
local entry, line = entry_at_cursor(handle)
|
||||
local name = entry and entry.name
|
||||
pmacs.async(function()
|
||||
local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode)
|
||||
if not ok then
|
||||
-- On failure `entries` carries the raised value, not a listing.
|
||||
report("dired", entries)
|
||||
return
|
||||
end
|
||||
if not handle.buf:is_valid() then return end
|
||||
handle.entries = entries
|
||||
handle.errors = errors
|
||||
paint(handle)
|
||||
-- The re-read settles a tick or more later, and the user may have
|
||||
-- left (a buffer switch, or `q`) in the meantime. The paint names
|
||||
-- its buffer and is safe; seating is ambient, so a stale seat here
|
||||
-- would move an unrelated buffer's cursor to a line index that
|
||||
-- only means something in this listing.
|
||||
if pmacs.window.buffer() == handle.buf then
|
||||
seat_cursor(handle, name, line)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired.sort-cycle",
|
||||
description = "Cycle the sort mode: name -> mtime -> size.",
|
||||
fn = function()
|
||||
local handle = active_handle()
|
||||
if handle == nil then return end
|
||||
local entry, line = entry_at_cursor(handle)
|
||||
local name = entry and entry.name
|
||||
-- A pure reorder of the entries already in hand: sort is a display
|
||||
-- decision, not a reason to re-read the directory.
|
||||
handle.sort_mode = next_sort_mode(handle.sort_mode)
|
||||
sort_entries(handle.entries, handle.sort_mode)
|
||||
paint(handle)
|
||||
seat_cursor(handle, name, line)
|
||||
pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "dired.quit",
|
||||
description = "Leave dired, restoring the previous buffer.",
|
||||
fn = function()
|
||||
local handle = active_handle()
|
||||
if handle == nil then return end
|
||||
-- Q#BP11b, matching `listview.quit`: `q` keeps its name and its
|
||||
-- user-visible behavior, delegating to `window.quit` only when
|
||||
-- dired really is in a side window.
|
||||
local params = pmacs.window.params()
|
||||
if params and params.side and params.quit_action then
|
||||
pmacs.window.quit()
|
||||
return
|
||||
end
|
||||
local target = handle.prev
|
||||
if not (target and target:is_valid()) then
|
||||
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
|
||||
end
|
||||
pmacs.window.switch_buffer(target)
|
||||
end,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Keys
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Global: both sequences are unbound repo-wide, and both are the Emacs
|
||||
-- defaults.
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" }
|
||||
|
||||
-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than
|
||||
-- per buffer: a second dired buffer needs no `keymap.bind` of its own,
|
||||
-- and Stage 3's wdired swap changes the whole keymap with the mode
|
||||
-- instead of unbinding key by key.
|
||||
local function bind(sequence, command)
|
||||
pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command }
|
||||
end
|
||||
|
||||
bind("RET", "dired.visit")
|
||||
bind("f", "dired.visit")
|
||||
bind("^", "dired.parent")
|
||||
bind("n", "cursor.down")
|
||||
bind("<down>", "cursor.down")
|
||||
bind("p", "cursor.up")
|
||||
bind("<up>", "cursor.up")
|
||||
bind("g", "dired.revert")
|
||||
bind("q", "dired.quit")
|
||||
bind("s", "dired.sort-cycle")
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Test seam
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- The layout constants, so acceptance can assert column positions
|
||||
-- without hardcoding the numbers this file computes.
|
||||
pmacs.dired._layout = {
|
||||
MARK_START = MARK_START,
|
||||
KIND_START = KIND_START,
|
||||
PERMS_START = PERMS_START,
|
||||
PERMS_END = PERMS_END,
|
||||
SIZE_START = SIZE_START,
|
||||
MTIME_START = MTIME_START,
|
||||
NAME_START = NAME_START,
|
||||
}
|
||||
|
|
@ -12,7 +12,10 @@
|
|||
-- `symlink_target` is present only on symlink entries.
|
||||
-- `opts` may contain `supersede = "<key>"` to chain into the M3
|
||||
-- supersede semantics (a later read_dir under the same key
|
||||
-- cancels the earlier one).
|
||||
-- cancels the earlier one), and `tolerant = true` to swap the
|
||||
-- all-or-nothing listing for `{ entries = ..., errors = ... }`
|
||||
-- (see fs.read_dir's own comment below). Any other key is an
|
||||
-- error rather than being silently ignored.
|
||||
--
|
||||
-- Order: entries are returned in *filesystem iteration order*,
|
||||
-- which is whatever the kernel's `readdir` syscall returns. On
|
||||
|
|
@ -69,24 +72,61 @@ end
|
|||
|
||||
local fs = {}
|
||||
|
||||
-- Shared opts.supersede extractor; raises on misshapen opts.
|
||||
local function supersede_key(opts, where)
|
||||
if opts == nil then return nil end
|
||||
-- Shared read-op opts parser; raises on misshapen opts.
|
||||
--
|
||||
-- Unknown keys are REJECTED, not ignored. The earlier version read
|
||||
-- `opts.supersede` and silently dropped everything else, which means a
|
||||
-- typo'd `tolerant` would degrade to the fatal contract with no signal
|
||||
-- at all --- exactly the failure the tolerant opt exists to prevent
|
||||
-- (dired framing §8, minor c). `allowed` is the per-op whitelist.
|
||||
local function read_opts(opts, where, allowed)
|
||||
if opts == nil then return nil, false end
|
||||
if type(opts) ~= "table" then
|
||||
error(where .. ": opts must be a table or nil, got " .. type(opts))
|
||||
end
|
||||
local k = opts.supersede
|
||||
if k ~= nil and type(k) ~= "string" then
|
||||
for key in pairs(opts) do
|
||||
if not allowed[key] then
|
||||
local names = {}
|
||||
for name in pairs(allowed) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
error(string.format("%s: unknown opts key %q (expected one of: %s)",
|
||||
where, tostring(key), table.concat(names, ", ")))
|
||||
end
|
||||
end
|
||||
local key = opts.supersede
|
||||
if key ~= nil and type(key) ~= "string" then
|
||||
error(where .. ": opts.supersede must be a string")
|
||||
end
|
||||
return k
|
||||
local tolerant = opts.tolerant
|
||||
if tolerant ~= nil and type(tolerant) ~= "boolean" then
|
||||
error(where .. ": opts.tolerant must be a boolean")
|
||||
end
|
||||
return key, tolerant == true
|
||||
end
|
||||
|
||||
local READ_DIR_OPTS = { supersede = true, tolerant = true }
|
||||
local STAT_OPTS = { supersede = true }
|
||||
|
||||
-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6):
|
||||
--
|
||||
-- read_dir(path) -> { <entry>, ... }
|
||||
-- read_dir(path, { tolerant = true }) -> { entries = { <entry>, ... },
|
||||
-- errors = { { name = ...?,
|
||||
-- message = ... }, ... } }
|
||||
--
|
||||
-- The bare array is the M8.1 contract and stays exactly as it was, so
|
||||
-- an existing consumer (the frozen M8.2 dired fixture consumes it with
|
||||
-- `ipairs`) is unaffected. Under the opt, a per-entry `readdir` /
|
||||
-- `lstat` / `readlink` failure and a non-UTF-8 symlink *target* become
|
||||
-- `errors` rows instead of failing the whole listing; a failure on the
|
||||
-- parent directory, and a non-UTF-8 entry *name*, stay fatal. An
|
||||
-- `errors` row has no `name` when the entry never materialized.
|
||||
function fs.read_dir(path, opts)
|
||||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.read_dir: path must be a string, got " .. type(path))
|
||||
end
|
||||
local id = async_mod._dispatch_fs_read_dir(path, supersede_key(opts, "pmacs.fs.read_dir"))
|
||||
local key, tolerant = read_opts(opts, "pmacs.fs.read_dir", READ_DIR_OPTS)
|
||||
local id = async_mod._dispatch_fs_read_dir(path, key, tolerant)
|
||||
return build_handle(id)
|
||||
end
|
||||
|
||||
|
|
@ -94,7 +134,8 @@ function fs.stat(path, opts)
|
|||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.stat: path must be a string, got " .. type(path))
|
||||
end
|
||||
local id = async_mod._dispatch_fs_stat(path, supersede_key(opts, "pmacs.fs.stat"))
|
||||
local key = read_opts(opts, "pmacs.fs.stat", STAT_OPTS)
|
||||
local id = async_mod._dispatch_fs_stat(path, key)
|
||||
return build_handle(id)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,38 @@
|
|||
local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required")
|
||||
local raw_open = assert(terminal._open, "pmacs.terminal._open is required")
|
||||
|
||||
-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree
|
||||
-- with no settings written and no profiles registered behaves as before.
|
||||
pmacs.config.define {
|
||||
name = "terminal.default-profile",
|
||||
type = "string",
|
||||
default = "",
|
||||
allow_empty = true,
|
||||
mutability = "live",
|
||||
description = "Profile name from pmacs.terminal.profiles to open by default. " ..
|
||||
"Empty means no profile: fall back to $SHELL.",
|
||||
}
|
||||
|
||||
pmacs.config.define {
|
||||
name = "terminal.scrollback-rows",
|
||||
type = "integer",
|
||||
default = 10000,
|
||||
min = 0,
|
||||
max = 4000000,
|
||||
mutability = "live",
|
||||
description = "Rows of scrollback retained per terminal. " ..
|
||||
"0 retains no history.",
|
||||
}
|
||||
|
||||
pmacs.config.define {
|
||||
name = "terminal.escape-key",
|
||||
type = "string",
|
||||
default = "C-c",
|
||||
mutability = "live",
|
||||
description = "Chord that escapes to the editor from a terminal. " ..
|
||||
"Pressing it twice sends the chord itself to the child.",
|
||||
}
|
||||
|
||||
local function bind_terminal_keys(buffer)
|
||||
local function bind(sequence, command)
|
||||
pmacs.keymap.bind {
|
||||
|
|
@ -19,22 +51,145 @@ local function bind_terminal_keys(buffer)
|
|||
bind("M->", "terminal.scroll-bottom")
|
||||
end
|
||||
|
||||
-- Q#TC1: profiles are a raw Lua table, not a config setting. The
|
||||
-- registry stores four scalars and has no table kind, so a profile —
|
||||
-- inherently `{ command, args, cwd, env }` — lives here beside
|
||||
-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings
|
||||
-- exist.
|
||||
terminal.profiles = terminal.profiles or {}
|
||||
|
||||
local PROFILE_FIELDS = {
|
||||
command = "string",
|
||||
args = "table",
|
||||
cwd = "string",
|
||||
env = "table",
|
||||
}
|
||||
|
||||
-- Every diagnostic below renders a caller- or user-supplied value, so
|
||||
-- rendering must never be the thing that fails. `%q` is partial — it
|
||||
-- raises on a table or function — and a profile name arrives straight
|
||||
-- from `open { profile = ... }`.
|
||||
local function describe_name(name)
|
||||
if type(name) == "string" then return string.format("%q", name) end
|
||||
return string.format("<%s %s>", type(name), tostring(name))
|
||||
end
|
||||
|
||||
local function validate_profile(name, profile)
|
||||
local shown = describe_name(name)
|
||||
if type(profile) ~= "table" then
|
||||
error(string.format("terminal profile %s must be a table", shown), 0)
|
||||
end
|
||||
for key, value in pairs(profile) do
|
||||
local expected = PROFILE_FIELDS[key]
|
||||
if not expected then
|
||||
error(string.format("terminal profile %s: unknown field %q", shown, tostring(key)), 0)
|
||||
end
|
||||
if type(value) ~= expected then
|
||||
error(string.format(
|
||||
"terminal profile %s: field %q must be a %s, got %s",
|
||||
shown, key, expected, type(value)), 0)
|
||||
end
|
||||
end
|
||||
return profile
|
||||
end
|
||||
|
||||
-- `terminal.profiles` is a raw user table, so its keys are whatever the
|
||||
-- user wrote. Sorting them directly raises "attempt to compare number
|
||||
-- with string" the moment the table holds both a string and a numeric
|
||||
-- key — and it raises on the UNKNOWN-PROFILE path, replacing the very
|
||||
-- error this list exists to explain with an opaque one. Sorting DISPLAY
|
||||
-- strings is total over every key type, so the diagnostic survives a
|
||||
-- malformed table.
|
||||
local function known_profile_names()
|
||||
local names = {}
|
||||
for name in pairs(terminal.profiles) do names[#names + 1] = tostring(name) end
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
|
||||
-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is
|
||||
-- selected. An explicitly requested profile that does not exist is an
|
||||
-- error even when `terminal.default-profile` is valid — a typo must not
|
||||
-- silently fall back to the default.
|
||||
local function resolve_profile(requested)
|
||||
local name = requested
|
||||
if name == nil then
|
||||
local configured = pmacs.config.get("terminal.default-profile")
|
||||
if configured == nil or configured == "" then return nil end
|
||||
name = configured
|
||||
end
|
||||
local profile = terminal.profiles[name]
|
||||
if profile == nil then
|
||||
local known = known_profile_names()
|
||||
local listed = #known > 0 and table.concat(known, ", ") or "(none defined)"
|
||||
error(string.format(
|
||||
"terminal profile %s is not defined; known profiles: %s",
|
||||
describe_name(name), listed), 0)
|
||||
end
|
||||
return validate_profile(name, profile)
|
||||
end
|
||||
|
||||
-- Q#TC3a merge order, per field: explicit open field, then the profile's
|
||||
-- field, then the scalar setting, then the built-in fallback. `env` is
|
||||
-- the one field where "first wins" would be wrong, so it MERGES with
|
||||
-- explicit entries overriding the profile's — any other reading silently
|
||||
-- drops half a user's environment.
|
||||
local function merge_env(profile_env, explicit_env)
|
||||
if profile_env == nil then return explicit_env end
|
||||
local merged = {}
|
||||
for key, value in pairs(profile_env) do merged[key] = value end
|
||||
for key, value in pairs(explicit_env or {}) do merged[key] = value end
|
||||
return merged
|
||||
end
|
||||
|
||||
function terminal.open(spec)
|
||||
local buffer = raw_open(spec)
|
||||
spec = spec or {}
|
||||
local resolved = {}
|
||||
for key, value in pairs(spec) do
|
||||
if key ~= "profile" then resolved[key] = value end
|
||||
end
|
||||
|
||||
local profile = resolve_profile(spec.profile)
|
||||
if profile then
|
||||
for key in pairs(PROFILE_FIELDS) do
|
||||
if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end
|
||||
end
|
||||
resolved.env = merge_env(profile.env, spec.env)
|
||||
end
|
||||
|
||||
-- The two open-time settings resolve through the GLOBAL chain
|
||||
-- (Q#TC2b): they are read before the identity buffer exists, so there
|
||||
-- is no terminal to resolve a buffer-local against.
|
||||
if resolved.scrollback_rows == nil then
|
||||
resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows")
|
||||
end
|
||||
if resolved.command == nil then
|
||||
resolved.command = os.getenv("SHELL") or "/bin/sh"
|
||||
end
|
||||
|
||||
local buffer = raw_open(resolved)
|
||||
bind_terminal_keys(buffer)
|
||||
return buffer
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "terminal",
|
||||
description = "Open a terminal running $SHELL (or /bin/sh).",
|
||||
fn = function()
|
||||
return terminal.open {
|
||||
command = os.getenv("SHELL") or "/bin/sh",
|
||||
}
|
||||
description = "Open a terminal running the configured profile, or $SHELL.",
|
||||
fn = function(profile)
|
||||
return terminal.open { profile = profile }
|
||||
end,
|
||||
}
|
||||
|
||||
-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a
|
||||
-- terminal keybinding as part of protecting the golden journey, and §2
|
||||
-- step 8 grades the terminal "works but undiscoverable". `C-c` is
|
||||
-- already a live global prefix (fold's `C-c @ ...`), so this is a new
|
||||
-- leaf under it rather than a shadow.
|
||||
--
|
||||
-- Named limitation: unreachable from INSIDE a terminal window, where
|
||||
-- `C-c` is consumed as the escape. `M-x terminal` still works there.
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" }
|
||||
|
||||
pmacs.command.define {
|
||||
name = "terminal.copy-selection",
|
||||
description = "Copy the active terminal selection.",
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ backlog.
|
|||
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||
or something else, and therefore has no authority by name alone.
|
||||
- Canonical base at this snapshot:
|
||||
`githubsucks/main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2
|
||||
landed-doc refresh #150, folding Stage 2 #149, the ledger refresh #147, web
|
||||
grammars HTML+CSS #146, and the LaTeX Stage 1 #144 / inline-math framing
|
||||
#145 pair; protocol v20).
|
||||
`githubsucks/main` @ `d152120` (the bottom-panel landed-doc refresh #156
|
||||
atop the inline-math slice #158, dired Stage 1 #165, the GPU terminal
|
||||
input fix #166, Lean 4 Stage 2 #161, the dired framing #164,
|
||||
COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap
|
||||
blank-slab fix #159; protocol v20).
|
||||
- On the transfer source, `origin/main` named a release mirror at
|
||||
`d3fa632` and lagged badly. On the current destination, `origin` names
|
||||
the canonical URL. This difference is why all recovery begins by
|
||||
|
|
@ -51,7 +52,7 @@ git worktree list
|
|||
git status --short --branch
|
||||
```
|
||||
|
||||
The `git log` command must expose `0dd16a5` or a newer intentional main.
|
||||
The `git log` command must expose `d152120` or a newer intentional main.
|
||||
If it does not, stop and repair the remote/fetch configuration.
|
||||
|
||||
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
|
||||
|
|
@ -188,44 +189,153 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
suites**; `git diff --check` clean. The sweep needs an isolated
|
||||
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
|
||||
|
||||
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
|
||||
## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
|
||||
|
||||
- Approved framing: `docs/dired-framing.md` (revision 5), landing as its
|
||||
own docs PR off `githubsucks/main` @ `2af1ab3`, branch
|
||||
`githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The
|
||||
repo's `-framing`-branch convention (`vterm-framing`,
|
||||
`gpu-initial-target-framing`, `tab-width-parity-framing`).
|
||||
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
|
||||
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
|
||||
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
|
||||
(wdired) each get their own detailed framing after the prior stage lands.
|
||||
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
|
||||
2026-07-25, one review round, 12/12 CI green). Durable facts moved to
|
||||
`docs/agent-handoff.md` §1 per rule 3 below.
|
||||
- **Stage 1 (the dired view) is next and unstarted.** Branch `dired`
|
||||
(worktree `../pmacs-dired-arc`) carries the framing commits only and is
|
||||
based on the now-superseded `0827dd1`; **rebase it onto the `main`
|
||||
resulting from the framing PR before implementing**, or cut a fresh
|
||||
branch — its framing commits become redundant once the docs PR lands.
|
||||
- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the
|
||||
`dired` major mode + mode keymap; buffer-per-directory with lexical
|
||||
canonicalization and the ownership check; read-only intercept +
|
||||
`set_round_trip_input`; visit routing through `window.display_file`;
|
||||
parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`;
|
||||
cursor preservation by basename; the `dired.kill-when-opening` config
|
||||
key; **and the tolerant `read_dir` opt** — the only Rust in the stage.
|
||||
- The one Rust change is load-bearing and is why Stage 1 is not
|
||||
pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire
|
||||
listing** on any of five per-entry conditions, and the tolerant wrapper
|
||||
its own module doc delegates to package authors **cannot be written in
|
||||
Lua** — the primitive returns one error and no partial vec.
|
||||
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md`
|
||||
§20 Priority 1, which names this work explicitly; journey steps 7 and
|
||||
(partially) 3; **adds no interaction island** — keys are a mode-scoped
|
||||
keymap, and wdired is a mode swap; adopts `pmacs.config` for
|
||||
`dired.kill-when-opening`; inherits §9's worker-attribution gap for its
|
||||
`read_dir` jobs without worsening it.
|
||||
- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
|
||||
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
|
||||
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
|
||||
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
|
||||
superseded `0827dd1` and carried only the framing content #164 already
|
||||
put on `main`, so merging it would have reconciled two histories of one
|
||||
document. It is left untouched and carries nothing unmerged.
|
||||
- **Stage 1 implemented; no wire change (protocol stays v20).** What
|
||||
landed on the branch:
|
||||
- `builtin/runtime/dired.lua`: one buffer per directory named
|
||||
`*dired:<canonical path>*` with the handle-table ownership check;
|
||||
read-only intercept + `set_round_trip_input`; the `dired` major mode
|
||||
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
|
||||
basename cursor re-seating across every wholesale repaint;
|
||||
`display_file` for file visits and same-window reuse for directory
|
||||
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
|
||||
Loaded after `window.lua`.
|
||||
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
|
||||
and one walk that either fails on a per-entry condition or records it
|
||||
(Q#DR6). `src/async_runtime.rs` carries the listing in
|
||||
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
|
||||
keys the Lua result **shape** on `errors.is_some()`, so the bare array
|
||||
the frozen M8.2 fixture consumes with `ipairs` is untouched;
|
||||
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
|
||||
keys** (a typo'd `tolerant` used to degrade silently to fatal).
|
||||
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
|
||||
`normalize_buffer_path` is `pub` and exposed as
|
||||
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
|
||||
mirror exists and Stage 2 owes no mirror removal. This makes B2
|
||||
("tolerant `read_dir` is the only Rust change") false by one small
|
||||
binding, deliberately.
|
||||
- `tests/dired_acceptance.rs`: 22 tests over framing items 1–16,
|
||||
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
|
||||
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
|
||||
dedicated dired panel to carry its dedication across a descent.
|
||||
`display_buffer` never replaces the buffer in a slot dedicated to
|
||||
another one — it discards every side-specific parameter and falls back
|
||||
to the document window (Q#BP3 2.iii), and the exact-window arm errors.
|
||||
Dired does not unpin the user's panel; both arms are pinned.
|
||||
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
|
||||
descent *routing*. Dired holds focus in its own panel, so a raw
|
||||
`switch_buffer` lands in the same window and every 3c assertion holds
|
||||
either way. Dedication is the only discriminator, so the
|
||||
dedicated-panel test is the real pin — and the vacuity is documented at
|
||||
the assertion rather than relabelled.
|
||||
- **The pre-existing test dired's first mode-scoped binding broke
|
||||
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
|
||||
binding in the stack resolves through `describe.key` context-free, which
|
||||
held only while the modes table was empty. It now sets the effective
|
||||
context per binding and explicitly *clears* the mode for global ones,
|
||||
because a leaked mode legitimately shadows a global chord of the same
|
||||
name (dired's `RET` shadows `edit.newline-and-indent`).
|
||||
- Durable substrate facts, independent of this arc:
|
||||
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
|
||||
buffer before removal, so `kill-when-opening` kills **after** the
|
||||
replacement is displayed.
|
||||
- Interactive origin does **not** survive an await: work resumed in
|
||||
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
|
||||
acts for the *ambient* active frontend (S1-9).
|
||||
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
|
||||
entry says whether a symlink points at a directory; `RET` probes by
|
||||
trying to list it (S1-8).
|
||||
- A path-backed buffer's *name* is its full path, not its basename —
|
||||
worth knowing before writing any name assertion.
|
||||
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
|
||||
RET on an empty field opens whatever sorts first, and
|
||||
RET-on-where-you-are is the gesture the binding exists for. The field
|
||||
is prefilled instead.
|
||||
- **Bite verification:** 15 claims, each mutated in place and required to
|
||||
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
|
||||
file swap does not apply; every mutation was applied and reverted with
|
||||
`git checkout --`. One came back VACUOUS and is recorded above.
|
||||
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
|
||||
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
|
||||
guarded on the active buffer (an ambient `move_to_line` after an await
|
||||
moved an unrelated buffer's cursor — the buffer-level instance of
|
||||
S1-9); `fmt_size` keeps the column width past ten digits, because
|
||||
`_layout` is a contract Stage 3 is planned against; and the symlink
|
||||
descent dropped its probe, since `open_directory`'s
|
||||
changed-nothing-on-failure invariant *is* the probe (it was listing the
|
||||
target directory twice). Plus a consecutive-`readdir`-error cap, because
|
||||
**nothing cancels a dired listing** — it carries no supersede key, so
|
||||
cancellation was never the backstop the tolerant loop implicitly relied
|
||||
on. Naming/comment findings taken as-is.
|
||||
- Durable process lesson, hit twice now: a mutation-bite helper restores
|
||||
with `git checkout --`, which reverts to **HEAD** — so a fix must be
|
||||
committed *before* it is bitten. Round 1's fixes were briefly wiped by
|
||||
exactly that.
|
||||
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
|
||||
affinity #161) and again at `b889873` (GPU terminal input #166), both
|
||||
merged rather than rebased per the #135/#137 precedent so the review
|
||||
anchors stay addressable. Each conflict was a single doc hunk resolved
|
||||
as the union: this lane owns COHERENCE's journey step 7 file half, #161
|
||||
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
|
||||
Three things worth carrying:
|
||||
- **A conflicting PR silently stops running CI.** GitHub builds
|
||||
`pull_request` runs against the merge ref, which does not exist while
|
||||
the PR conflicts, so no run is created and nothing reports a
|
||||
failure — the checks list simply stays as it was. Three pushes to
|
||||
this branch produced no CI at all before the cause was found. Watch
|
||||
`mergeable` on a long-lived lane, not just the check list.
|
||||
- #161's own COHERENCE finding **falsified a claim in this lane's
|
||||
module doc**: `pmacs.error` is never defined in production, so an
|
||||
uncaught raise inside a `pmacs.async` coroutine does not reach
|
||||
`*errors*` as the comment said. It reaches a bare `error()` inside
|
||||
`pmacs._async.tick()`, whose result `tick_async` discards with
|
||||
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
|
||||
`set_status` load-bearing rather than tidy, and the comment now says
|
||||
so.
|
||||
- **A lane in review against a fast-moving `main` needs its gates rerun
|
||||
per integration, not per push.** Main advanced twice inside this
|
||||
review round, and the second time landed while the first
|
||||
integration's sweep was still running. The numbers below describe the
|
||||
twice-merged tree.
|
||||
- Verification on the twice-merged tree (`main` @ `b889873`):
|
||||
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
|
||||
default + 2,009 CRDT** library tests; dired acceptance **25 default +
|
||||
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
|
||||
vterm Stage 3 5 (both suites main added, green under this lane's
|
||||
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
|
||||
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
|
||||
suites, zero failures**; `git diff --check` clean. The sweep needs the
|
||||
isolated config for the reason recorded in the bottom-panel lane
|
||||
below.
|
||||
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
|
||||
Priority 1, which names this work explicitly; journey step 7's file half
|
||||
goes from no surface to a surface; **adds no interaction island** — keys
|
||||
are a mode-scoped keymap, and wdired will be a mode swap; adopts
|
||||
`pmacs.config` for `dired.kill-when-opening`; inherits §9's
|
||||
worker-attribution gap for its `read_dir` jobs without worsening it. The
|
||||
audited claims this changes are updated in `COHERENCE.md` itself, per its
|
||||
§25.
|
||||
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
|
||||
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
|
||||
not here. The two meet at `resolve_target_buffer`; dired supplies the
|
||||
buffer a directory should resolve *to*, and `pmacs .` should route into
|
||||
it rather than growing a second directory surface.
|
||||
not here — Stage 1 does **not** fix it. The two meet at
|
||||
`resolve_target_buffer`; dired supplies the buffer a directory should
|
||||
resolve *to*, and `pmacs .` should route into it rather than growing a
|
||||
second directory surface.
|
||||
|
||||
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
|
||||
|
||||
|
|
@ -268,6 +378,33 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
`a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135
|
||||
built specifically because "a decoded-message fixture would prove none of
|
||||
the three fit together".
|
||||
- **⚠ `a37` will report green in the new job without running, unless the
|
||||
job builds `pmacs-gpu` AND sets `PMACS_REQUIRE_GPU=1`.** Measured
|
||||
2026-07-26 while gating #173. `a37_real_daemon_real_pty_and_headless_gpu_
|
||||
render_one_terminal_session` derives its sibling binary path from
|
||||
`CARGO_BIN_EXE_pmacs`, and on a missing binary it `eprintln!`s a skip and
|
||||
**returns `ok`**. A fresh worktree running
|
||||
`cargo test --features crdt --test vterm_stage3_acceptance` reports **9/9
|
||||
in 0.17 s having never run it**; a real run takes ~4 s. Only
|
||||
`PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and `CLAUDE.md`
|
||||
applies that flag to `cargo test -p pmacs-gpu` — a **different package**,
|
||||
so the required local gate does not cover a37 either. The `gpu-render`
|
||||
job already sets the flag, which is what makes fix-shape part 2 sound;
|
||||
state it as a **requirement** of that job rather than inheriting it by
|
||||
luck, because a `crdt` leg added to the plain `test` job would run a37
|
||||
vacuously.
|
||||
- **`a37` is also load-sensitive, which changes how to read the expected
|
||||
first-run failures.** It passed at `d152120` and failed at that *same
|
||||
commit* twenty minutes later, with a second agent saturating the machine
|
||||
with `rustc` in between; it then failed identically on `d152120`,
|
||||
`04c5ad1`, and the #173 merge commit, which is how #173 established the
|
||||
failure was not its own. The signature is `last_frame_text` all spaces
|
||||
with `rendered_nonuniform_frames` nonzero — frames arrive, content does
|
||||
not. `pmacs-gpu`'s own suite flaked the same way under the same load
|
||||
(201/202, then 202/202 on immediate rerun). **So a red a37 on the first
|
||||
CI run is ambiguous by construction**: before treating it as a real
|
||||
failure, run the same command on the merge base, and prefer serialized
|
||||
execution for this suite over retry-until-green.
|
||||
- **Sort deliberate from accidental before proposing a fix.** Some of the 264
|
||||
are perf suites that are `#[ignore]`d by default and belong to their own
|
||||
jobs (`m10_2_perf` 6, `m10_11_perf` 1). `m10_10_perf` has **no** `#[ignore]`
|
||||
|
|
@ -290,153 +427,158 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
**not** `crdt`-gated and do run under CI's exact flags, including the
|
||||
controller-release pin whose only job is catching the plausible wrong fix.
|
||||
|
||||
## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW
|
||||
## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 is next
|
||||
|
||||
- Portable branch: `githubsucks/bottom-panel`, worktree
|
||||
`../pmacs-bottom-panel`, based on `githubsucks/main` @ `ddaa80d`.
|
||||
- Approved framing: `docs/bottom-panel-framing.md` revision 4, committed
|
||||
as the branch's first commit (`c27f75a`).
|
||||
- **Stage 1 implemented; no wire change (protocol stays v20).** What
|
||||
landed on the branch:
|
||||
- `src/window.rs`: `WindowParams` (`side` / `fixed_rows` / `dedicated`
|
||||
+ implementation-owned `quit_action` and `origin_document`), `Side`,
|
||||
a depth-bounded `QuitAction`, `MIN_WINDOW_OUTER_ROWS = 2`,
|
||||
`Layout::compute(area, fixed)`, the `subtree_min_rows` /
|
||||
`interactive_min_rows` recursions, `boundary_below`, and the three
|
||||
new `FrontendView` fields (`panel_capable`, `frame_geometry`,
|
||||
`panel_hidden`).
|
||||
- `src/editor_core.rs`: `primary_document_window`, the non-side target
|
||||
rule, `display_buffer` + the Q#BP3 placement policy, `quit_window`,
|
||||
`reconcile_panel_layout_core`, `resize_boundary`, per-frontend
|
||||
`JumpEntry`s, and the shared `resolve_target_buffer` seam that the
|
||||
#148 initial-target bootstrap now routes through as well.
|
||||
- `src/editor.rs`: the reconciliation transaction, geometry
|
||||
declaration, the side-window `dispatch_idle_for` gate, the divider
|
||||
paint, and the divider drag.
|
||||
- `src/lua_bindings/window_panel.rs`: the whole `pmacs.window` panel
|
||||
surface plus the shared adopter-placement helpers;
|
||||
`builtin/runtime/window.lua` owns `window.panel-height` /
|
||||
`window.min-height` and the resize commands.
|
||||
- Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all
|
||||
take `display = "current" | "panel"` (Stage 1 default `"current"`);
|
||||
LSP/compile visits route through `display_file`.
|
||||
- **Review round 1 addressed.** The load-bearing finding: the Q#BP6
|
||||
side-window split guard (`try_split_active`) had **no production
|
||||
caller** — `pmacs.window.split_horizontal` / `split_vertical`, and so
|
||||
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
|
||||
focused panel made the root wrapper's final child a split rather than
|
||||
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
|
||||
`document_subtree` key on. It survived the first round because the
|
||||
acceptance test called the core method **directly**; it now goes
|
||||
through the real Lua binding. This is the folding-arc round-2 lesson
|
||||
repeating exactly: *after wiring a guard into a production hook, pin it
|
||||
through the real path — a direct-call test misses the wiring.*
|
||||
Also fixed: the armed divider drag was not scoped to its arming
|
||||
frontend (it could cancel and swallow a peer's mouse events); a
|
||||
recompile carries no `display` and duplicated a panel-placed
|
||||
`*compilation*` into the document window; and
|
||||
`paint_mode_line_graphemes` had lost its doc block to an insertion.
|
||||
Five bite-verified fixes (three via `scripts/bite`, two by manual
|
||||
revert since their tests share `src/daemon.rs` with the production
|
||||
code).
|
||||
- Two Stage-2 hazard pins now exist in `src/daemon.rs`, closing the gap
|
||||
the review named: a fresh attach while `LOCAL` is focused in a panel
|
||||
inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap
|
||||
whose `after-load` hook creates and selects a panel still reasserts
|
||||
into a document window.
|
||||
- **Review round 2 addressed.** The load-bearing finding: **Q#BP7 item 1
|
||||
— "growth reaching the live tail re-arms follow" — was never
|
||||
implemented.** `at_bottom` is the instantaneous geometric readout
|
||||
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
|
||||
is momentarily tall enough to reach the tail, so the round-1 assertion
|
||||
could not see the gap: the next rows the child printed pushed the
|
||||
anchored view back into history. `src/terminal/view.rs` now has
|
||||
`rearm_follow_on_growth`, reached by one shared `declare_view_size`
|
||||
helper from every size-declaring path (`snapshot_for_view`,
|
||||
`record_view_size`, `view_status_for_size`) so grid and semantic
|
||||
declarations cannot disagree.
|
||||
Also fixed: the PTY fixtures emitted LF-only output, which staircases
|
||||
until every row clips to blanks — so the anchor assertions compared
|
||||
`""` with `""` and could not fail (now CRLF, each guarded by
|
||||
`assert!(!top_before.is_empty())`); acc33's contrast case asserted
|
||||
nothing; `start_run` let `already_in_panel` override an **explicit**
|
||||
`display = "current"`, which is the documented opt-out from the Stage 3
|
||||
flip (now gated on omission); and `window_drag` was a daemon-global
|
||||
slot that a peer's mode-line press could clear.
|
||||
- Durable test lessons from this round, both the same class:
|
||||
1. **A geometric readout is not a state predicate.** `at_bottom` says
|
||||
"the viewport currently reaches the tail", not "this view follows
|
||||
the tail". Pinning follow requires feeding MORE output and asserting
|
||||
the view moved (acc32b uses a filesystem gate between two bursts).
|
||||
2. **A PTY in the default mode does not translate LF to CRLF.** An
|
||||
`echo`-driven fixture staircases rightward and clips to blanks past
|
||||
the viewport width, so any text equality over it is vacuously true.
|
||||
Emit `\r\n`, and guard text comparisons with a non-empty assertion
|
||||
the way the daemon pin guards on `!panel_hidden`.
|
||||
- **Round-2 self-review caught a regression the round-2 commit
|
||||
introduced**, in the change it labelled "minor": routing
|
||||
`pmacs.window.buffer()`'s **no-argument** arm through the fid-scoped
|
||||
`selected_window` validator made it **fallible**, and
|
||||
`acting_frontend` can name a frontend with **no registered view** (a
|
||||
bare `dispatch_key` from an unattached peer does exactly that). The
|
||||
runtime calls that function on ordinary edits from `killring`,
|
||||
`syntax`, `autosave`, `pair`, `indent` and `comment` **without
|
||||
`pcall`**, so the raise never surfaced as an error — it silently
|
||||
dropped the operation. `kill_ring_acceptance` went 30/30 → 25/5
|
||||
(`frontend_detached_drops_per_frontend_state`: "B has kill state").
|
||||
The no-arg arm is back on ambient `active_buffer_id()` and documented
|
||||
as deliberately infallible; the explicit-window arm keeps its Q#BP11
|
||||
validation. New **acc19c** pins it through the real path (a
|
||||
`buffer.after-edit` subscriber during a viewless peer's `dispatch_key`)
|
||||
and bites against the regressing commit.
|
||||
Generalizes: **a "uniformity" cleanup that changes a function's
|
||||
fallibility is not minor** — check every caller's error discipline
|
||||
first, and remember that an ambient resolver's fallback IS its
|
||||
contract.
|
||||
- Verification on this branch: `cargo fmt --check` clean; strict
|
||||
workspace Clippy clean; 1,817 default + 1,994 CRDT library tests;
|
||||
`bottom_panel_stage1_acceptance` 46/46; kill ring 30 default + 30 CRDT;
|
||||
vterm Stage 1 9 default + 10 CRDT; M4 121; required GPU 152;
|
||||
compile 67; vterm Stage 2 4 / Stage 3 5 (7 CRDT); folding Stage 2 48;
|
||||
statusline 7; listview 6;
|
||||
**isolated-config workspace sweep 3,130 passed across 89 suites, zero
|
||||
failures**; `git diff --check` clean.
|
||||
- **Run the sweep with an isolated `XDG_CONFIG_HOME`.** The real
|
||||
`~/.config/pmacs/init.lua` on this desktop calls
|
||||
`pmacs.packages.install_local(...)`, so every editor the sweep builds
|
||||
races on one shared install root; a losing race sets a status message
|
||||
that leaks into the mode line and breaks
|
||||
`folding_stage2_acceptance::unfolded_frame_is_identical_to_the_pre_folding_baseline`,
|
||||
which compares whole painted frames. Standalone it is 48/48. This
|
||||
generalizes the known `compile_mode_acceptance` real-config trap:
|
||||
any suite that paints the status area inherits it.
|
||||
- **A latent pre-existing `main` bug surfaced while gating and is NOT
|
||||
this branch's**: `buffer::tests::proptests::rope_matches_crdt_projection_after_arbitrary_edits`
|
||||
fails on `main` @ `352bf0b` with `ops = [Insert(0,"a"),
|
||||
Insert(0,"aaa"), Replace(0,1,"a"), Undo]` — undo of a textually-null
|
||||
`Replace` returns a no-op edit result still carrying `crdt_op =
|
||||
Some`, violating the suite's own shape invariant. `src/buffer.rs` is
|
||||
byte-identical here, and the seed was deliberately **not** committed
|
||||
(it would make an unrelated failure deterministically red on this
|
||||
PR). Needs its own lane.
|
||||
- Durable test lesson from this round: `TerminalViewStatus.scroll_offset`
|
||||
is documented as the retained rows between **this viewport** and the
|
||||
live tail, so it necessarily tracks the viewport height. Asserting it
|
||||
constant across a panel height change is either vacuous or wrong —
|
||||
the invariant Q#BP7 actually states is that the **anchor** is frozen,
|
||||
which the acceptance now pins by comparing the first visible row's
|
||||
text, plus `at_bottom` for the follow re-arm.
|
||||
- `compile_mode_acceptance` needs `--test-threads=1` locally; it is
|
||||
67/67 there. Under default parallelism it fails roughly 1 run in 3,
|
||||
with a *different* test each time (acc14/acc25a, then acc24) —
|
||||
**verified pre-existing** by swapping in `githubsucks/main`'s
|
||||
`builtin/runtime/compile.lua` and reproducing the same rate. The
|
||||
`pmacs-gpu` bin tests have historically gone red under a loaded sweep
|
||||
(wgpu device contention). Rerun isolated before treating either as a
|
||||
regression.
|
||||
- Stage 2 (the GPU panel band, next available protocol version) has its
|
||||
own re-framing obligation before implementation; Stage 3 is the default
|
||||
placement flip.
|
||||
- Approved framing: `docs/terminal-config-and-copy-mode-framing.md`
|
||||
**revision 4** (four review rounds), committed as the first commit of
|
||||
Stage 1's branch. Two stages, two branches, two PRs; **no protocol
|
||||
change**.
|
||||
- **Stage 1 MERGED as #173** (`main` @ `cf54270`, 2026-07-26, one review
|
||||
round, all twelve checks green). Branch `githubsucks/terminal-config`
|
||||
and worktree `../pmacs-terminal-config` retained. Profiles, scrollback,
|
||||
a per-terminal configurable escape key, and the `C-c t` opening
|
||||
binding; no protocol change. Main was integrated **twice** during the
|
||||
single review round (`ccf29e3`, then `c93f9ee` after the first merge
|
||||
left the PR conflicting) — see the no-CI-while-conflicting fact below.
|
||||
- **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main`
|
||||
after Stage 1 merges: no dependency, but both edit
|
||||
`builtin/runtime/terminal.lua`.
|
||||
- Load-bearing decisions, each forced by scouted ground truth:
|
||||
- profiles are a **raw Lua table** — `ConfigValue` is four scalars with
|
||||
no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`;
|
||||
- the **two open-time settings resolve through the global chain**,
|
||||
because they are read before the identity buffer exists; only
|
||||
`terminal.escape-key` resolves per buffer;
|
||||
- the escape cache lives on **`TerminalSession`** so its lifetime is
|
||||
the terminal's. `value_epoch` alone is not a sufficient key: it does
|
||||
not advance when focus moves between terminals with different
|
||||
buffer-local values;
|
||||
- repeating the escape sends **that chord**, not a hardcoded `0x03`.
|
||||
- **Four bites, each against a different plausible wrong
|
||||
implementation** — hardcoded ETX fails acc6/9; epoch-only cache key
|
||||
fails acc7; single last-entry cache fails acc8's parse count; removing
|
||||
the invalid-value fallback fails acc10. The first version of acc7
|
||||
passed against the epoch-only bite because it asserted only that
|
||||
terminal A still worked; the discriminating assertion is that **each**
|
||||
terminal honors its own chord and not the other's.
|
||||
- Test instruments worth reusing: `cat -v` is the echo probe, because the
|
||||
screen rejects C0 controls before they reach cells so a raw echoed
|
||||
`Ctrl-X` is invisible; and the probe **counts occurrences** rather than
|
||||
testing presence, because a single-character probe collides with the
|
||||
child's own banner text.
|
||||
- **Review round 1 (2026-07-25) — five findings, all real, all fixed.**
|
||||
One blocker and two majors were the same failure in three places: a
|
||||
claim asserted somewhere cheaper than where it lives.
|
||||
- *Blocker — `COHERENCE.md` was stale in four places, not the three
|
||||
reported.* Step 8 still read "no keybinding"; §11 still read "five
|
||||
settings"; and §6's dispatch table still cited
|
||||
`is_terminal_escape_chord`, **a symbol this PR deletes**. §25 makes
|
||||
that update ride the PR. A PR that changes audited ground truth has
|
||||
to re-grep the audit for its own symbols, not only for its topic.
|
||||
- *Major — acceptance 5 was vacuous.* It asserted a registry
|
||||
round-trip, so it stayed green with the setting's **only** consumer
|
||||
deleted. It now opens a real terminal whose child overflows the
|
||||
24-row screen, scrolls the view to its oldest retained row, and
|
||||
asserts `LINE001` is present at 10,000 and absent at 0. **Asserting
|
||||
a value was stored is not asserting anything reads it.**
|
||||
- *Major — acceptance 8a asserted the session count, not the cache.*
|
||||
An editor-side map with no purge hook — the exact rejected design —
|
||||
leaks *while* sessions drain, so it passed. Fixed with a
|
||||
`TerminalManager::escape_caches()` seam. **A lifecycle claim needs a
|
||||
lifecycle observable.**
|
||||
- *Moderate — `table.sort` over user-controlled profile keys.* A
|
||||
table holding both a string and a numeric key raised `attempt to
|
||||
compare number with string` **on the unknown-profile path**,
|
||||
replacing the diagnostic being asked for; `%q` raised likewise on a
|
||||
non-string `profile` argument. Both are partial functions applied to
|
||||
user input **on a diagnostic path** — the error reporter was the
|
||||
thing that failed.
|
||||
- *Minor — the committed framing still said "not yet approved".*
|
||||
- **Three new bites, each falsified by revert**: deleting the scrollback
|
||||
consumer fails acc5 (and only acc5); restoring the raw-key sort
|
||||
reproduces `attempt to compare string with number` verbatim; and
|
||||
implementing the rejected editor-side map fails the new acc8a at
|
||||
`left: 2, right: 1` **while passing the old session-count version** —
|
||||
which is the review finding demonstrated rather than argued.
|
||||
- Verification after the round-1 fixes, on the tree merged with
|
||||
`githubsucks/main` @ `c93f9ee`: `cargo fmt --check` clean; strict
|
||||
workspace Clippy clean; 1,832 default + 2,009 CRDT library tests;
|
||||
`terminal_config_acceptance` **12/12 in both configurations**; vterm
|
||||
Stage 1/2 9+10 / 6+6; config registry 16+16; bottom-panel Stage 1
|
||||
46+46; M4 121; required GPU 202; `git diff --check` clean.
|
||||
- `compile_mode_acceptance` fails 11/67 against the **real** user
|
||||
config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the
|
||||
known pre-existing trap, not this branch.
|
||||
- **`vterm_stage3_acceptance::a37` fails on this machine — and fails
|
||||
identically on the PR's own base `d152120`**, so it is not this
|
||||
branch's regression. It is load-sensitive: it passed at `d152120`
|
||||
once and failed at that same commit twenty minutes later, with a
|
||||
second agent saturating the machine with `rustc` in between. Two
|
||||
ways it lies, both worth knowing: it **silently returns `ok` when
|
||||
`pmacs-gpu` is not built** in the same target dir (only
|
||||
`PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and the gate
|
||||
list applies that flag to `-p pmacs-gpu`, a *different* package), and
|
||||
it is **crdt-gated, so CI has never run it at all**. A green a37 in
|
||||
a gate log means nothing unless the binary was built and the flag
|
||||
was set. Needs its own lane; see the CI `crdt`-coverage lane on #168.
|
||||
- `pmacs-gpu` itself failed 201/202 once under the same load and passed
|
||||
202/202 on immediate rerun.
|
||||
|
||||
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING
|
||||
|
||||
Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in
|
||||
flight.
|
||||
|
||||
- Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two
|
||||
review rounds). No protocol change. Durable substrate facts live in
|
||||
`docs/agent-handoff.md` §1; the two round lessons are in §5.
|
||||
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
|
||||
2026-07-25).
|
||||
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**,
|
||||
on branch `githubsucks/bottom-panel-stage2-framing` (three commits,
|
||||
one per revision), worktree `../pmacs-bp-stage2`, based on
|
||||
`githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high;
|
||||
round 2 closed 1 blocking + 2 high + 1 medium and decided both open
|
||||
items; round 3 closed 1 blocking + 1 high + 1 medium. No open items
|
||||
remain. The approved
|
||||
parent framing `docs/bottom-panel-framing.md` (rev 4) remains
|
||||
authoritative, **including its acceptance criteria 37–55**.
|
||||
- Retained, carrying nothing unmerged: branch `bottom-panel` and worktree
|
||||
`../pmacs-bottom-panel`.
|
||||
- **Stage 2 ships as two serial slices**, 2A landing before 2B branches:
|
||||
**2A** = classified §1.3 census routing + `paint_frame` per-window
|
||||
painter extraction (with the active-window auto-scroll preparation), no
|
||||
protocol change; **2B** = protocol **v21**
|
||||
(`InstanceMessage::PanelFrame` plus
|
||||
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
|
||||
gated both directions, each extended enum byte-pinned on its own
|
||||
previous final variant), daemon panel projection, the GPU band, and the
|
||||
negotiated `panel_capable` flip. Stage 3 is the adopter default flip.
|
||||
- **Correction — this entry previously mis-stated the census contract.**
|
||||
It is **not** "route every consumer through `primary_document_window`".
|
||||
Q#BP14 classifies the 23 reads into four classes and routes only the
|
||||
**Projection** class that way; focus/input (#13–#15, #23), focus chrome
|
||||
and surface-routed (#16–#19), and focus/session (#20) keep their own
|
||||
authorities. Rerouting them would break remote-op validation and
|
||||
application, `DispatchIdle`, presence, focused search/menu/completion
|
||||
routing, and terminal bell ownership. The Stage 2 framing carries the
|
||||
full table.
|
||||
- **The GPU document bottom is three boundaries, not one.**
|
||||
`text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today
|
||||
`status_band_top`, `geometry_capacity_bottom`, and
|
||||
`document_text_bottom` at once. Once a band is installed they diverge:
|
||||
the status chrome must stay pixel-identical at the physical window
|
||||
bottom while document consumers move. A blanket rewrite of that helper
|
||||
moves both together and passes an "everything moved" assertion, so the
|
||||
Stage 2 criterion asserts **both directions in one scenario**. The
|
||||
census is 20 production sites (8 status-owned, 12 document-owned) + 1
|
||||
definition + 8 test sites = 29 matches; the framing carries the
|
||||
per-site table. The three easiest to misclassify are document
|
||||
completion `:6140`, minibuffer candidates `:7351`, and edge scrolling
|
||||
`:8561` — each with its own visible symptom.
|
||||
- **Folding Stage 3 and this arc's Stage 2 both touch the semantic
|
||||
projection.** Whichever is framed second re-scouts the other's landed
|
||||
state.
|
||||
|
||||
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
|
||||
|
||||
|
|
@ -511,6 +653,73 @@ git worktree add --track \
|
|||
reproduces in-process and so is not the GUI/TUI asymmetry; and a geometry
|
||||
change appearing to clear the visible screen, which reproduces pre-fix).
|
||||
Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained.
|
||||
- **Inline-math slice — MERGED as #158** (`main` @ `5aa9044`,
|
||||
2026-07-25). Detect → parse → layout → draw for `$…$`, entirely inside
|
||||
`pmacs-gpu`, no protocol change. Verified by the user's manual pass on
|
||||
a real paper after the landing. What is worth carrying forward:
|
||||
- **The v0 subset is 34 Greek symbols, sub/superscript, and `\frac`.**
|
||||
An unsupported command fails the **whole span** back to source, so on
|
||||
a real document most inline spans still show LaTeX. Widening the
|
||||
symbol map is the highest-value next increment — ahead of display
|
||||
math, which is also deferred.
|
||||
- **A stale frontend binary is invisible from the source tree.** The
|
||||
slice lives only in `pmacs-gpu`, so after it merged the feature was
|
||||
absent until `cargo build --release -p pmacs-gpu` and a client
|
||||
restart; the daemon needs neither. Diagnose with `strings` on the
|
||||
binary (`Latin Modern Math`, `MathBox`) rather than by re-reading the
|
||||
checkout, which was already current.
|
||||
- **Main was integrated three times in one day** (`8c86d34`,
|
||||
`46a1b8f`, `b889873`), merged not rebased to preserve review anchors.
|
||||
Two conflicts, both this ledger and nothing else. **The dangerous
|
||||
case was the one that did NOT conflict**: #166 auto-merged into
|
||||
`pmacs-gpu/src/main.rs`, the file this lane rewrites, because the two
|
||||
edits sat in different regions of it. Decide whether to integrate
|
||||
from the shared-**file** set, never from whether git complained.
|
||||
- **Integration was proved by test-count reconciliation**, not by a
|
||||
green run: predict what the other side adds, then check the deltas.
|
||||
GPU 199→202 matched `e547a90`'s 3; later lib 1,826→1,829 and CRDT
|
||||
2,003→2,006 matched #166's 3, with GPU unchanged because #166 adds
|
||||
none. Suites 91→92 was #161's new binary.
|
||||
- **Why the branch had no CI for a day**: a conflicting PR builds no
|
||||
merge ref, so no `pull_request` run is created. The ledger previously
|
||||
recorded this cause as unidentified; it is not. Check `mergeable` and
|
||||
confirm a run exists for the current head SHA.
|
||||
- **`m4_5_basedpyright` has no timeout and hangs forever**, parking a
|
||||
`--workspace` sweep (observed 2h26m at 38 of 92 suites). It is
|
||||
**intermittent**, so an earlier clean sweep proves nothing. Sweep with
|
||||
`cargo test --workspace --no-fail-fast -- --skip basedpyright` and
|
||||
judge progress by whether the suite count advances.
|
||||
- Named v0 approximations: the peer-caret half of acceptance 14 is
|
||||
pinned at the mapping level, not pixels; a soft-wrapped spacer draws
|
||||
its box whole at the first run's origin; the fit budget reads the
|
||||
bundled code face even under a custom `set_font` family.
|
||||
- **Bottom panel Stage 1 — MERGED as #155** (`main` @ `e745068`,
|
||||
2026-07-24, after two review rounds). Window placement, window
|
||||
parameters, TUI side windows, the divider, and the adopter `display`
|
||||
opt-in, with no protocol change. Both rounds found the same class of
|
||||
defect and are worth keeping:
|
||||
- **Round 1**: the Q#BP6 side-window split guard had *no production
|
||||
caller* — `C-x 2` still reached plain `split_active` — and survived
|
||||
because the acceptance test called the core method directly.
|
||||
- **Round 2**: Q#BP7's terminal growth re-arm had *never been
|
||||
implemented*, and the assertion meant to pin it (`at_bottom`) is a
|
||||
geometric readout that a still-anchored view satisfies; the anchor
|
||||
assertions beside it compared `""` with `""` because the PTY fixture
|
||||
emitted LF-only output.
|
||||
- **Post-round-2 self-review**, caught by CI going red on all four Test
|
||||
jobs: resolving `pmacs.window.buffer()`'s no-argument arm through the
|
||||
acting frontend made a total function partial, and six runtime modules
|
||||
silently dropped operations (`kill_ring_acceptance` 30/30 → 25/5).
|
||||
Fixed in `9110f9f` before merge.
|
||||
- Gating fact found on the way: **the workspace sweep must run with an
|
||||
isolated `XDG_CONFIG_HOME`**, because the real user `init.lua`
|
||||
installs a local package and the losing race leaks a status message
|
||||
into painted-frame comparisons. There is also a latent pre-existing
|
||||
`main` bug in the buffer CRDT undo path, unrelated to this arc.
|
||||
- `compile_mode_acceptance` is load-sensitive under default
|
||||
parallelism (~1 run in 3, a different test each time); verified
|
||||
pre-existing by swapping in `main`'s `compile.lua`. It is 67/67 at
|
||||
`--test-threads=1`.
|
||||
|
||||
- **GPU initial target — MERGED as #148** (`main` @ `0dd16a5`, 2026-07-24,
|
||||
after two review rounds). `pmacs --gpu [--socket …] FILE` opens a target
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-25, after the GPU terminal input fix (#166) landed —
|
||||
the double terminal-layout sync that made a GPU terminal untypable — following
|
||||
Lean 4 Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) —
|
||||
the dired
|
||||
arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
|
||||
**Last updated: 2026-07-26, after terminal configuration Stage 1 (#173)
|
||||
landed — profiles, scrollback, a per-terminal configurable escape key, and
|
||||
the `C-c t` opening binding — following the GPU terminal input fix (#166),
|
||||
the double terminal-layout sync that made a GPU terminal untypable; the
|
||||
inline-math slice (#158), the first mathematical typesetting in pmacs; Lean 4
|
||||
Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) — the
|
||||
dired arc's Stage 0 — COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
|
||||
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
|
||||
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
|
||||
GPU initial-target doc refresh (#152); and before that GPU
|
||||
|
|
@ -29,11 +31,13 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
|
||||
## 1. Where the project stands (2026-07-25)
|
||||
|
||||
- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1
|
||||
#160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math
|
||||
re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol
|
||||
unchanged at **v20**. The bullets below describe the arcs in their own
|
||||
terms; this line is the head-of-`main` anchor.
|
||||
- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop the
|
||||
inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix
|
||||
#166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163,
|
||||
find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159,
|
||||
bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets
|
||||
below describe the arcs in their own terms; this line is the
|
||||
head-of-`main` anchor.
|
||||
- **`COHERENCE.md` is now required reading and a required framing input
|
||||
— #163.** It carries the product-coherence thesis, an audited
|
||||
scorecard, per-concern gaps, and §20's priority order, and it is the
|
||||
|
|
@ -42,6 +46,32 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
interaction islands added, config-registry adoption, background-work
|
||||
attribution. Its §2 grades the golden journey **broken at step 3**
|
||||
(`pmacs .` exits 1).
|
||||
- **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3;
|
||||
merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU
|
||||
frontend. **No protocol change (still v20); the whole slice lives in
|
||||
`pmacs-gpu`**, because `pmacs-gpu` depends only on `pmacs-protocol` and
|
||||
never on `pmacs` — a core-crate parser would have been unreachable from
|
||||
where rendering happens.
|
||||
- `math_parse.rs` → `math_layout.rs` → a `ChunkSource::MathBox` spacer
|
||||
chunk → per-glyph mini-buffers drawn at the shaped line's real
|
||||
baseline, with fraction rules as quads. Font is bundled Latin Modern
|
||||
Math (~717 KiB) under the **GUST Font License** — not OFL.
|
||||
- **The v0 subset is narrow and deliberately so**: Greek (34 entries),
|
||||
sub/superscript, and `\frac`. Everything else — including relations
|
||||
like `\geq`, fences, big operators, and all display math (`$$…$$`,
|
||||
`\[…\]`) — is a named deferral, and an unsupported command degrades
|
||||
the **whole span** back to source rather than rendering partially.
|
||||
In a real paper most inline spans still show source; that is the
|
||||
designed behaviour, not a defect.
|
||||
- **Math is suppressed while the caret is inside its span**, so editing
|
||||
always sees source. That gate reads the effective caret plus
|
||||
selection endpoints and is fed by three separate refresh triggers;
|
||||
it is the most delicate part of the slice.
|
||||
- Selection and search washes cover the whole box rectangle, not
|
||||
sub-ranges (sub-range washes are deferred).
|
||||
- TUI shows the LaTeX source unchanged. That divergence is recorded
|
||||
against `COHERENCE.md` §16, which audits the "no privileged
|
||||
frontend" rule.
|
||||
- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge
|
||||
`2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**:
|
||||
pmacs previously had no discoverable way to open a file by path — no
|
||||
|
|
@ -73,14 +103,92 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
against an open buffer yet fails to load one that is not open —
|
||||
find-file expands the tilde Lua-side. Loading through the normalized
|
||||
path is a named deferral.
|
||||
- **GPU initial target LANDED — #148**
|
||||
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
||||
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
|
||||
refresh #150, folding Stage 2 #149, ledger refresh #147, web grammars #146,
|
||||
LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142),
|
||||
protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
|
||||
- **Stage 1 (the directory view) is IN REVIEW as PR #165** — the
|
||||
builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and
|
||||
`pmacs.path.canonicalize`. Its branch state, substrate facts, and
|
||||
verification live in `docs/active-work.md`; this section absorbs them
|
||||
when it merges.
|
||||
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
|
||||
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
|
||||
the GPU initial-target semantic bootstrap family).
|
||||
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
|
||||
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
|
||||
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
|
||||
has Emacs's `display-buffer` + window parameters, and a buffer can be
|
||||
displayed in a fixed-height window pinned to the bottom of the frame that
|
||||
feature code targets **by policy** instead of by stealing the selected
|
||||
window.
|
||||
- `src/window.rs`: `WindowParams { side, fixed_rows, dedicated }` plus
|
||||
implementation-owned `quit_action` / `origin_document` (Lua reads them,
|
||||
`set_params` refuses them); `MIN_WINDOW_OUTER_ROWS = 2`;
|
||||
`Layout::compute(area, fixed)` subtracts fixed children before dividing
|
||||
the remainder by weight, preserving last-flexible-takes-the-remainder, so
|
||||
a tree with no fixed leaves computes byte-identically to before.
|
||||
- **`Layout::compute` has TWO production callers**, and both must feed the
|
||||
same shared `panel_fixed_rows` map: `window_placements` and
|
||||
`src/overlay_paint.rs`'s peer-presence pass, which derives its own
|
||||
text-area `Rect` and never routes through the first. Leaving the second
|
||||
on unfixed geometry paints every peer cursor at the row it would occupy
|
||||
with no panel open.
|
||||
- **The minimum is recursive** (`subtree_min_rows`: horizontal splits sum,
|
||||
vertical splits max). "Two rows at the root" does not give each nested
|
||||
leaf two rows. `interactive_min_rows` is the same recursion over the
|
||||
user's `window.min-height` preference, and applies to drag/keyboard
|
||||
resize ONLY — the layout pass and frame-resize reconciliation use the
|
||||
structural floor, so changing a preference can never invalidate an
|
||||
existing layout.
|
||||
- **Hiding a panel is a durable state transition, not a per-frame effect**
|
||||
(`EditorState::reconcile_panel_layout`): it moves focus out and releases
|
||||
the terminal controller, because the terminal resize path merely returns
|
||||
on zero content without releasing. It runs after attach/resize/display/
|
||||
close and defensively before input dispatch, terminal sync, and paint.
|
||||
- `FrontendView` gains `panel_capable`, `frame_geometry`
|
||||
(`None` = **unknown**, never the GPU attach request's permanent 24×80
|
||||
placeholder), and derived `panel_hidden` — each spelled explicitly at
|
||||
every construction site, preserving folding's non-`Default` discipline.
|
||||
- `EditorCore::primary_document_window` is the Q#BP14 projection seam;
|
||||
`display_buffer` is Phase 1 of the display transaction (exact target →
|
||||
side affinity → ordinary reuse, with option-valued height/dedication);
|
||||
the Lua layer owns Phase 2 (activate → hook → reconcile → revalidate →
|
||||
final-focus matrix).
|
||||
- **Optimistic input is gated per WINDOW, not per buffer**:
|
||||
`dispatch_idle_for` returns `false` whenever the acting frontend's active
|
||||
window is a side window. Marking the panel's BUFFER round-trip would be
|
||||
wrong — `round_trip_buffers` is global by `BufferId`, so it would disable
|
||||
optimistic apply for another frontend editing that buffer as its document.
|
||||
- Jump entries are per frontend and carry their origin `WindowId`; a stale
|
||||
**side** origin is SKIPPED, because degrading it to an active-window
|
||||
switch is exactly the duplicate-panel corruption the arc removes.
|
||||
- `pmacs.window.display / display_file / quit / panel / params /
|
||||
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
|
||||
(`window.panel-height`, `window.min-height`, `C-x ^` / `C-x C-^`).
|
||||
Adopters take `display = "current" | "panel"`; **Stage 1 default is
|
||||
`"current"`** and Stage 3 flips it.
|
||||
- The divider is the upper subtree's existing mode-line row — no row added
|
||||
or consumed, `ui.divider` restyles every exposed segment of one boundary,
|
||||
and drag state is `HashMap<FrontendId, _>` so frontends cannot steal each
|
||||
other's gestures.
|
||||
- `open_initial_target` now shares one `resolve_target_buffer` +
|
||||
exact-window install seam with `display_file`, and reasserts into a
|
||||
document window after hooks (a startup hook can now create a panel).
|
||||
- Final gates: 1,817 default + 1,994 CRDT library tests; the new
|
||||
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
|
||||
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
||||
Stage 2 48. All 12 CI checks green at merge.
|
||||
- **Stage 2 (the GPU panel band) is FRAMED** —
|
||||
`docs/bottom-panel-stage2-framing.md`, four review rounds, no open
|
||||
items. It takes protocol **v21** and ships as two serial slices:
|
||||
**2A** classified census routing + per-window painter extraction (no
|
||||
wire change), then **2B** the wire, the daemon projection, the band,
|
||||
and the negotiated `panel_capable` flip. Parent acceptance 37–55
|
||||
remains authoritative. Stage 3 is the adopter default flip.
|
||||
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
|
||||
Projection class (#1–#12, #21–#22) routes through
|
||||
`primary_document_window`; focus/input (#13–#15, #23), focus chrome
|
||||
and surface-routed (#16–#19), and focus/session (#20) keep their own
|
||||
authorities. Rerouting them breaks remote-op validation and
|
||||
application, `DispatchIdle`, presence, focused
|
||||
search/menu/completion routing, and terminal bell ownership.
|
||||
- **GPU initial target LANDED — #148**
|
||||
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
||||
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
|
||||
|
|
@ -578,8 +686,14 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
(GPU) is next**, unframed.
|
||||
- **Web grammars HTML+CSS LANDED — #146**, and **LaTeX Stage 1 — #144**
|
||||
with its inline-math parent framing **#145**.
|
||||
- Remaining ranked arcs: 6 folding Stage 3, 7 DAP, 8 GPU splits, plus
|
||||
the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
|
||||
- **Arc 7 (bottom panel) Stage 1 LANDED — #155** — window placement,
|
||||
window parameters, TUI side windows, the divider, and the adopter
|
||||
`display` opt-in. **Stage 2 (the GPU band) is next and needs its own
|
||||
re-framing**; Stage 3 is the default flip. DAP was parked awaiting
|
||||
exactly this arc's Stage 1 and can now re-baseline its touch census.
|
||||
- Remaining ranked arcs: 6 folding Stage 3, 7 bottom-panel Stages 2–3,
|
||||
DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar
|
||||
prerequisite shipped in #123).
|
||||
|
||||
- **GPU terminal input LANDED — #166** (`main` @ `b889873`;
|
||||
`docs/gpu-terminal-input-framing.md` rev 2; one review round). The
|
||||
|
|
@ -745,6 +859,25 @@ final variant — its own round-trip cannot detect a discriminant shift.
|
|||
|
||||
## 5. Hard-won ops lessons
|
||||
|
||||
- **A test that skips on a missing precondition reports `ok`, and a gate log
|
||||
cannot tell that apart from a pass.** `vterm_stage3_acceptance::a37` — the
|
||||
only acceptance driving a real daemon, a real PTY and a real wgpu render
|
||||
together — derives `pmacs-gpu` from `CARGO_BIN_EXE_pmacs` and, when that
|
||||
binary is absent from the target directory, prints a skip and returns.
|
||||
A fresh worktree reports the suite 9/9 **in 0.17 s having never run it**;
|
||||
a real run takes ~4 s. `PMACS_REQUIRE_GPU=1` is what promotes the skip to
|
||||
a failure, and the standing gate list applies that flag to
|
||||
`cargo test -p pmacs-gpu`, a *different package*. Two habits follow:
|
||||
build the workspace before believing any suite that reaches for a sibling
|
||||
binary, and **judge such a suite by its elapsed time**, not its verdict.
|
||||
- **Before attributing a red test to your branch, run it on the merge base.**
|
||||
`a37` failed on the #173 branch, which looked like a regression; it failed
|
||||
identically on the PR's own base and on two intermediate commits, and had
|
||||
*passed* on that same base twenty minutes earlier. The variable was machine
|
||||
load from a second agent compiling continuously. Load-sensitive tests make
|
||||
both verdicts uninformative in isolation, so the base-commit run is the
|
||||
cheapest way to tell a regression from weather — and it is much cheaper
|
||||
than the bisect it replaces.
|
||||
- **A daemon-side fix is not deployed until the daemon is restarted from a
|
||||
tree that contains it.** #166's reporter rebuilt and saw no change: the
|
||||
running daemon had been started from a shared checkout still on a pre-fix
|
||||
|
|
@ -893,6 +1026,44 @@ final variant — its own round-trip cannot detect a discriminant shift.
|
|||
before any `FrontendEvent` touches render/size/editor state. Generalizes:
|
||||
when a new failure path can leave a handle installed without its owning
|
||||
session, dropping a handle is not the same as tearing down the connection.
|
||||
- **A guard with no production caller passes every direct-call test.**
|
||||
#155 round 1: `EditorCore::try_split_active` implemented the side-window
|
||||
split refusal, but `pmacs.window.split_horizontal` / `split_vertical` — and
|
||||
therefore `C-x 2` / `C-x 3` — still called plain `split_active`. The
|
||||
acceptance test called the core method directly, so reverting the guard
|
||||
entirely would have left every test green. Same shape as folding #142
|
||||
round 2. Assert through the outermost user-reachable seam
|
||||
(`try_exec(&s, "pmacs.window.split_horizontal()")`), then falsify by
|
||||
revert. When the test shares a file with the code it pins, `scripts/bite`
|
||||
cannot swap it — break the production line by hand and `git checkout --`.
|
||||
- **A geometric readout is not a state predicate.**
|
||||
`TerminalViewStatus::at_bottom` is defined as `scroll_offset == 0` — "the
|
||||
viewport currently reaches the tail", not "this view follows the tail". A
|
||||
still-anchored view satisfies it whenever it happens to be tall enough, so
|
||||
asserting it could not detect that Q#BP7's growth re-arm had never been
|
||||
implemented (#155 round 2): the next rows the child printed pushed the
|
||||
anchored view back into history. Pinning *following* requires advancing the
|
||||
world — feed more child output through a filesystem gate — and asserting the
|
||||
view came along. Related: `scroll_offset` is viewport-relative, so
|
||||
"unchanged across a height change" is vacuous or wrong; the invariant is
|
||||
the frozen ANCHOR.
|
||||
- **A PTY in the default mode does not translate LF to CRLF.** An
|
||||
`echo`-driven test fixture staircases rightward, and past the viewport
|
||||
width every row clips to blanks — so `assert_eq!(top_before, top_after)`
|
||||
compares `"" == ""` and passes for any regression (#155 round 2). Emit
|
||||
`printf '...\r\n'`, and guard text comparisons with
|
||||
`assert!(!observed.is_empty())` the same way the panel daemon pin guards on
|
||||
`!panel_hidden`.
|
||||
- **Widening an ambient resolver into a scoped one can make a total function
|
||||
partial.** #155 round 2 resolved both arms of `pmacs.window.buffer()`
|
||||
through the acting frontend "for uniformity". `acting_frontend` follows the
|
||||
interactive origin, which can name a frontend with no registered view (a
|
||||
bare `dispatch_key` from an unattached peer), so the no-argument arm began
|
||||
raising instead of answering. No runtime caller `pcall`s it, so killring,
|
||||
syntax, autosave, pair, indent and comment silently dropped operations —
|
||||
`kill_ring_acceptance` went 30/30 to 25/5 on every CI platform. The ambient
|
||||
resolver's fallback is what makes it *total*; keep it, and document that as
|
||||
deliberate. Uniformity is not free when the paths have different totality.
|
||||
- **An upgrade decision must be tracked independently of the outcome that
|
||||
triggered it.** #148 published a target's fresh `BufferSnapshot` to
|
||||
existing grid replicas only when the buffer was `newly_loaded ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,738 @@
|
|||
# Bottom panel Stage 2 — the GPU panel band (framing)
|
||||
|
||||
**Revision 4 — pre-implementation. Ground truth: canonical `main` @
|
||||
`ccf29e3`, protocol v20, 2026-07-25.**
|
||||
|
||||
Stage 1 (#155, merge `e745068`) gave pmacs window placement, window
|
||||
parameters, TUI side windows, the divider, and the adopter `display`
|
||||
opt-in. It deliberately set `FrontendView::panel_capable = false` for
|
||||
every semantic session, so a GPU frontend silently falls back to the
|
||||
non-side target. **Stage 2 flips that bit, under an exact negotiated
|
||||
rule, and earns the right to.**
|
||||
|
||||
This document is the re-framing `docs/bottom-panel-framing.md` (rev 4)
|
||||
§2 requires before Stage 2 is implemented. It does **not** restate the
|
||||
parent's decisions or replace its acceptance criteria. It records the
|
||||
re-scout against current `main`, closes the four scout obligations
|
||||
review round 1 required, and fixes what round 1 found wrong.
|
||||
|
||||
**Inherited reading, all of which remains authoritative:** parent
|
||||
Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document
|
||||
projection contract and its census classification)**, **Q#BP14a (panel
|
||||
input gating is per-window)**, Q#BP14b (focus chrome and per-window
|
||||
overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three
|
||||
geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
|
||||
**parent acceptance criteria 37–55**.
|
||||
|
||||
## 0. Revision history
|
||||
|
||||
### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed
|
||||
|
||||
- **R3-1 (blocker).** Rev 3's three-boundary model was right but its
|
||||
call-site table was wrong in five places, and each error was a real
|
||||
defect: `:6140` is **document completion placement** (classified
|
||||
status-owned, which would let completion overlap the panel);
|
||||
`:7195`/`:7212` are the two **status text bounds** (classified
|
||||
document-owned); `:7351` clips **global minibuffer candidate glyphs**
|
||||
to the dropdown's band anchor (classified document-owned, which would
|
||||
clip them against the document boundary); `:8561` (**document edge
|
||||
scrolling**) was missing entirely, leaving it tied to the old bottom;
|
||||
and `:8077` was described as completion placement when it is **caret
|
||||
clipping** (its class was right, its label wrong). §5.3's table is
|
||||
rebuilt from the full census and every row is verified against the
|
||||
source.
|
||||
**Root cause worth recording:** rev 3's table was built from a
|
||||
`grep | head -20` over 29 matches. The truncation is exactly why
|
||||
`:8561` vanished. The census is now stated as 20 production sites +
|
||||
1 definition + 8 test sites = 29, so a future reader can check the
|
||||
arithmetic instead of trusting the list.
|
||||
- **R3-2 (high).** The three equations permitted negative coordinates
|
||||
on a surface shorter than its chrome, where today's
|
||||
`text_area_bottom` clamps with `.max(0.0)`. All three are now
|
||||
explicitly clamped, preserving the current helper's behavior.
|
||||
- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s
|
||||
`cell.attachment.is_some()` rejection. It is now classified — and
|
||||
**shared**, with the reasoning pinned.
|
||||
|
||||
### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed
|
||||
|
||||
- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two
|
||||
boundaries that must **diverge** once a panel exists. Several sites it
|
||||
named are not document-bottom consumers at all: the status-band
|
||||
background (`main.rs:5908`) must stay at the physical window bottom,
|
||||
the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a
|
||||
*height* and never a bottom coordinate, and status text placement
|
||||
(`:7134`) sits inside an unchanged band. §5.3 now splits the single
|
||||
value into **three** named boundaries, classifies every existing
|
||||
`text_area_bottom` call site, and adds the contrast assertion that
|
||||
catches a uniformly-wrong implementation moving both together.
|
||||
- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish
|
||||
*advanced* from *accepted duplicate* from *rejected*. It now returns an
|
||||
explicit three-valued result. The exhaustion wording also permitted
|
||||
retaining stale geometry, which is not fail-closed: §3.1 now clears the
|
||||
authoritative declaration and reconciles to hidden, and adds the
|
||||
frontend-side terminal latch.
|
||||
- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but
|
||||
2A has no semantic panel projection — it can only prove the extracted
|
||||
painter accepts an explicit `None`. 52 is now also reasserted in 2B,
|
||||
where the contract becomes production-reachable.
|
||||
- **R2-4 (medium).** §9 names the four touched acceptance suites
|
||||
explicitly rather than relying on "standing suite".
|
||||
- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at
|
||||
scale 1.0, and `TEXT_TOP` stays unscaled.
|
||||
|
||||
### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed
|
||||
|
||||
- **R1-1 (blocker).** Rev 1 said all 23 census reads route through
|
||||
`primary_document_window`. That contradicts Q#BP14, which routes only
|
||||
the **Projection** class (#1–#12, #21–#22) that way and leaves focus,
|
||||
input, chrome, and bell consumers on their own authorities. Rev 1's
|
||||
rule would have broken remote-op validation, `DispatchIdle`,
|
||||
presence, focused search/menu/completion routing, and bell ownership.
|
||||
§3.2 now restores all four classes; §7's criterion pins them
|
||||
separately. The inherited-reading list above gains Q#BP14 and Q#BP14a.
|
||||
- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs`
|
||||
active reads as one disposition. Only `:644` selects the wrong
|
||||
window; `:629` and `:675` must keep tracking **actual focus**. §3.3
|
||||
is rewritten and the criterion states the required behavior instead
|
||||
of routing focus away.
|
||||
- **R1-3 (high).** The `panel_capable` flip needed an exact attach
|
||||
rule, not "for semantic sessions". §3.5 states it: **v21-or-later
|
||||
negotiated authenticated semantic session only**.
|
||||
- **R1-4 (high).** Option 1 accepted, but the epoch needed a state
|
||||
machine, split APIs, and a fail-closed allocator. §3.1 now carries
|
||||
the transition table and the API split. Rev 1's phrasing "rejects a
|
||||
lower-or-equal epoch carrying different data" was itself wrong — a
|
||||
lower epoch carrying *identical* data is still stale.
|
||||
- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted
|
||||
parent 37–55. §7 now declares the parent list authoritative, maps it
|
||||
to 2A/2B, and adds only refinements. The painter-extraction criterion
|
||||
pins cursor, `view_top`, and passive-window state, not just cells.
|
||||
- **R1-6.** All four scout obligations are closed in §5.
|
||||
- **R1-7.** The coherence statement understated journey impact and
|
||||
overclaimed on background work. §6 names journey steps 7–10 and
|
||||
narrows the §9 claim.
|
||||
- **R1-8.** Factual corrections in §1 and §3.2.
|
||||
|
||||
## 1. Anchor re-scout
|
||||
|
||||
| Parent anchor | Now at | Verdict |
|
||||
| --- | --- | --- |
|
||||
| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds |
|
||||
| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) |
|
||||
| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds |
|
||||
| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds |
|
||||
| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite |
|
||||
| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds |
|
||||
| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds |
|
||||
| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant |
|
||||
| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds |
|
||||
|
||||
**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no
|
||||
intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is
|
||||
v21**, no reservation was taken and none was needed.
|
||||
|
||||
Fifteen PRs merged between the parent's last re-scout (`47581f4`) and
|
||||
this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's
|
||||
mechanical model was falsified by any of them.
|
||||
|
||||
## 2. What Stage 1 already built for Stage 2
|
||||
|
||||
- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }`
|
||||
(`src/window.rs:522-528`), held as
|
||||
`FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means
|
||||
**unknown** — Q#BP15a's "unknown is first-class", already landed.
|
||||
- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) →
|
||||
`declare_frame_geometry` + `reconcile_panel_layout`, driven from two
|
||||
daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883`
|
||||
attach, `:1972-1973` resize).
|
||||
- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before
|
||||
the statusline fan-out and before the long mutable core borrow.
|
||||
- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already
|
||||
a two-variant enum, so Q#BP8's fan-out generalization is an added
|
||||
variant, not a refactor.
|
||||
- `primary_document_window` (`src/editor_core.rs:2830`) and
|
||||
`primary_document_buffer` (`:2845`).
|
||||
|
||||
## 3. Findings and decisions
|
||||
|
||||
### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine
|
||||
|
||||
**Decision: option 1.** The epoch is owned by the frontend for
|
||||
negotiated semantic-panel sessions. The deciding argument is one rev 1
|
||||
missed: **a font or scale transaction can require invalidating an old
|
||||
`PanelFrame` even when the derived `CellSize` is identical.** Daemon
|
||||
value dedup cannot detect that case, because the cell totals it
|
||||
compares are unchanged while the pixels behind them are not.
|
||||
|
||||
The landed allocator conflicts in three ways
|
||||
(`src/editor_core.rs:3155-3172`): it allocates the id itself, it
|
||||
early-returns when `total` is unchanged (value dedup), and it uses
|
||||
`saturating_add`, which is neither wrapping nor fail-closed — it pins
|
||||
at `u64::MAX`, after which two different geometries share one id.
|
||||
|
||||
**Acceptance rules for a semantic declaration:**
|
||||
|
||||
| Incoming declaration | Result |
|
||||
| --- | --- |
|
||||
| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged |
|
||||
| same epoch, same `total` | Idempotent no-op |
|
||||
| same epoch, **different** `total` | Reject |
|
||||
| **lower** epoch, any `total` | Reject |
|
||||
|
||||
The last row is deliberate and corrects rev 1: a lower epoch carrying
|
||||
identical data is still stale and must not be accepted.
|
||||
|
||||
**API split.** Two methods, not one method with an optional epoch:
|
||||
|
||||
- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator.
|
||||
Keeps value dedup (correct there: cells are the unit, and an
|
||||
unchanged grid means an old frame is still valid under unchanged
|
||||
metrics). Changes from `saturating_add` to **checked** allocation
|
||||
with an explicit fail-closed exhaustion arm.
|
||||
- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate`
|
||||
— the **semantic** path. No value dedup; applies the table above
|
||||
verbatim.
|
||||
|
||||
An ambiguous single method with an `Option<u64>` epoch is rejected
|
||||
explicitly: it would let a future caller silently take the wrong regime.
|
||||
|
||||
**The result is three-valued, not a boolean.** A boolean cannot
|
||||
distinguish the three outcomes the caller must act on differently:
|
||||
|
||||
```rust
|
||||
enum GeometryUpdate {
|
||||
/// Epoch advanced: stored verbatim. Run panel reconciliation.
|
||||
Advanced,
|
||||
/// Same epoch, same total: already current. Do no work.
|
||||
Duplicate,
|
||||
/// Same epoch with different total, or a lower epoch: stale or
|
||||
/// conflicting. Drop the event before any reconciliation.
|
||||
Rejected,
|
||||
}
|
||||
```
|
||||
|
||||
`Advanced` reconciles, `Duplicate` returns without touching panel
|
||||
state, and `Rejected` drops the event. Collapsing `Duplicate` into
|
||||
either neighbour is a defect in one direction or the other: folded into
|
||||
`Advanced` it reconciles on every repeated declaration, folded into
|
||||
`Rejected` it would log or surface a stale-event condition that never
|
||||
happened. (If a boolean is kept for a narrower internal caller, it must
|
||||
be named `advanced`, never `accepted` — `Duplicate` *is* accepted.)
|
||||
|
||||
**Initial epoch.** The frontend's first declaration after attach
|
||||
acceptance carries epoch `1`. `0` is reserved as "never declared" and
|
||||
is rejected on the wire.
|
||||
|
||||
**Exhaustion fails closed on both sides, and rev 2's wording did not.**
|
||||
Saying the panel "stays at its last valid geometry" is not fail-closed:
|
||||
if the real frame resizes after the allocator is exhausted, the daemon
|
||||
would keep painting a panel sized to geometry that no longer describes
|
||||
the frontend.
|
||||
|
||||
- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the
|
||||
authoritative `frame_geometry` (back to `None` = unknown) and
|
||||
reconcile. Unknown is already non-presentable under Q#BP2b, so the
|
||||
panel hides. Stale geometry is never retained.
|
||||
- **Frontend path.** On exhaustion the frontend sets a **terminal
|
||||
latch** for the life of the session: it sends no further geometry,
|
||||
and — critically — an old matching `Present` **cannot** make the band
|
||||
reappear, because the latch suppresses paint and hit-testing
|
||||
independently of frame validity. Only a fresh session (reconnect)
|
||||
clears it. Without the latch, a retained `Present` whose epoch still
|
||||
matches the last declaration would resurrect a band under geometry
|
||||
the frontend has disowned.
|
||||
|
||||
### 3.2 The census is classified, and it is mostly unrouted
|
||||
|
||||
**Correction to rev 1.** Q#BP14 routes only the **Projection** class
|
||||
through `primary_document_window`. Rev 1's "all 23 reads" was wrong and
|
||||
would have broken five subsystems. The four classes, restored:
|
||||
|
||||
| Class | Census items | Authority |
|
||||
| --- | --- | --- |
|
||||
| **Projection** | #1–#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` |
|
||||
| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together |
|
||||
| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer |
|
||||
| **Focus chrome / surface-routed** | #16–#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other |
|
||||
| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain |
|
||||
|
||||
Rerouting any of the last three classes to the document is a defect,
|
||||
not a simplification: it would break remote-op validation and
|
||||
application, `DispatchIdle`, presence, focused search/menu/completion
|
||||
routing, and bell ownership.
|
||||
|
||||
**How much is already routed.** `primary_document_window` has **four**
|
||||
references in `src/` and **two production paths**: directly at
|
||||
`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and
|
||||
through `primary_document_buffer` at `src/daemon.rs:2998`, which is
|
||||
census **#22** and carries a comment naming it. So one census item is
|
||||
routed and the Projection class is otherwise open. For scale, `src/*.rs`
|
||||
still holds ~80 non-test direct `.active` reads on top of the
|
||||
`active_window*` / `active_buffer*` helper family
|
||||
(`src/editor_core.rs:663-967`).
|
||||
|
||||
This is not a Stage 1 defect — with `panel_capable = false` no semantic
|
||||
frontend can hold a side window, so the unrouted Projection reads are
|
||||
unreachable from the GPU. It does mean **classified census routing is
|
||||
the bulk of Stage 2**, which is why it is Stage 2A.
|
||||
|
||||
### 3.3 The three statusline reads have two dispositions, not one
|
||||
|
||||
All three sites are real, but only one is wrong:
|
||||
|
||||
- `src/statusline.rs:644` — `.get(&view.active)` **selects the wrong
|
||||
window** when a panel is focused. This is the Projection read (#12).
|
||||
- `src/statusline.rs:629` and `:675` — `active: window_id ==
|
||||
view.active` **must continue tracking actual focus**. Three reasons:
|
||||
grid contexts need a truthful `active`; post-callback revalidation
|
||||
must notice a focus change; and parent acceptance 42 explicitly
|
||||
requires that a document provider may observe `active = false` while
|
||||
the panel is focused.
|
||||
|
||||
**The new semantic-layout target** therefore captures the **primary
|
||||
document window plus the visible side window**, marks each context
|
||||
`active` iff its `window_id == view.active`, invokes each provider
|
||||
**exactly once**, and **invalidates the entire evaluation** if a
|
||||
callback mutates layout or focus. Unprojected document splits run no
|
||||
callbacks (Q#BP8). Route the primary-document result to semantic
|
||||
`StatuslineSegments` and the side result to the panel mode line.
|
||||
|
||||
### 3.4 Fold projection
|
||||
|
||||
Unchanged from Q#BP17, with the anchor corrected: the extracted painter
|
||||
takes the map as a **parameter**; the panel path passes `None` when the
|
||||
owning frontend's `fold_projection` is false and must never call
|
||||
`fold_map_for_window`, which gates on the **active** frontend
|
||||
(`src/editor_core.rs:734`, gate at `:738`) — right for command-time
|
||||
reckoning, wrong for painting another frontend's panel. The stale
|
||||
comment is at `src/window.rs:562`.
|
||||
|
||||
### 3.5 The `panel_capable` flip needs a negotiated rule
|
||||
|
||||
Not "true for semantic sessions". Exactly:
|
||||
|
||||
> `panel_capable = true` **only** for an authenticated semantic session
|
||||
> that negotiated **v21 or later**.
|
||||
|
||||
A v6–v20 semantic frontend stays non-panel-capable and takes the
|
||||
existing Stage 1 fallback: the non-side target with **every
|
||||
side-specific parameter discarded**, leaving the document window
|
||||
undedicated (Q#BP2c). "It receives no new events" is insufficient — if
|
||||
the daemon nevertheless places that frontend's window in a side panel
|
||||
it cannot render, the window becomes invisible. The gate is on
|
||||
placement, not only on transport. Parent acceptance 51 pins the mixed
|
||||
session.
|
||||
|
||||
## 4. Revisions to the parent framing
|
||||
|
||||
Only these; everything else stands.
|
||||
|
||||
- **Q#BP9 resolves to v21.**
|
||||
- **Q#BP15a's epoch ownership is specified** by §3.1's table and API
|
||||
split, replacing the parent's one-line "frontend-owned" statement.
|
||||
- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes,
|
||||
two keep tracking focus.
|
||||
- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent
|
||||
acceptance 52's reference to `:339` should be read against that.
|
||||
|
||||
## 5. The four scout obligations, closed
|
||||
|
||||
### 5.1 The shared cell-grid validator boundary
|
||||
|
||||
`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`)
|
||||
currently interleaves both concerns. The exact split:
|
||||
|
||||
- **Factored into the shared parameterized wire-cell-grid validator:**
|
||||
checked area (the `checked_mul` + `usize::try_from` guard), the
|
||||
`MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count
|
||||
equality against declared area, cursor-in-bounds, and
|
||||
`validate_cells`'s glyph width / continuation topology and aggregate
|
||||
glyph-byte checks.
|
||||
- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis
|
||||
caps in `checked_area`, `validate_metadata` for title/signal/crash
|
||||
text, `validate_selection`, and the `at_bottom == (scroll_offset ==
|
||||
0)` coupling.
|
||||
|
||||
**Attachment rejection is shared, not terminal-only.** `validate_cells`
|
||||
also rejects `cell.attachment.is_some()`
|
||||
(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A
|
||||
cell carries a frontend attachment, which terminals never use"
|
||||
(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3
|
||||
missed it. **Stage 2 classifies it shared**: panels implement no
|
||||
attachment rendering, so a `PanelFrame` carrying one describes a
|
||||
surface the GPU would silently not draw. Shared rejection fails closed
|
||||
on the producer side rather than shipping an invisible cell. The error
|
||||
message is reworded away from "which terminals never use" to a
|
||||
grid-neutral phrasing when it moves. If a later stage gives panels
|
||||
attachment rendering, this rejection moves back to terminal-only as a
|
||||
deliberate, reviewed change — not by default.
|
||||
|
||||
`PanelFrame` takes the shared half plus its own presence/epoch rules
|
||||
and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K
|
||||
small-font panel wider than 512 columns is legal while the shared area
|
||||
budget still binds. Parent acceptance 39 pins exactly this.
|
||||
|
||||
### 5.2 The GPU outbox needs four more tags
|
||||
|
||||
`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four
|
||||
tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1,
|
||||
`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything
|
||||
else is `None` = lossless, counting against `OUTBOX_MAX = 8192`.
|
||||
|
||||
Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4,
|
||||
`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}`
|
||||
→ 7. Geometry is latest-wins (epochs need only increase, not be
|
||||
consecutive); resize drag is latest-wins over the complete event
|
||||
including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay
|
||||
lossless and ordered — repeated left `Down`s are what the daemon click
|
||||
state reads as a multi-click, and `Down(Right)` is the context-menu
|
||||
gesture. Tail-only replacement preserves ordering across an
|
||||
intervening event of any other class.
|
||||
|
||||
### 5.3 The pixel formula's inputs — and one trap
|
||||
|
||||
The formula in Q#BP15a is contract-level, not an implementation
|
||||
detail, because its inputs are not all safe to adopt:
|
||||
|
||||
| Input | Source | Note |
|
||||
| --- | --- | --- |
|
||||
| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe |
|
||||
| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today |
|
||||
| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe |
|
||||
| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** |
|
||||
| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** |
|
||||
|
||||
**The `mono_advance` trap.** `State::mono_advance` returns
|
||||
`measured_mono_advance` when a `FontFacts` probe has been applied, but
|
||||
otherwise falls back to **the first shaped glyph of the document
|
||||
buffer** (`main.rs:4903+`). Panel column count would therefore become
|
||||
**document-dependent**: two GPU frontends showing different files could
|
||||
derive different `total.cols` from identical metrics, and the same
|
||||
frontend's panel width could change when the document's first glyph
|
||||
changes.
|
||||
|
||||
**Decision.** The panel geometry declaration uses a **stable normal-face
|
||||
probe**, never the document sample. `probe_mono_advance(font_system,
|
||||
family, metrics)` (`main.rs:323`) already exists and is exactly this: it
|
||||
shapes `ADVANCE_PROBE` in a scratch buffer, independent of document
|
||||
contents, dividing total run width by logical cells so ligature
|
||||
substitution survives. The declaration resolves its advance from that
|
||||
probe for the current family/metrics. If the probe returns `None` (the
|
||||
family shapes no width), the frontend declares **zero usable geometry**
|
||||
under a new epoch — the panel hides — rather than falling back to a
|
||||
document sample.
|
||||
|
||||
**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by
|
||||
`FontMetrics::scale` like `status_band_height`. A 1–2 px rule is
|
||||
adequate decoration but too fragile as the drag hit strip; 4 px still
|
||||
reads as a rule while giving the pointer a usable target. **The entire
|
||||
strip is painted with `ui.divider`, and that exact rectangle is the
|
||||
hover/drag hit region** — paint geometry and hit geometry are the same
|
||||
rect, so they cannot drift apart.
|
||||
|
||||
**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset
|
||||
today, like `TEXT_LEFT` and the other paddings, while
|
||||
`FontMetrics::scale` governs font-derived metrics and row chrome.
|
||||
Scaling it only inside the declaration formula would disagree with the
|
||||
actual renderer; scaling every renderer and hit-test occurrence is a
|
||||
wholesale inset/DPI change and is **named here as separate work**, not
|
||||
smuggled into Stage 2. The formula is pinned to the real unscaled inset.
|
||||
|
||||
Accordingly, **Q#BP15a's "all quantities use the frontend's current
|
||||
scale" is narrowed**: font-derived metrics and the divider scale; fixed
|
||||
surface insets keep their current units.
|
||||
|
||||
#### The seam is three boundaries, not one
|
||||
|
||||
Rev 2 asked for a single document-bottom accessor. That was wrong:
|
||||
once a panel is installed, today's single value must **diverge into
|
||||
three**, because some of its consumers must not move at all.
|
||||
|
||||
All three clamp at zero, preserving today's `text_area_bottom`
|
||||
`.max(0.0)` behavior — without the clamps a surface shorter than its
|
||||
own chrome yields negative coordinates, and the "exact formula" stops
|
||||
being exact precisely where it matters most:
|
||||
|
||||
```
|
||||
status_band_top = max(0, surface_height - status_band_height)
|
||||
|
||||
geometry_capacity_bottom = max(0, status_band_top - divider_height)
|
||||
// divider reserved even while absent
|
||||
|
||||
document_text_bottom = max(0, status_band_top
|
||||
- installed_panel_height
|
||||
- installed_divider_height)
|
||||
```
|
||||
|
||||
`geometry_capacity_bottom` is what Q#BP15a's asymmetry already
|
||||
requires: the divider is subtracted **for sizing purposes even while
|
||||
the panel is absent**, which is what breaks the first-open cycle, while
|
||||
the document renderer does not actually lose those pixels until a
|
||||
`Present` panel is painted.
|
||||
|
||||
**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three
|
||||
at once**, and its doc comment calls it "the single source for every
|
||||
bottom-of-text computation" (Q#S3).
|
||||
|
||||
The census is **29 matches: 20 production call sites, 1 definition
|
||||
(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`,
|
||||
`:13793`, `:14013`, `:15306`, `:15386`). Every production site,
|
||||
classified individually against the source:
|
||||
|
||||
**Status-owned — must stay pixel-identical at the physical window
|
||||
bottom, using `status_band_top`** (8 sites):
|
||||
|
||||
| Site | What it is |
|
||||
| --- | --- |
|
||||
| `:5908` | Status-band background rect `y` |
|
||||
| `:6003` | `mb_visible_window` — rows that fit **above the band** |
|
||||
| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band |
|
||||
| `:7134` | `status_top` for the right status group |
|
||||
| `:7195` | `status_buffer` `TextBounds.top` — status text bound |
|
||||
| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound |
|
||||
| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin |
|
||||
| `:7922` | `status_top`, second site |
|
||||
|
||||
The minibuffer is **global, bufferless chrome anchored to the status
|
||||
band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its
|
||||
sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound
|
||||
`:7212` — stay status-owned. Clipping candidate glyphs at
|
||||
`document_text_bottom` would clip the dropdown against a boundary it
|
||||
does not sit above.
|
||||
|
||||
**Document-owned — must move when a band is installed, using
|
||||
`document_text_bottom`** (12 sites):
|
||||
|
||||
| Site | What it is |
|
||||
| --- | --- |
|
||||
| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid |
|
||||
| `:6118` | `completion_anchor_px` — anchor visibility bottom |
|
||||
| `:6140` | `completion_dropdown_layout` — **document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line |
|
||||
| `:6581` | `code_height` |
|
||||
| `:7174` | Code text clip bottom |
|
||||
| `:7242` | Math text clip bottom |
|
||||
| `:7273` | Gutter clip bottom |
|
||||
| `:7421` | Terminal clip bottom |
|
||||
| `:8077` | `code_caret_rect_in_clip` — **caret clipping** |
|
||||
| `:8497` | Minimap drawable height |
|
||||
| `:8501` | Visible-line estimate |
|
||||
| `:8561` | `edge_scroll_direction` — **document edge scrolling** |
|
||||
|
||||
**Geometry declaration** uses `geometry_capacity_bottom`, and is the
|
||||
Q#BP15a conversion only.
|
||||
|
||||
**Sites that consume no bottom coordinate at all** and must not be
|
||||
touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text
|
||||
buffers to `status_band_height` directly. Rev 2 listed them as seam
|
||||
consumers; they are not.
|
||||
|
||||
Three of these classifications are the ones a plausible implementation
|
||||
gets wrong, and each has a visible symptom: document completion
|
||||
(`:6140`) anchored to `status_band_top` **overlaps the panel**;
|
||||
minibuffer candidates (`:7351`) clipped at `document_text_bottom` are
|
||||
**cut off**; and edge scrolling (`:8561`) left on the old bottom
|
||||
**auto-scrolls from inside the panel**.
|
||||
|
||||
Each call site is classified individually. A blanket rewrite of
|
||||
`text_area_bottom` to subtract the band would move the status chrome
|
||||
with the document and is the defect this section exists to prevent.
|
||||
|
||||
**The contrast assertion (A2B-4).** "Every document consumer moved" is
|
||||
only half a test — a uniformly wrong implementation that moves
|
||||
everything passes it. The criterion must assert **both directions in
|
||||
one scenario**: installing a panel moves every document-owned consumer
|
||||
**while the status band stays pixel-identical** at the physical window
|
||||
bottom. That is the assertion a blanket rewrite fails.
|
||||
|
||||
The one-accessor-per-boundary rule still holds within each class: a
|
||||
second, unrouted derivation of any of the three is the exact shape of
|
||||
Stage 1's `Layout::compute` two-caller defect, where
|
||||
`src/overlay_paint.rs` derived its own rect and painted peer cursors at
|
||||
unfixed rows.
|
||||
|
||||
### 5.4 Ordering against folding Stage 3
|
||||
|
||||
Settled by review round 1: **bottom-panel Stage 2 first, through the
|
||||
landed GPU band.** Folding Stage 3 then re-scouts the extracted
|
||||
painter, the panel projection, clipping, and `fold_projection` behavior
|
||||
exactly once.
|
||||
|
||||
## 6. Coherence impact (per `COHERENCE.md` §20)
|
||||
|
||||
- **Journey steps touched: four, on the GPU frontend — steps 7–10**
|
||||
(find symbol / find file, terminal, build and test, error
|
||||
inspection). Rev 1 said "none directly", which contradicted its own
|
||||
next sentence. Today a GPU user who triggers references, project
|
||||
search, a terminal, compile, or error inspection gets the Stage 1
|
||||
non-side fallback: the output surface steals a document window
|
||||
instead of opening a panel. Every one of those steps therefore
|
||||
behaves differently on GPU than on TUI, and Stage 2 is what closes
|
||||
the divergence.
|
||||
- **Interaction islands added: none, and this is a reduction.** §6
|
||||
grades islands "weak, and growing by one island per modal feature".
|
||||
Stage 2 extends one already-adopted policy (`display = "panel"`,
|
||||
used by listview, compile, and terminal) to a second frontend rather
|
||||
than minting a GPU-only surface. Q#BP14b deliberately reuses the
|
||||
existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages
|
||||
instead of panel-specific twins.
|
||||
- **Config registry adoption: inherited, not extended.** Stage 1's
|
||||
`window.panel-height` and `window.min-height` already live in the
|
||||
registry. Stage 2 adds no new user-facing option; if the band needs
|
||||
one, it enters the registry.
|
||||
- **Background-work attribution: unchanged, and this stage does not
|
||||
advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It
|
||||
does not. A panel gives output a coherent *placement*; it does not
|
||||
make terminal PTYs, LSP servers, or workers appear in the
|
||||
activity/ownership view §9 describes, and it adds no join key across
|
||||
the four disjoint activity planes. The §9 gap is untouched.
|
||||
- **Section this serves:** `COHERENCE.md` §14, which records the panel
|
||||
primitive as landed for Stage 1 and names "Stage 2 (GPU band)
|
||||
pending its own framing" as the open item.
|
||||
|
||||
## 7. Acceptance
|
||||
|
||||
**Parent criteria 37–55 remain authoritative and are not replaced.**
|
||||
This section maps them to the two slices and adds only refinements.
|
||||
|
||||
### 7.1 Stage 2A — classified census routing + painter extraction
|
||||
|
||||
No protocol change. Parent criteria that apply in full: **42, 43, 44,
|
||||
51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of
|
||||
**52**.
|
||||
|
||||
**52 splits across the slices.** 2A has no semantic panel projection
|
||||
and no `PanelFrame`, so all it can prove is that the extracted painter
|
||||
honors an explicitly supplied `None` fold map and that the stale
|
||||
`src/window.rs:562` comment is corrected. The actual contract — *a
|
||||
semantic panel with `fold_projection = false` never collapses folds and
|
||||
never calls `fold_map_for_window`* — is production-reachable only once
|
||||
2B lands the projection and the capability flip. It is therefore
|
||||
reasserted in 2B (§7.2).
|
||||
|
||||
Refinements 2A adds:
|
||||
|
||||
- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census
|
||||
item (#1–#7, #9, #10, #12, #21, #22) resolves through
|
||||
`primary_document_window` / `primary_document_buffer`; #8 and #11
|
||||
align **and then activate**; **#13, #14, #15, #23 continue to resolve
|
||||
the actually focused window**; #16–#19 follow Q#BP14b's routing
|
||||
table; #20 keeps its per-session counter with focus choosing the
|
||||
eligible terminal. Each class is asserted separately, at the
|
||||
outermost user-reachable seam, and falsified by revert. A test that
|
||||
only proves "the document is used" would pass with the focus classes
|
||||
wrongly rerouted, so the focus-class assertions are the load-bearing
|
||||
half.
|
||||
- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644`
|
||||
resolves the primary document window, while `:629` and `:675`
|
||||
continue to report **actual focus** — pinned by a document provider
|
||||
truthfully observing `active = false` while the panel is focused
|
||||
(parent 42). The semantic-layout target captures primary document +
|
||||
visible side window, invokes each provider exactly once, and
|
||||
invalidates the whole evaluation when a callback mutates layout or
|
||||
focus.
|
||||
- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction
|
||||
preserves, for grid frontends: the painted **cells**, the **returned
|
||||
cursor**, the **focused window's `view_top` mutation** from the
|
||||
auto-scroll clamp, and **passive windows' untouched `view_top` and
|
||||
scroll state**. Byte-identical cells alone would not catch a clamp
|
||||
that silently moved to the wrong window.
|
||||
|
||||
### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band
|
||||
|
||||
Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47,
|
||||
48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and
|
||||
52** **through the actual negotiated capability flip** rather than
|
||||
through a test-only panel-capable semantic view. 52's 2B form is the
|
||||
production one: a real semantic frontend with `fold_projection = false`
|
||||
displaying a folded buffer in a panel shows every source line, and the
|
||||
panel path never reaches `fold_map_for_window`.
|
||||
|
||||
Refinements 2B adds:
|
||||
|
||||
- **A2B-1.** The epoch state machine of §3.1 is pinned row by row,
|
||||
including the lower-epoch-identical-data rejection and the
|
||||
same-epoch-different-total rejection, and each row's
|
||||
`Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate`
|
||||
performs no reconciliation and a `Rejected` mutates nothing. Epoch
|
||||
`0` is rejected on the wire. **Exhaustion is pinned on both sides**:
|
||||
grid exhaustion clears `frame_geometry` to unknown and the panel
|
||||
hides (a subsequent real resize must not paint a stale-geometry
|
||||
panel), and a frontend that exhausts latches — a retained `Present`
|
||||
whose epoch still matches cannot make the band reappear, and only a
|
||||
fresh session clears the latch.
|
||||
- **A2B-2.** A font or scale change that leaves `CellSize` **identical**
|
||||
still produces a new `geometry_epoch`, and the older `PanelFrame`
|
||||
neither paints nor hit-tests until a matching `Present` arrives. This
|
||||
is the case daemon value dedup cannot see and is why option 1 was
|
||||
chosen.
|
||||
- **A2B-3.** Panel columns are derived from the **stable normal-face
|
||||
probe**, not `State::mono_advance`'s document-glyph fallback: two GPU
|
||||
frontends with identical metrics and different documents derive
|
||||
identical `total.cols`, and a probe returning `None` declares zero
|
||||
usable geometry rather than falling back to a document sample.
|
||||
- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve
|
||||
document-owned consumers** of §5.3 by exactly
|
||||
`installed_panel_height + divider_height`, **while all eight
|
||||
status-owned sites stay pixel-identical** at the physical window
|
||||
bottom. Both halves are asserted in one scenario: a uniformly wrong
|
||||
implementation that moves the status band too passes the "everything
|
||||
moved" half alone. Three rows carry their own named symptom because
|
||||
they are the ones a plausible implementation misclassifies —
|
||||
**document completion (`:6140`) must not overlap the band**,
|
||||
**minibuffer candidates (`:7351`) must not be clipped by it**, and
|
||||
**edge scrolling (`:8561`) must not trigger from inside it**. The
|
||||
geometry declaration separately reserves the divider while the panel
|
||||
is `Absent`, and the document loses no pixels until a `Present` is
|
||||
painted. All three boundaries clamp at zero on a surface shorter than
|
||||
its chrome.
|
||||
- **A2B-5.** `panel_capable` is true only for a v21+ negotiated
|
||||
authenticated semantic session; a v20 semantic session is never
|
||||
**placed** in a side window, not merely denied the events.
|
||||
|
||||
## 8. Open items
|
||||
|
||||
**None.** Both round-1 open items are decided in §5.3:
|
||||
`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted
|
||||
`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled
|
||||
with wholesale inset/DPI scaling named as separate work.
|
||||
|
||||
One deferral is recorded rather than resolved: **wholesale surface-inset
|
||||
scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under
|
||||
`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than
|
||||
fixes. It belongs to a spacing-system change of its own.
|
||||
|
||||
## 9. Slices, branches, and gates
|
||||
|
||||
Per review round 1: **two serial implementation PRs**, each a named
|
||||
slice under this framing so one-feature/one-branch/one-PR holds. **2A
|
||||
lands before 2B branches** — not stacked.
|
||||
|
||||
- **Stage 2A** — classified census routing + per-window painter
|
||||
extraction. Branch `bottom-panel-stage2a`. No protocol change. The
|
||||
three-boundary GPU split is **2B**, not 2A: it is only observable
|
||||
once a band can be installed.
|
||||
- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and
|
||||
the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`,
|
||||
cut from `main` after 2A merges. Repeats 2A's relevant census
|
||||
assertions through the real capability flip.
|
||||
|
||||
Gates for both: the standing suite from `CLAUDE.md`, plus the **touched
|
||||
acceptance suites named explicitly** — the standing rule is to run the
|
||||
suites a change touches, and "standing suite" does not name them:
|
||||
|
||||
- `bottom_panel_stage1_acceptance` — the substrate both slices build on.
|
||||
- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`
|
||||
— new, one per slice.
|
||||
- `statusline_segments_acceptance` — the fan-out target change (§3.3).
|
||||
- `m11_5_semantic_acceptance` — the semantic census (§3.2).
|
||||
- `gpu_initial_target_acceptance` — parent criterion 55.
|
||||
- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including
|
||||
the normal-face probe and the unscaled-`TEXT_TOP` decision.
|
||||
- The three vterm suites — the panel hosts terminals.
|
||||
- Folding Stage 2's 48 — shared projection.
|
||||
- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`.
|
||||
|
||||
Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's
|
||||
`--headless-probe` run — one real daemon, real PTY, real wgpu, through a
|
||||
panel-hosted terminal — is a 2B gate.
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
# Dired — framing
|
||||
|
||||
**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.**
|
||||
**Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162;
|
||||
Stage 1 IN REVIEW as PR #165, review round 1 addressed.**
|
||||
Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings;
|
||||
rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's
|
||||
implementation falsified in the approved text (§0); rev 5 adds the
|
||||
**coherence impact** statement now required of every framing
|
||||
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5. Deliberately
|
||||
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5; rev 6 records what Stage
|
||||
1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first
|
||||
review round found (§0, S1-10…S1-12). Deliberately
|
||||
unnumbered: the roadmap's Arc 8 is GPU
|
||||
structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so
|
||||
the arc space is already forked in uncommitted work. (Rev 2 also cited
|
||||
|
|
@ -203,6 +206,135 @@ the correction belongs here rather than only in the code.
|
|||
same normalize-before-lookup family as Q#DR5's `apply_resource_op`
|
||||
correction.
|
||||
|
||||
### Stage 1 implementation notes (rev 5 → rev 6)
|
||||
|
||||
Implementing Stage 1 (PR #165) falsified four things the approved text
|
||||
asserted and settled five it left open. Recorded here rather than only
|
||||
in the code, per the rev-4 precedent.
|
||||
|
||||
- **S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly
|
||||
false, in the direction Q#DR2 preferred.** Q#DR2 made the mirror
|
||||
conditional (`Stage 1 may still mirror if exposure turns out to drag
|
||||
in EditorCore borrow plumbing it does not otherwise need`).
|
||||
`normalize_buffer_path` is a **free function** (`editor_core.rs`), so
|
||||
exposure drags in nothing: it is now `pub` and reachable as
|
||||
`pmacs.path.canonicalize`. Consequences, all deliberate: B2 ("tolerant
|
||||
`read_dir` is the only Rust change Stage 1 needs") is false by one
|
||||
small binding; acceptance 3b degenerates to the round-trip form the
|
||||
framing described; and the Stage 2 mirror-removal follow-up **is not
|
||||
owed** — there is no second canonical form to remove. The parity
|
||||
acceptance is still carried, now as "the Lua binding and the Rust
|
||||
function agree over one shared edge list", which is exactly the claim
|
||||
a future re-mirroring would break.
|
||||
- **S1-2. R2-3's dedication claim is falsified by the substrate.** It
|
||||
read "a dedicated dired panel stays dedicated across descent and the
|
||||
new dired buffer inherits it". `display_buffer` never replaces the
|
||||
buffer in a slot dedicated to another one: it discards every
|
||||
side-specific parameter and falls back to the document window (Q#BP3
|
||||
2.iii), and the exact-window arm errors outright. Dired therefore does
|
||||
**not** try to unpin the user's panel — which is also what Emacs's
|
||||
`display-buffer` does with a dedicated window. Acceptance 3c is split:
|
||||
a non-dedicated panel keeps the descent, and a dedicated one keeps its
|
||||
buffer *and* its pin while the new directory appears in the document
|
||||
window.
|
||||
- **S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now
|
||||
says so.** Dired holds the focus in its own panel, so a raw
|
||||
`switch_buffer` lands in that same window and every 3c assertion holds
|
||||
either way — the mutation is *vacuous* against it. Dedication is the
|
||||
only thing that distinguishes `display { side = … }` from the raw
|
||||
switch, so the dedicated-panel test is the discriminating pin. Found
|
||||
by running the bite rather than by reading the test; the vacuity is
|
||||
documented at the assertion instead of being left to be believed.
|
||||
- **S1-4. Dired is the first builtin to bind a mode-scoped key, and one
|
||||
pre-existing lib test assumed none existed.**
|
||||
`describe_key_identifies_every_default_binding` iterated *every*
|
||||
binding in the stack and asserted `pmacs.describe.key` resolves it
|
||||
context-free, which held only while the modes table was empty. It now
|
||||
sets the effective context per binding — and explicitly *clears* the
|
||||
mode for a global one, because a mode left over from a previous
|
||||
iteration legitimately shadows a global chord of the same name
|
||||
(dired's `RET` shadows `edit.newline-and-indent`, which is the point
|
||||
of the mode).
|
||||
- **S1-5. `C-x d` deliberately takes NO completion source.** It is the
|
||||
direct consequence of S0-1/S0-4: with a `files` source, RET on an
|
||||
empty field opens whatever sorts first (the minibuffer selects
|
||||
candidate 0 whenever the list is non-empty, and a selected candidate
|
||||
shadows typed text), and RET-on-the-directory-you-are-in is exactly
|
||||
the gesture `C-x d` exists for. The field is **prefilled** with the
|
||||
current directory instead — Emacs's own shape here — and free text
|
||||
always reaches `on_accept` because `CompletionSource::None` bypasses
|
||||
candidate resolution entirely. Directory-name completion is what dired
|
||||
itself replaces.
|
||||
- **S1-6. Ownership is the handle table ALONE**, narrower than Q#DR2's
|
||||
"present in dired's handle table, or `major_mode(buf) == "dired"`". A
|
||||
foreign buffer that carries the mode *is* the case the check exists to
|
||||
refuse, and a builtin's handle table cannot be lost the way a
|
||||
reloadable package's can. Acceptance 4 sets the mode on the foreign
|
||||
buffer to pin the stronger reading.
|
||||
- **S1-7. The mark column ships in Stage 1, rendered blank.** Q#DR4 is a
|
||||
Stage 2 decision, but reserving the two columns now means Stage 2 does
|
||||
not move every offset and Stage 3's column-classifying intercept can
|
||||
be written against constants that did not shift under it. The
|
||||
constants are computed from the widths (the fixture hardcoded
|
||||
`NAME_START = 39` and paid for it in every wdired test) and exported
|
||||
as `pmacs.dired._layout` so acceptance cannot drift from them.
|
||||
- **S1-8. A symlinked directory needs a probe, because kinds are
|
||||
lstat-based.** Both `read_dir` and `stat` report a link as
|
||||
`"symlink"`, so nothing in the entry says whether it points at a
|
||||
directory. `RET` on a symlink therefore *tries* to list the target
|
||||
(one extra syscall, on symlink lines only) and descends if that
|
||||
succeeds, else visits it as a file. Q#DR10 specified only the
|
||||
dir/file arms; this is the third.
|
||||
- **S1-9. Interactive origin does not survive the await.** Every listing
|
||||
is worker-dispatched, so the work after the first `:await()` resumes
|
||||
inside `tick_async`, where `InteractiveCommandOrigin` is empty and
|
||||
`pmacs.window.*` falls back to the **ambient** active frontend. Single
|
||||
frontend: correct. Multi-frontend: a dired opened from peer B while A
|
||||
is ambient would display for A. Not fixable from Lua (the display
|
||||
surface takes no frontend argument) and named here rather than
|
||||
discovered later.
|
||||
|
||||
### Stage 1 review round 1 (rev 6 → rev 7)
|
||||
|
||||
Three findings changed behavior; the rest were naming and comments. Each
|
||||
fix is bite-verified against the test that names it.
|
||||
|
||||
- **S1-10. An ambient re-seat is not safe after an await.** `dired.revert`
|
||||
painted its own buffer by name (safe) and then re-seated through
|
||||
`pmacs.editor.move_to_line`, which moves whatever window is
|
||||
**active** — so a user who switched buffers while the re-read was in
|
||||
flight had an unrelated buffer's cursor moved to a line index
|
||||
meaningful only in the dired listing. This is the buffer-level instance
|
||||
of the hazard S1-9 named at the frontend level, and it generalizes: in
|
||||
this codebase, *painting takes a buffer and seating takes the world*.
|
||||
Any post-await cursor operation needs an active-buffer guard;
|
||||
`open_directory` is exempt only because it displays the buffer first.
|
||||
- **S1-11. The rendered columns are a contract, so precision yields to
|
||||
width.** `%10d` overflowed at 10 GB (VM images, core dumps), widening
|
||||
the size field and shifting mtime and name right on that line alone.
|
||||
Cosmetically harmless today, but `_layout` is exported and Stage 3's
|
||||
column-classifying intercept is planned against it, so a
|
||||
contract-violating line now is a Stage 3 trap. `fmt_size` took
|
||||
`fmt_mtime`'s shape: exact bytes while they fit, else a fixed-width
|
||||
magnitude. Not the deferred human-readable column (§13) — the exact
|
||||
count still renders right up to the point where it cannot.
|
||||
- **S1-12. `open_directory`'s "changed nothing on failure" invariant is
|
||||
reusable as a PROBE.** S1-8's symlink descent originally listed the
|
||||
target to learn its kind and then opened it — two full listings of the
|
||||
same directory. Because a failed open touches no editor state
|
||||
(acceptance 15), the open itself is the probe: try the descent, fall
|
||||
back to `display_file`. One read. The comment that claimed "one
|
||||
syscall" for a full `read_dir` is corrected rather than left as a
|
||||
cost claim nobody would re-check.
|
||||
|
||||
Also, on the tolerant channel (Q#DR6): a `readdir` iterator may keep
|
||||
yielding errors without terminating, and **cancellation is not a backstop
|
||||
for a dired listing** — it carries no supersede key, so nothing cancels
|
||||
it. A consecutive-error cap now fails the listing the way an unopenable
|
||||
directory fails, rather than accumulating error rows on a worker thread.
|
||||
It is deliberately untested: faking a failing iterator would need the
|
||||
walk generic over it, a refactor with no other consumer.
|
||||
|
||||
## 0.5. Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
Required of every framing since #163. This arc was scouted and approved
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ candidates, ranked:
|
|||
|
||||
| Font | Table quality | License | Notes |
|
||||
|------|--------------|---------|-------|
|
||||
| **Latin Modern Math** | Full | OFL (GUST) | Reference, ships with TeX Live, most widely tested |
|
||||
| **Latin Modern Math** | Full | **GUST Font License** (not OFL) | Reference, ships with TeX Live, most widely tested; ~717 KiB. Bundled by #158 as `pmacs-gpu/fonts/latinmodern-math.otf` |
|
||||
| **STIX Two Math** | Full | OFL | Broader Unicode coverage |
|
||||
| **Cambria Math** | Full | Proprietary | Ships with Office; unavailable on Linux |
|
||||
| **Libertinus Math** | Full | OFL | Derivative of Latin Modern, wider |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,628 @@
|
|||
# Inline math — the first vertical slice (framing)
|
||||
|
||||
**Revision 3 — pre-implementation, framing only. Ground truth scouted against
|
||||
canonical `main` @ `352bf0b`, protocol v20, 2026-07-24. Rev 2 closed review
|
||||
round 1 (F1–F9); rev 3 closes round 2 (R2-1 – R2-4).**
|
||||
|
||||
### Round 2 (rev 2 → rev 3)
|
||||
|
||||
Verdict: converging — one deletion, one real gap, two nits.
|
||||
|
||||
| # | Finding | Closed in |
|
||||
| --- | --- | --- |
|
||||
| R2-1 | The tree-sitter paragraph appeared **twice** in Q#MS3; the second was stale rev-1 text. Introduced by rev 2's own rewrite, which added a copy without removing the original | Q#MS3 |
|
||||
| R2-2 | The F7 italic fix stopped at ASCII, so `$\alpha x$` drew an **upright α beside an italic 𝑥** — mixed styles inside one expression, and Greek is the slice's second flagship | Q#MS2, acceptance 13 |
|
||||
| R2-3 | §9 sat between §6 and §7 | section order |
|
||||
| R2-4 | Q#MS11 said a wash "covers" a span; a search match can **partially overlap** (`2$ af` in `before $x^2$ after`), which "covers" leaves unspecified | Q#MS11 |
|
||||
|
||||
Carried into acceptance from a round-2 non-finding: the Q#MS10 arithmetic puts
|
||||
`\frac{a}{b}` near 0.85 and suggests `\frac{x^2}{y}` also clears the 0.6
|
||||
floor, so criterion 12's fallback case must be **computed rather than guessed**
|
||||
or it will surprise-pass by rendering.
|
||||
|
||||
### Round 1 (rev 1 → rev 2)
|
||||
|
||||
Verdict: the slice's shape survived, both load-bearing corrections held, and
|
||||
nine findings landed — two of them decisions the implementation could not have
|
||||
proceeded without, one a compliance error.
|
||||
|
||||
| # | Finding | Closed in |
|
||||
| --- | --- | --- |
|
||||
| F1 | The fraction height budget was never confronted; lines cannot grow (`BASE_CODE_LINE_HEIGHT = 22.0` fixed) and a textstyle fraction does not fit | Q#MS10 (new) |
|
||||
| F2 | "Contributes no glyphs, reserves width" is a mechanism the chunk model does not have — a `RichChunk`'s only width is its `text` | Q#MS4, B1 |
|
||||
| F3 | Criterion 10 required source-width boxes while Q#MS4 implied layout-chosen width; the contradiction *is* the caret-toggle reflow question | Q#MS4, acceptance 10 |
|
||||
| F4 | Q#MS5 makes shaping depend on the caret — a new invalidation edge, and it must read the *effective* caret or flap during optimistic typing | Q#MS5 |
|
||||
| F5 | Detection had no currency guard (`$5 and $6` pairs) and no newline rule | Q#MS3 |
|
||||
| F6 | **Factual:** Latin Modern Math is GUST Font License and ~717 KiB, not OFL and ~200 KB | Q#MS7, §9 |
|
||||
| F7 | Without a math-italic mapping, `$x^2$` renders an upright roman `x` | Q#MS2 |
|
||||
| F8 | Layout still resolves glyph IDs internally; drawing must pin `Attrs` to the math family or measured and drawn advances diverge | Q#MS6, Q#MS7 |
|
||||
| F9 | Smaller: "after shaping decisions" contradicts Q#MS4; no selection/wash rule; `$$…$$` degradation untested; `Char` vs `Symbol` unmotivated | Q#MS3, Q#MS2, Q#MS11 (new), acceptance |
|
||||
|
||||
Two rev-1 claims were **wrong, not merely imprecise**, and are called out
|
||||
where they occur: the zero-glyph strut (F2) and the font licence (F6).
|
||||
|
||||
Parent arc: `docs/inline-math-framing.md` (rev 2, merged as #154). Sibling
|
||||
substrate lane: `docs/latex-grammar-math-substrate-framing.md` (rev 3), whose
|
||||
Stage 1 landed as #144.
|
||||
|
||||
This lane builds the **first end-to-end slice** of the parent's four-tier
|
||||
pipeline: a deliberately small LaTeX-math subset that is detected, parsed,
|
||||
laid out against a real OpenType MATH table, and **actually drawn on screen**.
|
||||
|
||||
## 0. Why a slice, and not "Tier 2 + Tier 3"
|
||||
|
||||
The obvious next unit was the parser (Tier 2) plus the layout engine
|
||||
(Tier 3). It is rejected here for the parent arc's own reason.
|
||||
|
||||
The substrate lane's **Q#LX5** refused to land the parser ahead of layout
|
||||
because *"the `MathNode` shape is only validated once [a layout consumer]
|
||||
exists"*. That argument does not stop at Tier 2. `MathBox` is only validated
|
||||
once a **renderer** consumes it: an unrendered layout engine can be
|
||||
self-consistent and still have the wrong shape — wrong units, wrong origin
|
||||
convention, a baseline the draw path cannot use. Landing Tiers 2+3 with no
|
||||
Tier 4 reproduces exactly the objection Q#LX5 raised, one layer up.
|
||||
|
||||
So the unit of work is **thin and vertical, not broad and horizontal**: the
|
||||
smallest grammar subset worth rendering, carried all the way to pixels. Every
|
||||
layer acquires a real consumer immediately. Breadth — big operators, stretchy
|
||||
fences, radicals, accents, display math — becomes follow-on work against an
|
||||
API that has already been exercised rather than one that has only been
|
||||
designed.
|
||||
|
||||
The cost is honest and named in §7: the slice touches
|
||||
`pmacs-gpu/src/main.rs`'s render path, which two other arcs also want.
|
||||
|
||||
## 1. Ground truth (scouted 2026-07-24 @ `352bf0b`)
|
||||
|
||||
### 1.1 Crate boundaries — the parent's file placement cannot work
|
||||
|
||||
The parent framing's integration table lists `src/math_parse.rs` and
|
||||
`src/math_layout.rs`, i.e. the **core `pmacs` crate**. Verified against the
|
||||
tree, that placement is unusable:
|
||||
|
||||
- **`pmacs-gpu` depends only on `pmacs-protocol`** (`pmacs-gpu/Cargo.toml:60`;
|
||||
there is no `pmacs` dependency). A parser in the core crate is therefore
|
||||
**unreachable from the frontend that renders it**.
|
||||
- **`ttf-parser` reaches only `pmacs-gpu`.** Per-crate check: `pmacs` no,
|
||||
`pmacs-protocol` no, `pmacs-gpu` yes (via `fontdb` → `cosmic-text` →
|
||||
`glyphon`). A layout module in the core would be a genuinely new dependency
|
||||
there, which is not what the parent's C1 established.
|
||||
|
||||
Both also contradict the parent's own prose — its design contract ("the
|
||||
instance never learns a pixel") and its protocol section ("math rendering is a
|
||||
pure frontend responsibility in v0"). The table was the outlier. Q#MS1 fixes
|
||||
it.
|
||||
|
||||
### 1.2 The GPU text pipeline this slice hooks
|
||||
|
||||
- `rebuild_code_slice` (`pmacs-gpu/src/main.rs:6136`) shapes **only the
|
||||
visible byte slice**; spans/decorations/adornments arrive in whole-file
|
||||
coordinates and are clipped and rebased onto it.
|
||||
- Per line, `chunks_for_line` (`:5100`) produces `RichChunk`s whose
|
||||
`ChunkSource` (`:7715`) is one of `Source { start }`,
|
||||
`SourceTab { start }`, `Adornment { anchor }`.
|
||||
- **Every existing variant is additive.** Adornments (inlay hints) inject text
|
||||
*between* source bytes; nothing today *replaces* a source range with a box
|
||||
of chosen width. That mechanism is what this slice must build (Q#MS4).
|
||||
- `build_hit_runs` (`:7739`) derives the projected→source hit map from the
|
||||
same chunks that feed glyphon, so the map and the shaped buffer cannot
|
||||
disagree. Any new chunk kind must participate here or clicks land wrong.
|
||||
- Custom drawing precedent: `SquiggleRenderer` (`:2825`) owns its WGSL shader
|
||||
and pipeline; the menu/background quad pipeline is the precedent for filled
|
||||
rectangles.
|
||||
- Fonts are embedded with `include_bytes!` from `pmacs-gpu/fonts/` under OFL
|
||||
(`JETBRAINS_MONO`, `:63`); `build_font_system` (`:217`) loads them into
|
||||
`fontdb`.
|
||||
|
||||
### 1.3 The acceptance seam already exists
|
||||
|
||||
`headless_or_skip(w, h, text)` builds a real headless GPU state and
|
||||
`render_offscreen()` returns mapped pixels (`copy_texture_to_buffer` at
|
||||
`:6570`). `headless_diag_face_recolors_band_counter_despite_unchanged_text`
|
||||
(`:12022`) is the precedent: render, mutate, render again, and assert on the
|
||||
pixel difference. Real-GPU tests run under `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
This matters because the slice's central claim — *math is actually drawn* —
|
||||
is exactly the kind of claim that a non-rendering test would pass vacuously.
|
||||
|
||||
## 2. What ships
|
||||
|
||||
One PR: detection (inline `$…$` only) → parse → layout against the MATH table
|
||||
→ draw, for the subset in Q#MS2, with the raw source shown whenever the
|
||||
cursor is inside the span (Q#MS5).
|
||||
|
||||
Explicitly **not** in this slice: display math `$$…$$`, big operators,
|
||||
stretchy fences, radicals, accents, `\text{}`, style overrides, tree-sitter
|
||||
injection detection, any wire surface, and the TUI.
|
||||
|
||||
## 3. Decisions
|
||||
|
||||
### Q#MS1 — Both modules live in `pmacs-gpu`
|
||||
|
||||
`pmacs-gpu/src/math_parse.rs` and `pmacs-gpu/src/math_layout.rs`. Not
|
||||
`src/`, for the three independent reasons in §1.1. This keeps v0 exactly what
|
||||
the parent says it is — a pure frontend responsibility — and keeps the core
|
||||
crate free of a font-metrics dependency it has no use for.
|
||||
|
||||
If instance-side detection ever lands (the parent's v1 `MathSpans`), the
|
||||
*parser* may move to a shared crate at that point. Nothing in this slice
|
||||
should assume it will.
|
||||
|
||||
### Q#MS2 — The subset: characters, sub/superscript, fraction
|
||||
|
||||
`MathNode` for this slice:
|
||||
|
||||
```rust
|
||||
enum MathNode {
|
||||
Char(char), // resolved codepoint: x, 2, +, α
|
||||
Group(Vec<MathNode>),
|
||||
Script { base: Box<MathNode>, sub: Option<Box<MathNode>>, sup: Option<Box<MathNode>> },
|
||||
Fraction { num: Box<MathNode>, den: Box<MathNode> },
|
||||
}
|
||||
```
|
||||
|
||||
Rev 1 had both `Char` and `Symbol`, each carrying a `char`, with no stated
|
||||
difference (F9d). Folded: `\alpha` resolves to `'α'` **in the parser**, so
|
||||
layout sees one kind. Provenance would only matter for error messages, which
|
||||
Q#MS8 does not produce.
|
||||
|
||||
This subset is chosen because it is the smallest one that **forces the MATH
|
||||
table to matter**. Characters alone could be positioned by guesswork and prove
|
||||
nothing. Scripts require `ScriptPercentScaleDown`, `SuperscriptShiftUp` and
|
||||
`SubscriptShiftDown`; fractions require `AxisHeight` and the fraction rule
|
||||
constants, plus nested box composition. Get those right and the remaining node
|
||||
kinds are more of the same; get them wrong and no amount of breadth helps.
|
||||
|
||||
The symbol map ships as a **seed** (Greek letters only, ~50 entries), not the
|
||||
parent's full ~200. Growing it is mechanical and needs no design.
|
||||
|
||||
**Math italic is in scope, and it covers Greek too (F7, R2-2).** Neither rev 1
|
||||
nor the parent mentioned italics, and without them `$x^2$` renders an upright
|
||||
roman `x` — which does not look like math, and would make the slice's flagship
|
||||
acceptance case visibly wrong.
|
||||
|
||||
Rev 2 fixed that for ASCII only, which reintroduced the same defect one symbol
|
||||
over: `\alpha` resolves to U+03B1 in the parser, so `$\alpha x$` would have
|
||||
drawn an upright α beside an italic 𝑥 — **mixed styles inside one
|
||||
expression**, with the Greek seed map being the slice's *second* flagship case.
|
||||
The mapping therefore follows TeX's actual convention:
|
||||
|
||||
| Class | Treatment | Range |
|
||||
| --- | --- | --- |
|
||||
| ASCII letters | math italic | U+1D434–U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) |
|
||||
| Lowercase Greek | math italic | U+1D6FC–U+1D714 |
|
||||
| Uppercase Greek | **upright** | left at U+0391–U+03A9 |
|
||||
| Digits, operators | upright | unchanged |
|
||||
|
||||
Uppercase-Greek-upright is not an omission; it is what TeX does, and matching
|
||||
it is why the table is stated rather than left as "letters become italic".
|
||||
Because the slice positions characters, this stays a pure char→char mapping —
|
||||
the same mechanical class as the Greek seed itself.
|
||||
|
||||
### Q#MS3 — Detection is the frontend byte scanner, inline only, currency-guarded
|
||||
|
||||
A two-pass scan over the visible slice for unescaped `$…$` pairs, run in
|
||||
`rebuild_code_slice` **off the edit path**. (Rev 1 said "after shaping
|
||||
decisions", inherited from the parent's "post-shape hook"; that contradicts
|
||||
Q#MS4, since a suppression chunk must exist *before* the line is shaped. The
|
||||
property that actually matters is that detection does not run per keystroke —
|
||||
F9a.)
|
||||
|
||||
**Currency guards are mandatory, not a refinement (F5).** Rev 1 relied on the
|
||||
parent's lone-`$` case and would have rendered `prices are $5 and $6 today` as
|
||||
math over `5 and ` — in exactly the grammar-less prose buffers this rule
|
||||
targets. Adopt Pandoc's rule:
|
||||
|
||||
- an opening `$` must be followed by a **non-space**;
|
||||
- a closing `$` must be preceded by a **non-space** and not followed by a
|
||||
**digit**;
|
||||
- `\$` is an escape and neither opens nor closes.
|
||||
|
||||
**A span may not cross a newline in v0.** Chunking is per line and the visible
|
||||
slice is line-ranged, so single-line spans are what keep visible-slice-scoped
|
||||
scanning stable under scroll. A `$` with no same-line partner yields no span.
|
||||
|
||||
Tree-sitter injection detection is deliberately not used, even though #144
|
||||
gives us `math_environment` / `math_delimiter` for `.tex`: that path is
|
||||
instance-side, the substrate lane already deferred it to this arc, and the
|
||||
slice must work in the grammar-less buffers where most inline math is typed.
|
||||
It stays available as the natural upgrade — and it is the principled fix for
|
||||
currency false-positives, which guards only approximate.
|
||||
|
||||
### Q#MS4 — Suppression is a spacer chunk, width-quantized, layout-chosen (F2, F3)
|
||||
|
||||
**Rev 1 was wrong about the mechanism.** It said the chunk "contributes no
|
||||
glyphs… reserves width". A `RichChunk`'s only width *is* its `text: String`
|
||||
(`pmacs-gpu/src/main.rs:7703`), which `line_from_chunks` feeds straight into a
|
||||
`BufferLine`; cosmic-text has no zero-glyph strut. There is nothing to reserve
|
||||
width with except text.
|
||||
|
||||
The mechanism is therefore the **`SourceTab` precedent**: `ChunkSource` gains a
|
||||
variant carrying the suppressed source range, and the chunk projects **spacer
|
||||
text** — runs of spaces — whose advance covers the box. Reserved width is
|
||||
consequently **quantized up to whole space advances**, which is a feature, not
|
||||
a rounding error: the projection stays grid-aligned with the surrounding
|
||||
monospace text, and hit runs stay integral.
|
||||
|
||||
**Width is layout-chosen, not pinned to the source width (F3).** Rev 1 implied
|
||||
both, and acceptance 10 demanded the latter. Resolved deliberately in favour of
|
||||
layout-chosen:
|
||||
|
||||
- Pinning to source width removes reflow, but `$\frac{a}{b}$` is 13 source
|
||||
columns against a box roughly 2 wide, so every fraction would sit in a large
|
||||
blank gap. That defect is permanent and visible on every render.
|
||||
- Layout-chosen width means the line **reflows when the caret crosses a span
|
||||
boundary** (Q#MS5 toggles suppression). That is a jump, but it is confined to
|
||||
one line, it happens only on a deliberate caret move, and it is the same
|
||||
behaviour `org-appear` has trained users to expect from Emacs.
|
||||
|
||||
A permanent visual defect is worse than a transient one tied to an explicit
|
||||
user action. Acceptance 10 is rewritten to match: text *before* the span never
|
||||
moves, text *after* it moves by exactly the quantized difference, and the
|
||||
reflow is confined to the affected line.
|
||||
|
||||
`build_hit_runs`'s invariant — the hit map derives from the same chunks
|
||||
glyphon shaped — is not weakened; the new variant participates like any other.
|
||||
A click inside a math box maps to the **start byte of the suppressed range**,
|
||||
the same snap-to-anchor rule `Adornment` uses. Sub-expression hit-testing is
|
||||
deferred; it needs a box→byte map this slice deliberately does not build.
|
||||
|
||||
### Q#MS10 — The height budget: fit to the line, or fall back (F1)
|
||||
|
||||
The code buffer is one cosmic-text `Buffer` with uniform metrics —
|
||||
`BASE_CODE_FONT_SIZE = 16.0`, `BASE_CODE_LINE_HEIGHT = 22.0`
|
||||
(`pmacs-gpu/src/main.rs:362`, `:359`). **Lines cannot grow.** A textstyle
|
||||
fraction at those metrics is roughly 17 px tall against an above-baseline
|
||||
budget of ~12–14 px, so a simple fraction is marginal and acceptance 1's own
|
||||
nested `\frac{x^2}{y}` plainly exceeds. Rev 1 hid this inside Q#MS8's "a box
|
||||
that would exceed the line" without saying whether that meant width or height,
|
||||
or what the budget was.
|
||||
|
||||
**Rule: the box is uniformly scaled to fit the line box, down to a floor of
|
||||
0.6×; below the floor the span falls back to source (Q#MS8).** No overdraw, no
|
||||
reflow of line height, no clipping surprises. The available budget is the line
|
||||
box less a one-pixel margin, split at the text baseline.
|
||||
|
||||
Rejected alternatives, for the record:
|
||||
|
||||
- **Overdraw into adjacent lines' leading.** The math pass draws after
|
||||
glyphon and *could* paint outside the line box, but a tall fraction would
|
||||
then visually collide with the line above — a defect the user cannot fix
|
||||
except by not writing math.
|
||||
- **Growing the line.** Not available: metrics are uniform for the whole
|
||||
buffer.
|
||||
|
||||
The honest consequence: **v0 shrinks nested math uniformly rather than by
|
||||
proper style level.** TeX shrinks nested fractions too, but it does so through
|
||||
display/text/script/scriptscript levels with per-level constants, which is the
|
||||
real answer and is deferred by name in §6. A uniform scale is a visibly
|
||||
cruder approximation of the same idea, and it is what keeps the slice thin.
|
||||
|
||||
### Q#MS5 — The cursor rule: render math only when the cursor is outside
|
||||
|
||||
When the caret is anywhere inside a math span (or on either delimiter), that
|
||||
span is **not** suppressed — the raw `$…$` renders as ordinary source text.
|
||||
|
||||
This is the parent's Q#IM5 proposal ("when the cursor approaches the boundary,
|
||||
the raw `$` reappears") adopted as a hard rule, and it buys the slice a great
|
||||
deal: there is no caret-inside-rendered-math problem to solve, because the two
|
||||
states are mutually exclusive. Editing math shows source; moving away renders
|
||||
it. Q#IM6's "best-effort fractional cursor projection" is then not needed at
|
||||
all in v0, and is deferred rather than approximated.
|
||||
|
||||
It also gives the feature an honest, self-explaining interaction model, which
|
||||
is worth more in v0 than sub-glyph caret fidelity.
|
||||
|
||||
**This creates a new shaping-invalidation edge, and it is the #120 trap class
|
||||
(F4).** Today caret motion within the visible slice touches no shaped line:
|
||||
the `CursorByte` arm updates the cursor and reshapes only on scroll-follow,
|
||||
and `rebuild_lines_reusing_scroll` (`pmacs-gpu/src/main.rs:5116`) retains
|
||||
lines on the premise that content and styling are unchanged. Making
|
||||
suppression a function of the caret breaks that premise. Two obligations
|
||||
follow, both of which the implementation owns explicitly:
|
||||
|
||||
- **Caret motion that crosses a span boundary must dirty the affected
|
||||
lines**, and the line-reuse predicate gains suppression state as a third
|
||||
input beside content and styling. A retained line computed under the
|
||||
opposite suppression state is exactly the stale-mirror failure #120 taught.
|
||||
- **The rule reads the *effective* caret the frontend draws**, not the last
|
||||
confirmed `CursorByte`. The GPU holds an optimistic cursor during
|
||||
unconfirmed edits; keying suppression off the confirmed value would make
|
||||
spans flap between rendered and source while typing.
|
||||
|
||||
Acceptance 7 exercises the behaviour; these two are named here because a test
|
||||
that only moves the caret and re-renders would pass even if the reuse
|
||||
predicate were left untouched, as long as something else happened to dirty the
|
||||
line.
|
||||
|
||||
### Q#MS6 — Layout positions CHARACTERS, not glyph IDs
|
||||
|
||||
```rust
|
||||
struct MathBox { width: f32, ascent: f32, descent: f32, items: Vec<MathItem> }
|
||||
enum MathItem {
|
||||
Glyph { ch: char, x: f32, baseline: f32, size_px: f32 },
|
||||
Rule { x: f32, y: f32, width: f32, thickness: f32 }, // fraction bar
|
||||
}
|
||||
```
|
||||
|
||||
Positions are in pixels relative to the box origin, resolved by the frontend
|
||||
that owns font metrics — consistent with the parent's contract.
|
||||
|
||||
**Characters, not glyph IDs, is a deliberate boundary — on the OUTPUT only
|
||||
(F8a).** Layout still resolves glyph IDs *internally*: advances and
|
||||
`MathItalicsCorrection` are glyph-keyed, so a `cmap` lookup happens whatever
|
||||
the item type. What the boundary buys is that the *emitted* items are
|
||||
drawable by the existing text machinery.
|
||||
|
||||
Glyph-ID **output** exists to select *variants* from the MATH table's
|
||||
`GlyphVariantRecord` / `GlyphConstruction` chains — precisely what stretchy
|
||||
fences and big operators need, and precisely what this slice defers. The slice
|
||||
must not pretend this generalises: when stretchy delimiters arrive they will
|
||||
need glyph-ID items, and `MathItem` will gain a variant then.
|
||||
|
||||
The fraction rule is a filled quad on the existing quad pipeline, not a glyph.
|
||||
|
||||
### Q#MS7 — The MATH font and its feature declaration
|
||||
|
||||
Bundle **Latin Modern Math** in `pmacs-gpu/fonts/`, embedded with
|
||||
`include_bytes!` beside JetBrains Mono. Two consumers read the same bytes:
|
||||
`fontdb`/cosmic-text for drawing, and `ttf-parser` directly for the MATH
|
||||
table, which cosmic-text does not expose.
|
||||
|
||||
**Licence and size, corrected (F6).** Rev 1 said "OFL, GUST" and the parent's
|
||||
table says "OFL (GUST)". Both are **wrong**. Verified against a local TeX Live
|
||||
copy, `latinmodern-math.otf` is **733,736 bytes (~717 KiB)** and its own
|
||||
copyright string reads *"released under the GUST Font License"* — an
|
||||
LPPL-derived licence, not the SIL OFL. Consequences:
|
||||
|
||||
- the bundled licence file must be the **GUST Font License**, named as such,
|
||||
not `OFL.txt` (the existing `fonts/OFL.txt` covers JetBrains Mono only);
|
||||
- the size claim must be honest: at ~717 KiB this becomes **the largest single
|
||||
embedded asset in the repository**, roughly 3.5× the figure rev 1 quoted;
|
||||
- GFL permits redistribution with its licence text, so the plan stands — but
|
||||
it is a *different* obligation from OFL and must be discharged as one;
|
||||
- if OFL-only ever becomes a requirement, **STIX Two Math** is the OFL
|
||||
alternative already listed in the parent's font table.
|
||||
|
||||
The parent framing carries the same error and needs the same correction; that
|
||||
is recorded in §9 as a follow-up rather than smuggled into this lane.
|
||||
|
||||
**Pin `Attrs` to the math family when drawing (F8b).** Layout measures with
|
||||
`ttf-parser` against the bundled bytes; drawing goes through cosmic-text. If
|
||||
fallback selects a different face for `α` or a math-italic `𝑥` than the one
|
||||
measured, drawn advances diverge silently from computed geometry and the box
|
||||
is subtly wrong everywhere. The draw path sets the family explicitly and does
|
||||
not rely on fallback.
|
||||
|
||||
Declare the dependency exactly as the parent's rev-2 C1 records:
|
||||
|
||||
```toml
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
|
||||
```
|
||||
|
||||
Bare `ttf-parser = "0.25"` unions `std` in and rebuilds the font chain.
|
||||
|
||||
A font whose MATH table is absent or unparseable is a **hard startup error in
|
||||
the math path only** — math spans fall back to raw source (Q#MS8), the editor
|
||||
does not fail. Bundled-font regressions must not be silent.
|
||||
|
||||
### Q#MS11 — Selection, search washes, and peer carets over a box (F9b)
|
||||
|
||||
Rev 1 named selection as a falsifier of B4 without proposing a rule. Any
|
||||
overlay addressed in *source* bytes meets a span whose source is suppressed.
|
||||
|
||||
- **A selection endpoint inside a span unsuppresses it.** This is Q#MS5's rule
|
||||
generalised from the caret to any selection boundary: if the user is
|
||||
addressing bytes inside the math, they see the bytes. A selection that
|
||||
merely *spans* the region (both endpoints outside) leaves it rendered.
|
||||
- **A wash that *intersects* a rendered span washes the whole reserved
|
||||
rectangle.** Intersection, not containment (R2-4): for selections the
|
||||
distinction is vacuous, since a contiguous selection with both endpoints
|
||||
outside a span necessarily contains it — but a **search match can partially
|
||||
overlap**, e.g. searching `2$ af` in `before $x^2$ after` matches from
|
||||
inside the span to outside it. Search hits and peer highlights paint the
|
||||
projected box, never a sub-range of it: the box has no interior byte map
|
||||
(Q#MS4), so a partial wash cannot be placed honestly.
|
||||
- **Peer carets snap to the span start**, the same rule as hits.
|
||||
|
||||
This keeps every overlay addressable without inventing a box→byte projection
|
||||
the slice does not build, and it makes "you are addressing this text" and "you
|
||||
see this text" the same condition throughout.
|
||||
|
||||
### Q#MS8 — Failure is always "show the source"
|
||||
|
||||
Unparseable expression, unsupported node kind, missing MATH constant, or a box
|
||||
that would exceed the line: the span is not suppressed and renders as ordinary
|
||||
source. The parent's red-squiggle treatment (its Q#IM4) is **deferred** — it
|
||||
reuses the diagnostic squiggle path, which is a second integration this slice
|
||||
does not need in order to be correct.
|
||||
|
||||
Consequence worth stating plainly: **an unsupported construct is
|
||||
indistinguishable from ordinary text in v0.** That is acceptable precisely
|
||||
because the subset is small and documented; it stops being acceptable when
|
||||
breadth arrives, which is when Q#IM4 should land.
|
||||
|
||||
### Q#MS9 — Caching is deferred
|
||||
|
||||
The parent's hash-keyed `MathBox` cache is **not** in this slice. Layout runs
|
||||
per visible span per reshape. This is a slice: the subset is tiny, the visible
|
||||
span count is small, and an unmeasured cache is a guess. The parent's latency
|
||||
targets stay as targets; the first measurement comes from this slice's own
|
||||
render path, and the cache lands when a number justifies its invalidation
|
||||
cost.
|
||||
|
||||
## 4. Bets (falsifiable)
|
||||
|
||||
- **B1' (restated after F2) — a spacer chunk composes with the existing
|
||||
pipeline.** Reserving width via projected spaces, quantized to whole space
|
||||
advances, needs only a new `ChunkSource` variant that `chunks_for_line` and
|
||||
`build_hit_runs` already iterate. Falsified if it requires changing how
|
||||
cosmic-text shapes the surrounding line, or if quantized spacer width cannot
|
||||
keep the projected hit map integral. *(Rev 1's "zero-glyph strut" wording is
|
||||
withdrawn: no such mechanism exists.)*
|
||||
- **B2 — scripts and fractions are enough to validate `MathBox`.** Falsified
|
||||
if adding a deferred node kind later forces a change to `MathBox`'s width /
|
||||
ascent / descent / origin contract, rather than only adding a `MathItem`
|
||||
variant.
|
||||
- **B3 — character positioning suffices for the subset.** Falsified if any
|
||||
node in Q#MS2 cannot be drawn correctly without selecting a glyph variant.
|
||||
- **B4' (sharpened after F9b) — the cursor rule plus Q#MS11 remove the caret
|
||||
problem rather than hiding it.** Falsified if any caret position, selection
|
||||
endpoint, search wash, or peer caret inside or across a math span still needs
|
||||
a projected-position approximation to behave correctly.
|
||||
- **B5 — `ttf-parser` supplies every constant the subset needs.** Falsified if
|
||||
script or fraction layout requires a MATH value `ttf-parser` does not
|
||||
expose.
|
||||
- **B6 (new, F1) — fit-to-line with a 0.6× floor keeps the subset legible.**
|
||||
Falsified if a plain `\frac{a}{b}` at default metrics lands below the floor
|
||||
(making the flagship case fall back to source), or if scaled output is
|
||||
illegible at the floor. Either outcome means the slice needs real TeX style
|
||||
levels rather than a uniform scale, which would be a scope change.
|
||||
|
||||
## 5. Acceptance
|
||||
|
||||
Parser and layout are pure and get ordinary unit tests. Everything that claims
|
||||
something reaches the screen runs on a real device through
|
||||
`headless_or_skip` + `render_offscreen`, under `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
1. **Parser** — `x^2`, `x_i`, `x_i^2`, `\frac{a}{b}`, `\alpha`, nested
|
||||
`\frac{x^2}{y}` produce the expected `MathNode` trees. Unbalanced `{`,
|
||||
unknown command, and an empty span are errors, not panics.
|
||||
2. **Detection** — `$x^2$` yields one span; `$a$ and $b$` yields two;
|
||||
`\$5` yields none. **Currency guards (F5):** `Price: $5.00` yields none,
|
||||
`prices are $5 and $6 today` yields **none** (the rev-1 rule would have
|
||||
matched `5 and `), `$ x $` yields none (space after opener), and a `$`
|
||||
whose only partner is on the next line yields none.
|
||||
3. **MATH constants are actually consulted** — layout of `x^2` with the real
|
||||
font places the `2` above the baseline and scaled down. Bite: stubbing
|
||||
`ScriptPercentScaleDown` to 100% changes the laid-out box, proving the
|
||||
constant is read rather than hardcoded.
|
||||
4. **Fraction geometry** — numerator above, denominator below, rule at the
|
||||
axis height, box ascent/descent enclose both.
|
||||
5. **It renders** — a buffer containing `$x^2$` renders differently from the
|
||||
same buffer with the math span suppressed. Asserted on pixels, so a layout
|
||||
engine wired to nothing cannot pass it.
|
||||
6. **The fraction rule is drawn** — `$\frac{a}{b}$` produces horizontal rule
|
||||
pixels between the two operand rows.
|
||||
7. **Cursor rule** — with the caret inside `$x^2$`, the raw `$x^2$` glyphs
|
||||
render and no math is drawn; moving the caret out re-renders the math.
|
||||
Both directions asserted.
|
||||
8. **Hit mapping** — a click on a rendered math box places the caret at the
|
||||
span's start byte, and the surrounding text's hit runs are unchanged.
|
||||
9. **Failure shows source** — `$\frac{a$` and `$\unknown{}$` render as
|
||||
ordinary source text with no panic and no missing glyphs.
|
||||
10. **Reflow is bounded and predictable (F3)** — in `before $x^2$ after`,
|
||||
`before` occupies identical pixels whether or not the span is rendered;
|
||||
`after` shifts by exactly the quantized width difference; no other line
|
||||
moves. Toggling via the Q#MS5 caret rule reflows only the affected line.
|
||||
11. **Line reuse honours suppression (F4)** — moving the caret across a span
|
||||
boundary changes the rendered output. Bite: with suppression left out of
|
||||
the line-reuse predicate, the retained line keeps the stale state and this
|
||||
fails. Suppression follows the **effective** caret, so it does not flap
|
||||
during an unconfirmed optimistic edit.
|
||||
12. **Height budget (F1) — measured, not assumed.** Computed against the
|
||||
bundled font, with the budget derived as Q#MS10 defines it (the line box
|
||||
less a 1 px margin, baseline placed by the **code** font — JetBrains Mono
|
||||
asc 16.32 / desc 4.80 at 16 px inside the 22 px line, *not* the math
|
||||
font's own 12.90/3.10):
|
||||
|
||||
| expression | ascent | descent | scale |
|
||||
| --- | --- | --- | --- |
|
||||
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
|
||||
| `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
|
||||
| `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
|
||||
| nesting depth 2 | — | — | 0.872 |
|
||||
| nesting depth 4 | — | — | 0.613 |
|
||||
| nesting depth 5 | — | — | **0.540** |
|
||||
|
||||
So **B6 holds** — the flagship fraction renders at 0.867 — and the
|
||||
fallback case is **depth 5**. Rev 3 guessed depth 2; the first
|
||||
measurement said depth 3 while the fraction gap was still a hardcoded
|
||||
`2 × thickness` guess; reading the MATH table's real
|
||||
`FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4)
|
||||
moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The
|
||||
round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732
|
||||
was inflated by the guessed gap.
|
||||
Round 2 predicted exactly this trap. Two things worth keeping: depth 2
|
||||
scores *higher* than depth 1 because the binding constraint flips from
|
||||
descent to ascent as nesting grows asymmetrically, so "deeper is always
|
||||
tighter" is false; and the test **searches** for the tripping depth rather
|
||||
than hardcoding it, so a font or metric change cannot silently leave the
|
||||
fallback arm unexercised.
|
||||
13. **Math italic (F7, R2-2)** — `$x$` renders the math-italic glyph, not
|
||||
roman `x`; `$h$` resolves through the U+210E hole rather than the 1D4xx
|
||||
run; digits in `$x2$` stay upright; **`$\alpha$` renders math-italic Greek
|
||||
and `$\Gamma$` stays upright**, so `$\alpha x$` is uniformly italic rather
|
||||
than mixed.
|
||||
14. **Overlays (Q#MS11)** — a selection endpoint inside a span unsuppresses
|
||||
it; a selection enclosing a rendered span leaves it rendered and washes
|
||||
the whole reserved rectangle; a peer caret inside a rendered span draws at
|
||||
the span start.
|
||||
15. **Deferred syntax degrades, not corrupts** — `$$x$$` renders as ordinary
|
||||
source text through the empty-span error path, with no panic and no
|
||||
half-rendered box (F9c).
|
||||
16. **Font provenance** — the bundled licence file is the GUST Font License
|
||||
and is distinct from the existing `fonts/OFL.txt`; a build with the MATH
|
||||
table absent or unparseable falls back to source and surfaces the error
|
||||
rather than failing silently (Q#MS7).
|
||||
17. **Feature declaration is differential, not absolute** — the `ttf-parser`
|
||||
feature set from `cargo tree -e features` is **byte-identical with and
|
||||
without this crate's dependency line**. Asserting "`std` is absent" would
|
||||
be wrong and would fail a correct implementation: `std` is *already*
|
||||
enabled upstream, because `fontdb` declares `std = ["ttf-parser/std"]`.
|
||||
What the declaration must not do is *widen* the set, which only a
|
||||
before/after comparison can show.
|
||||
18. Full gate suite per `CLAUDE.md`, including `PMACS_REQUIRE_GPU=1`.
|
||||
|
||||
## 6. Deferred (named)
|
||||
|
||||
Display math `$$…$$` and `\[…\]`; big operators; stretchy fences and glyph
|
||||
variant/assembly (with the `MathItem` glyph-ID variant they require);
|
||||
radicals; accents; `\text{}`; style overrides; the full ~200-entry symbol map;
|
||||
the red-squiggle error treatment (parent Q#IM4); the `MathBox` cache (Q#MS9);
|
||||
sub-expression hit-testing and caret projection inside rendered math (parent
|
||||
Q#IM6); colour-by-context (parent Q#IM2); tree-sitter injection detection and
|
||||
any `MathSpans` wire surface; the TUI's distinct-face fallback; Lua-registered
|
||||
delimiters; **proper TeX style levels** (display/text/script/scriptscript with
|
||||
per-level MATH constants), for which Q#MS10's uniform fit-to-line scale is a
|
||||
deliberately cruder stand-in; **sub-range washes** inside a rendered box
|
||||
(Q#MS11 washes the whole rectangle).
|
||||
|
||||
|
||||
## 7. Interaction with other work
|
||||
|
||||
The slice's Tier 4 half edits `pmacs-gpu/src/main.rs`'s render path, which two
|
||||
other lanes also claim:
|
||||
|
||||
- **Bottom panel** — Stage 1 is in review as **#155**; its **Stage 2** takes
|
||||
this render path *and* the next protocol version.
|
||||
- **Folding Stage 3 (GPU)** — next ranked, still unframed, and inherits the
|
||||
`BufferSnapshot` fold-mirror-clear obligation on the same path.
|
||||
|
||||
This lane reserves **no protocol version** and adds no wire surface, so it
|
||||
cannot collide there. For the render path the rule is the one the other two
|
||||
framings already apply to each other: **whichever lands second re-scouts
|
||||
against the first.** The parser and layout modules are new files and collide
|
||||
with nothing; only the `rebuild_code_slice` / chunk / render hunks are
|
||||
contended, and they are small and localised by design.
|
||||
|
||||
Sequencing preference: land after #155's Stage 1, whose merge does not touch
|
||||
this path, and re-scout if bottom-panel Stage 2 or folding Stage 3 lands
|
||||
first.
|
||||
|
||||
## 8. Prior art in pmacs
|
||||
|
||||
`SquiggleRenderer` (`pmacs-gpu/src/main.rs:2825`) for owning a custom pipeline
|
||||
beside glyphon; the menu/background quad path for filled rectangles; inlay
|
||||
hints (`ChunkSource::Adornment`) for interleaving non-source content and for
|
||||
the anchor-snapping hit rule; `headless_diag_face_recolors_band_counter…`
|
||||
(`:12022`) for asserting a rendering claim on real pixels; #144's query
|
||||
overlay for the eventual tree-sitter detection upgrade.
|
||||
|
||||
## 9. Follow-up outside this lane
|
||||
|
||||
The parent framing (`docs/inline-math-framing.md`, rev 2, merged as #154)
|
||||
carries the same font error F6 found here: its table row reads "Latin Modern
|
||||
Math | Full | OFL (GUST)". It should be corrected to the GUST Font License,
|
||||
with the ~717 KiB size, in its own docs change rather than in this branch —
|
||||
the parent is a merged document and this lane should not quietly edit it.
|
||||
|
|
@ -0,0 +1,658 @@
|
|||
# Terminal configuration and copy mode
|
||||
|
||||
**Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20),
|
||||
2026-07-25. APPROVED after four review rounds. Stage 1 is implemented on
|
||||
branch `terminal-config` (PR #173); Stage 2 (`terminal-copy-mode`) is
|
||||
framed but not started, and branches off `main` after Stage 1 merges.**
|
||||
|
||||
Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) —
|
||||
revision 3 named the key but not the storage, and two implementations
|
||||
satisfied its acceptance while behaving differently on A→B→A. It also corrects
|
||||
the read-only deferral, which understated the substrate required: the bypass
|
||||
path is `ensure_writable`-guarded too, so genuine immutability alone would
|
||||
break every generated buffer that refreshes.
|
||||
|
||||
Revision 3 corrects two design errors and decides the chords. The
|
||||
round-trip failure shape in revision 2 was **wrong in the reporter's favour**:
|
||||
a Lua intercept does not set `Buffer::read_only`, and there is no Lua binding
|
||||
that does, so an optimistic `CrdtOp` bypasses the intercept *and* passes
|
||||
`ensure_writable()` — the daemon buffer mutates too, rather than the mirror
|
||||
diverging alone (Q#TC6a). Revision 2 also had all three settings resolving
|
||||
against the terminal identity buffer, which is impossible for the two read
|
||||
*before* that buffer exists (Q#TC2b). Chords are now decided and
|
||||
collision-scouted rather than deferred to implementation (Q#TC10, Q#TC8a).
|
||||
|
||||
Revision 2 answered seven review findings. Four were load-bearing: the settings
|
||||
are `Live`, so the registry **accepts buffer-local overrides whether or not we
|
||||
want them**, and `value_epoch()` does not move on a buffer switch — an
|
||||
epoch-only cache can serve the wrong terminal's escape chord (Q#TC4); the
|
||||
double-escape byte is a hardcoded `0x03`, so a configured escape would still
|
||||
send Ctrl-C and make its own literal chord unreachable (Q#TC4b); the snapshot
|
||||
buffer needs `set_round_trip_input`, not only a read-only intercept, or a
|
||||
semantic frontend can optimistically edit it before daemon dispatch (Q#TC6);
|
||||
and the two stages must be two branches and two PRs. Revision 1's
|
||||
materialized-copy reframe is unchanged.
|
||||
|
||||
Two stages, one arc, no protocol change:
|
||||
|
||||
- **Stage 1 — configuration.** Terminal profiles, scrollback, and the escape
|
||||
key become configurable. Today the terminal has **zero** configuration
|
||||
surface: the `terminal` command hardcodes `os.getenv("SHELL") or "/bin/sh"`,
|
||||
`scrollback_rows` is a per-open argument only, and the escape chord is a
|
||||
literal in Rust.
|
||||
- **Stage 2 — copy mode and search over scrollback.** A command that turns
|
||||
the retained terminal screen and scrollback into an ordinary buffer, where
|
||||
isearch, motion, selection, and the kill ring already work.
|
||||
|
||||
Explicitly **not** in this arc: the panel terminal (blocked on bottom-panel
|
||||
Stage 2), and shell integration (cwd tracking, prompt marks, command zones) —
|
||||
the keystone that unlocks the VS Code-style cluster, which needs its own
|
||||
security framing because it decides what a child process may make the editor
|
||||
do.
|
||||
|
||||
## Branch and PR plan
|
||||
|
||||
**Two branches, two PRs.** Configuration and copy mode are independently
|
||||
releasable and have no dependency on each other; one framing covers the arc,
|
||||
but the one-feature/one-branch/one-PR rule governs the implementation.
|
||||
|
||||
1. `terminal-config` — Stage 1. Also carries the **terminal opening
|
||||
keybinding** (Q#TC10).
|
||||
2. `terminal-copy-mode` — Stage 2, branched off `main` after Stage 1 merges.
|
||||
|
||||
Sequencing is not a dependency but avoids a conflict: both stages edit
|
||||
`builtin/runtime/terminal.lua`.
|
||||
|
||||
## Ground truth (measured, not recalled)
|
||||
|
||||
Three facts constrain the design, and two of them rule out the obvious plan.
|
||||
|
||||
### 1. Terminal profiles cannot be a config-registry setting
|
||||
|
||||
`ConfigValue` is **four scalars** — `Bool`, `Int`, `Num`, `Str`
|
||||
(`src/config_registry.rs:312`) — and its own doc comment says they "are never
|
||||
stored --- only these four scalars (Q#CR3)". `ConfigKind` adds `Enum`, which
|
||||
is physically a string validated against choices fixed at `define` time
|
||||
(`src/config_registry.rs:115-145`). There is no table, list, or map kind.
|
||||
|
||||
A terminal profile is inherently a table: `{ command, args, cwd, env }` per
|
||||
name. **Table-valued settings are an existing named deferral of the config
|
||||
registry arc** — the same gap that keeps `pmacs.lsp.config`,
|
||||
`pmacs.pair.sets`, `pmacs.comment.strings`, and the `pmacs.parse.*` proxies as
|
||||
raw Lua. Profiles join that list rather than forcing that deferral open here.
|
||||
|
||||
### 2. Search cannot reuse isearch in place over a terminal
|
||||
|
||||
`SearchStore::set(buffer_id, query, matches: Vec<ByteRange>)`
|
||||
(`src/search.rs:99`) keys matches by buffer and addresses them as **byte
|
||||
ranges into that buffer's rope**; the painting path materializes the source
|
||||
with `buf.snapshot_rope().slice(0, buf.len(), ..)` (`src/search.rs:435`).
|
||||
|
||||
A terminal identity buffer is **empty and read-only** by construction. Its
|
||||
content lives in `TerminalScreen` as cells addressed by `(row, col)` across
|
||||
history plus visible rows — there are no rope bytes to range over. Searching a
|
||||
terminal in place therefore means a second, parallel search facility with its
|
||||
own match store and its own highlight path, because terminal painting consumes
|
||||
owned cells and not document style spans.
|
||||
|
||||
### 3. An in-place copy mode would be the seventh dispatch shadow
|
||||
|
||||
`dispatch_key`'s terminal-transport arm intercepts **every** key before
|
||||
ordinary keymap dispatch whenever `active_terminal_key` is `Some`, which keys
|
||||
purely on `is_terminal(window.buffer_id)` (`src/editor.rs:1098-1107`,
|
||||
`973-1016`). A mode that keeps the terminal buffer focused while rebinding
|
||||
keys to motion/selection must therefore add a new precedence rung.
|
||||
|
||||
`COHERENCE.md` §6 grades that ladder **weak, "and growing by one island per
|
||||
modal feature"**, records that **no transient-keymap mechanism exists to
|
||||
migrate to** (`KeymapStack` has exactly three fixed scopes, no layer stack, no
|
||||
push/pop, no lifetime), and notes that `describe-key` already lies while a
|
||||
shadow is active. It also names the counter-example: the entire picker/panel
|
||||
family uses ordinary **buffer-local keymaps** and is inspectable and
|
||||
rebindable.
|
||||
|
||||
### 4. What already exists and is reusable
|
||||
|
||||
- `retained_rows(projection)` (`src/terminal/view.rs:539`) iterates history
|
||||
plus visible rows; `copy_selection_bytes(rows, selection)`
|
||||
(`src/terminal/view.rs:849`) serializes a range with the fidelity Stage 2
|
||||
criterion 21 already pins — soft wraps joined, hard rows separated, trailing
|
||||
default blanks trimmed, wide glyphs and combining clusters copied once.
|
||||
- `ConfigRegistry::value_epoch()` (`src/config_registry.rs:1127`) is public and
|
||||
monotonic — cheap invalidation for a hot-path cache.
|
||||
- The Lua surface is `define` / `get` / `set` / `set_local` / `on_change` with
|
||||
a disposable handle (`src/lua_bindings/config.rs`).
|
||||
- `pmacs.terminal.open` already accepts
|
||||
`command, args, cwd, env, name, rows, cols, scrollback_rows, display,
|
||||
window`. **`display = "panel"` already works** (bottom-panel Stage 1) — the
|
||||
panel terminal is blocked on rendering, not on this surface.
|
||||
- Terminal buffers already carry buffer-local bindings (`M-w`, `M-v`, `C-v`,
|
||||
`M-<`, `M->`) installed by `terminal.open` in `builtin/runtime/terminal.lua`.
|
||||
|
||||
## Stage 1 — configuration
|
||||
|
||||
**Q#TC1 — Profiles are a raw Lua table, not a setting.**
|
||||
`pmacs.terminal.profiles` maps a name to a spec table, exactly following the
|
||||
`pmacs.lsp.config` precedent. The registry holds only scalars. Rejected
|
||||
alternative: widening `ConfigValue` with a table kind — that is the config
|
||||
arc's own named deferral, it is cross-cutting (persistence, `describe-setting`
|
||||
rendering, the `custom-file` question all key on the scalar assumption), and
|
||||
smuggling it into a terminal PR would be the wrong place to decide it.
|
||||
|
||||
**Q#TC2 — `terminal.default-profile` is `String`, not `Enum`.** `Enum`
|
||||
choices are frozen at `define` time; profiles are user-extensible from
|
||||
`init.lua` and later. Validation happens at open time, and an unknown name
|
||||
must produce a pointed error that **names the known profiles**, not a bare
|
||||
"unknown profile".
|
||||
|
||||
**Q#TC2a — the exact settings, defaults, and bounds.** All three are `Live`
|
||||
(see Q#TC2b), and every default reproduces today's behavior exactly, so a tree
|
||||
with no settings written behaves identically (acceptance 12).
|
||||
|
||||
| name | kind | default | bounds |
|
||||
|---|---|---|---|
|
||||
| `terminal.default-profile` | `String { allow_empty: true }` | `""` | — |
|
||||
| `terminal.scrollback-rows` | `Integer` | `10_000` (`DEFAULT_TERMINAL_SCROLLBACK_ROWS`) | `0 ..= 4_000_000` (`MAX_TERMINAL_HISTORY_CELLS`) |
|
||||
| `terminal.escape-key` | `String { allow_empty: false }` | `"C-c"` | parsed as a chord |
|
||||
|
||||
**Zero is a legal scrollback value meaning "retain no history".** The core's
|
||||
own validation rejects only values *above* `MAX_TERMINAL_HISTORY_CELLS`
|
||||
(`src/terminal/session.rs:114`), so `scrollback_rows = 0` is accepted through
|
||||
`terminal.open` today. A `1` minimum here would invent an asymmetry between the
|
||||
setting and the per-open field for no reason.
|
||||
|
||||
`""` is the **"no default profile" sentinel**: an empty string means "fall
|
||||
through to `$SHELL`", not "a profile named empty". `allow_empty: true` exists
|
||||
precisely to express it, and the open path treats empty and unset identically.
|
||||
|
||||
**Q#TC2b — the settings are `Live`, and the registry therefore accepts
|
||||
buffer-local overrides. That is specified rather than accidental.**
|
||||
`ConfigRegistry::set_local` refuses only `StartupOnly` definitions
|
||||
(`src/config_registry.rs:949`); a `Live` setting can be pinned per buffer by
|
||||
anyone. Declaring these global-only is **not currently expressible** — a
|
||||
`scope = "global"` define flag is one of the config registry's own named
|
||||
deferrals, and `autosave.interval-ms` already has the same latent problem.
|
||||
|
||||
Making them `StartupOnly` instead would buy enforcement at the cost of the
|
||||
feature: the escape key could never be changed mid-session, which kills Q#TC4's
|
||||
whole point. So they stay `Live`, and resolution is defined **per setting,
|
||||
because the three are not read at the same moment**:
|
||||
|
||||
| setting | read when | resolution |
|
||||
|---|---|---|
|
||||
| `terminal.escape-key` | every keystroke in a terminal (cached) | `get(name, terminal_buffer)` — **buffer-local → global → default** |
|
||||
| `terminal.default-profile` | once, **before** the terminal exists | `get(name)` — **global chain only** |
|
||||
| `terminal.scrollback-rows` | once, **before** the terminal exists | `get(name)` — **global chain only** |
|
||||
|
||||
The split is forced, not stylistic. The two open-time settings are consumed by
|
||||
`_open` **before it creates the identity buffer**, so there is no terminal
|
||||
buffer to resolve against — and no caller could have pinned a local override on
|
||||
a buffer that does not yet exist. `pmacs.config.get(name)` with no buffer
|
||||
argument already means exactly "the global chain, never an ambient buffer", so
|
||||
this is the registry's existing semantic rather than a new rule.
|
||||
|
||||
Consequences, stated so they are not discovered later:
|
||||
|
||||
- a per-terminal escape key is a supported feature, not a bug;
|
||||
- `set_local` on `terminal.default-profile` or `terminal.scrollback-rows` is
|
||||
**always inert**, for any buffer, because the open path never consults a
|
||||
buffer chain. This is deliberate; the alternative — resolving against
|
||||
whichever buffer happened to be current at open time — would make a
|
||||
terminal's scrollback depend on what the user was looking at when they
|
||||
pressed the key.
|
||||
|
||||
Rejected alternative: resolving the open-time settings against the *target
|
||||
window's pre-open buffer*. It is expressible, but it makes an ambient buffer
|
||||
load-bearing for a value the user set globally, which is the trap
|
||||
`pmacs.config`'s two-argument/one-argument split exists to avoid.
|
||||
|
||||
**Q#TC3 — `terminal.scrollback-rows` is `Integer` with bounds, and an explicit
|
||||
per-open `scrollback_rows` still wins.** The precedence is
|
||||
**explicit argument over global setting** — there is no ambient buffer in this
|
||||
chain at all (Q#TC2b resolves it through `get(name)`), so the rule is simply
|
||||
that what a caller passes to `terminal.open` beats what the user configured
|
||||
globally. The bounds above come from the existing validation, so the setting
|
||||
cannot express a value the core will reject.
|
||||
|
||||
**Q#TC3a — profile resolution order, field by field.** `profile` is accepted
|
||||
by **`pmacs.terminal.open` as well as the command**, so a Lua caller is not
|
||||
forced through the command to use one. For each field, the first source that
|
||||
supplies it wins:
|
||||
|
||||
1. an explicit `pmacs.terminal.open` field;
|
||||
2. the named profile's field — `profile` argument, else
|
||||
`terminal.default-profile` when non-empty;
|
||||
3. the scalar setting, where one exists (`scrollback_rows` only);
|
||||
4. the built-in fallback (`command` = `$SHELL`, else `/bin/sh`).
|
||||
|
||||
`env` is the one field where "first wins" is ambiguous, so it is stated:
|
||||
profile `env` and explicit `env` are **merged**, with explicit entries
|
||||
overriding profile entries of the same name. Any other reading silently drops
|
||||
half a user's environment.
|
||||
|
||||
An explicitly passed `profile` that does not exist is an error even when
|
||||
`terminal.default-profile` is valid — a typo must not silently fall back to
|
||||
the default.
|
||||
|
||||
**Q#TC4 — `terminal.escape-key` is a `String` chord spelling, parsed once and
|
||||
cached by `(buffer_id, value_epoch)`.** `is_terminal_escape_chord`
|
||||
(`src/editor.rs:4413`) currently compares against a literal `C-c`. Reading and
|
||||
parsing a setting on **every keystroke in a terminal** is not acceptable in
|
||||
that path.
|
||||
|
||||
**The cache key must include the buffer.** `value_epoch()` advances only on
|
||||
`set` / `set_local` / removal (`src/config_registry.rs:918`, `970`, `1011`,
|
||||
`1029`) — **it does not move when the focused terminal changes**. An
|
||||
epoch-only cache therefore serves terminal A's escape chord to terminal B for
|
||||
as long as no setting is written, which is exactly the case where nothing looks
|
||||
wrong. Keying on `(buffer_id, value_epoch)` is the minimum correct identity.
|
||||
|
||||
**Q#TC4c — the cache lives on `TerminalSession`, so its lifecycle is the
|
||||
terminal's.** Revision 3 named the key `(buffer_id, value_epoch)` but not the
|
||||
storage, and the two obvious storages behave differently on A→B→A:
|
||||
|
||||
- a **single last-entry cache** reparses on every switch between two
|
||||
terminals, and re-reports an invalid value each time — a status line that
|
||||
scolds you for a setting you already know about, forever;
|
||||
- an **editor-side map** preserves "parsed and reported once" but **leaks an
|
||||
entry per terminal** unless something purges it, and that purge is a second
|
||||
thing to get wrong.
|
||||
|
||||
`TerminalSession` (`src/terminal/session.rs:215`) is created in
|
||||
`TerminalManager::open` and dropped on kill/prune, so putting the cache there
|
||||
gets the lifecycle for free with no purge hook to forget. It carries the parsed
|
||||
chord, the `value_epoch` it was parsed at, and whether the current invalid
|
||||
value has already been reported.
|
||||
|
||||
**"Reports once" means once per terminal, per effective invalid value.**
|
||||
A→B→A must not re-report. Changing the setting from one invalid value to a
|
||||
*different* invalid value **does** re-report, because that is new information
|
||||
about a new mistake.
|
||||
|
||||
**The reporting channel is `EditorCore::status`** — the same channel
|
||||
`send_terminal_bytes` already uses for terminal failures
|
||||
(`src/editor.rs:1122`). Explicitly **not** `pmacs.error`: it is not installed
|
||||
as a module anywhere in `src/lua_bindings`, so its call sites across the
|
||||
runtime are dead, and a report sent there would be a report nobody sees.
|
||||
|
||||
**Q#TC4a — an unparseable escape key must not brick terminal input.** A bad
|
||||
value falls back to `C-c` and reports once. The failure mode this avoids is
|
||||
severe: with no escape chord, every key goes to the child and the user cannot
|
||||
reach any editor binding to fix the setting that broke it.
|
||||
|
||||
**Q#TC4b — repeating the configured escape sends THAT chord to the child, not
|
||||
Ctrl-C.** The double-escape arm currently writes a hardcoded
|
||||
`&[0x03]` (`src/editor.rs:988`). With `terminal.escape-key = "C-x"`, `C-x C-x`
|
||||
would send Ctrl-C — and literal Ctrl-X would become unreachable, since the
|
||||
first `C-x` is always consumed as the escape. The repeat arm must encode the
|
||||
**configured** chord through the existing `crate::terminal::input::encode_key`
|
||||
path, which is also how it inherits application-cursor and modifier handling
|
||||
rather than growing a second encoder.
|
||||
|
||||
Corollary worth pinning: after changing the escape away from `C-c`, an ordinary
|
||||
`C-c` must reach the child as `0x03` like any other unescaped key.
|
||||
|
||||
**Q#TC5 — the `terminal` command gains an optional profile argument** and
|
||||
otherwise keeps its current behavior; `$SHELL` remains the fallback when no
|
||||
profile is configured. No existing invocation changes meaning.
|
||||
|
||||
**Q#TC10 — the terminal opening keybinding is pulled forward into Stage 1.**
|
||||
`COHERENCE.md` Priority 1 names "a terminal keybinding" as part of protecting
|
||||
the golden journey, §2 step 8 grades the terminal "works but undiscoverable",
|
||||
and this stage already edits `terminal.lua`. Panel rendering imposes no
|
||||
dependency on binding a command that already exists. Close/kill semantics stay
|
||||
with the panel work, where the entry and exit points get designed together.
|
||||
|
||||
The chord is **decided and scouted, not deferred**: `C-c t`, global. See
|
||||
Q#TC8a for the collision evidence and for why binding under the existing `C-c`
|
||||
prefix is a new leaf rather than a shadow.
|
||||
|
||||
## Stage 2 — copy mode and search
|
||||
|
||||
**Q#TC6 — copy mode MATERIALIZES into an ordinary buffer. It does not add a
|
||||
dispatch shadow.**
|
||||
|
||||
`M-x terminal.copy-mode` snapshots the retained rows into a read-only,
|
||||
path-less buffer (`*terminal-copy: NAME*`) and displays it. That buffer is an
|
||||
ordinary document buffer, so:
|
||||
|
||||
- **isearch works, with no new search substrate** — it is a rope, so
|
||||
`SearchStore` and the existing match-painting path apply unchanged. Ground
|
||||
truth 2 is answered by not fighting it.
|
||||
- **motion, selection, `M-w`, the kill ring, even `M-x occur`-style consumers
|
||||
work** — everything that operates on a buffer.
|
||||
- **The "keys must not reach the child" problem dissolves structurally.**
|
||||
`active_terminal_key` keys on `is_terminal(window.buffer_id)`; the snapshot
|
||||
buffer is not a terminal, so the transport arm never fires. No new guard, no
|
||||
new precedence rung, and ground truth 3's coherence cost is avoided rather
|
||||
than paid.
|
||||
- **`describe-key` stays truthful**, because the bindings are buffer-local and
|
||||
inspectable — the idiom `COHERENCE.md` §6 identifies as the right side of
|
||||
the line.
|
||||
|
||||
**Q#TC6a — the snapshot is BOTH intercept-read-only AND round-trip-marked,
|
||||
and `set_round_trip_input` is the ONLY thing standing between a replica
|
||||
frontend and unauthorized mutation.**
|
||||
|
||||
The established idiom is two calls: `listview.lua:106` and `compile.lua:272`
|
||||
each pair `pmacs.buffer.add_intercept` with
|
||||
`pmacs.buffer.set_round_trip_input(buf, true)`. Revision 2 described the
|
||||
intercept as the guard and round-trip as defence in depth. **That was wrong,
|
||||
and the correction matters:**
|
||||
|
||||
- A Lua intercept guards the **dispatch/edit** path only. It does **not** set
|
||||
`Buffer::read_only`, which is "deliberately independent of edit intercepts"
|
||||
(`src/buffer.rs:493-500`) — that flag is what makes terminal identity buffers
|
||||
reject rope, undo/redo, and remote-CRDT mutation alike.
|
||||
- **No Lua binding sets `read_only` at all.** The whole `src/lua_bindings`
|
||||
tree only ever *reads* it (`fold.rs:313`). A Lua-created "read-only" buffer
|
||||
is therefore read-only against dispatch and nothing else.
|
||||
- So an optimistic `CrdtOp` from a semantic frontend bypasses the intercept
|
||||
**and passes `ensure_writable()`**. It is applied. The daemon buffer mutates
|
||||
in lockstep with the mirror — the user silently edits a buffer the editor
|
||||
told them is read-only. There is no divergence to notice, which is worse
|
||||
than divergence.
|
||||
|
||||
`set_round_trip_input` prevents this at the only point it can be prevented: it
|
||||
makes `dispatch_idle_for` report false while the buffer is focused, so the
|
||||
frontend never applies optimistically and never emits the op. It is not
|
||||
hardening — it is the guard.
|
||||
|
||||
Two things follow, and both are recorded rather than fixed here:
|
||||
|
||||
- **The same exposure exists today** for every Lua-created read-only buffer —
|
||||
listview panels and `*compilation*` included. They are correct only because
|
||||
they call `set_round_trip_input`. This arc must not be the place that
|
||||
unilaterally changes that substrate.
|
||||
- **Exposing `Buffer::set_read_only` to Lua** would make these buffers
|
||||
genuinely immutable at the rope/CRDT boundary the way terminal identity
|
||||
buffers are, turning round-trip back into real defence in depth. That is a
|
||||
substrate change affecting listview and compile as much as this snapshot, so
|
||||
it is named in Deferred with its own lane.
|
||||
|
||||
**Q#TC7 — the materializer reuses the existing serializer.** A whole-range
|
||||
variant of `copy_selection_bytes` over `retained_rows` inherits the criterion
|
||||
21 fidelity rather than re-deriving soft-wrap, wide-glyph, and trailing-blank
|
||||
behavior. Writing a second serializer would guarantee the two drift.
|
||||
|
||||
**Q#TC8 — one snapshot buffer per terminal, reused on re-invoke.** Re-running
|
||||
the command against the same terminal replaces the contents in place rather
|
||||
than accumulating buffers. It is killed with its terminal; killing the
|
||||
snapshot alone leaves the terminal untouched.
|
||||
|
||||
**Q#TC8a — the chords, decided and collision-scouted.**
|
||||
|
||||
Worth stating first because it is easy to get backwards: in a terminal window
|
||||
every **unescaped** key goes to the child, so terminal-local bindings are
|
||||
reached as `<escape> <key>`. The existing `M-w` copy is physically `C-c M-w`.
|
||||
The escape consumes itself and the next key starts a fresh ordinary sequence,
|
||||
which is also why `C-c`-leading bindings are structurally unreachable *inside*
|
||||
a terminal.
|
||||
|
||||
| action | scope | binding | physically typed |
|
||||
|---|---|---|---|
|
||||
| open a terminal (Q#TC10) | global | `C-c t` | `C-c t` |
|
||||
| enter copy mode | terminal buffer | `C-t` | `C-c C-t` |
|
||||
| refresh snapshot | snapshot buffer | `g` | `g` |
|
||||
| return to terminal | snapshot buffer | `q` | `q` |
|
||||
|
||||
Scouted against the real keymaps:
|
||||
|
||||
- **`C-c t` is free.** No bare global `C-c` binding exists; `C-c` is already a
|
||||
live global prefix from `fold.lua:48-52` (`C-c @ …`), and `C-c C-k` is
|
||||
buffer-scoped in compile/async. `C-c t` is a new leaf under an existing
|
||||
prefix, not a shadow.
|
||||
- **`C-t` is globally `edit.transpose-chars`** (`editops.lua:909`), and binding
|
||||
it **buffer-locally is legitimate**: `keymap.bind`'s strictness rejects
|
||||
binding a *prefix* of an existing sequence within a scope
|
||||
(`keymap_bind_conflict_surfaces_at_bind_time` — "would shadow"), not
|
||||
cross-scope shadowing, which is what scopes are for. Listview already binds
|
||||
`n`/`p`/`g`/`q`/`RET`/`SPC` buffer-locally. Transpose-chars is meaningless in
|
||||
a read-only terminal buffer.
|
||||
- `C-c C-t` matches emacs-libvterm's own `vterm-copy-mode` chord, so the muscle
|
||||
memory transfers.
|
||||
- `g` / `q` in the snapshot follow listview's precedent exactly.
|
||||
|
||||
**Named limitation:** `C-c t` cannot open a terminal *from inside* a terminal,
|
||||
because `C-c` is consumed as the escape there. `M-x terminal` still works. This
|
||||
is the documented consequence of Stage 2 criterion 19, not a new defect.
|
||||
|
||||
These are what make acceptance 21's `describe-key` claim testable: named
|
||||
bindings, in named buffers, that introspection must report truthfully.
|
||||
|
||||
**Q#TC9 — the live-terminal keys stay.** `M-w`, `M-v`, `C-v`, `M-<`, `M->` on
|
||||
the terminal buffer are the live affordances and do not change. Copy mode is
|
||||
additive, on its own binding, and does not replace scroll-and-select.
|
||||
|
||||
## Bets
|
||||
|
||||
- **B1.** Materializing gives search for free: no second match store, no
|
||||
second highlight path, no terminal-specific search UI. *Scored by Stage 2
|
||||
landing with zero changes under `src/search.rs`.*
|
||||
- **B2.** Point-in-time is sufficient for read-back/search/copy. *Scored by
|
||||
use; if false, the live frozen mode in Deferred becomes the real feature and
|
||||
this becomes its snapshot fallback.*
|
||||
- **B3.** No protocol change. The snapshot is an ordinary buffer, so both
|
||||
frontends render it with existing machinery. *Scored by the diff.*
|
||||
- **B4.** The escape-key cache keyed by `(buffer_id, value_epoch)` never
|
||||
becomes stale in a way a user can observe. *Scored by two acceptances, not
|
||||
one: changing the setting mid-session (8) and two terminals with different
|
||||
buffer-local values and no write between them (7). Revision 1's epoch-only
|
||||
cache would pass the first and fail the second, which is why the bet now
|
||||
names both.*
|
||||
- **B5.** Buffer-local escape keys are a feature rather than a hazard.
|
||||
*Unscored and honestly so: the registry cannot express global-only, so this
|
||||
is what we get either way. If per-terminal escapes turn out to confuse more
|
||||
than they help, the fix is the config registry's `scope = "global"` deferral,
|
||||
not a terminal change.*
|
||||
|
||||
## Deferred (named)
|
||||
|
||||
- **Live frozen copy mode** (true `vterm-copy-mode` semantics: freeze the
|
||||
terminal in place, navigate it, resume). Strictly larger; needs either the
|
||||
transient-keymap primitive `COHERENCE.md` §6 specifies or a deliberate
|
||||
seventh shadow.
|
||||
- **Shell integration** — cwd tracking, prompt marks, command zones, and the
|
||||
VS Code cluster downstream of it (command decorations, exit-code markers,
|
||||
rerun, sticky scroll, terminal IntelliSense). Its own arc, with a security
|
||||
framing.
|
||||
- **Table-valued settings** — the config registry's own deferral. This arc
|
||||
adds a **second** blocked adopter (after `pmacs.lsp.config` /
|
||||
`pmacs.pair.sets`); worth recording as evidence when that deferral is
|
||||
ranked.
|
||||
- **A `scope = "global"` define flag** — also the config registry's own
|
||||
deferral, and this arc is its second live case after `autosave.interval-ms`.
|
||||
Until it exists, `set_local` on any `Live` setting is accepted whether or not
|
||||
the owner wants it, so Q#TC2b specifies the behavior instead of pretending
|
||||
it is prevented.
|
||||
- **Panel terminal** — blocked on bottom-panel Stage 2 (semantic frontends are
|
||||
not `panel_capable`). `display = "panel"` already exists and works on the
|
||||
grid frontend.
|
||||
- OSC 8 hyperlinks, images (sixel/kitty), `faint`/`blink`/`conceal`/
|
||||
`strikethrough` (needs a shared `Style` widening, so a protocol bump),
|
||||
cursor shape/blink, kitty keyboard protocol.
|
||||
- Terminal session persistence/reconnect across editor restart.
|
||||
- **A terminal close/kill command** — the remaining half of `COHERENCE.md`
|
||||
§2 step 8's discoverability gap. It belongs with the panel-terminal work,
|
||||
where entry and exit points get designed together. The *opening* keybinding
|
||||
is **no longer deferred**: Stage 1 carries it as Q#TC10.
|
||||
- **Genuine immutability for generated buffers — and it is bigger than a Lua
|
||||
setter.** Today no Lua binding sets `read_only` (`src/lua_bindings` only
|
||||
reads it, `fold.rs:313`), so every Lua-created "read-only" buffer — listview
|
||||
panels, `*compilation*`, and this snapshot — is read-only against dispatch
|
||||
alone and relies entirely on `set_round_trip_input` (Q#TC6a).
|
||||
|
||||
Merely **exposing `set_read_only` would break all three.** The
|
||||
intercept-bypass path is `ensure_writable`-guarded too:
|
||||
`apply_edit_skip_intercepts` calls it first (`src/buffer.rs:994`), and that
|
||||
is exactly the primitive an owner uses to rewrite its own generated buffer.
|
||||
Flipping the flag would stop listview refreshing, `*compilation*` streaming,
|
||||
and this snapshot refreshing — the very operations those buffers exist for.
|
||||
|
||||
So the lane needs **two** things, not one: genuine immutability at the
|
||||
rope/CRDT boundary, *and* an owner-authorized update path that is not simply
|
||||
"skip the intercepts". Naming only the setter would have made it look like a
|
||||
one-line follow-up.
|
||||
|
||||
## Acceptance
|
||||
|
||||
### Stage 1 — `terminal-config`
|
||||
|
||||
1. `pmacs.terminal.profiles` accepts a strict spec table per name and rejects
|
||||
unknown fields before anything is spawned, matching `terminal.open`'s
|
||||
existing transactional contract.
|
||||
2. `terminal.default-profile` naming an unknown profile fails at open with an
|
||||
error that **lists the known profile names**, and creates no buffer,
|
||||
session, or process. An explicitly passed unknown `profile` fails the same
|
||||
way **even when `terminal.default-profile` is valid** (Q#TC3a).
|
||||
2a. That diagnostic is **total over a malformed profiles table** (review round
|
||||
1). `pmacs.terminal.profiles` is a raw user table, so listing its names must
|
||||
not assume its keys are comparable and rendering a requested name must not
|
||||
assume it is a string: a table holding both a string and a numeric key made
|
||||
`table.sort` raise `attempt to compare number with string` *on the
|
||||
unknown-profile path*, replacing the exact error being asked for, and `%q`
|
||||
raises on a non-string `profile` argument. Both are partial functions
|
||||
applied to user input on a diagnostic path — the failure class is
|
||||
"the error reporter is the thing that fails".
|
||||
3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile
|
||||
field beats scalar setting beats `$SHELL`. `env` **merges**, with explicit
|
||||
entries overriding profile entries of the same name.
|
||||
4. `""` in `terminal.default-profile` means "no profile" and is
|
||||
indistinguishable from unset (Q#TC2a).
|
||||
5. `terminal.scrollback-rows` takes effect for a terminal opened without an
|
||||
explicit `scrollback_rows`; an explicit per-open value overrides it; values
|
||||
outside `0 ..= 4_000_000` are rejected by the registry rather than by the
|
||||
core, and `0` is accepted as "retain no history".
|
||||
6. `terminal.escape-key` changes which chord escapes to the editor, observed
|
||||
through the **real dispatch path**, not by calling the predicate directly.
|
||||
7. **Two terminals with different buffer-local escape keys each honor their
|
||||
own**, with no setting written in between (Q#TC4/Q#TC2b). Driven as
|
||||
**A→B→A**, asserting both directions. This is the pin an epoch-only cache
|
||||
fails.
|
||||
8. Across that same **A→B→A** switch with no setting written, the parse count
|
||||
does **not** increase after each terminal's first keystroke (Q#TC4c) —
|
||||
pinned by counting parses, not by timing. This is the pin a single
|
||||
last-entry cache fails while still satisfying 7.
|
||||
8a. A terminal's cache does not outlive it: killing a terminal and opening a
|
||||
new one does not serve the dead terminal's chord, and no per-terminal cache
|
||||
entry survives its session (Q#TC4c). This is the pin an unpurged
|
||||
editor-side map fails.
|
||||
9. With `terminal.escape-key = "C-x"`: `C-x C-x` sends **Ctrl-X** to the child,
|
||||
and an ordinary `C-c` reaches the child as `0x03` like any other unescaped
|
||||
key (Q#TC4b). Bite: against the hardcoded `&[0x03]`, the first assertion
|
||||
fails.
|
||||
10. An unparseable `terminal.escape-key` falls back to `C-c`, reports through
|
||||
`EditorCore::status`, and leaves the terminal usable (Q#TC4a). Bite: with
|
||||
the fallback removed, the terminal becomes unescapable.
|
||||
10a. "Reports once" is once per terminal per effective invalid value
|
||||
(Q#TC4c): an **A→B→A** switch with the same invalid value reports **once**,
|
||||
while changing it to a *different* invalid value reports again. The report
|
||||
count is asserted, not the message text.
|
||||
11. The terminal opening keybinding invokes the existing command, and is
|
||||
verified to have shadowed nothing (Q#TC10).
|
||||
12. Existing `terminal` invocations and every existing terminal test behave
|
||||
identically with no settings defined and no profiles registered.
|
||||
|
||||
### Stage 2 — `terminal-copy-mode`
|
||||
|
||||
13. `terminal.copy-mode` produces a read-only buffer whose text is
|
||||
byte-identical to serializing the full retained range through the existing
|
||||
copy path (Q#TC7) — pinned against the serializer, so the two cannot drift.
|
||||
14. Soft wraps, hard rows, wide glyphs, combining clusters, and trailing
|
||||
default blanks appear in the snapshot exactly as Stage 2 criterion 21 pins
|
||||
them for selection copy.
|
||||
15. isearch over the snapshot finds content that is **only in scrollback**
|
||||
(scrolled off the visible screen), with no change to `src/search.rs` (B1).
|
||||
16. **Ungated, runs in CI:** focusing the snapshot buffer makes
|
||||
`dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a
|
||||
depends on, it needs no CRDT, and it fails the moment
|
||||
`set_round_trip_input` is dropped — so the load-bearing regression is
|
||||
caught by the default configuration rather than only by a `crdt`-gated
|
||||
test that CI never compiles.
|
||||
17. **Through a semantic frontend** (this one does need CRDT): keys typed in
|
||||
the snapshot buffer reach ordinary dispatch and never the child, and
|
||||
**neither the daemon buffer nor the frontend's mirror is mutated**
|
||||
(Q#TC6a). Bite: with `set_round_trip_input` removed, the optimistic op is
|
||||
emitted, bypasses the Lua intercept, passes `ensure_writable()`, and
|
||||
mutates **both sides** — a buffer the editor calls read-only silently
|
||||
accepts an edit.
|
||||
18. Re-invoking against the same terminal refreshes in place; the buffer count
|
||||
does not grow (Q#TC8). Killing the snapshot leaves the terminal running;
|
||||
killing the terminal removes the snapshot.
|
||||
19. `C-t` in a terminal buffer (physically `C-c C-t`) enters copy mode; `g`
|
||||
refreshes the snapshot from the live terminal and `q` returns to the source
|
||||
terminal (Q#TC8a).
|
||||
20. The live terminal's own keys are unchanged while a snapshot exists
|
||||
(Q#TC9), and the terminal keeps following its tail.
|
||||
21. The dispatch-shadow count is **unchanged at six** — pinned by asserting
|
||||
`describe-key` reports the truth for the snapshot buffer's `g` and `q`,
|
||||
which is the observable difference between the buffer-local idiom and a
|
||||
shadow.
|
||||
|
||||
## Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
- **§6 Interaction islands — this arc deliberately adds none.** It is the
|
||||
first modal-feeling terminal feature that resolves to the buffer-local
|
||||
keymap idiom §6 identifies as correct, rather than a seventh rung on the
|
||||
precedence ladder. The shadow count stays at six and `describe-key` stays
|
||||
truthful (acceptance 21). Worth recording in §6 as a worked example that the
|
||||
idiom scales to a case that looks modal.
|
||||
- **§11 Configuration as typed, layered data** — the terminal gains its first
|
||||
settings, and produces a second blocked adopter for **two** distinct registry
|
||||
deferrals: the missing table-valued kind (profiles) and the missing
|
||||
`scope = "global"` flag (the **two open-time settings** —
|
||||
`terminal.escape-key` deliberately supports buffer-locals, so only
|
||||
`default-profile` and `scrollback-rows` want an enforcement the registry
|
||||
cannot express). §11's ground truth should
|
||||
record both, because the argument for prioritizing them is now cumulative
|
||||
rather than hypothetical.
|
||||
- **§2 golden journey, step 8 — partially closed here.** Stage 1 carries the
|
||||
**terminal opening keybinding** that Priority 1 explicitly names (Q#TC10),
|
||||
which is the larger half of "works but undiscoverable". Close/kill stays with
|
||||
the panel work so the entry and exit points are designed together, and is
|
||||
named in Deferred rather than silently skipped.
|
||||
- **§5 Unify discovery** — the new commands must carry real descriptions so
|
||||
M-x rows are useful; no new introspection surface is added.
|
||||
- No background-work attribution change; no new activity view; no protocol
|
||||
change.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Full gate suite per `CLAUDE.md` for each PR separately, plus:
|
||||
|
||||
- **The touched terminal suites in BOTH configurations** — default and
|
||||
`--features crdt` — not only the CRDT one. `vterm_stage1_acceptance`,
|
||||
`vterm_stage2_acceptance`, and `vterm_stage3_acceptance` all carry tests in
|
||||
each, and acceptance 12 is a claim about the default configuration too.
|
||||
- `cargo test --test config_registry_acceptance` for the new settings.
|
||||
- New suites: `tests/terminal_config_acceptance.rs` (Stage 1) and
|
||||
`tests/terminal_copy_mode_acceptance.rs` (Stage 2).
|
||||
- Every behavioral claim bite-verified. The bites that matter most:
|
||||
**7/8/8a** — three pins that fail against three *different* wrong cache
|
||||
implementations (epoch-only key, single last-entry, unpurged map), which is
|
||||
why one pin was not enough; **9** (a hardcoded `0x03` makes the configured
|
||||
chord unreachable); **10** (its failure mode is a terminal nobody can
|
||||
escape); and **16/17** (a read-only buffer that silently accepts an edit on
|
||||
both sides).
|
||||
- **The observation seams the cache pins need are `escape_parses` (how often)
|
||||
and `escape_caches` (how many are still held).** Neither is inferable from
|
||||
behavior: for a *valid* setting a correct per-session cache and a leaking
|
||||
editor-side map produce identical keystroke results, and both leave the
|
||||
session count draining normally. Review round 1 caught 8a asserting the
|
||||
session count instead — which the unpurged-map bite passes, since a map with
|
||||
no purge hook leaks *while* sessions drain. A lifecycle claim needs a
|
||||
lifecycle observable; the count of live sessions is not one.
|
||||
- **Criterion 5 must open a real terminal and read back retained history.**
|
||||
Round 1 caught it asserting a registry round-trip instead, which is a test of
|
||||
the registry: it stays green with the setting's only consumer deleted. The
|
||||
same shape to watch for anywhere — *asserting that a value was stored is not
|
||||
asserting that anything reads it*.
|
||||
- **Do not gate the new suites on `#[cfg(feature = "crdt")]` unless a test
|
||||
genuinely needs CRDT.** CI never enables that feature, so a suite gated that
|
||||
way is written and then never run — 264 tests are currently dark for exactly
|
||||
this reason. That measurement and its lane live on **PR #168**, which is open
|
||||
and unmerged; it is not yet in `docs/active-work.md` on `main`.
|
||||
Acceptance 17 does need a semantic frontend, so that one test is gated — but
|
||||
acceptance 16 pins the same mechanism ungated, so the regression is caught in
|
||||
CI regardless. That pairing is the pattern to reuse whenever a claim's
|
||||
end-to-end proof needs CRDT.
|
||||
|
|
@ -1711,6 +1711,22 @@ half of criterion 14, is dark for the same reason. Stage 2 is fully covered
|
|||
(6/6). This is not a vterm problem: 264 tests workspace-wide are dark,
|
||||
including 177 in the library. It has its own lane in `docs/active-work.md`.
|
||||
|
||||
**And `a37` is darker still than that count implies: it reports `ok` without
|
||||
running whenever `pmacs-gpu` is absent from the same target directory**
|
||||
(measured 2026-07-26 while gating #173). It derives the sibling binary from
|
||||
`CARGO_BIN_EXE_pmacs` and, finding nothing, prints a skip notice and returns.
|
||||
A fresh worktree reports the suite 9/9 in 0.17 s having executed the arc's
|
||||
only real-daemon/real-PTY/real-wgpu path zero times; a genuine run takes
|
||||
about four seconds. `PMACS_REQUIRE_GPU=1` is the only thing that turns that
|
||||
skip into a failure, and the standing gate list applies that flag to
|
||||
`cargo test -p pmacs-gpu`, a different package. So the audit's claim that
|
||||
"only 3 of 9 Stage 3 tests drive a real daemon" was itself optimistic —
|
||||
**on a target directory without the frontend binary the honest number is 2**,
|
||||
and nothing in the gate log says so. It is also load-sensitive: it passed and
|
||||
then failed at the same commit twenty minutes apart under machine
|
||||
contention. Criterion 22's unpinned "without thrash" and this are the arc's
|
||||
two standing verification gaps.
|
||||
|
||||
**Not audited:** §11's blanket claim that "deferral means graceful ignore or
|
||||
documented absence, never escape leakage, panic, unbounded allocation, or
|
||||
child leak". That covers roughly twenty deferred items and none were
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ similar_names = "allow"
|
|||
multiple_crate_versions = "allow"
|
||||
|
||||
[dependencies]
|
||||
# OpenType MATH table reader for inline math layout (Q#MS7). Already in the
|
||||
# build graph via fontdb -> cosmic-text -> glyphon, so this declares a crate
|
||||
# the build compiles anyway. The feature set is a SUBSET of fontdb's; a bare
|
||||
# `ttf-parser = "0.25"` would union `std` in and rebuild the whole font chain.
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
|
||||
# OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds
|
||||
# the zwlr_data_control backend so the clipboard works under Wayland
|
||||
# without a window handle; the default X11 backend covers X sessions.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
% This is version 1.0, dated 22 June 2009, of the GUST Font License.
|
||||
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
|
||||
%
|
||||
% For the most recent version of this license see
|
||||
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
% or
|
||||
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
%
|
||||
% This work may be distributed and/or modified under the conditions
|
||||
% of the LaTeX Project Public License, either version 1.3c of this
|
||||
% license or (at your option) any later version.
|
||||
%
|
||||
% Please also observe the following clause:
|
||||
% 1) it is requested, but not legally required, that derived works be
|
||||
% distributed only after changing the names of the fonts comprising this
|
||||
% work and given in an accompanying "manifest", and that the
|
||||
% files comprising the Work, as listed in the manifest, also be given
|
||||
% new names. Any exceptions to this request are also given in the
|
||||
% manifest.
|
||||
%
|
||||
% We recommend the manifest be given in a separate file named
|
||||
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
|
||||
% of the font family. If a separate "readme" file accompanies the Work,
|
||||
% we recommend a name of the form README-<fontid>.txt.
|
||||
%
|
||||
% The latest version of the LaTeX Project Public License is in
|
||||
% http://www.latex-project.org/lppl.txt and version 1.3c or later
|
||||
% is part of all distributions of LaTeX version 2006/05/20 or later.
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,900 @@
|
|||
//! OpenType MATH metrics and the math-italic mapping (Tier 3, part one).
|
||||
//!
|
||||
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS6 / Q#MS7.
|
||||
//!
|
||||
//! Two consumers read the same bundled font bytes: cosmic-text draws with it,
|
||||
//! and this module measures with it. cosmic-text does not expose the MATH
|
||||
//! table, which is why `ttf-parser` is a direct dependency (Q#MS7) — already
|
||||
//! in the build graph via `fontdb`, declared with a feature subset that
|
||||
//! widens nothing.
|
||||
|
||||
use ttf_parser::Face;
|
||||
|
||||
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
|
||||
///
|
||||
/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin
|
||||
/// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6).
|
||||
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
|
||||
|
||||
/// The MATH constants this slice's subset needs, in font units.
|
||||
///
|
||||
/// Deliberately narrow: Q#MS2 covers scripts and fractions, so these are the
|
||||
/// constants those two require. Reading more would be speculative — the
|
||||
/// values for deferred constructs are only meaningful once they have a
|
||||
/// consumer (the Q#LX5 discipline, applied to metrics).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MathConstants {
|
||||
/// Units per em, for scaling everything below into pixels.
|
||||
pub units_per_em: u16,
|
||||
/// Vertical position of the fraction bar / math axis.
|
||||
pub axis_height: i16,
|
||||
/// Percentage (0–100) to scale one script level down.
|
||||
pub script_percent_scale_down: i16,
|
||||
/// Baseline shift for a superscript.
|
||||
pub superscript_shift_up: i16,
|
||||
/// Baseline shift for a subscript.
|
||||
pub subscript_shift_down: i16,
|
||||
/// Thickness of the fraction rule.
|
||||
pub fraction_rule_thickness: i16,
|
||||
/// Minimum gap between the numerator and the rule.
|
||||
pub fraction_numerator_gap_min: i16,
|
||||
/// Minimum gap between the rule and the denominator.
|
||||
pub fraction_denominator_gap_min: i16,
|
||||
}
|
||||
|
||||
/// Why the bundled font could not supply math metrics.
|
||||
///
|
||||
/// Q#MS7: this is a failure of the *math path only* — spans fall back to
|
||||
/// source and the editor keeps running. It is surfaced rather than swallowed
|
||||
/// so a bundled-font regression cannot be silent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MathFontError {
|
||||
/// The bytes are not a parseable font.
|
||||
Unparseable,
|
||||
/// Parsed, but carries no MATH table (e.g. a text-only font).
|
||||
NoMathTable,
|
||||
/// MATH table present but missing a constant the subset needs.
|
||||
MissingConstant(&'static str),
|
||||
/// The math font cannot draw this codepoint (F3). Q#MS8's rule is
|
||||
/// "failure is always show the source", so layout REFUSES rather than
|
||||
/// emitting a zero-width item that would render tofu over its neighbour.
|
||||
/// Layout is fallible for this reason alone; the draw pass needs a
|
||||
/// refusal signal, and it must exist before that pass consumes the API.
|
||||
UncoverableGlyph(char),
|
||||
}
|
||||
|
||||
impl MathConstants {
|
||||
/// Read the subset's constants from font bytes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError`] when the face, the MATH table, or a needed constant
|
||||
/// is absent.
|
||||
pub fn from_font_bytes(bytes: &[u8]) -> Result<Self, MathFontError> {
|
||||
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
|
||||
let math = face.tables().math.ok_or(MathFontError::NoMathTable)?;
|
||||
let constants = math
|
||||
.constants
|
||||
.ok_or(MathFontError::MissingConstant("constants"))?;
|
||||
Ok(Self {
|
||||
units_per_em: face.units_per_em(),
|
||||
axis_height: constants.axis_height().value,
|
||||
script_percent_scale_down: constants.script_percent_scale_down(),
|
||||
superscript_shift_up: constants.superscript_shift_up().value,
|
||||
subscript_shift_down: constants.subscript_shift_down().value,
|
||||
fraction_rule_thickness: constants.fraction_rule_thickness().value,
|
||||
fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
|
||||
fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a font-unit value to pixels at `font_size_px`.
|
||||
#[must_use]
|
||||
pub fn to_px(self, value: i16, font_size_px: f32) -> f32 {
|
||||
if self.units_per_em == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
f32::from(value) * font_size_px / f32::from(self.units_per_em)
|
||||
}
|
||||
|
||||
/// The per-level script scale, as a fraction (e.g. 0.7).
|
||||
#[must_use]
|
||||
pub fn script_scale(self) -> f32 {
|
||||
let pct = f32::from(self.script_percent_scale_down);
|
||||
if pct <= 0.0 { 0.7 } else { pct / 100.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a resolved codepoint to its math-mode presentation form (Q#MS2).
|
||||
///
|
||||
/// TeX's convention, which is why uppercase Greek is deliberately upright:
|
||||
///
|
||||
/// | Class | Treatment |
|
||||
/// |---|---|
|
||||
/// | ASCII letters | math italic, with the U+210E hole for `h` |
|
||||
/// | Lowercase Greek | math italic |
|
||||
/// | Uppercase Greek | upright |
|
||||
/// | Digits, operators | upright |
|
||||
///
|
||||
/// Without this, `$x^2$` draws a roman `x` and `$\alpha x$` draws an upright
|
||||
/// α beside an italic 𝑥 — mixed styles inside one expression (framing F7,
|
||||
/// R2-2).
|
||||
#[must_use]
|
||||
pub fn math_italic(ch: char) -> char {
|
||||
// U+210E PLANCK CONSTANT is the italic `h`; the 1D4xx run has a hole
|
||||
// there, so mapping arithmetically would produce a reserved codepoint.
|
||||
if ch == 'h' {
|
||||
return '\u{210E}';
|
||||
}
|
||||
let mapped = match ch {
|
||||
'A'..='Z' => 0x1D434 + (ch as u32 - 'A' as u32),
|
||||
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
|
||||
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
|
||||
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
|
||||
// The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE
|
||||
// that run, so they need explicit italic mappings — without them the
|
||||
// seed map's correction would render them upright beside italic
|
||||
// neighbours, which is the defect it was fixing.
|
||||
'\u{3F5}' => 0x1D716, // ϵ lunate epsilon
|
||||
'\u{3D5}' => 0x1D719, // ϕ phi symbol
|
||||
// Uppercase Greek, digits, operators: upright, per TeX.
|
||||
_ => return ch,
|
||||
};
|
||||
char::from_u32(mapped).unwrap_or(ch)
|
||||
}
|
||||
|
||||
/// A laid-out expression. Baseline at `y = 0`, positive `y` upward.
|
||||
///
|
||||
/// Q#MS6: items carry CHARACTERS, not glyph IDs. Layout still resolves glyph
|
||||
/// ids internally for advances and bounds — the boundary is on the emitted
|
||||
/// items, so each is drawable by the existing text machinery. Glyph-id items
|
||||
/// arrive with stretchy fences and big operators, both deferred.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MathBox {
|
||||
pub width: f32,
|
||||
pub ascent: f32,
|
||||
pub descent: f32,
|
||||
pub items: Vec<MathItem>,
|
||||
}
|
||||
|
||||
/// One drawable piece of a [`MathBox`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum MathItem {
|
||||
/// A character at its own size, `baseline` relative to the box baseline.
|
||||
Glyph {
|
||||
ch: char,
|
||||
x: f32,
|
||||
baseline: f32,
|
||||
size_px: f32,
|
||||
},
|
||||
/// The fraction bar. Not a glyph — drawn on the existing quad pipeline.
|
||||
Rule {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
thickness: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MathItem {
|
||||
fn shifted(self, dx: f32, dy: f32) -> Self {
|
||||
match self {
|
||||
Self::Glyph {
|
||||
ch,
|
||||
x,
|
||||
baseline,
|
||||
size_px,
|
||||
} => Self::Glyph {
|
||||
ch,
|
||||
x: x + dx,
|
||||
baseline: baseline + dy,
|
||||
size_px,
|
||||
},
|
||||
Self::Rule {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
} => Self::Rule {
|
||||
x: x + dx,
|
||||
y: y + dy,
|
||||
width,
|
||||
thickness,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MathBox {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
width: 0.0,
|
||||
ascent: 0.0,
|
||||
descent: 0.0,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb `other` at offset `(dx, dy)`, growing this box's extents.
|
||||
fn absorb(&mut self, other: &Self, dx: f32, dy: f32) {
|
||||
self.items
|
||||
.extend(other.items.iter().map(|item| item.shifted(dx, dy)));
|
||||
self.ascent = self.ascent.max(other.ascent + dy);
|
||||
self.descent = self.descent.max(other.descent - dy);
|
||||
}
|
||||
|
||||
/// Uniformly scale every extent and item (Q#MS10 fit-to-line).
|
||||
#[must_use]
|
||||
pub fn scaled(&self, factor: f32) -> Self {
|
||||
Self {
|
||||
width: self.width * factor,
|
||||
ascent: self.ascent * factor,
|
||||
descent: self.descent * factor,
|
||||
items: self
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| match *item {
|
||||
MathItem::Glyph {
|
||||
ch,
|
||||
x,
|
||||
baseline,
|
||||
size_px,
|
||||
} => MathItem::Glyph {
|
||||
ch,
|
||||
x: x * factor,
|
||||
baseline: baseline * factor,
|
||||
size_px: size_px * factor,
|
||||
},
|
||||
MathItem::Rule {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
} => MathItem::Rule {
|
||||
x: x * factor,
|
||||
y: y * factor,
|
||||
width: width * factor,
|
||||
thickness: thickness * factor,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The line-box height budget a math box must fit (Q#MS10), as
|
||||
/// `(above_baseline, below_baseline)` pixels.
|
||||
///
|
||||
/// Extracted rather than left inside a test: the draw pass must compute the
|
||||
/// SAME split the acceptance test asserts, and a duplicated derivation is
|
||||
/// exactly how a renderer and its test drift apart while both stay green.
|
||||
///
|
||||
/// The baseline is placed by the CODE font, not the math font — using the
|
||||
/// math font's own metrics understates the descent budget badly enough to
|
||||
/// make a plain fraction appear not to fit.
|
||||
#[must_use]
|
||||
pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) {
|
||||
const MARGIN_PX: f32 = 1.0;
|
||||
let upem = f32::from(code_font.units_per_em().max(1));
|
||||
let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem;
|
||||
let above = (baseline_from_top - MARGIN_PX).max(0.0);
|
||||
let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0);
|
||||
(above, below)
|
||||
}
|
||||
|
||||
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
|
||||
pub const MIN_FIT_SCALE: f32 = 0.6;
|
||||
|
||||
/// Scale `boxed` to fit `(ascent_budget, descent_budget)`, or `None` when
|
||||
/// that would fall below [`MIN_FIT_SCALE`] — in which case Q#MS8 shows the
|
||||
/// raw source rather than overdrawing into the neighbouring line.
|
||||
#[must_use]
|
||||
pub fn fit_to_line(boxed: &MathBox, ascent_budget: f32, descent_budget: f32) -> Option<MathBox> {
|
||||
let need_up = boxed.ascent;
|
||||
let need_down = boxed.descent;
|
||||
let up = if need_up <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
ascent_budget / need_up
|
||||
};
|
||||
let down = if need_down <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
descent_budget / need_down
|
||||
};
|
||||
let scale = up.min(down).min(1.0);
|
||||
if scale < MIN_FIT_SCALE {
|
||||
return None;
|
||||
}
|
||||
if scale >= 1.0 {
|
||||
return Some(boxed.clone());
|
||||
}
|
||||
Some(boxed.scaled(scale))
|
||||
}
|
||||
|
||||
/// Lays a [`MathNode`] tree out against the bundled MATH font.
|
||||
pub struct MathLayout<'a> {
|
||||
face: Face<'a>,
|
||||
constants: MathConstants,
|
||||
}
|
||||
|
||||
impl<'a> MathLayout<'a> {
|
||||
/// Build a layout engine over font bytes.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError`] when the face or its MATH table is unusable.
|
||||
pub fn new(bytes: &'a [u8]) -> Result<Self, MathFontError> {
|
||||
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
|
||||
let constants = MathConstants::from_font_bytes(bytes)?;
|
||||
Ok(Self { face, constants })
|
||||
}
|
||||
|
||||
/// Test-only introspection: the production draw path consumes the
|
||||
/// constants through `layout`, never raw.
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn constants(&self) -> MathConstants {
|
||||
self.constants
|
||||
}
|
||||
|
||||
/// Lay `node` out at `size_px`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError::UncoverableGlyph`] when the math font has no glyph
|
||||
/// for a character, so the caller can fall back to source (Q#MS8).
|
||||
pub fn layout(
|
||||
&self,
|
||||
node: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
use crate::math_parse::MathNode;
|
||||
match node {
|
||||
MathNode::Char(ch) => self.layout_char(*ch, size_px),
|
||||
MathNode::Group(children) => {
|
||||
let mut out = MathBox::empty();
|
||||
let mut pen = 0.0;
|
||||
for child in children {
|
||||
let child_box = self.layout(child, size_px)?;
|
||||
out.absorb(&child_box, pen, 0.0);
|
||||
pen += child_box.width;
|
||||
}
|
||||
out.width = pen;
|
||||
Ok(out)
|
||||
}
|
||||
MathNode::Script { base, sub, sup } => {
|
||||
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
|
||||
}
|
||||
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
|
||||
let presented = math_italic(ch);
|
||||
let upem = f32::from(self.constants.units_per_em.max(1));
|
||||
let (advance, ascent, descent) = self
|
||||
.face
|
||||
.glyph_index(presented)
|
||||
.map(|gid| {
|
||||
let adv = self
|
||||
.face
|
||||
.glyph_hor_advance(gid)
|
||||
.map_or(0.0, |a| f32::from(a) * size_px / upem);
|
||||
// Per-glyph bounds keep boxes tight, which is what makes a
|
||||
// fraction's extents honest; fall back to face metrics when
|
||||
// a glyph has no bounding box (e.g. a space).
|
||||
let (asc, desc) = self.face.glyph_bounding_box(gid).map_or_else(
|
||||
|| {
|
||||
(
|
||||
f32::from(self.face.ascender()) * size_px / upem,
|
||||
-f32::from(self.face.descender()) * size_px / upem,
|
||||
)
|
||||
},
|
||||
|bb| {
|
||||
(
|
||||
f32::from(bb.y_max) * size_px / upem,
|
||||
-f32::from(bb.y_min) * size_px / upem,
|
||||
)
|
||||
},
|
||||
);
|
||||
(adv, asc.max(0.0), desc.max(0.0))
|
||||
})
|
||||
// F3: no glyph means no honest box. Emitting a zero-width item
|
||||
// would draw tofu on top of the next character.
|
||||
.ok_or(MathFontError::UncoverableGlyph(ch))?;
|
||||
Ok(MathBox {
|
||||
width: advance,
|
||||
ascent,
|
||||
descent,
|
||||
items: vec![MathItem::Glyph {
|
||||
ch: presented,
|
||||
x: 0.0,
|
||||
baseline: 0.0,
|
||||
size_px,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_script(
|
||||
&self,
|
||||
base: &crate::math_parse::MathNode,
|
||||
sub: Option<&crate::math_parse::MathNode>,
|
||||
sup: Option<&crate::math_parse::MathNode>,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
let base_box = self.layout(base, size_px)?;
|
||||
let script_px = size_px * self.constants.script_scale();
|
||||
let mut out = MathBox::empty();
|
||||
out.absorb(&base_box, 0.0, 0.0);
|
||||
let mut widest = base_box.width;
|
||||
if let Some(sup) = sup {
|
||||
let sup_box = self.layout(sup, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.superscript_shift_up, size_px);
|
||||
out.absorb(&sup_box, base_box.width, shift);
|
||||
widest = widest.max(base_box.width + sup_box.width);
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
let sub_box = self.layout(sub, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.subscript_shift_down, size_px);
|
||||
out.absorb(&sub_box, base_box.width, -shift);
|
||||
widest = widest.max(base_box.width + sub_box.width);
|
||||
}
|
||||
out.width = widest;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn layout_fraction(
|
||||
&self,
|
||||
num: &crate::math_parse::MathNode,
|
||||
den: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
// TeX sets an inline \frac's operands one style down, which is also
|
||||
// what the parent framing's Tier 3 specifies (70%). It is load-bearing
|
||||
// for Q#MS10: full-size operands would not fit the line at all.
|
||||
let operand_px = size_px * self.constants.script_scale();
|
||||
let num_box = self.layout(num, operand_px)?;
|
||||
let den_box = self.layout(den, operand_px)?;
|
||||
let axis = self.constants.to_px(self.constants.axis_height, size_px);
|
||||
let thickness = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_rule_thickness, size_px)
|
||||
.max(1.0);
|
||||
// F4: the gaps come from the MATH table, not a guess. An earlier
|
||||
// revision used `thickness * 2.0`, which made fractions roughly twice
|
||||
// as airy as the font specifies and inflated the height budget the
|
||||
// fit-to-line scale is measured against.
|
||||
let num_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_numerator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
let den_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_denominator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
|
||||
let width = num_box.width.max(den_box.width);
|
||||
let mut out = MathBox::empty();
|
||||
// Numerator sits above the bar, denominator below it.
|
||||
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
|
||||
let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent;
|
||||
out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline);
|
||||
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
|
||||
out.items.push(MathItem::Rule {
|
||||
x: 0.0,
|
||||
y: axis,
|
||||
width,
|
||||
thickness,
|
||||
});
|
||||
out.ascent = out.ascent.max(axis + thickness / 2.0);
|
||||
out.descent = out.descent.max(-(axis - thickness / 2.0));
|
||||
out.width = width;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacer text reserving `width_px`, quantized UP to whole space advances.
|
||||
///
|
||||
/// Q#MS4 / B1': a `RichChunk`'s only width is its text, so a suppressed math
|
||||
/// span reserves room the way `SourceTab` does — with spaces. Quantizing up
|
||||
/// is deliberate: it keeps the projection grid-aligned with the surrounding
|
||||
/// monospace text and keeps hit runs integral, at the cost of up to one
|
||||
/// advance of slack on the right of the box.
|
||||
#[must_use]
|
||||
pub fn spacer_for_width(width_px: f32, space_advance_px: f32) -> String {
|
||||
if !width_px.is_finite() || width_px <= 0.0 || space_advance_px <= 0.0 {
|
||||
return String::new();
|
||||
}
|
||||
let n = (width_px / space_advance_px).ceil();
|
||||
// Guard the cast: a pathological advance must not mint a giant string.
|
||||
let n = n.clamp(0.0, 4096.0) as usize;
|
||||
" ".repeat(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::math_parse::parse;
|
||||
|
||||
/// Framing acceptance 16 (provenance half): the GUST licence ships
|
||||
/// beside the font, names itself, and is not the OFL that covers
|
||||
/// `JetBrains` Mono.
|
||||
#[test]
|
||||
fn bundled_licences_are_distinct_and_name_their_terms() {
|
||||
let gust = include_str!("../fonts/GUST-FONT-LICENSE.txt");
|
||||
let ofl = include_str!("../fonts/OFL.txt");
|
||||
assert!(gust.contains("GUST Font License"));
|
||||
assert!(gust.contains("LaTeX Project Public License"));
|
||||
assert!(!ofl.contains("GUST"));
|
||||
assert_ne!(gust, ofl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tex_symbol_greek_forms_are_italicised_too() {
|
||||
// F5's trap: correcting the seed map alone leaves these upright,
|
||||
// because they sit outside the U+03B1..03C9 run.
|
||||
assert_eq!(math_italic('\u{3F5}'), '\u{1D716}');
|
||||
assert_eq!(math_italic('\u{3D5}'), '\u{1D719}');
|
||||
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face");
|
||||
for ch in ['\u{3F5}', '\u{3D5}'] {
|
||||
assert!(
|
||||
face.glyph_index(math_italic(ch)).is_some(),
|
||||
"no glyph for the italic form of U+{:04X}",
|
||||
ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_quantizes_up_to_whole_advances() {
|
||||
// Exactly two advances stays two; a sliver over rounds up, so the
|
||||
// box never overlaps the text that follows it.
|
||||
assert_eq!(spacer_for_width(20.0, 10.0).len(), 2);
|
||||
assert_eq!(spacer_for_width(20.1, 10.0).len(), 3);
|
||||
assert_eq!(spacer_for_width(0.1, 10.0).len(), 1);
|
||||
// Degenerate inputs reserve nothing rather than panicking or
|
||||
// minting an enormous string.
|
||||
assert!(spacer_for_width(0.0, 10.0).is_empty());
|
||||
assert!(spacer_for_width(-5.0, 10.0).is_empty());
|
||||
assert!(spacer_for_width(10.0, 0.0).is_empty());
|
||||
assert!(spacer_for_width(f32::NAN, 10.0).is_empty());
|
||||
assert!(spacer_for_width(f32::INFINITY, 10.0).is_empty());
|
||||
assert!(spacer_for_width(1e9, 0.001).len() <= 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_box_reserves_at_least_its_own_width() {
|
||||
let boxed = lay(r"\frac{a}{b}", crate::BASE_CODE_FONT_SIZE);
|
||||
let advance = 9.6_f32; // a plausible monospace advance at 16 px
|
||||
let spacer = spacer_for_width(boxed.width, advance);
|
||||
let reserved = spacer.len() as f32 * advance;
|
||||
assert!(
|
||||
reserved >= boxed.width,
|
||||
"reserved {reserved} must cover box width {}",
|
||||
boxed.width
|
||||
);
|
||||
assert!(
|
||||
reserved - boxed.width < advance,
|
||||
"slack stays under one advance"
|
||||
);
|
||||
}
|
||||
|
||||
fn engine() -> MathLayout<'static> {
|
||||
MathLayout::new(LATIN_MODERN_MATH).expect("bundled font")
|
||||
}
|
||||
|
||||
fn lay(src: &str, size: f32) -> MathBox {
|
||||
let node = parse(src).expect("parses");
|
||||
engine().layout(&node, size).expect("lays out")
|
||||
}
|
||||
|
||||
/// F3 — a codepoint the math font cannot draw REFUSES, so the caller can
|
||||
/// fall back to source (Q#MS8) instead of drawing tofu at zero advance
|
||||
/// on top of the next character.
|
||||
#[test]
|
||||
fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() {
|
||||
let node = parse("x日").expect("parses — coverage is layout's problem");
|
||||
assert_eq!(
|
||||
engine().layout(&node, 16.0),
|
||||
Err(MathFontError::UncoverableGlyph('日'))
|
||||
);
|
||||
// The covered neighbour on its own still lays out.
|
||||
assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok());
|
||||
}
|
||||
|
||||
/// Framing acceptance 3, including its bite: the MATH constant must be
|
||||
/// READ, not hardcoded.
|
||||
#[test]
|
||||
fn superscript_is_raised_and_scaled_from_the_math_table() {
|
||||
let plain = lay("x", 16.0);
|
||||
let script = lay("x^2", 16.0);
|
||||
assert!(script.width > plain.width, "the 2 adds width");
|
||||
assert!(
|
||||
script.ascent > plain.ascent,
|
||||
"superscript must raise the box: {} vs {}",
|
||||
script.ascent,
|
||||
plain.ascent
|
||||
);
|
||||
let two = script
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match *i {
|
||||
MathItem::Glyph {
|
||||
ch: '2',
|
||||
baseline,
|
||||
size_px,
|
||||
..
|
||||
} => Some((baseline, size_px)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the 2 is emitted");
|
||||
assert!(two.0 > 0.0, "raised above baseline: {}", two.0);
|
||||
assert!(two.1 < 16.0, "scaled down: {}", two.1);
|
||||
|
||||
// Bite: with the script scale stubbed to 100%, the box changes —
|
||||
// proving the constant is consulted rather than assumed.
|
||||
let c = engine().constants();
|
||||
assert!(
|
||||
c.script_percent_scale_down < 100,
|
||||
"font advertises a real script scale ({}%), so 100% is a \
|
||||
meaningful stub",
|
||||
c.script_percent_scale_down
|
||||
);
|
||||
let stubbed = MathConstants {
|
||||
script_percent_scale_down: 100,
|
||||
..c
|
||||
};
|
||||
assert!(
|
||||
(stubbed.script_scale() - c.script_scale()).abs() > 0.01,
|
||||
"stubbing the constant must change the scale actually used"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscript_drops_below_the_baseline() {
|
||||
let script = lay("x_i", 16.0);
|
||||
let i = script
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match *item {
|
||||
MathItem::Glyph { ch, baseline, .. } if ch == math_italic('i') => Some(baseline),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the i is emitted");
|
||||
assert!(i < 0.0, "subscript sits below the baseline: {i}");
|
||||
assert!(script.descent > lay("x", 16.0).descent);
|
||||
}
|
||||
|
||||
/// Framing acceptance 4.
|
||||
#[test]
|
||||
fn fraction_stacks_operands_around_a_rule_at_the_axis() {
|
||||
let frac = lay(r"\frac{a}{b}", 16.0);
|
||||
let rule = frac
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match *item {
|
||||
MathItem::Rule {
|
||||
y,
|
||||
width,
|
||||
thickness,
|
||||
..
|
||||
} => Some((y, width, thickness)),
|
||||
MathItem::Glyph { .. } => None,
|
||||
})
|
||||
.expect("a fraction draws a rule");
|
||||
assert!(rule.0 > 0.0, "rule sits at the math axis, above baseline");
|
||||
assert!(rule.2 > 0.0 && rule.1 > 0.0);
|
||||
|
||||
let mut above = 0;
|
||||
let mut below = 0;
|
||||
for item in &frac.items {
|
||||
if let MathItem::Glyph { baseline, .. } = *item {
|
||||
if baseline > rule.0 {
|
||||
above += 1;
|
||||
} else if baseline < rule.0 {
|
||||
below += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!((above, below), (1, 1), "one operand each side of the bar");
|
||||
assert!(frac.ascent > 0.0 && frac.descent > 0.0);
|
||||
}
|
||||
|
||||
/// F1 / B6 — the height budget, computed rather than guessed.
|
||||
///
|
||||
/// The round-2 review warned that acceptance 12's fallback case must be
|
||||
/// derived by computation or it would "surprise-pass by rendering". It
|
||||
/// was right, and rev 3's guess was wrong: a doubly-nested fraction still
|
||||
/// fits. This test derives the budget the way Q#MS10 defines it — from
|
||||
/// the LINE BOX, whose baseline the CODE font places — and then searches
|
||||
/// for the depth that actually trips the floor, so the case can never
|
||||
/// drift out from under the acceptance criterion.
|
||||
#[test]
|
||||
fn fit_to_line_admits_real_fractions_and_finds_the_true_fallback_depth() {
|
||||
// Q#MS10: the budget is the line box less a one-pixel margin, split
|
||||
// at the text baseline. The baseline is where the CODE font puts it
|
||||
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
|
||||
// NOT where the math font's own metrics would.
|
||||
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
|
||||
let (asc_budget, desc_budget) = line_box_budget(
|
||||
&code,
|
||||
crate::BASE_CODE_FONT_SIZE,
|
||||
crate::BASE_CODE_LINE_HEIGHT,
|
||||
);
|
||||
assert!(
|
||||
asc_budget > 0.0 && desc_budget > 0.0,
|
||||
"budget must be positive: {asc_budget} / {desc_budget}"
|
||||
);
|
||||
|
||||
let scale_of = |src: &str| {
|
||||
let boxed = lay(src, crate::BASE_CODE_FONT_SIZE);
|
||||
let up = asc_budget / boxed.ascent.max(f32::EPSILON);
|
||||
let down = desc_budget / boxed.descent.max(f32::EPSILON);
|
||||
(up.min(down).min(1.0), boxed)
|
||||
};
|
||||
|
||||
// The flagship cases must RENDER, not fall back (B6).
|
||||
for src in [r"\frac{a}{b}", r"\frac{x^2}{y}", "x^2", r"\alpha x"] {
|
||||
let (scale, boxed) = scale_of(src);
|
||||
eprintln!(
|
||||
"{src}: asc={:.2} desc={:.2} scale={scale:.3}",
|
||||
boxed.ascent, boxed.descent
|
||||
);
|
||||
assert!(
|
||||
scale >= MIN_FIT_SCALE,
|
||||
"{src} must render, not fall back: scale {scale:.3} < {MIN_FIT_SCALE}"
|
||||
);
|
||||
assert!(fit_to_line(&boxed, asc_budget, desc_budget).is_some());
|
||||
}
|
||||
|
||||
// Now FIND the depth that trips the floor rather than assuming one.
|
||||
// Nest fractions until the scale drops below it.
|
||||
let mut src = String::from(r"\frac{a}{b}");
|
||||
let mut depth = 1;
|
||||
let tripped = loop {
|
||||
let (scale, _) = scale_of(&src);
|
||||
eprintln!("depth {depth}: scale={scale:.3}");
|
||||
if scale < MIN_FIT_SCALE {
|
||||
break Some((depth, src.clone()));
|
||||
}
|
||||
// Headroom above the real boundary (5 with the round-3 MATH
|
||||
// gaps): if a metric shift pushed the boundary past this bound,
|
||||
// the expect below would fire with a message reading "the floor
|
||||
// is dead code" when the truth is "the boundary moved past the
|
||||
// search". Keep the bound comfortably above the boundary.
|
||||
if depth >= 8 {
|
||||
break None;
|
||||
}
|
||||
src = format!(r"\frac{{{src}}}{{c}}");
|
||||
depth += 1;
|
||||
};
|
||||
let (depth, deep_src) = tripped.expect(
|
||||
"some nesting depth must exceed the floor, or Q#MS10's fallback \
|
||||
arm is unreachable and the floor is dead code",
|
||||
);
|
||||
assert!(
|
||||
depth > 2,
|
||||
"rev 3 guessed a doubly-nested fraction would fall back; the real \
|
||||
depth is {depth}, so acceptance 12 must use that case"
|
||||
);
|
||||
assert!(
|
||||
fit_to_line(
|
||||
&lay(&deep_src, crate::BASE_CODE_FONT_SIZE),
|
||||
asc_budget,
|
||||
desc_budget
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_scales_extents_and_items_together() {
|
||||
let boxed = lay(r"\frac{a}{b}", 16.0);
|
||||
let half = boxed.scaled(0.5);
|
||||
assert!((half.ascent - boxed.ascent * 0.5).abs() < 0.001);
|
||||
assert!((half.width - boxed.width * 0.5).abs() < 0.001);
|
||||
for (before, after) in boxed.items.iter().zip(half.items.iter()) {
|
||||
if let (MathItem::Glyph { size_px: b, .. }, MathItem::Glyph { size_px: a, .. }) =
|
||||
(before, after)
|
||||
{
|
||||
assert!((a - b * 0.5).abs() < 0.001, "glyph size scales too");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_advances_the_pen_left_to_right() {
|
||||
let boxed = lay("abc", 16.0);
|
||||
let xs: Vec<f32> = boxed
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match *item {
|
||||
MathItem::Glyph { x, .. } => Some(x),
|
||||
MathItem::Rule { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(xs.len(), 3);
|
||||
assert!(xs[0] < xs[1] && xs[1] < xs[2], "left to right: {xs:?}");
|
||||
assert!(boxed.width > xs[2], "width covers the last advance");
|
||||
}
|
||||
|
||||
/// B5 — `ttf-parser` supplies every constant the subset needs, from the
|
||||
/// bundled font. This is the bet that would sink Tier 3 if false, so it
|
||||
/// runs against the real embedded bytes rather than a fixture.
|
||||
#[test]
|
||||
fn bundled_font_yields_every_math_constant_the_subset_needs() {
|
||||
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH)
|
||||
.expect("bundled Latin Modern Math must expose MATH constants");
|
||||
assert_eq!(c.units_per_em, 1000, "LM Math is a 1000 upem font");
|
||||
assert!(c.axis_height > 0, "axis height: {}", c.axis_height);
|
||||
assert!(
|
||||
(50..=100).contains(&c.script_percent_scale_down),
|
||||
"script scale percent out of range: {}",
|
||||
c.script_percent_scale_down
|
||||
);
|
||||
assert!(c.superscript_shift_up > 0);
|
||||
assert!(c.subscript_shift_down > 0);
|
||||
assert!(c.fraction_rule_thickness > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_font_without_a_math_table_is_rejected_not_defaulted() {
|
||||
// Q#MS7: a font with no MATH table must surface, not silently
|
||||
// produce plausible-looking zeros.
|
||||
let err = MathConstants::from_font_bytes(crate::JETBRAINS_MONO)
|
||||
.expect_err("JetBrains Mono has no MATH table");
|
||||
assert_eq!(err, MathFontError::NoMathTable);
|
||||
assert_eq!(
|
||||
MathConstants::from_font_bytes(b"not a font"),
|
||||
Err(MathFontError::Unparseable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_units_convert_to_pixels_against_upem() {
|
||||
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH).expect("constants");
|
||||
// Half an em at 16 px is 8 px.
|
||||
let half_em = i16::try_from(c.units_per_em / 2).expect("fits");
|
||||
assert!((c.to_px(half_em, 16.0) - 8.0).abs() < 0.01);
|
||||
let scale = c.script_scale();
|
||||
assert!((0.5..=1.0).contains(&scale), "script scale: {scale}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_italic_follows_tex_convention_including_the_planck_hole() {
|
||||
// Framing acceptance 13.
|
||||
assert_eq!(math_italic('x'), '\u{1D465}');
|
||||
assert_eq!(math_italic('A'), '\u{1D434}');
|
||||
// The 1D4xx run has a hole at italic `h`; arithmetic would land on a
|
||||
// reserved codepoint, so `h` maps to U+210E instead.
|
||||
assert_eq!(math_italic('h'), '\u{210E}');
|
||||
// Lowercase Greek is italic...
|
||||
assert_eq!(math_italic('α'), '\u{1D6FC}');
|
||||
assert_eq!(math_italic('ω'), '\u{1D714}');
|
||||
// ...uppercase Greek is NOT (TeX convention, deliberate).
|
||||
assert_eq!(math_italic('Γ'), 'Γ');
|
||||
assert_eq!(math_italic('Ω'), 'Ω');
|
||||
// Digits and operators stay upright.
|
||||
assert_eq!(math_italic('2'), '2');
|
||||
assert_eq!(math_italic('+'), '+');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_italic_mapping_lands_on_a_real_glyph_in_the_bundled_font() {
|
||||
// A mapping that produces codepoints the bundled font cannot draw
|
||||
// would render tofu — worse than the roman fallback it replaced.
|
||||
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("parse bundled font");
|
||||
let sample = "abhxyzABXYZαβωΓΩ0129+=";
|
||||
for ch in sample.chars() {
|
||||
let mapped = math_italic(ch);
|
||||
assert!(
|
||||
face.glyph_index(mapped).is_some(),
|
||||
"no glyph for {ch:?} -> {mapped:?} (U+{:04X})",
|
||||
mapped as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
//! LaTeX math-mode parser for the first inline-math slice.
|
||||
//!
|
||||
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS2. This parses
|
||||
//! the deliberately small subset the slice renders — characters, groups,
|
||||
//! sub/superscripts and fractions — and nothing else. Every other LaTeX
|
||||
//! construct is an error, which Q#MS8 turns into "show the raw source".
|
||||
//!
|
||||
//! The AST is *semantic*, not presentational: `\alpha` resolves to `'α'`
|
||||
//! here, but the math-italic mapping (Q#MS2's table) belongs to layout, which
|
||||
//! is where a codepoint becomes a glyph. Keeping the split here means the AST
|
||||
//! matches what the user wrote, and a future non-italic style context does
|
||||
//! not have to unpick a decision the parser baked in.
|
||||
|
||||
/// One node of the slice's math subset (Q#MS2).
|
||||
///
|
||||
/// Rev 2 of the framing folded `Symbol` into `Char`: both carried a `char`,
|
||||
/// and after symbol resolution layout cannot act on the difference.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MathNode {
|
||||
/// A resolved codepoint: `x`, `2`, `+`, `α`.
|
||||
Char(char),
|
||||
/// A braced group, or the top-level expression.
|
||||
Group(Vec<MathNode>),
|
||||
/// A base with optional sub- and superscript.
|
||||
Script {
|
||||
base: Box<MathNode>,
|
||||
sub: Option<Box<MathNode>>,
|
||||
sup: Option<Box<MathNode>>,
|
||||
},
|
||||
/// `\frac{num}{den}`.
|
||||
Fraction {
|
||||
num: Box<MathNode>,
|
||||
den: Box<MathNode>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a span could not be parsed. Q#MS8 renders the raw source for all of
|
||||
/// these; the variants exist so tests can assert *which* rejection fired.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MathParseError {
|
||||
/// The span held no math (`$$` after delimiter stripping).
|
||||
Empty,
|
||||
/// A `{` with no matching `}`, or a stray `}`.
|
||||
UnbalancedBrace,
|
||||
/// A control sequence outside the subset, e.g. `\sqrt`.
|
||||
UnknownCommand(String),
|
||||
/// `\frac` without two braced arguments.
|
||||
MalformedCommand(&'static str),
|
||||
/// `^` or `_` with nothing to attach to, or given twice for one base.
|
||||
MalformedScript(&'static str),
|
||||
/// A `$` inside the span: the delimiters are the caller's business, and
|
||||
/// a bare one here means detection handed us something it should not
|
||||
/// have (framing acceptance 15 — `$$x$$` degrades through this path).
|
||||
UnexpectedDollar,
|
||||
}
|
||||
|
||||
/// Greek seed map (Q#MS2). Deliberately partial — growing it is mechanical.
|
||||
const GREEK: &[(&str, char)] = &[
|
||||
("alpha", 'α'),
|
||||
("beta", 'β'),
|
||||
("gamma", 'γ'),
|
||||
("delta", 'δ'),
|
||||
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
|
||||
("epsilon", '\u{3F5}'),
|
||||
("zeta", 'ζ'),
|
||||
("eta", 'η'),
|
||||
("theta", 'θ'),
|
||||
("iota", 'ι'),
|
||||
("kappa", 'κ'),
|
||||
("lambda", 'λ'),
|
||||
("mu", 'μ'),
|
||||
("nu", 'ν'),
|
||||
("xi", 'ξ'),
|
||||
("pi", 'π'),
|
||||
("rho", 'ρ'),
|
||||
("sigma", 'σ'),
|
||||
("tau", 'τ'),
|
||||
("upsilon", 'υ'),
|
||||
// TeX's \phi is U+03D5; U+03C6 is \varphi.
|
||||
("phi", '\u{3D5}'),
|
||||
("chi", 'χ'),
|
||||
("psi", 'ψ'),
|
||||
("omega", 'ω'),
|
||||
("Gamma", 'Γ'),
|
||||
("Delta", 'Δ'),
|
||||
("Theta", 'Θ'),
|
||||
("Lambda", 'Λ'),
|
||||
("Xi", 'Ξ'),
|
||||
("Pi", 'Π'),
|
||||
("Sigma", 'Σ'),
|
||||
("Upsilon", 'Υ'),
|
||||
("Phi", 'Φ'),
|
||||
("Psi", 'Ψ'),
|
||||
("Omega", 'Ω'),
|
||||
];
|
||||
|
||||
/// Parse the *interior* of a math span — delimiters already stripped.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`MathParseError`] for anything outside the Q#MS2 subset.
|
||||
pub fn parse(source: &str) -> Result<MathNode, MathParseError> {
|
||||
let mut parser = Parser {
|
||||
chars: source.chars().collect(),
|
||||
pos: 0,
|
||||
};
|
||||
let nodes = parser.parse_sequence(None)?;
|
||||
if parser.pos < parser.chars.len() {
|
||||
// Only a stray `}` can stop the top-level sequence early.
|
||||
return Err(MathParseError::UnbalancedBrace);
|
||||
}
|
||||
if nodes.is_empty() {
|
||||
return Err(MathParseError::Empty);
|
||||
}
|
||||
Ok(MathNode::Group(nodes))
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let ch = self.peek();
|
||||
if ch.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
/// Parse until `close` (or end of input when `None`).
|
||||
fn parse_sequence(&mut self, close: Option<char>) -> Result<Vec<MathNode>, MathParseError> {
|
||||
let mut out: Vec<MathNode> = Vec::new();
|
||||
loop {
|
||||
// Whitespace is insignificant in math mode, and it must be
|
||||
// skipped HERE rather than inside `parse_atom`: the `^`/`_`
|
||||
// dispatch below happens before atoms are read, so leaving a
|
||||
// space in front of a marker would make `x ^ 2` parse the caret
|
||||
// as a literal character.
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None => {
|
||||
if close.is_some() {
|
||||
return Err(MathParseError::UnbalancedBrace);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Some(ch) if Some(ch) == close => {
|
||||
self.pos += 1;
|
||||
return Ok(out);
|
||||
}
|
||||
// A `}` we were not asked to stop at is unbalanced.
|
||||
Some('}') => return Err(MathParseError::UnbalancedBrace),
|
||||
Some('$') => return Err(MathParseError::UnexpectedDollar),
|
||||
Some('^' | '_') => {
|
||||
let base = out.pop().ok_or(MathParseError::MalformedScript(
|
||||
"sub/superscript with no base",
|
||||
))?;
|
||||
out.push(self.parse_scripts(base)?);
|
||||
}
|
||||
Some(_) => {
|
||||
let atom = self.parse_atom()?;
|
||||
out.push(atom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One atom: a group, a command, or a single character.
|
||||
fn parse_atom(&mut self) -> Result<MathNode, MathParseError> {
|
||||
match self.bump() {
|
||||
Some('{') => Ok(MathNode::Group(self.parse_sequence(Some('}'))?)),
|
||||
Some('\\') => self.parse_command(),
|
||||
Some(ch) => Ok(MathNode::Char(ch)),
|
||||
None => Err(MathParseError::MalformedCommand("unexpected end of input")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_command(&mut self) -> Result<MathNode, MathParseError> {
|
||||
let mut name = String::new();
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
name.push(ch);
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if name.is_empty() {
|
||||
// `\$`, `\{` … — an escaped literal.
|
||||
return match self.bump() {
|
||||
Some(ch) => Ok(MathNode::Char(ch)),
|
||||
None => Err(MathParseError::MalformedCommand("trailing backslash")),
|
||||
};
|
||||
}
|
||||
if name == "frac" {
|
||||
let num = self.parse_required_group("\\frac numerator")?;
|
||||
let den = self.parse_required_group("\\frac denominator")?;
|
||||
return Ok(MathNode::Fraction {
|
||||
num: Box::new(num),
|
||||
den: Box::new(den),
|
||||
});
|
||||
}
|
||||
if let Some((_, ch)) = GREEK.iter().find(|(n, _)| *n == name) {
|
||||
return Ok(MathNode::Char(*ch));
|
||||
}
|
||||
Err(MathParseError::UnknownCommand(name))
|
||||
}
|
||||
|
||||
/// A `{…}` argument, skipping leading whitespace.
|
||||
fn parse_required_group(&mut self, what: &'static str) -> Result<MathNode, MathParseError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
Some('{') => {
|
||||
self.pos += 1;
|
||||
Ok(MathNode::Group(self.parse_sequence(Some('}'))?))
|
||||
}
|
||||
_ => Err(MathParseError::MalformedCommand(what)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach `^`/`_` to `base`, in either order, at most one each.
|
||||
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
|
||||
let mut sub: Option<Box<MathNode>> = None;
|
||||
let mut sup: Option<Box<MathNode>> = None;
|
||||
loop {
|
||||
// Whitespace is insignificant, here too: without this skip
|
||||
// `x^2 _i` builds a nested Script instead of one merged double
|
||||
// script (drawing the subscript displaced right by the
|
||||
// superscript's width), and `x^2 ^3` parses where TeX errors.
|
||||
let resume = self.pos;
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
let Some(marker @ ('^' | '_')) = self.peek() else {
|
||||
self.pos = resume;
|
||||
break;
|
||||
};
|
||||
self.pos += 1;
|
||||
let slot = self.parse_script_operand()?;
|
||||
match marker {
|
||||
'^' if sup.is_some() => {
|
||||
return Err(MathParseError::MalformedScript("double superscript"));
|
||||
}
|
||||
'_' if sub.is_some() => {
|
||||
return Err(MathParseError::MalformedScript("double subscript"));
|
||||
}
|
||||
'^' => sup = Some(Box::new(slot)),
|
||||
_ => sub = Some(Box::new(slot)),
|
||||
}
|
||||
}
|
||||
Ok(MathNode::Script {
|
||||
base: Box::new(base),
|
||||
sub,
|
||||
sup,
|
||||
})
|
||||
}
|
||||
|
||||
/// The operand of `^`/`_`: a braced group, or exactly one atom.
|
||||
fn parse_script_operand(&mut self) -> Result<MathNode, MathParseError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None | Some('^' | '_' | '}') => {
|
||||
Err(MathParseError::MalformedScript("script with no operand"))
|
||||
}
|
||||
Some(_) => self.parse_atom(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One detected inline span, as byte offsets into the scanned line.
|
||||
///
|
||||
/// `start`/`end` bracket the WHOLE span including both `$` delimiters, which
|
||||
/// is what Q#MS4 suppresses; [`Self::interior`] is what the parser sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MathSpan {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
impl MathSpan {
|
||||
/// Byte range of the math source between the delimiters.
|
||||
#[must_use]
|
||||
pub fn interior(self) -> std::ops::Range<usize> {
|
||||
self.start + 1..self.end - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Find inline `$…$` spans in ONE line (Q#MS3).
|
||||
///
|
||||
/// Spans never cross a newline: chunking is per line and the visible slice is
|
||||
/// line-ranged, so single-line spans are what keep visible-slice-scoped
|
||||
/// scanning stable under scroll. Callers pass one line at a time.
|
||||
///
|
||||
/// Currency guards are mandatory, not a refinement (framing F5). Without
|
||||
/// them `prices are $5 and $6 today` pairs the two `$` and renders `5 and `
|
||||
/// as math — in exactly the grammar-less prose buffers this scanner targets.
|
||||
/// Pandoc's rule:
|
||||
///
|
||||
/// - an opening `$` must be followed by a non-space;
|
||||
/// - a closing `$` must be preceded by a non-space and not followed by a digit;
|
||||
/// - `\$` is an escape and neither opens nor closes.
|
||||
#[must_use]
|
||||
pub fn detect_math_spans(line: &str) -> Vec<MathSpan> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut spans = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut open: Option<usize> = None;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\\' {
|
||||
// Skip the escaped byte: `\$` is literal, so it can neither open
|
||||
// nor close. Stepping two also stops `\\$` from being read as an
|
||||
// escape of the dollar.
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if bytes[i] != b'$' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if bytes.get(i + 1) == Some(&b'$') {
|
||||
// `$$` is display math, which this slice defers. It is NOT two
|
||||
// inline delimiters: reading it that way makes `$$x$$` match the
|
||||
// inner `$x$`, which parses, so the span would half-render as
|
||||
// math with a stray `$` on each side. Acceptance 15 requires it
|
||||
// to degrade to source, so `$$` is opaque — it neither opens nor
|
||||
// closes, and abandons any pending opener.
|
||||
i += 2;
|
||||
open = None;
|
||||
continue;
|
||||
}
|
||||
match open {
|
||||
None => {
|
||||
// Opener: next byte must exist and be a non-space.
|
||||
let opens = bytes
|
||||
.get(i + 1)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$');
|
||||
if opens {
|
||||
open = Some(i);
|
||||
}
|
||||
}
|
||||
Some(start) => {
|
||||
let prev_ok = i > start + 1 && !bytes[i - 1].is_ascii_whitespace();
|
||||
let next_ok = bytes.get(i + 1).is_none_or(|b| !b.is_ascii_digit());
|
||||
if prev_ok && next_ok {
|
||||
spans.push(MathSpan { start, end: i + 1 });
|
||||
open = None;
|
||||
} else if !prev_ok {
|
||||
// `$foo $` — the closer is disqualified by the space
|
||||
// before it. Treat this `$` as a fresh opener candidate
|
||||
// rather than letting the span run to the next one.
|
||||
open = bytes
|
||||
.get(i + 1)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$')
|
||||
.then_some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ch(c: char) -> MathNode {
|
||||
MathNode::Char(c)
|
||||
}
|
||||
|
||||
fn group(nodes: Vec<MathNode>) -> MathNode {
|
||||
MathNode::Group(nodes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_finds_inline_spans() {
|
||||
assert_eq!(
|
||||
detect_math_spans("$x^2$"),
|
||||
vec![MathSpan { start: 0, end: 5 }]
|
||||
);
|
||||
let two = detect_math_spans("$a$ and $b$");
|
||||
assert_eq!(two.len(), 2, "{two:?}");
|
||||
let line = "before $x^2$ after";
|
||||
let span = detect_math_spans(line)[0];
|
||||
assert_eq!(&line[span.start..span.end], "$x^2$");
|
||||
assert_eq!(&line[span.interior()], "x^2");
|
||||
}
|
||||
|
||||
/// Framing F5 / acceptance 2 — the case rev 1's rule would have
|
||||
/// mis-rendered as math over "5 and ".
|
||||
#[test]
|
||||
fn currency_guards_reject_prose_dollars() {
|
||||
assert!(detect_math_spans("Price: $5.00").is_empty());
|
||||
assert!(
|
||||
detect_math_spans("prices are $5 and $6 today").is_empty(),
|
||||
"a digit after the closer disqualifies it"
|
||||
);
|
||||
assert!(
|
||||
detect_math_spans("$ x $").is_empty(),
|
||||
"space after the opener disqualifies it"
|
||||
);
|
||||
assert!(
|
||||
detect_math_spans("costs $5 or $6").is_empty(),
|
||||
"both guards together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_dollars_neither_open_nor_close() {
|
||||
assert!(detect_math_spans(r"\$5 and \$6").is_empty());
|
||||
// An escaped dollar inside a span does not close it.
|
||||
let line = r"$a\$b$";
|
||||
let spans = detect_math_spans(line);
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(&line[spans[0].start..spans[0].end], r"$a\$b$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpaired_dollar_yields_nothing() {
|
||||
assert!(detect_math_spans("$x").is_empty());
|
||||
assert!(detect_math_spans("x$").is_empty());
|
||||
// Q#MS3: spans never cross a newline. Callers scan per line, so a
|
||||
// partner on the next line is simply not visible to this call.
|
||||
assert!(detect_math_spans("$x").is_empty());
|
||||
assert!(detect_math_spans("y$").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_display_delimiters_degrade_rather_than_half_match() {
|
||||
// Acceptance 15. `$$` is opaque, so display math yields NO span and
|
||||
// falls through to source. Asserting emptiness rather than "any span
|
||||
// found must fail to parse" matters: the interior of the inner `$x$`
|
||||
// parses perfectly well, so the weaker form passed vacuously while
|
||||
// `$$x$$` half-rendered with a stray `$` on each side.
|
||||
assert!(detect_math_spans("$$").is_empty());
|
||||
assert!(
|
||||
detect_math_spans("$$x$$").is_empty(),
|
||||
"display math must not match the inner $x$"
|
||||
);
|
||||
assert!(detect_math_spans(r"$$\frac{a}{b}$$").is_empty());
|
||||
// A real inline span beside display math is still found.
|
||||
let mixed = detect_math_spans("$a$ then $$b$$");
|
||||
assert_eq!(mixed.len(), 1, "{mixed:?}");
|
||||
}
|
||||
|
||||
/// Round-3 F6 — a DOCUMENTED casualty of the `$$`-opaque rule, not a
|
||||
/// guard failure: in `$a$$b$` the first span's legitimate closer is
|
||||
/// immediately followed by the second span's opener, the lookahead
|
||||
/// reads that pair as display-math `$$`, and the pending opener is
|
||||
/// abandoned. Adjacent inline spans therefore need a separating
|
||||
/// character. Pandoc finds two spans here; this scanner deliberately
|
||||
/// finds none, because distinguishing `$a$$b$` from `$$x$$` requires
|
||||
/// closer-context the framing's opaque-`$$` rule gave away.
|
||||
#[test]
|
||||
fn adjacent_inline_spans_are_eaten_by_the_display_guard() {
|
||||
assert!(detect_math_spans("$a$$b$").is_empty());
|
||||
assert!(detect_math_spans("$x^2$$y^2$").is_empty());
|
||||
// One separating character restores both spans.
|
||||
assert_eq!(detect_math_spans("$a$ $b$").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_before_a_script_marker_still_merges_the_scripts() {
|
||||
// F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built
|
||||
// a NESTED script and drew the subscript displaced right.
|
||||
assert_eq!(parse("x^2 _i"), parse("x^2_i"));
|
||||
assert_eq!(parse("x _i ^2"), parse("x_i^2"));
|
||||
// And a doubled script is still an error with space between.
|
||||
assert!(matches!(
|
||||
parse("x^2 ^3"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_greek_seed_uses_tex_letter_forms() {
|
||||
// F5: TeX's \epsilon is lunate and \phi is the symbol form; the
|
||||
// U+03B5 / U+03C6 glyphs are \varepsilon / \varphi.
|
||||
assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')])));
|
||||
assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_characters_parse_in_order() {
|
||||
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_attach_to_the_preceding_atom() {
|
||||
// Framing acceptance 1.
|
||||
assert_eq!(
|
||||
parse("x^2"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: None,
|
||||
sup: Some(Box::new(ch('2'))),
|
||||
}]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse("x_i"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: Some(Box::new(ch('i'))),
|
||||
sup: None,
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_scripts_parse_in_either_order() {
|
||||
let expected = MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: Some(Box::new(ch('i'))),
|
||||
sup: Some(Box::new(ch('2'))),
|
||||
};
|
||||
assert_eq!(parse("x_i^2"), Ok(group(vec![expected.clone()])));
|
||||
assert_eq!(parse("x^2_i"), Ok(group(vec![expected])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braced_script_operands_group() {
|
||||
assert_eq!(
|
||||
parse("x^{i+1}"),
|
||||
Ok(group(vec![MathNode::Script {
|
||||
base: Box::new(ch('x')),
|
||||
sub: None,
|
||||
sup: Some(Box::new(group(vec![ch('i'), ch('+'), ch('1')]))),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fraction_takes_two_braced_arguments() {
|
||||
assert_eq!(
|
||||
parse(r"\frac{a}{b}"),
|
||||
Ok(group(vec![MathNode::Fraction {
|
||||
num: Box::new(group(vec![ch('a')])),
|
||||
den: Box::new(group(vec![ch('b')])),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_nest() {
|
||||
// Framing acceptance 1 and 12's over-tall candidate.
|
||||
let inner = MathNode::Fraction {
|
||||
num: Box::new(group(vec![ch('a')])),
|
||||
den: Box::new(group(vec![ch('b')])),
|
||||
};
|
||||
assert_eq!(
|
||||
parse(r"\frac{\frac{a}{b}}{c}"),
|
||||
Ok(group(vec![MathNode::Fraction {
|
||||
num: Box::new(group(vec![inner])),
|
||||
den: Box::new(group(vec![ch('c')])),
|
||||
}]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greek_seed_resolves_to_codepoints_not_markup() {
|
||||
assert_eq!(parse(r"\alpha"), Ok(group(vec![ch('α')])));
|
||||
assert_eq!(parse(r"\Gamma"), Ok(group(vec![ch('Γ')])));
|
||||
// The AST stays semantic: no italic mapping here (that is layout's,
|
||||
// per this module's header and Q#MS2).
|
||||
assert_eq!(parse(r"\alpha x"), Ok(group(vec![ch('α'), ch('x')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_is_insignificant() {
|
||||
assert_eq!(parse("x ^ 2"), parse("x^2"));
|
||||
assert_eq!(parse(r"\frac {a} {b}"), parse(r"\frac{a}{b}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_violations_are_errors_not_panics() {
|
||||
// Framing acceptance 1 and 9.
|
||||
assert_eq!(parse(""), Err(MathParseError::Empty));
|
||||
assert_eq!(parse(" "), Err(MathParseError::Empty));
|
||||
assert_eq!(parse("{a"), Err(MathParseError::UnbalancedBrace));
|
||||
assert_eq!(parse("a}"), Err(MathParseError::UnbalancedBrace));
|
||||
assert_eq!(
|
||||
parse(r"\sqrt{2}"),
|
||||
Err(MathParseError::UnknownCommand("sqrt".to_owned()))
|
||||
);
|
||||
assert!(matches!(
|
||||
parse(r"\frac{a}"),
|
||||
Err(MathParseError::MalformedCommand(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(r"\frac a b"),
|
||||
Err(MathParseError::MalformedCommand(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("^2"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("x^"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("x^2^3"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interior_dollar_is_rejected_so_display_math_degrades() {
|
||||
// Framing acceptance 15: `$$x$$` reaches us as the interior `$x$`
|
||||
// (outer delimiters stripped), and must degrade to source rather
|
||||
// than half-render. The empty-span path covers `$$` alone.
|
||||
assert_eq!(parse("$x$"), Err(MathParseError::UnexpectedDollar));
|
||||
assert_eq!(parse(""), Err(MathParseError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_literals_survive_as_characters() {
|
||||
assert_eq!(parse(r"\{"), Ok(group(vec![ch('{')])));
|
||||
assert_eq!(parse(r"\$"), Ok(group(vec![ch('$')])));
|
||||
}
|
||||
}
|
||||
|
|
@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel;
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fs::{
|
||||
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking,
|
||||
stat_blocking,
|
||||
FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
|
||||
remove_blocking, rename_blocking, stat_blocking,
|
||||
};
|
||||
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
|
||||
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
|
||||
|
|
@ -220,9 +220,10 @@ enum ReplyKind {
|
|||
/// T M4.1.
|
||||
Parse { duration_ms: u64 },
|
||||
/// `dispatch_fs_read_dir` completed; payload is the directory
|
||||
/// listing. The Vec is `Serialize` so it crosses the bus
|
||||
/// directly --- no side handoff like parse trees need. T M8.1.
|
||||
ReadDir(Vec<FsDirEntry>),
|
||||
/// listing. The listing is `Serialize` so it crosses the bus
|
||||
/// directly --- no side handoff like parse trees need. T M8.1; its
|
||||
/// per-entry error channel is dired Q#DR6.
|
||||
ReadDir(FsDirListing),
|
||||
/// `dispatch_fs_stat` completed; payload is the per-path
|
||||
/// metadata. T M8.1.
|
||||
Stat(FsDirEntry),
|
||||
|
|
@ -266,10 +267,11 @@ pub enum JobResult {
|
|||
duration_ms: u64,
|
||||
},
|
||||
/// `dispatch_fs_read_dir` produced a directory listing. The
|
||||
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a
|
||||
/// per-entry table when `_take_result` consumes the result.
|
||||
/// T M8.1.
|
||||
ReadDir(Vec<FsDirEntry>),
|
||||
/// Lua boundary in [`crate::lua_bindings`] turns the entries into
|
||||
/// per-entry tables when `_take_result` consumes the result, and
|
||||
/// keys the result *shape* on whether the listing carries a
|
||||
/// per-entry error channel. T M8.1 / dired Q#DR6.
|
||||
ReadDir(FsDirListing),
|
||||
/// `dispatch_fs_stat` produced metadata for a single path. The
|
||||
/// Lua boundary turns the [`FsDirEntry`] into the same table
|
||||
/// shape `read_dir` entries use. T M8.1.
|
||||
|
|
@ -832,11 +834,21 @@ impl AsyncRuntime {
|
|||
/// `lstat`-style metadata. Polls cancel every batch of
|
||||
/// entries; supersede follows the same rule as the other
|
||||
/// dispatchers. T M8.1.
|
||||
pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
|
||||
///
|
||||
/// `tolerance` selects the per-entry contract (dired Q#DR6):
|
||||
/// [`ReadDirTolerance::Fatal`] is the original all-or-nothing
|
||||
/// listing, [`ReadDirTolerance::PerEntry`] carries per-entry
|
||||
/// failures alongside the entries that survived.
|
||||
pub fn dispatch_fs_read_dir(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
tolerance: ReadDirTolerance,
|
||||
supersede: Option<&str>,
|
||||
) -> JobId {
|
||||
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
|
||||
let bus = self.workers.clone();
|
||||
self.pool.dispatch(move |_pool| {
|
||||
let kind = run_fs_read_dir(&cancel, &path);
|
||||
let kind = run_fs_read_dir(&cancel, &path, tolerance);
|
||||
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
|
||||
});
|
||||
id
|
||||
|
|
@ -1038,8 +1050,8 @@ impl AsyncRuntime {
|
|||
ReplyKind::Parse { duration_ms } => {
|
||||
PendingState::Complete(JobResult::Parse { duration_ms })
|
||||
}
|
||||
ReplyKind::ReadDir(entries) => {
|
||||
PendingState::Complete(JobResult::ReadDir(entries))
|
||||
ReplyKind::ReadDir(listing) => {
|
||||
PendingState::Complete(JobResult::ReadDir(listing))
|
||||
}
|
||||
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
|
||||
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
|
||||
|
|
@ -1295,9 +1307,13 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
|
|||
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
|
||||
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
|
||||
/// human-readable message attached.
|
||||
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind {
|
||||
match read_dir_blocking(path, cancel) {
|
||||
Ok(entries) => ReplyKind::ReadDir(entries),
|
||||
fn run_fs_read_dir(
|
||||
cancel: &CancellationToken,
|
||||
path: &Path,
|
||||
tolerance: ReadDirTolerance,
|
||||
) -> ReplyKind {
|
||||
match read_dir_blocking(path, cancel, tolerance) {
|
||||
Ok(listing) => ReplyKind::ReadDir(listing),
|
||||
Err(FsError::Cancelled) => ReplyKind::Cancelled,
|
||||
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
|
||||
ReplyKind::Error(e.to_string())
|
||||
|
|
|
|||
|
|
@ -2790,6 +2790,88 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Deterministic reduction of a `rope_matches_crdt_projection_
|
||||
/// after_arbitrary_edits` failure found by raising the case
|
||||
/// count (`PROPTEST_CASES=2000`) on `main` @ `e745068`.
|
||||
///
|
||||
/// **What happens.** Replacing a byte range with *identical*
|
||||
/// bytes is a textual no-op but a real CRDT operation (a delete
|
||||
/// plus an insert). Undoing it therefore advances the CRDT
|
||||
/// version while leaving the materialized text unchanged, so
|
||||
/// `undo_crdt_mode` derives an EMPTY replacement edit — and
|
||||
/// still attaches the `crdt_op` that `crdt.undo()` produced.
|
||||
/// That trips the proptest's `crdt_op` shape invariant, "a
|
||||
/// no-op edit must have `crdt_op = None`".
|
||||
///
|
||||
/// **What was verified about the consequences**, so the next
|
||||
/// reader does not have to redo it:
|
||||
///
|
||||
/// * content stays correct — rope and CRDT projection agree
|
||||
/// before and after (asserted below);
|
||||
/// * replicas stay converged — both `crdt_op` consumers
|
||||
/// (`EditorCore::queue_daemon_origin_crdt_op` and the remote-op
|
||||
/// path) read `edit.crdt_op` unconditionally and do **not**
|
||||
/// short-circuit on an empty range, so the op is broadcast;
|
||||
/// * the cursor does not jump — `EditorCore::undo` only clamps
|
||||
/// to buffer length and never seeks `edit.range.start`.
|
||||
///
|
||||
/// **The open question** is therefore whether the *invariant* is
|
||||
/// simply mis-scoped rather than the behavior being wrong. It
|
||||
/// was written for the FORWARD `apply_edit` short-circuit, which
|
||||
/// returns before ever producing an op; CRDT-mode undo/redo
|
||||
/// never reach that path. One artifact is genuinely arbitrary
|
||||
/// either way: `derive_replacement_edit` reports the empty range
|
||||
/// at the buffer END rather than at the edit site.
|
||||
///
|
||||
/// Ignored, not deleted: it documents a real, reproducible
|
||||
/// asymmetry that nothing else on `main` records, and un-ignoring
|
||||
/// it is the first step of whichever resolution wins.
|
||||
#[test]
|
||||
#[ignore = "known pre-existing main behavior; see the doc comment \
|
||||
for the verified consequences and the open question"]
|
||||
fn crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op() {
|
||||
let mut buffer =
|
||||
Buffer::new_with_crdt(BufferId::next(), "*identity-undo*", 1).expect("crdt");
|
||||
buffer
|
||||
.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"hello",
|
||||
})
|
||||
.expect("seed insert");
|
||||
|
||||
// Replace one byte with the SAME byte.
|
||||
let forward = buffer
|
||||
.apply_edit(EditOp::Replace {
|
||||
range: Range::new(1, 2),
|
||||
bytes: b"e",
|
||||
})
|
||||
.expect("identity replace");
|
||||
assert_eq!(forward.range, Range::new(1, 2));
|
||||
assert_eq!(forward.inserted_len, 1);
|
||||
|
||||
let undone = buffer.undo().expect("undo");
|
||||
assert!(
|
||||
undone.range.is_empty() && undone.inserted_len == 0,
|
||||
"the undo produced no textual change: {:?}/{}",
|
||||
undone.range,
|
||||
undone.inserted_len
|
||||
);
|
||||
assert!(
|
||||
undone.crdt_op.is_some(),
|
||||
"…yet it carries a version-advancing CRDT op — the invariant \
|
||||
the proptest trips on"
|
||||
);
|
||||
|
||||
// Content is unharmed in both projections.
|
||||
let mut bytes = vec![0u8; buffer.len() as usize];
|
||||
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
|
||||
assert_eq!(String::from_utf8(bytes).expect("utf8"), "hello");
|
||||
assert_eq!(
|
||||
buffer.crdt_state().expect("crdt").materialize_string(),
|
||||
"hello"
|
||||
);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
// Smaller proptest case count than the default (64) to keep
|
||||
// CI overhead modest; the per-op invariant check is the
|
||||
|
|
|
|||
132
src/editor.rs
132
src/editor.rs
|
|
@ -525,6 +525,17 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/window.lua"),
|
||||
)
|
||||
.expect("load window builtin chunk");
|
||||
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
|
||||
// whose `window.panel-height` setting a `display = "panel"`
|
||||
// listing resolves, and after the pre-runtime tables it drives
|
||||
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
|
||||
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/dired.lua"),
|
||||
include_str!("../builtin/runtime/dired.lua"),
|
||||
)
|
||||
.expect("load dired builtin chunk");
|
||||
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
|
||||
// compile.lua must load AFTER lsp.lua. It takes over
|
||||
// `M-g n` / `M-g p` for the unified error dispatchers, and
|
||||
|
|
@ -978,19 +989,28 @@ impl EditorState {
|
|||
.get(&frontend_id)
|
||||
.is_some_and(|state| state.terminal_escape);
|
||||
if let Some(view_key) = terminal_key {
|
||||
// Q#TC4: the escape chord is per terminal, resolved through
|
||||
// `terminal.escape-key` and cached on the session so this
|
||||
// hot path parses at most once per (terminal, config epoch).
|
||||
let escape_chord = self.terminal_escape_chord(view_key.buffer_id);
|
||||
if escaped {
|
||||
self.dispatchers
|
||||
.entry(frontend_id)
|
||||
.or_default()
|
||||
.terminal_escape = false;
|
||||
if chord.is_some_and(is_terminal_escape_chord) {
|
||||
if chord == Some(escape_chord) {
|
||||
// Q#TC4b: repeating the escape sends THAT chord to the
|
||||
// child, not a hardcoded ETX. With a configured escape
|
||||
// of `C-x`, sending Ctrl-C here would both surprise the
|
||||
// user and make literal Ctrl-X unreachable, since the
|
||||
// first press is always consumed as the escape.
|
||||
self.claim_terminal_controller(view_key);
|
||||
self.send_terminal_bytes(view_key.buffer_id, &[0x03]);
|
||||
self.send_terminal_escape_literal(view_key, escape_chord);
|
||||
return;
|
||||
}
|
||||
// The post-escape key starts a fresh ordinary sequence below.
|
||||
} else if !dispatcher_pending {
|
||||
if chord.is_some_and(is_terminal_escape_chord) {
|
||||
if chord == Some(escape_chord) {
|
||||
let state = self.dispatchers.entry(frontend_id).or_default();
|
||||
state.terminal_escape = true;
|
||||
state.dispatcher = KeyDispatcher::new();
|
||||
|
|
@ -1106,6 +1126,54 @@ impl EditorState {
|
|||
.then_some(key)
|
||||
}
|
||||
|
||||
/// This terminal's effective escape chord (Q#TC4).
|
||||
///
|
||||
/// Resolution is `get("terminal.escape-key", terminal_buffer)` —
|
||||
/// buffer-local, then global, then default — because unlike the two
|
||||
/// open-time settings this one is read while the terminal exists, so
|
||||
/// a per-terminal escape is expressible and supported (Q#TC2b).
|
||||
///
|
||||
/// The parse and the once-per-terminal invalid-value report both live
|
||||
/// in [`crate::terminal::TerminalManager::escape_chord`]; this method
|
||||
/// only supplies the resolved spelling and the epoch that keys the
|
||||
/// cache, and surfaces any report through the status line — the same
|
||||
/// channel `send_terminal_bytes` uses for terminal failures.
|
||||
fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord {
|
||||
let lua = self.lua_host.lua();
|
||||
let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch(
|
||||
lua,
|
||||
"terminal.escape-key",
|
||||
Some(buffer_id),
|
||||
crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY,
|
||||
);
|
||||
let (chord, report) = self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.escape_chord(buffer_id, epoch, &spelling);
|
||||
if let Some(message) = report {
|
||||
self.core.borrow_mut().status = message;
|
||||
}
|
||||
chord
|
||||
}
|
||||
|
||||
/// Send the configured escape chord to the child as literal input
|
||||
/// (Q#TC4b), through the same encoder ordinary keys use so it
|
||||
/// inherits application-cursor and modifier handling.
|
||||
fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) {
|
||||
let event = KeyEvent::new(chord.code, chord.modifiers);
|
||||
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else {
|
||||
return;
|
||||
};
|
||||
let modes = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(key)
|
||||
.unwrap_or_default();
|
||||
if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) {
|
||||
self.send_terminal_bytes(key.buffer_id, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_terminal_controller(&self, key: TerminalViewKey) {
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
let _ = manager.register_view(key);
|
||||
|
|
@ -4410,10 +4478,6 @@ fn sanitize_single_line(s: &str) -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn is_terminal_escape_chord(chord: Chord) -> bool {
|
||||
chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL
|
||||
}
|
||||
|
||||
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
|
||||
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
|
||||
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);
|
||||
|
|
@ -5706,17 +5770,25 @@ mod tests {
|
|||
|
||||
// ---- T M2.11 acceptance --------------------------------------------------
|
||||
|
||||
/// Every chord in the default global keymap must round-trip through
|
||||
/// Every chord in the default keymap must round-trip through
|
||||
/// `pmacs.describe.key`: returning a non-nil table whose `command`
|
||||
/// matches the binding the keymap stack stores.
|
||||
///
|
||||
/// `describe.key` resolves against the **effective context**
|
||||
/// (buffer-local → mode → global), so a mode-scoped default is
|
||||
/// asserted with a buffer that carries that mode rather than
|
||||
/// context-free. Dired is the first builtin to bind mode-scoped keys
|
||||
/// (#129's first non-detection consumer), and without the mode in
|
||||
/// place its `n` / `p` / `g` correctly resolve to nothing.
|
||||
#[test]
|
||||
fn describe_key_identifies_every_default_binding() {
|
||||
use crate::keymap_stack::Scope;
|
||||
let s = EditorState::new();
|
||||
let kms = s.lua_host.keymaps().borrow();
|
||||
let bindings: Vec<(String, String)> = kms
|
||||
let bindings: Vec<(Scope, String, String)> = kms
|
||||
.iter_all()
|
||||
.into_iter()
|
||||
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command))
|
||||
.map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command))
|
||||
.collect();
|
||||
drop(kms);
|
||||
// Sanity floor: the default keymap binds at least the M1 surface.
|
||||
|
|
@ -5725,18 +5797,50 @@ mod tests {
|
|||
"default keymap unexpectedly small: {} bindings",
|
||||
bindings.len()
|
||||
);
|
||||
let modes: usize = bindings
|
||||
.iter()
|
||||
.filter(|(scope, _, _)| matches!(scope, Scope::Mode(_)))
|
||||
.count();
|
||||
assert!(
|
||||
modes >= 1,
|
||||
"a mode-scoped default is expected since dired Stage 1; \
|
||||
found none, so the mode arm below asserts nothing"
|
||||
);
|
||||
|
||||
for (seq, expected_command) in &bindings {
|
||||
for (scope, seq, expected_command) in &bindings {
|
||||
let mode = match scope {
|
||||
Scope::Mode(name) => Some(name.clone()),
|
||||
// No buffer-scoped defaults exist; a future one would
|
||||
// need its own buffer context here.
|
||||
Scope::Buffer(_) => continue,
|
||||
Scope::Global => None,
|
||||
};
|
||||
// Set the context explicitly on EVERY iteration, including
|
||||
// the global one: a mode left over from a previous iteration
|
||||
// legitimately shadows a global binding of the same chord
|
||||
// (dired's mode-scoped `RET` shadows
|
||||
// `edit.newline-and-indent`, which is the point of the
|
||||
// mode), so a leaked mode would make this assert the wrong
|
||||
// thing.
|
||||
let context = match &mode {
|
||||
Some(name) => {
|
||||
format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ")
|
||||
}
|
||||
None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(),
|
||||
};
|
||||
let script = format!(
|
||||
"local r = pmacs.describe.key({seq:?}); \
|
||||
"{context}local r = pmacs.describe.key({seq:?}); \
|
||||
if r == nil then return 'nil' else return r.command end"
|
||||
);
|
||||
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
|
||||
panic!("describe.key({seq}) raised: {e}");
|
||||
});
|
||||
assert_eq!(
|
||||
&got, expected_command,
|
||||
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
|
||||
&got,
|
||||
expected_command,
|
||||
"describe.key for {seq:?} (scope {}) returned {got:?}, \
|
||||
expected {expected_command:?}",
|
||||
scope.render()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4787,7 +4787,14 @@ fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
|
|||
/// path's on-disk identity. Every step is best-effort — if `$HOME`
|
||||
/// or the cwd is unavailable the path is returned as far as it could
|
||||
/// be resolved rather than panicking.
|
||||
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
|
||||
///
|
||||
/// Public because dired needs the *same* canonical form the buffer
|
||||
/// registry keys on (Q#DR2): its buffer-per-directory naming and
|
||||
/// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror
|
||||
/// of this function would be a second implementation of a canonical
|
||||
/// form — the tab-width-constants class in miniature. `pmacs.path
|
||||
/// .canonicalize` is this function, not a copy of it.
|
||||
pub fn normalize_buffer_path(path: PathBuf) -> PathBuf {
|
||||
let path = expand_tilde(path);
|
||||
let abs = if path.is_absolute() {
|
||||
path
|
||||
|
|
|
|||
329
src/fs.rs
329
src/fs.rs
|
|
@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
|
|||
/// directories.
|
||||
const READDIR_CANCEL_POLL_EVERY: usize = 32;
|
||||
|
||||
/// How many *consecutive* `readdir` iterator errors a tolerant listing
|
||||
/// records before giving up and failing (dired Q#DR6).
|
||||
///
|
||||
/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an
|
||||
/// `Err`: a directory pulled out from under a stalled network mount can
|
||||
/// keep producing them. Tolerant mode records-and-continues, so without
|
||||
/// a bound that is an unbounded error vector on a worker thread.
|
||||
///
|
||||
/// Cancellation is **not** an adequate backstop here, which is the
|
||||
/// reason this constant exists rather than a comment saying it is: a
|
||||
/// dired listing carries no supersede key and nothing cancels it, so the
|
||||
/// only thing that would stop the loop is the directory itself. A
|
||||
/// directory whose iterator produces nothing but errors has no partial
|
||||
/// answer worth rendering, so the listing fails with the last error the
|
||||
/// way an unopenable directory does.
|
||||
///
|
||||
/// Deliberately untested: forcing a real `readdir` to yield errors
|
||||
/// repeatedly is not portable, and faking it would need the walk to be
|
||||
/// generic over its iterator — a refactor with no other consumer. The
|
||||
/// counter resets on any entry that materializes.
|
||||
const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024;
|
||||
|
||||
/// One directory entry as returned by [`read_dir_blocking`].
|
||||
///
|
||||
/// The shape is what `dired` / `magit-class` / `outline-class`
|
||||
|
|
@ -116,6 +138,59 @@ impl FsEntryKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
|
||||
///
|
||||
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
|
||||
/// failed the *entire* listing, which makes a plain refresh of a busy
|
||||
/// directory (`/tmp`, a build tree) fail outright. The module doc used
|
||||
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
|
||||
/// cannot be: the primitive hands Lua one structured error and no
|
||||
/// partial vec, so there is nothing to be tolerant *with*.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReadDirTolerance {
|
||||
/// Any per-entry failure fails the whole listing. The original
|
||||
/// M8.1 contract, and still the default at every Lua call site
|
||||
/// that does not opt in.
|
||||
Fatal,
|
||||
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
|
||||
/// enumeration continues. A failure on the *parent* `read_dir`
|
||||
/// stays fatal (a directory you cannot open has no partial
|
||||
/// answer), and so does a non-UTF-8 entry **name** --- see
|
||||
/// [`FsError::NonUtf8Path`].
|
||||
PerEntry,
|
||||
}
|
||||
|
||||
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
|
||||
///
|
||||
/// `name` is optional because a per-entry `readdir` *iterator* error
|
||||
/// has no filename to report: the entry never materialized, and the
|
||||
/// underlying error is about the parent directory. Every other arm has
|
||||
/// an entry in hand and names it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FsDirEntryError {
|
||||
/// Basename of the entry that failed, when one is known.
|
||||
pub name: Option<String>,
|
||||
/// Rendered failure, already formatted for display.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// What [`read_dir_blocking`] returns: the entries it could read, plus
|
||||
/// the per-entry failures when the caller asked to tolerate them.
|
||||
///
|
||||
/// `errors` is `None` under [`ReadDirTolerance::Fatal`] and `Some`
|
||||
/// (possibly empty) under [`ReadDirTolerance::PerEntry`]. The
|
||||
/// distinction is load-bearing at the Lua boundary: it is what selects
|
||||
/// the bare-array result shape the M8.1 surface promises from the
|
||||
/// `{ entries = …, errors = … }` shape the tolerant opt returns, so the
|
||||
/// conversion never has to look the job back up.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FsDirListing {
|
||||
/// One entry per readable child, in filesystem iteration order.
|
||||
pub entries: Vec<FsDirEntry>,
|
||||
/// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode.
|
||||
pub errors: Option<Vec<FsDirEntryError>>,
|
||||
}
|
||||
|
||||
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
|
||||
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
|
||||
///
|
||||
|
|
@ -192,50 +267,100 @@ pub enum FsError {
|
|||
/// `to_string_lossy` would have mangled dired/wdired round-trips).
|
||||
///
|
||||
/// Errors on the *parent* `read_dir` call surface as
|
||||
/// [`FsError::Io`]. Errors on individual entries (a single broken
|
||||
/// symlink, a permission-denied stat) currently propagate the same
|
||||
/// way --- the cleanest behavior at this primitive layer is "fail
|
||||
/// fast and let the caller decide whether a partial listing is
|
||||
/// acceptable"; dired-class will likely want a per-entry-tolerant
|
||||
/// wrapper but that's the package's job, not the primitive's.
|
||||
/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
|
||||
/// open has no partial answer.
|
||||
///
|
||||
/// Errors on individual entries (a permission-denied `lstat`, a child
|
||||
/// unlinked between `readdir` and `lstat`, a `readlink` failure, a
|
||||
/// non-UTF-8 symlink target) are governed by `tolerance`. Under
|
||||
/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is
|
||||
/// the M8.1 contract every existing caller relies on; under
|
||||
/// [`ReadDirTolerance::PerEntry`] they land in
|
||||
/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6).
|
||||
///
|
||||
/// A non-UTF-8 entry **name** is fatal in both modes. That is not a
|
||||
/// listing problem but a path-representation one: [`FsDirEntry::name`]
|
||||
/// is a `String` and every `pmacs.fs` op takes a `String` path, so a
|
||||
/// tolerantly-rendered non-UTF-8 name would be a name the caller could
|
||||
/// not pass back through `rename`. Byte-preserving paths are the named
|
||||
/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target*
|
||||
/// differs in kind --- the entry's own name is fine and nothing needs
|
||||
/// to round-trip the target --- so it joins the per-entry channel.
|
||||
pub fn read_dir_blocking(
|
||||
path: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<FsDirEntry>, FsError> {
|
||||
tolerance: ReadDirTolerance,
|
||||
) -> Result<FsDirListing, FsError> {
|
||||
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let mut out: Vec<FsDirEntry> = Vec::new();
|
||||
let mut errors: Option<Vec<FsDirEntryError>> =
|
||||
matches!(tolerance, ReadDirTolerance::PerEntry).then(Vec::new);
|
||||
let parent_str = path.display().to_string();
|
||||
let mut consecutive_entry_errors = 0usize;
|
||||
for (i, entry_result) in iter.enumerate() {
|
||||
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
let entry = entry_result.map_err(|source| FsError::Io {
|
||||
path: parent_str.clone(),
|
||||
source,
|
||||
})?;
|
||||
let entry = match entry_result {
|
||||
Ok(entry) => entry,
|
||||
Err(source) => {
|
||||
// R2-2: the entry never materialized, so there is no
|
||||
// name to report and the error names the parent.
|
||||
let error = FsError::Io {
|
||||
path: parent_str.clone(),
|
||||
source,
|
||||
};
|
||||
consecutive_entry_errors += 1;
|
||||
if consecutive_entry_errors > READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS {
|
||||
return Err(error);
|
||||
}
|
||||
record_entry_error(&mut errors, None, error)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
consecutive_entry_errors = 0;
|
||||
let entry_path = entry.path();
|
||||
let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io {
|
||||
path: entry_path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let kind = classify(&metadata);
|
||||
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
|
||||
match std::fs::read_link(&entry_path) {
|
||||
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?),
|
||||
Err(source) => {
|
||||
return Err(FsError::Io {
|
||||
// Resolved first so a later per-entry failure can name it.
|
||||
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
|
||||
let metadata = match std::fs::symlink_metadata(&entry_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(source) => {
|
||||
record_entry_error(
|
||||
&mut errors,
|
||||
Some(&name),
|
||||
FsError::Io {
|
||||
path: entry_path.display().to_string(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
},
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
|
||||
let kind = classify(&metadata);
|
||||
let mut symlink_target = None;
|
||||
if matches!(kind, FsEntryKind::Symlink) {
|
||||
match std::fs::read_link(&entry_path) {
|
||||
// A target we cannot represent leaves the entry in the
|
||||
// listing with its target unknown, not the entry out of
|
||||
// it: one weird symlink in `/tmp` used to take the
|
||||
// whole directory down.
|
||||
Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) {
|
||||
Ok(target) => symlink_target = Some(target),
|
||||
Err(error) => record_entry_error(&mut errors, Some(&name), error)?,
|
||||
},
|
||||
Err(source) => record_entry_error(
|
||||
&mut errors,
|
||||
Some(&name),
|
||||
FsError::Io {
|
||||
path: entry_path.display().to_string(),
|
||||
source,
|
||||
},
|
||||
)?,
|
||||
}
|
||||
}
|
||||
out.push(FsDirEntry {
|
||||
name,
|
||||
kind,
|
||||
|
|
@ -246,7 +371,33 @@ pub fn read_dir_blocking(
|
|||
symlink_target,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
Ok(FsDirListing {
|
||||
entries: out,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Route one per-entry failure: append it to the tolerant channel, or
|
||||
/// propagate it when the caller asked for the fatal contract.
|
||||
///
|
||||
/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the
|
||||
/// mode in the accumulator rather than passing it separately makes the
|
||||
/// two impossible to disagree.
|
||||
fn record_entry_error(
|
||||
errors: &mut Option<Vec<FsDirEntryError>>,
|
||||
name: Option<&str>,
|
||||
error: FsError,
|
||||
) -> Result<(), FsError> {
|
||||
match errors {
|
||||
Some(list) => {
|
||||
list.push(FsDirEntryError {
|
||||
name: name.map(ToOwned::to_owned),
|
||||
message: error.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
None => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
|
||||
|
|
@ -495,12 +646,23 @@ mod tests {
|
|||
CancellationToken::new()
|
||||
}
|
||||
|
||||
/// The fatal-mode shorthand every pre-Q#DR6 test used.
|
||||
fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result<Vec<FsDirEntry>, FsError> {
|
||||
read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| {
|
||||
assert!(
|
||||
listing.errors.is_none(),
|
||||
"fatal mode must not open a per-entry channel"
|
||||
);
|
||||
listing.entries
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dir_returns_entries_with_lstat_metadata() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
|
||||
std::fs::create_dir(td.path().join("subdir")).expect("mkdir");
|
||||
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
|
||||
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
|
||||
let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, vec!["a.txt", "subdir"]);
|
||||
|
|
@ -516,7 +678,7 @@ mod tests {
|
|||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
|
||||
symlink("real.txt", td.path().join("link")).expect("symlink");
|
||||
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
|
||||
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
|
||||
let link = entries.iter().find(|e| e.name == "link").unwrap();
|
||||
assert_eq!(link.kind, FsEntryKind::Symlink);
|
||||
assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
|
||||
|
|
@ -535,7 +697,7 @@ mod tests {
|
|||
}
|
||||
let cancel = token();
|
||||
cancel.cancel();
|
||||
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel");
|
||||
let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel");
|
||||
assert!(matches!(err, FsError::Cancelled), "got {err:?}");
|
||||
}
|
||||
|
||||
|
|
@ -627,7 +789,7 @@ mod tests {
|
|||
fn read_dir_on_missing_path_reports_io_error() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let missing = td.path().join("does-not-exist");
|
||||
let err = read_dir_blocking(&missing, &token()).expect_err("must error");
|
||||
let err = read_dir_fatal(&missing, &token()).expect_err("must error");
|
||||
match err {
|
||||
FsError::Io { path, .. } => {
|
||||
assert!(
|
||||
|
|
@ -640,6 +802,109 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() {
|
||||
// `Some(vec![])` rather than `None` is the whole shape
|
||||
// contract: the Lua boundary keys the bare-array-vs-table
|
||||
// result on `errors.is_some()`, so a clean tolerant listing
|
||||
// must still carry the channel.
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
|
||||
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
|
||||
.expect("tolerant read_dir");
|
||||
assert_eq!(listing.entries.len(), 1);
|
||||
assert_eq!(listing.errors.as_deref(), Some(&[][..]));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
|
||||
// A legal Unix symlink target that is not representable as a
|
||||
// Rust `String`. Before Q#DR6 this single entry took the whole
|
||||
// listing down.
|
||||
symlink(
|
||||
std::ffi::OsStr::from_bytes(b"tgt-\xff"),
|
||||
td.path().join("weird"),
|
||||
)
|
||||
.expect("symlink");
|
||||
|
||||
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
|
||||
.expect("tolerant read_dir must survive a non-UTF-8 target");
|
||||
let weird = listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "weird")
|
||||
.expect("the entry itself must be listed");
|
||||
assert_eq!(weird.kind, FsEntryKind::Symlink);
|
||||
assert!(
|
||||
weird.symlink_target.is_none(),
|
||||
"an unrepresentable target reports as unknown"
|
||||
);
|
||||
assert!(
|
||||
listing.entries.iter().any(|e| e.name == "real.txt"),
|
||||
"the readable sibling must survive too"
|
||||
);
|
||||
let errors = listing.errors.expect("tolerant mode opens the channel");
|
||||
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
|
||||
assert_eq!(errors[0].name.as_deref(), Some("weird"));
|
||||
|
||||
// The same directory under the fatal contract still fails
|
||||
// whole-listing --- the opt is what changes behavior, not the
|
||||
// walk.
|
||||
let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail");
|
||||
assert!(
|
||||
matches!(err, FsError::NonUtf8Path { .. }),
|
||||
"expected NonUtf8Path, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Failure mode 1 from the framing: a directory readable but not
|
||||
// searchable. `readdir` yields the names; every child `lstat`
|
||||
// fails with EACCES.
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let dir = td.path().join("no-search");
|
||||
std::fs::create_dir(&dir).expect("mkdir");
|
||||
std::fs::write(dir.join("child"), b"x").expect("write child");
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400");
|
||||
let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok();
|
||||
if searchable {
|
||||
// Running as root (or on a filesystem that ignores the
|
||||
// bits): the premise cannot be established, so assert
|
||||
// nothing rather than pass vacuously.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("restore perms");
|
||||
eprintln!("lstat still succeeds without search permission; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry);
|
||||
let fatal = read_dir_fatal(&dir, &token());
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("restore perms");
|
||||
|
||||
let listing = tolerant.expect("tolerant read_dir must not fail the listing");
|
||||
assert!(
|
||||
listing.entries.is_empty(),
|
||||
"the unreadable child cannot be described: {:?}",
|
||||
listing.entries
|
||||
);
|
||||
let errors = listing.errors.expect("tolerant mode opens the channel");
|
||||
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
|
||||
assert_eq!(
|
||||
errors[0].name.as_deref(),
|
||||
Some("child"),
|
||||
"an lstat failure has an entry in hand and must name it"
|
||||
);
|
||||
let err = fatal.expect_err("fatal mode must still fail the whole listing");
|
||||
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
|
||||
|
|
@ -650,7 +915,7 @@ mod tests {
|
|||
// Rust `String`.
|
||||
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
|
||||
std::fs::write(td.path().join(bad_name), b"").expect("write entry");
|
||||
let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8");
|
||||
let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8");
|
||||
match err {
|
||||
FsError::NonUtf8Path { parent, bytes } => {
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
|
|||
}
|
||||
}
|
||||
|
||||
/// Read a `String` setting plus the registry epoch that keys any cache
|
||||
/// built from it (Q#TC4c).
|
||||
///
|
||||
/// The epoch is returned WITH the value deliberately: a caller caching a
|
||||
/// parsed form needs both, and reading them in two calls would let a
|
||||
/// `set` land between them and produce a cache stamped with the wrong
|
||||
/// epoch. `fallback` covers a bare core whose runtime never defined the
|
||||
/// setting, matching [`config_u32`].
|
||||
#[must_use]
|
||||
pub fn config_string_and_epoch(
|
||||
lua: &Lua,
|
||||
name: &str,
|
||||
buffer_id: Option<BufferId>,
|
||||
fallback: &str,
|
||||
) -> (String, u64) {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
|
||||
return (fallback.to_owned(), 0);
|
||||
};
|
||||
let borrowed = registry.borrow();
|
||||
let epoch = borrowed.value_epoch();
|
||||
let value = match borrowed.get(name, buffer_id) {
|
||||
Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(),
|
||||
_ => fallback.to_owned(),
|
||||
};
|
||||
(value, epoch)
|
||||
}
|
||||
|
||||
/// Short-circuit a binding when the init phase has completed.
|
||||
///
|
||||
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
|
||||
|
|
@ -2439,6 +2466,7 @@ pub fn install(
|
|||
)?;
|
||||
pmacs.set("instance", install_instance_module(lua, registry)?)?;
|
||||
pmacs.set("ansi", install_ansi_module(lua)?)?;
|
||||
pmacs.set("path", install_path_module(lua)?)?;
|
||||
pmacs.set("packages", install_packages_module(lua)?)?;
|
||||
pmacs.set("state", install_state_module(lua)?)?;
|
||||
pmacs.set("session", install_session_module(lua)?)?;
|
||||
|
|
@ -3555,6 +3583,46 @@ impl UserData for AnsiParserLua {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build the `pmacs.path.*` table: pure path arithmetic, no
|
||||
/// filesystem access and no editor state.
|
||||
///
|
||||
/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`]
|
||||
/// itself — the function the buffer registry's path keys already go
|
||||
/// through on write and that `find_buffer_for_path` looks up with. It
|
||||
/// expands a leading `~`, absolutizes against the process cwd, folds
|
||||
/// `.` / `..` lexically, and drops redundant separators (so a trailing
|
||||
/// slash disappears everywhere except at root). Symlinks are
|
||||
/// deliberately **not** resolved: dired's `..` must return where the
|
||||
/// user navigated from, and a not-yet-created "[new file]" path has
|
||||
/// nothing to resolve.
|
||||
///
|
||||
/// Exposed rather than mirrored in Lua because dired keys one buffer per
|
||||
/// directory on this form (Q#DR2). Two implementations that disagree on
|
||||
/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape
|
||||
/// root) would mint two buffers for one directory with no error
|
||||
/// anywhere.
|
||||
///
|
||||
/// The result crosses the boundary through `to_string_lossy`, so a
|
||||
/// non-UTF-8 `$HOME` (or a non-UTF-8 argument) can yield a Lua string
|
||||
/// that no longer names the `PathBuf` the registry keys on. That is the
|
||||
/// same limit `pmacs.fs` already documents — byte-preserving paths are
|
||||
/// post-v0.1 work that widens every path in the API — and it is recorded
|
||||
/// here so this binding is not read as an exception to it.
|
||||
fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
|
||||
let path = lua.create_table()?;
|
||||
path.set(
|
||||
"canonicalize",
|
||||
lua.create_function(|_, raw: String| {
|
||||
Ok(
|
||||
crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
)
|
||||
})?,
|
||||
)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Build the `pmacs.ansi.*` table. The only entry today is
|
||||
/// `parser()`; future additions (e.g. an event-table-validator
|
||||
/// helper) live alongside it.
|
||||
|
|
@ -6479,6 +6547,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
|
|||
Ok(t)
|
||||
}
|
||||
|
||||
/// Convert a settled `read_dir` listing to its Lua result value.
|
||||
///
|
||||
/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode
|
||||
/// listing carries no error channel and stays the **bare array** the
|
||||
/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with
|
||||
/// `ipairs` --- while a tolerant listing becomes
|
||||
/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`.
|
||||
/// Keying on the payload rather than on the job keeps the additive
|
||||
/// promise checkable in one place.
|
||||
fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result<mlua::Value> {
|
||||
let entries = lua.create_table_with_capacity(listing.entries.len(), 0)?;
|
||||
for (i, entry) in listing.entries.iter().enumerate() {
|
||||
entries.set(i + 1, fs_dir_entry_to_lua(lua, entry)?)?;
|
||||
}
|
||||
let Some(errors) = listing.errors else {
|
||||
return Ok(mlua::Value::Table(entries));
|
||||
};
|
||||
let rows = lua.create_table_with_capacity(errors.len(), 0)?;
|
||||
for (i, error) in errors.iter().enumerate() {
|
||||
let row = lua.create_table_with_capacity(0, 2)?;
|
||||
// `name` is absent for a per-entry `readdir` iterator error:
|
||||
// the entry never materialized, so there is nothing to name.
|
||||
if let Some(name) = &error.name {
|
||||
row.set("name", name.as_str())?;
|
||||
}
|
||||
row.set("message", error.message.as_str())?;
|
||||
rows.set(i + 1, row)?;
|
||||
}
|
||||
let out = lua.create_table_with_capacity(0, 2)?;
|
||||
out.set("entries", entries)?;
|
||||
out.set("errors", rows)?;
|
||||
Ok(mlua::Value::Table(out))
|
||||
}
|
||||
|
||||
fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result<mlua::Value> {
|
||||
match payload {
|
||||
StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))),
|
||||
|
|
@ -6570,9 +6672,23 @@ pub fn install_async(
|
|||
let rt = runtime.clone();
|
||||
async_mod.set(
|
||||
"_dispatch_fs_read_dir",
|
||||
lua.create_function(move |_, (path, key): (String, Option<String>)| {
|
||||
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref()))
|
||||
})?,
|
||||
lua.create_function(
|
||||
move |_, (path, key, tolerant): (String, Option<String>, Option<bool>)| {
|
||||
// dired Q#DR6: the tolerance is decided at dispatch
|
||||
// and travels in the settled payload, so the result
|
||||
// conversion below never has to look the job back up.
|
||||
let tolerance = if tolerant == Some(true) {
|
||||
crate::fs::ReadDirTolerance::PerEntry
|
||||
} else {
|
||||
crate::fs::ReadDirTolerance::Fatal
|
||||
};
|
||||
Ok(rt.dispatch_fs_read_dir(
|
||||
std::path::PathBuf::from(path),
|
||||
tolerance,
|
||||
key.as_deref(),
|
||||
))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
|
|
@ -6777,16 +6893,15 @@ pub fn install_async(
|
|||
i64::try_from(duration_ms).unwrap_or(i64::MAX),
|
||||
));
|
||||
}
|
||||
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => {
|
||||
Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
|
||||
// Lua surface for fs.read_dir settle:
|
||||
// status "ok", value = array of per-entry
|
||||
// tables. T M8.1.
|
||||
// tables (T M8.1), or the
|
||||
// `{ entries = …, errors = … }` table when the
|
||||
// caller opted into per-entry tolerance
|
||||
// (dired Q#DR6).
|
||||
out.push_back(mlua::Value::String(lua.create_string("ok")?));
|
||||
let t = lua.create_table_with_capacity(entries.len(), 0)?;
|
||||
for (i, entry) in entries.into_iter().enumerate() {
|
||||
t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?;
|
||||
}
|
||||
out.push_back(mlua::Value::Table(t));
|
||||
out.push_back(fs_dir_listing_to_lua(lua, listing)?);
|
||||
}
|
||||
Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
|
||||
// Lua surface for fs.stat settle: status
|
||||
|
|
@ -6933,9 +7048,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
|
|||
"ok",
|
||||
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
|
||||
),
|
||||
JobOutcome::Complete(JobResult::ReadDir(entries)) => (
|
||||
JobOutcome::Complete(JobResult::ReadDir(listing)) => (
|
||||
"ok",
|
||||
mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)),
|
||||
mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)),
|
||||
),
|
||||
JobOutcome::Complete(JobResult::Stat(entry)) => {
|
||||
("ok", mlua::Value::String(lua.create_string(&entry.name)?))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ pub use pmacs_protocol::terminal::{
|
|||
/// Configuration-time, not a wire bound: history never crosses the
|
||||
/// protocol, so this stays core-owned.
|
||||
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
|
||||
|
||||
/// Default `terminal.escape-key`, and the fallback an unparseable value
|
||||
/// falls back to (Q#TC4a).
|
||||
pub const DEFAULT_TERMINAL_ESCAPE_KEY: &str = "C-c";
|
||||
/// Maximum retained main-screen history cells. Core-owned for the same
|
||||
/// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`].
|
||||
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::ansi::AnsiParserProfile;
|
|||
use crate::buffer::{Buffer, BufferId};
|
||||
use crate::cell::{Cell, CellCoord, CellSize};
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::key::{Chord, parse_chord};
|
||||
use crate::process::{
|
||||
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
|
||||
RestartPolicy, StdinMode, TerminalMode,
|
||||
|
|
@ -218,12 +219,40 @@ pub(super) struct TerminalSession {
|
|||
pub(super) screen: TerminalScreen,
|
||||
pub(super) process: TerminalProcessState,
|
||||
pub(super) annotated: bool,
|
||||
/// Resolved `terminal.escape-key` for this terminal (Q#TC4c).
|
||||
///
|
||||
/// The cache lives HERE, not in an editor-side map, because a
|
||||
/// session is created in [`TerminalManager::open`] and dropped on
|
||||
/// kill/prune — so its lifetime is exactly the cache's, with no
|
||||
/// purge hook to forget. An editor-side map would leak an entry per
|
||||
/// terminal; a single last-entry cache would reparse (and re-report
|
||||
/// an invalid value) every time focus alternates between two
|
||||
/// terminals.
|
||||
pub(super) escape: Option<EscapeCache>,
|
||||
}
|
||||
|
||||
/// One terminal's parsed escape chord, valid for one config epoch.
|
||||
pub(super) struct EscapeCache {
|
||||
/// The `ConfigRegistry::value_epoch` this was parsed at. The key is
|
||||
/// `(this session, epoch)`: the epoch alone is not enough, because
|
||||
/// it does not advance when focus moves between terminals with
|
||||
/// different buffer-local values.
|
||||
pub(super) epoch: u64,
|
||||
/// The effective chord — the parsed spelling, or the `C-c` fallback.
|
||||
pub(super) chord: Chord,
|
||||
/// The invalid spelling already reported for this terminal, if any.
|
||||
/// Reporting is once per terminal per effective invalid value: an
|
||||
/// unchanged bad value stays quiet, a *different* bad value reports
|
||||
/// again because it is a new mistake.
|
||||
pub(super) reported_invalid: Option<String>,
|
||||
}
|
||||
|
||||
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalManager {
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
/// Total escape-key parses performed (Q#TC4c observability).
|
||||
escape_parses: u64,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// Removed buffers whose children are still being reaped. Their events
|
||||
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
||||
|
|
@ -331,6 +360,7 @@ impl TerminalManager {
|
|||
screen,
|
||||
process: TerminalProcessState::Running,
|
||||
annotated: false,
|
||||
escape: None,
|
||||
},
|
||||
);
|
||||
debug_assert!(previous.is_none(), "fresh BufferId collided");
|
||||
|
|
@ -538,6 +568,89 @@ impl TerminalManager {
|
|||
.map_err(TerminalError::Process)
|
||||
}
|
||||
|
||||
/// Resolve this terminal's effective escape chord, parsing at most
|
||||
/// once per `(terminal, config epoch)` (Q#TC4c).
|
||||
///
|
||||
/// `spelling` is the caller-resolved `terminal.escape-key` value and
|
||||
/// `epoch` the registry's `value_epoch()` it was read at. Returns the
|
||||
/// effective chord plus, at most once per terminal per effective
|
||||
/// invalid value, a message the caller should surface.
|
||||
///
|
||||
/// An unparseable spelling falls back to `C-c` rather than leaving the
|
||||
/// terminal with no escape at all (Q#TC4a): without one, every key goes
|
||||
/// to the child and the user cannot reach the binding that would fix
|
||||
/// the setting that broke it.
|
||||
pub fn escape_chord(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
epoch: u64,
|
||||
spelling: &str,
|
||||
) -> (Chord, Option<String>) {
|
||||
let fallback = default_escape_chord();
|
||||
if let Some(session) = self.sessions.get(&buffer_id)
|
||||
&& let Some(cache) = session.escape.as_ref()
|
||||
&& cache.epoch == epoch
|
||||
{
|
||||
return (cache.chord, None);
|
||||
}
|
||||
self.escape_parses = self.escape_parses.saturating_add(1);
|
||||
let Some(session) = self.sessions.get_mut(&buffer_id) else {
|
||||
return (fallback, None);
|
||||
};
|
||||
let previously_reported = session
|
||||
.escape
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.reported_invalid.clone());
|
||||
let (chord, reported_invalid, report) = match parse_chord(spelling) {
|
||||
Ok(chord) => (chord, None, None),
|
||||
Err(error) => {
|
||||
let already = previously_reported.as_deref() == Some(spelling);
|
||||
let message = (!already).then(|| {
|
||||
format!(
|
||||
"terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c"
|
||||
)
|
||||
});
|
||||
(fallback, Some(spelling.to_owned()), message)
|
||||
}
|
||||
};
|
||||
session.escape = Some(EscapeCache {
|
||||
epoch,
|
||||
chord,
|
||||
reported_invalid,
|
||||
});
|
||||
(chord, report)
|
||||
}
|
||||
|
||||
/// How many escape-key spellings this manager has parsed.
|
||||
///
|
||||
/// An observability seam for Q#TC4c's cache contract, which is
|
||||
/// otherwise unpinnable for a VALID setting: a correct per-session
|
||||
/// cache and a single last-entry cache produce identical behavior
|
||||
/// there and differ only in how often they parse. Counting reports
|
||||
/// covers the invalid case; this covers the valid one.
|
||||
#[must_use]
|
||||
pub fn escape_parses(&self) -> u64 {
|
||||
self.escape_parses
|
||||
}
|
||||
|
||||
/// How many terminals currently hold a cached escape chord.
|
||||
///
|
||||
/// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses`
|
||||
/// cannot cover: parse counting says a valid setting is read once, but
|
||||
/// says nothing about whether the cache is ever released. Because the
|
||||
/// cache lives on [`TerminalSession`], this count falls with the
|
||||
/// session set by construction — which is exactly the property worth
|
||||
/// pinning, since the rejected alternative (an editor-side
|
||||
/// `HashMap<BufferId, EscapeCache>`) has no purge hook and would hold
|
||||
/// this at its high-water mark while sessions drained.
|
||||
#[must_use]
|
||||
pub fn escape_caches(&self) -> usize {
|
||||
self.sessions
|
||||
.values()
|
||||
.filter(|session| session.escape.is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Resize a terminal screen and its PTY after validating shared limits.
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
|
|
@ -730,3 +843,12 @@ fn sanitize_metadata(value: &str) -> String {
|
|||
}
|
||||
clean
|
||||
}
|
||||
|
||||
/// The built-in terminal escape chord, and the fallback for an
|
||||
/// unparseable `terminal.escape-key` (Q#TC4a).
|
||||
pub(super) fn default_escape_chord() -> Chord {
|
||||
Chord::new(
|
||||
crossterm::event::KeyCode::Char('c'),
|
||||
crossterm::event::KeyModifiers::CONTROL,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
|
|||
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
|
||||
format!("ok (parse {duration_ms}ms)")
|
||||
}
|
||||
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
|
||||
format!("ok ({} entries)", entries.len())
|
||||
JobOutcome::Complete(JobResult::ReadDir(listing)) => {
|
||||
// Per-entry failures (dired Q#DR6) are counted here too: a
|
||||
// tolerant listing that dropped half a directory is not the
|
||||
// same observable outcome as a clean one.
|
||||
match listing.errors.as_deref() {
|
||||
Some(errors @ [_, ..]) => format!(
|
||||
"ok ({} entries, {} unreadable)",
|
||||
listing.entries.len(),
|
||||
errors.len()
|
||||
),
|
||||
_ => format!("ok ({} entries)", listing.entries.len()),
|
||||
}
|
||||
}
|
||||
JobOutcome::Complete(JobResult::Stat(entry)) => {
|
||||
format!("ok (stat {:?})", entry.name)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,746 @@
|
|||
//! Terminal configuration acceptance (Stage 1 of
|
||||
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12).
|
||||
//!
|
||||
//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that
|
||||
//! feature, so a gated suite is written and then never run.
|
||||
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use mlua::Value;
|
||||
use pmacs::cell::{CellSize, Glyph};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use pmacs::terminal::TerminalViewKey;
|
||||
use pmacs::window::WindowId;
|
||||
|
||||
fn exec(state: &EditorState, src: &str) {
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(src)
|
||||
.exec()
|
||||
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
|
||||
}
|
||||
|
||||
fn eval_err(state: &EditorState, src: &str) -> String {
|
||||
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
|
||||
match result {
|
||||
Ok(_) => panic!("expected an error from: {src}"),
|
||||
Err(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport every test projects through. Deliberately SHORTER than
|
||||
/// the 24-row screen a terminal opens with, so "scroll to the oldest
|
||||
/// retained row" has somewhere to go even when nothing is retained —
|
||||
/// which is what makes the two scrollback arms differ by content rather
|
||||
/// than by whether scrolling was possible at all.
|
||||
fn viewport() -> CellSize {
|
||||
CellSize::new(10, 40)
|
||||
}
|
||||
|
||||
fn cells_to_text(cells: &[pmacs::cell::Cell]) -> String {
|
||||
let mut text = String::new();
|
||||
for cell in cells {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(c) => text.push(*c),
|
||||
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
|
||||
let manager = state.terminal_manager.borrow();
|
||||
let Some(snapshot) = manager.snapshot(buffer) else {
|
||||
return String::new();
|
||||
};
|
||||
cells_to_text(&snapshot.cells)
|
||||
}
|
||||
|
||||
/// Text a view actually shows, which is where retained history is
|
||||
/// visible at all — the live `screen_text` above always reads the tail.
|
||||
fn view_text(state: &EditorState, key: TerminalViewKey) -> String {
|
||||
let mut manager = state.terminal_manager.borrow_mut();
|
||||
manager
|
||||
.snapshot_for_view(key, viewport())
|
||||
.map(|snapshot| cells_to_text(&snapshot.cells))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Scroll a view to its OLDEST retained row and read it back.
|
||||
fn oldest_view_text(state: &EditorState, key: TerminalViewKey) -> String {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.scroll_view(key, viewport(), i32::MAX);
|
||||
view_text(state, key)
|
||||
}
|
||||
|
||||
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if screen_text(state, buffer).contains(needle) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Give LOCAL a window on `buffer` and register/claim its terminal view,
|
||||
/// which is what makes `dispatch_key`'s terminal arm reachable.
|
||||
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId {
|
||||
state.core.borrow_mut().switch_active_buffer(buffer).ok();
|
||||
let window = state.core.borrow().active_window_id();
|
||||
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
|
||||
let mut manager = state.terminal_manager.borrow_mut();
|
||||
manager.register_view(key);
|
||||
manager.claim_controller(key);
|
||||
let _ = manager.snapshot_for_view(key, viewport());
|
||||
window
|
||||
}
|
||||
|
||||
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
|
||||
let manager = state.terminal_manager.borrow();
|
||||
state
|
||||
.core
|
||||
.borrow()
|
||||
.registry
|
||||
.borrow()
|
||||
.ids()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| manager.is_terminal(*id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Open a terminal from Lua and return the identity buffer it created.
|
||||
///
|
||||
/// The id is derived by diffing the manager's terminal set rather than
|
||||
/// returned through Lua: `BufferIdLua` exposes no id accessor, and
|
||||
/// diffing also asserts in passing that exactly one terminal appeared.
|
||||
fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId {
|
||||
let before = terminal_buffers(state);
|
||||
exec(
|
||||
state,
|
||||
&format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"),
|
||||
);
|
||||
let after = terminal_buffers(state);
|
||||
let mut fresh: Vec<_> = after
|
||||
.into_iter()
|
||||
.filter(|id| !before.contains(id))
|
||||
.collect();
|
||||
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
|
||||
fresh.remove(0)
|
||||
}
|
||||
|
||||
/// `cat -v` is the echo instrument, deliberately: the terminal screen
|
||||
/// rejects C0/C1 controls before they enter cells (Vterm Stage 1
|
||||
/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test
|
||||
/// probing for it could never pass. `-v` renders it as the printable
|
||||
/// two-character `^X`, which is what makes "the configured chord reached
|
||||
/// the child" observable at all.
|
||||
const CAT_PROFILE: &str = r#"
|
||||
pmacs.terminal.profiles.echo = {
|
||||
command = "/bin/sh",
|
||||
args = { "-c", "printf 'READY\r\n'; exec cat -v" },
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Did the last key ARM the terminal escape?
|
||||
///
|
||||
/// Observed behaviorally rather than through an accessor: while the
|
||||
/// escape is armed the next key goes to ordinary dispatch, so it never
|
||||
/// reaches the child. `cat` echoes anything that does reach it, which
|
||||
/// makes "the probe character did not appear" the exact observable for
|
||||
/// "that chord was consumed as the escape".
|
||||
fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool {
|
||||
// Count occurrences rather than testing for presence: the screen
|
||||
// already holds the child's own output, and a single-character probe
|
||||
// like 'R' collides with the "READY" banner. Only an INCREASE proves
|
||||
// this keystroke reached the child.
|
||||
let before = screen_text(state, buffer).matches(probe).count();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if screen_text(state, buffer).matches(probe).count() > before {
|
||||
return false;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Acceptance 1: a profile spec is strict, and rejects before anything spawns.
|
||||
#[test]
|
||||
fn acc1_profile_specs_are_strict_and_reject_before_spawning() {
|
||||
let state = EditorState::new();
|
||||
let before = state.core.borrow().registry.borrow().ids().len();
|
||||
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#,
|
||||
);
|
||||
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#);
|
||||
assert!(
|
||||
err.contains("unknown field") && err.contains("nonsense"),
|
||||
"the error must name the offending field: {err}"
|
||||
);
|
||||
|
||||
exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }");
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.terminal.open { profile = "wrong" }"#,
|
||||
);
|
||||
assert!(err.contains("must be a string"), "typed field error: {err}");
|
||||
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
before,
|
||||
"a rejected profile must create no buffer"
|
||||
);
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance 2: an unknown profile names the known ones and creates nothing.
|
||||
#[test]
|
||||
fn acc2_unknown_profile_lists_known_names_and_creates_nothing() {
|
||||
let state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#,
|
||||
);
|
||||
let before = state.core.borrow().registry.borrow().ids().len();
|
||||
|
||||
// Via the default setting.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "ghost")"#,
|
||||
);
|
||||
let err = eval_err(&state, "return pmacs.terminal.open {}");
|
||||
assert!(err.contains("ghost"), "names the missing profile: {err}");
|
||||
assert!(
|
||||
err.contains("echo") && err.contains("other"),
|
||||
"must LIST the known profiles: {err}"
|
||||
);
|
||||
|
||||
// An explicit bad profile fails even though the default is now valid —
|
||||
// a typo must not silently fall back (Q#TC3a).
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
||||
);
|
||||
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#);
|
||||
assert!(err.contains("typo"), "explicit bad profile errors: {err}");
|
||||
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
before,
|
||||
"no buffer, session, or process is created"
|
||||
);
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance 2 (malformed table): `pmacs.terminal.profiles` is a raw
|
||||
/// user table, so a diagnostic that walks its keys must be total over
|
||||
/// them. A table holding both a string and a numeric key made
|
||||
/// `table.sort` raise "attempt to compare number with string" — on the
|
||||
/// unknown-profile path, replacing the exact error being asked for.
|
||||
#[test]
|
||||
fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() {
|
||||
let state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.terminal.profiles[1] = { command = "/bin/sh" }"#,
|
||||
);
|
||||
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.terminal.open { profile = "ghost" }"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("ghost") && err.contains("echo"),
|
||||
"the unknown-profile error must survive a malformed table: {err}"
|
||||
);
|
||||
assert!(
|
||||
!err.contains("attempt to compare"),
|
||||
"listing known profiles must not raise: {err}"
|
||||
);
|
||||
|
||||
// Rendering the REQUESTED name is partial too: `%q` raises on a
|
||||
// table, and the name arrives straight from the caller.
|
||||
let err = eval_err(&state, r"return pmacs.terminal.open { profile = {} }");
|
||||
assert!(
|
||||
err.contains("is not defined") && err.contains("known profiles"),
|
||||
"a non-string profile name must render, not raise: {err}"
|
||||
);
|
||||
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and
|
||||
/// `env` MERGES rather than replacing.
|
||||
#[test]
|
||||
fn acc3_field_resolution_order_and_env_merge() {
|
||||
let mut state = EditorState::new();
|
||||
exec(
|
||||
&state,
|
||||
r#"
|
||||
pmacs.terminal.profiles.merged = {
|
||||
command = "/bin/sh",
|
||||
args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" },
|
||||
env = { FROM_PROFILE = "p", SHARED = "profile" },
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let buffer = open_cat_terminal(
|
||||
&state,
|
||||
r#"profile = "merged", env = { SHARED = "explicit" }"#,
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "PROFILE:p:explicit", buffer),
|
||||
"profile env survives and explicit env overrides the same key: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile).
|
||||
#[test]
|
||||
fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
||||
);
|
||||
|
||||
// Explicit command beats the profile's.
|
||||
let explicit = open_cat_terminal(
|
||||
&state,
|
||||
r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#,
|
||||
);
|
||||
assert!(tick_until(&mut state, "EXPLICIT", explicit));
|
||||
|
||||
// `""` is the no-profile sentinel: falls through to $SHELL.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "")"#,
|
||||
);
|
||||
let bare = open_cat_terminal(&state, "");
|
||||
let spec_ok = state.terminal_manager.borrow().is_terminal(bare);
|
||||
assert!(spec_ok, "an empty default must open a $SHELL terminal");
|
||||
assert!(
|
||||
!screen_text(&state, bare).contains("READY"),
|
||||
"the echo profile must NOT have been applied"
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// A child that overflows the 24-row screen and then goes quiet, so its
|
||||
/// early output can only still be found in RETAINED HISTORY. Zero-padded
|
||||
/// so `LINE001` is not a substring of `LINE100`.
|
||||
const FILL_PROFILE: &str = r#"
|
||||
pmacs.terminal.profiles.fill = {
|
||||
command = "/bin/sh",
|
||||
args = { "-c",
|
||||
"i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" },
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Acceptance 5: the scrollback SETTING reaches the screen's retained
|
||||
/// history, an explicit spec value overrides it, and `0` is legal.
|
||||
///
|
||||
/// Asserted end to end, through a real child and a real view, rather
|
||||
/// than by reading the value back out of the registry: a registry
|
||||
/// round-trip is a test of the registry, and would stay green with the
|
||||
/// setting's only consumer (`terminal.lua`'s `resolved.scrollback_rows`
|
||||
/// fallback) deleted outright.
|
||||
#[test]
|
||||
fn acc5_scrollback_setting_reaches_retained_history() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, FILL_PROFILE);
|
||||
|
||||
// Arm 1: `0` is legal, and means the early rows are GONE.
|
||||
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
|
||||
let none = open_cat_terminal(&state, r#"profile = "fill""#);
|
||||
assert!(tick_until(&mut state, "DONE", none), "child finished");
|
||||
let window = focus_terminal(&state, none);
|
||||
let none_key = TerminalViewKey::new(FrontendId::LOCAL, window, none);
|
||||
let oldest = oldest_view_text(&state, none_key);
|
||||
assert!(
|
||||
!oldest.contains("LINE001"),
|
||||
"with scrollback 0 the oldest retained row must not be the \
|
||||
child's first line: {oldest:?}"
|
||||
);
|
||||
|
||||
// Arm 2: a large setting retains it, reachable by scrolling back.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.scrollback-rows", 10000)"#,
|
||||
);
|
||||
let kept = open_cat_terminal(&state, r#"profile = "fill""#);
|
||||
assert!(tick_until(&mut state, "DONE", kept), "child finished");
|
||||
let window = focus_terminal(&state, kept);
|
||||
let kept_key = TerminalViewKey::new(FrontendId::LOCAL, window, kept);
|
||||
let oldest = oldest_view_text(&state, kept_key);
|
||||
assert!(
|
||||
oldest.contains("LINE001"),
|
||||
"with scrollback 10000 the first line must survive in history: \
|
||||
{oldest:?}"
|
||||
);
|
||||
|
||||
// Arm 3: an explicit spec value beats the setting, which is still 10000.
|
||||
let overridden = open_cat_terminal(&state, r#"profile = "fill", scrollback_rows = 0"#);
|
||||
assert!(tick_until(&mut state, "DONE", overridden), "child finished");
|
||||
let window = focus_terminal(&state, overridden);
|
||||
let overridden_key = TerminalViewKey::new(FrontendId::LOCAL, window, overridden);
|
||||
let oldest = oldest_view_text(&state, overridden_key);
|
||||
assert!(
|
||||
!oldest.contains("LINE001"),
|
||||
"an explicit scrollback_rows = 0 must beat the setting: {oldest:?}"
|
||||
);
|
||||
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 5 (bounds): the registered range rejects out-of-range
|
||||
/// values, and `0` is inside it rather than a disabled sentinel.
|
||||
#[test]
|
||||
fn acc5_scrollback_bounds() {
|
||||
let state = EditorState::new();
|
||||
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
|
||||
assert_eq!(
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
||||
.eval::<i64>()
|
||||
.unwrap(),
|
||||
0,
|
||||
"0 is a legal scrollback value meaning 'retain no history'"
|
||||
);
|
||||
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("-1") || err.contains("min"),
|
||||
"below range: {err}"
|
||||
);
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("4000001") || err.contains("max"),
|
||||
"above range: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 6 and 9: the configured chord escapes, repeating it sends
|
||||
/// THAT chord to the child, and an ordinary `C-c` still reaches the child.
|
||||
#[test]
|
||||
fn acc6_acc9_configured_escape_chord_and_literal_repeat() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
assert!(tick_until(&mut state, "READY", buffer));
|
||||
focus_terminal(&state, buffer);
|
||||
|
||||
exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#);
|
||||
|
||||
// `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against
|
||||
// the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "^X", buffer),
|
||||
"C-x C-x must send literal Ctrl-X: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
|
||||
// With the escape moved, an ordinary C-c is just another key.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "^C", buffer),
|
||||
"plain C-c must reach the child once the escape moved: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse
|
||||
/// count that does not grow, and a cache that dies with its terminal.
|
||||
#[test]
|
||||
fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let a = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
exec(&state, "TERM_A = TERM_BUF");
|
||||
let b = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
exec(&state, "TERM_B = TERM_BUF");
|
||||
assert!(tick_until(&mut state, "READY", a));
|
||||
assert!(tick_until(&mut state, "READY", b));
|
||||
|
||||
// Different buffer-local escapes, then NO further writes.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#,
|
||||
);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#,
|
||||
);
|
||||
|
||||
// Prime both caches. Each priming press ARMS the escape, so it is
|
||||
// consumed with a probe — otherwise the next chord would be read as
|
||||
// the escape repeat rather than a fresh escape.
|
||||
focus_terminal(&state, a);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x");
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b");
|
||||
let primed = state.terminal_manager.borrow().escape_parses();
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().escape_caches(),
|
||||
2,
|
||||
"each primed terminal holds its own cache"
|
||||
);
|
||||
|
||||
// Acceptance 7 — BOTH directions. Asserting only that A still works
|
||||
// after A->B->A is not enough: an epoch-only cache hands whichever
|
||||
// entry it finds to every terminal, so A keeps working by accident
|
||||
// while B silently inherits A's chord. The discriminating assertion
|
||||
// is that EACH terminal honors its OWN chord and NOT the other's.
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, b, 'R'),
|
||||
"terminal B must escape on its own C-b"
|
||||
);
|
||||
// ...and A's chord must be ordinary input in B, not an escape.
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
!escape_was_armed(&mut state, b, 'S'),
|
||||
"terminal A's C-x must NOT escape terminal B"
|
||||
);
|
||||
|
||||
focus_terminal(&state, a);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, a, 'Q'),
|
||||
"terminal A must still escape on its own C-x after A->B->A"
|
||||
);
|
||||
|
||||
// Acceptance 8: that round trip parsed nothing new. A single
|
||||
// last-entry cache would have reparsed twice.
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().escape_parses(),
|
||||
primed,
|
||||
"A->B->A with no setting written must not reparse"
|
||||
);
|
||||
|
||||
// Acceptance 8a: the cache dies with its terminal.
|
||||
//
|
||||
// Waiting for the SESSION count to fall is not the assertion — a
|
||||
// session set that drains while an editor-side `HashMap<BufferId,
|
||||
// EscapeCache>` keeps its entry (the rejected implementation named
|
||||
// in Q#TC4c, which has no purge hook) satisfies it exactly. The
|
||||
// discriminating observable is the CACHE count, which such a map
|
||||
// would hold at its high-water mark of 2.
|
||||
let sessions_before = state.terminal_manager.borrow().len();
|
||||
exec(&state, "pmacs.terminal.terminate(TERM_A)");
|
||||
exec(&state, "pmacs.buffer.kill(TERM_A)");
|
||||
// Pruning is tick-driven (the manager reaps on the process tick), so
|
||||
// the session outlives the kill call by design.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while state.terminal_manager.borrow().len() >= sessions_before {
|
||||
state.tick_processes();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"killing the terminal must remove its session"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().escape_caches(),
|
||||
1,
|
||||
"killing terminal A must drop ITS cache, not merely its session"
|
||||
);
|
||||
|
||||
// ...and the surviving cache is B's, so the right one was dropped.
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, b, 'T'),
|
||||
"terminal B must still escape on its own C-b after A was killed"
|
||||
);
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().escape_parses(),
|
||||
primed,
|
||||
"B's surviving cache must not have been reparsed"
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 10 and 10a: an unparseable value falls back, reports through
|
||||
/// the status line, and reports once per terminal per effective bad value.
|
||||
#[test]
|
||||
fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
assert!(tick_until(&mut state, "READY", buffer));
|
||||
focus_terminal(&state, buffer);
|
||||
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#,
|
||||
);
|
||||
state.core.borrow_mut().status.clear();
|
||||
|
||||
// Acceptance 10: falls back to C-c, so the terminal stays escapable.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
// Read the report BEFORE probing: `status` is a single slot, and the
|
||||
// probe key's own rejected self-insert would overwrite it.
|
||||
let reported = state.core.borrow().status.clone();
|
||||
assert!(
|
||||
reported.contains("terminal.escape-key") && reported.contains("not-a-chord"),
|
||||
"the report must name the setting and the bad value: {reported:?}"
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, buffer, 'Q'),
|
||||
"an invalid escape-key must fall back to C-c, not leave the \
|
||||
terminal unescapable"
|
||||
);
|
||||
|
||||
// Acceptance 10a: the same bad value does not report again.
|
||||
state.core.borrow_mut().status.clear();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
state.core.borrow().status.is_empty(),
|
||||
"an unchanged invalid value must not re-report: {:?}",
|
||||
state.core.borrow().status
|
||||
);
|
||||
let _ = escape_was_armed(&mut state, buffer, 'W');
|
||||
|
||||
// A DIFFERENT bad value is new information, so it reports again.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.escape-key", "also-bad")"#,
|
||||
);
|
||||
state.core.borrow_mut().status.clear();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
state.core.borrow().status.contains("also-bad"),
|
||||
"a different invalid value must report: {:?}",
|
||||
state.core.borrow().status
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 11: the opening binding exists, resolves to the command, and
|
||||
/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all
|
||||
/// proves the second half).
|
||||
#[test]
|
||||
fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() {
|
||||
let state = EditorState::new();
|
||||
let command: Option<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#)
|
||||
.eval()
|
||||
.expect("describe.key");
|
||||
assert_eq!(
|
||||
command.as_deref(),
|
||||
Some("terminal"),
|
||||
"C-c t must open a terminal"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 12: with no settings written and no profiles registered, the
|
||||
/// defaults reproduce the pre-arc behavior.
|
||||
#[test]
|
||||
fn acc12_defaults_reproduce_prior_behavior() {
|
||||
let state = EditorState::new();
|
||||
let lua = state.lua_host.lua();
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.default-profile")"#)
|
||||
.eval::<String>()
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
||||
.eval::<i64>()
|
||||
.unwrap(),
|
||||
10_000
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.escape-key")"#)
|
||||
.eval::<String>()
|
||||
.unwrap(),
|
||||
"C-c"
|
||||
);
|
||||
assert!(
|
||||
lua.load("return next(pmacs.terminal.profiles) == nil")
|
||||
.eval::<bool>()
|
||||
.unwrap(),
|
||||
"no profiles are registered by default"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue