Skip to main content

presentar_terminal/direct/
direct_canvas.rs

1//! Direct terminal canvas implementing the Canvas trait.
2//!
3//! This writes directly to a `CellBuffer`,
4//! which is then rendered via the `DiffRenderer`.
5
6use super::cell_buffer::{CellBuffer, Modifiers};
7use crate::color::ColorMode;
8use presentar_core::{Canvas, Color, Point, Rect, TextStyle, Transform2D};
9use unicode_segmentation::UnicodeSegmentation;
10use unicode_width::UnicodeWidthStr;
11
12/// Direct terminal canvas that implements presentar's Canvas trait.
13///
14/// This canvas writes directly to a `CellBuffer`,
15/// enabling zero-allocation steady-state rendering.
16pub struct DirectTerminalCanvas<'a> {
17    /// The cell buffer to write to.
18    buffer: &'a mut CellBuffer,
19    /// Clip region stack.
20    clip_stack: Vec<ClipRect>,
21    /// Transform stack.
22    transform_stack: Vec<Transform2D>,
23    /// Current accumulated transform.
24    current_transform: Transform2D,
25    /// Color mode for palette mapping.
26    color_mode: ColorMode,
27}
28
29/// Simple clip rectangle.
30#[derive(Clone, Copy, Debug)]
31struct ClipRect {
32    x: u16,
33    y: u16,
34    width: u16,
35    height: u16,
36}
37
38impl ClipRect {
39    const fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
40        Self {
41            x,
42            y,
43            width,
44            height,
45        }
46    }
47
48    const fn contains(self, x: u16, y: u16) -> bool {
49        x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
50    }
51
52    #[allow(clippy::cast_possible_wrap)]
53    fn intersect(self, other: Self) -> Option<Self> {
54        let x1 = self.x.max(other.x);
55        let y1 = self.y.max(other.y);
56        let x2 = (self.x + self.width).min(other.x + other.width);
57        let y2 = (self.y + self.height).min(other.y + other.height);
58
59        if x2 > x1 && y2 > y1 {
60            Some(Self::new(x1, y1, x2 - x1, y2 - y1))
61        } else {
62            None
63        }
64    }
65
66    const fn is_empty(self) -> bool {
67        self.width == 0 || self.height == 0
68    }
69}
70
71impl<'a> DirectTerminalCanvas<'a> {
72    /// Create a new direct canvas.
73    #[must_use]
74    pub fn new(buffer: &'a mut CellBuffer) -> Self {
75        let clip = ClipRect::new(0, 0, buffer.width(), buffer.height());
76        Self {
77            buffer,
78            clip_stack: vec![clip],
79            transform_stack: Vec::new(),
80            current_transform: Transform2D::IDENTITY,
81            color_mode: ColorMode::detect(),
82        }
83    }
84
85    /// Create a canvas with a specific color mode.
86    #[must_use]
87    pub fn with_color_mode(mut self, mode: ColorMode) -> Self {
88        self.color_mode = mode;
89        self
90    }
91
92    /// Get the current color mode.
93    #[must_use]
94    pub const fn color_mode(&self) -> ColorMode {
95        self.color_mode
96    }
97
98    /// Get the buffer width.
99    #[must_use]
100    pub fn width(&self) -> u16 {
101        self.buffer.width()
102    }
103
104    /// Get the buffer height.
105    #[must_use]
106    pub fn height(&self) -> u16 {
107        self.buffer.height()
108    }
109
110    /// Get the current clip region.
111    fn clip(&self) -> ClipRect {
112        self.clip_stack
113            .last()
114            .copied()
115            .unwrap_or_else(|| ClipRect::new(0, 0, self.buffer.width(), self.buffer.height()))
116    }
117
118    /// Transform a point using the current transform.
119    fn transform_point(&self, p: Point) -> Point {
120        let m = &self.current_transform.matrix;
121        Point::new(
122            m[0] * p.x + m[2] * p.y + m[4],
123            m[1] * p.x + m[3] * p.y + m[5],
124        )
125    }
126
127    /// Convert a presentar Rect to terminal coordinates, applying transform and clipping.
128    fn to_terminal_rect(&self, rect: Rect) -> Option<ClipRect> {
129        let top_left = self.transform_point(Point::new(rect.x, rect.y));
130        let bottom_right =
131            self.transform_point(Point::new(rect.x + rect.width, rect.y + rect.height));
132
133        let x = top_left.x.round() as i32;
134        let y = top_left.y.round() as i32;
135        let w = (bottom_right.x - top_left.x).round() as i32;
136        let h = (bottom_right.y - top_left.y).round() as i32;
137
138        if x < 0 || y < 0 || w <= 0 || h <= 0 {
139            // Handle negative coordinates by adjusting
140            let x = x.max(0) as u16;
141            let y = y.max(0) as u16;
142            let w = w.max(0) as u16;
143            let h = h.max(0) as u16;
144
145            if w == 0 || h == 0 {
146                return None;
147            }
148
149            let rect = ClipRect::new(x, y, w, h);
150            self.clip().intersect(rect)
151        } else {
152            let rect = ClipRect::new(x as u16, y as u16, w as u16, h as u16);
153            self.clip().intersect(rect)
154        }
155    }
156
157    /// Set a cell with clipping.
158    fn set_cell(
159        &mut self,
160        x: u16,
161        y: u16,
162        symbol: &str,
163        fg: Color,
164        bg: Color,
165        modifiers: Modifiers,
166    ) {
167        let clip = self.clip();
168        if clip.contains(x, y) && x < self.buffer.width() && y < self.buffer.height() {
169            self.buffer.update(x, y, symbol, fg, bg, modifiers);
170
171            // Handle wide characters
172            let width = UnicodeWidthStr::width(symbol);
173            if width > 1 && x + 1 < self.buffer.width() {
174                if let Some(cell) = self.buffer.get_mut(x + 1, y) {
175                    cell.make_continuation();
176                }
177                self.buffer.mark_dirty(x + 1, y);
178            }
179        }
180    }
181
182    /// Convert text style to modifiers.
183    fn style_to_modifiers(style: &TextStyle) -> Modifiers {
184        let mut modifiers = Modifiers::NONE;
185        if matches!(style.weight, presentar_core::FontWeight::Bold) {
186            modifiers = modifiers.with(Modifiers::BOLD);
187        }
188        if matches!(style.style, presentar_core::FontStyle::Italic) {
189            modifiers = modifiers.with(Modifiers::ITALIC);
190        }
191        modifiers
192    }
193}
194
195impl Canvas for DirectTerminalCanvas<'_> {
196    fn fill_rect(&mut self, rect: Rect, color: Color) {
197        let Some(r) = self.to_terminal_rect(rect) else {
198            return;
199        };
200
201        // Skip if clipped to empty
202        if r.is_empty() {
203            return;
204        }
205
206        for y in r.y..r.y + r.height {
207            for x in r.x..r.x + r.width {
208                self.set_cell(x, y, " ", color, color, Modifiers::NONE);
209            }
210        }
211    }
212
213    fn stroke_rect(&mut self, rect: Rect, color: Color, _width: f32) {
214        let Some(r) = self.to_terminal_rect(rect) else {
215            return;
216        };
217
218        // Top and bottom edges
219        for x in r.x..r.x + r.width {
220            self.set_cell(x, r.y, "─", color, Color::TRANSPARENT, Modifiers::NONE);
221            if r.height > 1 {
222                self.set_cell(
223                    x,
224                    r.y + r.height - 1,
225                    "─",
226                    color,
227                    Color::TRANSPARENT,
228                    Modifiers::NONE,
229                );
230            }
231        }
232
233        // Left and right edges
234        for y in r.y..r.y + r.height {
235            self.set_cell(r.x, y, "│", color, Color::TRANSPARENT, Modifiers::NONE);
236            if r.width > 1 {
237                self.set_cell(
238                    r.x + r.width - 1,
239                    y,
240                    "│",
241                    color,
242                    Color::TRANSPARENT,
243                    Modifiers::NONE,
244                );
245            }
246        }
247
248        // Corners
249        self.set_cell(r.x, r.y, "┌", color, Color::TRANSPARENT, Modifiers::NONE);
250        if r.width > 1 {
251            self.set_cell(
252                r.x + r.width - 1,
253                r.y,
254                "┐",
255                color,
256                Color::TRANSPARENT,
257                Modifiers::NONE,
258            );
259        }
260        if r.height > 1 {
261            self.set_cell(
262                r.x,
263                r.y + r.height - 1,
264                "└",
265                color,
266                Color::TRANSPARENT,
267                Modifiers::NONE,
268            );
269            if r.width > 1 {
270                self.set_cell(
271                    r.x + r.width - 1,
272                    r.y + r.height - 1,
273                    "┘",
274                    color,
275                    Color::TRANSPARENT,
276                    Modifiers::NONE,
277                );
278            }
279        }
280    }
281
282    #[allow(clippy::cast_possible_wrap)]
283    fn draw_text(&mut self, text: &str, position: Point, style: &TextStyle) {
284        let p = self.transform_point(position);
285        let mut x = p.x.round() as i32;
286        let y = p.y.round() as i32;
287
288        if y < 0 {
289            return;
290        }
291        let y = y as u16;
292
293        let clip = self.clip();
294        if y < clip.y || y >= clip.y + clip.height {
295            return;
296        }
297
298        let modifiers = Self::style_to_modifiers(style);
299        let fg = style.color;
300
301        // Render grapheme by grapheme
302        for grapheme in text.graphemes(true) {
303            if x < 0 {
304                x += UnicodeWidthStr::width(grapheme) as i32;
305                continue;
306            }
307
308            let xu = x as u16;
309            if xu >= clip.x + clip.width {
310                break;
311            }
312
313            if xu >= clip.x {
314                // CRITICAL: Preserve existing background when drawing text.
315                // Text rendering should NOT overwrite background colors set by fill_rect.
316                // Without this, backgrounds would be lost during text rendering.
317                let existing_bg = self
318                    .buffer
319                    .get(xu, y)
320                    .map(|c| c.bg)
321                    .unwrap_or(Color::TRANSPARENT);
322                self.set_cell(xu, y, grapheme, fg, existing_bg, modifiers);
323            }
324
325            x += UnicodeWidthStr::width(grapheme) as i32;
326        }
327    }
328
329    fn draw_line(&mut self, from: Point, to: Point, color: Color, _width: f32) {
330        let from = self.transform_point(from);
331        let to = self.transform_point(to);
332
333        // Bresenham's line algorithm
334        let x0 = from.x.round() as i32;
335        let y0 = from.y.round() as i32;
336        let x1 = to.x.round() as i32;
337        let y1 = to.y.round() as i32;
338
339        let dx = (x1 - x0).abs();
340        let dy = -(y1 - y0).abs();
341        let sx = if x0 < x1 { 1 } else { -1 };
342        let sy = if y0 < y1 { 1 } else { -1 };
343        let mut err = dx + dy;
344
345        let mut x = x0;
346        let mut y = y0;
347
348        loop {
349            if x >= 0 && y >= 0 {
350                let ch = if dx > (-dy) * 2 {
351                    "─"
352                } else if (-dy) > dx * 2 {
353                    "│"
354                } else if (sx > 0) == (sy > 0) {
355                    "╲"
356                } else {
357                    "╱"
358                };
359                self.set_cell(
360                    x as u16,
361                    y as u16,
362                    ch,
363                    color,
364                    Color::TRANSPARENT,
365                    Modifiers::NONE,
366                );
367            }
368
369            if x == x1 && y == y1 {
370                break;
371            }
372
373            let e2 = 2 * err;
374            if e2 >= dy {
375                if x == x1 {
376                    break;
377                }
378                err += dy;
379                x += sx;
380            }
381            if e2 <= dx {
382                if y == y1 {
383                    break;
384                }
385                err += dx;
386                y += sy;
387            }
388        }
389    }
390
391    fn fill_circle(&mut self, center: Point, radius: f32, color: Color) {
392        let c = self.transform_point(center);
393        let r = radius.round() as i32;
394        let cx = c.x.round() as i32;
395        let cy = c.y.round() as i32;
396
397        // Midpoint circle algorithm with fill
398        for y in (cy - r)..=(cy + r) {
399            let dy = (y - cy).abs();
400            let dx = ((r * r - dy * dy) as f32).sqrt() as i32;
401            for x in (cx - dx)..=(cx + dx) {
402                if x >= 0 && y >= 0 {
403                    self.set_cell(x as u16, y as u16, " ", color, color, Modifiers::NONE);
404                }
405            }
406        }
407    }
408
409    fn stroke_circle(&mut self, center: Point, radius: f32, color: Color, _width: f32) {
410        let c = self.transform_point(center);
411        let r = radius.round() as i32;
412        let cx = c.x.round() as i32;
413        let cy = c.y.round() as i32;
414
415        // Midpoint circle algorithm
416        let mut x = r;
417        let mut y = 0;
418        let mut err = 0;
419
420        while x >= y {
421            let points = [
422                (cx + x, cy + y),
423                (cx + y, cy + x),
424                (cx - y, cy + x),
425                (cx - x, cy + y),
426                (cx - x, cy - y),
427                (cx - y, cy - x),
428                (cx + y, cy - x),
429                (cx + x, cy - y),
430            ];
431
432            for (px, py) in points {
433                if px >= 0 && py >= 0 {
434                    self.set_cell(
435                        px as u16,
436                        py as u16,
437                        "●",
438                        color,
439                        Color::TRANSPARENT,
440                        Modifiers::NONE,
441                    );
442                }
443            }
444
445            y += 1;
446            err += 1 + 2 * y;
447            if 2 * (err - x) + 1 > 0 {
448                x -= 1;
449                err += 1 - 2 * x;
450            }
451        }
452    }
453
454    fn fill_arc(
455        &mut self,
456        center: Point,
457        radius: f32,
458        start_angle: f32,
459        end_angle: f32,
460        color: Color,
461    ) {
462        let c = self.transform_point(center);
463        let cx = c.x.round() as i32;
464        let cy = c.y.round() as i32;
465
466        let steps = (radius * 4.0) as i32;
467        if steps <= 0 {
468            return;
469        }
470
471        let angle_step = (end_angle - start_angle) / steps as f32;
472
473        for i in 0..=steps {
474            let angle = start_angle + i as f32 * angle_step;
475            let x = cx + (radius * angle.cos()).round() as i32;
476            let y = cy + (radius * angle.sin()).round() as i32;
477            if x >= 0 && y >= 0 {
478                self.set_cell(x as u16, y as u16, " ", color, color, Modifiers::NONE);
479            }
480        }
481    }
482
483    fn draw_path(&mut self, points: &[Point], color: Color, width: f32) {
484        if points.len() < 2 {
485            return;
486        }
487
488        for window in points.windows(2) {
489            self.draw_line(window[0], window[1], color, width);
490        }
491    }
492
493    fn fill_polygon(&mut self, points: &[Point], color: Color) {
494        if points.len() < 3 {
495            return;
496        }
497
498        // Transform points
499        let transformed: Vec<Point> = points.iter().map(|p| self.transform_point(*p)).collect();
500
501        // Find bounding box
502        let min_y = transformed
503            .iter()
504            .map(|p| p.y.round() as i32)
505            .min()
506            .unwrap_or(0);
507        let max_y = transformed
508            .iter()
509            .map(|p| p.y.round() as i32)
510            .max()
511            .unwrap_or(0);
512
513        // Scanline fill
514        for y in min_y..=max_y {
515            let mut intersections: Vec<i32> = Vec::new();
516
517            for i in 0..transformed.len() {
518                let p1 = transformed[i];
519                let p2 = transformed[(i + 1) % transformed.len()];
520
521                let y1 = p1.y.round() as i32;
522                let y2 = p2.y.round() as i32;
523
524                if (y1 <= y && y < y2) || (y2 <= y && y < y1) {
525                    let t = (y as f32 - p1.y) / (p2.y - p1.y);
526                    let x = (p1.x + t * (p2.x - p1.x)).round() as i32;
527                    intersections.push(x);
528                }
529            }
530
531            intersections.sort_unstable();
532
533            for chunk in intersections.chunks(2) {
534                if chunk.len() == 2 {
535                    for x in chunk[0]..=chunk[1] {
536                        if x >= 0 && y >= 0 {
537                            self.set_cell(x as u16, y as u16, " ", color, color, Modifiers::NONE);
538                        }
539                    }
540                }
541            }
542        }
543    }
544
545    fn push_clip(&mut self, rect: Rect) {
546        if let Some(r) = self.to_terminal_rect(rect) {
547            if let Some(clipped) = self.clip().intersect(r) {
548                self.clip_stack.push(clipped);
549            } else {
550                // Empty clip
551                self.clip_stack.push(ClipRect::new(0, 0, 0, 0));
552            }
553        } else {
554            // Empty clip
555            self.clip_stack.push(ClipRect::new(0, 0, 0, 0));
556        }
557    }
558
559    fn pop_clip(&mut self) {
560        if self.clip_stack.len() > 1 {
561            self.clip_stack.pop();
562        }
563    }
564
565    fn push_transform(&mut self, transform: Transform2D) {
566        self.transform_stack.push(self.current_transform);
567
568        // Multiply transforms
569        let a = &self.current_transform.matrix;
570        let b = &transform.matrix;
571        self.current_transform = Transform2D {
572            matrix: [
573                a[0] * b[0] + a[2] * b[1],
574                a[1] * b[0] + a[3] * b[1],
575                a[0] * b[2] + a[2] * b[3],
576                a[1] * b[2] + a[3] * b[3],
577                a[0] * b[4] + a[2] * b[5] + a[4],
578                a[1] * b[4] + a[3] * b[5] + a[5],
579            ],
580        };
581    }
582
583    fn pop_transform(&mut self) {
584        if let Some(t) = self.transform_stack.pop() {
585            self.current_transform = t;
586        }
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use presentar_core::{FontStyle, FontWeight};
594
595    fn create_canvas(width: u16, height: u16) -> CellBuffer {
596        CellBuffer::new(width, height)
597    }
598
599    #[test]
600    fn test_canvas_creation() {
601        let mut buffer = create_canvas(80, 24);
602        let canvas = DirectTerminalCanvas::new(&mut buffer);
603        assert_eq!(canvas.width(), 80);
604        assert_eq!(canvas.height(), 24);
605    }
606
607    #[test]
608    fn test_canvas_with_color_mode() {
609        let mut buffer = create_canvas(80, 24);
610        let canvas = DirectTerminalCanvas::new(&mut buffer).with_color_mode(ColorMode::Color256);
611        assert_eq!(canvas.color_mode(), ColorMode::Color256);
612    }
613
614    #[test]
615    fn test_fill_rect() {
616        let mut buffer = create_canvas(20, 10);
617        {
618            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
619            canvas.fill_rect(Rect::new(1.0, 1.0, 3.0, 3.0), Color::RED);
620        }
621
622        let cell = buffer.get(2, 2).unwrap();
623        assert_eq!(cell.bg, Color::RED);
624    }
625
626    #[test]
627    fn test_fill_rect_outside_bounds() {
628        let mut buffer = create_canvas(10, 10);
629        {
630            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
631            canvas.fill_rect(Rect::new(100.0, 100.0, 3.0, 3.0), Color::RED);
632        }
633        // Should not panic
634    }
635
636    #[test]
637    fn test_stroke_rect() {
638        let mut buffer = create_canvas(20, 10);
639        {
640            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
641            canvas.stroke_rect(Rect::new(1.0, 1.0, 5.0, 5.0), Color::GREEN, 1.0);
642        }
643
644        assert_eq!(buffer.get(1, 1).unwrap().symbol.as_str(), "┌");
645        assert_eq!(buffer.get(5, 1).unwrap().symbol.as_str(), "┐");
646        assert_eq!(buffer.get(1, 5).unwrap().symbol.as_str(), "└");
647        assert_eq!(buffer.get(5, 5).unwrap().symbol.as_str(), "┘");
648    }
649
650    #[test]
651    fn test_stroke_rect_single_cell() {
652        let mut buffer = create_canvas(10, 10);
653        {
654            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
655            canvas.stroke_rect(Rect::new(1.0, 1.0, 1.0, 1.0), Color::GREEN, 1.0);
656        }
657    }
658
659    #[test]
660    fn test_draw_text() {
661        let mut buffer = create_canvas(20, 5);
662        {
663            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
664            canvas.draw_text("Hello", Point::new(0.0, 0.0), &TextStyle::default());
665        }
666
667        assert_eq!(buffer.get(0, 0).unwrap().symbol.as_str(), "H");
668        assert_eq!(buffer.get(1, 0).unwrap().symbol.as_str(), "e");
669    }
670
671    #[test]
672    fn test_draw_text_bold_italic() {
673        let mut buffer = create_canvas(20, 5);
674        {
675            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
676            let style = TextStyle {
677                weight: FontWeight::Bold,
678                style: FontStyle::Italic,
679                ..Default::default()
680            };
681            canvas.draw_text("Hi", Point::new(0.0, 0.0), &style);
682        }
683
684        let cell = buffer.get(0, 0).unwrap();
685        assert!(cell.modifiers.contains(Modifiers::BOLD));
686        assert!(cell.modifiers.contains(Modifiers::ITALIC));
687    }
688
689    #[test]
690    fn test_draw_text_clipped_y() {
691        let mut buffer = create_canvas(20, 5);
692        {
693            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
694            canvas.draw_text("Hello", Point::new(0.0, 10.0), &TextStyle::default());
695        }
696        // Should not render
697    }
698
699    #[test]
700    fn test_draw_text_negative_y() {
701        let mut buffer = create_canvas(20, 5);
702        {
703            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
704            canvas.draw_text("Hello", Point::new(0.0, -5.0), &TextStyle::default());
705        }
706    }
707
708    #[test]
709    fn test_draw_text_partial_clip() {
710        let mut buffer = create_canvas(5, 5);
711        {
712            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
713            canvas.draw_text("Hello World", Point::new(0.0, 0.0), &TextStyle::default());
714        }
715        // Should clip at width
716        assert_eq!(buffer.get(4, 0).unwrap().symbol.as_str(), "o");
717    }
718
719    #[test]
720    fn test_draw_text_negative_x() {
721        let mut buffer = create_canvas(10, 5);
722        {
723            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
724            canvas.draw_text("Hello", Point::new(-2.0, 0.0), &TextStyle::default());
725        }
726        // First visible should be 'l'
727        assert_eq!(buffer.get(0, 0).unwrap().symbol.as_str(), "l");
728    }
729
730    #[test]
731    fn test_draw_line_horizontal() {
732        let mut buffer = create_canvas(20, 10);
733        {
734            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
735            canvas.draw_line(
736                Point::new(0.0, 5.0),
737                Point::new(10.0, 5.0),
738                Color::WHITE,
739                1.0,
740            );
741        }
742    }
743
744    #[test]
745    fn test_draw_line_vertical() {
746        let mut buffer = create_canvas(20, 20);
747        {
748            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
749            canvas.draw_line(
750                Point::new(5.0, 0.0),
751                Point::new(5.0, 10.0),
752                Color::WHITE,
753                1.0,
754            );
755        }
756    }
757
758    #[test]
759    fn test_draw_line_diagonal() {
760        let mut buffer = create_canvas(20, 20);
761        {
762            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
763            canvas.draw_line(
764                Point::new(0.0, 0.0),
765                Point::new(10.0, 10.0),
766                Color::WHITE,
767                1.0,
768            );
769        }
770    }
771
772    #[test]
773    fn test_draw_line_same_point() {
774        let mut buffer = create_canvas(20, 20);
775        {
776            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
777            canvas.draw_line(
778                Point::new(5.0, 5.0),
779                Point::new(5.0, 5.0),
780                Color::WHITE,
781                1.0,
782            );
783        }
784    }
785
786    #[test]
787    fn test_fill_circle() {
788        let mut buffer = create_canvas(20, 20);
789        {
790            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
791            canvas.fill_circle(Point::new(10.0, 10.0), 5.0, Color::BLUE);
792        }
793        // Center should be filled
794        assert_eq!(buffer.get(10, 10).unwrap().bg, Color::BLUE);
795    }
796
797    #[test]
798    fn test_stroke_circle() {
799        let mut buffer = create_canvas(20, 20);
800        {
801            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
802            canvas.stroke_circle(Point::new(10.0, 10.0), 5.0, Color::GREEN, 1.0);
803        }
804    }
805
806    #[test]
807    fn test_fill_arc() {
808        let mut buffer = create_canvas(20, 20);
809        {
810            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
811            canvas.fill_arc(
812                Point::new(10.0, 10.0),
813                5.0,
814                0.0,
815                std::f32::consts::PI,
816                Color::RED,
817            );
818        }
819    }
820
821    #[test]
822    fn test_fill_arc_zero_radius() {
823        let mut buffer = create_canvas(20, 20);
824        {
825            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
826            canvas.fill_arc(
827                Point::new(10.0, 10.0),
828                0.0,
829                0.0,
830                std::f32::consts::PI,
831                Color::RED,
832            );
833        }
834    }
835
836    #[test]
837    fn test_draw_path() {
838        let mut buffer = create_canvas(20, 20);
839        {
840            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
841            let points = [
842                Point::new(0.0, 0.0),
843                Point::new(5.0, 5.0),
844                Point::new(10.0, 0.0),
845            ];
846            canvas.draw_path(&points, Color::WHITE, 1.0);
847        }
848    }
849
850    #[test]
851    fn test_draw_path_empty() {
852        let mut buffer = create_canvas(20, 20);
853        {
854            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
855            canvas.draw_path(&[], Color::WHITE, 1.0);
856        }
857    }
858
859    #[test]
860    fn test_draw_path_single_point() {
861        let mut buffer = create_canvas(20, 20);
862        {
863            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
864            canvas.draw_path(&[Point::new(5.0, 5.0)], Color::WHITE, 1.0);
865        }
866    }
867
868    #[test]
869    fn test_fill_polygon() {
870        let mut buffer = create_canvas(20, 20);
871        {
872            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
873            let points = [
874                Point::new(5.0, 0.0),
875                Point::new(10.0, 10.0),
876                Point::new(0.0, 10.0),
877            ];
878            canvas.fill_polygon(&points, Color::BLUE);
879        }
880    }
881
882    #[test]
883    fn test_fill_polygon_insufficient_points() {
884        let mut buffer = create_canvas(20, 20);
885        {
886            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
887            canvas.fill_polygon(&[Point::new(5.0, 5.0)], Color::BLUE);
888            canvas.fill_polygon(&[Point::new(5.0, 5.0), Point::new(10.0, 10.0)], Color::BLUE);
889        }
890    }
891
892    #[test]
893    fn test_push_pop_clip() {
894        let mut buffer = create_canvas(20, 10);
895        {
896            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
897            canvas.push_clip(Rect::new(5.0, 5.0, 10.0, 5.0));
898            canvas.fill_rect(Rect::new(0.0, 0.0, 20.0, 10.0), Color::RED);
899            canvas.pop_clip();
900        }
901
902        // Outside clip should be unchanged
903        assert_eq!(buffer.get(0, 0).unwrap().bg, Color::TRANSPARENT);
904        // Inside clip should be filled
905        assert_eq!(buffer.get(7, 7).unwrap().bg, Color::RED);
906    }
907
908    #[test]
909    fn test_push_clip_empty() {
910        let mut buffer = create_canvas(20, 10);
911        {
912            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
913            canvas.push_clip(Rect::new(100.0, 100.0, 10.0, 10.0));
914            canvas.fill_rect(Rect::new(0.0, 0.0, 20.0, 10.0), Color::RED);
915            canvas.pop_clip();
916        }
917        // Nothing should be filled
918        assert_eq!(buffer.get(0, 0).unwrap().bg, Color::TRANSPARENT);
919    }
920
921    #[test]
922    fn test_pop_clip_at_root() {
923        let mut buffer = create_canvas(20, 10);
924        {
925            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
926            canvas.pop_clip();
927            canvas.pop_clip();
928        }
929    }
930
931    #[test]
932    fn test_push_pop_transform() {
933        let mut buffer = create_canvas(20, 10);
934        {
935            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
936            canvas.push_transform(Transform2D::translate(5.0, 5.0));
937            canvas.fill_rect(Rect::new(0.0, 0.0, 2.0, 2.0), Color::BLUE);
938            canvas.pop_transform();
939        }
940
941        assert_eq!(buffer.get(5, 5).unwrap().bg, Color::BLUE);
942        assert_eq!(buffer.get(0, 0).unwrap().bg, Color::TRANSPARENT);
943    }
944
945    #[test]
946    fn test_transform_stack() {
947        let mut buffer = create_canvas(20, 20);
948        {
949            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
950            canvas.push_transform(Transform2D::translate(5.0, 5.0));
951            canvas.push_transform(Transform2D::translate(2.0, 2.0));
952            canvas.fill_rect(Rect::new(0.0, 0.0, 2.0, 2.0), Color::GREEN);
953            canvas.pop_transform();
954            canvas.pop_transform();
955        }
956
957        assert_eq!(buffer.get(7, 7).unwrap().bg, Color::GREEN);
958    }
959
960    #[test]
961    fn test_pop_transform_empty() {
962        let mut buffer = create_canvas(20, 10);
963        {
964            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
965            canvas.pop_transform();
966        }
967    }
968
969    #[test]
970    fn test_wide_character() {
971        let mut buffer = create_canvas(20, 5);
972        {
973            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
974            canvas.draw_text("日本", Point::new(0.0, 0.0), &TextStyle::default());
975        }
976
977        assert_eq!(buffer.get(0, 0).unwrap().symbol.as_str(), "日");
978        assert!(buffer.get(1, 0).unwrap().is_continuation());
979        assert_eq!(buffer.get(2, 0).unwrap().symbol.as_str(), "本");
980    }
981
982    #[test]
983    fn test_clip_rect_methods() {
984        let r1 = ClipRect::new(0, 0, 10, 10);
985        let r2 = ClipRect::new(5, 5, 10, 10);
986
987        assert!(r1.contains(5, 5));
988        assert!(!r1.contains(10, 10));
989
990        let intersect = r1.intersect(r2).unwrap();
991        assert_eq!(intersect.x, 5);
992        assert_eq!(intersect.y, 5);
993        assert_eq!(intersect.width, 5);
994        assert_eq!(intersect.height, 5);
995    }
996
997    #[test]
998    fn test_clip_rect_no_intersect() {
999        let r1 = ClipRect::new(0, 0, 5, 5);
1000        let r2 = ClipRect::new(10, 10, 5, 5);
1001
1002        assert!(r1.intersect(r2).is_none());
1003    }
1004
1005    #[test]
1006    fn test_clip_rect_empty() {
1007        let r = ClipRect::new(0, 0, 0, 0);
1008        assert!(r.is_empty());
1009    }
1010
1011    #[test]
1012    fn test_negative_rect() {
1013        let mut buffer = create_canvas(20, 10);
1014        {
1015            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1016            canvas.fill_rect(Rect::new(-5.0, -5.0, 10.0, 10.0), Color::RED);
1017        }
1018        // Should still fill visible portion
1019        assert_eq!(buffer.get(0, 0).unwrap().bg, Color::RED);
1020    }
1021
1022    #[test]
1023    fn test_to_terminal_rect_zero_size() {
1024        let mut buffer = create_canvas(20, 10);
1025        {
1026            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1027            canvas.fill_rect(Rect::new(5.0, 5.0, 0.0, 0.0), Color::RED);
1028        }
1029    }
1030
1031    // =========================================================================
1032    // REGRESSION TESTS
1033    // =========================================================================
1034
1035    /// Regression test for selection artifact bug.
1036    ///
1037    /// Bug: When a row was selected (blue background), then deselected,
1038    /// the blue background persisted because draw_text was overwriting
1039    /// the fill_rect background with TRANSPARENT.
1040    ///
1041    /// Fix: draw_text now preserves the existing background color instead
1042    /// of setting it to TRANSPARENT.
1043    #[test]
1044    fn test_draw_text_preserves_fill_rect_background() {
1045        let mut buffer = create_canvas(20, 5);
1046        {
1047            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1048
1049            // Step 1: Fill row with selection background (simulates selected row)
1050            canvas.fill_rect(Rect::new(0.0, 1.0, 20.0, 1.0), Color::BLUE);
1051
1052            // Step 2: Draw text on top (simulates rendering row content)
1053            canvas.draw_text("Process 1234", Point::new(0.0, 1.0), &TextStyle::default());
1054
1055            // CRITICAL: Text characters MUST preserve the BLUE background
1056            // This was the bug - draw_text was setting bg to TRANSPARENT
1057            let cell = buffer.get(0, 1).unwrap();
1058            assert_eq!(
1059                cell.bg,
1060                Color::BLUE,
1061                "Text draw MUST preserve fill_rect background, got {:?}",
1062                cell.bg
1063            );
1064        }
1065    }
1066
1067    /// Regression test: Selection change clears old selection properly.
1068    ///
1069    /// Simulates the full selection lifecycle:
1070    /// 1. Row 1 selected (highlighted bg)
1071    /// 2. Selection moves to row 2
1072    /// 3. Row 1 should now have dimmed bg (not highlighted!)
1073    #[test]
1074    fn test_selection_change_clears_old_background() {
1075        let dimmed_bg = Color::new(0.08, 0.08, 0.1, 1.0);
1076        let selection_bg = Color::new(0.15, 0.12, 0.22, 1.0); // ttop-style subtle selection
1077
1078        let mut buffer = create_canvas(20, 5);
1079
1080        // Frame 1: Row 1 selected
1081        {
1082            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1083            canvas.fill_rect(Rect::new(0.0, 1.0, 20.0, 1.0), selection_bg);
1084            canvas.draw_text("Selected Row", Point::new(0.0, 1.0), &TextStyle::default());
1085
1086            canvas.fill_rect(Rect::new(0.0, 2.0, 20.0, 1.0), dimmed_bg);
1087            canvas.draw_text("Normal Row", Point::new(0.0, 2.0), &TextStyle::default());
1088        }
1089
1090        // Verify frame 1
1091        assert_eq!(buffer.get(0, 1).unwrap().bg, selection_bg);
1092        assert_eq!(buffer.get(0, 2).unwrap().bg, dimmed_bg);
1093
1094        // Frame 2: Selection moves to row 2
1095        {
1096            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1097            // Row 1 now dimmed
1098            canvas.fill_rect(Rect::new(0.0, 1.0, 20.0, 1.0), dimmed_bg);
1099            canvas.draw_text("Normal Row", Point::new(0.0, 1.0), &TextStyle::default());
1100
1101            // Row 2 now selected
1102            canvas.fill_rect(Rect::new(0.0, 2.0, 20.0, 1.0), selection_bg);
1103            canvas.draw_text("Selected Row", Point::new(0.0, 2.0), &TextStyle::default());
1104        }
1105
1106        // CRITICAL: Row 1 must now have dimmed background, NOT selection_bg
1107        assert_eq!(
1108            buffer.get(0, 1).unwrap().bg,
1109            dimmed_bg,
1110            "Old selection must be cleared to dimmed_bg"
1111        );
1112        assert_eq!(
1113            buffer.get(0, 2).unwrap().bg,
1114            selection_bg,
1115            "New selection must have selection_bg"
1116        );
1117    }
1118
1119    /// Test that text color is set correctly while preserving background.
1120    #[test]
1121    fn test_draw_text_sets_foreground_color() {
1122        let mut buffer = create_canvas(20, 5);
1123        {
1124            let mut canvas = DirectTerminalCanvas::new(&mut buffer);
1125            canvas.fill_rect(Rect::new(0.0, 0.0, 20.0, 1.0), Color::BLACK);
1126
1127            let style = TextStyle {
1128                color: Color::YELLOW,
1129                ..Default::default()
1130            };
1131            canvas.draw_text("Test", Point::new(0.0, 0.0), &style);
1132        }
1133
1134        let cell = buffer.get(0, 0).unwrap();
1135        assert_eq!(cell.fg, Color::YELLOW);
1136        assert_eq!(cell.bg, Color::BLACK);
1137    }
1138}