Merge canonical main (8c86d34) into the inline-math slice
The lane was 28 commits behind. Merged rather than rebased, per the #135/#137 precedent: the PR is awaiting review rounds and a rebase would break every review anchor. The only conflict was docs/active-work.md, where both sides add lanes. Kept both: main's lanes verbatim, with this lane leading since it is the one in flight. The conflict was pre-existing rather than introduced by the dired or Lean 4 ledger commits -- it already conflicted against main ate745068. The integration surface, derived from git diff merge-base..main rather than from another PR's file list, is pmacs-gpu/src/main.rs: main gained 72 lines there frome547a90, the minimap all-blank-slab divide-by-zero fix, and this lane rewrites large parts of the same file. Git auto-merged it textually. A clean auto-merge is not evidence the tree compiles, so the full gate suite is what discharges it; the ledger records the post-integration numbers separately from the pre-integration ones, which described a tree 28 commits behind.
This commit is contained in:
commit
f3e065dc78
16
AGENTS.md
16
AGENTS.md
|
|
@ -1,11 +1,15 @@
|
|||
# pmacs agent instructions
|
||||
|
||||
**Start here: read `docs/agent-handoff.md`, then
|
||||
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
|
||||
`docs/active-work.md`, before taking on any work.** The handoff carries
|
||||
durable project state, working method, substrate invariants, and the
|
||||
standing backlog. The active-work ledger carries volatile branches,
|
||||
standing backlog. `COHERENCE.md` carries the product-coherence thesis
|
||||
and its audited ground truth (scorecard, per-concern gaps, priority
|
||||
order) — it is the standard new work gets evaluated against, not just a
|
||||
backlog item; read it before framing anything and cite the section a
|
||||
framing doc serves. The active-work ledger carries volatile branches,
|
||||
checkpoints, verification, and exact cross-machine recovery commands.
|
||||
Keep both updated according to their own update protocols.
|
||||
Keep all three updated according to their own update protocols.
|
||||
|
||||
Always true, independent of the handoff:
|
||||
|
||||
|
|
@ -14,7 +18,11 @@ Always true, independent of the handoff:
|
|||
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
|
||||
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
|
||||
-> full gate suite -> PR -> user review rounds -> user says when to
|
||||
merge. Never merge unprompted. One feature, one branch, one PR.
|
||||
merge. Never merge unprompted. One feature, one branch, one PR. A
|
||||
framing doc for coherence-affecting work should state its coherence
|
||||
impact (journey steps touched, interaction islands added, config
|
||||
registry adoption, background-work attribution) per `COHERENCE.md`
|
||||
§20.
|
||||
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
|
||||
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
|
||||
`cargo test --lib --features crdt`; the touched acceptance suites;
|
||||
|
|
|
|||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -1,11 +1,15 @@
|
|||
# pmacs agent instructions
|
||||
|
||||
**Start here: read `docs/agent-handoff.md`, then
|
||||
**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then
|
||||
`docs/active-work.md`, before taking on any work.** The handoff carries
|
||||
durable project state, working method, substrate invariants, and the
|
||||
standing backlog. The active-work ledger carries volatile branches,
|
||||
standing backlog. `COHERENCE.md` carries the product-coherence thesis
|
||||
and its audited ground truth (scorecard, per-concern gaps, priority
|
||||
order) — it is the standard new work gets evaluated against, not just a
|
||||
backlog item; read it before framing anything and cite the section a
|
||||
framing doc serves. The active-work ledger carries volatile branches,
|
||||
checkpoints, verification, and exact cross-machine recovery commands.
|
||||
Keep both updated according to their own update protocols.
|
||||
Keep all three updated according to their own update protocols.
|
||||
|
||||
Always true, independent of the handoff:
|
||||
|
||||
|
|
@ -14,7 +18,11 @@ Always true, independent of the handoff:
|
|||
(`pmacs-protocol`). `#![forbid(unsafe_code)]`.
|
||||
- Workflow: framing doc in `docs/` -> user approval -> branch -> implement
|
||||
-> full gate suite -> PR -> user review rounds -> user says when to
|
||||
merge. Never merge unprompted. One feature, one branch, one PR.
|
||||
merge. Never merge unprompted. One feature, one branch, one PR. A
|
||||
framing doc for coherence-affecting work should state its coherence
|
||||
impact (journey steps touched, interaction islands added, config
|
||||
registry adoption, background-work attribution) per `COHERENCE.md`
|
||||
§20.
|
||||
- Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace
|
||||
--all-targets -- -D warnings` (as its own step); `cargo test --lib`;
|
||||
`cargo test --lib --features crdt`; the touched acceptance suites;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -169,6 +169,27 @@ dependencies = [
|
|||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arborium-lean"
|
||||
version = "2.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80b795046d03aae5780c58e746ddaf780f683e36d9efa8f67abbe9bc01299eb5"
|
||||
dependencies = [
|
||||
"arborium-sysroot",
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arborium-sysroot"
|
||||
version = "2.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59d99d80550b726f9dec7ee6d07118c31e08b10e729ac488eabd4c10603dc841"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"dlmalloc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
|
|
@ -747,6 +768,17 @@ dependencies = [
|
|||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlmalloc"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
|
|
@ -2538,6 +2570,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
|||
name = "pmacs"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"arborium-lean",
|
||||
"codebook-tree-sitter-latex",
|
||||
"crossbeam",
|
||||
"crossterm",
|
||||
|
|
|
|||
18
Cargo.toml
18
Cargo.toml
|
|
@ -241,6 +241,24 @@ codebook-tree-sitter-latex = "0.6"
|
|||
# engine (see `crate::syntax::BUILTIN_LANGUAGES`).
|
||||
tree-sitter-html = "0.23"
|
||||
tree-sitter-css = "0.25"
|
||||
# Lean 4 (`.lean`) — Arc 8 Stage 1 (`docs/lean4-mode-framing.md`, Q#LN1).
|
||||
# `leanprover` ships no tree-sitter grammar (Lean parses with its own
|
||||
# kernel), so both candidates are third-party. The obvious-looking
|
||||
# `tree-sitter-lean4` is NOT usable: it depends on `tree-sitter = "0.25"`
|
||||
# DIRECTLY rather than the shared `tree-sitter-language` ABI crate, which
|
||||
# `^0.25` makes incompatible with our 0.26 and would fork the graph (the
|
||||
# same defect that rules out `tree-sitter-dockerfile` above); it exports
|
||||
# only `pub fn language()` while its README advertises a `LANGUAGE` const
|
||||
# that does not exist; and its package `include` omits `queries/`, so it
|
||||
# ships no highlights at all. `arborium-lean` is a republish from the
|
||||
# arborium grammar collection that does it correctly: `tree-sitter-language
|
||||
# 0.1` as its sole runtime dep, a pre-generated ABI-15 `parser.c` plus
|
||||
# `scanner.c` (no CLI at build time), and `HIGHLIGHTS_QUERY` /
|
||||
# `INJECTIONS_QUERY` / `LOCALS_QUERY` constants. Note the shape: it exports
|
||||
# `const fn language() -> LanguageFn`, so the entry in
|
||||
# `crate::syntax::BUILTIN_LANGUAGES` reads `arborium_lean::language().into()`
|
||||
# rather than the `LANGUAGE.into()` every other entry uses.
|
||||
arborium-lean = "2.18"
|
||||
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
|
||||
# the feature surface tight to keep build time low. `poll` feeds the
|
||||
# compile-mode group readers (cancellable poll-based reads, Q#CM3).
|
||||
|
|
|
|||
|
|
@ -611,6 +611,129 @@ cmd { name = "editor.switch-buffer",
|
|||
}
|
||||
end }
|
||||
|
||||
-- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ----------------
|
||||
--
|
||||
-- Until now pmacs had no discoverable way to open a file by path: a file
|
||||
-- entered a session only from the CLI, an LSP jump, a project-search
|
||||
-- visit, or `C-x C-r` (whose prompt does pass free text through, but
|
||||
-- completes only over the recent list). This is that surface.
|
||||
--
|
||||
-- Two substrate facts shape it, and both are load-bearing:
|
||||
--
|
||||
-- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and
|
||||
-- yields bare basenames (`minibuffer.rs` `list_directory`), capped at
|
||||
-- the shared candidate limit. A custom function source could not do
|
||||
-- better: sources are called with NO arguments, so a callback cannot
|
||||
-- see the input to re-root on, and it runs synchronously outside any
|
||||
-- coroutine, where `Handle:await()` raises --- so it cannot list a
|
||||
-- directory either. Hierarchical completion is a named Rust change in
|
||||
-- the framing, not something this command can fake.
|
||||
--
|
||||
-- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates`
|
||||
-- sets `selected = Some(0)` whenever the candidate list is non-empty,
|
||||
-- and `resolve_accepted_value` returns the CANDIDATE whenever
|
||||
-- anything is selected. So `on_accept` receives typed text only when
|
||||
-- the input filters every candidate away --- which, since candidates
|
||||
-- are basenames and the filter is a subsequence match, is exactly
|
||||
-- when the input contains a `/`. That makes the deeper-path case work
|
||||
-- (`sub/inner.txt` matches no basename, so it arrives verbatim) and
|
||||
-- leaves TWO documented consequences, each pinned by a test rather
|
||||
-- than left to be rediscovered:
|
||||
--
|
||||
-- (a) typing a NEW bare name that happens to be a subsequence of an
|
||||
-- existing entry opens the existing file instead of creating the
|
||||
-- new one --- `find_file_selected_candidate_shadows_typed_text`.
|
||||
-- A new bare name that matches nothing is unaffected and creates
|
||||
-- normally (`find_file_bare_new_name_creates_in_the_root`).
|
||||
-- (b) accepting on EMPTY input opens the first candidate in sort
|
||||
-- order. `fuzzy_score` returns `Some(0)` for an empty needle, so
|
||||
-- everything ties and `filter_and_sort` falls back to
|
||||
-- lexicographic order --- which puts dotfiles first, and can put
|
||||
-- a DIRECTORY first, in which case the open fails and reports.
|
||||
-- This is the same mechanism `M-x` and `switch-buffer` already
|
||||
-- have, so it is inherited rather than introduced; it is recorded
|
||||
-- as decided, not overlooked, and listed in the framing's
|
||||
-- deferrals beside the accept-semantics fix that would close it.
|
||||
--
|
||||
-- The root is the active buffer's directory, or the process cwd when the
|
||||
-- buffer has no backing path (`source_root` defaults to "." Rust-side,
|
||||
-- so the nil case needs no special handling here). It appears in the
|
||||
-- prompt because the field itself must stay empty: any prefill would
|
||||
-- contain a `/` and filter every candidate away, killing completion.
|
||||
|
||||
-- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil.
|
||||
local function find_file_dirname(path)
|
||||
local dir = path:match("^(.*)/[^/]*$")
|
||||
if dir == nil then return nil end
|
||||
if dir == "" then return "/" end
|
||||
return dir
|
||||
end
|
||||
|
||||
-- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` ->
|
||||
-- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's
|
||||
-- own `expand_tilde`.
|
||||
--
|
||||
-- This has to happen HERE, before the path reaches the core, because
|
||||
-- `get_or_load_buffer` normalizes the path it STORES but loads from the
|
||||
-- raw one --- so a `~/...` path deduplicates against an already-open
|
||||
-- buffer yet fails to load when the file is not open yet. Expanding up
|
||||
-- front makes both halves agree.
|
||||
local function find_file_expand_tilde(path)
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then return path end
|
||||
if home:sub(-1) == "/" then home = home:sub(1, -2) end
|
||||
if path == "~" then return home end
|
||||
local rest = path:match("^~/(.*)$")
|
||||
if rest == nil then return path end
|
||||
return home .. "/" .. rest
|
||||
end
|
||||
|
||||
-- Turn an accepted value into a path. The value is either a bare
|
||||
-- basename (a selected candidate) or whatever the user typed, so a
|
||||
-- non-absolute value joins onto the prompt's root --- which resolves
|
||||
-- both cases to the same file when they name the same one.
|
||||
local function find_file_resolve(root, value)
|
||||
local path = find_file_expand_tilde(value)
|
||||
if path:sub(1, 1) == "/" then return path end
|
||||
local base = root or "."
|
||||
if base:sub(-1) == "/" then return base .. path end
|
||||
return base .. "/" .. path
|
||||
end
|
||||
|
||||
-- The active buffer's directory, or nil when it has no backing path.
|
||||
local function find_file_root()
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf == nil then return nil end
|
||||
local ok, path = pcall(function() return buf:path() end)
|
||||
if not (ok and path) then return nil end
|
||||
return find_file_dirname(path)
|
||||
end
|
||||
|
||||
cmd { name = "find-file",
|
||||
description = "Open a file by path, completing within one directory.",
|
||||
fn = function()
|
||||
local root = find_file_root()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Find file (" .. (root or ".") .. "): ",
|
||||
source = "files",
|
||||
source_root = root,
|
||||
history = "find-file",
|
||||
on_accept = function(value)
|
||||
if value == nil or value == "" then return end
|
||||
local path = find_file_resolve(root, value)
|
||||
-- A path that does not exist yet CREATES a buffer bound to
|
||||
-- it: `display_file` routes through `resolve_target_buffer`,
|
||||
-- which on NotFound creates, binds, and sets "[new file]".
|
||||
-- That is Emacs parity and deliberate, so only a real
|
||||
-- failure (a directory, a permission error) reaches here.
|
||||
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
|
||||
if not ok then
|
||||
pmacs.editor.set_status("find-file: " .. tostring(err))
|
||||
end
|
||||
end,
|
||||
}
|
||||
end }
|
||||
|
||||
-- Command palette (M-x) ------------------------------------------------------
|
||||
--
|
||||
-- Opens the minibuffer with a "commands" completion source, then
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ bind("C-x o", "window.focus-next")
|
|||
bind("C-x O", "window.focus-prev")
|
||||
bind("C-x 0", "window.close")
|
||||
bind("C-x 1", "window.close-others")
|
||||
bind("C-x C-f", "find-file")
|
||||
bind("C-x b", "editor.switch-buffer")
|
||||
bind("C-x C-b", "editor.list-buffers")
|
||||
bind("C-x <right>", "editor.next-buffer")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ pmacs.comment.strings = {
|
|||
sh = "#",
|
||||
toml = "#",
|
||||
yaml = "#",
|
||||
-- Lean 4 (framing Q#LN5). `--` only: Lean's block comment is `/- -/` and
|
||||
-- its docstring `/-- -/`, but block-comment toggling is the comment arc's
|
||||
-- own named deferral and this lane does not front-run it.
|
||||
lean4 = "--",
|
||||
}
|
||||
|
||||
-- Start of the line containing `pos`: chunked backward scan for the
|
||||
|
|
|
|||
|
|
@ -732,8 +732,29 @@ end)
|
|||
|
||||
-- Start a run in `slot`. Shared by compile and shell-command; grep
|
||||
-- has its own worker path.
|
||||
-- Whether `buf` is currently the acting frontend's side-window buffer
|
||||
-- (bottom-panel arc). Used so a recompile re-displays into the panel it
|
||||
-- is already in rather than duplicating itself into the document window.
|
||||
local function already_in_panel(buf)
|
||||
if not buf then return false end
|
||||
local panel = pmacs.window.panel()
|
||||
if not panel then return false end
|
||||
local ok, shown = pcall(pmacs.window.buffer, panel)
|
||||
return ok and shown == buf
|
||||
end
|
||||
|
||||
local function start_run(slot, cmdline, opts)
|
||||
opts = opts or {}
|
||||
-- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run
|
||||
-- supersedes anything, rewrites the buffer, or spawns a process, so
|
||||
-- an unknown value leaves no half-started run behind. In Stages 1-2
|
||||
-- omission means "current"; Stage 3 flips the default.
|
||||
local display = opts.display
|
||||
if display ~= nil and display ~= "current" and display ~= "panel" then
|
||||
error(string.format(
|
||||
"compile.run: unknown display %q (expected \"current\" or \"panel\")",
|
||||
tostring(display)))
|
||||
end
|
||||
-- q-target discipline (Q#CM11): capture only when coming from a
|
||||
-- non-generated buffer, so `g` reruns don't re-capture and
|
||||
-- compile → g → q restores the original buffer.
|
||||
|
|
@ -805,7 +826,27 @@ local function start_run(slot, cmdline, opts)
|
|||
-- attach here stacked a duplicate render view per run (round-5
|
||||
-- finding 1; translation itself is buffer-level and unaffected by
|
||||
-- attachment count).
|
||||
pmacs.window.switch_buffer(slot.buf)
|
||||
-- The FIRST display of this run is the side-affine one (Q#BP3): a
|
||||
-- persistent *compilation* already visible in a document window must
|
||||
-- not preempt the requested panel. Compile output is passive, so it
|
||||
-- takes `select = false` explicitly.
|
||||
--
|
||||
-- A recompile reaches here with NO `display` (only cmdline/cwd are
|
||||
-- stored in `_last`), so the raw switch below would put this buffer in
|
||||
-- the selected DOCUMENT window while the panel still shows it — the
|
||||
-- duplicate presentation this arc removes elsewhere. Detect that the
|
||||
-- buffer already owns the panel slot and keep it there.
|
||||
--
|
||||
-- Gated on OMISSION, never on an explicit value: `display = "current"`
|
||||
-- is the documented user-facing opt-out from the Stage 3 default flip,
|
||||
-- so it must reach the raw switch even when the previous run was
|
||||
-- panel-placed. The duplicate presentation that produces is the
|
||||
-- escape hatch's documented cost (R3-rp2).
|
||||
if display == "panel" or (display == nil and already_in_panel(slot.buf)) then
|
||||
pmacs.window.display(slot.buf, { side = "bottom", select = false })
|
||||
else
|
||||
pmacs.window.switch_buffer(slot.buf)
|
||||
end
|
||||
if not ok then
|
||||
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
|
||||
slot.expected_rev = buf:revision()
|
||||
|
|
@ -866,7 +907,9 @@ local function visit_error(slot, idx)
|
|||
if not e then return end
|
||||
local path = resolve_error_path(slot, e.file)
|
||||
pmacs.editor.push_jump()
|
||||
local ok, err = pcall(pmacs.buffer.find_or_open, path)
|
||||
-- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the
|
||||
-- source in the document target, leaving the panel where it is.
|
||||
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
|
||||
if not ok then
|
||||
pmacs.editor.jump_back()
|
||||
pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err))
|
||||
|
|
@ -995,6 +1038,15 @@ pmacs.command.define {
|
|||
fn = function()
|
||||
local slot = slot_for_buffer(pmacs.window.buffer())
|
||||
if not slot then return end
|
||||
-- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or
|
||||
-- restores the PRESENTATION rather than leaving a source buffer
|
||||
-- stranded in the panel slot. Capability fallback and pre-arc
|
||||
-- placement keep today's previous-buffer restore below.
|
||||
local params = pmacs.window.params()
|
||||
if params and params.side and params.quit_action then
|
||||
pmacs.window.quit()
|
||||
return
|
||||
end
|
||||
local target = slot.prev
|
||||
if not (target and target:is_valid()) then
|
||||
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
|
||||
|
|
|
|||
|
|
@ -123,7 +123,23 @@ function pmacs.listview.open(spec)
|
|||
p.prev = active
|
||||
end
|
||||
render(p, spec.rows or {})
|
||||
pmacs.window.switch_buffer(p.buffer)
|
||||
-- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and
|
||||
-- `listview.refresh` are active-window-only, so an interactive panel
|
||||
-- MUST take `select = true` or it would silently seat the wrong
|
||||
-- window. In Stages 1-2 omitting `display` keeps today's raw switch;
|
||||
-- Stage 3 flips the default. An unknown value errors before anything
|
||||
-- is displayed.
|
||||
local display = spec.display
|
||||
if display ~= nil and display ~= "current" and display ~= "panel" then
|
||||
error(string.format(
|
||||
"listview.open: unknown display %q (expected \"current\" or \"panel\")",
|
||||
tostring(display)))
|
||||
end
|
||||
if display == "panel" then
|
||||
pmacs.window.display(p.buffer, { side = "bottom", select = true })
|
||||
else
|
||||
pmacs.window.switch_buffer(p.buffer)
|
||||
end
|
||||
seat_cursor(p, 1)
|
||||
end
|
||||
|
||||
|
|
@ -160,6 +176,15 @@ pmacs.command.define {
|
|||
fn = function()
|
||||
local p = panel_for_current_buffer()
|
||||
if not p then return end
|
||||
-- Bottom-panel arc (Q#BP11b): `q` keeps its name and its
|
||||
-- user-visible behavior, delegating to `window.quit` only when the
|
||||
-- listview really is in a side window. Capability fallback (and any
|
||||
-- pre-arc placement) keeps the previous-buffer switch below.
|
||||
local params = pmacs.window.params()
|
||||
if params and params.side and params.quit_action then
|
||||
pmacs.window.quit()
|
||||
return
|
||||
end
|
||||
local target = p.prev
|
||||
if not (target and target:is_valid()) then
|
||||
target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*")
|
||||
|
|
|
|||
|
|
@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition()
|
|||
return
|
||||
end
|
||||
pmacs.editor.push_jump()
|
||||
local ok2, oerr = pcall(pmacs.buffer.find_or_open, path)
|
||||
-- Bottom-panel arc (Q#BP11b): the target-aware load. `find_or_open`
|
||||
-- switches the ACTIVE window, which would replace a focused panel;
|
||||
-- `display_file` resolves the DOCUMENT target first and fires the
|
||||
-- load/switch hook with that window active.
|
||||
local ok2, oerr = pcall(pmacs.window.display_file, path, { select = true })
|
||||
if not ok2 then
|
||||
-- Open failed: drop the origin we just pushed so M-, isn't
|
||||
-- left pointing at a jump that never happened.
|
||||
|
|
@ -1601,7 +1605,9 @@ local function visit_location(loc)
|
|||
return
|
||||
end
|
||||
pmacs.editor.push_jump()
|
||||
local ok, err = pcall(pmacs.buffer.find_or_open, path)
|
||||
-- Bottom-panel arc (Q#BP11b): a visit FROM a panel must land in the
|
||||
-- document target and leave the panel intact.
|
||||
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
|
||||
if not ok then
|
||||
-- Open failed: drop the origin we just pushed so M-, isn't left
|
||||
-- pointing at a jump that never happened.
|
||||
|
|
|
|||
|
|
@ -62,6 +62,22 @@ pmacs.pair.sets = {
|
|||
markdown = { "()", "[]", "{}", '""', "``" },
|
||||
sh = { "()", "[]", "{}", '""', "''" },
|
||||
bash = { "()", "[]", "{}", '""', "''" },
|
||||
-- Lean 4 (framing Q#LN6). `⟨⟩` (anonymous constructor) is among the
|
||||
-- most-typed constructs in Lean and omitting it would make the pair set
|
||||
-- feel broken; `⦃⦄` (strict implicit binder) and `⟮⟯` ride along because
|
||||
-- the Stage 4 input method can produce them (`\{{}}`, `\([])'`) and a
|
||||
-- bracket the pair set does not understand is worse than one it does.
|
||||
--
|
||||
-- All three are OUTSIDE the nine built-in pair chars, so per Q#AP1 their
|
||||
-- opener is a source-peer op and their closer a daemon-peer op: their undo
|
||||
-- is cross-peer-degraded. That is the documented, pre-existing limitation
|
||||
-- of user-extended pairs, whose general fix is chronological cross-peer
|
||||
-- undo arbitration (named substrate work).
|
||||
--
|
||||
-- No `''`: Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so
|
||||
-- pairing it would fight the user constantly. Same reasoning that excludes
|
||||
-- it for Rust.
|
||||
lean4 = { "()", "[]", "{}", "⟨⟩", "⦃⦄", "⟮⟯", '""' },
|
||||
}
|
||||
|
||||
-- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil
|
||||
|
|
|
|||
|
|
@ -227,6 +227,11 @@ local default_modeline_aliases = {
|
|||
yml = "yaml",
|
||||
makefile = "make",
|
||||
docker = "dockerfile",
|
||||
-- Lean 4 (framing Q#LN2). The grammar entry is named `lean4` because that
|
||||
-- name becomes the `didOpen` language_id, but an Emacs `-*- mode: lean -*-`
|
||||
-- or a Vim `ft=lean` line is what people actually write, so neither
|
||||
-- spelling strands a file.
|
||||
lean = "lean4",
|
||||
}
|
||||
for name, language in pairs(default_modeline_aliases) do
|
||||
if pmacs.parse.modeline_aliases[name] == nil then
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
-- window.lua --- side-window settings, quit, and keyboard resize.
|
||||
--
|
||||
-- The Lua half of the bottom-panel arc's window surface. The placement
|
||||
-- policy itself is Rust (`pmacs.window.display` / `display_file` /
|
||||
-- `quit` / `resize`); this module owns the two settings those paths
|
||||
-- resolve, plus the interactive commands and their Emacs bindings.
|
||||
--
|
||||
-- Both settings are read against the window's OWN buffer (buffer-local
|
||||
-- override -> global -> default), so a project or a mode hook can pin a
|
||||
-- taller panel for one buffer with `pmacs.config.set_local`.
|
||||
--
|
||||
-- Framing: docs/bottom-panel-framing.md (Q#BP2, Q#BP5b, Q#BP11).
|
||||
|
||||
-- Outer rows (text + mode line) a freshly created panel takes when the
|
||||
-- caller supplies no explicit `height`. Only consulted at CREATION: a
|
||||
-- replacement preserves whatever height the user dragged the slot to.
|
||||
pmacs.config.define {
|
||||
name = "window.panel-height",
|
||||
description = "Outer rows a newly created bottom panel occupies.",
|
||||
type = "integer",
|
||||
default = 12,
|
||||
min = 2,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
-- A preference, not a structural rule: it constrains INTERACTIVE resize
|
||||
-- (drag and the commands below) and is deliberately ignored by the
|
||||
-- ordinary layout pass and by frame-resize reconciliation, so raising it
|
||||
-- can never invalidate a layout that already exists.
|
||||
--
|
||||
-- The registry floor is 1 rather than 2 on purpose: a value below the
|
||||
-- STRUCTURAL floor is clamped when it is read, not rejected when it is
|
||||
-- written, so a user who asks for a smaller minimum simply gets the
|
||||
-- smallest one the layout can actually honor.
|
||||
pmacs.config.define {
|
||||
name = "window.min-height",
|
||||
description = "Smallest outer rows interactive resize will leave a window.",
|
||||
type = "integer",
|
||||
default = 2,
|
||||
min = 1,
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.quit",
|
||||
description = "Quit the selected side window: restore or delete it",
|
||||
fn = function() pmacs.window.quit() end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.enlarge",
|
||||
description = "Make the selected window one row taller",
|
||||
fn = function() pmacs.window.resize(nil, 1) end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "window.shrink",
|
||||
description = "Make the selected window one row shorter",
|
||||
fn = function() pmacs.window.resize(nil, -1) end,
|
||||
}
|
||||
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x ^", command = "window.enlarge" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x C-^", command = "window.shrink" }
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Active work — cross-machine resume ledger
|
||||
|
||||
**Snapshot: 2026-07-24.** This file records volatile work that has not
|
||||
**Snapshot: 2026-07-25.** This file records volatile work that has not
|
||||
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
|
||||
entries when their PR merges; do not let this become a second permanent
|
||||
backlog.
|
||||
|
|
@ -54,10 +54,30 @@ git status --short --branch
|
|||
The `git log` command must expose `0dd16a5` or a newer intentional main.
|
||||
If it does not, stop and repair the remote/fetch configuration.
|
||||
|
||||
## Inline-math slice lane — IN FLIGHT
|
||||
## Inline-math slice lane — PR #158 OPEN, main integrated
|
||||
|
||||
- Portable branch: `githubsucks/inline-math-slice`; worktree
|
||||
`../pmacs-math-slice`.
|
||||
`../pmacs-math-slice`. **PR #158**, base `main`.
|
||||
- **Canonical `main` @ `8c86d34` merged into the lane** (2026-07-25),
|
||||
28 commits behind at the time. Merged rather than rebased, per the
|
||||
#135/#137 precedent: the PR is awaiting review rounds and a rebase
|
||||
would break every review anchor. The only conflict was this ledger —
|
||||
both sides' lanes were kept — and it was **pre-existing**, not
|
||||
introduced by the dired (#164) or Lean 4 ledger commits; it already
|
||||
conflicted against `main` @ `e745068`.
|
||||
- **Integration surface** (derived from `git diff <merge-base>..main`,
|
||||
not from another PR's file list): `pmacs-gpu/src/main.rs` gained 72
|
||||
lines on main from `e547a90` — the minimap all-blank-slab
|
||||
divide-by-zero fix — and this lane rewrites large parts of the same
|
||||
file. Git auto-merged it **textually**; a clean auto-merge is not
|
||||
evidence the tree compiles (the folding-arc lesson), so the full gate
|
||||
suite below is what actually discharges it.
|
||||
- **CI had never run on this branch** — zero workflow runs since the PR
|
||||
opened on 2026-07-24, while every other open PR has a full 12-check
|
||||
run. Not a fork and not a trigger-config issue (the workflow fires on
|
||||
all `pull_request` events); cause unidentified. The integration push
|
||||
is what gets it its first run, so treat that run as the branch's
|
||||
first real CI evidence.
|
||||
- Framing: `docs/inline-math-slice-framing.md` rev 3, approved after two
|
||||
review rounds; parent arc framing merged as #154.
|
||||
- State: parser, font bundle (GUST licence), MATH-table layout with the
|
||||
|
|
@ -79,15 +99,276 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
dependency line) are implemented on the branch tip.
|
||||
- Clippy is CLEAN on the whole workspace at `-D warnings` — the draw
|
||||
pass consumed every formerly-dead item.
|
||||
- Verification at the tip: 199 `pmacs-gpu` tests under
|
||||
`PMACS_REQUIRE_GPU=1`; 1,815 default + 1,992 CRDT library tests; M4
|
||||
121; full workspace sweep green (isolated `XDG_CONFIG_HOME`).
|
||||
- Remaining before PR: nothing known; awaiting the user's review pass.
|
||||
- Verification **pre-integration** (at `14c1c01`, against the old base):
|
||||
199 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`; 1,815 default +
|
||||
1,992 CRDT library tests; M4 121; full workspace sweep green (isolated
|
||||
`XDG_CONFIG_HOME`). Superseded by the post-integration run below —
|
||||
those numbers describe a tree 28 commits behind.
|
||||
- Verification **post-integration** (the merge commit; this is the set
|
||||
that describes what the PR now proposes): recorded when the gate suite
|
||||
finishes.
|
||||
- Remaining: the user's review pass.
|
||||
Named v0 approximations: the peer-caret half of acceptance 14 is
|
||||
pinned at the mapping level (unit tests), 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
|
||||
(the draw anchors to the real shaped baseline either way).
|
||||
## Lean 4 lane (Arc 8) — Stage 1 IN REVIEW (PR #160)
|
||||
|
||||
- Portable branch: `githubsucks/lean4-stage1`, worked in the shared
|
||||
checkout (no sibling worktree), based on `githubsucks/main` @ `e745068`.
|
||||
- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as
|
||||
the branch's first commit (`a382965`) after three review rounds. **Seven
|
||||
stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star:
|
||||
match or exceed VS Code's Lean support.
|
||||
- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no
|
||||
frontend change.** Four commits: framing, grammar, theme captures,
|
||||
editing surface + acceptance.
|
||||
- `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one
|
||||
`BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes
|
||||
the `didOpen` language_id), claiming `.lean` only.
|
||||
- `src/highlight.rs`: four capture entries — `constructor`, `character`,
|
||||
`keyword.conditional`, `warning`.
|
||||
- `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the
|
||||
`⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean` → `lean4` modeline alias.
|
||||
- `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` /
|
||||
`highlight.rs`: 12 criteria, 17 tests.
|
||||
- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is
|
||||
unusable (depends on `tree-sitter ^0.25` directly against our 0.26,
|
||||
exports no `LANGUAGE` const despite its README, packages no queries);
|
||||
`arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated
|
||||
ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke
|
||||
pins the failure mode that matters: `→`/`∀`/`≥` must produce
|
||||
`(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build
|
||||
degrades silently on exactly those characters rather than failing loudly.
|
||||
- **Q#LN4 is a deliberate retro-paint of seven language entries**, not
|
||||
four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
|
||||
base-first into javascriptreact/typescript/typescriptreact. Its shape is
|
||||
"every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table
|
||||
brace — not "constructors". Pinned in both directions per #146.
|
||||
- Implementation findings not in the framing:
|
||||
- `warning` had to move from bold red to bold **bright** red: `number`
|
||||
is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the
|
||||
same colour. Found by writing the test.
|
||||
- `Some(1)` is **not** `@constructor` — in call position a narrower
|
||||
`@function` pattern wins. Only bare or pattern-position capitalized
|
||||
identifiers reach it. Pinned so the blast-radius claim stays honest.
|
||||
- Lean node kinds nest: `module > declaration > def|theorem`.
|
||||
- `pmacs.parse.injection_aliases` is a documented **write-only** Lua
|
||||
proxy (canonical map is Rust-side), so fence tests must drive
|
||||
`_parse_now` and inspect layer languages, never read the table back.
|
||||
- **Review round 1 addressed.** The finding: acc12's server-list assertion
|
||||
could not fail for the regression it named — the shared `editor()`
|
||||
helper wipes `pmacs.lsp.config` before any buffer opens, so
|
||||
`#pmacs.lsp.list() == 0` holds for every language regardless of what
|
||||
Stage 1 ships. It now asserts against a **pristine** `EditorState` that
|
||||
`pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same
|
||||
lookup finds `rust`; bite-verified by adding a `lean4` config to
|
||||
`lsp.lua` and watching it fail. Also fixed a stale column in a
|
||||
`highlight.rs` comment.
|
||||
- Verification on this branch: `cargo fmt --check` clean; strict workspace
|
||||
Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1
|
||||
9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU
|
||||
152; **isolated-config workspace sweep 3,150 across 90 suites**;
|
||||
`git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME`
|
||||
for the reason recorded in the bottom-panel lane below.
|
||||
- **Stage 2 is multi-root LSP server affinity** — pure substrate, no Lean
|
||||
content, and it changes `ensure_server`, which every LSP language
|
||||
shares. It is sequenced next because Lean is the language that makes its
|
||||
absence a correctness failure rather than an inconvenience. Two
|
||||
corrections the framing already carries for it: `root` is computed at
|
||||
`lsp.lua:537`, **after** the reuse loop, so the fix must hoist it; and
|
||||
`project_root_for` never returns nil for a file with a path, so the
|
||||
affinity key must be the root only when a root was actually *detected*,
|
||||
or markerless scratch files fragment into one server per directory for
|
||||
every language.
|
||||
|
||||
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
|
||||
|
||||
- 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`).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW
|
||||
|
||||
- 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.
|
||||
|
||||
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-24, after GPU initial-target (#148, protocol v20)
|
||||
landed, following folding Stage 2 (#149) and its landed-doc refresh (#150),
|
||||
**Last updated: 2026-07-25, after find-file (#162) landed — the dired
|
||||
arc's Stage 0 — following 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
|
||||
initial-target (#148, protocol v20),
|
||||
following folding Stage 2 (#149) and its landed-doc refresh (#150),
|
||||
web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair
|
||||
(#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the
|
||||
documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering
|
||||
|
|
@ -19,9 +24,55 @@ reads it the way you just did.
|
|||
For volatile branches, checkpoints, verification, and recovery
|
||||
commands, read `docs/active-work.md` immediately after this file.
|
||||
|
||||
## 1. Where the project stands (2026-07-24)
|
||||
## 1. Where the project stands (2026-07-25)
|
||||
|
||||
- `main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2 landed-doc
|
||||
- `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.
|
||||
- **`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
|
||||
standard new work is evaluated against. Per `CLAUDE.md`, **every new
|
||||
framing doc must state its coherence impact** — journey steps touched,
|
||||
interaction islands added, config-registry adoption, background-work
|
||||
attribution. Its §2 grades the golden journey **broken at step 3**
|
||||
(`pmacs .` exits 1).
|
||||
- **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
|
||||
such command existed and `pmacs.buffer.find_or_open` had no interactive
|
||||
caller. Pure Lua in `builtin/commands/default.lua`, one keymap line, an
|
||||
8-test dispatch-driven acceptance suite; no Rust, no protocol change.
|
||||
Two substrate facts it documents, both worth knowing before touching
|
||||
any minibuffer prompt:
|
||||
- **Completion over files is flat and cannot be made hierarchical from
|
||||
Lua.** A custom `source` function is called with **zero arguments**
|
||||
(`minibuffer.rs:591`) and runs synchronously outside any coroutine,
|
||||
where `Handle:await()` raises — so it can neither see the input to
|
||||
re-root on nor list a directory. Only the Rust
|
||||
`CompletionSource::Files { root }` can list, and it is
|
||||
single-directory and 1024-capped.
|
||||
- **A selected candidate SHADOWS typed text.** `recompute_candidates`
|
||||
sets `selected = Some(0)` whenever the list is non-empty
|
||||
(`minibuffer.rs:372-377`) and `resolve_accepted_value` returns the
|
||||
candidate over the typed contents (`:564-574`). So free-text accept
|
||||
fires only when the input filters every candidate away — for
|
||||
basename candidates under a subsequence filter, when it contains a
|
||||
`/`. This applies to `M-x` and `switch-buffer` too. Consequences are
|
||||
pinned as decisions, including the hole where a new bare name that is
|
||||
a subsequence of an existing entry opens the existing file, and the
|
||||
empty-input case (`fuzzy_score` gives `Some(0)` for an empty needle
|
||||
and ties break lexicographically, so dotfiles lead).
|
||||
- Also: `get_or_load_buffer` computes a normalized path but **loads
|
||||
from the raw one** (`editor_core.rs:842-856`), so a `~/…` path dedups
|
||||
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 =
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -8741,7 +8741,17 @@ fn dominant_line_shape(
|
|||
indent_sum += shape.indent_cols;
|
||||
content_sum += shape.content_cols;
|
||||
}
|
||||
(count > 0).then_some(MinimapLineShape {
|
||||
// `then`, NOT `then_some`: `bool::then_some` takes its argument by
|
||||
// value, so the struct literal --- and with it `indent_sum / count`
|
||||
// --- is evaluated before the guard is ever consulted. A slab of
|
||||
// all-blank source lines makes `count` zero and panics the frontend
|
||||
// on the division. `bool::then` defers the body into a closure, so
|
||||
// the zero case short-circuits to `None`.
|
||||
//
|
||||
// Clippy's `unnecessary_lazy_evaluations` lint pushes in exactly the
|
||||
// wrong direction here; it does not fire on a body that can panic,
|
||||
// but do not "simplify" this back.
|
||||
(count > 0).then(|| MinimapLineShape {
|
||||
indent_cols: indent_sum / count,
|
||||
content_cols: content_sum.div_ceil(count),
|
||||
})
|
||||
|
|
@ -11433,6 +11443,66 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimap_downsampling_survives_a_slab_of_blank_lines() {
|
||||
// Regression: `dominant_line_shape` counted only lines with
|
||||
// content, then built its average with `then_some` --- which
|
||||
// evaluates its argument eagerly, so `indent_sum / count`
|
||||
// divided by zero whenever a downsampled pixel row covered
|
||||
// nothing but blank lines. Reachable on any long file with a
|
||||
// run of blank lines, which is precisely when the bucketing
|
||||
// branch runs at all.
|
||||
let red = style_with_fg(CellColor::Rgb(255, 0, 0));
|
||||
let lines = vec![red; 10_000];
|
||||
// Every line blank: `minimap_line_shape("")` yields
|
||||
// `content_cols == 0`, so `has_content()` is false throughout
|
||||
// and every bucket counts zero contentful lines.
|
||||
let shapes = vec![
|
||||
MinimapLineShape {
|
||||
indent_cols: 0,
|
||||
content_cols: 0,
|
||||
};
|
||||
lines.len()
|
||||
];
|
||||
|
||||
let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30, FontMetrics::default());
|
||||
|
||||
// The strokes are all suppressed (no content to draw), but the
|
||||
// thumb still paints --- the point is that this returns at all.
|
||||
assert!(
|
||||
rects.len() <= 8,
|
||||
"blank slabs must emit no line strokes, got {}",
|
||||
rects.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimap_downsampling_averages_only_contentful_lines() {
|
||||
// Guards the other half: a bucket that mixes blank and
|
||||
// contentful lines must average over the contentful ones only,
|
||||
// so the fix cannot regress into `count = slice.len()`.
|
||||
let blank = MinimapLineShape {
|
||||
indent_cols: 0,
|
||||
content_cols: 0,
|
||||
};
|
||||
let solid = MinimapLineShape {
|
||||
indent_cols: 4,
|
||||
content_cols: 20,
|
||||
};
|
||||
let shapes = [blank, solid, solid, blank];
|
||||
|
||||
let shape = dominant_line_shape(&shapes, 0, 4).expect("bucket has contentful lines");
|
||||
|
||||
assert_eq!(shape.indent_cols, 4, "blank lines must not dilute indent");
|
||||
assert_eq!(shape.content_cols, 20, "blank lines must not dilute length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimap_dominant_line_shape_is_none_for_an_empty_bucket() {
|
||||
let shape = dominant_line_shape(&[], 0, 0);
|
||||
assert!(shape.is_none(), "an empty bucket has no shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimap_hidden_when_surface_is_too_narrow() {
|
||||
let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))];
|
||||
|
|
|
|||
272
src/daemon.rs
272
src/daemon.rs
|
|
@ -894,6 +894,21 @@ fn peer_declared_terminal_support(
|
|||
.is_some_and(|state| state.negotiated_protocol_version >= 19)
|
||||
}
|
||||
|
||||
/// Whether a session can **render** a side window (bottom-panel arc,
|
||||
/// Q#BP13).
|
||||
///
|
||||
/// Grid sessions paint the whole cell grid the daemon composes, so a side
|
||||
/// window is just another leaf for them. A semantic session needs the
|
||||
/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1
|
||||
/// answers `false` for every semantic peer, whatever it declares. No
|
||||
/// client-asserted standalone boolean is trusted: the answer is derived
|
||||
/// from the daemon's own negotiated state, and Stage 2 turns the version
|
||||
/// arm on (`semantic_render && negotiated_protocol_version >=
|
||||
/// PANEL_MIN_VERSION`).
|
||||
fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
|
||||
!session_state.negotiated_capabilities.semantic_render
|
||||
}
|
||||
|
||||
/// The same belt-and-braces write-loop gate for the additive
|
||||
/// protocol-v19 terminal frame. The semantic producer skips construction
|
||||
/// for an older peer; this filter independently prevents an unknown
|
||||
|
|
@ -1628,38 +1643,40 @@ fn open_initial_target(
|
|||
target: InitialTarget,
|
||||
) -> Result<OpenedInitialTarget, String> {
|
||||
let path = resolve_initial_target(target);
|
||||
let display_path = path.display().to_string();
|
||||
let (buffer_id, newly_loaded, newly_created) = {
|
||||
// Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's
|
||||
// ORIGINAL document window before any I/O. A startup hook may now
|
||||
// create and select a side window, and bootstrap must reassert the
|
||||
// requested buffer in a document window rather than overwriting a
|
||||
// panel merely because it became `view.active`.
|
||||
let (origin_window, buffer_id, fire) = {
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_frontend = frontend_id;
|
||||
let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) {
|
||||
Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
let buffer_id = core.registry.borrow_mut().create(display_path.clone());
|
||||
core.set_buffer_path(buffer_id, Some(path.clone()));
|
||||
"[new file]".clone_into(&mut core.status);
|
||||
(buffer_id, false, true)
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!("cannot open {}: {error}", path.display()));
|
||||
}
|
||||
};
|
||||
core.switch_active_buffer_for(frontend_id, buffer_id)
|
||||
let origin_window = core
|
||||
.primary_document_window(frontend_id)
|
||||
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
|
||||
let (buffer_id, fire) = core.resolve_target_buffer(&path)?;
|
||||
core.install_buffer_in_window(origin_window, buffer_id)
|
||||
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
|
||||
(buffer_id, newly_loaded, newly_created)
|
||||
core.focus_window(frontend_id, origin_window);
|
||||
(origin_window, buffer_id, fire)
|
||||
};
|
||||
|
||||
if newly_loaded {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||||
} else if !newly_created {
|
||||
match fire {
|
||||
crate::editor_core::HookKind::AfterLoad => {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||||
}
|
||||
// Dedup is a logical switch even when the fresh view already shares
|
||||
// this BufferId; configuration must observe it exactly once.
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-switch", mlua::MultiValue::new());
|
||||
crate::editor_core::HookKind::AfterSwitch => {
|
||||
editor
|
||||
.lua_host
|
||||
.run_hook("buffer.after-switch", mlua::MultiValue::new());
|
||||
}
|
||||
crate::editor_core::HookKind::None => {}
|
||||
}
|
||||
editor.reconcile_panel_layout(frontend_id);
|
||||
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_frontend = frontend_id;
|
||||
|
|
@ -1669,11 +1686,28 @@ fn open_initial_target(
|
|||
path.display()
|
||||
));
|
||||
}
|
||||
core.switch_active_buffer_for(frontend_id, buffer_id)
|
||||
// Reassert into the original document window when it is still live;
|
||||
// if a hook closed it, rehome to an eligible non-side window in the
|
||||
// same frontend WITHOUT firing a second hook.
|
||||
let destination = if core
|
||||
.views
|
||||
.get(&frontend_id)
|
||||
.is_some_and(|view| view.layout.iter_ids().contains(&origin_window))
|
||||
{
|
||||
origin_window
|
||||
} else {
|
||||
core.non_side_target(frontend_id)
|
||||
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?
|
||||
};
|
||||
core.install_buffer_in_window(destination, buffer_id)
|
||||
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?;
|
||||
core.focus_window(frontend_id, destination);
|
||||
Ok(OpenedInitialTarget {
|
||||
buffer_id,
|
||||
publish_to_replicas: newly_loaded || newly_created,
|
||||
publish_to_replicas: matches!(
|
||||
fire,
|
||||
crate::editor_core::HookKind::AfterLoad | crate::editor_core::HookKind::None
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1766,9 +1800,15 @@ fn handle_session_established(
|
|||
// `RenderState` vs a `SemanticRenderState` below — a grid session
|
||||
// collapses folds, a semantic one keeps raw-line reckoning until
|
||||
// Stage 3.
|
||||
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
|
||||
// negotiated bit in this same transaction. Stage 1 ships the TUI
|
||||
// side windows only, so a semantic session is not panel-capable and
|
||||
// a `side` request falls back to its document target with every
|
||||
// side-specific parameter discarded.
|
||||
let fresh_view = build_fresh_frontend_view(
|
||||
editor,
|
||||
!session_state.negotiated_capabilities.semantic_render,
|
||||
peer_declared_panel_support(session_state),
|
||||
);
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
|
|
@ -1850,6 +1890,14 @@ fn handle_session_established(
|
|||
}
|
||||
streams.insert(frontend_id, write_stream);
|
||||
term_sizes.insert(frontend_id, initial_size);
|
||||
// Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its
|
||||
// authoritative geometry declaration, cached BEFORE any input can
|
||||
// reach it. A semantic session deliberately stays UNKNOWN — Stage 2's
|
||||
// authenticated `FrontendCellGeometry` fills it, and the permanent
|
||||
// 24x80 attach placeholder is never consulted for panel layout.
|
||||
if editor.core.borrow().panel_capable_for(frontend_id) {
|
||||
editor.sync_frame_geometry(frontend_id, initial_size);
|
||||
}
|
||||
|
||||
if let Some(opened) = opened_target {
|
||||
last_active_buffer_sent.insert(frontend_id, opened.buffer_id);
|
||||
|
|
@ -1933,6 +1981,13 @@ fn handle_dispatcher_event(
|
|||
if let Some(ts) = term_sizes.get_mut(&source) {
|
||||
*ts = size;
|
||||
}
|
||||
// Bottom-panel arc (Q#BP2b): a frame that can no
|
||||
// longer satisfy the panel hides it, moves focus out,
|
||||
// and releases its terminal controller here — before
|
||||
// the next drained event dispatches.
|
||||
if editor.core.borrow().panel_capable_for(source) {
|
||||
editor.sync_frame_geometry(source, size);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "crdt")]
|
||||
FrontendEvent::CrdtOp {
|
||||
|
|
@ -2938,6 +2993,10 @@ fn build_fresh_frontend_view(
|
|||
// collapses folds. Passed explicitly from the negotiated
|
||||
// selected-render bit at the call site — never inferred here.
|
||||
fold_projection: bool,
|
||||
// Bottom-panel arc (Q#BP13): whether this session can RENDER a side
|
||||
// window. Same explicit-at-the-call-site discipline as
|
||||
// `fold_projection`; never inferred from a `FrontendId` here.
|
||||
panel_capable: bool,
|
||||
) -> crate::window::FrontendView {
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{FrontendView, Layout, Window, WindowId};
|
||||
|
|
@ -2946,16 +3005,14 @@ fn build_fresh_frontend_view(
|
|||
// scratch). M10.8's fresh-scratch behavior made overlays
|
||||
// never fire because attaching frontends were in distinct
|
||||
// buffers.
|
||||
let local_view = core
|
||||
.views
|
||||
.get(&FrontendId::LOCAL)
|
||||
.expect("LOCAL view present");
|
||||
let local_active_win_id = local_view.active;
|
||||
//
|
||||
// Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT
|
||||
// buffer, not `local_view.active`. A TUI panel may own focus at
|
||||
// attach time, and panel content must never become a newly attached
|
||||
// frontend's full-window document.
|
||||
let buffer_id = core
|
||||
.windows
|
||||
.get(&local_active_win_id)
|
||||
.expect("LOCAL's active window present in core.windows")
|
||||
.buffer_id;
|
||||
.primary_document_buffer(FrontendId::LOCAL)
|
||||
.expect("LOCAL always retains a document window");
|
||||
let text_view = {
|
||||
let reg = core.registry.borrow();
|
||||
let buf = reg.get(buffer_id).expect("shared buffer present");
|
||||
|
|
@ -2968,6 +3025,13 @@ fn build_fresh_frontend_view(
|
|||
layout: Layout::single(id),
|
||||
active: id,
|
||||
fold_projection,
|
||||
panel_capable,
|
||||
// Grid sessions cache their real attach/resize size; a semantic
|
||||
// session stays UNKNOWN until Stage 2's authenticated
|
||||
// declaration, and must never be sized against the attach
|
||||
// request's permanent 24×80 placeholder (Q#BP15a).
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3233,7 +3297,7 @@ mod tests {
|
|||
let semantic_peer = FrontendId(20);
|
||||
let live_grid_peer = FrontendId(21);
|
||||
let dead_grid_peer = FrontendId(22);
|
||||
let semantic_view = build_fresh_frontend_view(&mut editor, false);
|
||||
let semantic_view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
|
|
@ -3885,6 +3949,9 @@ mod tests {
|
|||
layout: Layout::single(wid),
|
||||
active: wid,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -4017,7 +4084,7 @@ mod tests {
|
|||
let fid = FrontendId(99);
|
||||
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
|
||||
// projection until Stage 3).
|
||||
let view = build_fresh_frontend_view(&mut editor, false);
|
||||
let view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
|
||||
let before = editor
|
||||
|
|
@ -4080,7 +4147,7 @@ mod tests {
|
|||
let fid = FrontendId(99);
|
||||
// Both these fixtures model a SEMANTIC session (Q#FD21: no fold
|
||||
// projection until Stage 3).
|
||||
let view = build_fresh_frontend_view(&mut editor, false);
|
||||
let view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
assert_eq!(
|
||||
editor
|
||||
|
|
@ -4123,4 +4190,133 @@ mod tests {
|
|||
"key must self-insert into the displayed buffer, not the attach-time scratch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom-panel arc, §1.3 #22 (framing acceptance 51's Stage-1 half).
|
||||
///
|
||||
/// A fresh no-target attach clones `LOCAL`'s **primary document**
|
||||
/// buffer, not `local_view.active`. Stage 1 makes a TUI panel a real
|
||||
/// focus target, so `LOCAL` can legitimately own focus in a panel at
|
||||
/// attach time — and panel content must never become a newly attached
|
||||
/// frontend's full-window document.
|
||||
#[test]
|
||||
fn fresh_attach_inherits_locals_document_buffer_not_its_focused_panel() {
|
||||
let mut editor = EditorState::new();
|
||||
let document_buffer = editor.core.borrow().active_buffer_id();
|
||||
let panel_buffer = editor.core.borrow().registry.borrow_mut().create("*panel*");
|
||||
// Open a bottom panel on LOCAL and focus it.
|
||||
let panel = {
|
||||
let mut core = editor.core.borrow_mut();
|
||||
let mut request = crate::editor_core::DisplayRequest::new(panel_buffer);
|
||||
request.side = Some(crate::window::Side::Bottom);
|
||||
request.height = Some(5);
|
||||
request.select = Some(true);
|
||||
let outcome = core
|
||||
.display_buffer(FrontendId::LOCAL, &request)
|
||||
.expect("panel placement");
|
||||
core.focus_window(FrontendId::LOCAL, outcome.target);
|
||||
outcome.target
|
||||
};
|
||||
assert_eq!(
|
||||
editor.core.borrow().views[&FrontendId::LOCAL].active,
|
||||
panel,
|
||||
"LOCAL really is focused in the panel"
|
||||
);
|
||||
|
||||
let fid = FrontendId(123);
|
||||
let view = build_fresh_frontend_view(&mut editor, false, false);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
|
||||
assert_eq!(
|
||||
editor
|
||||
.core
|
||||
.borrow()
|
||||
.active_window_for(fid)
|
||||
.expect("fresh view window")
|
||||
.buffer_id,
|
||||
document_buffer,
|
||||
"the new frontend inherited LOCAL's DOCUMENT buffer; inheriting \
|
||||
`local_view.active` would have made the panel its document"
|
||||
);
|
||||
assert_ne!(document_buffer, panel_buffer);
|
||||
}
|
||||
|
||||
/// Bottom-panel arc, Q#BP11b / R4-B4 (framing acceptance 55's
|
||||
/// Stage-1 half).
|
||||
///
|
||||
/// Stage 1 lets a startup hook create and select a side window. The
|
||||
/// initial-target bootstrap must still reassert the requested buffer
|
||||
/// in — and activate — a **non-side** document window, rather than
|
||||
/// overwriting the panel merely because it became `view.active`.
|
||||
#[test]
|
||||
fn initial_target_reasserts_a_document_window_when_a_hook_selects_a_panel() {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let target = dir.path().join("target.txt");
|
||||
std::fs::write(&target, b"target contents\n").expect("write target");
|
||||
|
||||
let mut editor = EditorState::new();
|
||||
editor
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r#"
|
||||
pmacs.lsp.config = {}
|
||||
pmacs.hook.add("buffer.after-load", function()
|
||||
if HOOK_RAN then return end
|
||||
HOOK_RAN = true
|
||||
HOOK_PANEL = pmacs.window.display(
|
||||
pmacs.buffer.create("*hook-panel*"),
|
||||
{ side = "bottom", height = 4, select = true })
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.exec()
|
||||
.expect("install hook");
|
||||
|
||||
// A GRID session (panel-capable), which is the realistic shape
|
||||
// for a hook-created panel in Stage 1 — and real geometry, so
|
||||
// the panel is genuinely VISIBLE and focused when the reassert
|
||||
// runs. Without the declaration, reconciliation would hide the
|
||||
// panel and move focus out on its own, and the assertions below
|
||||
// would pass without exercising the reassert at all.
|
||||
let fid = FrontendId(124);
|
||||
let view = build_fresh_frontend_view(&mut editor, true, true);
|
||||
editor.core.borrow_mut().register_frontend_view(fid, view);
|
||||
editor.sync_frame_geometry(fid, CellSize::new(24, 80));
|
||||
|
||||
let opened = open_initial_target(
|
||||
&mut editor,
|
||||
fid,
|
||||
InitialTarget {
|
||||
path: target.as_os_str().as_bytes().to_vec(),
|
||||
cwd: dir.path().as_os_str().as_bytes().to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("bootstrap succeeds despite the panel-creating hook");
|
||||
|
||||
let core = editor.core.borrow();
|
||||
assert!(
|
||||
!core.views[&fid].panel_hidden,
|
||||
"the hook's panel is visible, so focus really was on it when \
|
||||
the reassert ran"
|
||||
);
|
||||
let active = core.views[&fid].active;
|
||||
let active_window = core.windows.get(&active).expect("active window live");
|
||||
assert!(
|
||||
!active_window.is_side(),
|
||||
"bootstrap activated a DOCUMENT window, not the hook's panel"
|
||||
);
|
||||
assert_eq!(
|
||||
active_window.buffer_id, opened.buffer_id,
|
||||
"…showing the requested target"
|
||||
);
|
||||
let panel = core
|
||||
.side_window_for(fid)
|
||||
.expect("the hook's panel survived");
|
||||
assert_ne!(
|
||||
core.windows[&panel].buffer_id, opened.buffer_id,
|
||||
"the panel was not overwritten with the target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop>
|
|||
|
||||
let resolve = |wid: WindowId| -> Option<SavedLeaf> {
|
||||
let win = core.windows.get(&wid)?;
|
||||
// Bottom-panel arc (Q#BP10): side windows are transient display
|
||||
// policy, never desktop state. Dropping the leaf here makes the
|
||||
// existing single-surviving-child collapse remove the root
|
||||
// wrapper too, so the saved tree is the document tree exactly —
|
||||
// no `SavedLeaf` shape change and no `DESKTOP_VERSION` bump.
|
||||
if win.is_side() {
|
||||
return None;
|
||||
}
|
||||
let path = reg.get(win.buffer_id).ok()?.file_path()?;
|
||||
Some(SavedLeaf {
|
||||
path: path.display().to_string(),
|
||||
|
|
@ -437,6 +445,12 @@ pub fn restore_into(
|
|||
active,
|
||||
// Desktop restore rebuilds LOCAL's grid view (Q#FD21).
|
||||
fold_projection: true,
|
||||
// …which renders side windows natively (Q#BP13). Every
|
||||
// field is spelled explicitly, preserving folding's
|
||||
// non-`Default` discipline.
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
active
|
||||
|
|
|
|||
360
src/editor.rs
360
src/editor.rs
|
|
@ -161,6 +161,16 @@ pub struct EditorState {
|
|||
/// Last left-button down event, used to synthesize terminal double
|
||||
/// clicks from crossterm's plain Down/Up mouse event stream.
|
||||
mouse_click: Option<MouseClickState>,
|
||||
/// In-progress split-boundary drags (bottom-panel arc, Q#BP5), armed
|
||||
/// by a left press on a mode-line row that is an exposed segment of a
|
||||
/// horizontal boundary. Selection is untouched for the whole gesture.
|
||||
///
|
||||
/// Keyed by frontend, unlike the older global `mouse_click` slot: the
|
||||
/// daemon routes every attached grid frontend through one
|
||||
/// `dispatch_mouse`, so a single slot would let one frontend's press
|
||||
/// steal or clear another's in-flight gesture, and concurrent drags
|
||||
/// are perfectly legal.
|
||||
window_drag: HashMap<FrontendId, WindowDragState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -208,8 +218,25 @@ struct MouseClickState {
|
|||
at: Instant,
|
||||
}
|
||||
|
||||
/// An armed split-boundary drag (Q#BP5).
|
||||
///
|
||||
/// `owner` is the window whose bottom mode-line row was pressed; the
|
||||
/// boundary it resolves to is recomputed on every motion, so a layout
|
||||
/// mutation mid-drag cannot move a boundary that no longer exists.
|
||||
#[derive(Copy, Clone)]
|
||||
struct WindowDragState {
|
||||
owner: WindowId,
|
||||
last_row: u32,
|
||||
}
|
||||
|
||||
const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Grip glyph stamped at the right end of a divider segment (Q#BP5a).
|
||||
///
|
||||
/// It lands on the mode line's protected trailing blank, so it adds no
|
||||
/// column and clobbers no information.
|
||||
const DIVIDER_HANDLE_GLYPH: char = '⇕';
|
||||
|
||||
impl EditorState {
|
||||
/// Construct a fresh editor for an unnamed scratch buffer.
|
||||
///
|
||||
|
|
@ -488,6 +515,16 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/indent.lua"),
|
||||
)
|
||||
.expect("load indent builtin chunk");
|
||||
// Bottom-panel arc: `window.panel-height` / `window.min-height`
|
||||
// plus the quit and keyboard-resize commands. Must load BEFORE
|
||||
// listview/compile/terminal, which resolve `window.panel-height`
|
||||
// when they open a panel.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/window.lua"),
|
||||
include_str!("../builtin/runtime/window.lua"),
|
||||
)
|
||||
.expect("load window 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
|
||||
|
|
@ -586,6 +623,7 @@ impl EditorState {
|
|||
snippets,
|
||||
statusline_registry,
|
||||
mouse_click: None,
|
||||
window_drag: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -763,9 +801,73 @@ impl EditorState {
|
|||
&& !core.search_active()
|
||||
&& !core.query_replace_active()
|
||||
&& !core.menu_is_open()
|
||||
&& core
|
||||
.active_window_for(frontend_id)
|
||||
.is_some_and(|window| !core.buffer_round_trips(window.buffer_id))
|
||||
&& core.active_window_for(frontend_id).is_some_and(|window| {
|
||||
// Bottom-panel arc (Q#BP14a): a focused SIDE window turns
|
||||
// optimistic apply off for this frontend, independently
|
||||
// of the buffer-global round-trip set.
|
||||
//
|
||||
// Marking the panel's BUFFER round-trip instead would be
|
||||
// wrong twice: `round_trip_buffers` is keyed by
|
||||
// `BufferId` across every frontend and window, so it
|
||||
// would disable optimistic input for another frontend
|
||||
// editing the same buffer as its document; and an opt-out
|
||||
// would be unsafe, because the GPU would optimistically
|
||||
// edit its document mirror while daemon input targets the
|
||||
// panel — every resulting op then fails remote-op
|
||||
// validation and the mirror silently diverges.
|
||||
!window.is_side() && !core.buffer_round_trips(window.buffer_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// The idempotent panel-reconciliation transaction (Q#BP2b).
|
||||
///
|
||||
/// Runs after attach / resize / display / split / close, after any
|
||||
/// `fixed_rows` or setting change, after any Lua hook or callback
|
||||
/// transaction that can mutate the layout, and **defensively** before
|
||||
/// final-focus resolution, input dispatch, terminal sync, and paint.
|
||||
/// Two events drained in one burst therefore cannot route the second
|
||||
/// to a panel the first made invisible, and a render callback cannot
|
||||
/// leave stale panel geometry for the painter.
|
||||
pub fn reconcile_panel_layout(&self, frontend_id: FrontendId) -> bool {
|
||||
let outcome = self
|
||||
.core
|
||||
.borrow_mut()
|
||||
.reconcile_panel_layout_core(frontend_id);
|
||||
if let Some(window_id) = outcome.released_terminal {
|
||||
// Hiding is a DURABLE transition: the terminal resize path
|
||||
// merely returns on zero content without releasing the
|
||||
// controller, so an invisible panel would otherwise keep
|
||||
// owning its child.
|
||||
let buffer_id = self
|
||||
.core
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.map(|window| window.buffer_id);
|
||||
if let Some(buffer_id) = buffer_id {
|
||||
let _ = self.terminal_manager.borrow_mut().release_controller(
|
||||
crate::terminal::TerminalViewKey {
|
||||
frontend_id,
|
||||
window_id,
|
||||
buffer_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
outcome.changed
|
||||
}
|
||||
|
||||
/// Cache one frontend's authoritative frame capacity and reconcile
|
||||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// The single seam for grid and `LOCAL` views, whose real attach and
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this
|
||||
/// in Stage 1; its geometry stays **unknown**.
|
||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.declare_frame_geometry(frontend_id, total);
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
}
|
||||
|
||||
/// Local-frontend compatibility wrapper.
|
||||
|
|
@ -777,6 +879,9 @@ impl EditorState {
|
|||
/// Drop one detached frontend's pending key and terminal escape state.
|
||||
pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) {
|
||||
self.dispatchers.remove(&frontend_id);
|
||||
// A detached frontend cannot finish a divider gesture, and its
|
||||
// `owner` window is about to stop being live (Q#BP5).
|
||||
self.window_drag.remove(&frontend_id);
|
||||
self.terminal_manager
|
||||
.borrow_mut()
|
||||
.detach_frontend(frontend_id);
|
||||
|
|
@ -800,6 +905,10 @@ impl EditorState {
|
|||
// Authenticate every path through this input event, including modal
|
||||
// callbacks such as M-x minibuffer acceptance.
|
||||
let _origin = self.interactive_origin.enter(frontend_id);
|
||||
// Bottom-panel arc (Q#BP2b): reconcile defensively before input
|
||||
// dispatch, so two events drained in one burst cannot route the
|
||||
// second to a panel the first made invisible.
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
let chord = key_event_to_chord(key);
|
||||
{
|
||||
let mut core = self.core.borrow_mut();
|
||||
|
|
@ -1084,6 +1193,10 @@ impl EditorState {
|
|||
///
|
||||
/// This is called before process drain and paint, never from rendering.
|
||||
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
|
||||
// Bottom-panel arc (Q#BP2b): a panel that just became
|
||||
// unsatisfiable must have released its controller before this
|
||||
// runs, or the child would be resized against a dead rect.
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
let Some(key) = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
|
|
@ -1815,6 +1928,29 @@ impl EditorState {
|
|||
return;
|
||||
}
|
||||
|
||||
// Bottom-panel arc (Q#BP5): an armed divider drag owns the
|
||||
// pointer for the whole gesture, INCLUDING rows outside any
|
||||
// window — otherwise tracking would stop the moment the pointer
|
||||
// crossed the frame's status row.
|
||||
//
|
||||
// Scoped to the ARMING frontend. The daemon routes every attached
|
||||
// grid frontend through this same dispatcher, so an unscoped
|
||||
// check would let one frontend's in-flight drag cancel and
|
||||
// swallow another frontend's clicks.
|
||||
if self.window_drag.contains_key(&frontend_id) {
|
||||
match ev.kind {
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
self.drag_window_boundary(frontend_id, cell_row, term_size);
|
||||
}
|
||||
// Any other event — release, a different button, a
|
||||
// wheel notch — ends THIS frontend's gesture only.
|
||||
_ => {
|
||||
self.window_drag.remove(&frontend_id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((win_id, rect)) = window_at_cell(
|
||||
&self.core.borrow(),
|
||||
frontend_id,
|
||||
|
|
@ -1826,6 +1962,15 @@ impl EditorState {
|
|||
};
|
||||
let inner_rows = rect.size.rows.saturating_sub(1);
|
||||
let local_row = cell_row.saturating_sub(rect.origin.row);
|
||||
// A press on a mode-line row that is an exposed segment of a
|
||||
// horizontal boundary arms a divider drag, ahead of the terminal
|
||||
// router: a document terminal above the panel owns a boundary
|
||||
// too. Selection is untouched, so this click still creates none.
|
||||
if matches!(ev.kind, MouseEventKind::Down(MouseButton::Left)) && local_row >= inner_rows {
|
||||
self.mouse_click = None;
|
||||
self.arm_window_drag(frontend_id, win_id, cell_row);
|
||||
return;
|
||||
}
|
||||
let buffer_id = self.core.borrow().windows[&win_id].buffer_id;
|
||||
if self.terminal_manager.borrow().is_terminal(buffer_id) {
|
||||
let content_size = CellSize::new(inner_rows, rect.size.cols);
|
||||
|
|
@ -1928,6 +2073,132 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Arm a divider drag if `owner`'s bottom row really is an exposed
|
||||
/// segment of a horizontal boundary (Q#BP5).
|
||||
fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) {
|
||||
let is_divider = self
|
||||
.core
|
||||
.borrow()
|
||||
.views
|
||||
.get(&frontend_id)
|
||||
.is_some_and(|view| view.layout.boundary_below(owner).is_some());
|
||||
// Only this frontend's slot is written, and only its own press
|
||||
// can clear it — a peer pressing some other window's mode line
|
||||
// must not disarm an in-flight gesture here.
|
||||
if is_divider {
|
||||
self.window_drag.insert(
|
||||
frontend_id,
|
||||
WindowDragState {
|
||||
owner,
|
||||
last_row: cell_row,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
self.window_drag.remove(&frontend_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Continue an armed divider drag (Q#BP5).
|
||||
///
|
||||
/// The boundary is re-resolved from `owner` on every motion, so a
|
||||
/// layout mutation mid-drag cannot move a boundary that no longer
|
||||
/// exists. Motion is applied incrementally and re-anchored each
|
||||
/// event, so the clamp absorbs over-travel instead of accumulating it.
|
||||
fn drag_window_boundary(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
cell_row: u32,
|
||||
term_size: CellSize,
|
||||
) {
|
||||
let Some(drag) = self.window_drag.get(&frontend_id).copied() else {
|
||||
return;
|
||||
};
|
||||
self.window_drag.insert(
|
||||
frontend_id,
|
||||
WindowDragState {
|
||||
last_row: cell_row,
|
||||
..drag
|
||||
},
|
||||
);
|
||||
let delta = i64::from(cell_row) - i64::from(drag.last_row);
|
||||
let Ok(delta) = i32::try_from(delta) else {
|
||||
return;
|
||||
};
|
||||
if delta == 0 || term_size.rows < 2 {
|
||||
return;
|
||||
}
|
||||
// A drag that runs into the clamp is a no-op, not an error to
|
||||
// surface: the pointer simply cannot move the boundary further.
|
||||
let _ = self.resize_window_boundary(frontend_id, drag.owner, delta, term_size.rows - 1);
|
||||
}
|
||||
|
||||
/// Move the boundary `win` owns by `delta_rows`, growing `win`
|
||||
/// (Q#BP5 / Q#BP5b), under the interactive `window.min-height`
|
||||
/// preference snapshotted before any geometry changes.
|
||||
///
|
||||
/// Returns the core's pointed error, if any; a `no adjustable
|
||||
/// horizontal boundary` result is a no-op by construction.
|
||||
pub fn resize_window_boundary(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
win: WindowId,
|
||||
delta_rows: i32,
|
||||
area_rows: u32,
|
||||
) -> Result<(), String> {
|
||||
// One gesture, one set of minima: resolved against each leaf's
|
||||
// CURRENT buffer (buffer-local override → global → default)
|
||||
// before the geometry moves.
|
||||
let minima: HashMap<WindowId, u32> = {
|
||||
let core = self.core.borrow();
|
||||
core.views
|
||||
.get(&frontend_id)
|
||||
.map(|view| {
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let buffer_id = core.windows.get(&id).map(|w| w.buffer_id);
|
||||
(id, self.window_min_height(buffer_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let result = self.core.borrow_mut().resize_boundary(
|
||||
frontend_id,
|
||||
win,
|
||||
delta_rows,
|
||||
area_rows,
|
||||
&|id| {
|
||||
minima
|
||||
.get(&id)
|
||||
.copied()
|
||||
.unwrap_or(crate::window::MIN_WINDOW_OUTER_ROWS)
|
||||
},
|
||||
);
|
||||
if result.is_ok() {
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve the `window.min-height` preference for a buffer, clamped
|
||||
/// into `[MIN_WINDOW_OUTER_ROWS, …]` (Q#BP2).
|
||||
///
|
||||
/// A core with no Lua host — or one whose runtime has not defined the
|
||||
/// setting — falls back to the structural floor, so the preference
|
||||
/// can never make an existing layout invalid.
|
||||
#[must_use]
|
||||
pub fn window_min_height(&self, buffer_id: Option<crate::buffer::BufferId>) -> u32 {
|
||||
crate::lua_bindings::config_u32(
|
||||
self.lua_host.lua(),
|
||||
"window.min-height",
|
||||
buffer_id,
|
||||
crate::window::MIN_WINDOW_OUTER_ROWS,
|
||||
)
|
||||
.max(crate::window::MIN_WINDOW_OUTER_ROWS)
|
||||
}
|
||||
|
||||
fn dispatch_terminal_mouse(
|
||||
&mut self,
|
||||
key: TerminalViewKey,
|
||||
|
|
@ -2368,8 +2639,12 @@ pub(crate) fn window_placements(
|
|||
return HashMap::new();
|
||||
};
|
||||
let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols);
|
||||
// Bottom-panel arc (Q#BP2, R5-B1): both production `Layout::compute`
|
||||
// callers feed in the SAME shared fixed map, so a side window's rows
|
||||
// are identical in the placement pass and the peer-overlay pass.
|
||||
let fixed = core.panel_fixed_rows(frontend_id, area.size.rows);
|
||||
view.layout
|
||||
.compute(area)
|
||||
.compute(area, &fixed)
|
||||
.into_iter()
|
||||
.map(|(window_id, outer)| {
|
||||
let content = Rect::new(
|
||||
|
|
@ -2834,6 +3109,13 @@ pub fn paint_frame(
|
|||
if term_size.rows < 2 || term_size.cols == 0 {
|
||||
return None;
|
||||
}
|
||||
// Bottom-panel arc (Q#BP2b/Q#BP15a): a grid frontend's real frame
|
||||
// size IS its authoritative geometry declaration. Declaring and
|
||||
// reconciling here — before the statusline fan-out and before the
|
||||
// long mutable borrow — means the painter never sees stale panel
|
||||
// geometry, and a panel the frame can no longer satisfy has already
|
||||
// surrendered focus and its terminal controller.
|
||||
state.sync_frame_geometry(frontend_id, term_size);
|
||||
// Statusline callbacks may call arbitrary editor APIs. Evaluate the
|
||||
// complete visible-window fan-out before the long mutable core borrow
|
||||
// below, then paint only the transactionally validated owned results.
|
||||
|
|
@ -2871,6 +3153,22 @@ pub fn paint_frame(
|
|||
|
||||
let placements = window_placements(core, frontend_id, term_size);
|
||||
let active = core.views.get(&frontend_id)?.active;
|
||||
// Bottom-panel arc (Q#BP5a): the divider IS the upper subtree's
|
||||
// existing mode-line row — no row is added or consumed, and
|
||||
// `fixed_rows` excludes it. Resolved once per frame, before the
|
||||
// mutable per-window loop borrows `core.windows`. A boundary whose
|
||||
// upper child is a nested subtree exposes SEVERAL leaf segments along
|
||||
// the same edge, so the root panel divider is full width even when
|
||||
// the document subtree ends in several columns.
|
||||
let divider_windows: Vec<WindowId> =
|
||||
core.views.get(&frontend_id).map_or_else(Vec::new, |view| {
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.filter(|id| view.layout.boundary_below(*id).is_some())
|
||||
.collect()
|
||||
});
|
||||
let divider_style = theme.face("ui.divider");
|
||||
|
||||
// Clear the whole grid first so windows that shrink on resize
|
||||
// don't leak the old contents.
|
||||
|
|
@ -3094,6 +3392,12 @@ pub fn paint_frame(
|
|||
}
|
||||
drop(reg);
|
||||
|
||||
for id in ÷r_windows {
|
||||
if let Some(placement) = placements.get(id) {
|
||||
paint_divider_segment(grid, &placement.outer, divider_style);
|
||||
}
|
||||
}
|
||||
|
||||
paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme);
|
||||
|
||||
// An active isearch owns the bottom row (its prompt + match
|
||||
|
|
@ -3579,6 +3883,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 {
|
|||
graphemes.iter().map(|grapheme| grapheme.width).sum()
|
||||
}
|
||||
|
||||
/// Restyle one exposed segment of a horizontal split boundary and stamp
|
||||
/// its grip (Q#BP5a).
|
||||
///
|
||||
/// The segment is the window's own mode-line row: the glyphs the mode
|
||||
/// line already painted are preserved, only the *surface* changes, and
|
||||
/// the grip lands on the protected suffix's trailing blank. `ui.divider`
|
||||
/// resolves through the ordinary `ui.*` face walk, so an unset face
|
||||
/// leaves today's mode-line surface untouched and the affordance is the
|
||||
/// grip alone.
|
||||
fn paint_divider_segment(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
rect: &crate::window::Rect,
|
||||
style: Option<crate::cell::Style>,
|
||||
) {
|
||||
if rect.size.rows == 0 || rect.size.cols == 0 {
|
||||
return;
|
||||
}
|
||||
let row = rect.origin.row + rect.size.rows - 1;
|
||||
if let Some(style) = style {
|
||||
for col in 0..rect.size.cols {
|
||||
grid.at(CellCoord::new(row, rect.origin.col + col)).style = style;
|
||||
}
|
||||
}
|
||||
let cell = grid.at(CellCoord::new(row, rect.origin.col + rect.size.cols - 1));
|
||||
cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH);
|
||||
}
|
||||
|
||||
/// Paint complete graphemes at a logical signed origin. A grapheme that
|
||||
/// straddles either clip edge is omitted wholesale, so a wide glyph can never
|
||||
/// leave a dangling half-cell at a window or left/right collision boundary.
|
||||
|
|
@ -6360,7 +6691,8 @@ mod tests {
|
|||
let core = s.core.borrow();
|
||||
assert_eq!(core.windows.len(), 8);
|
||||
let area = crate::window::Rect::new(0, 0, 40, 120);
|
||||
let placements = core.active_layout().compute(area);
|
||||
let fixed = core.panel_fixed_rows(core.active_frontend_key(), area.size.rows);
|
||||
let placements = core.active_layout().compute(area, &fixed);
|
||||
assert_eq!(placements.len(), 8);
|
||||
for r in placements.values() {
|
||||
assert!(!r.is_empty(), "rect was empty: {r:?}");
|
||||
|
|
@ -6776,16 +7108,14 @@ mod tests {
|
|||
} else {
|
||||
panic!("expected split");
|
||||
}
|
||||
let p1 = s
|
||||
.core
|
||||
.borrow()
|
||||
.active_layout()
|
||||
.compute(crate::window::Rect::new(0, 0, 24, 90));
|
||||
let p2 = s
|
||||
.core
|
||||
.borrow()
|
||||
.active_layout()
|
||||
.compute(crate::window::Rect::new(0, 0, 24, 60));
|
||||
let p1 = s.core.borrow().active_layout().compute(
|
||||
crate::window::Rect::new(0, 0, 24, 90),
|
||||
&std::collections::HashMap::new(),
|
||||
);
|
||||
let p2 = s.core.borrow().active_layout().compute(
|
||||
crate::window::Rect::new(0, 0, 24, 60),
|
||||
&std::collections::HashMap::new(),
|
||||
);
|
||||
// Both should preserve the 2:1 ratio. Find the two windows
|
||||
// and verify the larger:smaller ratio is 2:1 in both.
|
||||
let wider1 = p1.values().map(|r| r.size.cols).max().unwrap();
|
||||
|
|
|
|||
1505
src/editor_core.rs
1505
src/editor_core.rs
File diff suppressed because it is too large
Load Diff
296
src/highlight.rs
296
src/highlight.rs
|
|
@ -174,6 +174,46 @@ impl Theme {
|
|||
// prefix-walks to `tag`.
|
||||
("tag", fg(5)),
|
||||
("attribute", fg(3)),
|
||||
// Lean 4 (framing Q#LN4). These four are the captures the Lean
|
||||
// query uses that the set above lacks — but three of them are
|
||||
// NOT Lean-only, and adding them here changes languages that
|
||||
// already ship. That is the #146 lesson (`attribute`, above,
|
||||
// retro-painted rust/lua/yaml) and it is deliberate, not
|
||||
// incidental:
|
||||
//
|
||||
// * `constructor` reaches SEVEN entries — rust, lua, python,
|
||||
// javascript, and (because `tree_sitter_javascript::
|
||||
// HIGHLIGHT_QUERY` is concatenated base-first into them)
|
||||
// javascriptreact, typescript, typescriptreact. Its shape is
|
||||
// not "constructors": rust/python/javascript tag every
|
||||
// capitalized identifier (`#match? "^[A-Z]"`), and lua tags
|
||||
// every table-constructor brace. So this recolors `Some`,
|
||||
// `None`, `Ok`, `Err`, every class-cased name, and every Lua
|
||||
// `{}`. All of those render as unstyled default text today.
|
||||
// * `character` reaches zig only.
|
||||
// * `keyword.conditional` reaches cmake and zig, which
|
||||
// currently flatten it to `keyword`; giving it
|
||||
// `keyword.control`'s style makes their conditionals read the
|
||||
// way rust's already do.
|
||||
// * `warning` reaches no other grammar. It exists for Lean's
|
||||
// `sorry` — an unproved goal, the single most important thing
|
||||
// to see in a proof file.
|
||||
//
|
||||
// The alternative was an in-repo query overlay renaming these
|
||||
// into the existing vocabulary (the #144 LaTeX pattern), which
|
||||
// would fork a 213-line query we would then own and hand-merge
|
||||
// on every crate bump. There is no middle option: styling Lean's
|
||||
// constructors without touching the other seven entries requires
|
||||
// renaming the capture, which requires the overlay.
|
||||
("constructor", fg(11)),
|
||||
("character", fg(2)),
|
||||
("keyword.conditional", fg_bold(13)),
|
||||
// Bold BRIGHT red, deliberately the loudest entry in the table
|
||||
// and deliberately distinct from `number`'s plain `fg(1)`: in a
|
||||
// proof file `sorry` means "this is admitted, not proved", which
|
||||
// is the one thing a reader must never skim past. Plain `fg(1)`
|
||||
// would have collided with every numeric literal on colour alone.
|
||||
("warning", fg_bold(9)),
|
||||
];
|
||||
let by_capture = entries
|
||||
.iter()
|
||||
|
|
@ -1484,6 +1524,262 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Paint `src` as `language` into a one-row grid and return the style
|
||||
/// at column `col`. Shared by the Q#LN4 retro-paint pins below.
|
||||
fn painted_fg_at(
|
||||
language_name: &str,
|
||||
file: &str,
|
||||
src: &str,
|
||||
col: u32,
|
||||
) -> pmacs_protocol::cell::Color {
|
||||
painted_style_at(language_name, file, src, col).fg
|
||||
}
|
||||
|
||||
/// As [`painted_fg_at`], but returns the whole style — needed where a
|
||||
/// colour alone does not discriminate (Lean's `warning` vs `number`).
|
||||
fn painted_style_at(
|
||||
language_name: &str,
|
||||
file: &str,
|
||||
src: &str,
|
||||
col: u32,
|
||||
) -> pmacs_protocol::cell::Style {
|
||||
use crate::buffer::{Buffer, BufferId, EditOp};
|
||||
use crate::cell::{Cell, CellSize};
|
||||
use crate::syntax::{ParseView, SyntaxRegistry};
|
||||
|
||||
let reg = SyntaxRegistry::new();
|
||||
let language = reg.language(language_name).expect("grammar loads");
|
||||
let mut buf = Buffer::new(BufferId::next(), file);
|
||||
buf.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: src.as_bytes(),
|
||||
})
|
||||
.unwrap();
|
||||
let view = ParseView::new(&buf, language, language_name.to_owned());
|
||||
let handle = view.handle();
|
||||
let _vid = buf.attach_view(Box::new(view));
|
||||
let mut req = handle.make_request();
|
||||
req.injection_aliases = reg.injection_alias_snapshot();
|
||||
let bundle = crate::syntax::run_parse(req).expect("parse");
|
||||
handle.install(reg.resolve_layer_queries(&bundle));
|
||||
|
||||
let mut hv = SyntaxHighlightView::new(handle, reg.theme());
|
||||
let (rows, cols) = (1usize, 40usize);
|
||||
let mut backing: Vec<Cell> = vec![Cell::default(); rows * cols];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
stride: cols as u32,
|
||||
size: CellSize::new(rows as u32, cols as u32),
|
||||
};
|
||||
let viewport = Viewport {
|
||||
buffer_start: 0,
|
||||
buffer_end: u64::MAX,
|
||||
cell_origin: CellCoord::new(0, 0),
|
||||
cell_size: CellSize::new(rows as u32, cols as u32),
|
||||
gutter_w: 0,
|
||||
folds: None,
|
||||
};
|
||||
hv.render(&buf, viewport, &mut grid);
|
||||
grid.get(CellCoord::new(0, col)).style
|
||||
}
|
||||
|
||||
/// Does `language`'s compiled highlight query use `capture`?
|
||||
fn query_uses_capture(language: &str, capture: &str) -> bool {
|
||||
let reg = crate::syntax::SyntaxRegistry::new();
|
||||
let Some(query) = reg.highlights_query(language) else {
|
||||
panic!("{language} has no highlights query");
|
||||
};
|
||||
query.capture_names().contains(&capture)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_grid_paints_comment_keyword_name_operator_and_number() {
|
||||
// Framing acceptance 5: the grammar plus the crate query plus the
|
||||
// theme table actually produce distinct styles on a painted grid.
|
||||
// Asserted end-to-end rather than at the query level because a
|
||||
// capture that resolves to `Style::default()` is indistinguishable
|
||||
// from no capture at all to a reader.
|
||||
use pmacs_protocol::cell::Color;
|
||||
|
||||
// `-- c` — the whole comment run.
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", "-- c\n", 0),
|
||||
Color::Indexed(8),
|
||||
"a Lean line comment paints the comment style"
|
||||
);
|
||||
|
||||
// `def foo : Nat := 42`
|
||||
let src = "def foo : Nat := 42\n";
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", src, 0),
|
||||
Color::Indexed(5),
|
||||
"`def` paints the keyword style"
|
||||
);
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", src, 4),
|
||||
Color::Indexed(4),
|
||||
"the definition's name paints the function style"
|
||||
);
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", src, 14),
|
||||
Color::Indexed(6),
|
||||
"`:=` paints the operator style"
|
||||
);
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", src, 17),
|
||||
Color::Indexed(1),
|
||||
"a numeric literal paints the number style"
|
||||
);
|
||||
|
||||
// A string literal, and `theorem` as a second declaration keyword.
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", "def s := \"hi\"\n", 9),
|
||||
Color::Indexed(2),
|
||||
"a string literal paints the string style"
|
||||
);
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 0),
|
||||
Color::Indexed(5),
|
||||
"`theorem` paints the keyword style"
|
||||
);
|
||||
assert_eq!(
|
||||
painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 8),
|
||||
Color::Indexed(4),
|
||||
"the theorem's name paints the function style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_sorry_paints_the_warning_style_distinctly_from_a_number() {
|
||||
// Framing acceptance 6. `sorry` admits a goal without proving it —
|
||||
// in a proof file it is the single most important token to notice,
|
||||
// and it is why Q#LN4 adds a `warning` entry at all.
|
||||
//
|
||||
// The style is asserted in FULL, not by colour: `number` and the
|
||||
// first-choice `warning` colour were both indexed red, so a
|
||||
// colour-only assertion would have passed with `sorry` painted
|
||||
// exactly like the literal `42` beside it. That is the whole failure
|
||||
// this test exists to prevent.
|
||||
use pmacs_protocol::cell::Color;
|
||||
|
||||
let sorry = painted_style_at("lean4", "a.lean", "theorem t : True := sorry\n", 20);
|
||||
assert_eq!(
|
||||
sorry.fg,
|
||||
Color::Indexed(9),
|
||||
"`sorry` paints the warning colour"
|
||||
);
|
||||
assert!(sorry.bold, "`sorry` is bold");
|
||||
|
||||
let number = painted_style_at("lean4", "a.lean", "def n := 42\n", 9);
|
||||
assert_ne!(
|
||||
(sorry.fg, sorry.bold),
|
||||
(number.fg, number.bold),
|
||||
"`sorry` must be visually distinct from a numeric literal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() {
|
||||
// Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was
|
||||
// added for Lean, but four crates emit it — and because
|
||||
// `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated
|
||||
// base-first into the react/typescript entries
|
||||
// (`src/syntax.rs`), it reaches SEVEN language entries, not four.
|
||||
//
|
||||
// Asserted at the query level rather than per-fixture precisely
|
||||
// because the composition is the fragile part: if someone stops
|
||||
// concatenating the JS base query into `typescript`, this fails
|
||||
// while any single-language fixture would still pass.
|
||||
for language in [
|
||||
"rust",
|
||||
"lua",
|
||||
"python",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
] {
|
||||
assert!(
|
||||
query_uses_capture(language, "constructor"),
|
||||
"`{language}` emits @constructor, so Q#LN4's entry retro-paints it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_capture_additions_paint_rust_constructors_and_lua_braces() {
|
||||
// Framing acceptance 7, the "actually reaches painted cells" half —
|
||||
// a query-name check alone would not prove the theme entry resolves.
|
||||
// Both of these rendered as unstyled default text before Q#LN4.
|
||||
use pmacs_protocol::cell::Color;
|
||||
|
||||
// `None` at col 8 — a bare capitalized identifier, which is what the
|
||||
// rust query's `#match? "^[A-Z]"` tags. Note that `Some(1)` does NOT
|
||||
// work here: in call position a narrower `@function` pattern wins and
|
||||
// paints fg 4. The distinction is worth keeping in the test, because
|
||||
// it is the difference between "capitalized identifiers recolor" and
|
||||
// "enum variants recolor" — only the former is true.
|
||||
assert_eq!(
|
||||
painted_fg_at("rust", "a.rs", "let x = None;\n", 8),
|
||||
Color::Indexed(11),
|
||||
"a bare Rust capitalized identifier paints the shared @constructor style"
|
||||
);
|
||||
// `Some` in pattern position (col 10) does reach @constructor.
|
||||
assert_eq!(
|
||||
painted_fg_at("rust", "a.rs", "match v { Some(z) => z, None => 0 };\n", 10),
|
||||
Color::Indexed(11),
|
||||
"a Rust pattern-position variant paints the shared @constructor style"
|
||||
);
|
||||
// ...but in CALL position the narrower @function pattern wins. Pinned
|
||||
// so the blast radius recorded in the framing stays accurate.
|
||||
assert_eq!(
|
||||
painted_fg_at("rust", "a.rs", "let e = Err(1);\n", 8),
|
||||
Color::Indexed(4),
|
||||
"a called variant keeps @function, not @constructor"
|
||||
);
|
||||
// Lua tags the table-constructor BRACES, not a name: `{` at col 10
|
||||
// of `local t = {}`.
|
||||
assert_eq!(
|
||||
painted_fg_at("lua", "a.lua", "local t = {}\n", 10),
|
||||
Color::Indexed(11),
|
||||
"a Lua table brace paints the shared @constructor style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_capture_additions_do_not_reach_unrelated_languages() {
|
||||
// Framing acceptance 8 — the negative pin, redrawn in review round 1.
|
||||
//
|
||||
// Rev 1 named Lua and Python here, which was a self-contradiction:
|
||||
// both are retro-painted by `constructor`, so a "nothing moved"
|
||||
// assertion over them would have been vacuous — the #155 R2 shape.
|
||||
// These ten emit NONE of the four names, verified by grep over the
|
||||
// crate queries in the dependency graph.
|
||||
//
|
||||
// Stated at the query level, which is stronger than a fixture
|
||||
// snapshot: it holds for every construct in the language, not just
|
||||
// the one a fixture happened to exercise.
|
||||
const ADDED: [&str; 4] = ["constructor", "character", "keyword.conditional", "warning"];
|
||||
for language in [
|
||||
"markdown", "json", "yaml", "html", "css", "c", "cpp", "go", "toml", "bash",
|
||||
] {
|
||||
for capture in ADDED {
|
||||
assert!(
|
||||
!query_uses_capture(language, capture),
|
||||
"`{language}` must not emit @{capture}; Q#LN4 would silently restyle it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Non-vacuity: the same predicate must find each name where it DOES
|
||||
// occur. Without this, a `query_uses_capture` that always returned
|
||||
// false would pass the loop above.
|
||||
assert!(query_uses_capture("lean4", "constructor"));
|
||||
assert!(query_uses_capture("zig", "character"));
|
||||
assert!(query_uses_capture("cmake", "keyword.conditional"));
|
||||
assert!(query_uses_capture("lean4", "warning"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_grid_paints_html_tag_and_attribute() {
|
||||
// Q#WEB4 acceptance: the two capture entries this lane adds (`tag`,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ mod diag;
|
|||
mod fold;
|
||||
mod index;
|
||||
mod mcp;
|
||||
mod window_panel;
|
||||
// Every `pub` item a moved domain owned is re-exported so its prior
|
||||
// `crate::lua_bindings::<item>` path still resolves — the split must not
|
||||
// shrink the public API surface. That includes the `install_*` wiring fns:
|
||||
|
|
@ -647,6 +648,26 @@ impl PackageInstallOverride {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve an integer setting out of the shared `pmacs.config` registry
|
||||
/// (bottom-panel arc, Q#BP2 / Q#BP11).
|
||||
///
|
||||
/// The registry lives in Lua app data, so Rust-side consumers — the
|
||||
/// divider drag, the keyboard resize commands, and side-window creation
|
||||
/// — reach it here rather than round-tripping through Lua. `fallback`
|
||||
/// covers a bare core whose runtime never defined the setting (unit-test
|
||||
/// construction), and a negative or out-of-range stored value.
|
||||
#[must_use]
|
||||
pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback: u32) -> u32 {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
|
||||
return fallback;
|
||||
};
|
||||
let borrowed = registry.borrow();
|
||||
match borrowed.get(name, buffer_id) {
|
||||
Ok(crate::config_registry::ConfigValue::Int(v)) => u32::try_from(*v).unwrap_or(fallback),
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-circuit a binding when the init phase has completed.
|
||||
///
|
||||
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
|
||||
|
|
@ -1572,7 +1593,7 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) {
|
||||
pub(crate) fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) {
|
||||
let snapshot = match lua.app_data_ref::<SharedHookRegistry>() {
|
||||
Some(hooks) => hooks.borrow().snapshot(name),
|
||||
None => None,
|
||||
|
|
@ -8467,6 +8488,11 @@ fn install_terminal(
|
|||
manager: &crate::terminal::SharedTerminalManager,
|
||||
supervisor: &SharedProcessSupervisor,
|
||||
) -> mlua::Result<()> {
|
||||
// Bottom-panel arc (Q#BP2b): the panel-reconciliation transaction
|
||||
// must be able to RELEASE a hidden panel's terminal controller from a
|
||||
// Lua-owning context, so the manager joins the LSP manager and the
|
||||
// process supervisor as app data.
|
||||
lua.set_app_data(manager.clone());
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let terminal = lua.create_table()?;
|
||||
|
||||
|
|
@ -8475,8 +8501,8 @@ fn install_terminal(
|
|||
let supervisor = supervisor.clone();
|
||||
terminal.set(
|
||||
"_open",
|
||||
lua.create_function(move |lua, spec: Table| -> mlua::Result<BufferIdLua> {
|
||||
let spec = parse_terminal_spec(&spec)?;
|
||||
lua.create_function(move |lua, spec_table: Table| -> mlua::Result<BufferIdLua> {
|
||||
let spec = parse_terminal_spec(&spec_table)?;
|
||||
let core = lua
|
||||
.app_data_ref::<SharedCore>()
|
||||
.map(|core| core.clone())
|
||||
|
|
@ -8489,37 +8515,54 @@ fn install_terminal(
|
|||
"pmacs.terminal.open: target frontend has no active window",
|
||||
));
|
||||
}
|
||||
// Bottom-panel arc (Q#BP11b): parse placement BEFORE the
|
||||
// session, process, buffer, or wrapper exists, so an
|
||||
// unknown `display` value creates nothing to roll back.
|
||||
let placement = window_panel::parse_adopter_placement(
|
||||
&core,
|
||||
frontend_id,
|
||||
"pmacs.terminal.open",
|
||||
spec_table.get::<Option<String>>("display")?.as_deref(),
|
||||
spec_table.get::<Option<u64>>("window")?,
|
||||
)?;
|
||||
let buffer_id = {
|
||||
let mut manager = manager.borrow_mut();
|
||||
manager
|
||||
.open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut())
|
||||
.map_err(mlua::Error::external)?
|
||||
};
|
||||
let key = {
|
||||
let mut core = core.borrow_mut();
|
||||
if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) {
|
||||
let outcome = match window_panel::place_adopter_buffer(
|
||||
lua,
|
||||
&core,
|
||||
frontend_id,
|
||||
buffer_id,
|
||||
&placement,
|
||||
true,
|
||||
) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
let mut core = core.borrow_mut();
|
||||
let _ = core.registry.borrow_mut().remove(buffer_id);
|
||||
manager
|
||||
.borrow_mut()
|
||||
.prune(&mut core, &mut supervisor.borrow_mut());
|
||||
return Err(mlua::Error::external(format!(
|
||||
"pmacs.terminal.open: active-window switch failed: {error}"
|
||||
)));
|
||||
return Err(error);
|
||||
}
|
||||
crate::terminal::TerminalViewKey::new(
|
||||
frontend_id,
|
||||
core.views
|
||||
.get(&frontend_id)
|
||||
.expect("checked frontend has active view")
|
||||
.active,
|
||||
buffer_id,
|
||||
)
|
||||
};
|
||||
let key =
|
||||
crate::terminal::TerminalViewKey::new(frontend_id, outcome.target, buffer_id);
|
||||
let claimed = {
|
||||
let mut manager = manager.borrow_mut();
|
||||
manager.register_view(key) && manager.claim_controller(key)
|
||||
};
|
||||
if !claimed {
|
||||
// Placement failure removes any side wrapper this
|
||||
// transaction created, BEFORE the existing
|
||||
// session/buffer rollback completes (Q#BP11b).
|
||||
if outcome.created_side {
|
||||
core.borrow_mut()
|
||||
.remove_side_window(frontend_id, outcome.target);
|
||||
}
|
||||
let mut core = core.borrow_mut();
|
||||
let _ = core.registry.borrow_mut().remove(buffer_id);
|
||||
manager
|
||||
|
|
@ -8529,7 +8572,7 @@ fn install_terminal(
|
|||
"pmacs.terminal.open: failed to claim the new terminal view",
|
||||
));
|
||||
}
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
window_panel::finish_adopter_placement(lua, &core, frontend_id, outcome)?;
|
||||
Ok(BufferIdLua(buffer_id))
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8691,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result<crate::terminal::TerminalS
|
|||
"rows",
|
||||
"cols",
|
||||
"scrollback_rows",
|
||||
// Bottom-panel arc (Q#BP11b): placement, parsed separately by
|
||||
// `_open` and never part of the child's `TerminalSpec`.
|
||||
"display",
|
||||
"window",
|
||||
];
|
||||
let mut unknown = None;
|
||||
table.clone().for_each(|key: Value, _: Value| {
|
||||
|
|
@ -12166,16 +12213,25 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs {
|
|||
)]
|
||||
fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
||||
let win = lua.create_table()?;
|
||||
// Bottom-panel arc (Q#BP11): display policy, side windows, quit, and
|
||||
// boundary resize live in their own module.
|
||||
window_panel::install(lua, core, &win)?;
|
||||
|
||||
{
|
||||
// Bottom-panel arc (Q#BP6): `try_split_active` refuses a side
|
||||
// window. This binding is what `C-x 2` reaches, so the refusal
|
||||
// has to live on THIS path — splitting the panel leaf would make
|
||||
// the root wrapper's final child a split rather than
|
||||
// `Leaf(side)`, and both `Layout::compute`'s fixed pass and
|
||||
// `document_subtree` key on exactly that shape.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"split_horizontal",
|
||||
lua.create_function(move |_, ()| {
|
||||
let new_id = cc
|
||||
.borrow_mut()
|
||||
.split_active(crate::window::Orientation::Horizontal, true);
|
||||
Ok(new_id.raw())
|
||||
cc.borrow_mut()
|
||||
.try_split_active(crate::window::Orientation::Horizontal, true)
|
||||
.map(crate::window::WindowId::raw)
|
||||
.map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -12185,10 +12241,10 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
win.set(
|
||||
"split_vertical",
|
||||
lua.create_function(move |_, ()| {
|
||||
let new_id = cc
|
||||
.borrow_mut()
|
||||
.split_active(crate::window::Orientation::Vertical, true);
|
||||
Ok(new_id.raw())
|
||||
cc.borrow_mut()
|
||||
.try_split_active(crate::window::Orientation::Vertical, true)
|
||||
.map(crate::window::WindowId::raw)
|
||||
.map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -12271,8 +12327,7 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
win.set(
|
||||
"close_others",
|
||||
lua.create_function(move |_, ()| {
|
||||
cc.borrow_mut().close_others();
|
||||
Ok(())
|
||||
cc.borrow_mut().close_others().map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -12302,9 +12357,41 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
|
||||
{
|
||||
let cc = core.clone();
|
||||
// With no argument: the ambient active buffer, exactly as before
|
||||
// this arc. With an explicit window id: that window's buffer,
|
||||
// validated against the acting frontend's layout like every other
|
||||
// `WindowId`-taking operation (bottom-panel arc, Q#BP11) — an
|
||||
// adopter has to be able to ask "is my buffer the one in the
|
||||
// panel" without first selecting the panel.
|
||||
win.set(
|
||||
"buffer",
|
||||
lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?,
|
||||
lua.create_function(
|
||||
move |lua, target: Option<u64>| -> mlua::Result<BufferIdLua> {
|
||||
// The no-arg arm deliberately stays on ambient
|
||||
// `active_buffer_id()`, and stays INFALLIBLE. This is not
|
||||
// the asymmetry it looks like: dispatch sets
|
||||
// `active_frontend` to the acting frontend before running a
|
||||
// command, so the two agree on every real path — while
|
||||
// `acting_frontend` can additionally name a frontend that
|
||||
// has no registered view, where a `views`-keyed lookup
|
||||
// raises instead of answering. `killring`, `syntax`,
|
||||
// `autosave`, `pair`, `indent` and `comment` all call this
|
||||
// on ordinary edits without `pcall`, so a raise here does
|
||||
// not surface as an error — it silently drops the
|
||||
// operation (it lost a whole kill in `kill_ring_acceptance`
|
||||
// when this arm was routed through `selected_window`).
|
||||
let Some(raw) = target else {
|
||||
return Ok(BufferIdLua(cc.borrow().active_buffer_id()));
|
||||
};
|
||||
let fid = window_panel::acting_frontend(lua, &cc);
|
||||
let id = window_panel::lookup_window(&cc, fid, raw)?;
|
||||
cc.borrow()
|
||||
.windows
|
||||
.get(&id)
|
||||
.map(|window| BufferIdLua(window.buffer_id))
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live"))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,642 @@
|
|||
// window_panel.rs --- `pmacs.window` display policy + side windows.
|
||||
|
||||
//! The Lua surface of the bottom-panel arc (Q#BP11): `display`,
|
||||
//! `display_file`, `quit`, `panel`, `params` / `set_params`, `resize`,
|
||||
//! and `display_target`.
|
||||
//!
|
||||
//! # Where the transaction lives
|
||||
//!
|
||||
//! [`crate::editor_core::EditorCore::display_buffer`] is **Phase 1**: it
|
||||
//! picks a target under Q#BP3, installs the buffer, and reports what must
|
||||
//! happen next. It contains no Lua. This module is **Phase 2** (Q#BP4):
|
||||
//! activate the target, fire the lifecycle hook so overlays reattach and
|
||||
//! saveplace / recentf / syntax / LSP observe the right active window,
|
||||
//! run panel reconciliation (a hook may resize, close, or replace the
|
||||
//! target), then **revalidate both window ids** and apply the final-focus
|
||||
//! matrix.
|
||||
//!
|
||||
//! Two corrections that matrix encodes, both of which an earlier revision
|
||||
//! of the framing got wrong:
|
||||
//!
|
||||
//! * `select = true` **keeps the target selected** — restoring the saved
|
||||
//! window unconditionally would erase the request outright;
|
||||
//! * `select = false` restores a saved window **even when it is the
|
||||
//! panel** — a passive display invoked from a focused panel must not
|
||||
//! blur it.
|
||||
//!
|
||||
//! # What Lua may not write
|
||||
//!
|
||||
//! `side` is immutable after placement (Q#BP2a), and `quit_action` /
|
||||
//! `origin_document` are implementation-owned (Q#BP2c): `params` reports
|
||||
//! them for diagnostics, `set_params` refuses them. Lua therefore cannot
|
||||
//! forge a window id, a buffer restore chain, or stale cursor state.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined};
|
||||
use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome};
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId};
|
||||
|
||||
/// The frontend a `pmacs.window.*` call acts for.
|
||||
///
|
||||
/// An interactive command carries authenticated origin; a programmatic
|
||||
/// call falls back to the ambient active frontend, exactly as the
|
||||
/// terminal surface does.
|
||||
pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId {
|
||||
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
|
||||
.and_then(|origin| origin.current())
|
||||
.unwrap_or_else(|| core.borrow().active_frontend_key())
|
||||
}
|
||||
|
||||
/// Run the panel-reconciliation transaction from a Lua-owning context
|
||||
/// (Q#BP2b).
|
||||
///
|
||||
/// The core half is pure; releasing a terminal controller needs the
|
||||
/// manager, which the terminal module publishes as Lua app data for
|
||||
/// exactly this reason. A bare core without one still reconciles — it
|
||||
/// simply has no controller to release.
|
||||
pub(crate) fn reconcile_panel_layout(lua: &Lua, core: &SharedCore, fid: FrontendId) {
|
||||
let outcome = core.borrow_mut().reconcile_panel_layout_core(fid);
|
||||
let Some(window_id) = outcome.released_terminal else {
|
||||
return;
|
||||
};
|
||||
let Some(manager) = lua.app_data_ref::<crate::terminal::SharedTerminalManager>() else {
|
||||
return;
|
||||
};
|
||||
let buffer_id = core
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&window_id)
|
||||
.map(|window| window.buffer_id);
|
||||
if let Some(buffer_id) = buffer_id {
|
||||
let _ = manager
|
||||
.borrow_mut()
|
||||
.release_controller(crate::terminal::TerminalViewKey::new(
|
||||
fid, window_id, buffer_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A window is "visible" for the final-focus matrix when it is live in
|
||||
/// this frontend's layout and not a derived-hidden panel (Q#BP2b).
|
||||
fn visible(core: &SharedCore, fid: FrontendId, win: WindowId) -> bool {
|
||||
let core = core.borrow();
|
||||
let Some(view) = core.views.get(&fid) else {
|
||||
return false;
|
||||
};
|
||||
if !view.layout.iter_ids().contains(&win) {
|
||||
return false;
|
||||
}
|
||||
!(view.panel_hidden
|
||||
&& core
|
||||
.windows
|
||||
.get(&win)
|
||||
.is_some_and(crate::window::Window::is_side))
|
||||
}
|
||||
|
||||
/// Phase 2 of the display transaction (Q#BP4).
|
||||
fn complete_display(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
outcome: DisplayOutcome,
|
||||
fire: HookKind,
|
||||
) -> mlua::Result<()> {
|
||||
core.borrow_mut().focus_window(fid, outcome.target);
|
||||
match fire {
|
||||
HookKind::AfterSwitch => {
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
}
|
||||
HookKind::AfterLoad => {
|
||||
run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new());
|
||||
}
|
||||
HookKind::None => {}
|
||||
}
|
||||
// A hook may have resized, closed, or replaced the target, so
|
||||
// reconcile BEFORE the final-focus decision reads visibility.
|
||||
reconcile_panel_layout(lua, core, fid);
|
||||
|
||||
let target_ok = visible(core, fid, outcome.target);
|
||||
let saved_ok = visible(core, fid, outcome.saved_active);
|
||||
let final_focus = match (outcome.select, target_ok, saved_ok) {
|
||||
// `select = true` KEEPS the target selected.
|
||||
(true, true, _) | (false, true, false) => Some(outcome.target),
|
||||
// `select = false` restores the saved window even when it is the
|
||||
// panel — a passive display from a focused panel must not blur it.
|
||||
(true, false, true) | (false, _, true) => Some(outcome.saved_active),
|
||||
// Both ids died with the hook: fall back to the non-side target
|
||||
// rule rather than leaving focus on a dead window.
|
||||
_ => None,
|
||||
};
|
||||
let resolved = match final_focus {
|
||||
Some(win) => win,
|
||||
None => core
|
||||
.borrow()
|
||||
.non_side_target(fid)
|
||||
.map_err(mlua::Error::runtime)?,
|
||||
};
|
||||
core.borrow_mut().focus_window(fid, resolved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the shared `{side, window, height, dedicated, select}` option
|
||||
/// table.
|
||||
fn parse_request(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
opts: Option<Table>,
|
||||
) -> mlua::Result<DisplayRequest> {
|
||||
let mut request = DisplayRequest::new(buffer_id);
|
||||
let Some(opts) = opts else {
|
||||
return Ok(request);
|
||||
};
|
||||
if let Some(side) = opts.get::<Option<String>>("side")? {
|
||||
request.side = Some(Side::from_name(&side).ok_or_else(|| {
|
||||
mlua::Error::runtime(format!(
|
||||
"pmacs.window.display: unsupported side {side:?} (only \"bottom\" ships)"
|
||||
))
|
||||
})?);
|
||||
}
|
||||
if let Some(raw) = opts.get::<Option<u64>>("window")? {
|
||||
request.window = Some(lookup_window(core, fid, raw)?);
|
||||
}
|
||||
if let Some(height) = opts.get::<Option<u32>>("height")? {
|
||||
request.height = Some(height);
|
||||
}
|
||||
if let Some(dedicated) = opts.get::<Option<bool>>("dedicated")? {
|
||||
request.dedicated = Some(dedicated);
|
||||
}
|
||||
if let Some(select) = opts.get::<Option<bool>>("select")? {
|
||||
request.select = Some(select);
|
||||
}
|
||||
// The setting is resolved against the buffer being displayed, and
|
||||
// only consumed when the slot is actually CREATED (Q#BP3).
|
||||
request.default_panel_rows = config_u32(
|
||||
lua,
|
||||
"window.panel-height",
|
||||
Some(buffer_id),
|
||||
DEFAULT_PANEL_ROWS,
|
||||
)
|
||||
.max(MIN_WINDOW_OUTER_ROWS);
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// The ACTING frontend's selected window.
|
||||
///
|
||||
/// Not `active_window_id()`, which resolves through the ambient active
|
||||
/// frontend: every other id in this module is `fid`-scoped, and the two
|
||||
/// only coincide because dispatch happens to set `active_frontend` first.
|
||||
pub(crate) fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result<WindowId> {
|
||||
core.borrow()
|
||||
.views
|
||||
.get(&fid)
|
||||
.map(|view| view.active)
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout"))
|
||||
}
|
||||
|
||||
/// Resolve a raw Lua window id, refusing one that is not live in the
|
||||
/// acting frontend's layout (Q#BP11).
|
||||
pub(crate) fn lookup_window(
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
raw: u64,
|
||||
) -> mlua::Result<WindowId> {
|
||||
let core = core.borrow();
|
||||
let view = core
|
||||
.views
|
||||
.get(&fid)
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout"))?;
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.find(|id| id.raw() == raw)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime(format!(
|
||||
"pmacs.window: window {raw} is not live in this frontend's layout"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// A parsed adopter placement request (Q#BP11b).
|
||||
///
|
||||
/// `listview`, compile, and terminal all take the same strict
|
||||
/// `display = "current" | "panel"` value. In Stages 1–2 omission means
|
||||
/// `"current"`; Stage 3 flips omission to `"panel"`. Explicit
|
||||
/// `"current"` always preserves the adopter's pre-arc selected-window
|
||||
/// behavior and is the user-facing opt-out from that flip.
|
||||
pub(crate) enum AdopterPlacement {
|
||||
/// Today's behavior: the raw switch into the frontend's active
|
||||
/// window, deliberately bypassing display-policy dedication.
|
||||
Current,
|
||||
/// The bottom panel.
|
||||
Panel,
|
||||
/// An exact target window.
|
||||
Window(WindowId),
|
||||
}
|
||||
|
||||
/// Parse an adopter's placement **before** it creates a buffer, session,
|
||||
/// process, or wrapper — so an unknown value leaves nothing to roll back.
|
||||
///
|
||||
/// # Errors
|
||||
/// An unknown `display` value, a `window` combined with
|
||||
/// `display = "panel"`, or a window id that is not live in the acting
|
||||
/// frontend's layout.
|
||||
pub(crate) fn parse_adopter_placement(
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
operation: &str,
|
||||
display: Option<&str>,
|
||||
window: Option<u64>,
|
||||
) -> mlua::Result<AdopterPlacement> {
|
||||
let display = match display {
|
||||
None | Some("current") => AdopterPlacement::Current,
|
||||
Some("panel") => AdopterPlacement::Panel,
|
||||
Some(other) => {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"{operation}: unknown display {other:?} (expected \"current\" or \"panel\")"
|
||||
)));
|
||||
}
|
||||
};
|
||||
match (window, &display) {
|
||||
(Some(_), AdopterPlacement::Panel) => Err(mlua::Error::runtime(format!(
|
||||
"{operation}: `window` and `display = \"panel\"` are mutually exclusive"
|
||||
))),
|
||||
(Some(raw), _) => Ok(AdopterPlacement::Window(lookup_window(core, fid, raw)?)),
|
||||
(None, _) => Ok(display),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install `buffer_id` per `placement`, returning Phase 1's outcome
|
||||
/// (Q#BP11b).
|
||||
///
|
||||
/// `Current` keeps the pre-arc raw switch: it is the deliberate escape
|
||||
/// hatch every existing adopter caller already relies on, and it does not
|
||||
/// consult display-policy dedication.
|
||||
///
|
||||
/// # Errors
|
||||
/// Any placement failure. The caller owns its own session/buffer
|
||||
/// rollback, and inspects `created_side` to remove a wrapper this
|
||||
/// transaction created.
|
||||
pub(crate) fn place_adopter_buffer(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
placement: &AdopterPlacement,
|
||||
select: bool,
|
||||
) -> mlua::Result<DisplayOutcome> {
|
||||
if matches!(placement, AdopterPlacement::Current) {
|
||||
let mut borrowed = core.borrow_mut();
|
||||
borrowed
|
||||
.switch_active_buffer_for(fid, buffer_id)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
let target = borrowed
|
||||
.views
|
||||
.get(&fid)
|
||||
.map(|view| view.active)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime("adopter placement: acting frontend has no active window")
|
||||
})?;
|
||||
return Ok(DisplayOutcome {
|
||||
target,
|
||||
saved_active: target,
|
||||
select: true,
|
||||
created_side: false,
|
||||
});
|
||||
}
|
||||
let mut request = DisplayRequest::new(buffer_id);
|
||||
match placement {
|
||||
AdopterPlacement::Panel => request.side = Some(Side::Bottom),
|
||||
AdopterPlacement::Window(window) => request.window = Some(*window),
|
||||
AdopterPlacement::Current => unreachable!("handled above"),
|
||||
}
|
||||
request.select = Some(select);
|
||||
request.default_panel_rows = config_u32(
|
||||
lua,
|
||||
"window.panel-height",
|
||||
Some(buffer_id),
|
||||
DEFAULT_PANEL_ROWS,
|
||||
)
|
||||
.max(MIN_WINDOW_OUTER_ROWS);
|
||||
core.borrow_mut()
|
||||
.display_buffer(fid, &request)
|
||||
.map_err(mlua::Error::runtime)
|
||||
}
|
||||
|
||||
/// Phase 2 for an adopter that had to interleave its own work (claiming a
|
||||
/// terminal controller, seating a cursor) between placement and the hook.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates the final-focus resolution error when both window ids died
|
||||
/// inside the hook.
|
||||
pub(crate) fn finish_adopter_placement(
|
||||
lua: &Lua,
|
||||
core: &SharedCore,
|
||||
fid: FrontendId,
|
||||
outcome: DisplayOutcome,
|
||||
) -> mlua::Result<()> {
|
||||
complete_display(lua, core, fid, outcome, HookKind::AfterSwitch)
|
||||
}
|
||||
|
||||
/// Install the bottom-panel surface onto the existing `pmacs.window`
|
||||
/// table.
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one flat list of bindings, each following the same \
|
||||
acting-frontend / Rc-borrow shape; splitting them fragments \
|
||||
a coherent surface"
|
||||
)]
|
||||
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display",
|
||||
lua.create_function(
|
||||
move |lua, (buffer, opts): (BufferIdLua, Option<Table>)| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let request = parse_request(lua, &cc, fid, buffer.0, opts)?;
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.display_buffer(fid, &request)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
complete_display(lua, &cc, fid, outcome, HookKind::AfterSwitch)?;
|
||||
Ok(outcome.target.raw())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP11b — the target-aware load transaction. `find_or_open`
|
||||
// switches the ACTIVE window in both branches before firing
|
||||
// hooks, so a visit to a previously unopened file would replace
|
||||
// a focused panel before any display policy could help.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display_file",
|
||||
lua.create_function(
|
||||
move |lua, (path, opts): (String, Option<Table>)| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
let mut explicit_window = None;
|
||||
let mut select = None;
|
||||
if let Some(opts) = opts.as_ref() {
|
||||
if let Some(raw) = opts.get::<Option<u64>>("window")? {
|
||||
explicit_window = Some(lookup_window(&cc, fid, raw)?);
|
||||
}
|
||||
select = opts.get::<Option<bool>>("select")?;
|
||||
}
|
||||
// 1. Side-effect-free dedup: do NOT read the file yet.
|
||||
let existing = cc.borrow().find_buffer_for_path(&path_buf);
|
||||
// 2. Resolve the destination BEFORE I/O, so a
|
||||
// dedicated origin cannot force load-before-failure.
|
||||
cc.borrow()
|
||||
.probe_display_target(fid, existing, explicit_window)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
// 3. Load, dedup, or create the path-backed buffer.
|
||||
let (buffer_id, fire) = cc
|
||||
.borrow_mut()
|
||||
.resolve_target_buffer(&path_buf)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
// 4. Enter Q#BP4's transaction, so any hook observes
|
||||
// the DOCUMENT TARGET as active.
|
||||
let mut request = DisplayRequest::new(buffer_id);
|
||||
request.window = explicit_window;
|
||||
request.select = select;
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.display_buffer(fid, &request)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
complete_display(lua, &cc, fid, outcome, fire)?;
|
||||
Ok(outcome.target.raw())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP11a — the non-side target: what an ordinary visit from a
|
||||
// panel should address.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"display_target",
|
||||
lua.create_function(move |lua, ()| -> mlua::Result<u64> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let core = cc.borrow();
|
||||
core.non_side_target(fid)
|
||||
.map(WindowId::raw)
|
||||
.map_err(mlua::Error::runtime)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// The acting frontend's side window, or nil.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"panel",
|
||||
lua.create_function(move |lua, ()| -> mlua::Result<Option<u64>> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
Ok(cc.borrow().side_window_for(fid).map(WindowId::raw))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP2c — `window.quit`. A window with no recorded action gets
|
||||
// a pointed error WITHOUT closing or switching anything.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"quit",
|
||||
lua.create_function(move |lua, target: Option<u64>| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let target = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => cc
|
||||
.borrow()
|
||||
.views
|
||||
.get(&fid)
|
||||
.map(|view| view.active)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::runtime("pmacs.window.quit: no acting frontend view")
|
||||
})?,
|
||||
};
|
||||
let outcome = cc
|
||||
.borrow_mut()
|
||||
.quit_window(fid, target)
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
match outcome {
|
||||
QuitOutcome::Deleted { focus } => {
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
if let Some(focus) = focus {
|
||||
cc.borrow_mut().focus_window(fid, focus);
|
||||
}
|
||||
}
|
||||
QuitOutcome::Restored { target, .. } => {
|
||||
// Restoring is an ordinary presentation change:
|
||||
// fire the switch hook so store-backed overlays
|
||||
// reattach to the reinstated buffer.
|
||||
cc.borrow_mut().focus_window(fid, target);
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
if visible(&cc, fid, target) {
|
||||
cc.borrow_mut().focus_window(fid, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Read-only diagnostics over `WindowParams` (Q#BP2c).
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"params",
|
||||
lua.create_function(move |lua, target: Option<u64>| -> mlua::Result<Table> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => selected_window(&cc, fid)?,
|
||||
};
|
||||
let core = cc.borrow();
|
||||
let window = core
|
||||
.windows
|
||||
.get(&id)
|
||||
.ok_or_else(|| mlua::Error::runtime("pmacs.window.params: window not live"))?;
|
||||
let table = lua.create_table()?;
|
||||
table.set("window", id.raw())?;
|
||||
table.set("side", window.params.side.map(Side::name))?;
|
||||
table.set("fixed_rows", window.params.fixed_rows)?;
|
||||
table.set("dedicated", window.params.dedicated)?;
|
||||
table.set(
|
||||
"origin_document",
|
||||
window.params.origin_document().map(WindowId::raw),
|
||||
)?;
|
||||
table.set(
|
||||
"quit_action",
|
||||
window.params.quit_action().map(|action| match action {
|
||||
crate::window::QuitAction::Delete => "delete",
|
||||
crate::window::QuitAction::Restore { .. } => "restore",
|
||||
}),
|
||||
)?;
|
||||
table.set(
|
||||
"quit_depth",
|
||||
window
|
||||
.params
|
||||
.quit_action()
|
||||
.map_or(0, crate::window::QuitAction::depth),
|
||||
)?;
|
||||
table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?;
|
||||
Ok(table)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Only `fixed_rows` and `dedicated` are writable (Q#BP2c).
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"set_params",
|
||||
lua.create_function(
|
||||
move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = lookup_window(&cc, fid, target)?;
|
||||
for key in ["side", "origin_document", "quit_action"] {
|
||||
if opts.get::<Value>(key)? != Value::Nil {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"pmacs.window.set_params: `{key}` is not settable"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let height = match opts.get::<Option<u32>>("fixed_rows")? {
|
||||
Some(rows) => Some(
|
||||
crate::editor_core::EditorCore::clamp_panel_rows(rows)
|
||||
.map_err(mlua::Error::runtime)?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let dedicated = opts.get::<Option<bool>>("dedicated")?;
|
||||
{
|
||||
let mut core = cc.borrow_mut();
|
||||
let window = core.windows.get_mut(&id).ok_or_else(|| {
|
||||
mlua::Error::runtime("pmacs.window.set_params: window not live")
|
||||
})?;
|
||||
if let Some(rows) = height {
|
||||
// Inert on an ordinary window by construction:
|
||||
// the fixed map is built from side windows only.
|
||||
window.params.fixed_rows = Some(rows);
|
||||
}
|
||||
if let Some(dedicated) = dedicated {
|
||||
window.params.dedicated = dedicated;
|
||||
}
|
||||
}
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Q#BP5b — `resize(win, delta_rows)` resolves from the SUPPLIED
|
||||
// window; the `window.enlarge` / `window.shrink` commands are
|
||||
// implicitly active.
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"resize",
|
||||
lua.create_function(
|
||||
move |lua, (target, delta): (Option<u64>, i32)| -> mlua::Result<()> {
|
||||
let fid = acting_frontend(lua, &cc);
|
||||
let id = match target {
|
||||
Some(raw) => lookup_window(&cc, fid, raw)?,
|
||||
None => selected_window(&cc, fid)?,
|
||||
};
|
||||
let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| {
|
||||
mlua::Error::runtime(
|
||||
"pmacs.window.resize: this frontend has not declared its geometry yet",
|
||||
)
|
||||
})?;
|
||||
let minima: std::collections::HashMap<WindowId, u32> = {
|
||||
let core = cc.borrow();
|
||||
core.views
|
||||
.get(&fid)
|
||||
.map(|view| {
|
||||
view.layout
|
||||
.iter_ids()
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let buffer_id = core.windows.get(&id).map(|w| w.buffer_id);
|
||||
(
|
||||
id,
|
||||
config_u32(
|
||||
lua,
|
||||
"window.min-height",
|
||||
buffer_id,
|
||||
MIN_WINDOW_OUTER_ROWS,
|
||||
)
|
||||
.max(MIN_WINDOW_OUTER_ROWS),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
cc.borrow_mut()
|
||||
.resize_boundary(fid, id, delta, area_rows, &|id| {
|
||||
minima.get(&id).copied().unwrap_or(MIN_WINDOW_OUTER_ROWS)
|
||||
})
|
||||
.map_err(mlua::Error::runtime)?;
|
||||
reconcile_panel_layout(lua, &cc, fid);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -109,7 +109,12 @@ pub fn paint_other_frontend_overlays(
|
|||
return;
|
||||
}
|
||||
let text_area = Rect::new(0, 0, text_rows, term_size.cols);
|
||||
let placements = core.active_layout().compute(text_area);
|
||||
// Bottom-panel arc (R5-B1): this pass derives its own text-area
|
||||
// `Rect` instead of reusing `window_placements`, so it must ask for
|
||||
// the same fixed extents — otherwise every peer cursor paints at the
|
||||
// row it would occupy with no panel open.
|
||||
let fixed = core.panel_fixed_rows(core.active_frontend_key(), text_rows);
|
||||
let placements = core.active_layout().compute(text_area, &fixed);
|
||||
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
|
|||
176
src/syntax.rs
176
src/syntax.rs
|
|
@ -251,6 +251,12 @@ pub fn default_injection_aliases() -> HashMap<String, String> {
|
|||
("golang", "go"),
|
||||
("yml", "yaml"),
|
||||
("md", "markdown"),
|
||||
// Lean 4 (framing Q#LN17). A ```lean fence is overwhelmingly Lean 4
|
||||
// in practice, so the Lean 3 spelling is deliberately mapped forward
|
||||
// rather than left unresolved. `lean4` needs no alias — it is the
|
||||
// entry name. `lean4-mode` does the equivalent through
|
||||
// `markdown-code-lang-modes`.
|
||||
("lean", "lean4"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(a, b)| (a.to_owned(), b.to_owned()))
|
||||
|
|
@ -1130,6 +1136,33 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
|
|||
locals_query: &[],
|
||||
injections_query: &[],
|
||||
},
|
||||
// Lean 4 (framing `docs/lean4-mode-framing.md`, Arc 8 Stage 1).
|
||||
//
|
||||
// The entry is named `lean4`, not `lean` (Q#LN2): this name becomes the
|
||||
// `language_id` sent in `didOpen` — `ensure_server` at
|
||||
// `builtin/runtime/lsp.lua:540` passes it straight through — and the
|
||||
// Lean ecosystem's id is `lean4` (`lean` is Lean 3, which is
|
||||
// end-of-life). The grammar's own C symbol is `tree_sitter_lean`; that
|
||||
// is arborium's business, not ours. Stage 3 adds
|
||||
// `pmacs.lsp.config.lean4` against this name.
|
||||
//
|
||||
// Note the loader shape: `arborium-lean` exports `const fn language() ->
|
||||
// LanguageFn` rather than a `LANGUAGE` const, so this is the one entry
|
||||
// that calls a function to get the `LanguageFn` before `.into()`.
|
||||
//
|
||||
// `.olean` (compiled artifacts) and `.ilean` (JSON metadata) are
|
||||
// deliberately unclaimed (Q#LN3). Locals and injections are empty
|
||||
// because the crate ships both as empty strings — Lean has no embedded
|
||||
// sublanguage worth injecting, and its scoping is far beyond what a
|
||||
// tree-sitter locals query could model.
|
||||
LanguageEntry {
|
||||
name: "lean4",
|
||||
extensions: &["lean"],
|
||||
loader: || arborium_lean::language().into(),
|
||||
highlights_query: &[arborium_lean::HIGHLIGHTS_QUERY],
|
||||
locals_query: &[],
|
||||
injections_query: &[],
|
||||
},
|
||||
];
|
||||
|
||||
/// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate
|
||||
|
|
@ -2394,6 +2427,149 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_languages_include_lean4() {
|
||||
// Framing acceptance 1/3 (`docs/lean4-mode-framing.md`). The entry is
|
||||
// named `lean4` because that name becomes the `didOpen` language_id
|
||||
// (Q#LN2), and it claims `.lean` ONLY: `.olean` is a compiled binary
|
||||
// artifact and `.ilean` is JSON metadata (Q#LN3).
|
||||
let lean = BUILTIN_LANGUAGES
|
||||
.iter()
|
||||
.find(|l| l.name == "lean4")
|
||||
.expect("`lean4` language entry must be present");
|
||||
assert!(lean.extensions.contains(&"lean"), "`lean4` claims `.lean`");
|
||||
for unclaimed in ["olean", "ilean"] {
|
||||
assert!(
|
||||
!lean.extensions.contains(&unclaimed),
|
||||
"`lean4` must not claim `.{unclaimed}`"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
lean.highlights_query
|
||||
.contains(&arborium_lean::HIGHLIGHTS_QUERY),
|
||||
"`lean4` drives highlighting from the crate's query constant, not an overlay"
|
||||
);
|
||||
assert!(
|
||||
lean.locals_query.is_empty() && lean.injections_query.is_empty(),
|
||||
"`lean4` ships neither locals nor injections (Q#LN1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_grammar_loads_and_parses() {
|
||||
// Framing acceptance 2 and the open half of Q#LN1: `arborium-lean`
|
||||
// exports `const fn language() -> LanguageFn` (not the `LANGUAGE`
|
||||
// const every other entry uses) over `tree-sitter-language 0.1`, and
|
||||
// its README demonstrates usage against a `tree_sitter_patched_
|
||||
// arborium` core. Neither is supposed to matter — the LanguageFn ABI
|
||||
// is shared — but "supposed to" is not evidence, so this pins that
|
||||
// OUR `tree-sitter` 0.26 core accepts it and produces a real tree.
|
||||
//
|
||||
// The fixture exercises the grammar's external scanner (`scanner.c`
|
||||
// supplies a NEWLINE token, so layout-sensitive `def`/`theorem`
|
||||
// bodies depend on it) and the Unicode operators that make Lean
|
||||
// Lean — `→`, `∀`, `≥` — which a byte-oriented misbuild would shred.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let language = reg
|
||||
.language("lean4")
|
||||
.expect("`lean4` language loads from BUILTIN_LANGUAGES");
|
||||
let mut buf = fresh_buffer("Basic.lean");
|
||||
buf.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: "-- a comment\n\
|
||||
def fibonacci : Nat → Nat\n\
|
||||
\x20 | 0 => 0\n\
|
||||
\x20 | n + 1 => n\n\
|
||||
\n\
|
||||
theorem fib_nonneg : ∀ n, fibonacci n ≥ 0 := by\n\
|
||||
\x20 intro n\n\
|
||||
\x20 exact Nat.zero_le _\n"
|
||||
.as_bytes(),
|
||||
})
|
||||
.unwrap();
|
||||
let view = ParseView::new(&buf, language, "lean4".to_owned());
|
||||
let handle = view.handle();
|
||||
let _vid = buf.attach_view(Box::new(view));
|
||||
let bundle = parse_synchronously(&handle);
|
||||
assert_eq!(
|
||||
bundle.root_tree().root_node().kind(),
|
||||
"module",
|
||||
"Lean grammar roots at module"
|
||||
);
|
||||
let sexp = bundle.root_tree().root_node().to_sexp();
|
||||
// This specific committed fixture parses cleanly. The claim is
|
||||
// scoped to the fixture on purpose: Lean's syntax is user-extensible
|
||||
// via macros, so a static grammar necessarily mis-parses some legal
|
||||
// input (the upstream grammar says so itself, and the framing scores
|
||||
// it as bet 3). What a clean parse HERE proves is that the crate is
|
||||
// wired correctly, not that Lean is fully parseable.
|
||||
assert!(
|
||||
!bundle.root_tree().root_node().has_error(),
|
||||
"the fixture parses without error; got {sexp}"
|
||||
);
|
||||
// `def` and `theorem` sit under a `declaration` wrapper, not directly
|
||||
// under `module`.
|
||||
for expected in ["(comment)", "(def ", "(theorem "] {
|
||||
assert!(
|
||||
sexp.contains(expected),
|
||||
"expected `{expected}` in the tree; got {sexp}"
|
||||
);
|
||||
}
|
||||
// The load-bearing part of this test. A grammar built against a
|
||||
// mismatched core, or one whose scanner mis-handles multibyte input,
|
||||
// does not fail loudly — it produces a tree that silently degrades on
|
||||
// exactly the characters Lean is made of. `→` must become an `arrow`,
|
||||
// `∀` a `forall`, and `≥` a `comparison`; if these three hold, the
|
||||
// UTF-8 path through the parser is sound.
|
||||
for expected in ["(arrow ", "(forall ", "(comparison "] {
|
||||
assert!(
|
||||
sexp.contains(expected),
|
||||
"Unicode operator did not produce `{expected}`; got {sexp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lean4_highlights_resolve() {
|
||||
// The crate's 213-line query must COMPILE against the grammar it
|
||||
// ships with — the node-name compatibility gate. A query referencing
|
||||
// a node this grammar version lacks fails here rather than silently
|
||||
// producing no spans at runtime.
|
||||
let reg = SyntaxRegistry::new();
|
||||
let query = reg
|
||||
.highlights_query("lean4")
|
||||
.expect("lean4 highlights compile against the grammar");
|
||||
let names = query.capture_names();
|
||||
// The four capture names Q#LN4 adds to the GLOBAL theme table are
|
||||
// present here — this is the forward direction of that decision; the
|
||||
// reverse direction (what they do to other languages) is pinned in
|
||||
// `highlight.rs`.
|
||||
for expected in ["constructor", "character", "keyword.conditional", "warning"] {
|
||||
assert!(
|
||||
names.contains(&expected),
|
||||
"lean4 query uses `@{expected}`, which Q#LN4 adds to the theme; got {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_for_path_resolves_lean_extension() {
|
||||
let reg = SyntaxRegistry::new();
|
||||
assert_eq!(
|
||||
reg.language_name_for_path("Mathlib/Data/Nat/Basic.lean")
|
||||
.as_deref(),
|
||||
Some("lean4"),
|
||||
"`.lean` resolves to the lean4 grammar"
|
||||
);
|
||||
for unclaimed in ["Basic.olean", "Basic.ilean"] {
|
||||
assert_ne!(
|
||||
reg.language_name_for_path(unclaimed).as_deref(),
|
||||
Some("lean4"),
|
||||
"{unclaimed} must not resolve to lean4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_languages_include_html_and_css() {
|
||||
// Both crate grammars export their query constants (no overlay). HTML
|
||||
|
|
|
|||
|
|
@ -132,8 +132,7 @@ impl TerminalManager {
|
|||
last_bell_count: bell_count,
|
||||
..TerminalViewState::default()
|
||||
});
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
declare_view_size(state, projection, viewport_size);
|
||||
Some(project_snapshot(
|
||||
key.buffer_id,
|
||||
viewport_size,
|
||||
|
|
@ -234,8 +233,7 @@ impl TerminalManager {
|
|||
last_bell_count: bell_count,
|
||||
..TerminalViewState::default()
|
||||
});
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
declare_view_size(state, projection, viewport_size);
|
||||
let rows = retained_rows(projection);
|
||||
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
||||
Some(TerminalViewStatus {
|
||||
|
|
@ -289,8 +287,7 @@ impl TerminalManager {
|
|||
last_bell_count: bell_count,
|
||||
..TerminalViewState::default()
|
||||
});
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
declare_view_size(state, projection, viewport_size);
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -595,6 +592,56 @@ fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<
|
|||
.then(|| row_lead(first))
|
||||
}
|
||||
|
||||
/// The shared viewport-size declaration path (bottom-panel arc, Q#BP7).
|
||||
///
|
||||
/// Normalize, then re-arm live-tail following when the newly declared
|
||||
/// viewport reaches the tail, then record the size. Every path that
|
||||
/// *declares* a size routes through here so grid and semantic
|
||||
/// declarations cannot disagree; `scroll_view` and `begin_selection`
|
||||
/// deliberately do not, because they write `top` themselves.
|
||||
fn declare_view_size(
|
||||
state: &mut TerminalViewState,
|
||||
projection: BorrowedScreenProjection<'_>,
|
||||
viewport_size: CellSize,
|
||||
) {
|
||||
normalize_state(state, projection);
|
||||
rearm_follow_on_growth(state, projection, viewport_size.rows);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
}
|
||||
|
||||
/// Q#BP7 item 1: **growth reaching the live tail re-arms follow.**
|
||||
///
|
||||
/// A height change is a viewport change, never a scroll change — `top`
|
||||
/// is preserved verbatim — but once a taller viewport covers the tail,
|
||||
/// staying anchored would leave the view frozen just short of the live
|
||||
/// output while `at_bottom` reported `true`: `at_bottom` is the
|
||||
/// instantaneous geometric readout `scroll_offset == 0`, so it cannot
|
||||
/// distinguish "following" from "anchored, and currently tall enough to
|
||||
/// reach". The next rows the child prints would then push the anchored
|
||||
/// view back into history with nothing to explain it.
|
||||
///
|
||||
/// **Only when no selection is active** (R1-8): a historical selection
|
||||
/// froze this anchor on purpose, and growth must not yank the user's
|
||||
/// region out from under them. `scroll_view` already handles the
|
||||
/// scroll-driven arm (`next == tail_start`), so during ordinary
|
||||
/// scrolling `scroll_offset == 0` implies follow is already armed —
|
||||
/// which makes this rule fire on exactly the growth (and shrink-back)
|
||||
/// case it names, and be idempotent everywhere else.
|
||||
fn rearm_follow_on_growth(
|
||||
state: &mut TerminalViewState,
|
||||
projection: BorrowedScreenProjection<'_>,
|
||||
viewport_rows: u32,
|
||||
) {
|
||||
if state.top.is_none() || state.selection.is_some() || viewport_rows == 0 {
|
||||
return;
|
||||
}
|
||||
let rows = retained_rows(projection);
|
||||
if view_geometry(&rows, state, viewport_rows).scroll_offset == 0 {
|
||||
state.top = None;
|
||||
state.selection_froze_top = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) {
|
||||
if state
|
||||
.alternate_active
|
||||
|
|
|
|||
600
src/window.rs
600
src/window.rs
|
|
@ -154,6 +154,201 @@ pub fn decimal_digits(mut n: usize) -> u32 {
|
|||
d
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window parameters (bottom-panel arc, Q#BP2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which edge of the frame a *side window* is pinned to.
|
||||
///
|
||||
/// Stage 1 of the bottom-panel arc ships exactly one side. Left / right /
|
||||
/// top are named deferrals, so the enum stays closed rather than
|
||||
/// accepting a value no allocator honors: a Lua caller asking for an
|
||||
/// unsupported side gets a pointed error at the boundary instead of a
|
||||
/// silently ordinary window.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Side {
|
||||
/// Pinned to the bottom of the frame (the panel slot).
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Parse the Lua-facing spelling. `None` for every unsupported value.
|
||||
#[must_use]
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"bottom" => Some(Self::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-facing spelling.
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structural floor for a window's **outer** row extent: one text row
|
||||
/// plus its mode line (`content = outer - 1`).
|
||||
///
|
||||
/// Every programmatic source of `fixed_rows` clamps a nonzero request up
|
||||
/// to this floor; a request of `0` is rejected rather than being an
|
||||
/// invisible "open" (Q#BP2). This is *not* a promise that the layout can
|
||||
/// never produce a smaller rect — [`Layout::compute`] has always been
|
||||
/// allowed to hand out zero extents on an intrinsically tiny frame. The
|
||||
/// bounded promise is narrower: the panel allocator never makes an
|
||||
/// otherwise satisfiable document tree unsatisfiable.
|
||||
pub const MIN_WINDOW_OUTER_ROWS: u32 = 2;
|
||||
|
||||
/// Default `window.panel-height`: outer rows a freshly created panel
|
||||
/// takes when the caller supplies no explicit `height` (Q#BP11).
|
||||
pub const DEFAULT_PANEL_ROWS: u32 = 12;
|
||||
|
||||
/// How far back [`QuitAction::Restore`] chains may be retained before the
|
||||
/// oldest retained presentation is truncated to [`QuitAction::Delete`]
|
||||
/// (Q#BP2c, R4-B6). Repeated panel replacement would otherwise grow the
|
||||
/// recursive history without bound.
|
||||
pub const MAX_PANEL_QUIT_DEPTH: usize = 64;
|
||||
|
||||
/// What `window.quit` does to a side window (Q#BP2c).
|
||||
///
|
||||
/// Present only on a side window; ordinary windows and every capability
|
||||
/// fallback carry `None`. Replacing a side presentation captures the
|
||||
/// outgoing one in `Restore` so `C → B → A → delete` restores the actual
|
||||
/// presentations rather than forgetting `A` or leaking `C`'s height and
|
||||
/// dedication into it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum QuitAction {
|
||||
/// Close the side window and collapse its wrapper.
|
||||
Delete,
|
||||
/// Reinstate a previously displayed presentation, then fall back to
|
||||
/// `then` on the next quit.
|
||||
Restore {
|
||||
/// Buffer that was displayed. Revalidated at quit time: a killed
|
||||
/// buffer degrades the whole entry to [`QuitAction::Delete`].
|
||||
buffer_id: BufferId,
|
||||
/// Requested outer rows of that presentation.
|
||||
fixed_rows: u32,
|
||||
/// Whether that presentation was dedicated.
|
||||
dedicated: bool,
|
||||
/// Saved cursor, clamped against the buffer's current contents.
|
||||
cursor: Position,
|
||||
/// Saved first visible line.
|
||||
view_top: usize,
|
||||
/// Saved sticky goal column.
|
||||
goal_col: Option<u32>,
|
||||
/// Saved region, if one was active.
|
||||
selection: Option<Selection>,
|
||||
/// The action that was in force *before* this presentation
|
||||
/// replaced its predecessor.
|
||||
then: Box<QuitAction>,
|
||||
},
|
||||
}
|
||||
|
||||
impl QuitAction {
|
||||
/// Number of retained presentations in this chain, counted
|
||||
/// iteratively so a long history can never blow the stack.
|
||||
#[must_use]
|
||||
pub fn depth(&self) -> usize {
|
||||
let mut depth = 0usize;
|
||||
let mut cursor = self;
|
||||
while let Self::Restore { then, .. } = cursor {
|
||||
depth += 1;
|
||||
cursor = then;
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
/// Truncate the oldest retained `Restore` to [`QuitAction::Delete`]
|
||||
/// so the chain holds at most `cap` presentations. Iterative, like
|
||||
/// [`Self::depth`].
|
||||
pub fn truncate_to(&mut self, cap: usize) {
|
||||
if cap == 0 {
|
||||
*self = Self::Delete;
|
||||
return;
|
||||
}
|
||||
let mut kept = 0usize;
|
||||
let mut cursor = self;
|
||||
loop {
|
||||
match cursor {
|
||||
Self::Delete => return,
|
||||
Self::Restore { then, .. } => {
|
||||
kept += 1;
|
||||
if kept >= cap {
|
||||
**then = Self::Delete;
|
||||
return;
|
||||
}
|
||||
cursor = then;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-window display-policy parameters (Q#BP2).
|
||||
///
|
||||
/// `side` is immutable after placement; `quit_action` and
|
||||
/// `origin_document` are implementation-owned bookkeeping that the Lua
|
||||
/// `set_params` surface refuses to write (Q#BP2c), so Lua cannot forge a
|
||||
/// window id, a buffer restore chain, or stale cursor state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WindowParams {
|
||||
/// Side this window is pinned to, or `None` for an ordinary
|
||||
/// document window. Immutable after placement (Q#BP2a).
|
||||
pub side: Option<Side>,
|
||||
/// Requested **outer** rows (including the mode line) when this is a
|
||||
/// side window. Inert on any other window — the fixed map is built
|
||||
/// from side windows only.
|
||||
pub fixed_rows: Option<u32>,
|
||||
/// Whether `display_buffer` may replace this window's buffer.
|
||||
///
|
||||
/// Binds the **policy layer only**: raw `pmacs.window.switch_buffer`
|
||||
/// and `switch_active_buffer_for` deliberately ignore it, because
|
||||
/// they are the low-level escape hatch and every existing caller
|
||||
/// predates this arc (Q#BP2c).
|
||||
pub dedicated: bool,
|
||||
/// See [`WindowParams::quit_action`].
|
||||
quit_action: Option<QuitAction>,
|
||||
/// See [`WindowParams::origin_document`].
|
||||
origin_document: Option<WindowId>,
|
||||
}
|
||||
|
||||
impl WindowParams {
|
||||
/// What `window.quit` does here, if anything.
|
||||
#[must_use]
|
||||
pub fn quit_action(&self) -> Option<&QuitAction> {
|
||||
self.quit_action.as_ref()
|
||||
}
|
||||
|
||||
/// Install (or clear) the quit action. Rust-internal: no Lua path
|
||||
/// reaches this.
|
||||
pub fn set_quit_action(&mut self, action: Option<QuitAction>) {
|
||||
self.quit_action = action;
|
||||
}
|
||||
|
||||
/// The remembered document window this side window was entered
|
||||
/// from (Q#BP2c). Recorded at panel creation, refreshed on every
|
||||
/// focus transition from a non-side window into the panel, and
|
||||
/// revalidated on every use.
|
||||
#[must_use]
|
||||
pub fn origin_document(&self) -> Option<WindowId> {
|
||||
self.origin_document
|
||||
}
|
||||
|
||||
/// Record (or clear) the remembered document window. Rust-internal.
|
||||
pub fn set_origin_document(&mut self, origin: Option<WindowId>) {
|
||||
self.origin_document = origin;
|
||||
}
|
||||
|
||||
/// True iff this window is pinned to a side.
|
||||
#[must_use]
|
||||
pub fn is_side(&self) -> bool {
|
||||
self.side.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// One leaf of the window tree: a buffer plus per-window state.
|
||||
pub struct Window {
|
||||
/// Unique identifier.
|
||||
|
|
@ -186,6 +381,9 @@ pub struct Window {
|
|||
/// Line-number gutter mode for this window (UX gutter arc). `Off` by
|
||||
/// default → no gutter, no coordinate change.
|
||||
pub line_numbers: LineNumberMode,
|
||||
/// Display-policy parameters (bottom-panel arc, Q#BP2). Default for
|
||||
/// every ordinary window: no side, no fixed extent, undedicated.
|
||||
pub params: WindowParams,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
|
|
@ -204,9 +402,16 @@ impl Window {
|
|||
goal_col: None,
|
||||
last_visible_rows: 0,
|
||||
line_numbers: LineNumberMode::Off,
|
||||
params: WindowParams::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this window is pinned to a side (bottom-panel arc).
|
||||
#[must_use]
|
||||
pub fn is_side(&self) -> bool {
|
||||
self.params.is_side()
|
||||
}
|
||||
|
||||
/// Width in cells this window's line-number gutter occupies, or `0`
|
||||
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
|
||||
/// the renderer caps this against the window width and applies it as a
|
||||
|
|
@ -305,6 +510,23 @@ pub struct Layout {
|
|||
pub root: LayoutNode,
|
||||
}
|
||||
|
||||
/// A frontend's last authoritative cell-equivalent frame capacity
|
||||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// `geometry_epoch` is a monotonically increasing declaration id owned by
|
||||
/// the frontend. Grid / `LOCAL` views cache their real attach and resize
|
||||
/// sizes here with an internal epoch; a semantic view stays `None` —
|
||||
/// **unknown**, never `24×80` — until Stage 2's authenticated
|
||||
/// `FrontendCellGeometry` fills it.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeclaredFrameGeometry {
|
||||
/// Monotonic declaration id. A lower or repeated epoch carrying
|
||||
/// different data is stale.
|
||||
pub geometry_epoch: u64,
|
||||
/// Whole-frame capacity in cells, including the one global status row.
|
||||
pub total: CellSize,
|
||||
}
|
||||
|
||||
/// T M10.8 — one attached frontend's view of the editor.
|
||||
///
|
||||
/// Per-frontend state for multi-frontend operation: the split tree
|
||||
|
|
@ -346,6 +568,32 @@ pub struct FrontendView {
|
|||
/// explicitly, so the projection is never inferred from a
|
||||
/// `FrontendId` (**Bet B8**).
|
||||
pub fold_projection: bool,
|
||||
/// Whether this frontend can *render* a side window (bottom-panel
|
||||
/// arc, Q#BP13).
|
||||
///
|
||||
/// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId) and
|
||||
/// every grid session. Stage 1 sets `false` for every semantic
|
||||
/// session — the GPU band is Stage 2 — so a `display` carrying a
|
||||
/// `side` falls back to the non-side target and **discards every
|
||||
/// side-specific parameter** rather than pinning a document window it
|
||||
/// could not show. Like `fold_projection`, deliberately has no
|
||||
/// `Default`: every construction site chooses explicitly.
|
||||
pub panel_capable: bool,
|
||||
/// This frontend's last authoritative frame capacity, or `None` while
|
||||
/// it is **unknown** (Q#BP2b).
|
||||
///
|
||||
/// The panel allocator is the only consumer, and it must never guess:
|
||||
/// a panel requested before a real declaration stays non-presentable
|
||||
/// rather than being sized against the GPU attach request's permanent
|
||||
/// `24×80` placeholder.
|
||||
pub frame_geometry: Option<DeclaredFrameGeometry>,
|
||||
/// Cached derived layout state: the side window exists but cannot be
|
||||
/// satisfied on the current frame (Q#BP2b).
|
||||
///
|
||||
/// Recomputed from authoritative geometry by
|
||||
/// `EditorState::reconcile_panel_layout`; never persisted, never set
|
||||
/// from Lua, and never `true` while no side window exists.
|
||||
pub panel_hidden: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
|
|
@ -359,17 +607,75 @@ impl Layout {
|
|||
|
||||
/// Walk the tree and assign each leaf a viewport rectangle.
|
||||
///
|
||||
/// Splits divide proportionally according to their weights. If a
|
||||
/// child's allocated extent is `0` (terminal too small for the
|
||||
/// Splits divide proportionally according to their weights, except
|
||||
/// that a leaf listed in `fixed` takes exactly that many **rows** out
|
||||
/// of a horizontal split before the remainder is divided (Q#BP2).
|
||||
/// The map is the *effective* allocation, not the stored request: a
|
||||
/// hidden panel is passed as `0`, which gives it an empty rect and
|
||||
/// hands every reclaimed row back to the document subtree.
|
||||
///
|
||||
/// `fixed` is interpreted only on leaves of a **horizontal** split —
|
||||
/// a vertical split divides columns, where a row count means nothing
|
||||
/// — and the last flexible child still takes the remainder, so a tree
|
||||
/// with no fixed leaves computes byte-identically to before this arc.
|
||||
/// If a child's allocated extent is `0` (terminal too small for the
|
||||
/// split), that child receives an empty rect, and renderers must
|
||||
/// skip it.
|
||||
#[must_use]
|
||||
pub fn compute(&self, area: Rect) -> HashMap<WindowId, Rect> {
|
||||
pub fn compute(&self, area: Rect, fixed: &HashMap<WindowId, u32>) -> HashMap<WindowId, Rect> {
|
||||
let mut out = HashMap::new();
|
||||
compute_node(&self.root, area, &mut out);
|
||||
compute_node(&self.root, area, fixed, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The single side leaf among `sides`, if this layout holds one.
|
||||
///
|
||||
/// `sides` answers "is this window pinned to a side"; the caller owns
|
||||
/// the `Window` table, so the predicate is injected rather than
|
||||
/// duplicated here. At most one bottom side leaf exists per
|
||||
/// `FrontendView` (Q#BP2a).
|
||||
#[must_use]
|
||||
pub fn side_leaf(&self, sides: impl Fn(WindowId) -> bool) -> Option<WindowId> {
|
||||
self.iter_ids().into_iter().find(|id| sides(*id))
|
||||
}
|
||||
|
||||
/// The document subtree beneath the root-level panel wrapper.
|
||||
///
|
||||
/// A side window is installed as the final child of a horizontal
|
||||
/// split wrapping the entire prior root (Q#BP2a), so the document
|
||||
/// subtree is that wrapper's first child. Returns `None` when the
|
||||
/// tree does not have that exact shape.
|
||||
#[must_use]
|
||||
pub fn document_subtree(&self, side: WindowId) -> Option<&LayoutNode> {
|
||||
match &self.root {
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} if children.len() == 2
|
||||
&& matches!(children[1], LayoutNode::Leaf(id) if id == side) =>
|
||||
{
|
||||
Some(&children[0])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the entire current root in a horizontal split whose final
|
||||
/// child is `side` (Q#BP2a).
|
||||
///
|
||||
/// `fixed_rows` makes the panel's weight inert, so the prior root
|
||||
/// keeps the flexible remainder and its **structure** — nodes,
|
||||
/// weights, order, ids — is untouched (Bet B6).
|
||||
pub fn install_side_leaf(&mut self, side: WindowId) {
|
||||
let prior = std::mem::replace(&mut self.root, LayoutNode::Leaf(side));
|
||||
self.root = LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
weights: vec![1, 1],
|
||||
children: vec![prior, LayoutNode::Leaf(side)],
|
||||
};
|
||||
}
|
||||
|
||||
/// All [`WindowId`]s in left→right / top→bottom order.
|
||||
#[must_use]
|
||||
pub fn iter_ids(&self) -> Vec<WindowId> {
|
||||
|
|
@ -414,25 +720,211 @@ impl Layout {
|
|||
/// if the layout has only one window.
|
||||
#[must_use]
|
||||
pub fn focus_next(&self, current: WindowId) -> WindowId {
|
||||
let ids = self.iter_ids();
|
||||
match ids.iter().position(|&id| id == current) {
|
||||
Some(i) => ids[(i + 1) % ids.len()],
|
||||
None => *ids.first().unwrap_or(¤t),
|
||||
}
|
||||
self.focus_step(current, true, &|_| true)
|
||||
}
|
||||
|
||||
/// Step focus to the previous window.
|
||||
#[must_use]
|
||||
pub fn focus_prev(&self, current: WindowId) -> WindowId {
|
||||
self.focus_step(current, false, &|_| true)
|
||||
}
|
||||
|
||||
/// [`Self::focus_next`] / [`Self::focus_prev`] restricted to windows
|
||||
/// `eligible` accepts (Q#BP6: a hidden panel is never a focus
|
||||
/// destination, though it becomes one again as soon as it reappears).
|
||||
///
|
||||
/// A currently focused ineligible window can always leave, so the
|
||||
/// caller can never strand focus: `current` itself is not filtered.
|
||||
#[must_use]
|
||||
pub fn focus_step(
|
||||
&self,
|
||||
current: WindowId,
|
||||
forward: bool,
|
||||
eligible: &impl Fn(WindowId) -> bool,
|
||||
) -> WindowId {
|
||||
let ids = self.iter_ids();
|
||||
match ids.iter().position(|&id| id == current) {
|
||||
Some(i) => ids[(i + ids.len() - 1) % ids.len()],
|
||||
None => *ids.first().unwrap_or(¤t),
|
||||
if ids.is_empty() {
|
||||
return current;
|
||||
}
|
||||
let Some(start) = ids.iter().position(|&id| id == current) else {
|
||||
return ids
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| eligible(*id))
|
||||
.unwrap_or_else(|| *ids.first().unwrap_or(¤t));
|
||||
};
|
||||
let n = ids.len();
|
||||
for step in 1..=n {
|
||||
let i = if forward {
|
||||
(start + step) % n
|
||||
} else {
|
||||
(start + n - (step % n)) % n
|
||||
};
|
||||
if eligible(ids[i]) {
|
||||
return ids[i];
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Index path from the root to `target`'s leaf, or `None` when the
|
||||
/// layout does not hold it.
|
||||
#[must_use]
|
||||
pub fn path_to(&self, target: WindowId) -> Option<Vec<usize>> {
|
||||
let mut path = Vec::new();
|
||||
path_to_node(&self.root, target, &mut path).then_some(path)
|
||||
}
|
||||
|
||||
/// The node at `path`, or `None` when the path does not resolve.
|
||||
#[must_use]
|
||||
pub fn node_at(&self, path: &[usize]) -> Option<&LayoutNode> {
|
||||
let mut node = &self.root;
|
||||
for &i in path {
|
||||
match node {
|
||||
LayoutNode::Split { children, .. } => node = children.get(i)?,
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// Mutable [`Self::node_at`].
|
||||
pub fn node_at_mut(&mut self, path: &[usize]) -> Option<&mut LayoutNode> {
|
||||
let mut node = &mut self.root;
|
||||
for &i in path {
|
||||
match node {
|
||||
LayoutNode::Split { children, .. } => node = children.get_mut(i)?,
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
/// The horizontal boundary immediately **below** `target` (Q#BP5b
|
||||
/// rule 2), or `None` when there is none.
|
||||
///
|
||||
/// Walk up from the leaf to the nearest horizontal-split ancestor at
|
||||
/// which the path child has a **following sibling**. "Nearest
|
||||
/// horizontal ancestor" alone is wrong: when the subtree is that
|
||||
/// ancestor's *final* child there is no boundary below it there, and
|
||||
/// the real one is further up. This is also the boundary a drag on
|
||||
/// `target`'s bottom mode-line row moves, so keyboard resize and drag
|
||||
/// are the same operation (acceptance 31).
|
||||
#[must_use]
|
||||
pub fn boundary_below(&self, target: WindowId) -> Option<SplitBoundary> {
|
||||
let path = self.path_to(target)?;
|
||||
for depth in (0..path.len()).rev() {
|
||||
let parent_path = &path[..depth];
|
||||
let child_index = path[depth];
|
||||
let LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} = self.node_at(parent_path)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if child_index + 1 < children.len() {
|
||||
return Some(SplitBoundary {
|
||||
path: parent_path.to_vec(),
|
||||
upper: child_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// One horizontal split boundary: the split node plus the index of the
|
||||
/// child immediately **above** the dividing line (Q#BP5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SplitBoundary {
|
||||
/// Index path from the root to the horizontal split node.
|
||||
pub path: Vec<usize>,
|
||||
/// Index of the child above the boundary; `upper + 1` is below it.
|
||||
pub upper: usize,
|
||||
}
|
||||
|
||||
fn path_to_node(node: &LayoutNode, target: WindowId, path: &mut Vec<usize>) -> bool {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => *id == target,
|
||||
LayoutNode::Split { children, .. } => {
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
path.push(i);
|
||||
if path_to_node(child, target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>) {
|
||||
/// Minimum **outer** rows a subtree needs for every one of its leaves to
|
||||
/// clear [`MIN_WINDOW_OUTER_ROWS`] (Q#BP2).
|
||||
///
|
||||
/// The recursion is the point: "leave the document tree two rows" is
|
||||
/// wrong, because two rows at the root does not give each nested leaf two
|
||||
/// rows. Horizontal splits stack rows, so minima add; vertical splits
|
||||
/// share rows, so the tallest child governs.
|
||||
#[must_use]
|
||||
pub fn subtree_min_rows(node: &LayoutNode) -> u32 {
|
||||
match node {
|
||||
LayoutNode::Leaf(_) => MIN_WINDOW_OUTER_ROWS,
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} => children.iter().map(subtree_min_rows).sum(),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Vertical,
|
||||
children,
|
||||
..
|
||||
} => children.iter().map(subtree_min_rows).max().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same sum/max recursion over the user's `window.min-height`
|
||||
/// *preference* (Q#BP2).
|
||||
///
|
||||
/// `per_leaf` resolves the setting against that window's own buffer
|
||||
/// (buffer-local override → global → default) and is snapshotted once per
|
||||
/// gesture, before any geometry changes. Only **interactive** resize —
|
||||
/// drag, keyboard, and the Stage 2 `PanelResizeRows` — consults this; the
|
||||
/// ordinary layout pass and frame-resize reconciliation use
|
||||
/// [`subtree_min_rows`] alone, so changing a preference can never
|
||||
/// invalidate an existing layout.
|
||||
#[must_use]
|
||||
pub fn interactive_min_rows(node: &LayoutNode, per_leaf: &impl Fn(WindowId) -> u32) -> u32 {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => per_leaf(*id),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Horizontal,
|
||||
children,
|
||||
..
|
||||
} => children
|
||||
.iter()
|
||||
.map(|child| interactive_min_rows(child, per_leaf))
|
||||
.sum(),
|
||||
LayoutNode::Split {
|
||||
orientation: Orientation::Vertical,
|
||||
children,
|
||||
..
|
||||
} => children
|
||||
.iter()
|
||||
.map(|child| interactive_min_rows(child, per_leaf))
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_node(
|
||||
node: &LayoutNode,
|
||||
area: Rect,
|
||||
fixed: &HashMap<WindowId, u32>,
|
||||
out: &mut HashMap<WindowId, Rect>,
|
||||
) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => {
|
||||
out.insert(*id, area);
|
||||
|
|
@ -442,18 +934,66 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
|
|||
weights,
|
||||
children,
|
||||
} => {
|
||||
let total: u32 = weights.iter().map(|w| (*w).max(1)).sum();
|
||||
let primary = match orientation {
|
||||
Orientation::Horizontal => area.size.rows,
|
||||
Orientation::Vertical => area.size.cols,
|
||||
};
|
||||
// Pass 1 — subtract the fixed children. Only a horizontal
|
||||
// split divides rows, so `fixed` is inert anywhere else.
|
||||
let mut extents: Vec<Option<u32>> = vec![None; children.len()];
|
||||
let mut fixed_total: u32 = 0;
|
||||
if matches!(orientation, Orientation::Horizontal) {
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
if let LayoutNode::Leaf(id) = child
|
||||
&& let Some(rows) = fixed.get(id).copied()
|
||||
{
|
||||
// Saturating: a request larger than the frame
|
||||
// takes what is left rather than wrapping. The
|
||||
// caller has already clamped against the document
|
||||
// minimum; this is the last-resort floor.
|
||||
let take = rows.min(primary.saturating_sub(fixed_total));
|
||||
extents[i] = Some(take);
|
||||
fixed_total += take;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pass 2 — divide the remainder by weight among the flexible
|
||||
// children, preserving last-flexible-takes-the-remainder.
|
||||
let remainder = primary.saturating_sub(fixed_total);
|
||||
let total: u32 = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| extents[*i].is_none())
|
||||
.map(|(i, _)| weights.get(i).copied().unwrap_or(1).max(1))
|
||||
.sum();
|
||||
let last_flexible = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(i, _)| extents[*i].is_none())
|
||||
.map(|(i, _)| i);
|
||||
let mut flexible_used: u32 = 0;
|
||||
let mut cursor: u32 = 0;
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let w = weights.get(i).copied().unwrap_or(1).max(1);
|
||||
let extent = if i + 1 == children.len() {
|
||||
primary - cursor
|
||||
let extent = if let Some(rows) = extents[i] {
|
||||
rows
|
||||
} else {
|
||||
primary * w / total
|
||||
let w = weights.get(i).copied().unwrap_or(1).max(1);
|
||||
// u64 intermediates: `remainder * w` is the only
|
||||
// place this arithmetic could overflow a u32, and a
|
||||
// saturating fallback there would hand a non-last
|
||||
// child the whole remainder and underflow the last
|
||||
// one. Widening deletes the case outright.
|
||||
let e = if Some(i) == last_flexible {
|
||||
remainder - flexible_used
|
||||
} else if total == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total))
|
||||
.unwrap_or(remainder)
|
||||
};
|
||||
flexible_used += e;
|
||||
e
|
||||
};
|
||||
let child_area = match orientation {
|
||||
Orientation::Horizontal => Rect {
|
||||
|
|
@ -465,13 +1005,21 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap<WindowId, Rect>
|
|||
size: CellSize::new(area.size.rows, extent),
|
||||
},
|
||||
};
|
||||
compute_node(child, child_area, out);
|
||||
compute_node(child, child_area, fixed, out);
|
||||
cursor += extent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every [`WindowId`] beneath `node`, in layout order.
|
||||
#[must_use]
|
||||
pub fn node_ids(node: &LayoutNode) -> Vec<WindowId> {
|
||||
let mut out = Vec::new();
|
||||
collect_ids(node, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_ids(node: &LayoutNode, out: &mut Vec<WindowId>) {
|
||||
match node {
|
||||
LayoutNode::Leaf(id) => out.push(*id),
|
||||
|
|
@ -598,7 +1146,7 @@ mod tests {
|
|||
fn single_window_takes_full_area() {
|
||||
let w = id();
|
||||
let layout = Layout::single(w);
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
assert_eq!(placements.get(&w), Some(&rect_24x80()));
|
||||
}
|
||||
|
||||
|
|
@ -608,7 +1156,7 @@ mod tests {
|
|||
let b = id();
|
||||
let mut layout = Layout::single(a);
|
||||
assert!(layout.split_window(a, Orientation::Vertical, b));
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
let ra = placements[&a];
|
||||
let rb = placements[&b];
|
||||
assert_eq!(ra.size.rows, 24);
|
||||
|
|
@ -624,7 +1172,7 @@ mod tests {
|
|||
let b = id();
|
||||
let mut layout = Layout::single(a);
|
||||
assert!(layout.split_window(a, Orientation::Horizontal, b));
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
let ra = placements[&a];
|
||||
let rb = placements[&b];
|
||||
assert_eq!(ra.size.cols, 80);
|
||||
|
|
@ -644,15 +1192,15 @@ mod tests {
|
|||
} else {
|
||||
panic!("expected split");
|
||||
}
|
||||
let p1 = layout.compute(Rect::new(0, 0, 24, 90));
|
||||
let p1 = layout.compute(Rect::new(0, 0, 24, 90), &HashMap::new());
|
||||
assert_eq!(p1[&a].size.cols, 60);
|
||||
assert_eq!(p1[&b].size.cols, 30);
|
||||
// Resize down by 1/3.
|
||||
let p2 = layout.compute(Rect::new(0, 0, 24, 60));
|
||||
let p2 = layout.compute(Rect::new(0, 0, 24, 60), &HashMap::new());
|
||||
assert_eq!(p2[&a].size.cols, 40);
|
||||
assert_eq!(p2[&b].size.cols, 20);
|
||||
// Resize wide.
|
||||
let p3 = layout.compute(Rect::new(0, 0, 24, 300));
|
||||
let p3 = layout.compute(Rect::new(0, 0, 24, 300), &HashMap::new());
|
||||
assert_eq!(p3[&a].size.cols, 200);
|
||||
assert_eq!(p3[&b].size.cols, 100);
|
||||
}
|
||||
|
|
@ -681,7 +1229,7 @@ mod tests {
|
|||
}
|
||||
leaves.extend(more);
|
||||
assert_eq!(leaves.len(), 8);
|
||||
let placements = layout.compute(rect_24x80());
|
||||
let placements = layout.compute(rect_24x80(), &HashMap::new());
|
||||
assert_eq!(placements.len(), 8);
|
||||
// Every rect must be non-empty (terminal large enough).
|
||||
for id in &leaves {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,373 @@
|
|||
// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance.
|
||||
|
||||
//! Acceptance for `find-file`, the dired arc's Stage 0
|
||||
//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11).
|
||||
//!
|
||||
//! Dispatch-driven throughout: the prompt is opened with a real
|
||||
//! `C-x C-f`, filled by typing real keys, and completed with a real
|
||||
//! RET. `pmacs.command.invoke` would bypass the binding (a dead
|
||||
//! keymap entry would pass vacuously) and the Lua lifecycle
|
||||
//! `minibuffer.accept()` bypasses the dispatch path interactive input
|
||||
//! actually takes --- the editops suite's discipline, for the same
|
||||
//! reasons.
|
||||
//!
|
||||
//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a
|
||||
//! language server.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctrl(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(c), KeyModifiers::CONTROL),
|
||||
);
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
/// Open the find-file prompt through the real `C-x C-f` binding.
|
||||
fn open_prompt(s: &mut EditorState) {
|
||||
ctrl(s, 'x');
|
||||
ctrl(s, 'f');
|
||||
assert!(
|
||||
eval::<bool>(s, "return pmacs.minibuffer.is_active()"),
|
||||
"C-x C-f must open a minibuffer prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// The active buffer's backing path, or `None`.
|
||||
fn active_path(s: &EditorState) -> Option<String> {
|
||||
eval::<Option<String>>(
|
||||
s,
|
||||
"local b = pmacs.window.buffer()\n\
|
||||
if b == nil then return nil end\n\
|
||||
local ok, p = pcall(function() return b:path() end)\n\
|
||||
if ok then return p end\n\
|
||||
return nil",
|
||||
)
|
||||
}
|
||||
|
||||
fn candidates(s: &EditorState) -> Vec<String> {
|
||||
eval::<Vec<String>>(s, "return pmacs.minibuffer.candidates()")
|
||||
}
|
||||
|
||||
fn status(s: &EditorState) -> String {
|
||||
s.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
/// An editor whose active buffer is a real file inside `dir`, so
|
||||
/// find-file's root resolves to that directory.
|
||||
fn editor_in(dir: &std::path::Path) -> EditorState {
|
||||
let anchor = dir.join("anchor.txt");
|
||||
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
|
||||
let state = EditorState::new();
|
||||
state.lua_host.reopen_init_phase_for_testing();
|
||||
let anchor_str = anchor.display().to_string();
|
||||
exec(
|
||||
&state,
|
||||
&format!("pmacs.buffer.find_or_open({anchor_str:?})"),
|
||||
);
|
||||
state
|
||||
}
|
||||
|
||||
/// 0a --- completion is flat: it offers the root's own entries and
|
||||
/// never descends into a subdirectory.
|
||||
#[test]
|
||||
fn find_file_completion_lists_the_root_only_and_does_not_descend() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("alpha.txt"), b"a").expect("write");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
|
||||
let cands = candidates(&s);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "alpha.txt"),
|
||||
"root entry must be offered; got {cands:?}"
|
||||
);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "sub"),
|
||||
"the subdirectory itself must be offered; got {cands:?}"
|
||||
);
|
||||
assert!(
|
||||
!cands.iter().any(|c| c == "inner.txt"),
|
||||
"completion must NOT descend into subdirectories; got {cands:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no
|
||||
/// bare-basename candidate, so it reaches `on_accept` verbatim and is
|
||||
/// joined onto the prompt's root.
|
||||
#[test]
|
||||
fn find_file_free_text_opens_a_path_below_the_root() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
let inner = td.path().join("sub").join("inner.txt");
|
||||
std::fs::write(&inner, b"deep contents\n").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
type_str(&mut s, "sub/inner.txt");
|
||||
|
||||
assert!(
|
||||
candidates(&s).is_empty(),
|
||||
"a needle containing '/' must filter every basename candidate away, \
|
||||
or the selection would shadow the typed text"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a file must be open");
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&path).expect("canonicalize opened"),
|
||||
std::fs::canonicalize(&inner).expect("canonicalize fixture"),
|
||||
"free text must open the deeper path"
|
||||
);
|
||||
let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)");
|
||||
assert_eq!(text, "deep contents", "the file's real contents must load");
|
||||
}
|
||||
|
||||
/// 0c --- a path that does not exist creates a `[new file]` buffer
|
||||
/// bound to it, rather than erroring. The name contains a `/` so the
|
||||
/// candidate list is empty and the typed text is what arrives (see
|
||||
/// `find_file_selected_candidate_shadows_typed_text` for the other
|
||||
/// half of that rule).
|
||||
#[test]
|
||||
fn find_file_nonexistent_path_creates_a_new_file_buffer() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
let fresh = td.path().join("sub").join("brand-new.txt");
|
||||
assert!(!fresh.exists(), "fixture must not exist yet");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
type_str(&mut s, "sub/brand-new.txt");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a buffer must be bound to the new path");
|
||||
assert!(
|
||||
path.ends_with("sub/brand-new.txt"),
|
||||
"the buffer must be bound to the typed path; got {path}"
|
||||
);
|
||||
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
|
||||
assert_eq!(len, 0, "a new-file buffer starts empty");
|
||||
assert!(
|
||||
!fresh.exists(),
|
||||
"find-file must not create the file on disk --- only the buffer"
|
||||
);
|
||||
let line = status(&s);
|
||||
assert!(
|
||||
line.contains("[new file]"),
|
||||
"the new-file status must surface; got {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The everyday new-file flow: a BARE name, no separator, matching no
|
||||
/// existing entry. The candidate list empties on its own, so the typed
|
||||
/// text arrives and joins onto the root. This is the path users hit
|
||||
/// first, and it is the only route through `find_file_resolve` that
|
||||
/// combines free text with a relative join.
|
||||
#[test]
|
||||
fn find_file_bare_new_name_creates_in_the_root() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let fresh = td.path().join("zzz.txt");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
// "zzz.txt" is not a subsequence of "anchor.txt" (no 'z' in it), so
|
||||
// nothing survives the filter and the typed name is what accepts.
|
||||
type_str(&mut s, "zzz.txt");
|
||||
assert!(
|
||||
candidates(&s).is_empty(),
|
||||
"fixture premise: a bare non-matching name must empty the list; got {:?}",
|
||||
candidates(&s)
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a buffer must be bound to the new path");
|
||||
assert_eq!(
|
||||
std::path::Path::new(&path).parent(),
|
||||
Some(td.path()),
|
||||
"a bare name must join onto the prompt's root; got {path}"
|
||||
);
|
||||
assert!(
|
||||
path.ends_with("zzz.txt"),
|
||||
"the buffer must carry the typed name; got {path}"
|
||||
);
|
||||
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
|
||||
assert_eq!(len, 0, "a new-file buffer starts empty");
|
||||
assert!(!fresh.exists(), "nothing is written to disk until save");
|
||||
}
|
||||
|
||||
/// The failure arm. Accepting a DIRECTORY candidate reaches
|
||||
/// `display_file`, whose load fails (opening a directory succeeds, the
|
||||
/// read does not), and the command's `pcall` must turn that into a
|
||||
/// status message rather than letting the error escape mid-dispatch.
|
||||
/// Without the `pcall` this test fails, which is the point --- the
|
||||
/// guard is pinned through the real accept path, not asserted directly.
|
||||
#[test]
|
||||
fn find_file_accepting_a_directory_reports_instead_of_raising() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
let before = active_path(&s).expect("the anchor must be open");
|
||||
|
||||
open_prompt(&mut s);
|
||||
// Only the directory matches: "anchor.txt" contains no 's'.
|
||||
type_str(&mut s, "sub");
|
||||
assert_eq!(
|
||||
candidates(&s),
|
||||
vec!["sub".to_string()],
|
||||
"fixture premise: the directory must be the sole candidate"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let line = status(&s);
|
||||
assert!(
|
||||
line.starts_with("find-file: "),
|
||||
"the failure must surface as this command's status message; got {line:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
active_path(&s).as_deref(),
|
||||
Some(before.as_str()),
|
||||
"a failed open must leave the active buffer alone"
|
||||
);
|
||||
assert!(
|
||||
!eval::<bool>(&s, "return pmacs.minibuffer.is_active()"),
|
||||
"the prompt must have closed even though the open failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// 0d --- with no backing path, the prompt roots at the process cwd
|
||||
/// (`source_root` is omitted, and the Rust side defaults to ".").
|
||||
/// The test crate's cwd is the crate root, so `Cargo.toml` is a
|
||||
/// stable, real candidate there.
|
||||
#[test]
|
||||
fn find_file_without_a_backing_path_roots_at_the_process_cwd() {
|
||||
let mut s = EditorState::new();
|
||||
s.lua_host.reopen_init_phase_for_testing();
|
||||
assert!(
|
||||
active_path(&s).is_none(),
|
||||
"the scratch buffer must have no backing path"
|
||||
);
|
||||
|
||||
open_prompt(&mut s);
|
||||
|
||||
let cands = candidates(&s);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "Cargo.toml"),
|
||||
"a pathless buffer must root the prompt at the process cwd; got {cands:?}"
|
||||
);
|
||||
// The field must start EMPTY. Any prefill (e.g. Emacs's
|
||||
// directory-in-the-field) would contain a `/`, which filters every
|
||||
// basename candidate away and silently disables completion --- the
|
||||
// reason the root is named in the prompt string instead.
|
||||
let typed: String = eval(&s, "return pmacs.minibuffer.contents()");
|
||||
assert_eq!(
|
||||
typed, "",
|
||||
"the prompt field must start empty or completion is dead on arrival"
|
||||
);
|
||||
}
|
||||
|
||||
/// The documented hole in Q#DR11, pinned so it is a decision rather
|
||||
/// than an accident: `recompute_candidates` selects index 0 whenever
|
||||
/// the list is non-empty and `resolve_accepted_value` returns the
|
||||
/// SELECTED CANDIDATE over the typed text, so typing a new bare name
|
||||
/// that is a subsequence of an existing entry opens the existing file.
|
||||
/// Fixing this needs a Rust change to accept semantics, which Stage 0
|
||||
/// deliberately does not make.
|
||||
#[test]
|
||||
fn find_file_selected_candidate_shadows_typed_text() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
// "nots" is a subsequence of "notes.md", so the candidate survives
|
||||
// the filter and shadows the typed name.
|
||||
type_str(&mut s, "nots");
|
||||
assert_eq!(
|
||||
candidates(&s),
|
||||
vec!["notes.md".to_string()],
|
||||
"the fixture depends on 'nots' matching 'notes.md'"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a file must be open");
|
||||
assert!(
|
||||
path.ends_with("notes.md"),
|
||||
"documented behavior: the selected candidate wins over typed text; got {path}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A leading `~` is expanded before the path reaches the core. This
|
||||
/// matters because `get_or_load_buffer` normalizes the path it STORES
|
||||
/// but loads from the RAW one, so an unexpanded `~/...` would dedup
|
||||
/// against an open buffer yet fail to load a file that is not open.
|
||||
#[test]
|
||||
fn find_file_expands_a_leading_tilde() {
|
||||
let Some(home) = std::env::var_os("HOME") else {
|
||||
eprintln!("HOME unset; skipping tilde expansion pin");
|
||||
return;
|
||||
};
|
||||
let home = home.to_string_lossy().into_owned();
|
||||
if home.is_empty() || !std::path::Path::new(&home).is_dir() {
|
||||
eprintln!("HOME is not a usable directory; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut s = EditorState::new();
|
||||
s.lua_host.reopen_init_phase_for_testing();
|
||||
open_prompt(&mut s);
|
||||
// Contains a '/', so the typed text reaches on_accept verbatim.
|
||||
// The leaf does not exist, so this lands on the new-file path and
|
||||
// touches no disk state.
|
||||
type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a buffer must be bound");
|
||||
assert!(
|
||||
!path.contains('~'),
|
||||
"the tilde must be expanded, not passed through; got {path}"
|
||||
);
|
||||
assert!(
|
||||
path.starts_with(&home),
|
||||
"the expansion must use $HOME; got {path} with HOME={home}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1516,6 +1516,9 @@ fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> W
|
|||
layout: Layout::single(win_id),
|
||||
active: win_id,
|
||||
fold_projection,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
win_id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
//! Lean 4 mode, Stage 1 acceptance (Arc 8, `docs/lean4-mode-framing.md`).
|
||||
//!
|
||||
//! Covers the framing's Stage 1 criteria that live above the Rust
|
||||
//! substrate — major mode, modeline aliasing, comment toggle, the pair
|
||||
//! set, and markdown fence injection. Criteria 1, 2, and the Q#LN4
|
||||
//! retro-paint pins (7, 8) are unit tests in `src/syntax.rs` and
|
||||
//! `src/highlight.rs`, where the theme table and grammar registry live.
|
||||
//!
|
||||
//! Dispatch-driven, following `comment_toggle_acceptance`: `M-;` and
|
||||
//! typed characters go through `dispatch_key` so the real command
|
||||
//! boundary and typed-edit provenance are exercised. Buffers are
|
||||
//! file-backed (language detection needs a path); each editor gets a
|
||||
//! private tempdir `StateDir` and an emptied `pmacs.lsp.config` so
|
||||
//! nothing spawns a language server — Stage 1 has no LSP at all.
|
||||
//!
|
||||
//! Criterion 12 is the reason this suite touches no process: it must
|
||||
//! pass on a machine with no `lean`, no `lake`, and no configured elan
|
||||
//! toolchain. That is not hypothetical — the machine this arc was
|
||||
//! scouted on has elan installed with no default toolchain, where
|
||||
//! `lake --version` itself fails.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::StateDir;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fresh_state_dir() -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pmacs-lean4-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn editor(state_dir: &std::path::Path) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
s.lua_host.lua().remove_app_data::<StateDir>();
|
||||
s.lua_host
|
||||
.lua()
|
||||
.set_app_data(StateDir(state_dir.to_path_buf()));
|
||||
exec(&s, "pmacs.lsp.config = {}");
|
||||
s
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn alt(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn buffer_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
fn cursor(s: &EditorState) -> i64 {
|
||||
eval(s, "return pmacs.editor.cursor()")
|
||||
}
|
||||
|
||||
/// Fresh editor visiting `name` (created in the state tempdir) with
|
||||
/// `body` on disk, cursor at 0.
|
||||
fn editor_visiting(name: &str, body: &str) -> EditorState {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, name, body);
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
s
|
||||
}
|
||||
|
||||
fn major_mode(s: &EditorState) -> Option<String> {
|
||||
eval(s, "return pmacs.buffer.major_mode(pmacs.window.buffer())")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 3 — major mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn acc3_opening_a_lean_file_sets_the_lean4_major_mode() {
|
||||
let s = editor_visiting("Basic.lean", "def x : Nat := 1\n");
|
||||
assert_eq!(
|
||||
major_mode(&s).as_deref(),
|
||||
Some("lean4"),
|
||||
"a .lean file carries the lean4 major mode"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 4 — modeline aliasing (Q#LN2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn acc4_emacs_and_vim_modelines_spelling_lean_resolve_to_lean4() {
|
||||
// The grammar entry is `lean4`, but `-*- mode: lean -*-` and `ft=lean`
|
||||
// are what people write. Both must land on the same mode, or a file
|
||||
// with an explicit modeline is stranded with no grammar.
|
||||
//
|
||||
// Deliberately on a `.txt` path: if the fixture were `.lean`, the
|
||||
// extension alone would produce `lean4` and the assertion would pass
|
||||
// with the alias table empty — the vacuous shape.
|
||||
for body in [
|
||||
"-- -*- mode: lean -*-\ndef x : Nat := 1\n",
|
||||
"-- vim: ft=lean\ndef x : Nat := 1\n",
|
||||
] {
|
||||
let s = editor_visiting("modeline.txt", body);
|
||||
assert_eq!(
|
||||
major_mode(&s).as_deref(),
|
||||
Some("lean4"),
|
||||
"modeline {body:?} resolves through the alias to lean4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acc4b_the_lean_alias_is_load_bearing() {
|
||||
// Non-vacuity guard for acc4: with the alias removed, the same
|
||||
// fixture resolves to the raw `lean` name instead. If this ever
|
||||
// reports `lean4`, acc4 is proving nothing.
|
||||
let s = editor_visiting("modeline.txt", "x\n");
|
||||
exec(&s, "pmacs.parse.modeline_aliases.lean = nil");
|
||||
let dir = fresh_state_dir();
|
||||
let f = write_file(&dir, "other.txt", "-- -*- mode: lean -*-\ndef x := 1\n");
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
assert_eq!(
|
||||
major_mode(&s).as_deref(),
|
||||
Some("lean"),
|
||||
"without the alias the modeline name is not normalized"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 9 — comment toggle (Q#LN5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn acc9_comment_toggle_round_trips_with_the_dash_dash_prefix() {
|
||||
let mut s = editor_visiting("Basic.lean", "def x : Nat := 1\ndef y : Nat := 2\n");
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"-- def x : Nat := 1\ndef y : Nat := 2\n",
|
||||
"M-; comments a Lean line with `-- `"
|
||||
);
|
||||
// Round trip, including the padding space.
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"def x : Nat := 1\ndef y : Nat := 2\n",
|
||||
"M-; uncomments it exactly"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 10 — pairs (Q#LN6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn acc10_lean_bracket_pairs_close_and_the_prime_does_not() {
|
||||
// The three Unicode brackets are the reason this decision exists: all
|
||||
// are outside the nine built-in pair chars, so they exercise the
|
||||
// user-extended pair path rather than the frontends' optimistic
|
||||
// classifier.
|
||||
for (opener, expected) in [("⟨", "⟨⟩"), ("⦃", "⦃⦄"), ("⟮", "⟮⟯")] {
|
||||
let mut s = editor_visiting("Basic.lean", "");
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, opener);
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
expected,
|
||||
"typing {opener} inserts the closing half"
|
||||
);
|
||||
assert_eq!(
|
||||
cursor(&s),
|
||||
i64::try_from(opener.len()).expect("opener length fits"),
|
||||
"the point sits between the pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acc10b_the_prime_suffix_does_not_pair_in_lean() {
|
||||
// Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so
|
||||
// pairing it would fight the user on nearly every proof.
|
||||
let mut s = editor_visiting("Basic.lean", "");
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
type_str(&mut s, "h'");
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"h'",
|
||||
"the prime is a suffix in Lean, not an opener"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 11 — markdown fences (Q#LN17)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse `src` as markdown and return the child layer language names.
|
||||
///
|
||||
/// Goes through the real `_parse_now` injection path rather than reading
|
||||
/// the alias table: `pmacs.parse.injection_aliases` is a documented
|
||||
/// WRITE-ONLY proxy (the canonical map lives Rust-side), so an
|
||||
/// alias-table read would prove nothing about what the parser does.
|
||||
fn markdown_layer_languages(src: &[u8]) -> Vec<String> {
|
||||
let state = EditorState::new();
|
||||
let buf_id = state
|
||||
.lua_host
|
||||
.registry()
|
||||
.borrow_mut()
|
||||
.create_from_bytes("doc.md".to_owned(), src);
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.globals()
|
||||
.set("BUF", pmacs::lua_bindings::BufferIdLua(buf_id))
|
||||
.expect("bind BUF");
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.parse._parse_now(BUF, 'markdown')")
|
||||
.exec()
|
||||
.expect("synchronous parse");
|
||||
let bundle = state
|
||||
.syntax_registry
|
||||
.view(buf_id)
|
||||
.and_then(|h| h.current())
|
||||
.expect("installed bundle");
|
||||
bundle
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.language_name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acc11_lean_and_lean4_markdown_fences_both_inject_the_lean_grammar() {
|
||||
// Both spellings must resolve to the same grammar: `lean4` is the entry
|
||||
// name and `lean` goes through the injection alias. A ```lean fence is
|
||||
// overwhelmingly Lean 4 in practice, which is why the Lean 3 spelling
|
||||
// is mapped forward rather than left unresolved (Q#LN17).
|
||||
for fence in ["lean", "lean4"] {
|
||||
let src = format!("# Doc\n\n```{fence}\ndef x : Nat := 1\n```\n");
|
||||
let langs = markdown_layer_languages(src.as_bytes());
|
||||
assert!(
|
||||
langs.iter().any(|l| l == "lean4"),
|
||||
"```{fence} injects a lean4 child layer; got {langs:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acc11b_an_unknown_fence_name_still_injects_nothing() {
|
||||
// Non-vacuity guard for acc11: the alias must be what resolves `lean`,
|
||||
// not some catch-all that would light up any fence name.
|
||||
let langs = markdown_layer_languages(b"# Doc\n\n```leen\ndef x := 1\n```\n");
|
||||
assert!(
|
||||
!langs.iter().any(|l| l == "lean4"),
|
||||
"a misspelled fence must not reach the lean4 grammar; got {langs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Criterion 12 — no toolchain required
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() {
|
||||
// Stage 1 is grammar + Lua tables only. Opening a Lean file must not
|
||||
// reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and
|
||||
// even then it is fallible by design (Q#LN7).
|
||||
|
||||
// The load-bearing assertion, and it must run against a PRISTINE editor.
|
||||
// The shared `editor()` helper wipes `pmacs.lsp.config` before any
|
||||
// buffer opens, so an assertion about the server list under that harness
|
||||
// holds for every language regardless of what Stage 1 ships — it could
|
||||
// not fail for the regression it names. This checks the real claim
|
||||
// directly: no builtin runtime file defines a Lean server config. A
|
||||
// Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here.
|
||||
let pristine = EditorState::new();
|
||||
let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil");
|
||||
assert!(
|
||||
no_lean_config,
|
||||
"Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3"
|
||||
);
|
||||
// Non-vacuity: the same lookup finds the configs that DO ship, so this
|
||||
// is not passing because `pmacs.lsp.config` is empty or absent.
|
||||
let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil");
|
||||
assert!(
|
||||
rust_config_exists,
|
||||
"the config table is populated, so the lean4 absence above is meaningful"
|
||||
);
|
||||
|
||||
// And nothing is spawned by opening the file. This half retains its
|
||||
// value under the wiped config: a direct probe spawn from `lean.lua`
|
||||
// would show up here whatever `pmacs.lsp.config` contains.
|
||||
let s = editor_visiting("Basic.lean", "def x : Nat := 1\n");
|
||||
let procs: i64 = eval(&s, "return #pmacs.process.list()");
|
||||
assert_eq!(procs, 0, "opening a Lean buffer spawns no child process");
|
||||
}
|
||||
|
|
@ -465,6 +465,9 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() {
|
|||
layout: pmacs::window::Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ fn attach_view(
|
|||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
|
|
|
|||
Loading…
Reference in New Issue