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