From fae7ed3fd0a6ec7c2e55f2bcf419c282df468e8a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 13:38:38 -0400 Subject: [PATCH 1/4] feat(tui): line-number gutter (UX arc sub-arc 1, TUI half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a reserved left gutter column with absolute line numbers in the TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc- framing.md). Default OFF (Emacs tradition), so zero layout/coordinate change until a window opts in. - window.rs: LineNumberMode { Off, Absolute } + per-window `line_numbers` field + `gutter_width()` (digits(line_count) + PAD) + `decimal_digits`. - editor.rs: the gutter is one viewport shift at the paint site (cell_origin.col += gutter_w, cell_size.cols -= gutter_w) — every viewport-relative painter (text, syntax, diag underline, search) stays gutter-agnostic. The sites that read rect.origin.col directly get a manual +gutter_w: cursor placement, local selection, mouse hit-test (a gutter click maps to line start, Q#UX6). paint_line_number_gutter writes right-aligned dim digits alloc-free. - overlay_paint.rs: remote-presence cursor/selection shift by gutter_w. - Lua: pmacs.window.set_line_numbers/line_numbers + window.toggle-line-numbers command. No protocol/daemon change (frontend-local, Q#UX1). Tests: gutter render (right-aligned digits + past-EOF blanks) + decimal_digits. Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass both flavors. Needs a human eyeball (coordinate-math change) before the PR. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- builtin/commands/default.lua | 6 ++ src/editor.rs | 148 +++++++++++++++++++++++++++++++++-- src/lua_bindings/mod.rs | 38 +++++++++ src/overlay_paint.rs | 23 ++++-- src/window.rs | 61 +++++++++++++++ 5 files changed, 263 insertions(+), 13 deletions(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index b220a74..eb0fb3a 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -219,6 +219,12 @@ cmd { name = "window.split-horizontal", cmd { name = "window.split-vertical", description = "Split the active window vertically (children sit side-by-side).", fn = function() pmacs.window.split_vertical() end } +cmd { name = "window.toggle-line-numbers", + description = "Toggle the active window's line-number gutter (off / absolute).", + fn = function() + local cur = pmacs.window.line_numbers() + pmacs.window.set_line_numbers(cur == "off" and "absolute" or "off") + end } cmd { name = "window.focus-next", description = "Move focus to the next window in iteration order.", fn = function() pmacs.window.focus_next() end } diff --git a/src/editor.rs b/src/editor.rs index 95bd64d..41108c9 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -882,7 +882,20 @@ impl EditorState { }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); - let local_col = cell_col.saturating_sub(rect.origin.col); + // UX gutter (Q#UX6): subtract the reserved gutter width so the + // hit-test lands on the right text byte. A click inside the gutter + // strip (raw < gutter_w) saturates to column 0 → the start of that + // line, a mild, useful affordance for the MVP. + let gutter_w = { + let core = self.core.borrow(); + core.windows.get(&win_id).map_or(0, |w| { + let g = w.gutter_width(); + if g >= rect.size.cols { 0 } else { g } + }) + }; + let local_col = cell_col + .saturating_sub(rect.origin.col) + .saturating_sub(gutter_w); match ev.kind { MouseEventKind::Down(MouseButton::Left) => { @@ -1608,11 +1621,20 @@ pub fn paint_frame( continue; }; let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); + // UX gutter (Q#UX2): reserve a left strip for line numbers and + // shrink+shift the text area into the remainder, so every + // viewport-relative painter (text, syntax, diagnostics, search) + // stays gutter-agnostic. A window too narrow for the gutter falls + // back to no gutter this frame rather than starving the text. + let gutter_w = { + let w = window.gutter_width(); + if w >= rect.size.cols { 0 } else { w } + }; let viewport = Viewport { buffer_start: viewport_buffer_start, buffer_end: buf.len(), - cell_origin: rect.origin, - cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols), + cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), + cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), }; // Composition (T M2.9): base text_view paints first, then // each overlay in attach order. See [`crate::view::View`]. @@ -1620,7 +1642,10 @@ pub fn paint_frame( for overlay in &mut window.overlays { overlay.render(buf, viewport, grid); } - paint_local_selection(grid, buf, window, &rect, inner_rows); + if gutter_w > 0 { + paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w); + } + paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w); // Mode line for this window. Painted last so the line // itself is always visible regardless of overlay activity. let coord = window @@ -1685,9 +1710,15 @@ pub fn paint_frame( { return None; } + // UX gutter: the terminal caret sits in the text area, past the + // reserved gutter strip (mirrors the viewport shift above). + let gutter_w = { + let w = aw.gutter_width(); + if w >= active_rect.size.cols { 0 } else { w } + }; let grid_row = active_rect.origin.row + (disp.row - aw.view_top as u32); let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1); - let grid_col = (active_rect.origin.col + disp.col).min(max_col); + let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col); Some(CellCoord::new(grid_row, grid_col)) } @@ -1745,12 +1776,69 @@ fn inner_rows(rect: &crate::window::Rect) -> u32 { rect.size.rows.saturating_sub(1) } +/// Paint the left line-number gutter for `window` into the reserved strip +/// `[rect.origin.col, rect.origin.col + gutter_w)` over the window's text +/// rows (UX gutter arc). Numbers are 1-based, right-aligned with a single +/// trailing pad cell; rows past end-of-buffer stay blank. Dimly styled so +/// the gutter recedes behind the code. The caller guarantees `gutter_w > +/// 0` and that it fits within `rect.size.cols`. +fn paint_line_number_gutter( + grid: &mut crate::cell::CellGrid<'_>, + window: &crate::window::Window, + rect: &crate::window::Rect, + inner_rows: u32, + gutter_w: u32, +) { + let line_count = window.text_view.line_count(); + let style = crate::cell::Style { + fg: crate::cell::Color::Indexed(8), + ..crate::cell::Style::default() + }; + // The number's rightmost digit sits at `field - 1`; the last gutter + // cell (`gutter_w - 1`) is a trailing pad separating it from the code. + let field = gutter_w.saturating_sub(1); + for r in 0..inner_rows { + let grid_row = rect.origin.row + r; + // Blank + style the whole strip first, so a number that shrank a + // digit (e.g. after a large delete) leaves no stale trailing glyph. + for c in 0..gutter_w { + let cell = grid.at(CellCoord::new(grid_row, rect.origin.col + c)); + cell.glyph = crate::cell::Glyph::Char(' '); + cell.style = style; + cell.attachment = None; + } + let buffer_line = window.view_top + r as usize; + if buffer_line >= line_count { + continue; // past end-of-buffer: blank gutter + } + // Write the 1-based number right-aligned, rightmost digit first, + // alloc-free. `field >= digits(line_count)` by construction, so + // the leftmost digit always leaves at least a leading pad cell. + let mut val = buffer_line + 1; + let mut col = field; + loop { + col -= 1; + let digit = (val % 10) as u8; + grid.at(CellCoord::new(grid_row, rect.origin.col + col)) + .glyph = crate::cell::Glyph::Char((b'0' + digit) as char); + val /= 10; + if val == 0 || col == 0 { + break; + } + } + } +} + fn paint_local_selection( grid: &mut crate::cell::CellGrid<'_>, buf: &crate::buffer::Buffer, window: &crate::window::Window, rect: &crate::window::Rect, inner_rows: u32, + // UX gutter: the reserved left-strip width; selection cells are the + // text-relative display column shifted right by this (Q#UX2). 0 when + // the gutter is off, so this is a no-op then. + gutter_w: u32, ) { let Some((sel_start, sel_end)) = window.region() else { return; @@ -1758,6 +1846,7 @@ fn paint_local_selection( if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end { return; } + let text_cols = rect.size.cols.saturating_sub(gutter_w); let first_row = window.view_top; let last_row = first_row.saturating_add(inner_rows as usize); @@ -1786,15 +1875,15 @@ fn paint_local_selection( } let row_offset = display_row.saturating_sub(first_row) as u32; - let start_col = start_coord.col.min(rect.size.cols); - let end_col = end_coord.col.min(rect.size.cols); + let start_col = start_coord.col.min(text_cols); + let end_col = end_coord.col.min(text_cols); if start_col >= end_col { continue; } for col in start_col..end_col { let cell = grid.at(CellCoord::new( rect.origin.row + row_offset, - rect.origin.col + col, + rect.origin.col + gutter_w + col, )); cell.style.reverse = true; } @@ -2196,6 +2285,49 @@ mod tests { use super::*; use crate::frontend::KeyEventKind; + #[test] + fn line_number_gutter_renders_right_aligned_digits() { + use crate::buffer::{Buffer, BufferId}; + use crate::cell::{Cell, CellGrid, CellSize, Glyph}; + use crate::text_view::TextView; + use crate::window::{LineNumberMode, Window, WindowId}; + + // 12 lines → decimal_digits(12) = 2, gutter_w = 2 + PAD(2) = 4. + let content = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n"; + let bid = BufferId::next(); + let buf = Buffer::from_bytes(bid, "test", content); + let view = TextView::new(&buf); + let mut window = Window::new(WindowId::next(), bid, view); + window.line_numbers = LineNumberMode::Absolute; + assert_eq!(window.gutter_width(), 4, "2-digit line count + 2 pad"); + + let (rows, cols) = (12u32, 20u32); + let mut storage = vec![Cell::default(); (rows * cols) as usize]; + let mut grid = CellGrid { + cells: &mut storage, + stride: cols, + size: CellSize::new(rows, cols), + }; + let rect = Rect::new(0, 0, rows, cols); + paint_line_number_gutter(&mut grid, &window, &rect, rows, 4); + + let glyph = |r: u32, c: u32| storage[(r * cols + c) as usize].glyph.clone(); + // Row 0 = line 1: " 1 " (digit right-aligned at col 2, col 3 = pad). + assert_eq!(glyph(0, 0), Glyph::Char(' ')); + assert_eq!(glyph(0, 1), Glyph::Char(' ')); + assert_eq!(glyph(0, 2), Glyph::Char('1')); + assert_eq!(glyph(0, 3), Glyph::Char(' ')); + // Row 4 = line 5. + assert_eq!(glyph(4, 2), Glyph::Char('5')); + // Row 9 = line 10: two digits → col1='1', col2='0', col3 pad. + assert_eq!(glyph(9, 1), Glyph::Char('1')); + assert_eq!(glyph(9, 2), Glyph::Char('0')); + assert_eq!(glyph(9, 3), Glyph::Char(' ')); + // Row 11 = line 12. + assert_eq!(glyph(11, 1), Glyph::Char('1')); + assert_eq!(glyph(11, 2), Glyph::Char('2')); + } + fn fresh_with(content: &[u8]) -> EditorState { let s = EditorState::new(); let new_id = s diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 5a171d6..434f632 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -10093,6 +10093,44 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result { )?; } + { + // UX gutter: set the active window's line-number mode + // ("off" | "absolute"). Per-window (Q#UX5); a friendly toggle + // command wraps this in `builtin/`. + let cc = core.clone(); + win.set( + "set_line_numbers", + lua.create_function(move |_, mode: String| { + let m = match mode.as_str() { + "off" | "none" => crate::window::LineNumberMode::Off, + "absolute" | "abs" | "on" => crate::window::LineNumberMode::Absolute, + other => { + return Err(mlua::Error::external(format!( + "unknown line-number mode {other:?} (expected off|absolute)" + ))); + } + }; + cc.borrow_mut().active_window_mut().line_numbers = m; + Ok(()) + })?, + )?; + } + + { + // Read the active window's line-number mode as a string. + let cc = core.clone(); + win.set( + "line_numbers", + lua.create_function(move |_, ()| { + let mode = match cc.borrow().active_window().line_numbers { + crate::window::LineNumberMode::Off => "off", + crate::window::LineNumberMode::Absolute => "absolute", + }; + Ok(mode) + })?, + )?; + } + { let cc = core.clone(); win.set( diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index 72ed9b4..af0ee7f 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -135,6 +135,15 @@ pub fn paint_other_frontend_overlays( let Ok(buf) = reg.get(window.buffer_id) else { continue; }; + // UX gutter: this window may reserve a left strip for line + // numbers; a remote cursor/selection is a text-relative column + // shifted right by that width (0 when the gutter is off). This + // pass runs after `paint_frame`, so it learns the width here. + let gutter_w = { + let g = window.gutter_width(); + if g >= rect.size.cols { 0 } else { g } + }; + let text_cols = rect.size.cols.saturating_sub(gutter_w); // Source's byte position → display coords via THIS // recipient window's text_view (the recipient's view // of the buffer). @@ -154,11 +163,11 @@ pub fn paint_other_frontend_overlays( // Column bounds: disp.col is the buffer column; window // doesn't horizontally scroll in v1.0, so cells past // rect.size.cols are simply off-grid for this window. - if disp.col >= rect.size.cols { + if disp.col >= text_cols { continue; } let cursor_grid_row = rect.origin.row + row_in_window as u32; - let cursor_grid_col = rect.origin.col + disp.col; + let cursor_grid_col = rect.origin.col + gutter_w + disp.col; paint_cursor_cell(grid, cursor_grid_row, cursor_grid_col, color); if let Some(label_ch) = label { paint_label_cell(grid, cursor_grid_row, cursor_grid_col, label_ch, color); @@ -171,7 +180,9 @@ pub fn paint_other_frontend_overlays( } else { (sel.active, sel.anchor) }; - paint_selection_in_window(grid, buf, window, rect, inner_rows, lo, hi, color); + paint_selection_in_window( + grid, buf, window, rect, inner_rows, gutter_w, lo, hi, color, + ); } } } @@ -223,6 +234,7 @@ fn paint_selection_in_window( window: &crate::window::Window, rect: Rect, inner_rows: u32, + gutter_w: u32, lo: crate::rope::Position, hi: crate::rope::Position, color: Color, @@ -230,6 +242,7 @@ fn paint_selection_in_window( if lo >= hi { return; } + let text_cols = rect.size.cols.saturating_sub(gutter_w); // Walk byte positions from lo to hi, mapping each to a // display coord. Step in single-byte increments; pos_to_display // tolerates byte-boundary positions and returns None for @@ -249,9 +262,9 @@ fn paint_selection_in_window( break; }; match (disp.row as usize).checked_sub(window.view_top) { - Some(r) if r < inner_rows as usize && disp.col < rect.size.cols => { + Some(r) if r < inner_rows as usize && disp.col < text_cols => { let grid_row = rect.origin.row + r as u32; - let grid_col = rect.origin.col + disp.col; + let grid_col = rect.origin.col + gutter_w + disp.col; if grid_row < grid.size.rows && grid_col < grid.size.cols { let cell = grid.at(CellCoord::new(grid_row, grid_col)); cell.style.underline = UnderlineStyle::Single; diff --git a/src/window.rs b/src/window.rs index a5ed06a..9988622 100644 --- a/src/window.rs +++ b/src/window.rs @@ -129,6 +129,36 @@ pub struct Selection { pub anchor: Position, } +/// Line-number display mode for a window's left gutter (UX gutter arc). +/// `Off` reserves no gutter at all — text starts at column 0, and every +/// coordinate is unchanged (the default, matching the Emacs tradition). +/// Additional modes (relative, hybrid) arrive in a later sub-arc. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum LineNumberMode { + /// No gutter; zero layout change. + #[default] + Off, + /// Absolute 1-based line numbers, right-aligned in the gutter. + Absolute, +} + +/// Cells of horizontal padding the line-number gutter adds around the +/// digit field: a leading and a trailing blank, so `gutter_w = digits + +/// PAD` (Q#UX3). Kept as a named constant so both frontends can share the +/// convention (Q#UX7). `u32` to match the cell-grid column type. +pub const LINE_NUMBER_GUTTER_PAD: u32 = 2; + +/// Number of decimal digits in `n` (for `n >= 1`). Allocation-free. +#[must_use] +pub fn decimal_digits(mut n: usize) -> u32 { + let mut d = 1u32; + while n >= 10 { + n /= 10; + d += 1; + } + d +} + /// One leaf of the window tree: a buffer plus per-window state. pub struct Window { /// Unique identifier. @@ -158,6 +188,9 @@ pub struct Window { /// render. Updated by the renderer; consumed by `cursor.page-down` /// / `cursor.page-up`. `0` until the first render lands. pub last_visible_rows: u32, + /// Line-number gutter mode for this window (UX gutter arc). `Off` by + /// default → no gutter, no coordinate change. + pub line_numbers: LineNumberMode, } impl Window { @@ -175,6 +208,22 @@ impl Window { view_top: 0, goal_col: None, last_visible_rows: 0, + line_numbers: LineNumberMode::Off, + } + } + + /// Width in cells this window's line-number gutter occupies, or `0` + /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; + /// the renderer caps this against the window width and applies it as a + /// left offset to the text area. Every gutter coordinate-math site + /// reads this one function so the width stays consistent. + #[must_use] + pub fn gutter_width(&self) -> u32 { + match self.line_numbers { + LineNumberMode::Off => 0, + LineNumberMode::Absolute => { + decimal_digits(self.text_view.line_count().max(1)) + LINE_NUMBER_GUTTER_PAD + } } } @@ -465,6 +514,18 @@ fn collapse_single_child_splits(node: &mut LayoutNode) { mod tests { use super::*; + #[test] + fn decimal_digits_counts_correctly() { + assert_eq!(decimal_digits(1), 1); + assert_eq!(decimal_digits(9), 1); + assert_eq!(decimal_digits(10), 2); + assert_eq!(decimal_digits(99), 2); + assert_eq!(decimal_digits(100), 3); + assert_eq!(decimal_digits(1000), 4); + // A 6-digit file → 6 digits + PAD gutter. + assert_eq!(decimal_digits(123_456), 6); + } + fn id() -> WindowId { WindowId::next() } From f7583e7994f17368ece42b8a29b4b579571b71a3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 13:59:43 -0400 Subject: [PATCH 2/4] feat(gpu): line-number gutter (UX arc sub-arc 1, GPU half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- pmacs-gpu/src/main.rs | 212 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 203 insertions(+), 9 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 076a9d8..b8e7961 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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 = 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::::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>>>; @@ -622,6 +654,15 @@ struct State { /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, + /// 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 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