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:
parent
fae7ed3fd0
commit
f7583e7994
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue