More buffer design changes

This commit is contained in:
Greg Shuflin
2026-02-12 01:11:57 -08:00
parent 9ff2abceed
commit d404d8ffd9
+169 -54
View File
@@ -92,94 +92,209 @@ Special buffers in Emacs are distinguished by convention and properties, not by
### 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.
The prompt line is a regular buffer that supports normal editing commands. You can use movement, selection, and editing primitives to compose your command — the same muscle memory works everywhere. This is a notable improvement over Vim, where the command line is a separate input context with its own limited editing model. (Vim's `q:` cmdwin partially addresses this but is clunky.)
### Xi Editor
Every view is just a buffer with metadata attached. No special-cased IDs.
## Design Space for pane-editor
### Command Palettes (VS Code, Sublime, etc.)
There's a spectrum between Neovim's explicit enum and Emacs's emergent properties:
The command palette pattern treats command input as a prominent UI element — typically a floating centered or top-anchored panel with:
- Real-time filtering/fuzzy matching
- Hints and documentation alongside candidates
- Categorized results (commands, files, symbols, etc.)
### Option A: `BufferRole` enum (Neovim-like)
The key insight is that this is a *presentation* concern. The underlying buffer holding the user's input text is the same regardless of whether it renders as a vim-style bottom line or a centered floating palette. The pane layout / rendering layer decides how and where to display it.
```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
}
```
## Decomposing the Command Buffer
`TextBuffer` gets a `role: BufferRole` field. Core logic checks `buffer.role()` instead of `buffer_id == COMMAND_BUFFER_ID`.
Before choosing an approach, it's worth asking: what actually makes the command buffer special? Here's every special behavior in the current code, and what property would express it:
**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.
| Current behavior | Where | Property |
|---|---|---|
| Always in Insert mode | `core.rs:69``set_mode(Mode::Insert)` at creation | **Remove this.** Command buffer should support Normal mode like any other buffer (Kakoune-style). You should be able to hjkl around and edit your command. |
| Enter submits instead of inserting `\n` | `configuration.rs:49``(Mode::Insert, true) => Command(Execute)` | Submit behavior lives in the input mapping layer (Configuration / future DSL), keyed off buffer properties. Not inherently tied to single-line — a multi-line command buffer could submit on e.g. Ctrl+Enter or a keybinding. |
| Escape returns to previous buffer | `configuration.rs:58``(_, true) => FocusPreviousBuffer` | `ephemeral: true` (Escape on ephemeral buffer = defocus) |
| Tab triggers completion | `configuration.rs:62``(Mode::Insert, true) => Command(Tab)` | Tab behavior is context-dependent, lives in input mapping |
| Cleared on focus | `core.rs:254` — clears when `target == COMMAND_BUFFER_ID` | `ephemeral: true` |
| Cleared on defocus | `core.rs:265` — clears when leaving command buffer | `ephemeral: true` |
| Doesn't replace main display | `core.rs:115``main_display_buffer()` skips it | `listed: false` (unlisted buffers don't claim the main editing area) |
| Not in tab bar | implicit | `listed: false` |
### Option B: Capability flags / properties (Emacs-like)
Almost every behavior decomposes into generic properties (`ephemeral`, `listed`). But there is one irreducible property: *this buffer's contents are commands to be interpreted by the editor's command system*. That's not the same as "ephemeral" — a finder buffer is ephemeral too, but submitting it opens a file rather than executing a command. The core needs to know which buffer to read command text from when `CommandAction::Execute` fires, and that's a property of the buffer itself: `command: bool`.
### What Vim Gets Wrong Here
In Vim, the command line (`:` prompt) is a separate input context with its own limited editing model. You can't use Normal mode motions to edit your command — only basic readline-style bindings. Vim's `q:` cmdwin partially addresses this by opening an actual buffer, but it's a separate workflow. This is an unnecessary inconsistency.
In pane-editor, the command buffer is just a buffer. It starts in Insert mode (because you're typing a command), but you can Escape to Normal mode and use the full editing vocabulary — hjkl, word motions, etc. — then `i` back to Insert to keep typing. The same keybindings, the same muscle memory, everywhere.
## Design: Properties-Only (Emacs-like)
Drop `BufferKind` entirely. Buffer behavior is determined by composable properties on `TextBuffer`. The properties are minimal — they capture what the *core* needs to know to handle buffer lifecycle. Everything else (keybindings, submit behavior, rendering position) lives in other layers.
### Three Layers of "Buffer Behavior"
It's important to separate what lives where:
1. **Buffer properties** (on `TextBuffer`) — intrinsic buffer state that the core needs for lifecycle management: Is it listed? Is it ephemeral? Is it read-only? Does it have a backing file?
2. **Input mapping** (in `Configuration` / future DSL) — what keystrokes do in a given buffer context. Enter submits vs inserts newline. Tab completes vs inserts tab. Escape defocuses vs transitions to Normal mode. This is user-configurable and can vary per buffer properties, per mode, per anything.
3. **Presentation** (in pane layout / rendering) — where and how a buffer is displayed. Bottom command line vs centered floating palette vs side panel. This is orthogonal to both buffer properties and input mapping.
This separation is what makes it possible to have the same command buffer render as a vim-style bottom line OR a VS Code-style command palette — the buffer doesn't change, only the pane layout does.
### Buffer Properties
```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 appears in tab bar / buffer list and claims the main editing area.
listed: bool,
/// Buffer is cleared on focus and defocus. No unsaved-changes warning.
ephemeral: bool,
/// Buffer contents cannot be modified. Mode::Insert doesn't apply —
/// `TransitionToMode(Mode::Insert)` is a no-op on a read-only buffer.
read_only: bool,
/// Buffer contents are interpreted as editor commands on submit.
/// The core reads this buffer's text when CommandAction::Execute fires.
command: bool,
}
```
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 }`.
Four properties. Note what's *not* here:
**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.
- **No `single_line`.** Whether a buffer is constrained to one line is a rendering/UI concern, not an intrinsic buffer property. A command buffer could be single-line in a vim-style bottom bar and multi-line in a command palette. The buffer itself doesn't care.
- **No `on_submit` callback.** The `command` flag tells the core *where submission goes* (the command system). The input mapping layer decides *when* submission happens (Enter, Ctrl+Enter, whatever). Other buffer types (finder, prompt) would have their own semantic flags and their own submit routing.
- **No mode override.** The command buffer supports Normal mode just like any other buffer. It starts in Insert mode because that's natural, but you can Escape to Normal, edit with motions, and `i` back.
### Option C: Hybrid (recommended starting point)
### Constructing Buffers by Role
A small `BufferKind` enum for the core categories the editor fundamentally understands, plus property flags for the orthogonal behavioral dimensions:
Convenience constructors express common configurations:
```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.)
}
impl BufferProperties {
/// A normal file-editing buffer.
fn document() -> Self {
BufferProperties { listed: true, ephemeral: false, read_only: false, command: false }
}
/// 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
/// A command-line input buffer (the `:` prompt).
fn command() -> Self {
BufferProperties { listed: false, ephemeral: true, read_only: false, command: true }
}
/// A read-only informational buffer (help text, about, etc.)
fn info() -> Self {
BufferProperties { listed: false, ephemeral: false, read_only: true, command: false }
}
}
```
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.
A "finder" buffer is `{ listed: false, ephemeral: true, read_only: false, command: false }` — ephemeral like a command buffer, but its submit action is "open the selected file" (handled by finder-specific logic, not the command system). A "scratch" buffer is `document()` with no backing file.
### How the Core Uses Properties
The core never asks "is this the command buffer?" — it asks about capabilities:
```rust
// Instead of: if target == COMMAND_BUFFER_ID { self.clear_command_buffer(); }
if buffer.properties.ephemeral {
buffer.clear();
}
// Instead of: if self.active_buffer == COMMAND_BUFFER_ID { return previous }
if !active_buffer.properties.listed {
// unlisted buffer doesn't claim the main display area
return self.previous_buffer();
}
// Instead of: force Insert mode on command buffer
// Nothing. The buffer starts in whatever mode it starts in. Mode transitions
// are handled normally. read_only buffers reject TransitionToMode(Insert).
```
### How Configuration Uses Properties
`Configuration::get_editor_action` receives `&BufferProperties` instead of a `BufferId`:
```rust
fn get_editor_action(&self, input: Input, props: &BufferProperties, mode: Mode, ...) {
match input {
// Escape in an ephemeral buffer defocuses. In a non-ephemeral buffer,
// transitions to Normal (from Insert) or is a no-op (already Normal).
Input::Escape if props.ephemeral => FocusPreviousBuffer,
Input::Escape => match mode {
Mode::Insert => TransitionToMode(Mode::Normal),
Mode::Normal => None,
},
// Enter in Insert mode in a command buffer submits to the command system.
// In a non-command buffer, inserts a newline. The future DSL will allow
// full customization of this per buffer property.
Input::Newline => match (mode, props.command) {
(Mode::Insert, true) => Command(CommandAction::Execute),
(Mode::Insert, false) => InsertCharacter('\n'),
_ => None,
},
// Normal mode works everywhere — hjkl, i, etc.
// The same bindings apply in a command buffer as in a document buffer.
Input::Character(ch) => match mode {
Mode::Insert if !props.read_only => InsertCharacter(ch),
Mode::Normal if ch == 'i' && !props.read_only => TransitionToMode(Mode::Insert),
Mode::Normal if ch == ':' => FocusCommandBuffer,
// hjkl etc. — same in all buffers
// ...
},
// ...
}
}
```
Notice: `is_command` (the identity check) disappears. The configuration checks `props.command`, `props.ephemeral`, and `props.read_only` — meaningful properties, not magic IDs. The same Normal mode keybindings work in every buffer.
### Mode and read_only Interaction
`Mode::Insert` simply doesn't apply to a read-only buffer:
- `TransitionToMode(Mode::Insert)` is a no-op (or never generated — Configuration checks `read_only` before emitting it)
- The buffer is always effectively in Normal mode
- This covers help buffers, about screens, etc. naturally
### What About the Future DSL?
All of the hardcoded `match` arms in Configuration are temporary. The future scripting/DSL layer will allow users to define arbitrary input mappings per buffer property, per mode, per any predicate. The point of `BufferProperties` is that it provides stable, meaningful predicates for those mappings to key off of — not to encode behavior directly.
## What Changes
Regardless of which option, the refactor involves:
1. **Add `BufferProperties` to `TextBuffer`.** Four fields: `listed`, `ephemeral`, `read_only`, `command`. Plus convenience constructors `document()`, `command()`, `info()`.
1. **Remove `COMMAND_BUFFER_ID` constant.** Buffer identity no longer implies role.
2. **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. **Remove forced `set_mode(Mode::Insert)` on command buffer.** The command buffer starts in Insert mode naturally (the `FocusBuffer` action could transition to Insert, or it just stays in whatever mode — the user can switch freely).
3. **Replace identity checks with property checks.** `id == COMMAND_BUFFER_ID` becomes `buffer.is_command()` or `buffer.kind() == BufferKind::Command`.
4. **Replace identity checks with property checks in `EditorCore`:**
- `main_display_buffer()` checks `!listed` instead of `== COMMAND_BUFFER_ID`
- `FocusBuffer` clears buffer if `ephemeral` instead of `== COMMAND_BUFFER_ID`
- `FocusPreviousBuffer` clears buffer if `ephemeral`
- `command_buffer()` / `command_buffer_is_active()` become property-based queries or go away entirely
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. **Update `Configuration`:**
- Receives `&BufferProperties` instead of `BufferId`
- Checks `props.command`, `props.ephemeral`, `props.read_only` instead of `is_command`
- No longer imports `COMMAND_BUFFER_ID`
- Normal mode keybindings work uniformly across all buffers
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`.
6. **Core tracks command buffer by `Option<BufferId>`** rather than a constant. Set when a command buffer is created/focused, cleared when it's destroyed.
## 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.)
- **Lazy vs eager creation.** Should the command buffer be created lazily (only when `:` is pressed) or eagerly at startup? Currently eager. Lazy creation means the command buffer is truly "just another buffer" that gets created and destroyed on demand, but adds complexity to the `:` keybinding path.
- **Presentation layer.** Rendering is currently driven by pane layout position (the command pane is always at the bottom). `BufferProperties` should not dictate rendering — the same `ephemeral` buffer could render as a bottom bar or a floating palette depending on pane layout configuration. Is the current pane layout system flexible enough for this, or does it need extension?
- **Label / prompt prefix.** The `:` prefix in the command line is a rendering concern. Should there be a `label: Option<String>` on the buffer or on the pane layout entry? Probably the latter (presentation, not buffer state).
- **Submit semantics.** `command: true` tells the core where submission goes (the command system). But what about non-command buffers that also have submit behavior (finder, prompt)? Each would need its own flag, or there could be a more general `submit_target: Option<SubmitTarget>` enum. For now `command: bool` is sufficient — generalize when a second submit target actually appears.
- **History.** Vim's command line has history (up/down to cycle previous commands). If the command buffer is ephemeral (cleared on focus), history would need to live outside the buffer — in the command system. This is probably the right place for it anyway.