Plan implementation for showing cursor beam
Public
The user wants me to analyze the code and come up with a plan for how to show a cursor position as a beam (like a text cursor). Let me first understand the current codebase structure.
I should:
- Read the relevant files to understand the architecture
- Look at the renderer and how text is currently being displayed
- Understand the data structures for cursor position
- Come up with a plan for rendering a cursor beam
Let me start by exploring the codebase structure and reading key files.
src/platform/macos/ renderer.rs
- TL;DR
- Represent the cursor as an optional Cursor in Scene with position, color, style (beam), and visible flag.
- Add a tiny second render pipeline for solid-colored quads (reusing the existing text vertex shader, new fragment that ignores texture).
- Compute the beam quad in NDC from GridConfig and window size; toggle visibility with a simple Instant-based timer; feed cursor row/col from Terminal::cursor_position each frame.
- Recommended approach (simple path)
Scene representation
- Add a Cursor struct and field to Scene:
- struct Cursor { pos: CellPosition, color: Rgba, style: CursorStyle, visible: bool }
- enum CursorStyle { Beam { width_px: f32 } } // keep just Beam for now; leave room for Block/Underline later
- Extend Scene to carry cursor: Option. Keep it optional so you can disable cursor rendering without branching elsewhere.
- Add a Cursor struct and field to Scene:
Reuse vs new pipeline
- Add a small “solid color” pipeline for the cursor. Keep text_vertex as-is; add a new fragment shader solid_fragment that returns in.color directly. This avoids touching the atlas or text sampler and prevents sampling/bleed issues.
Shader changes
- Add to shaders.metal:
- fragment float4 solid_fragment(TextVertexOut in [[stage_in]]) { return in.color; }
- No changes to text_vertex or TextVertexIn/Out needed. You’ll use the same vertex buffer layout; just ignore texCoords in the cursor path.
- Add to shaders.metal:
Calculating cursor position in NDC
- Use GridConfig::grid_to_ndc to get top-left NDC of the target cell:
- let (ndc_left, ndc_top) = grid_config.grid_to_ndc(CellPosition { row, col }, &window_size, scale_factor)
- Convert beam dimensions from pixels to NDC:
- window_width_px = window_size.width as f32 * scale_factor as f32
- window_height_px = window_size.height as f32 * scale_factor as f32
- beam_w_px = cursor.style.width_px (e.g., max(2.0, 0.1 * cell_width))
- beam_h_px = grid_config.cell_height
- beam_w_ndc = 2.0 * (beam_w_px / window_width_px)
- beam_h_ndc = 2.0 * (beam_h_px / window_height_px)
- Build a triangle strip (4 vertices) for a rectangle starting at the cell’s left edge:
- TL: (ndc_left, ndc_top)
- BL: (ndc_left, ndc_top - beam_h_ndc)
- TR: (ndc_left + beam_w_ndc, ndc_top)
- BR: (ndc_left + beam_w_ndc, ndc_top - beam_h_ndc)
- Colors: set vertex color to the desired cursor color; texCoords can be zeroed (unused by solid_fragment).
- Use GridConfig::grid_to_ndc to get top-left NDC of the target cell:
Blinking (timing mechanism)
- In Renderer, add:
- cursor_blink_interval: Duration (e.g., 500 ms)
- cursor_blink_last_toggle: Instant
- cursor_blink_on: bool
- Each frame (or render pass), toggle if elapsed() >= interval:
- if last_toggle.elapsed() >= interval { cursor_blink_on = !cursor_blink_on; last_toggle = Instant::now(); }
- When input occurs (key press), reset blink:
- cursor_blink_on = true; cursor_blink_last_toggle = Instant::now();
- Only draw the cursor when cursor.visible && cursor_blink_on.
- In Renderer, add:
Integration with Terminal cursor tracking
- Wherever the Scene is built from Terminal:
- let (row, col) = terminal.cursor_position();
- scene.cursor = Some(Cursor {
pos: CellPosition { row, col },
color: Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
style: CursorStyle::Beam { width_px: max(2.0, 0.1 * grid_config.cell_width) },
visible: true
});
- Keep Terminal::max_cols in sync with grid columns:
- Ideally call terminal.update_max_cols(renderer.grid_config.cols) on resize or font change (you already compute cols in GridConfig; the Terminal method exists but is commented—re-enable or replicate).
- Render order: draw text first (existing path), then draw the cursor quad with the solid pipeline so it sits on top of glyphs.
- Minimal Renderer additions (suggested fields and setup):
- solid_pipeline_state: RenderPipelineState
- reuse text_vertex_buffer for the 4 cursor vertices (or a small dedicated buffer)
- During init:
- solid_pipeline_state = prepare_pipeline_state(&device, &library, "text_vertex", "solid_fragment")
- During draw:
- if let Some(cursor) = scene.cursor and cursor_blink_on { fill 4 vertices; set_pipeline_state(solid); set_vertex_buffer; draw_primitives(TriangleStrip, vertex_start=0, vertex_count=4); }
- Wherever the Scene is built from Terminal:
Example minimal code snippets (only where needed)
shaders.metal
- Add:
- fragment float4 solid_fragment(TextVertexOut in [[stage_in]]) { return in.color; }
- Add:
Scene
- pub struct Cursor { pub pos: CellPosition, pub color: Rgba, pub style: CursorStyle, pub visible: bool }
- pub enum CursorStyle { Beam { width_px: f32 } }
- pub struct Scene { pub background: Rgba, pub cells: Vec, pub cursor: Option }
Renderer fields
- solid_pipeline_state: RenderPipelineState
- cursor_blink_interval: std::time::Duration
- cursor_blink_last_toggle: std::time::Instant
- cursor_blink_on: bool
Renderer::new setup
- let solid_pipeline_state = prepare_pipeline_state(&device, &library, "text_vertex", "solid_fragment");
- init blink timers (interval=Duration::from_millis(500), last_toggle=Instant::now(), cursor_blink_on=true)
Per-frame
- Update blink
- If scene.cursor and blink_on:
- compute quad vertices as above; colors from scene.cursor.color; texCoords = float2(0,0)
- text_vertex_buffer.copy(&device, vertices);
- encoder.set_render_pipeline_state(&solid_pipeline_state);
- encoder.set_vertex_buffer(0, Some(&text_vertex_buffer.buffer), 0);
- encoder.draw_primitives(metal::MTLPrimitiveType::TriangleStrip, 0, 4);
Effort/scope
- M (1–3h): new fragment shader + pipeline, Scene additions, simple blink timer, quad generation, draw call after text, and wiring Terminal cursor to Scene.
- Rationale and trade-offs
- A dedicated solid-color pipeline is the simplest robust way to draw a filled beam without modifying the atlas or risking texture sampling artifacts or shader conditionals. It also keeps the text path unchanged and makes future cursor styles easy (block/underline).
- Reusing the text pipeline with a “white texel” atlas entry is possible but more brittle (needs atlas mutation and careful sampling to avoid edge bleed with linear filtering).
- Risks and guardrails
- Off-by-one/fractional pixel jitter on HiDPI: compute all positions in pixels first, then convert to NDC as shown; prefer integer beam width in px to avoid subpixel shimmering.
- Blend state: ensure the solid pipeline uses the same color attachment format and blending as text so alpha works; otherwise the beam may appear opaque when desired to be slightly translucent.
- Z order: draw the cursor after text; don’t forget to bind the correct pipeline before issuing the draw call.
- Resizes/font changes: keep Terminal::max_cols synchronized with GridConfig.cols to keep wrapping logic and cursor col in range.
- When to consider the advanced path
- You need multiple cursor styles (block, underline), inverted colors, or XOR blending across glyphs.
- Cursor needs to animate smoothly (e.g., fade in/out) rather than toggle.
- You want to avoid an extra pipeline switch for performance in very high-frequency draw loops.
- Optional advanced path
- Single-pipeline approach: reuse text pipeline by binding a dedicated 1x1 A8 texture filled with 255 for the cursor pass and sampling that in text_fragment; pass texCoords=(0.5,0.5) for all vertices. This eliminates the second pipeline at the cost of managing an extra texture.
- Style extensions: extend CursorStyle with Block and Underline; for Block, draw a full cell quad; for Underline, draw a short-height quad positioned at the baseline + small offset using GridConfig.baseline_offset for placement.
- Smooth blinking: keep a running time uniform and compute alpha via a sin() in a fragment shader, or do CPU-side alpha interpolation and update per-frame.