fix(canvas): Lines that start outside the visible grid are now drawn (#1501)

Previously lines with points that were outside the canvas bounds were
not drawn at all. Now they are clipped to the bounds of the canvas so
that the portion of the line within the canvas is draw.

To facilitate this, a new `Painter::bounds()` method which returns the
bounds of the canvas is added.

Fixes: https://github.com/ratatui/ratatui/issues/1489
This commit is contained in:
Ivan Smoliakov 2024-11-22 01:16:11 +01:00 committed by GitHub
parent 8f282473b2
commit afd1ce179b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 12 deletions

10
Cargo.lock generated
View file

@ -1377,6 +1377,15 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "line-clipping"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76364bf78d7ed059f98564fc5fb94c5a6eb2b6c9edd621cb1528febe1e918b29"
dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@ -2200,6 +2209,7 @@ dependencies = [
"indoc",
"instability",
"itertools 0.13.0",
"line-clipping",
"pretty_assertions",
"ratatui",
"ratatui-core",

View file

@ -53,6 +53,7 @@ unicode-segmentation.workspace = true
unicode-width.workspace = true
serde = { workspace = true, optional = true }
document-features = { workspace = true, optional = true }
line-clipping = "0.2.1"
[dev-dependencies]
rstest.workspace = true

View file

@ -424,6 +424,25 @@ impl<'a, 'b> Painter<'a, 'b> {
pub fn paint(&mut self, x: usize, y: usize, color: Color) {
self.context.grid.paint(x, y, color);
}
/// Canvas context bounds by axis.
///
/// # Example
///
/// ```
/// use ratatui::{
/// style::Color,
/// symbols,
/// widgets::canvas::{Context, Painter},
/// };
///
/// let mut ctx = Context::new(1, 1, [0.0, 2.0], [0.0, 2.0], symbols::Marker::Braille);
/// let mut painter = Painter::from(&mut ctx);
/// assert_eq!(painter.bounds(), (&[0.0, 2.0], &[0.0, 2.0]));
/// ```
pub fn bounds(&self) -> (&[f64; 2], &[f64; 2]) {
(&self.context.x_bounds, &self.context.y_bounds)
}
}
impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> {

View file

@ -1,3 +1,4 @@
use line_clipping::{cohen_sutherland, LineSegment, Point, Window};
use ratatui_core::style::Color;
use crate::canvas::{Painter, Shape};
@ -31,13 +32,21 @@ impl Line {
}
impl Shape for Line {
#[allow(clippy::similar_names)]
fn draw(&self, painter: &mut Painter) {
let Some((x1, y1)) = painter.get_point(self.x1, self.y1) else {
let (x_bounds, y_bounds) = painter.bounds();
let Some((world_x1, world_y1, world_x2, world_y2)) =
clip_line(x_bounds, y_bounds, self.x1, self.y1, self.x2, self.y2)
else {
return;
};
let Some((x2, y2)) = painter.get_point(self.x2, self.y2) else {
let Some((x1, y1)) = painter.get_point(world_x1, world_y1) else {
return;
};
let Some((x2, y2)) = painter.get_point(world_x2, world_y2) else {
return;
};
let (dx, x_range) = if x2 >= x1 {
(x2 - x1, x1..=x2)
} else {
@ -71,6 +80,27 @@ impl Shape for Line {
}
}
fn clip_line(
&[xmin, xmax]: &[f64; 2],
&[ymin, ymax]: &[f64; 2],
x1: f64,
y1: f64,
x2: f64,
y2: f64,
) -> Option<(f64, f64, f64, f64)> {
if let Some(LineSegment {
p1: Point { x: x1, y: y1 },
p2: Point { x: x2, y: y2 },
}) = cohen_sutherland::clip_line(
LineSegment::new(Point::new(x1, y1), Point::new(x2, y2)),
Window::new(xmin, xmax, ymin, ymax),
) {
Some((x1, y1, x2, y2))
} else {
None
}
}
fn draw_line_low(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) {
let dx = (x2 - x1) as isize;
let dy = (y2 as isize - y1 as isize).abs();
@ -124,9 +154,59 @@ mod tests {
use crate::canvas::Canvas;
#[rstest]
#[case::off_grid(&Line::new(-1.0, -1.0, 10.0, 10.0, Color::Red), [" "; 10])]
#[case::off_grid(&Line::new(0.0, 0.0, 11.0, 11.0, Color::Red), [" "; 10])]
#[case::horizontal(&Line::new(0.0, 0.0, 10.0, 0.0, Color::Red), [
#[case::off_grid1(&Line::new(-1.0, 0.0, -1.0, 10.0, Color::Red), [" "; 10])]
#[case::off_grid2(&Line::new(0.0, -1.0, 10.0, -1.0, Color::Red), [" "; 10])]
#[case::off_grid3(&Line::new(-10.0, 5.0, -1.0, 5.0, Color::Red), [" "; 10])]
#[case::off_grid4(&Line::new(5.0, 11.0, 5.0, 20.0, Color::Red), [" "; 10])]
#[case::off_grid5(&Line::new(-10.0, 0.0, 5.0, 0.0, Color::Red), [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
"••••• ",
])]
#[case::off_grid6(&Line::new(-1.0, -1.0, 10.0, 10.0, Color::Red), [
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
])]
#[case::off_grid7(&Line::new(0.0, 0.0, 11.0, 11.0, Color::Red), [
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
])]
#[case::off_grid8(&Line::new(-1.0, -1.0, 11.0, 11.0, Color::Red), [
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
])]
#[case::horizontal1(&Line::new(0.0, 0.0, 10.0, 0.0, Color::Red), [
" ",
" ",
" ",
@ -138,7 +218,7 @@ mod tests {
" ",
"••••••••••",
])]
#[case::horizontal(&Line::new(10.0, 10.0, 0.0, 10.0, Color::Red), [
#[case::horizontal2(&Line::new(10.0, 10.0, 0.0, 10.0, Color::Red), [
"••••••••••",
" ",
" ",
@ -150,10 +230,10 @@ mod tests {
" ",
" ",
])]
#[case::vertical(&Line::new(0.0, 0.0, 0.0, 10.0, Color::Red), [""; 10])]
#[case::vertical(&Line::new(10.0, 10.0, 10.0, 0.0, Color::Red), [""; 10])]
#[case::vertical1(&Line::new(0.0, 0.0, 0.0, 10.0, Color::Red), [""; 10])]
#[case::vertical2(&Line::new(10.0, 10.0, 10.0, 0.0, Color::Red), [""; 10])]
// dy < dx, x1 < x2
#[case::diagonal(&Line::new(0.0, 0.0, 10.0, 5.0, Color::Red), [
#[case::diagonal1(&Line::new(0.0, 0.0, 10.0, 5.0, Color::Red), [
" ",
" ",
" ",
@ -166,7 +246,7 @@ mod tests {
"",
])]
// dy < dx, x1 > x2
#[case::diagonal(&Line::new(10.0, 0.0, 0.0, 5.0, Color::Red), [
#[case::diagonal2(&Line::new(10.0, 0.0, 0.0, 5.0, Color::Red), [
" ",
" ",
" ",
@ -179,7 +259,7 @@ mod tests {
"",
])]
// dy > dx, y1 < y2
#[case::diagonal(&Line::new(0.0, 0.0, 5.0, 10.0, Color::Red), [
#[case::diagonal3(&Line::new(0.0, 0.0, 5.0, 10.0, Color::Red), [
"",
"",
"",
@ -192,7 +272,7 @@ mod tests {
"",
])]
// dy > dx, y1 > y2
#[case::diagonal(&Line::new(0.0, 10.0, 5.0, 0.0, Color::Red), [
#[case::diagonal4(&Line::new(0.0, 10.0, 5.0, 0.0, Color::Red), [
"",
"",
"",