Merge pull request #91 from levineuwirth/fix-gpu-gutter-correctness

fix(gpu): gutter click classification + fit guard; test v14 wire round-trip
This commit is contained in:
Levi Neuwirth 2026-07-07 11:06:40 -04:00 committed by GitHub
commit 6979ab3006
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 383 additions and 52 deletions

190
README.md
View File

@ -10,23 +10,113 @@ discards the single-threaded substrate; workers, message bus, and a
coroutine-based async surface are core primitives, not bolt-ons.
The editor is partitioned into a long-lived **instance** (the daemon
that owns buffers, processes, and language services) and a thin
**frontend** that attaches over a typed protocol. Frontends can run
locally over a Unix socket or remotely over SSH; reconnect-on-drop
modeled on `mosh` keeps remote sessions alive across laptop suspends.
that owns buffers, processes, and language services) and thin
**frontends** that attach over a typed protocol (currently v14). Two
frontends ship today:
The first-class package is a **REPL package** written entirely against
the public Lua API: PTY-spawned shells (bash, zsh, fish, lua), an
ECMA-48 ANSI parser, multi-REPL coexistence, and scrollback management
with line/byte retention. Successful completion of an audit verifying
the package uses zero direct Rust core access was the v0.1 ship gate.
- a **TUI** (crossterm cell grid), attachable locally over a Unix
socket or remotely over SSH, with reconnect-on-drop modeled on
`mosh`; and
- **pmacs-gpu**, a GPU frontend (wgpu + winit + glyphon) that renders
from a *semantic projection* of editor state --- style spans,
decorations, inlay adornments --- rather than a character grid, and
edits optimistically against a local CRDT replica for
latency-free typing.
Buffers are optionally CRDT-backed (`loro`, behind `--features crdt`),
so multiple frontends --- TUI and GPU, local and remote --- can edit
the same buffers concurrently with live cursor/selection presence.
## Status
**v1.0.0 --- stable.** The design described above is implemented and
working. Solo development carried the project to 1.0; public
contributions are open from this release. Use, evaluate, file issues,
and send pull requests.
**v1.0.0 --- stable core, active development.** The v1.0 gate (the
instance/frontend partition, the Lua surface, and a REPL package
audited to use zero direct Rust core access) shipped some time ago;
development since has landed the GPU frontend at near input/render
parity with the TUI, the semantic-frontend protocol (v6 → v14), the
LSP feature arc, in-buffer search, the context menu + OS clipboard,
the line-number gutter with diagnostic signs, and the package-manager
hardening pass. Current direction lives in `docs/roadmap-2026-07.md`.
Public contributions are open: use, evaluate, file issues, send pull
requests.
## Highlights
**Editing & UI.** CUA-style region editing plus Emacs kill/yank
bindings; linear undo/redo; incremental search, substring and regex
(`C-s` / `C-r` / `C-M-s`); line-number gutter with absolute, relative,
and hybrid modes; diagnostic gutter signs; right-click context menu;
OS clipboard integration (OSC 52 in the TUI, native in the GPU);
minibuffer with completion dropdown and per-bucket persisted history;
buffer-list mode (`C-x C-b`); self-navigable help system
(`describe-command`, `describe-key`); atomic saves (temp + rename +
parent fsync, mode-preserving).
**Language intelligence.** LSP client with async, never-blocking
requests: diagnostics (severity-colored underlines/squiggles, gutter
signs, statusline counts, `M-g n`/`M-g p` navigation), rename with
`prepareRename`, go-to-definition including cross-file navigation,
hover, signature help, references, document symbols, code actions,
buffer formatting, semantic tokens, and inlay hints (rendered inline
in the GPU frontend). Servers are preconfigured for rust-analyzer,
clangd (C/C++), basedpyright, gopls, typescript-language-server
(TS/TSX/JS/JSX), lua-language-server, bash-language-server, taplo
(TOML), and zls (Zig). Syntax highlighting is dual-authority:
bundled tree-sitter grammars (Rust, Lua, Markdown, C, C++) paint
lexical structure and LSP semantic tokens refine it --- languages
without a bundled grammar still get full semantic coloring. A
persistent project symbol index (`.pmacs/index.json`) rides the same
worker infrastructure.
**Collaboration & frontends.** With `--features crdt`, buffers are
CRDT-backed and any number of frontends attach to one daemon and edit
concurrently; peers see each other's cursors and selections as
translucent washes. The GPU frontend adds a live minimap (click to
jump, drag to scrub), wavy diagnostic squiggles, a status band with
live diagnostic counts, and optimistic local editing that rebases
in-flight edits through authoritative frames.
**Extensibility.** ~37 `pmacs.*` Lua namespaces cover buffers,
windows, commands, keymaps (global/mode/buffer scope), hooks, themes
(truecolor-capable syntax palette), tree-sitter, LSP stores, async
workers, and a PTY-aware process supervisor with an ECMA-48 ANSI
parser. A package manager installs from git (`github:owner/repo`,
version/branch/commit pins) with transitive dependency resolution and
a SHA-256 lockfile. Pmacs is also an **MCP client**: packages can
spawn MCP servers and consume their tools, resources, and prompts ---
AI integrations are packages over a transport, not a built-in
feature. The bundled REPL package (PTY shells, ANSI rendering,
multi-REPL, scrollback retention) is written entirely against the
public Lua API.
## Running
Single-process TUI:
```sh
pmacs [FILE] # TUI; -nw reserved for when a GUI default lands
```
Daemon + attached frontends (build with `--features crdt` for
multi-frontend editing and the GPU frontend):
```sh
pmacs --daemon --socket NAME # foreground daemon; bare NAME →
# <runtime>/pmacs/NAME.sock
pmacs --attach --socket NAME # TUI frontend; F12 detaches
pmacs --attach user@host # remote TUI over SSH
pmacs-gpu --attach /run/user/$UID/pmacs/NAME.sock # GPU frontend
```
`pmacs --attach` also understands `ssh:user@host/instance`,
`local:/path.sock`, and bare hostnames (treated as SSH). See
`pmacs --help` for the full matrix.
User configuration is plain Lua at
`$XDG_CONFIG_HOME/pmacs/init.lua` (default `~/.config/pmacs/init.lua`),
loaded after the builtin runtime so plain assignments override
defaults --- keybindings, `pmacs.lsp.config`, theme overrides, and
package installs all live there.
## Build
@ -34,7 +124,9 @@ Builds on the toolchain pinned in `rust-toolchain.toml` (Rust
`1.95.0`, edition 2024); rustup selects it automatically.
```sh
cargo build --release # produce target/release/pmacs (LuaJIT)
cargo build --release # target/release/pmacs (LuaJIT flavor)
cargo build --release --features crdt # + CRDT buffers (daemon use)
cargo build --release -p pmacs-gpu # the GPU frontend binary
cargo run --release -- <file> # build and run on a file
cargo test --workspace # unit + integration tests (all crates)
cargo fmt --check
@ -76,7 +168,11 @@ CI runs the matrix on every push.
Release-only perf gates (M5 keystroke-to-render, M6 ingest/RSS/cancel
and scrollback navigation/search) are `#[ignore]`'d during normal
test runs and exercised in CI under dedicated jobs.
test runs and exercised in CI under dedicated jobs. The GPU frontend
has headless render tests (offscreen wgpu, pixels read back) that run
in CI under lavapipe and skip gracefully on machines without a Vulkan
adapter (`PMACS_REQUIRE_GPU=1` turns a missing adapter into a hard
failure).
## Runtime requirements
@ -125,59 +221,53 @@ The Lua VM (LuaJIT or Lua 5.4) is statically vendored via `mlua`'s
`vendored` feature, so there is no external Lua dependency at
runtime.
## What v0.1 ships with
- **Editor core.** Persistent rope with O(log N) edits and snapshots;
buffers with chained intercept-views; undo/redo; atomic file I/O;
crossterm-driven TUI.
- **Lua surface.** Embedded LuaJIT (or Lua 5.4) with `pmacs.command`,
`pmacs.keymap` (global / mode / buffer scopes), `pmacs.hook`
(typed kinds: all-must-succeed, first-non-nil, last-write-wins),
`pmacs.buffer`, `pmacs.window`, `pmacs.editor`. Minibuffer is itself
a buffer. `describe-key` and `describe-command` for self-introspection.
- **Async runtime.** Worker pool + message bus + coroutine-based Lua
async surface (`pmacs.async`). Cancellation is provably correct
under load.
- **Language services.** Tree-sitter highlighting and LSP integration
ride the worker/message infrastructure. Project indexing as a third
service. Symbol search across 1M+ symbols completes under a second.
- **Frontend partition.** Daemon mode with local Unix-socket transport;
cell-delta diffing on the instance side; SSH transport variant for
remote attach; reconnect-on-drop preserves session state across
laptop suspend / network drop.
- **REPL package.** A 691-line Lua package that wires the M6 ANSI
parser to PTY-spawned shells with raw-mode line discipline. Three-
region buffer (history / prompt / input) with read-only enforcement;
RET / C-c / C-d bindings; multi-REPL coexistence; scrollback
retention with line- and byte-bounded truncation. Published
alongside an audit verifying zero direct Rust core access.
The GPU frontend additionally needs a Vulkan-capable driver stack
(any real GPU driver, or lavapipe for software rendering); its font
(JetBrains Mono, OFL-licensed) is bundled into the binary.
## Layout
The workspace has three first-party crates:
```
src/ Rust core
src/ pmacs — the core + TUI + daemon
rope.rs persistent byte-sequence backing every buffer
buffer.rs buffer + view chain + undo/redo
editor_core.rs cursor + commands + edit dispatch
crdt.rs loro-backed CRDT buffer state (feature `crdt`)
daemon.rs instance side of the frontend partition
attach.rs frontend side; transports + reconnect
semantic_render.rs semantic-frame producer (StyleSpans, Decorations, …)
lsp.rs language-server client
diag.rs, highlight.rs diagnostic + syntax/semantic-token rendering
syntax.rs tree-sitter integration
search.rs incremental search (substring + regex)
minibuffer.rs prompt, completion, persisted history
menu.rs context-menu model
file_io.rs atomic saves + external-modification detection
async_runtime.rs worker pool + message bus
process.rs PTY-aware process supervisor
ansi.rs ECMA-48 parser
daemon.rs instance side of the frontend partition
attach.rs frontend side; protocol + reconnect
lsp.rs language-server client
syntax.rs tree-sitter integration
project_index.rs symbol / file indexing
project.rs, project_index.rs project detection + symbol index
packages/ resolver, fetcher, installer, lockfile, loader
mcp.rs MCP client (packages speak to MCP servers)
lua_bindings/ pmacs.* Lua surface installers
text_view.rs cell-grid renderer
frontend.rs crossterm TUI
lua_bindings.rs pmacs.* Lua surface installers
main.rs entry point (TUI + daemon modes)
main.rs entry point (TUI / daemon / attach modes)
pmacs-protocol/ wire types + framing codec shared by all frontends
pmacs-gpu/ the GPU frontend (wgpu + winit + glyphon)
builtin/ Lua runtime shipped with the binary
commands/default.lua named commands for every editor primitive
keymaps/default.lua default key bindings
hooks/default.lua built-in hook definitions
runtime/ packages (async, lsp, repl, syntax)
menus/default.lua context-menu items
runtime/ async, lsp, syntax, mcp, fs runtimes
packages/repl/ the bundled REPL package
docs/ design notes, framing docs, and the roadmap
tests/ integration tests (acceptance gates per milestone)
```

