409 lines
19 KiB
Markdown
409 lines
19 KiB
Markdown
# Mode system wiring — side-quest (cross-cutting substrate)
|
|
|
|
The keymap stack already supports mode-scoped bindings (`Scope::Mode`,
|
|
`KeymapStack::modes`, `bind_mode()`, and `resolve()` iteration over
|
|
`active_modes`), but **dispatch passes an empty mode list** (`&[]` at
|
|
`src/editor.rs:809`), so every `scope = "mode"` binding silently falls
|
|
through to the global keymap. This frames the minimal wiring to make
|
|
mode-scoped keybindings resolve, enabling per-language bindings (a future
|
|
markdown mode's `C-c C-c` family, Lua-mode bindings, etc.).
|
|
|
|
Side-quest backlog: `docs/side-quest-backlog.md:133-134` and the
|
|
prioritization at lines 246-249. The latter also mentions mode-scoped
|
|
settings, but `pmacs.config` deliberately has only global and buffer-local
|
|
scopes; this side-quest wires key resolution only. Per-language settings
|
|
remain the shipped pattern: a `buffer.after-load` hook calling
|
|
`pmacs.config.set_local`.
|
|
|
|
The auto-indent framing records the same empty-mode dispatch gap at lines
|
|
66-68, 291-294, and 434-436. This wiring is a necessary substrate for
|
|
modeline detection (backlog line 43), which will need somewhere to store
|
|
its result, but that feature is not blocked on it (language detection
|
|
already works through the extension chain).
|
|
|
|
## Ground truth (as of canonical `main` @ `2e37c04`, protocol v18)
|
|
|
|
All line numbers below reflect that tree.
|
|
|
|
- **`KeymapStack` already mode-aware** (`src/keymap_stack.rs`):
|
|
- `Scope::Mode(String)` — variant ready in the scope enum.
|
|
- `KeymapStack::modes: Vec<(String, Keymap)>` — the per-mode keymap
|
|
storage, populated by `bind_mode()`.
|
|
- `KeymapStack::bind_mode(name, seq, cmd)` — **works today**.
|
|
Any Lua code can call
|
|
`pmacs.keymap.bind { scope = "mode", mode = "rust", ... }` without
|
|
error; it just never fires because dispatch never passes the mode name.
|
|
- `KeymapStack::resolve(seq, buffer, active_modes)` — mode iteration
|
|
over `active_modes` is ready, just never reached in active-context
|
|
production resolution.
|
|
- `KeyDispatcher::dispatch(chord, stack, buffer, active_modes)` —
|
|
signature accepts modes; passes them through to `resolve`.
|
|
|
|
- **Dispatch callsite** (`src/editor.rs:800-810`):
|
|
```rust
|
|
let active_buffer = Some(self.core.borrow().active_buffer_id());
|
|
let action = {
|
|
let stack = self.lua_host.keymaps().borrow();
|
|
self.dispatcher.dispatch(chord, &stack, active_buffer, &[])
|
|
};
|
|
```
|
|
This is the sole production `KeyDispatcher::dispatch` call. The remaining
|
|
`dispatch(…, &[])` calls are tests in `keymap_stack.rs`.
|
|
|
|
- **Effective-key introspection is independently mode-blind**:
|
|
- `pmacs.describe.key` resolves with the active buffer but `&[]` for
|
|
modes (`src/lua_bindings/mod.rs:5893-5907`). Its own comment says it
|
|
and dispatch must update together when modes land.
|
|
- `help::render_key`, reached by `pmacs.help.show_key` / describe-key
|
|
and by followed `[key: …]` links, also resolves with `&[]`
|
|
(`src/help.rs:84-102`, called at
|
|
`src/lua_bindings/mod.rs:5466-5479` and `src/help.rs:417-435`).
|
|
Existing links encode buffer context only for buffer-scoped bindings;
|
|
a mode-scoped link carries no mode context.
|
|
- `pmacs.keymap.lookup` resolves with neither a buffer nor modes
|
|
(`src/lua_bindings/mod.rs:6112-6117`); it is the raw global lookup,
|
|
not an effective-key query, and stays that way in this side-quest.
|
|
|
|
- **Buffer struct has no mode field** (`src/buffer.rs:158-219`). Language
|
|
is tracked externally: `parse_lang_by_buffer` in `syntax.lua`,
|
|
`attachments` in `lsp.lua`.
|
|
|
|
- **Lua API for mode bindings already exists**: `parse_scope_arg` at
|
|
`src/lua_bindings/mod.rs:12548-12575` handles `scope = "mode"` with a
|
|
`mode` field. `BindArgs::apply` calls `stack.bind_mode()`. Tests exercise
|
|
this.
|
|
|
|
- **Keymap resolution order** (buffer-local → modes → global) is
|
|
correct for the major-mode-as-primary design: buffer-local bindings
|
|
(compile-mode's panels) take priority over mode bindings, which take
|
|
priority over global.
|
|
|
|
- **Language detection** already runs per-buffer in `buffer.after-load`
|
|
(`syntax.lua`), resolving through extension → LSP filetype →
|
|
filename → shebang. The resulting language string is the natural major
|
|
mode name. Detection can return a language with no bundled grammar; the
|
|
syntax attach path deliberately rejects only the parse, not the language.
|
|
|
|
- **Config registry (#127):** `pmacs.config` has global and buffer-local
|
|
scopes only. Language/project conventions are hooks that call
|
|
`set_local`; this wiring does not add a third config scope.
|
|
|
|
- **Statusline provider mechanism (#125, protocol v18):** composable
|
|
`pmacs.statusline` providers evaluate per frontend/window; the TUI
|
|
composes their output into the mode line via `paint_mode_line`.
|
|
This is the both-frontends display mechanism for any new fact that
|
|
should appear in the mode line.
|
|
|
|
## Decisions
|
|
|
|
### Q#MSW1 — Store the major mode on `Buffer` (private field + accessors)
|
|
|
|
```rust
|
|
// src/buffer.rs
|
|
pub struct Buffer {
|
|
// …existing fields…
|
|
major_mode: Option<String>, // private, per Buffer convention
|
|
}
|
|
|
|
impl Buffer {
|
|
pub fn major_mode(&self) -> Option<&str> {
|
|
self.major_mode.as_deref()
|
|
}
|
|
pub fn set_major_mode(&mut self, mode: Option<String>) {
|
|
self.major_mode = mode;
|
|
}
|
|
}
|
|
```
|
|
|
|
Motivation: the Rust core needs the mode at dispatch time without calling
|
|
into Lua. Adding it to `Buffer` makes it accessible via `Registry::get()`
|
|
from `EditorState`, and the field follows buffer identity without a second
|
|
lifecycle-managed map.
|
|
|
|
**Hot-path contract: resolving a key allocates nothing for mode lookup.**
|
|
Change `KeymapStack::resolve` and `KeyDispatcher::dispatch` from
|
|
`active_modes: &[String]` to `active_modes: &[&str]`. At dispatch, hold the
|
|
buffer-registry borrow only across pure keymap resolution, take
|
|
`Buffer::major_mode()` by reference, and pass `Option<&str>::as_slice()` as
|
|
the zero-or-one mode slice. `Scope::Mode` owns the name only when a binding
|
|
actually resolves, so the returned action remains valid after the registry
|
|
borrow drops and before any Lua command runs.
|
|
|
|
Rejected alternatives:
|
|
- **Lua table only** — would need a Lua call during dispatch to retrieve
|
|
modes, risking borrow conflicts and adding latency.
|
|
- **EditorCore map** — duplicates what `Buffer` already owns and would
|
|
need lifecycle management on buffer kill.
|
|
- **Clone into `Vec<String>` per keypress** — avoidable allocation in the
|
|
input hot path.
|
|
|
|
### Q#MSW2 — Major mode name = detected language name, one field
|
|
|
|
The language detection chain already produces a string like `"rust"`,
|
|
`"python"`, `"markdown"`. Store exactly that as `Buffer.major_mode`.
|
|
No separate mode name space — the language name IS the mode name.
|
|
This means `pmacs.keymap.bind { scope = "mode", mode = "rust", ... }`
|
|
is both a "rust mode" binding and a "rust language" binding.
|
|
|
|
`active_modes` at dispatch will be `[major_mode]` — a single-element
|
|
slice for v1. The keymap resolution iterates modes in order, so a single
|
|
mode works without special-casing.
|
|
|
|
Displayed through the provider without cosmetic name transformation. The
|
|
raw language string (`"rust"`, `"cpp"`, `"javascript"`) enters the mode
|
|
line; the statusline framework's universal one-line sanitation still
|
|
applies. Cosmetic overrides belong in a user-registered provider. The
|
|
built-in provider (Q#MSW5) stays simple and faithful.
|
|
|
|
### Q#MSW3 — Minor modes deferred entirely
|
|
|
|
This wiring is the minimal bridge to make mode-scoped bindings resolve.
|
|
Minor modes (flycheck, evil, spellcheck) would need activation/deactivation
|
|
ordering, a toggle API, per-buffer minor-mode lists, and likely a
|
|
`buffer.after-mode-change` hook. None of that is needed for the mode
|
|
system to **work** — defer all of it to a follow-up.
|
|
|
|
What this means concretely:
|
|
- No `minor_modes: Vec<String>` field on `Buffer`.
|
|
- No `buffer.after-mode-change` hook; dispatch and statusline read the
|
|
setter's value live, but packages receive no transition notification.
|
|
- No `pmacs.minor_mode` Lua API.
|
|
- No mode-line indication for minor modes.
|
|
|
|
### Q#MSW4 — Auto-initialize once on `buffer.after-load`; never on switch
|
|
|
|
The Rust side provides storage. The Lua side initializes it alongside
|
|
existing language detection in `syntax.lua`. Refactor
|
|
`attach_for_active_buffer` to accept an `initialize_mode` boolean and use
|
|
the language it already resolves:
|
|
|
|
```lua
|
|
local function attach_for_active_buffer(initialize_mode)
|
|
local buf = pmacs.window.buffer()
|
|
if not buf then return end
|
|
local key = tostring(buf)
|
|
local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key]
|
|
or resolve_active_language(buf)
|
|
|
|
-- Initialization is before the grammar gate: server-only languages
|
|
-- are valid major modes even though syntax cannot attach a parse view.
|
|
if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then
|
|
pmacs.buffer.set_major_mode(buf, lang)
|
|
end
|
|
|
|
if not lang or not pmacs.parse._has_language(lang) then return end
|
|
-- Existing parse dispatch / overlay attach follows unchanged.
|
|
end
|
|
```
|
|
|
|
`buffer.after-load` calls `attach_for_active_buffer(true)`.
|
|
`buffer.after-switch` calls `attach_for_active_buffer(false)` after its
|
|
existing overlay reset. A switch reattaches syntax but **never**
|
|
auto-initializes or rewrites the mode.
|
|
|
|
This separation is load-bearing:
|
|
|
|
- A user hook can override the detected mode after load; later switches
|
|
preserve it.
|
|
- `pmacs.buffer.set_major_mode(buf, nil)` is a real persistent clear, not
|
|
an “uninitialized” state that the next switch silently repopulates.
|
|
- A server-only language detected via filetype/shebang gets a mode before
|
|
`_has_language` rejects only its missing grammar.
|
|
- First language wins for parsing exactly as today. Changing the major mode
|
|
explicitly does not silently swap an installed grammar or LSP attachment.
|
|
|
|
### Q#MSW5 — Mode displayed via a built-in statusline provider (both frontends)
|
|
|
|
`#125` already ships composable `pmacs.statusline` providers whose
|
|
output feeds `paint_mode_line` (TUI) and `StatuslineSegments` (GPU).
|
|
Instead of adding a `mode_name` parameter to the painter, ship a
|
|
built-in provider:
|
|
|
|
```lua
|
|
-- Registration: strict typed fields, unknown key = error.
|
|
-- ctx.buffer is the buffer handle for the window being painted,
|
|
-- NOT the active/focused buffer — this is correct for passive splits.
|
|
pmacs.statusline.register {
|
|
name = "mode",
|
|
side = "left",
|
|
priority = 0,
|
|
face = "ui.modeline",
|
|
fn = function(ctx)
|
|
local mode = pmacs.buffer.major_mode(ctx.buffer)
|
|
if mode == nil then return "" end
|
|
return "(" .. mode .. ")"
|
|
end,
|
|
}
|
|
```
|
|
|
|
`ctx.buffer` is critical: the provider evaluates per-window, so a
|
|
passive split showing a Python buffer must say `"(python)"`, not
|
|
`"(rust)"` from the focused buffer. `pmacs.editor.active_modes()` is
|
|
unsuitable here for the same reason `pmacs.lsp.active_buffer_language()`
|
|
was replaced by `ctx.buffer` in #125's built-in LSP provider.
|
|
|
|
Position: after the protected left chrome (active marker, modified flag,
|
|
buffer name), formatted as `+* name (mode)`. `paint_mode_line` appends
|
|
custom left segments after `protected_left`, so the "between name and
|
|
modified marker" claim is impossible without painter changes — the
|
|
honest position is after the full protected block.
|
|
|
|
This gives both frontends the display at once, requires zero painter
|
|
signature changes (the `too_many_arguments` allow stays untouched),
|
|
and lets users disable/unregister the built-in handle and register their
|
|
own formatting. The `"ui.modeline"` face is inherited from the surrounding
|
|
chrome. Returning `""` for no mode is an ordinary successful omitted
|
|
segment under the provider framework; no failure latch is involved.
|
|
|
|
### Q#MSW6 — New Lua API surface
|
|
|
|
Two functions on `pmacs.buffer.*`:
|
|
|
|
- `pmacs.buffer.major_mode(id) -> string|nil` — returns the buffer's
|
|
major mode name, or nil if none is set.
|
|
- `pmacs.buffer.set_major_mode(id, name)` — sets it; `name` is a string
|
|
or nil to clear. A clear persists across buffer switches because only
|
|
`buffer.after-load`, never `buffer.after-switch`, auto-initializes.
|
|
|
|
Additionally, `pmacs.editor.active_modes() -> table` returns the current
|
|
active mode list (for the active buffer), matching what dispatch would
|
|
resolve at the time of the call: either `{major_mode}` or `{}`. It is useful
|
|
for introspection from modes or the minibuffer. Passive-window consumers
|
|
must use the parameterized `pmacs.buffer.major_mode(ctx.buffer)` instead.
|
|
|
|
### Q#MSW7 — No protocol changes
|
|
|
|
Mode is a daemon-side concept. Frontends never see mode names — they
|
|
receive resolved key actions and styled text. The single exception is the
|
|
status line, where the GPU frontend receives the existing
|
|
`StatuslineSegments` payload and can display the mode if the provider
|
|
includes it. Zero new protocol messages.
|
|
|
|
### Q#MSW8 — Effective-key introspection uses the dispatch context
|
|
|
|
Every API that claims to describe the binding effective in the current
|
|
buffer must resolve with both that buffer and its major mode:
|
|
|
|
- `pmacs.describe.key`
|
|
- `pmacs.help.show_key` / `help::render_key` (the interactive
|
|
describe-key path)
|
|
|
|
Both derive the borrowed zero-or-one mode slice from the same `Buffer`
|
|
field dispatch uses. `help::render_key` therefore accepts explicit borrowed
|
|
mode context rather than hard-coding `&[]`.
|
|
|
|
Help-link targets must also preserve the scope needed after `*help*` becomes
|
|
active. Keep the existing `@buffer:<id>` target for buffer-local bindings,
|
|
add `@mode:<name>` for mode bindings, and parse either into the context
|
|
passed to `render_key`. Global links carry neither. Following a mode link
|
|
therefore describes that mode binding rather than resolving against the
|
|
help buffer and falling through to global.
|
|
|
|
`pmacs.keymap.lookup` deliberately remains the raw global lookup. It has no
|
|
buffer parameter today and changing it into an ambient-context query would
|
|
silently change an existing API unrelated to describe-key.
|
|
|
|
## Bets
|
|
|
|
1. **Single-element `active_modes` covers the useful cases** — no one
|
|
needs multiple active modes before minor-mode semantics exist.
|
|
Compile-mode's buffer-local bindings already work as a substitute.
|
|
2. **Language name is the right mode name** — no user will want a
|
|
`"rust-mode"` that differs from `"rust"`. If they do, init.lua can
|
|
call `pmacs.buffer.set_major_mode` with a custom name.
|
|
3. **After-load-only initialization is sufficient** — every normally
|
|
opened buffer gets `buffer.after-load`; later switches only restore
|
|
views. The hidden-buffer (registry-only) gap is pre-existing: those
|
|
buffers receive neither language detection nor syntax attachment and
|
|
need their own fix.
|
|
An explicit nil clear is persistent across switches, not across a future
|
|
reload path that deliberately fires `buffer.after-load`; reload re-detects
|
|
the language just like a fresh open.
|
|
4. **GPU optimistic-edit bypass is a non-issue for mode-scoped
|
|
bindings** — the GPU frontend optimistically inserts plain printable
|
|
characters (outside `BUILTIN_PAIR_CHARS`) and Tab without round-tripping
|
|
through dispatch (RET and built-in pair chars round-trip, per Q#AI1
|
|
and Q#AP1). A mode-scoped binding on a plain printable or Tab would
|
|
silently not fire on GPU, but mode bindings are `C-c C-c`-style
|
|
control chords, which the optimistic path never touches. Documented
|
|
here so it is not a surprise if someone binds a plain printable in a
|
|
mode.
|
|
|
|
## Deferred (named)
|
|
|
|
- **Minor mode system** — activation/deactivation ordering, toggle,
|
|
minor mode list in buffer, minor-mode indicator in the mode line.
|
|
- **`buffer.after-mode-change` hook** — the explicit setter changes what
|
|
dispatch/statusline read immediately, but there is no notification API
|
|
for packages that want to react to transitions. Add that with dynamic
|
|
mode-aware package semantics, not for this single built-in initializer.
|
|
- **Mode-scoped settings** — `pmacs.config` remains global +
|
|
buffer-local. Per-language configuration uses an after-load hook calling
|
|
`set_local`; a first-class mode scope needs its own precedence,
|
|
introspection, and mode-change invalidation design.
|
|
- **Modeline detection** (`-*- mode: … -*-`, `vim: ft=…`) — a separate
|
|
side-quest (`side-quest-backlog.md:43`). This framing just wires the
|
|
mechanism; modeline detection can override the initialized mode via
|
|
`pmacs.buffer.set_major_mode` when it lands.
|
|
- **Explicit-mode session persistence** — desktop restore reopens files and
|
|
recovers detected modes through `buffer.after-load`, but explicit overrides
|
|
and clears are not serialized. Design that with session/settings persistence,
|
|
not as hidden state in this wiring layer.
|
|
- **Mode help display** (`describe-mode`) — straightforward once the mode
|
|
is stored, but not table-stakes for wiring.
|
|
|
|
## Acceptance
|
|
|
|
Keymap acceptance is dispatch-driven (keypress → action) against the daemon
|
|
process. Statusline and introspection cases exercise their real evaluator /
|
|
Lua surfaces. Rust fixtures must empty `pmacs.lsp.config` before creation
|
|
unless the test intentionally exercises the LSP path — otherwise a real
|
|
server starts on buffer open.
|
|
|
|
1. **Mode binding resolves**: a Lua test registers
|
|
`pmacs.keymap.bind { scope = "mode", mode = "rust", sequence = "C-c C-c",
|
|
command = "test.cmd" }`, opens a Rust buffer (LSP config cleared),
|
|
sends `C-c C-c`, and asserts `test.cmd` runs. The same sequence in a
|
|
Python buffer is unbound and never runs `test.cmd`.
|
|
|
|
2. **No mode → no mode bindings**: a file with no detected language
|
|
(e.g. a `.txt` with no config) has `major_mode = nil`; mode-scoped
|
|
bindings never fire.
|
|
|
|
3. **Mode displayed in mode line**: the built-in `"mode"` statusline
|
|
provider returns `"(rust)"` for a Rust buffer and no segment for an
|
|
unknown-language buffer. Evaluation uses `ctx.buffer`; a split with
|
|
Rust active and Python passive produces the correct per-window strings.
|
|
|
|
4. **Mode survives buffer switch**: open A (rust) and B (python), switch
|
|
back and forth; `pmacs.buffer.major_mode` returns the correct language
|
|
each time.
|
|
|
|
5. **Buffer-local beats mode beats global**: register the same sequence
|
|
at all three scopes and drive all three cases: buffer-local fires when
|
|
present; after removing it the active mode binding fires; in a buffer
|
|
with no matching mode the global binding fires.
|
|
|
|
6. **`pmacs.editor.active_modes()` returns current mode list**: matches
|
|
the single-element `{lang}` or empty table for unknown-language buffers.
|
|
|
|
7. **Explicit mode override is not clobbered**: call
|
|
`pmacs.buffer.set_major_mode(id, "markdown")`, switch away and back;
|
|
`pmacs.buffer.major_mode(id)` still returns `"markdown"`.
|
|
|
|
8. **Explicit clear is not clobbered**: clear a detected mode with
|
|
`pmacs.buffer.set_major_mode(id, nil)`, switch away and back; the getter
|
|
still returns nil and the detected-language mode binding does not fire.
|
|
|
|
9. **Server-only language receives a mode**: add a filetype/shebang mapping
|
|
to a language with no bundled grammar, open a matching file, and assert
|
|
that `major_mode` and `active_modes()` contain that language while no
|
|
parse view is attached.
|
|
|
|
10. **Describe-key agrees with dispatch**: for a mode binding that dispatch
|
|
resolves, `pmacs.describe.key` reports the mode command and
|
|
`scope = "mode:rust"`, and `pmacs.help.show_key` renders the same command
|
|
and scope rather than the global fallback. Following that mode binding's
|
|
`[key: … @mode:rust]` link from command help produces the same result
|
|
after `*help*` is active.
|