1use dartboard_core::{Canvas, CellValue, Pos, RgbColor};
11use ratatui::buffer::Buffer;
12use ratatui::layout::Rect;
13use ratatui::style::Color;
14use ratatui::widgets::Widget;
15
16#[derive(Debug, Clone, Copy)]
19pub struct CanvasStyle {
20 pub oob_bg: Color,
22 pub default_glyph_fg: Color,
24 pub selection_bg: Color,
26 pub selection_fg: Color,
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SelectionShape {
49 Rect,
50 Ellipse,
51}
52
53#[derive(Debug, Clone, Copy)]
56pub struct SelectionView {
57 pub anchor: Pos,
58 pub cursor: Pos,
59 pub shape: SelectionShape,
60}
61
62impl SelectionView {
63 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 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#[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#[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
168pub 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 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}