diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 11d0906..860b9c4 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -34,9 +34,10 @@ use glyphon::{ use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan, - cell::Color as CellColor, + cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; +use wgpu::util::DeviceExt; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; @@ -60,6 +61,51 @@ const BG: wgpu::Color = wgpu::Color { a: 1.0, }; +const TEXT_LEFT: f32 = 16.0; +const TEXT_TOP: f32 = 16.0; +const TEXT_RIGHT_GAP: f32 = 10.0; +const MINIMAP_WIDTH: f32 = 48.0; +const MINIMAP_RIGHT: f32 = 12.0; +const MINIMAP_TOP: f32 = 12.0; +const MINIMAP_BOTTOM: f32 = 12.0; +const MINIMAP_MIN_SURFACE_WIDTH: u32 = 180; +const MINIMAP_MIN_THUMB_HEIGHT: f32 = 18.0; +const MINIMAP_H_PAD: f32 = 3.0; +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; +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]; +const MINIMAP_THUMB_BORDER: [f32; 4] = [0.86, 0.86, 0.96, 0.7]; +const QUAD_SHADER: &str = r" +struct VertexOut { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main( + @location(0) pos: vec2, + @location(1) color: vec4, +) -> VertexOut { + var out: VertexOut; + out.pos = vec4(pos, 0.0, 1.0); + out.color = color; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + return in.color; +} +"; + +const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24; +const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] = + wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; + /// Text the hello-world (and attach-pre-snapshot / attach-failed) /// modes render. Once the daemon's `BufferSnapshot` arrives the /// rendered text becomes the rope contents instead. @@ -172,10 +218,15 @@ struct State { viewport: Viewport, atlas: TextAtlas, text_renderer: TextRenderer, + quad_renderer: QuadRenderer, buffer: Buffer, /// What the buffer is currently shaped to. Held so we can detect /// no-op updates and skip the re-shape. current_text: String, + /// Code-shape data derived from `current_text`, used to give the + /// minimap horizontal structure even though `FileStyleSummary` + /// carries only one dominant style per line. + current_line_shapes: Vec, /// Local CRDT replica seeded by `BufferSnapshot`. `None` in /// hello-world mode or before the first snapshot arrives in /// attach mode. @@ -211,6 +262,22 @@ struct State { /// bytes into `current_text`; source byte ranges for style spans and /// decorations therefore remain source-relative. current_adornments: Vec, + /// Whole-file per-line dominant styles for the minimap (session 7). + /// The daemon emits this summary on first frame and after CRDT + /// generation changes. We keep the latest summary until a newer one + /// arrives, matching the ownership rule used by style spans, + /// decorations, and inline adornments. + current_summary: Option, +} + +struct QuadRenderer { + pipeline: wgpu::RenderPipeline, +} + +#[derive(Clone, Debug)] +struct FileStyleSummaryState { + generation: u64, + lines: Vec, } impl ApplicationHandler for App { @@ -305,6 +372,61 @@ struct ViewportSend { generation: u64, } +impl QuadRenderer { + fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pmacs-gpu quad shader"), + source: wgpu::ShaderSource::Wgsl(QUAD_SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pmacs-gpu quad pipeline layout"), + bind_group_layouts: &[], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pmacs-gpu quad pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: QUAD_VERTEX_STRIDE, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &QUAD_VERTEX_ATTRS, + }], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + Self { pipeline } + } + + fn render<'pass>( + &'pass self, + pass: &mut wgpu::RenderPass<'pass>, + vertex_buffer: &'pass wgpu::Buffer, + vertex_count: u32, + ) { + pass.set_pipeline(&self.pipeline); + pass.set_vertex_buffer(0, vertex_buffer.slice(..)); + pass.draw(0..vertex_count, 0..1); + } +} + impl State { fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self { let window = Arc::new( @@ -373,6 +495,7 @@ impl State { let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format); let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + let quad_renderer = QuadRenderer::new(&device, surface_format); // Smaller font in attach mode (file contents tend to be more // than one line); larger only fits "hello, pmacs"-shaped @@ -404,13 +527,16 @@ impl State { viewport, atlas, text_renderer, + quad_renderer, buffer, current_text: initial_text.to_owned(), + current_line_shapes: minimap_line_shapes(initial_text), loro_doc: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), current_adornments: Vec::new(), + current_summary: None, } } @@ -434,6 +560,7 @@ impl State { } self.current_text.clear(); self.current_text.push_str(text); + self.current_line_shapes = minimap_line_shapes(text); self.reshape(); true } @@ -460,13 +587,15 @@ impl State { /// reshape the display projection. Session 6 consumes `AtOffset` /// text adornments (LSP inlay hints); other placements/content /// remain explicitly deferred. + /// - `FileStyleSummary` — replace the whole-file minimap summary. + /// Session 7 renders it as a right-side per-line style overview + /// plus a visible-window affordance. /// - `Goodbye` — surfaced via the reader thread's clean-EOF path, /// not handled here. /// - /// Remaining `SemanticFrame` variants (`FileStyleSummary`) plus - /// the grid variants (`CellDelta`, `Cursor`, `CursorByte`) and - /// presence updates are ignored in session 6 — they land in - /// subsequent Phase A sessions. + /// Remaining semantic variants plus the grid variants (`CellDelta`, + /// `Cursor`, `CursorByte`) and presence updates are ignored in + /// session 7 — they land in subsequent Phase A sessions. fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option { match msg { InstanceMessage::BufferSnapshot { @@ -488,6 +617,7 @@ impl State { self.current_spans.clear(); self.current_decorations.clear(); self.current_adornments.clear(); + self.current_summary = None; if !self.set_text(&text) { self.reshape(); } @@ -586,10 +716,38 @@ impl State { self.reshape(); None } + InstanceMessage::FileStyleSummary { + buffer_id, + generation, + lines, + } => { + self.apply_file_style_summary(buffer_id, generation, lines); + None + } _ => None, } } + fn apply_file_style_summary( + &mut self, + buffer_id: BufferId, + generation: u64, + lines: Vec, + ) { + if self.current_buffer_id != Some(buffer_id) { + return; + } + if self + .current_summary + .as_ref() + .is_some_and(|summary| generation < summary.generation) + { + return; + } + self.current_summary = Some(FileStyleSummaryState { generation, lines }); + self.window.request_redraw(); + } + /// `full = true` path: discard prior styling, take the segments' /// spans as authoritative for the declared viewport. fn replace_style_spans(&mut self, segments: Vec) { @@ -808,6 +966,17 @@ impl State { let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); + let minimap_vertices = self.minimap_vertex_bytes(); + let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; + let minimap_buffer = (!minimap_vertices.is_empty()).then(|| { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pmacs-gpu minimap vertices"), + contents: &minimap_vertices, + usage: wgpu::BufferUsages::VERTEX, + }) + }); + let text_bounds_right = self.text_bounds_right(); self.text_renderer .prepare( @@ -818,13 +987,13 @@ impl State { &self.viewport, [TextArea { buffer: &self.buffer, - left: 16.0, - top: 16.0, + left: TEXT_LEFT, + top: TEXT_TOP, scale: 1.0, bounds: TextBounds { left: 0, top: 0, - right: self.config.width.cast_signed(), + right: text_bounds_right, bottom: self.config.height.cast_signed(), }, default_color: Color::rgb(230, 230, 235), @@ -859,11 +1028,63 @@ impl State { self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); + if let Some(vertex_buffer) = minimap_buffer.as_ref() { + self.quad_renderer + .render(&mut pass, vertex_buffer, minimap_vertex_count); + } } self.queue.submit(std::iter::once(encoder.finish())); frame.present(); self.atlas.trim(); } + + fn text_bounds_right(&self) -> i32 { + if self.has_minimap() { + minimap_left(self.config.width).map_or(self.config.width.cast_signed(), |left| { + (left - TEXT_RIGHT_GAP).max(TEXT_LEFT + 1.0).round() as i32 + }) + } else { + self.config.width.cast_signed() + } + } + + fn has_minimap(&self) -> bool { + self.current_summary + .as_ref() + .is_some_and(|summary| !summary.lines.is_empty()) + && minimap_left(self.config.width).is_some() + } + + fn minimap_vertex_bytes(&self) -> Vec { + let Some(summary) = self.current_summary.as_ref() else { + return Vec::new(); + }; + let visible_lines = estimated_visible_lines(self.config.height); + let rects = minimap_rects( + &summary.lines, + &self.current_line_shapes, + self.config.width, + self.config.height, + 0, + visible_lines, + ); + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } +} + +#[derive(Clone, Copy, Debug)] +struct MinimapRect { + x: f32, + y: f32, + w: f32, + h: f32, + color: [f32; 4], +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct MinimapLineShape { + indent_cols: usize, + content_cols: usize, } #[derive(Clone, Debug)] @@ -872,6 +1093,304 @@ struct RichChunk { color: Option, } +fn minimap_left(surface_width: u32) -> Option { + if surface_width < MINIMAP_MIN_SURFACE_WIDTH { + return None; + } + let x = surface_width as f32 - MINIMAP_RIGHT - MINIMAP_WIDTH; + (x > TEXT_LEFT + TEXT_RIGHT_GAP).then_some(x) +} + +fn estimated_visible_lines(surface_height: u32) -> usize { + ((surface_height as f32 - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT) + .ceil() + .max(1.0) as usize +} + +fn minimap_rects( + lines: &[CellStyle], + shapes: &[MinimapLineShape], + surface_width: u32, + surface_height: u32, + first_visible_line: usize, + visible_lines: usize, +) -> Vec { + let Some(x) = minimap_left(surface_width) else { + return Vec::new(); + }; + if lines.is_empty() || surface_height as f32 <= MINIMAP_TOP + MINIMAP_BOTTOM { + return Vec::new(); + } + let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM; + let pixel_rows = height.round().max(1.0) as usize; + let mut rects = Vec::new(); + rects.push(MinimapRect { + x, + y: MINIMAP_TOP, + w: MINIMAP_WIDTH, + h: height, + color: MINIMAP_BG, + }); + + if lines.len() <= pixel_rows { + for (idx, style) in lines.iter().copied().enumerate() { + let y0 = MINIMAP_TOP + idx as f32 * height / lines.len() as f32; + let y1 = MINIMAP_TOP + (idx + 1) as f32 * height / lines.len() as f32; + if let Some(shape) = shapes + .get(idx) + .copied() + .filter(MinimapLineShape::has_content) + { + push_minimap_line_stroke( + &mut rects, + x, + y0, + (y1 - y0).clamp(1.0, MINIMAP_MAX_LINE_STROKE_HEIGHT), + minimap_style_color(style), + shape, + ); + } + } + } else { + for row in 0..pixel_rows { + let line_start = row * lines.len() / pixel_rows; + let line_end = ((row + 1) * lines.len()) + .div_ceil(pixel_rows) + .min(lines.len()); + let y0 = MINIMAP_TOP + row as f32 * height / pixel_rows as f32; + let y1 = MINIMAP_TOP + (row + 1) as f32 * height / pixel_rows as f32; + if let Some(shape) = dominant_line_shape(shapes, line_start, line_end) { + push_minimap_line_stroke( + &mut rects, + x, + y0, + (y1 - y0).max(1.0), + minimap_style_color(dominant_line_style(&lines[line_start..line_end])), + shape, + ); + } + } + } + + push_minimap_thumb( + &mut rects, + x, + height, + lines.len(), + first_visible_line, + visible_lines, + ); + rects +} + +impl MinimapLineShape { + fn has_content(&self) -> bool { + self.content_cols > 0 + } +} + +fn push_minimap_line_stroke( + rects: &mut Vec, + x: f32, + y: f32, + h: f32, + color: [f32; 4], + shape: MinimapLineShape, +) { + if !shape.has_content() { + return; + } + let available = (MINIMAP_WIDTH - MINIMAP_H_PAD * 2.0).max(MINIMAP_MIN_STROKE_WIDTH); + let indent = (shape.indent_cols as f32 / MINIMAP_CODE_COLS * available) + .min((available - MINIMAP_MIN_STROKE_WIDTH).max(0.0)); + let width = (shape.content_cols as f32 / MINIMAP_CODE_COLS * available).clamp( + MINIMAP_MIN_STROKE_WIDTH, + (available - indent).max(MINIMAP_MIN_STROKE_WIDTH), + ); + rects.push(MinimapRect { + x: x + MINIMAP_H_PAD + indent, + y, + w: width, + h, + color, + }); +} + +fn push_minimap_thumb( + rects: &mut Vec, + x: f32, + minimap_height: f32, + line_count: usize, + first_visible_line: usize, + visible_lines: usize, +) { + let start_line = first_visible_line.min(line_count); + let end_line = start_line + .saturating_add(visible_lines.max(1)) + .min(line_count); + let mut y0 = MINIMAP_TOP + start_line as f32 * minimap_height / line_count as f32; + let mut y1 = MINIMAP_TOP + end_line as f32 * minimap_height / line_count as f32; + if y1 - y0 < MINIMAP_MIN_THUMB_HEIGHT { + let mid = (y0 + y1) * 0.5; + y0 = (mid - MINIMAP_MIN_THUMB_HEIGHT * 0.5).max(MINIMAP_TOP); + y1 = (y0 + MINIMAP_MIN_THUMB_HEIGHT).min(MINIMAP_TOP + minimap_height); + y0 = (y1 - MINIMAP_MIN_THUMB_HEIGHT).max(MINIMAP_TOP); + } + let h = (y1 - y0).max(1.0); + rects.push(MinimapRect { + x, + y: y0, + w: MINIMAP_WIDTH, + h, + color: MINIMAP_THUMB_FILL, + }); + rects.push(MinimapRect { + x, + y: y0, + w: 1.0, + h, + color: MINIMAP_THUMB_BORDER, + }); + rects.push(MinimapRect { + x: x + MINIMAP_WIDTH - 1.0, + y: y0, + w: 1.0, + h, + color: MINIMAP_THUMB_BORDER, + }); +} + +fn dominant_line_style(lines: &[CellStyle]) -> CellStyle { + if lines.is_empty() { + return CellStyle::default(); + } + let mut tally: Vec<(CellStyle, usize)> = Vec::new(); + for style in lines { + if let Some((_, count)) = tally.iter_mut().find(|(candidate, _)| candidate == style) { + *count += 1; + } else { + tally.push((*style, 1)); + } + } + tally + .into_iter() + .max_by_key(|(_, count)| *count) + .map_or(CellStyle::default(), |(style, _)| style) +} + +fn dominant_line_shape( + shapes: &[MinimapLineShape], + line_start: usize, + line_end: usize, +) -> Option { + let slice = shapes.get(line_start.min(shapes.len())..line_end.min(shapes.len()))?; + let mut count = 0usize; + let mut indent_sum = 0usize; + let mut content_sum = 0usize; + for shape in slice.iter().filter(|shape| shape.has_content()) { + count += 1; + indent_sum += shape.indent_cols; + content_sum += shape.content_cols; + } + (count > 0).then_some(MinimapLineShape { + indent_cols: indent_sum / count, + content_cols: content_sum.div_ceil(count), + }) +} + +fn minimap_line_shapes(text: &str) -> Vec { + text.split('\n').map(minimap_line_shape).collect() +} + +fn minimap_line_shape(line: &str) -> MinimapLineShape { + let mut total_cols = 0usize; + let mut indent_cols = 0usize; + let mut in_indent = true; + for ch in line.trim_end_matches('\r').chars() { + let next_col = advance_minimap_col(total_cols, ch); + if in_indent && (ch == ' ' || ch == '\t') { + indent_cols = next_col; + } else { + in_indent = false; + } + total_cols = next_col; + } + MinimapLineShape { + indent_cols, + content_cols: total_cols.saturating_sub(indent_cols), + } +} + +fn advance_minimap_col(col: usize, ch: char) -> usize { + if ch == '\t' { + ((col / 4) + 1) * 4 + } else { + col + 1 + } +} + +fn minimap_style_color(style: CellStyle) -> [f32; 4] { + match style.fg { + CellColor::Default => MINIMAP_DEFAULT_LINE, + CellColor::Rgb(r, g, b) => rgb_to_minimap_color(r, g, b), + CellColor::Indexed(idx) => { + let c = indexed_to_glyphon(idx); + rgb_to_minimap_color(c.r(), c.g(), c.b()) + } + } +} + +fn rgb_to_minimap_color(r: u8, g: u8, b: u8) -> [f32; 4] { + [ + f32::from(r) / 255.0, + f32::from(g) / 255.0, + f32::from(b) / 255.0, + 0.9, + ] +} + +fn rects_to_vertex_bytes( + rects: &[MinimapRect], + surface_width: u32, + surface_height: u32, +) -> Vec { + let mut bytes = Vec::with_capacity(rects.len() * 6 * QUAD_VERTEX_STRIDE as usize); + for rect in rects { + push_rect_vertices(&mut bytes, *rect, surface_width, surface_height); + } + bytes +} + +fn push_rect_vertices(bytes: &mut Vec, rect: MinimapRect, width: u32, height: u32) { + if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 { + return; + } + let x0 = px_to_ndc_x(rect.x, width); + let x1 = px_to_ndc_x(rect.x + rect.w, width); + let y0 = px_to_ndc_y(rect.y, height); + let y1 = px_to_ndc_y(rect.y + rect.h, height); + push_quad_vertex(bytes, x0, y0, rect.color); + push_quad_vertex(bytes, x1, y0, rect.color); + push_quad_vertex(bytes, x1, y1, rect.color); + push_quad_vertex(bytes, x0, y0, rect.color); + push_quad_vertex(bytes, x1, y1, rect.color); + push_quad_vertex(bytes, x0, y1, rect.color); +} + +fn push_quad_vertex(bytes: &mut Vec, x: f32, y: f32, color: [f32; 4]) { + for value in [x, y, color[0], color[1], color[2], color[3]] { + bytes.extend_from_slice(&value.to_ne_bytes()); + } +} + +fn px_to_ndc_x(x: f32, width: u32) -> f32 { + x / width as f32 * 2.0 - 1.0 +} + +fn px_to_ndc_y(y: f32, height: u32) -> f32 { + 1.0 - y / height as f32 * 2.0 +} + /// Build the rich-text chunks fed to glyphon. Source chunks come from /// `text` and retain source-byte styling; inline adornments create /// extra chunks at their anchors and therefore do not shift any source @@ -1094,6 +1613,21 @@ mod tests { } } + fn color_close(a: [f32; 4], b: [f32; 4]) -> bool { + a.into_iter() + .zip(b) + .all(|(left, right)| (left - right).abs() < 0.001) + } + + fn f32_at(bytes: &[u8], index: usize) -> f32 { + let start = index * std::mem::size_of::(); + f32::from_ne_bytes( + bytes[start..start + std::mem::size_of::()] + .try_into() + .expect("f32 bytes"), + ) + } + fn span(start: u64, end: u64, fg: CellColor) -> StyleSpan { StyleSpan { range: ByteRange { start, end }, @@ -1210,4 +1744,131 @@ mod tests { assert_eq!(chunk_texts(&chunks), vec!["abcd", "X"]); } + + #[test] + fn minimap_rects_project_line_styles_as_right_side_bands() { + let red = style_with_fg(CellColor::Rgb(255, 0, 0)); + let blue = style_with_fg(CellColor::Rgb(0, 0, 255)); + let shapes = minimap_line_shapes("alpha\nbeta\ngamma\ndelta"); + let rects = minimap_rects(&[red, red, blue, blue], &shapes, 240, 80, 0, 2); + + assert!( + rects + .iter() + .any(|r| color_close(r.color, rgb_to_minimap_color(255, 0, 0))), + "red line summary band should render" + ); + assert!( + rects + .iter() + .any(|r| color_close(r.color, rgb_to_minimap_color(0, 0, 255))), + "blue line summary band should render" + ); + assert!( + rects + .iter() + .any(|r| color_close(r.color, MINIMAP_THUMB_FILL)), + "visible-window affordance should render" + ); + } + + #[test] + fn minimap_rects_bucket_large_files_to_pixel_rows() { + let red = style_with_fg(CellColor::Rgb(255, 0, 0)); + let blue = style_with_fg(CellColor::Rgb(0, 0, 255)); + let lines: Vec<_> = (0..10_000) + .map(|idx| if idx % 2 == 0 { red } else { blue }) + .collect(); + let shapes = vec![ + MinimapLineShape { + indent_cols: 0, + content_cols: 40, + }; + lines.len() + ]; + + let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30); + + let pixel_rows = (120.0 - MINIMAP_TOP - MINIMAP_BOTTOM).round() as usize; + assert!( + rects.len() <= pixel_rows + 4, + "minimap must bucket by visible rows, not emit per source line" + ); + } + + #[test] + fn minimap_hidden_when_surface_is_too_narrow() { + let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))]; + let shapes = [MinimapLineShape { + indent_cols: 0, + content_cols: 10, + }]; + + assert!(minimap_rects(&lines, &shapes, 120, 120, 0, 1).is_empty()); + } + + #[test] + fn minimap_rects_use_line_shape_for_indent_and_length() { + let red = style_with_fg(CellColor::Rgb(255, 0, 0)); + let shapes = [ + MinimapLineShape { + indent_cols: 0, + content_cols: 80, + }, + MinimapLineShape { + indent_cols: 24, + content_cols: 12, + }, + ]; + + let rects = minimap_rects(&[red, red], &shapes, 240, 80, 0, 2); + let strokes: Vec<_> = rects + .iter() + .filter(|r| color_close(r.color, rgb_to_minimap_color(255, 0, 0))) + .collect(); + + assert_eq!(strokes.len(), 2); + assert!( + strokes[1].x > strokes[0].x, + "indented source line should shift right in the minimap" + ); + assert!( + strokes[1].w < strokes[0].w, + "shorter source line should draw a shorter minimap stroke" + ); + } + + #[test] + fn minimap_line_shapes_preserve_trailing_empty_line() { + let shapes = minimap_line_shapes("a\n"); + + assert_eq!( + shapes, + vec![ + MinimapLineShape { + indent_cols: 0, + content_cols: 1, + }, + MinimapLineShape::default(), + ] + ); + } + + #[test] + fn minimap_rects_encode_six_vertices_per_quad() { + let rect = MinimapRect { + x: 0.0, + y: 0.0, + w: 10.0, + h: 10.0, + color: rgb_to_minimap_color(255, 0, 0), + }; + + let bytes = rects_to_vertex_bytes(&[rect], 100, 100); + + assert_eq!(bytes.len(), 6 * QUAD_VERTEX_STRIDE as usize); + assert!((f32_at(&bytes, 0) + 1.0).abs() < 0.001); + assert!((f32_at(&bytes, 1) - 1.0).abs() < 0.001); + assert!((f32_at(&bytes, 2) - 1.0).abs() < 0.001); + } }