135
docs/roadmap-2026-07.md Normal file
View File

@ -0,0 +1,135 @@
# pmacs roadmap — July 2026
Date: 2026-07-07. Produced from a five-way codebase/docs/memory sweep
(core editing + persistence, LSP surface, GPU parity, extensibility +
terminal, deferred-work inventory across all framing docs).
**Decision (2026-07-07): push Arc 1 (LSP utility surface), with Arc 2
(editing table stakes) items interleaved between sub-arcs.**
---
## State assessment (as of main @ ccb0ff6, protocol v14)
**Strong.** CRDT multi-frontend core; both frontends at near input/render
parity (GPU exceeds TUI: minimap, wavy squiggles); LSP data layer — 13
language ids / ~8 servers, rename + cross-file definition + formatting
fully working, semantic tokens + inlay hints rendering; package manager
(git installs, lockfile, resolver, SHA-256 hardening); MCP client; async
worker + PTY subprocess substrate; bundled line-oriented REPL package;
headless GPU render CI; help system; buffer-list UI; atomic save;
persisted minibuffer history.
**Dark matter — built but unwired (highest leverage).**
- Complete completion framework (`src/completion_framework.rs`:
providers for lsp/snippets/project-symbols/dabbrev; store;
`CompletionView` popup in `src/completion.rs`) — **no keybinding, no
typed-char trigger, popup never instantiated, no GPU message.**
- `HoverView` (`src/hover.rs`), `SignatureView` (`src/signature.rs`),
document-highlight store — plumbing only. Hover / signature /
references / document-symbols surface as one-line modeline summaries
(`builtin/runtime/lsp.lua` marks each "future UX work").
- Code actions apply the **first** action blindly — no picker.
- Semantic-token pull is refresh/manual-only (no pull on attach/edit) —
semantic styling can silently never appear. Arguably a bug.
- Wire-declared but unproduced protocol families: `FoldState`,
`BlockAdornments`, `ResourceOffer` (no fold engine / blame / diff
source).
**Absent.** Query-replace, kill ring (single slot only), keyboard
macros, rectangles, registers, comment/uncomment, auto-indent,
snippets, auto-pairing; desktop-save/session restore, recentf,
saveplace, autosave, backups, crash recovery; theming beyond syntax
captures (all chrome hardcoded per-frontend; GPU font hardcoded);
terminal emulation (`src/ansi.rs` parses but discards alt-screen /
cursor addressing — no grid/scrollback); compile/grep/shell-command
modes; DAP debugging; GPU splits / multi-buffer / auto-reconnect.
---
## Arcs, ranked by value-per-effort
### Arc 1 — LSP utility surface: "light up the dark matter" ← ACTIVE
Data layer is done; only UI is missing.
- **1a. In-buffer completion popup** (first). Trigger on typing,
TAB/RET accept, both frontends. GPU needs a wire message — mirror the
minibuffer-dropdown pattern (protocol v12). Framework + popup view
already exist.
- **1b. Panels**: hover popup, code-action picker, references list,
document-symbol outline. Generalize the buffer-list UI pattern
(`*buffer-list*` buffer-local bindings) into a reusable list-buffer
idiom.
- **1c. Semantic-token auto-pull fix** (small): pull on attach + on
edit-flush, like inlay hints already do.
- **1d. Signature-help auto-trigger** on `(`.
### Arc 2 — Editing table stakes (interleave with Arc 1)
Each small, core-only, frontend-agnostic: query-replace (isearch
exists; `search.rs` has no replace API), real kill ring + `M-y`,
comment/uncomment, auto-indent on newline, auto-pairing.
### Arc 3 — Persistence/serialization
Desktop-save (buffer set + layout + cursors → restore), recentf,
saveplace, autosave + crash recovery, optional backups. Generalize the
`$XDG_STATE_HOME/pmacs/` pattern from minibuffer history. Framing
question: what is a "session" in a daemon world; do CRDT snapshots
ride along.
### Arc 4 — Themes + extensibility surface
Extend `pmacs.theme` from syntax captures to named UI faces (modeline,
minibuffer, gutter, selection, status band); wire GPU chrome to it —
Q#UX1 lesson applies: rendering is frontend-local but control is
daemon-owned, so a wire channel (`ThemeFacts`-style) is needed. Add
`pmacs.gpu.set_font` (designed, never built) and a Lua
statusline-segment API.
### Arc 5 — Terminal, staged
- **Stage 1**: compile-mode / grep-mode / shell-command on the existing
PTY + ANSI + REPL-package substrate (line-oriented output buffer,
error-regex jump-to-file, `M-x compile`). Cheap, transformative.
- **Stage 2 (vterm)**: extend `ansi.rs` into a 2D grid model
(alt-screen, cursor addressing, scrollback — parser already
recognizes and discards these), grid-backed buffer view, GPU
rendering question (grid cells vs text buffer).
### Arc 6 — Folding (keystone gutter rider)
Fold engine (tree-sitter fold ranges) unblocks gutter fold markers +
the unproduced `FoldState` wire family + a visible feature. Git gutter
markers similarly just need a diff source.
### Arc 7 — Debugging (DAP)
Greenfield but newly unblocked: gutter (breakpoint signs), process
substrate (DAP = JSON-RPC over stdio, same shape as the LSP client),
list-buffer panels from Arc 1b (stack/variables). Frame after Arc 1
ships — panels are reused here.
### Arc 8 — GPU structural parity
Splits / multi-buffer (largest unscoped design problem), auto-reconnect
after daemon restart, cursor blink, font/theme config (overlaps Arc 4).
---
## Housekeeping (do opportunistically)
- Merge PR #91 (gutter click classification + fit guard).
- Delete or mark-superseded stale docs: `pmacs-gpu-editing-perf-handoff.md`
(freeze fixed by PR #60 arc), `session-5-stale-styling-handover.md`
(fixed, task #25 closed), `python_experiment.md`, `#run.sh#`,
`semantic-frontend-protocol.md.local-bak`.
- `V0.2-PREREQUISITES.md` cited twice by CHANGELOG but missing on disk —
reconstruct or unlink.
- README still leads with "What v0.1 ships with" — refresh.
- F-016 lua_bindings split: ~58 tranches left; `editor.rs` (7k lines)
and `pmacs-gpu/main.rs` (7.6k lines) splits are named follow-up arcs.
- Deferred backlogs live in each framing doc's Deferred section; the
consolidated sweep is reflected above.

View File

@ -102,6 +102,11 @@ const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6;
/// `W` its width; it spans the full line height.
const GUTTER_SIGN_X: f32 = 4.0;
const GUTTER_SIGN_W: f32 = 4.0;
/// Minimum text-area width (px) the gutter must leave. If reserving the
/// gutter would crowd the text below this, the gutter is dropped for the
/// frame — the GPU mirror of the TUI's too-narrow-window disable, so a
/// narrow window or a very large file can never force `left >= right`.
const MIN_TEXT_WIDTH_PX: f32 = 48.0;
const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92];
const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82];
const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18];
@ -2818,7 +2823,19 @@ impl State {
return 0.0;
}
let lines = self.current_line_starts.len().max(1);
decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX
let want = decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX;
// Fit guard (mirrors the TUI's too-narrow disable): never reserve so
// much gutter that the text area collapses. `text_bounds_right` is
// the text clip edge (minimap-aware); if the gutter would leave less
// than `MIN_TEXT_WIDTH_PX` past `TEXT_LEFT`, drop it this frame
// rather than shift `text_left` to or past the clip and render into
// a degenerate `left >= right` rectangle.
let avail = self.text_bounds_right() as f32 - TEXT_LEFT;
if want + MIN_TEXT_WIDTH_PX > avail {
0.0
} else {
want
}
}
/// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every
@ -2882,6 +2899,22 @@ impl State {
/// line) → projected byte → run map → slice byte → + `vstart`.
/// `None` when no buffer is attached or the position is outside
/// anything hit-testable.
/// Text-relative x for hit testing, classifying the gutter band first
/// (UX gutter, Q#UX6). A click left of the text origin (`raw_x < 0`,
/// i.e. inside the gutter) is not a text hit — it clamps to `0.0`, the
/// line start, rather than feeding glyphon a negative x (undefined).
/// Mirrors the TUI's saturate-to-column-0 affordance and is the stable
/// seam a future gutter marker would branch on instead of relying on
/// glyphon's negative-x edge behavior.
fn gutter_aware_rel_x(&self, x: f64) -> f32 {
let raw_x = x as f32 - self.text_left();
if self.line_numbers.is_on() && raw_x < 0.0 {
0.0
} else {
raw_x
}
}
fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option<u64> {
self.current_buffer_id?;
if self.hit_map_dirty {
@ -2900,7 +2933,7 @@ impl State {
self.projected_line_starts = projected_line_starts;
self.hit_map_dirty = false;
}
let rel_x = x as f32 - self.text_left();
let rel_x = self.gutter_aware_rel_x(x);
let rel_y = y as f32 - TEXT_TOP;
let cursor = self.buffer.hit(rel_x, rel_y)?;
let line_start = *self.projected_line_starts.get(cursor.line)?;
@ -7517,4 +7550,55 @@ mod tests {
"relative numbering must differ from absolute ({differing} bytes differ)"
);
}
#[test]
fn gutter_aware_rel_x_clamps_the_gutter_band() {
// F1: a click in the gutter band (left of the text origin) clamps
// to the line start (rel_x 0), never a negative x into glyphon.
let text = "alpha\nbeta\ngamma\n";
let Some(mut s) = headless_or_skip(400, 300, text) else {
return;
};
s.line_numbers = LineNumberMode::Absolute;
let text_left = f64::from(s.text_left());
assert!(text_left > f64::from(TEXT_LEFT), "the gutter is present");
// Inside the gutter band and at the exact origin → clamped to 0.
assert!(s.gutter_aware_rel_x(text_left - 4.0).abs() < f32::EPSILON);
assert!(s.gutter_aware_rel_x(text_left).abs() < f32::EPSILON);
// Well into the text → a positive text-relative x.
assert!(s.gutter_aware_rel_x(text_left + 40.0) > 0.0);
// With the gutter off there's no band, so a left-of-origin x passes
// through negative (the pre-gutter behavior is unchanged).
s.line_numbers = LineNumberMode::Off;
assert!(s.gutter_aware_rel_x(f64::from(TEXT_LEFT) - 4.0) < 0.0);
}
#[test]
fn narrow_window_drops_the_gutter() {
// F2: a window too narrow to fit the gutter + a minimum text area
// drops the gutter for the frame (no `left >= right`), mirroring the
// TUI. A wide window keeps it.
let text = "l1\nl2\nl3\n";
let Some(mut narrow) = headless_or_skip(60, 200, text) else {
return;
};
narrow.line_numbers = LineNumberMode::Absolute;
assert!(
narrow.gutter_width_px() < f32::EPSILON,
"a 60px window can't fit gutter + min text → gutter dropped"
);
assert!(
(narrow.text_left() - TEXT_LEFT).abs() < f32::EPSILON,
"text origin unshifted when the gutter is dropped"
);
let mut wide = State::new_headless(800, 200, text).expect("adapter was just available");
wide.line_numbers = LineNumberMode::Absolute;
assert!(
wide.gutter_width_px() > 0.0,
"a wide window keeps the gutter"
);
}
}

View File

@ -2769,6 +2769,28 @@ mod tests {
}
}
#[test]
fn line_numbers_all_modes_round_trip_through_postcard() {
// UX gutter v14: `LineNumbers` swapped `enabled: bool` for a
// `LineNumberMode` enum. Pin every variant's postcard shape so a
// future enum reorder / addition can't silently change the wire.
let bid = crate::buffer::BufferId::next();
for mode in [
LineNumberMode::Off,
LineNumberMode::Absolute,
LineNumberMode::Relative,
LineNumberMode::Hybrid,
] {
let msg = InstanceMessage::LineNumbers {
buffer_id: bid,
mode,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn frontend_event_viewport_round_trips_through_postcard() {
let ev = FrontendEvent::Viewport {