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#[derive(Debug, Default, Clone, PartialEq)]
8pub struct Line {
9    /// `x` of the starting point
10    pub x1: f64,
11    /// `y` of the starting point
12    pub y1: f64,
13    /// `x` of the ending point
14    pub x2: f64,
15    /// `y` of the ending point
16    pub y2: f64,
17    /// Color of the line
18    pub color: Color,
19}
20
21impl Line {
22    /// Create a new line from `(x1, y1)` to `(x2, y2)` with the given color
23    pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64, color: Color) -> Self {
24        Self {
25            x1,
26            y1,
27            x2,
28            y2,
29            color,
30        }
31    }
32}
33
34impl Shape for Line {
35    #[expect(clippy::similar_names)]
36    fn draw(&self, painter: &mut Painter) {
37        let (x_bounds, y_bounds) = painter.bounds();
38        let Some((world_x1, world_y1, world_x2, world_y2)) =
39            clip_line(x_bounds, y_bounds, self.x1, self.y1, self.x2, self.y2)
40        else {
41            return;
42        };
43        let Some((x1, y1)) = painter.get_point(world_x1, world_y1) else {
44            return;
45        };
46        let Some((x2, y2)) = painter.get_point(world_x2, world_y2) else {
47            return;
48        };
49
50        let (dx, x_range) = if x2 >= x1 {
51            (x2 - x1, x1..=x2)
52        } else {
53            (x1 - x2, x2..=x1)
54        };
55        let (dy, y_range) = if y2 >= y1 {
56            (y2 - y1, y1..=y2)
57        } else {
58            (y1 - y2, y2..=y1)
59        };
60
61        if dx == 0 {
62            for y in y_range {
63                painter.paint(x1, y, self.color);
64            }
65        } else if dy == 0 {
66            for x in x_range {
67                painter.paint(x, y1, self.color);
68            }
69        } else if dy < dx {
70            if x1 > x2 {
71                draw_line_low(painter, x2, y2, x1, y1, self.color);
72            } else {
73                draw_line_low(painter, x1, y1, x2, y2, self.color);
74            }
75        } else if y1 > y2 {
76            draw_line_high(painter, x2, y2, x1, y1, self.color);
77        } else {
78            draw_line_high(painter, x1, y1, x2, y2, self.color);
79        }
80    }
81}
82
83fn clip_line(
84    &[xmin, xmax]: &[f64; 2],
85    &[ymin, ymax]: &[f64; 2],
86    x1: f64,
87    y1: f64,
88    x2: f64,
89    y2: f64,
90) -> Option<(f64, f64, f64, f64)> {
91    if let Some(LineSegment {
92        p1: Point { x: x1, y: y1 },
93        p2: Point { x: x2, y: y2 },
94    }) = cohen_sutherland::clip_line(
95        LineSegment::new(Point::new(x1, y1), Point::new(x2, y2)),
96        Window::new(xmin, xmax, ymin, ymax),
97    ) {
98        Some((x1, y1, x2, y2))
99    } else {
100        None
101    }
102}
103
104fn draw_line_low(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) {
105    let dx = (x2 - x1) as isize;
106    let dy = (y2 as isize - y1 as isize).abs();
107    let mut d = 2 * dy - dx;
108    let mut y = y1;
109    for x in x1..=x2 {
110        painter.paint(x, y, color);
111        if d > 0 {
112            y = if y1 > y2 {
113                y.saturating_sub(1)
114            } else {
115                y.saturating_add(1)
116            };
117            d -= 2 * dx;
118        }
119        d += 2 * dy;
120    }
121}
122
123fn draw_line_high(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) {
124    let dx = (x2 as isize - x1 as isize).abs();
125    let dy = (y2 - y1) as isize;
126    let mut d = 2 * dx - dy;
127    let mut x = x1;
128    for y in y1..=y2 {
129        painter.paint(x, y, color);
130        if d > 0 {
131            x = if x1 > x2 {
132                x.saturating_sub(1)
133            } else {
134                x.saturating_add(1)
135            };
136            d -= 2 * dy;
137        }
138        d += 2 * dx;
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use ratatui_core::buffer::Buffer;
145    use ratatui_core::layout::Rect;
146    use ratatui_core::style::Style;
147    use ratatui_core::symbols::Marker;
148    use ratatui_core::widgets::Widget;
149    use rstest::rstest;
150
151    use super::*;
152    use crate::canvas::Canvas;
153
154    #[rstest]
155    #[case::off_grid1(&Line::new(-1.0, 0.0, -1.0, 10.0, Color::Red), ["          "; 10])]
156    #[case::off_grid2(&Line::new(0.0, -1.0, 10.0, -1.0, Color::Red), ["          "; 10])]
157    #[case::off_grid3(&Line::new(-10.0, 5.0, -1.0, 5.0, Color::Red), ["          "; 10])]
158    #[case::off_grid4(&Line::new(5.0, 11.0, 5.0, 20.0, Color::Red), ["          "; 10])]
159    #[case::off_grid5(&Line::new(-10.0, 0.0, 5.0, 0.0, Color::Red), [
160        "          ",
161        "          ",
162        "          ",
163        "          ",
164        "          ",
165        "          ",
166        "          ",
167        "          ",
168        "          ",
169        "••••••    ",
170    ])]
171    #[case::off_grid6(&Line::new(-1.0, -1.0, 10.0, 10.0, Color::Red), [
172        "         •",
173        "        • ",
174        "       •  ",
175        "      •   ",
176        "     •    ",
177        "    •     ",
178        "   •      ",
179        "  •       ",
180        " •        ",
181        "•         ",
182    ])]
183    #[case::off_grid7(&Line::new(0.0, 0.0, 11.0, 11.0, Color::Red), [
184        "         •",
185        "        • ",
186        "       •  ",
187        "      •   ",
188        "     •    ",
189        "    •     ",
190        "   •      ",
191        "  •       ",
192        " •        ",
193        "•         ",
194    ])]
195    #[case::off_grid8(&Line::new(-1.0, -1.0, 11.0, 11.0, Color::Red), [
196        "         •",
197        "        • ",
198        "       •  ",
199        "      •   ",
200        "     •    ",
201        "    •     ",
202        "   •      ",
203        "  •       ",
204        " •        ",
205        "•         ",
206    ])]
207    #[case::horizontal1(&Line::new(0.0, 0.0, 10.0, 0.0, Color::Red), [
208        "          ",
209        "          ",
210        "          ",
211        "          ",
212        "          ",
213        "          ",
214        "          ",
215        "          ",
216        "          ",
217        "••••••••••",
218    ])]
219    #[case::horizontal2(&Line::new(10.0, 10.0, 0.0, 10.0, Color::Red), [
220        "••••••••••",
221        "          ",
222        "          ",
223        "          ",
224        "          ",
225        "          ",
226        "          ",
227        "          ",
228        "          ",
229        "          ",
230    ])]
231    #[case::vertical1(&Line::new(0.0, 0.0, 0.0, 10.0, Color::Red), ["•         "; 10])]
232    #[case::vertical2(&Line::new(10.0, 10.0, 10.0, 0.0, Color::Red), ["         •"; 10])]
233    // dy < dx, x1 < x2
234    #[case::diagonal1(&Line::new(0.0, 0.0, 10.0, 5.0, Color::Red), [
235        "          ",
236        "          ",
237        "          ",
238        "          ",
239        "          ",
240        "        ••",
241        "      ••  ",
242        "    ••    ",
243        "  ••      ",
244        "••        ",
245    ])]
246    // dy < dx, x1 > x2
247    #[case::diagonal2(&Line::new(10.0, 0.0, 0.0, 5.0, Color::Red), [
248        "          ",
249        "          ",
250        "          ",
251        "          ",
252        "          ",
253        "••        ",
254        "  ••      ",
255        "    ••    ",
256        "      ••  ",
257        "        ••",
258    ])]
259    // dy > dx, y1 < y2
260    #[case::diagonal3(&Line::new(0.0, 0.0, 5.0, 10.0, Color::Red), [
261        "     •    ",
262        "    •     ",
263        "    •     ",
264        "   •      ",
265        "   •      ",
266        "  •       ",
267        "  •       ",
268        " •        ",
269        " •        ",
270        "•         ",
271    ])]
272    // dy > dx, y1 > y2
273    #[case::diagonal4(&Line::new(0.0, 10.0, 5.0, 0.0, Color::Red), [
274        "•         ",
275        " •        ",
276        " •        ",
277        "  •       ",
278        "  •       ",
279        "   •      ",
280        "   •      ",
281        "    •     ",
282        "    •     ",
283        "     •    ",
284    ])]
285    fn tests<'expected_line, ExpectedLines>(#[case] line: &Line, #[case] expected: ExpectedLines)
286    where
287        ExpectedLines: IntoIterator,
288        ExpectedLines::Item: Into<ratatui_core::text::Line<'expected_line>>,
289    {
290        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
291        let canvas = Canvas::default()
292            .marker(Marker::Dot)
293            .x_bounds([0.0, 10.0])
294            .y_bounds([0.0, 10.0])
295            .paint(|context| context.draw(line));
296        canvas.render(buffer.area, &mut buffer);
297
298        let mut expected = Buffer::with_lines(expected);
299        for cell in &mut expected.content {
300            if cell.symbol() == "•" {
301                cell.set_style(Style::new().red());
302            }
303        }
304        assert_eq!(buffer, expected);
305    }
306}