pmacs/docs/mode-system-wiring-framing.md

19 KiB

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):

    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)

// 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:

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:

-- 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 settingspmacs.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.