feat(gpu): line-number gutter (UX arc sub-arc 1, GPU half)

Mirror the TUI line-number gutter in the pmacs-gpu frontend — the GPU half
of sub-arc 1. Frontend-local, no protocol change (Q#UX1); off by default,
enabled with `--line-numbers`.

The gutter is a reserved left strip mirroring the minimap's reserved right
column. All horizontal text geometry hangs off `TEXT_LEFT`; the gutter adds
`gutter_width_px()` to it via `text_left()`, applied at every byte→pixel x
site (main TextArea, caret, washes/squiggles) and subtracted at the one
pixel→byte site (mouse hit-test). The main text clip-left moves off 0.

- gutter_width_px = digits(line_count) * mono_advance + gap (px), advance
  read from the shaped code buffer.
- A dedicated gutter_text_renderer + gutter_buffer draw right-aligned dim
  numbers, reshaped per scroll (refresh_gutter_buffer), same font size +
  line height as the code so rows align one-for-one.
- --line-numbers flag filtered out before the mode parser (position-
  independent), threaded App → State.

Headless render test asserts enabling the gutter changes the frame (ink +
shift). fmt + clippy clean; 53 pmacs-gpu tests pass (render tests on the
local adapter). Needs a human eyeball before the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-07-06 13:59:43 -04:00
parent fae7ed3fd0
commit f7583e7994
1 changed files with 203 additions and 9 deletions

View File

@ -87,6 +87,15 @@ const MINIMAP_CODE_COLS: f32 = 100.0;
const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5;
const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0;
const CODE_LINE_HEIGHT: f32 = 22.0;
/// Font size of the code buffer (and the line-number gutter, so their
/// line heights match and rows align).
const CODE_FONT_SIZE: f32 = 16.0;
/// Gap in px between the line-number gutter digits and the code
/// (UX gutter arc, GPU side of sub-arc 1 — mirrors the TUI gutter).
const GUTTER_GAP_PX: f32 = 10.0;
/// Fallback monospace advance in px when no shaped glyph is available to
/// measure (0.6 em at the 16px code font).
const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6;
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];
@ -262,9 +271,28 @@ enum Mode {
Attach { socket: PathBuf },
}
/// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes
/// the line-number gutter (UX gutter arc). Mirrors the TUI's
/// `pmacs::window::decimal_digits` — kept local since pmacs-gpu doesn't
/// depend on the `pmacs` crate.
fn decimal_digits(mut n: usize) -> u32 {
let mut d = 1u32;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
fn main() {
env_logger::init();
let mode = parse_args(std::env::args().skip(1).collect());
// Filter the frontend-local `--line-numbers` flag out before the
// mode parser (UX gutter arc, Q#UX5) — it's orthogonal to the
// hello-world/attach mode and may appear in any position.
let mut args: Vec<String> = std::env::args().skip(1).collect();
let line_numbers = args.iter().any(|a| a == "--line-numbers");
args.retain(|a| a != "--line-numbers");
let mode = parse_args(args);
let event_loop = EventLoop::<AppEvent>::with_user_event()
.build()
.expect("create winit event loop");
@ -275,6 +303,7 @@ fn main() {
state: None,
attach_client: None,
modifiers: winit::keyboard::ModifiersState::empty(),
line_numbers,
};
event_loop
.run_app(&mut app)
@ -333,6 +362,9 @@ struct App {
/// delivers modifiers separately from key presses, so we track the
/// current set and apply it when a key is sent (session B1).
modifiers: winit::keyboard::ModifiersState,
/// Line-number gutter toggle from `--line-numbers` (UX gutter arc);
/// applied to `State` once it's built in `resumed`.
line_numbers: bool,
}
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
@ -622,6 +654,15 @@ struct State {
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
/// rebuilding rescanned every line shape per frame.
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
/// Line-number gutter toggle (UX gutter arc, GPU side). Frontend-local
/// (Q#UX5), set from the `--line-numbers` flag. Off ⇒ zero coordinate
/// change: `gutter_width_px()` is 0 and every shift site is a no-op.
line_numbers: bool,
/// Shaped right-aligned line numbers, one per visible code line — its
/// own text layer over the code, aligned row-for-row (same line height).
gutter_buffer: Buffer,
/// Dedicated renderer for the gutter number layer (like the menu / mb).
gutter_text_renderer: TextRenderer,
}
/// The wire-authoritative status facts (Q#S1, protocol v8),
@ -757,7 +798,9 @@ impl ApplicationHandler<AppEvent> for App {
Mode::HelloWorld => HELLO_TEXT,
Mode::Attach { .. } => "(connecting...)",
};
self.state = Some(State::new(event_loop, initial_text));
let mut state = State::new(event_loop, initial_text);
state.line_numbers = self.line_numbers;
self.state = Some(state);
// In attach mode, kick off the connection now that the event
// loop is running and a proxy is available. Failure logs and
@ -1700,6 +1743,9 @@ impl State {
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
let mb_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// UX gutter — a renderer for the line-number layer.
let gutter_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
let quad_renderer = QuadRenderer::new(&device, format);
let squiggle_renderer = SquiggleRenderer::new(&device, format);
@ -1749,6 +1795,17 @@ impl State {
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
// Line-number gutter buffer (UX gutter arc): same font size + line
// height as the code buffer so its rows align one-for-one.
let mut gutter_buffer = Buffer::new(
&mut font_system,
Metrics::new(CODE_FONT_SIZE, CODE_LINE_HEIGHT),
);
gutter_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(config.height as f32),
);
buffer.set_text(
&mut font_system,
initial_text,
@ -1831,6 +1888,9 @@ impl State {
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
line_numbers: false,
gutter_buffer,
gutter_text_renderer,
}
}
@ -2732,6 +2792,66 @@ impl State {
self.scroll_top != old
}
/// Monospace glyph advance in px, read from the currently-shaped code
/// buffer (every glyph shares it in a monospace font), with a fallback
/// when the buffer has no glyphs yet. Used to size the line-number
/// gutter (UX gutter arc).
fn mono_advance(&self) -> f32 {
self.buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter())
.next()
.map_or(GUTTER_MONO_ADVANCE_FALLBACK, |g| g.w)
}
/// Width in px the line-number gutter reserves on the left, or 0 when
/// disabled (UX gutter arc, Q#UX3): `digits * advance + gap`. Mirrors
/// the TUI's `Window::gutter_width`; the unit here is pixels.
fn gutter_width_px(&self) -> f32 {
if !self.line_numbers {
return 0.0;
}
let lines = self.current_line_starts.len().max(1);
decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX
}
/// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every
/// byte→pixel x site adds this instead of the bare `TEXT_LEFT` (Q#UX2),
/// and the pixel→byte hit-test subtracts it.
fn text_left(&self) -> f32 {
TEXT_LEFT + self.gutter_width_px()
}
/// Reshape the gutter buffer to the right-aligned line numbers for the
/// currently-shaped code lines (UX gutter arc). One number per code
/// line starting at `shaped_top`, so the two buffers align row-for-row
/// at the same `top` and line height. No-op when the gutter is off.
fn refresh_gutter_buffer(&mut self) {
use std::fmt::Write as _;
if !self.line_numbers {
return;
}
let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize;
let first = self.shaped_top;
let n = self.buffer.lines.len();
let mut text = String::new();
for i in 0..n {
if i > 0 {
text.push('\n');
}
let _ = write!(text, "{:>digits$}", first + i + 1);
}
self.gutter_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.gutter_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Resolve a window-pixel position to an **absolute source byte**
/// (Q#M2): pixel → cosmic-text hit (shaped line + byte within
/// line) → projected byte → run map → slice byte → + `vstart`.
@ -2755,7 +2875,7 @@ impl State {
self.projected_line_starts = projected_line_starts;
self.hit_map_dirty = false;
}
let rel_x = x as f32 - TEXT_LEFT;
let rel_x = x as f32 - self.text_left();
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)?;
@ -3771,6 +3891,9 @@ impl State {
let frame_start = debug_frame().then(std::time::Instant::now);
self.refresh_status_line();
self.refresh_menu_buffer();
// UX gutter: reshape the line-number layer to the current scroll
// (no-op when the gutter is off).
self.refresh_gutter_buffer();
// Q#CM1 — the context-menu popup quads (bg / highlight /
// separators), drawn as a top layer after everything else.
let menu_vertices = self.menu_vertex_bytes();
@ -3881,6 +4004,15 @@ impl State {
(self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT);
let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
// UX gutter: the code's left origin (past the gutter) and the
// main-text clip-left. Computed here as locals — calling `self.*`
// inside the `prepare` args would conflict with its `&mut` borrows.
let text_left = self.text_left();
let gutter_clip_left = if self.line_numbers {
text_left.floor() as i32
} else {
0
};
self.text_renderer
.prepare(
&self.device,
@ -3891,11 +4023,11 @@ impl State {
[
TextArea {
buffer: &self.buffer,
left: TEXT_LEFT,
left: text_left,
top: TEXT_TOP,
scale: 1.0,
bounds: TextBounds {
left: 0,
left: gutter_clip_left,
top: 0,
right: text_bounds_right,
// Clip at the status band (Q#S3): a final
@ -3940,6 +4072,39 @@ impl State {
)
.expect("text_renderer prepare");
// UX gutter: prepare the line-number layer in the reserved left
// strip (empty when off → renders nothing). Same `top` + line
// height as the code, so numbers align row-for-row.
let gutter_areas: Vec<TextArea> = if self.line_numbers {
vec![TextArea {
buffer: &self.gutter_buffer,
left: TEXT_LEFT,
top: TEXT_TOP,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: gutter_clip_left,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(120, 120, 135),
custom_glyphs: &[],
}]
} else {
Vec::new()
};
self.gutter_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
gutter_areas,
&mut self.swash_cache,
)
.expect("gutter_text_renderer prepare");
// Q#CM1 — prepare the menu glyphs in their own layer (empty when
// closed, so the renderer draws nothing).
let menu_areas: Vec<TextArea> = self
@ -4052,6 +4217,11 @@ impl State {
self.text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("text_renderer render");
// UX gutter: line numbers in the reserved left strip (empty
// layer when off).
self.gutter_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("gutter_text_renderer render");
// Caret over the text so the insertion point reads on top
// of the glyph it sits before (session B1).
if let Some(vertex_buffer) = caret_buffer.as_ref() {
@ -4339,20 +4509,22 @@ impl State {
}
let slice_cursor = cursor - vstart;
let (line_lo, _) = source_line_range(slice, slice_cursor);
// UX gutter: the caret sits in the code area, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
continue;
}
let line_base = line_lo;
let mut x = TEXT_LEFT;
let mut x = text_left;
for glyph in run.glyphs {
if line_base + glyph.start as u64 >= slice_cursor {
x = TEXT_LEFT + glyph.x;
x = text_left + glyph.x;
break;
}
// Cursor is past this glyph; track its right edge so a
// cursor at line end lands after the final glyph.
x = TEXT_LEFT + glyph.x + glyph.w;
x = text_left + glyph.x + glyph.w;
}
return Some(MinimapRect {
x,
@ -4383,6 +4555,8 @@ impl State {
if hi <= lo {
return;
}
// UX gutter: washes/squiggles are code-relative, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
let mut min_x: Option<f32> = None;
@ -4410,7 +4584,7 @@ impl State {
None => (TEXT_TOP + run.line_top, run.line_height),
};
rects.push(MinimapRect {
x: TEXT_LEFT + x0,
x: text_left + x0,
y,
w: x1 - x0,
h,
@ -7147,4 +7321,24 @@ mod tests {
"text should paint visible ink (only {differing} bytes differ from the empty frame)"
);
}
#[test]
fn headless_line_number_gutter_changes_the_frame() {
// UX gutter: enabling line numbers must add ink on the left and
// shift the code right — the rendered frame must differ.
let Some(mut off) = headless_or_skip(400, 300, "alpha\nbeta\ngamma\ndelta\n") else {
return;
};
let off_px = off.render_offscreen();
let mut on = State::new_headless(400, 300, "alpha\nbeta\ngamma\ndelta\n")
.expect("adapter was just available");
on.line_numbers = true;
let on_px = on.render_offscreen();
assert_eq!(off_px.len(), on_px.len());
let differing = off_px.iter().zip(&on_px).filter(|(a, b)| a != b).count();
assert!(
differing > 200,
"the gutter should add ink + shift the text (only {differing} bytes differ)"
);
}
}