Skip to main content

ratatui_widgets/canvas/
line.rs

1use line_clipping::{LineSegment, Point, Window, cohen_sutherland};
2use ratatui_core::style::Color;
3
4use crate::canvas::{Painter, Shape};
5
6/// A line from `(x1, y1)` to `(x2, y2)` with the given color
7///
8/// # Examples
9///
10/// ```rust
11/// # use ratatui_core::style::Color;
12/// # use ratatui_widgets::canvas::{Canvas, Line};
13/// Canvas::default().paint(|ctx| {
14///     ctx.draw(&Line::new(0.0, 0.0, 1.0, 0.0, Color::Red));
15///     ctx.draw(&Line::new(1.0, 0.0, 0.5, 1.0, Color::Red));
16///     ctx.draw(&Line::new(0.5, 1.0, 0.0, 0.0, Color::Red));
17/// });
18/// ```
19#[derive(Debug, Default, Clone, PartialEq)]
20pub struct Line {
21    /// `x` of the starting point
22    pub x1: f64,
23    /// `y` of the starting point
24    pub y1: f64,
25    /// `x` of the ending point
26    pub x2: f64,
27    /// `y` of the ending point
28    pub y2: f64,
29    /// Color of the line
30    pub color: Color,
31}
32
33impl Line {
34    /// Create a new line from `(x1, y1)` to `(x2, y2)` with the given color
35    pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64, color: Color) -> Self {
36        Self {
37            x1,
38            y1,
39            x2,
40            y2,
41            color,
42        }
43    }
44}
45
46impl Shape for Line {
47    #[expect(clippy::similar_names)]
48    fn draw(&self, painter: &mut Painter) {
49        let (x_bounds, y_bounds) = painter.bounds();
50        let Some((world_x1, world_y1, world_x2, world_y2)) =
51            clip_line(x_bounds, y_bounds, self.x1, self.y1, self.x2, self.y2)
52        else {
53            return;
54        };
55        let Some((x1, y1)) = painter.get_point(world_x1, world_y1) else {
56            return;
57        };
58        let Some((x2, y2)) = painter.get_point(world_x2, world_y2) else {
59            return;
60        };
61
62        draw_line(painter, x1, y1, x2, y2, self.color);
63    }
64}
65
66fn clip_line(
67    &[xmin, xmax]: &[f64; 2],
68    &[ymin, ymax]: &[f64; 2],
69    x1: f64,
70    y1: f64,
71    x2: f64,
72    y2: f64,
73) -> Option<(f64, f64, f64, f64)> {
74    if let Some(LineSegment {
75        p1: Point { x: x1, y: y1 },
76        p2: Point { x: x2, y: y2 },
77    }) = cohen_sutherland::clip_line(
78        LineSegment::new(Point::new(x1, y1), Point::new(x2, y2)),
79        Window::new(xmin, xmax, ymin, ymax),
80    ) {
81        Some((x1, y1, x2, y2))
82    } else {
83        None
84    }
85}
86
87pub(super) fn draw_line(
88    painter: &mut Painter,
89    x1: usize,
90    y1: usize,
91    x2: usize,
92    y2: usize,
93    color: Color,
94) {
95    for_each_line_point(x1, y1, x2, y2, |x, y| painter.paint(x, y, color));
96}
97
98/// Calls `f(x, y)` for each pixel on the Bresenham line from `(x1, y1)` to `(x2, y2)`.  
99pub(super) fn for_each_line_point<F>(x1: usize, y1: usize, x2: usize, y2: usize, mut f: F)
100where
101    F: FnMut(usize, usize),
102{
103    let dx = x2.abs_diff(x1);
104    let dy = y2.abs_diff(y1);
105
106    if dx == 0 {
107        for y in y1.min(y2)..=y1.max(y2) {
108            f(x1, y);
109        }
110    } else if dy == 0 {
111        for x in x1.min(x2)..=x1.max(x2) {
112            f(x, y1);
113        }
114    } else if dy < dx {
115        if x1 > x2 {
116            for_each_line_point_low(x2, y2, x1, y1, f);
117        } else {
118            for_each_line_point_low(x1, y1, x2, y2, f);
119        }
120    } else if y1 > y2 {
121        for_each_line_point_high(x2, y2, x1, y1, f);
122    } else {
123        for_each_line_point_high(x1, y1, x2, y2, f);
124    }
125}
126
127fn for_each_line_point_low<F>(x1: usize, y1: usize, x2: usize, y2: usize, mut f: F)
128where
129    F: FnMut(usize, usize),
130{
131    let dx = (x2 - x1) as isize;
132    let dy = (y2 as isize - y1 as isize).abs();
133    let mut d = 2 * dy - dx;
134    let mut y = y1;
135    for x in x1..=x2 {
136        f(x, y);
137        if d > 0 {
138            y = if y1 > y2 {
139                y.saturating_sub(1)
140            } else {
141                y.saturating_add(1)
142            };
143            d -= 2 * dx;
144        }
145        d += 2 * dy;
146    }
147}
148
149fn for_each_line_point_high<F>(x1: usize, y1: usize, x2: usize, y2: usize, mut f: F)
150where
151    F: FnMut(usize, usize),
152{
153    let dx = (x2 as isize - x1 as isize).abs();
154    let dy = (y2 - y1) as isize;
155    let mut d = 2 * dx - dy;
156    let mut x = x1;
157    for y in y1..=y2 {
158        f(x, y);
159        if d > 0 {
160            x = if x1 > x2 {
161                x.saturating_sub(1)
162            } else {
163                x.saturating_add(1)
164            };
165            d -= 2 * dy;
166        }
167        d += 2 * dx;
168    }
169}
170
171/// A filled line from `(x1, y1)` to `(x2, y2)` that fills the area under/over the line
172/// to `fill_to_y` with the given color.
173///
174/// This is useful for creating area charts or filling the space under a line graph.
175#[derive(Debug, Clone, PartialEq)]
176pub struct FilledLine {
177    /// `x` of the starting point
178    pub x1: f64,
179    /// `y` of the starting point
180    pub y1: f64,
181    /// `x` of the ending point
182    pub x2: f64,
183    /// `y` of the ending point
184    pub y2: f64,
185    /// Y-coordinate to fill to (fills area between the line and this Y value)
186    pub fill_to_y: f64,
187    /// Color of the line and filled area
188    pub color: Color,
189}
190
191impl FilledLine {
192    /// Create a new filled line from `(x1, y1)` to `(x2, y2)` that fills to `fill_to_y`
193    pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64, fill_to_y: f64, color: Color) -> Self {
194        Self {
195            x1,
196            y1,
197            x2,
198            y2,
199            fill_to_y,
200            color,
201        }
202    }
203}
204
205impl Shape for FilledLine {
206    #[expect(clippy::similar_names)]
207    fn draw(&self, painter: &mut Painter) {
208        let (x_bounds, y_bounds) = painter.bounds();
209        let Some((world_x1, world_y1, world_x2, world_y2)) =
210            clip_line(x_bounds, y_bounds, self.x1, self.y1, self.x2, self.y2)
211        else {
212            return;
213        };
214        let Some((x1, y1)) = painter.get_point(world_x1, world_y1) else {
215            return;
216        };
217        let Some((x2, y2)) = painter.get_point(world_x2, world_y2) else {
218            return;
219        };
220
221        let y_fill = self.fill_to_y.clamp(y_bounds[0], y_bounds[1]);
222        let Some((_, y_fill)) = painter.get_point(world_x1, y_fill) else {
223            return;
224        };
225
226        for_each_line_point(x1, y1, x2, y2, |x, y| {
227            let start = y.min(y_fill);
228            let end = y.max(y_fill);
229            for y in start..=end {
230                painter.paint(x, y, self.color);
231            }
232        });
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use ratatui_core::buffer::Buffer;
239    use ratatui_core::layout::Rect;
240    use ratatui_core::style::Style;
241    use ratatui_core::symbols::Marker;
242    use ratatui_core::widgets::Widget;
243    use rstest::rstest;
244
245    use super::*;
246    use crate::canvas::Canvas;
247
248    #[rstest]
249    #[case::off_grid1(&Line::new(-1.0, 0.0, -1.0, 10.0, Color::Red), ["          "; 10])]
250    #[case::off_grid2(&Line::new(0.0, -1.0, 10.0, -1.0, Color::Red), ["          "; 10])]
251    #[case::off_grid3(&Line::new(-10.0, 5.0, -1.0, 5.0, Color::Red), ["          "; 10])]
252    #[case::off_grid4(&Line::new(5.0, 11.0, 5.0, 20.0, Color::Red), ["          "; 10])]
253    #[case::off_grid5(&Line::new(-10.0, 0.0, 5.0, 0.0, Color::Red), [
254        "          ",
255        "          ",
256        "          ",
257        "          ",
258        "          ",
259        "          ",
260        "          ",
261        "          ",
262        "          ",
263        "••••••    ",
264    ])]
265    #[case::off_grid6(&Line::new(-1.0, -1.0, 10.0, 10.0, Color::Red), [
266        "         •",
267        "        • ",
268        "       •  ",
269        "      •   ",
270        "     •    ",
271        "    •     ",
272        "   •      ",
273        "  •       ",
274        " •        ",
275        "•         ",
276    ])]
277    #[case::off_grid7(&Line::new(0.0, 0.0, 11.0, 11.0, Color::Red), [
278        "         •",
279        "        • ",
280        "       •  ",
281        "      •   ",
282        "     •    ",
283        "    •     ",
284        "   •      ",
285        "  •       ",
286        " •        ",
287        "•         ",
288    ])]
289    #[case::off_grid8(&Line::new(-1.0, -1.0, 11.0, 11.0, Color::Red), [
290        "         •",
291        "        • ",
292        "       •  ",
293        "      •   ",
294        "     •    ",
295        "    •     ",
296        "   •      ",
297        "  •       ",
298        " •        ",
299        "•         ",
300    ])]
301    #[case::horizontal1(&Line::new(0.0, 0.0, 10.0, 0.0, Color::Red), [
302        "          ",
303        "          ",
304        "          ",
305        "          ",
306        "          ",
307        "          ",
308        "          ",
309        "          ",
310        "          ",
311        "••••••••••",
312    ])]
313    #[case::horizontal2(&Line::new(10.0, 10.0, 0.0, 10.0, Color::Red), [
314        "••••••••••",
315        "          ",
316        "          ",
317        "          ",
318        "          ",
319        "          ",
320        "          ",
321        "          ",
322        "          ",
323        "          ",
324    ])]
325    #[case::vertical1(&Line::new(0.0, 0.0, 0.0, 10.0, Color::Red), ["•         "; 10])]
326    #[case::vertical2(&Line::new(10.0, 10.0, 10.0, 0.0, Color::Red), ["         •"; 10])]
327    // dy < dx, x1 < x2
328    #[case::diagonal1(&Line::new(0.0, 0.0, 10.0, 5.0, Color::Red), [
329        "          ",
330        "          ",
331        "          ",
332        "          ",
333        "          ",
334        "        ••",
335        "      ••  ",
336        "    ••    ",
337        "  ••      ",
338        "••        ",
339    ])]
340    // dy < dx, x1 > x2
341    #[case::diagonal2(&Line::new(10.0, 0.0, 0.0, 5.0, Color::Red), [
342        "          ",
343        "          ",
344        "          ",
345        "          ",
346        "          ",
347        "••        ",
348        "  ••      ",
349        "    ••    ",
350        "      ••  ",
351        "        ••",
352    ])]
353    // dy > dx, y1 < y2
354    #[case::diagonal3(&Line::new(0.0, 0.0, 5.0, 10.0, Color::Red), [
355        "     •    ",
356        "    •     ",
357        "    •     ",
358        "   •      ",
359        "   •      ",
360        "  •       ",
361        "  •       ",
362        " •        ",
363        " •        ",
364        "•         ",
365    ])]
366    // dy > dx, y1 > y2
367    #[case::diagonal4(&Line::new(0.0, 10.0, 5.0, 0.0, Color::Red), [
368        "•         ",
369        " •        ",
370        " •        ",
371        "  •       ",
372        "  •       ",
373        "   •      ",
374        "   •      ",
375        "    •     ",
376        "    •     ",
377        "     •    ",
378    ])]
379    fn tests<'expected_line, ExpectedLines>(#[case] line: &Line, #[case] expected: ExpectedLines)
380    where
381        ExpectedLines: IntoIterator,
382        ExpectedLines::Item: Into<ratatui_core::text::Line<'expected_line>>,
383    {
384        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
385        let canvas = Canvas::default()
386            .marker(Marker::Dot)
387            .x_bounds([0.0, 10.0])
388            .y_bounds([0.0, 10.0])
389            .paint(|context| context.draw(line));
390        canvas.render(buffer.area, &mut buffer);
391
392        let mut expected = Buffer::with_lines(expected);
393        for cell in &mut expected.content {
394            if cell.symbol() == "•" {
395                cell.set_style(Style::new().red());
396            }
397        }
398        assert_eq!(buffer, expected);
399    }
400
401    #[rstest]
402    #[case::filled_off_grid1(&FilledLine::new(-1.0, 0.0, -1.0, 10.0, 0.0, Color::Red), ["          "; 10])]
403    #[case::filled_off_grid2(&FilledLine::new(0.0, -1.0, 10.0, -1.0, 0.0, Color::Red), ["          "; 10])]
404    #[case::filled_off_grid3(&FilledLine::new(-10.0, 5.0, -1.0, 5.0, 0.0, Color::Red), ["          "; 10])]
405    #[case::filled_off_grid4(&FilledLine::new(5.0, 11.0, 5.0, 20.0, 0.0, Color::Red), ["          "; 10])]
406    #[case::filled_off_grid5(&FilledLine::new(-10.0, 0.0, 5.0, 0.0, -10.0, Color::Red), [
407            "          ",
408            "          ",
409            "          ",
410            "          ",
411            "          ",
412            "          ",
413            "          ",
414            "          ",
415            "          ",
416            "••••••    ",
417        ])]
418    #[case::filled_off_grid6(&FilledLine::new(0.0, 0.0, 10.0, 10.0, 0.0, Color::Red), [
419            "         •",
420            "        ••",
421            "       •••",
422            "      ••••",
423            "     •••••",
424            "    ••••••",
425            "   •••••••",
426            "  ••••••••",
427            " •••••••••",
428            "••••••••••",
429        ])]
430    #[case::filled_off_grid7(&FilledLine::new(0.0, 0.0, 11.0, 11.0, 0.0, Color::Red), [
431            "         •",
432            "        ••",
433            "       •••",
434            "      ••••",
435            "     •••••",
436            "    ••••••",
437            "   •••••••",
438            "  ••••••••",
439            " •••••••••",
440            "••••••••••",
441        ])]
442    #[case::filled_off_grid8(&FilledLine::new(-1.0, -1.0, 11.0, 11.0, 0.0, Color::Red), [
443            "         •",
444            "        ••",
445            "       •••",
446            "      ••••",
447            "     •••••",
448            "    ••••••",
449            "   •••••••",
450            "  ••••••••",
451            " •••••••••",
452            "••••••••••",
453        ])]
454    #[case::filled_horizontal1(&FilledLine::new(0.0, 0.0, 10.0, 0.0, 0.0, Color::Red), [
455            "          ",
456            "          ",
457            "          ",
458            "          ",
459            "          ",
460            "          ",
461            "          ",
462            "          ",
463            "          ",
464            "••••••••••",
465        ])]
466    #[case::filled_horizontal2(&FilledLine::new(0.0, 0.0, 10.0, 0.0, 10.0, Color::Red), [
467            "••••••••••",
468            "••••••••••",
469            "••••••••••",
470            "••••••••••",
471            "••••••••••",
472            "••••••••••",
473            "••••••••••",
474            "••••••••••",
475            "••••••••••",
476            "••••••••••",
477        ])]
478    #[case::filled_horizontal3(&FilledLine::new(10.0, 10.0, 0.0, 10.0, 10.0, Color::Red), [
479            "••••••••••",
480            "          ",
481            "          ",
482            "          ",
483            "          ",
484            "          ",
485            "          ",
486            "          ",
487            "          ",
488            "          ",
489        ])]
490    #[case::filled_horizontal4(&FilledLine::new(10.0, 10.0, 0.0, 10.0, 0.0, Color::Red), [
491            "••••••••••",
492            "••••••••••",
493            "••••••••••",
494            "••••••••••",
495            "••••••••••",
496            "••••••••••",
497            "••••••••••",
498            "••••••••••",
499            "••••••••••",
500            "••••••••••",
501        ])]
502    #[case::filled_vertical1(&FilledLine::new(0.0, 0.0, 0.0, 10.0, 0.0, Color::Red), ["•         "; 10])]
503    #[case::filled_vertical2(&FilledLine::new(10.0, 10.0, 10.0, 0.0, 0.0, Color::Red), ["         •"; 10])]
504    // dy < dx, x1 < x2
505    #[case::filled_diagonal1(&FilledLine::new(0.0, 0.0, 10.0, 5.0, 0.0, Color::Red), [
506            "          ",
507            "          ",
508            "          ",
509            "          ",
510            "          ",
511            "        ••",
512            "      ••••",
513            "    ••••••",
514            "  ••••••••",
515            "••••••••••",
516        ])]
517    // dy < dx, x1 > x2
518    #[case::filled_diagonal2(&FilledLine::new(10.0, 0.0, 0.0, 5.0, 0.0, Color::Red), [
519            "          ",
520            "          ",
521            "          ",
522            "          ",
523            "          ",
524            "••        ",
525            "••••      ",
526            "••••••    ",
527            "••••••••  ",
528            "••••••••••",
529        ])]
530    // dy > dx, y1 < y2
531    #[case::filled_diagonal3(&FilledLine::new(0.0, 0.0, 5.0, 10.0, 0.0, Color::Red), [
532            "     •    ",
533            "    ••    ",
534            "    ••    ",
535            "   •••    ",
536            "   •••    ",
537            "  ••••    ",
538            "  ••••    ",
539            " •••••    ",
540            " •••••    ",
541            "••••••    ",
542        ])]
543    // dy > dx, y1 > y2
544    #[case::filled_diagonal4(&FilledLine::new(0.0, 10.0, 5.0, 0.0, 0.0, Color::Red), [
545            "•         ",
546            "••        ",
547            "••        ",
548            "•••       ",
549            "•••       ",
550            "••••      ",
551            "••••      ",
552            "•••••     ",
553            "•••••     ",
554            "••••••    ",
555        ])]
556    #[case::filled_split1(&FilledLine::new(0.0, 0.0, 10.0, 10.0, 5.0, Color::Red), [
557            "         •",
558            "        ••",
559            "       •••",
560            "      ••••",
561            "     •••••",
562            "••••••••••",
563            "••••      ",
564            "•••       ",
565            "••        ",
566            "•         ",
567        ])]
568    #[case::filled_split2(&FilledLine::new(0.0, 0.0, 10.0, 10.0, 7.0, Color::Red), [
569            "         •",
570            "        ••",
571            "       •••",
572            "••••••••••",
573            "••••••    ",
574            "•••••     ",
575            "••••      ",
576            "•••       ",
577            "••        ",
578            "•         ",
579        ])]
580    fn tests_filled<'expected_line, ExpectedLines>(
581        #[case] line: &FilledLine,
582        #[case] expected: ExpectedLines,
583    ) where
584        ExpectedLines: IntoIterator,
585        ExpectedLines::Item: Into<ratatui_core::text::Line<'expected_line>>,
586    {
587        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
588        let canvas = Canvas::default()
589            .marker(Marker::Dot)
590            .x_bounds([0.0, 10.0])
591            .y_bounds([0.0, 10.0])
592            .paint(|context| context.draw(line));
593        canvas.render(buffer.area, &mut buffer);
594
595        let mut expected = Buffer::with_lines(expected);
596        for cell in &mut expected.content {
597            if cell.symbol() == "•" {
598                cell.set_style(Style::new().red());
599            }
600        }
601        assert_eq!(buffer, expected);
602    }
603}