Skip to main content

dartboard_tui/
lib.rs

1//! Reusable ratatui widget that renders a dartboard `Canvas`.
2//!
3//! `CanvasWidget` borrows a `CanvasWidgetState` and draws the canvas cells,
4//! optional selection overlay, and optional floating-selection overlay into a
5//! ratatui buffer. The widget carries only styling; per-session view data
6//! lives in the state. Each consumer (standalone app, late-sh integration,
7//! etc.) builds its own state per render, similar to how ratatui's
8//! `Paragraph::new(text).style(...)` works.
9
10use dartboard_core::{Canvas, CellValue, Pos, RgbColor};
11use ratatui::buffer::Buffer;
12use ratatui::layout::Rect;
13use ratatui::style::Color;
14use ratatui::widgets::Widget;
15
16/// Styling hooks for `CanvasWidget`. Each consumer supplies colors from its
17/// own theme. Defaults are sensible for a dark terminal background.
18#[derive(Debug, Clone, Copy)]
19pub struct CanvasStyle {
20    /// Background color painted for cells outside the canvas bounds.
21    pub oob_bg: Color,
22    /// Fallback foreground when a cell has no explicit `fg`.
23    pub default_glyph_fg: Color,
24    /// Background color for cells inside an active selection.
25    pub selection_bg: Color,
26    /// Foreground color for cells inside an active selection.
27    pub selection_fg: Color,
28    /// Background color for cells covered by a floating selection.
29    pub floating_bg: Color,
30}
31
32impl Default for CanvasStyle {
33    fn default() -> Self {
34        Self {
35            oob_bg: Color::Rgb(16, 16, 16),
36            default_glyph_fg: Color::Rgb(136, 128, 120),
37            selection_bg: Color::Rgb(64, 40, 24),
38            selection_fg: Color::Rgb(208, 166, 89),
39            floating_bg: Color::Rgb(32, 48, 64),
40        }
41    }
42}
43
44/// Selection shape as it appears to the renderer. Mirrors the shape used by
45/// standalone dartboard; kept here (rather than in `dartboard-core`) because
46/// the shape is strictly about how selection is drawn, not how it's stored.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SelectionShape {
49    Rect,
50    Ellipse,
51}
52
53/// View of an in-flight selection. `anchor` is the fixed corner, `cursor` is
54/// the moving corner.
55#[derive(Debug, Clone, Copy)]
56pub struct SelectionView {
57    pub anchor: Pos,
58    pub cursor: Pos,
59    pub shape: SelectionShape,
60}
61
62impl SelectionView {
63    /// Bounding rectangle (min/max inclusive) of the selection.
64    fn bounds(self) -> ((usize, usize), (usize, usize)) {
65        let min_x = self.anchor.x.min(self.cursor.x);
66        let max_x = self.anchor.x.max(self.cursor.x);
67        let min_y = self.anchor.y.min(self.cursor.y);
68        let max_y = self.anchor.y.max(self.cursor.y);
69        ((min_x, min_y), (max_x, max_y))
70    }
71
72    /// Whether `pos` falls inside the selected region.
73    pub fn contains(&self, pos: Pos) -> bool {
74        let ((min_x, min_y), (max_x, max_y)) = self.bounds();
75        if pos.x < min_x || pos.x > max_x || pos.y < min_y || pos.y > max_y {
76            return false;
77        }
78        match self.shape {
79            SelectionShape::Rect => true,
80            SelectionShape::Ellipse => {
81                let w = max_x - min_x + 1;
82                let h = max_y - min_y + 1;
83                if w <= 1 || h <= 1 {
84                    return true;
85                }
86                let px = pos.x as f64 + 0.5;
87                let py = pos.y as f64 + 0.5;
88                let cx = (min_x + max_x + 1) as f64 / 2.0;
89                let cy = (min_y + max_y + 1) as f64 / 2.0;
90                let rx = w as f64 / 2.0;
91                let ry = h as f64 / 2.0;
92                let dx = (px - cx) / rx;
93                let dy = (py - cy) / ry;
94                dx * dx + dy * dy <= 1.0
95            }
96        }
97    }
98}
99
100fn selection_covers_cell(canvas: &Canvas, selection: SelectionView, pos: Pos) -> bool {
101    if selection.contains(pos) {
102        return true;
103    }
104    let Some(origin) = canvas.glyph_origin(pos) else {
105        return false;
106    };
107    let Some(glyph) = canvas.glyph_at(origin) else {
108        return false;
109    };
110    (0..glyph.width).any(|dx| {
111        selection.contains(Pos {
112            x: origin.x + dx,
113            y: origin.y,
114        })
115    })
116}
117
118/// View of a floating selection pinned to `anchor`. Consumers pass a flat
119/// cells slice of length `width * height` (row-major). `None` entries are
120/// rendered as background in opaque mode and skipped in transparent mode.
121#[derive(Debug, Clone, Copy)]
122pub struct FloatingView<'a> {
123    pub width: usize,
124    pub height: usize,
125    pub cells: &'a [Option<CellValue>],
126    pub anchor: Pos,
127    pub transparent: bool,
128    pub active_color: RgbColor,
129}
130
131impl<'a> FloatingView<'a> {
132    fn cell(&self, cx: usize, cy: usize) -> Option<CellValue> {
133        self.cells[cy * self.width + cx]
134    }
135}
136
137/// Per-render data the widget reads. The `canvas` reference and optional
138/// selection/floating views are what make each session's view distinct.
139#[derive(Debug)]
140pub struct CanvasWidgetState<'a> {
141    pub canvas: &'a Canvas,
142    pub viewport_origin: Pos,
143    pub selection: Option<SelectionView>,
144    pub floating: Option<FloatingView<'a>>,
145}
146
147impl<'a> CanvasWidgetState<'a> {
148    pub fn new(canvas: &'a Canvas, viewport_origin: Pos) -> Self {
149        Self {
150            canvas,
151            viewport_origin,
152            selection: None,
153            floating: None,
154        }
155    }
156
157    pub fn selection(mut self, selection: SelectionView) -> Self {
158        self.selection = Some(selection);
159        self
160    }
161
162    pub fn floating(mut self, floating: FloatingView<'a>) -> Self {
163        self.floating = Some(floating);
164        self
165    }
166}
167
168/// Widget that renders the canvas + overlays.
169pub struct CanvasWidget<'a> {
170    state: &'a CanvasWidgetState<'a>,
171    style: CanvasStyle,
172}
173
174impl<'a> CanvasWidget<'a> {
175    pub fn new(state: &'a CanvasWidgetState<'a>) -> Self {
176        Self {
177            state,
178            style: CanvasStyle::default(),
179        }
180    }
181
182    pub fn style(mut self, style: CanvasStyle) -> Self {
183        self.style = style;
184        self
185    }
186}
187
188impl<'a> Widget for CanvasWidget<'a> {
189    fn render(self, area: Rect, buf: &mut Buffer) {
190        let canvas = self.state.canvas;
191        let cw = canvas.width;
192        let ch = canvas.height;
193        let ox = self.state.viewport_origin.x;
194        let oy = self.state.viewport_origin.y;
195        let selection = self.state.selection;
196
197        for dy in 0..area.height {
198            for dx in 0..area.width {
199                let x = ox + dx as usize;
200                let y = oy + dy as usize;
201                let cell = &mut buf[(area.x + dx, area.y + dy)];
202
203                if x >= cw || y >= ch {
204                    cell.set_bg(self.style.oob_bg);
205                    continue;
206                }
207
208                let pos = Pos { x, y };
209                let cell_value = canvas.cell(pos);
210                let glyph_fg = canvas
211                    .fg(pos)
212                    .map(rgb_to_color)
213                    .unwrap_or(self.style.default_glyph_fg);
214
215                if selection
216                    .map(|selection| selection_covers_cell(canvas, selection, pos))
217                    .unwrap_or(false)
218                {
219                    cell.set_bg(self.style.selection_bg)
220                        .set_fg(self.style.selection_fg);
221                    if let Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) = cell_value {
222                        cell.set_char(ch);
223                    }
224                } else if let Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) = cell_value {
225                    cell.set_char(ch).set_fg(glyph_fg);
226                }
227            }
228        }
229
230        if let Some(floating) = self.state.floating {
231            let active_fg = rgb_to_color(floating.active_color);
232            for cy in 0..floating.height {
233                for cx in 0..floating.width {
234                    let canvas_x = floating.anchor.x + cx;
235                    let canvas_y = floating.anchor.y + cy;
236
237                    if canvas_x >= cw || canvas_y >= ch || canvas_x < ox || canvas_y < oy {
238                        continue;
239                    }
240
241                    let dx = (canvas_x - ox) as u16;
242                    let dy = (canvas_y - oy) as u16;
243                    if dx >= area.width || dy >= area.height {
244                        continue;
245                    }
246
247                    let cell = &mut buf[(area.x + dx, area.y + dy)];
248                    match floating.cell(cx, cy) {
249                        Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => {
250                            cell.set_char(ch)
251                                .set_bg(self.style.floating_bg)
252                                .set_fg(active_fg);
253                        }
254                        Some(CellValue::WideCont) => {
255                            cell.set_bg(self.style.floating_bg);
256                        }
257                        None if !floating.transparent => {
258                            cell.set_char(' ').set_bg(self.style.floating_bg);
259                        }
260                        None => {}
261                    }
262                }
263            }
264        }
265    }
266}
267
268fn rgb_to_color(c: RgbColor) -> Color {
269    Color::Rgb(c.r, c.g, c.b)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use dartboard_core::{Canvas, CanvasOp, Pos, RgbColor};
276    use ratatui::buffer::Buffer;
277    use ratatui::layout::Rect;
278    use ratatui::widgets::Widget;
279
280    fn blank_canvas(width: usize, height: usize) -> Canvas {
281        Canvas::with_size(width, height)
282    }
283
284    #[test]
285    fn renders_empty_canvas_without_panic() {
286        let canvas = blank_canvas(4, 3);
287        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 });
288        let widget = CanvasWidget::new(&state);
289        let area = Rect::new(0, 0, 4, 3);
290        let mut buf = Buffer::empty(area);
291        widget.render(area, &mut buf);
292    }
293
294    #[test]
295    fn renders_painted_cell_with_its_color() {
296        let mut canvas = blank_canvas(4, 2);
297        canvas.apply(&CanvasOp::PaintCell {
298            pos: Pos { x: 1, y: 0 },
299            ch: 'X',
300            fg: RgbColor::new(200, 100, 50),
301        });
302        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 });
303        let widget = CanvasWidget::new(&state);
304        let area = Rect::new(0, 0, 4, 2);
305        let mut buf = Buffer::empty(area);
306        widget.render(area, &mut buf);
307
308        let cell = &buf[(1, 0)];
309        assert_eq!(cell.symbol(), "X");
310        assert_eq!(cell.fg, Color::Rgb(200, 100, 50));
311    }
312
313    #[test]
314    fn out_of_bounds_area_gets_oob_bg() {
315        let canvas = blank_canvas(2, 2);
316        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 });
317        let widget = CanvasWidget::new(&state);
318        let area = Rect::new(0, 0, 4, 3);
319        let mut buf = Buffer::empty(area);
320        widget.render(area, &mut buf);
321
322        // Cell (3, 2) is outside the 2x2 canvas — should be OOB_BG.
323        assert_eq!(buf[(3, 2)].bg, CanvasStyle::default().oob_bg);
324    }
325
326    #[test]
327    fn selection_rect_highlights_bounded_cells() {
328        let canvas = blank_canvas(5, 5);
329        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 }).selection(SelectionView {
330            anchor: Pos { x: 1, y: 1 },
331            cursor: Pos { x: 2, y: 2 },
332            shape: SelectionShape::Rect,
333        });
334        let widget = CanvasWidget::new(&state);
335        let area = Rect::new(0, 0, 5, 5);
336        let mut buf = Buffer::empty(area);
337        widget.render(area, &mut buf);
338
339        let style = CanvasStyle::default();
340        assert_eq!(buf[(1, 1)].bg, style.selection_bg);
341        assert_eq!(buf[(2, 2)].bg, style.selection_bg);
342        assert_ne!(buf[(0, 0)].bg, style.selection_bg);
343        assert_ne!(buf[(3, 3)].bg, style.selection_bg);
344    }
345
346    #[test]
347    fn selection_highlights_both_halves_when_wide_origin_is_selected() {
348        let mut canvas = blank_canvas(6, 1);
349        canvas.set(Pos { x: 2, y: 0 }, '🌱');
350        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 }).selection(SelectionView {
351            anchor: Pos { x: 2, y: 0 },
352            cursor: Pos { x: 2, y: 0 },
353            shape: SelectionShape::Rect,
354        });
355        let widget = CanvasWidget::new(&state);
356        let area = Rect::new(0, 0, 6, 1);
357        let mut buf = Buffer::empty(area);
358        widget.render(area, &mut buf);
359
360        let style = CanvasStyle::default();
361        assert_eq!(buf[(2, 0)].bg, style.selection_bg);
362        assert_eq!(buf[(3, 0)].bg, style.selection_bg);
363    }
364
365    #[test]
366    fn selection_highlights_both_halves_when_wide_continuation_is_selected() {
367        let mut canvas = blank_canvas(6, 1);
368        canvas.set(Pos { x: 2, y: 0 }, '🌱');
369        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 }).selection(SelectionView {
370            anchor: Pos { x: 3, y: 0 },
371            cursor: Pos { x: 3, y: 0 },
372            shape: SelectionShape::Rect,
373        });
374        let widget = CanvasWidget::new(&state);
375        let area = Rect::new(0, 0, 6, 1);
376        let mut buf = Buffer::empty(area);
377        widget.render(area, &mut buf);
378
379        let style = CanvasStyle::default();
380        assert_eq!(buf[(2, 0)].bg, style.selection_bg);
381        assert_eq!(buf[(3, 0)].bg, style.selection_bg);
382    }
383
384    #[test]
385    fn floating_view_stamps_cells_at_anchor() {
386        let canvas = blank_canvas(5, 5);
387        let cells = vec![
388            Some(CellValue::Narrow('A')),
389            None,
390            Some(CellValue::Narrow('B')),
391        ];
392        let state = CanvasWidgetState::new(&canvas, Pos { x: 0, y: 0 }).floating(FloatingView {
393            width: 3,
394            height: 1,
395            cells: &cells,
396            anchor: Pos { x: 1, y: 0 },
397            transparent: true,
398            active_color: RgbColor::new(255, 0, 0),
399        });
400        let widget = CanvasWidget::new(&state);
401        let area = Rect::new(0, 0, 5, 5);
402        let mut buf = Buffer::empty(area);
403        widget.render(area, &mut buf);
404
405        assert_eq!(buf[(1, 0)].symbol(), "A");
406        assert_eq!(buf[(3, 0)].symbol(), "B");
407    }
408}