Buffer design doc

This commit is contained in:
Greg Shuflin
2026-02-12 00:51:59 -08:00
parent ee03479a2f
commit 9ff2abceed
+185
View File
@@ -0,0 +1,185 @@
# Buffer Types Architecture
## Problem
The current design uses a magic constant `COMMAND_BUFFER_ID = BufferId(999)` to identify the command buffer. The core checks `== COMMAND_BUFFER_ID` in ~6 places to decide special behavior (clear on focus/unfocus, keep main view stable, special rendering). This is brittle and doesn't generalize -- if we want other special-purpose buffers (finder, prompt, help), each would need its own magic constant and special-case checks.
**Goal:** Make "command buffer" a property that any buffer could have, so the editor reasons about buffer *capabilities and roles* rather than buffer *identity*.
## Prior Art
### Neovim: Explicit `buftype`
Neovim has an explicit, enumerated `buftype` option on each buffer:
| Value | Description |
|---|---|
| `""` (empty) | Normal file-visiting buffer. Standard read/write/save. |
| `"nofile"` | Not associated with a file. `:w` doesn't work. Never considered "modified". Used for scratch buffers, plugin UI panels. |
| `"nowrite"` | Like `nofile` but buffer name is treated as a file path (changes with `:cd`). |
| `"acwrite"` | Custom write semantics via autocommands. `:w` triggers `BufWriteCmd` instead of file I/O. Used by netrw, database editors, REST clients. |
| `"quickfix"` | Quickfix/location list. Managed by Neovim's quickfix system. Read-only. |
| `"help"` | Help documentation. Read-only, special tag navigation. |
| `"terminal"` | Terminal emulator. Hosts a PTY subprocess. Special input handling. |
| `"prompt"` | Only the last line is editable. Enter invokes a callback. Used for REPL-style interfaces. |
`buftype` works in concert with other buffer-local options:
- **`bufhidden`** -- what happens when buffer leaves all windows (`hide`, `unload`, `delete`, `wipe`)
- **`buflisted`** -- whether buffer appears in `:ls` and `:bnext`
- **`swapfile`** -- whether a swap file is created
- **`modifiable`** -- whether text can be changed
A typical special buffer combines these: `buftype=nofile, bufhidden=wipe, nobuflisted, noswapfile, nomodifiable`.
**Key property of Neovim's approach:** The set of buftypes is finite and known to the editor core. Each type changes how the core handles the buffer (whether `:w` works, whether the buffer is considered modified, how the name is interpreted). New buffer "kinds" are composed from these primitives rather than adding new enum variants.
### Emacs: Emergent Type from Major Modes + Buffer-Local Variables
Emacs has no explicit buffer type enum. A buffer's "type" is emergent from:
1. **Its major mode** (defines behavior, keybindings, syntax)
2. **Buffer-local variables** (configure that behavior per-buffer)
3. **Whether it visits a file** (`buffer-file-name`)
4. **Whether it is read-only** (`buffer-read-only`)
5. **Whether it has an associated process**
#### Major Modes
Every buffer has exactly one major mode. Modes form an inheritance hierarchy rooted in three base modes:
- **`text-mode`** -- human-language editing (org-mode, markdown-mode, etc.)
- **`prog-mode`** -- programming languages (rust-mode, python-mode, etc.)
- **`special-mode`** -- Emacs-generated content, not files. Automatically read-only. Defines `q` to quit, `g` to revert. Parent of help-mode, compilation-mode, dired-mode, etc.
Plus `fundamental-mode` as the zero state.
`define-derived-mode` creates a child mode that inherits its parent's keymap, syntax table, and hooks. When activated, modes run parent body first, then child, and hooks fire from ancestors down. This is how you get modes like `compilation-mode` that inherit `special-mode`'s read-only behavior and `q`-to-quit binding while adding their own error-navigation commands.
#### Buffer-Local Variables
Any Emacs variable can have a per-buffer binding. Built-in permanently buffer-local variables include `major-mode`, `buffer-file-name`, `buffer-read-only`, `tab-width`, `indent-tabs-mode`, `mode-line-format`, and many others. When a major mode activates, it calls `kill-all-local-variables` (reset), then sets up its own buffer-local bindings for things like `comment-start`, `indent-line-function`, `font-lock-defaults`, etc.
This means two buffers in the same major mode can still behave differently based on their other variable values. Buffer "type" is a continuous, multi-dimensional property space rather than a discrete enum.
#### The Minibuffer
The minibuffer is the closest analog to our command buffer. It is implemented as a real buffer (`*Minibuf-N*`) but with unique constraints:
- Displayed only in a dedicated minibuffer window (always at the bottom of the frame)
- When not active, uses `minibuffer-inactive-mode`; when active, installs context-dependent keymaps (`minibuffer-local-map`, `minibuffer-local-completion-map`, etc.)
- **Recursive minibuffers**: when a command invokes the minibuffer while one is already active, a new minibuffer buffer is created (`*Minibuf-2*`, etc.). The innermost is active; exiting it restores the previous. Controlled by `enable-recursive-minibuffers`.
- The minibuffer window doubles as the **echo area** when not in use for input (displaying messages, which are also logged to `*Messages*`)
The minibuffer is "just a buffer" in that it supports editing commands and has text content, but it has special protections (can't be killed/renamed, dedicated window, special activation semantics).
#### Special Buffers
Special buffers in Emacs are distinguished by convention and properties, not by a type field:
| Buffer | Mode | Key Properties |
|---|---|---|
| `*scratch*` | `lisp-interaction-mode` | No file, writable, evaluates Lisp |
| `*Messages*` | `messages-buffer-mode` | Read-only, auto-maintained log |
| `*Help*` | `help-mode` | Read-only, hyperlinks, back/forward navigation |
| `*Completions*` | `completion-list-mode` | Read-only, navigable candidate list |
| `*compilation*` | `compilation-mode` | Read-only, clickable source locations |
| Shell buffers | `shell-mode` (via `comint-mode`) | Associated subprocess, input ring |
| Terminal buffers | `term-mode` | Full VT100 emulation, char/line sub-modes |
| Dired buffers | `dired-mode` | Directory listing, file operations |
**Key property of Emacs's approach:** Completely open-ended. New buffer "types" are invented by writing a new major mode -- the editor core doesn't need to know about them in advance. But this means the core can't reason abstractly about buffer categories without checking specific mode identities.
### Kakoune
The prompt line is a regular buffer. Kakoune treats everything uniformly -- buffers are buffers, and their role comes from context/properties rather than identity.
### Xi Editor
Every view is just a buffer with metadata attached. No special-cased IDs.
## Design Space for pane-editor
There's a spectrum between Neovim's explicit enum and Emacs's emergent properties:
### Option A: `BufferRole` enum (Neovim-like)
```rust
enum BufferRole {
Document, // normal file editing
Command, // command-line input (single-line, always insert mode)
Prompt, // general single-line input (save confirmation, etc.)
Finder, // search/picker
Help, // read-only informational
Scratch, // no file, writable
}
```
`TextBuffer` gets a `role: BufferRole` field. Core logic checks `buffer.role()` instead of `buffer_id == COMMAND_BUFFER_ID`.
**Pros:** Simple, explicit, easy to reason about. The core knows all possible roles at compile time.
**Cons:** Every new kind of buffer requires a new enum variant. Roles are mutually exclusive.
### Option B: Capability flags / properties (Emacs-like)
```rust
struct BufferProperties {
file_backed: bool, // has a backing file
writable: bool, // can be edited
single_line: bool, // constrained to one line
ephemeral: bool, // cleared on defocus, no save semantics
listed: bool, // appears in buffer list / tab bar
on_submit: Option<...>, // callback when Enter is pressed
}
```
Buffer "type" is emergent from the combination of properties. A command buffer is `{ single_line: true, ephemeral: true, on_submit: Some(execute_command), listed: false }`.
**Pros:** Composable, open-ended. New buffer behaviors don't require new enum variants. Properties can be changed at runtime.
**Cons:** Harder to reason about -- you need to check multiple flags. Possible to create nonsensical combinations. More complex.
### Option C: Hybrid (recommended starting point)
A small `BufferKind` enum for the core categories the editor fundamentally understands, plus property flags for the orthogonal behavioral dimensions:
```rust
/// What kind of content this buffer holds. Determines core editor behavior.
enum BufferKind {
Document, // file or scratch text editing
Command, // command-line input
Info, // read-only informational (help, about, etc.)
}
/// Behavioral properties orthogonal to kind.
struct BufferProperties {
listed: bool, // appears in tab bar / buffer list
ephemeral: bool, // no unsaved-changes warning
single_line: bool, // constrain to one line of input
// ... extend as needed
}
```
This gives you the benefits of explicit categorization where it matters (the core needs to know "is this a command buffer?" to handle Enter/Escape/focus semantics) while keeping orthogonal properties composable.
## What Changes
Regardless of which option, the refactor involves:
1. **Remove `COMMAND_BUFFER_ID` constant.** Buffer identity no longer implies role.
2. **Add role/kind/properties to `TextBuffer`.** The buffer itself knows what it is.
3. **Replace identity checks with property checks.** `id == COMMAND_BUFFER_ID` becomes `buffer.is_command()` or `buffer.kind() == BufferKind::Command`.
4. **Find command buffer(s) by query, not by ID.** `EditorCore` could cache the active command buffer ID, or search: `self.buffers.values().find(|b| b.is_command())`.
5. **Generalize focus semantics.** The "clear on focus, restore previous buffer on defocus" behavior currently hardcoded for the command buffer becomes driven by buffer properties (e.g., `ephemeral` buffers always clear and restore).
6. **`Configuration` stops importing `COMMAND_BUFFER_ID`.** Input mapping checks buffer properties instead: `if buffer.is_command()` rather than `if active_buffer_id == COMMAND_BUFFER_ID`.
## Open Questions
- Should `BufferKind` be extensible (e.g., plugins can register new kinds)? Probably not yet -- start simple.
- Should the command buffer be created lazily (only when `:` is pressed) or eagerly at startup? Currently eager. Lazy creation would mean the command buffer truly is "just another buffer" that gets created and destroyed on demand.
- How does this interact with the `Mode` system? Currently the command buffer is always in Insert mode. Should `BufferKind::Command` imply Insert mode, or should that remain a separate property?
- Should `BufferKind` affect rendering, or should rendering be driven entirely by pane layout position? (Currently the command pane is rendered based on layout position, which is good separation.)