Add the canvas engine and a canvas playground example
The canvas draws a frame in three steps. Scene::build works out what's shown and where: folding decides which nodes show, boxes are sized from their wrapped titles (with tags and a +N badge in the bottom border), the camera turns canvas positions into screen cells, and every branch and cross-link gets a right-angle route. The line layer records which arms meet in each cell, so borders and lines join with the right box-drawing character (├ ┼ ┨ ╭), in any mix of light, thick, double, dashed and dotted. Rendering then writes lines, arrowheads and labels, and the nodes on top. Branches leave sideways and bend near their parent, so siblings share one bus; cross-links take the wider gap and carry their label on the longest level run. Far zoom keeps the full width and halves the height (one line per node), Overview shows dots. hjkl picks the nearest node within 45° of the direction, and new nodes find a free spot next to their parent or sibling. The theme works out looks in layers (default, tag style, named style, the node's own), with a mid-tone palette for dark and light terminals, and an ASCII fallback for fonts without box drawing. `cargo run --example canvas` opens a sample document to move around, zoom, fold, edit and drag with the mouse. The tests compare rendered text against the DESIGN.md mockup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
69aa7eee58
commit
3b58d6494d
23 files changed
+3641
-7
No files matched your search
@@ -0,0 +1,275 @@
|
||||
//! The line layer: where lines and box borders run on screen, cell by cell.
|
||||
//! Each cell remembers the arms that meet in it, so where lines cross or join
|
||||
//! they get the right character (`├ ┬ ┼ ┨`) instead of overwriting each other.
|
||||
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::camera::{ScreenPos, ScreenRect};
|
||||
use crate::model::{Border, LinkKey, NodeId, Stroke};
|
||||
use crate::ui::glyphs::{Arms, Dash, Direction, Glyphs, Weight};
|
||||
|
||||
/// What a line cell belongs to, so a click on a line knows what it hit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineOwner {
|
||||
/// The border of a node's box.
|
||||
Border(NodeId),
|
||||
/// The branch line from a node's parent down to the node.
|
||||
Branch(NodeId),
|
||||
/// A cross-link.
|
||||
Link(LinkKey),
|
||||
}
|
||||
|
||||
/// The color and owner given to the cells a line or border passes through.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Paint {
|
||||
pub color: Color,
|
||||
pub owner: LineOwner,
|
||||
}
|
||||
|
||||
/// How the straight runs through a cell are dashed. Lines that disagree
|
||||
/// make it solid.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum DashMix {
|
||||
#[default]
|
||||
Unset,
|
||||
Solid,
|
||||
Dashed(Dash),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct Cell {
|
||||
arms: Arms,
|
||||
dash: DashMix,
|
||||
rounded: bool,
|
||||
paint: Option<Paint>,
|
||||
}
|
||||
|
||||
/// The lines and borders of one frame, over a view of a given size. Cells
|
||||
/// outside the view are dropped as they're added.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LineLayer {
|
||||
width: i32,
|
||||
height: i32,
|
||||
cells: Vec<Cell>,
|
||||
}
|
||||
|
||||
impl LineLayer {
|
||||
/// An empty layer for a view of this size.
|
||||
pub fn new(size: Size) -> LineLayer {
|
||||
let (width, height) = (i32::from(size.width), i32::from(size.height));
|
||||
LineLayer { width, height, cells: vec![Cell::default(); size.area() as usize] }
|
||||
}
|
||||
|
||||
/// Adds a box's border. `shown` tells which of its cells are visible:
|
||||
/// another box drawn on top can hide part of it.
|
||||
pub fn add_border(
|
||||
&mut self,
|
||||
rect: ScreenRect,
|
||||
border: Border,
|
||||
paint: Paint,
|
||||
shown: impl Fn(ScreenPos) -> bool,
|
||||
) {
|
||||
let stroke = match border {
|
||||
Border::Rounded | Border::Square => Stroke::Solid,
|
||||
Border::Double => Stroke::Double,
|
||||
Border::Thick => Stroke::Thick,
|
||||
Border::Dashed => Stroke::Dashed,
|
||||
Border::Hidden => return,
|
||||
};
|
||||
if rect.width < 2 || rect.height < 2 {
|
||||
return;
|
||||
}
|
||||
let (left, top, right, bottom) = (rect.left(), rect.top(), rect.right() - 1, rect.bottom() - 1);
|
||||
let corners = [
|
||||
(ScreenPos::new(left, top), Direction::Right, Direction::Down),
|
||||
(ScreenPos::new(right, top), Direction::Left, Direction::Down),
|
||||
(ScreenPos::new(left, bottom), Direction::Right, Direction::Up),
|
||||
(ScreenPos::new(right, bottom), Direction::Left, Direction::Up),
|
||||
];
|
||||
for (at, one, other) in corners {
|
||||
if shown(at) {
|
||||
self.add_arm(at, one, stroke, paint);
|
||||
self.add_arm(at, other, stroke, paint);
|
||||
if border == Border::Rounded
|
||||
&& let Some(cell) = self.cell_mut(at)
|
||||
{
|
||||
cell.rounded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for col in left + 1..right {
|
||||
for row in [top, bottom] {
|
||||
let at = ScreenPos::new(col, row);
|
||||
if shown(at) {
|
||||
self.add_arm(at, Direction::Left, stroke, paint);
|
||||
self.add_arm(at, Direction::Right, stroke, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in top + 1..bottom {
|
||||
for col in [left, right] {
|
||||
let at = ScreenPos::new(col, row);
|
||||
if shown(at) {
|
||||
self.add_arm(at, Direction::Up, stroke, paint);
|
||||
self.add_arm(at, Direction::Down, stroke, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a line through `points`, joined by straight runs (each pair of
|
||||
/// points shares a row or a column). Cells for which `skip` is true are
|
||||
/// left out: that's where the line passes under a box.
|
||||
pub fn add_path(
|
||||
&mut self,
|
||||
points: &[ScreenPos],
|
||||
stroke: Stroke,
|
||||
paint: Paint,
|
||||
skip: impl Fn(ScreenPos) -> bool,
|
||||
) {
|
||||
for pair in points.windows(2) {
|
||||
let [from, to] = [pair[0], pair[1]];
|
||||
let Some(direction) = direction_between(from, to) else { continue };
|
||||
let mut at = from;
|
||||
loop {
|
||||
if !skip(at) {
|
||||
if at != to {
|
||||
self.add_arm(at, direction, stroke, paint);
|
||||
}
|
||||
if at != from {
|
||||
self.add_arm(at, direction.opposite(), stroke, paint);
|
||||
}
|
||||
}
|
||||
if at == to {
|
||||
break;
|
||||
}
|
||||
at = step(at, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds one arm to a cell, as where a line meets a border. A border's
|
||||
/// color and owner stay: the line only adds its arm.
|
||||
pub fn add_arm(&mut self, at: ScreenPos, direction: Direction, stroke: Stroke, paint: Paint) {
|
||||
let Some(cell) = self.cell_mut(at) else { return };
|
||||
cell.arms.add(direction, Weight::of(stroke));
|
||||
let dash = match stroke {
|
||||
Stroke::Dashed => DashMix::Dashed(Dash::Dashed),
|
||||
Stroke::Dotted => DashMix::Dashed(Dash::Dotted),
|
||||
Stroke::Solid | Stroke::Thick | Stroke::Double => DashMix::Solid,
|
||||
};
|
||||
cell.dash = match cell.dash {
|
||||
DashMix::Unset => dash,
|
||||
same if same == dash => same,
|
||||
_ => DashMix::Solid,
|
||||
};
|
||||
let is_border = matches!(cell.paint, Some(Paint { owner: LineOwner::Border(_), .. }));
|
||||
if !is_border {
|
||||
cell.paint = Some(paint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cell a line runs through, with its character and color.
|
||||
pub fn cells(&self, glyphs: Glyphs) -> impl Iterator<Item = (ScreenPos, char, Color)> + '_ {
|
||||
self.cells.iter().enumerate().filter(|(_, cell)| !cell.arms.is_empty()).map(move |(index, cell)| {
|
||||
let index = index as i32;
|
||||
let at = ScreenPos::new(index % self.width, index / self.width);
|
||||
let dash = match cell.dash {
|
||||
DashMix::Dashed(dash) => Some(dash),
|
||||
DashMix::Unset | DashMix::Solid => None,
|
||||
};
|
||||
let color = cell.paint.map_or(Color::Reset, |paint| paint.color);
|
||||
(at, glyphs.line(cell.arms, dash, cell.rounded), color)
|
||||
})
|
||||
}
|
||||
|
||||
/// What the line at a cell belongs to, if a line runs there.
|
||||
pub fn owner(&self, at: ScreenPos) -> Option<LineOwner> {
|
||||
let cell = self.cell(at)?;
|
||||
if cell.arms.is_empty() { None } else { cell.paint.map(|paint| paint.owner) }
|
||||
}
|
||||
|
||||
/// Whether any line runs through a cell.
|
||||
pub fn is_used(&self, at: ScreenPos) -> bool {
|
||||
self.cell(at).is_some_and(|cell| !cell.arms.is_empty())
|
||||
}
|
||||
|
||||
fn index(&self, at: ScreenPos) -> Option<usize> {
|
||||
let inside = (0..self.width).contains(&at.col) && (0..self.height).contains(&at.row);
|
||||
inside.then(|| (at.row * self.width + at.col) as usize)
|
||||
}
|
||||
|
||||
fn cell(&self, at: ScreenPos) -> Option<&Cell> {
|
||||
self.index(at).and_then(|index| self.cells.get(index))
|
||||
}
|
||||
|
||||
fn cell_mut(&mut self, at: ScreenPos) -> Option<&mut Cell> {
|
||||
self.index(at).and_then(|index| self.cells.get_mut(index))
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction from one cell to another on the same row or column.
|
||||
pub fn direction_between(from: ScreenPos, to: ScreenPos) -> Option<Direction> {
|
||||
match (to.col - from.col, to.row - from.row) {
|
||||
(0, 0) => None,
|
||||
(0, rows) if rows < 0 => Some(Direction::Up),
|
||||
(0, _) => Some(Direction::Down),
|
||||
(cols, 0) if cols < 0 => Some(Direction::Left),
|
||||
(_, 0) => Some(Direction::Right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next cell in a direction.
|
||||
pub fn step(at: ScreenPos, direction: Direction) -> ScreenPos {
|
||||
match direction {
|
||||
Direction::Up => ScreenPos::new(at.col, at.row - 1),
|
||||
Direction::Right => ScreenPos::new(at.col + 1, at.row),
|
||||
Direction::Down => ScreenPos::new(at.col, at.row + 1),
|
||||
Direction::Left => ScreenPos::new(at.col - 1, at.row),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(layer: &LineLayer, size: Size) -> Vec<String> {
|
||||
let mut rows = vec![vec![' '; size.width as usize]; size.height as usize];
|
||||
for (at, glyph, _) in layer.cells(Glyphs::Unicode) {
|
||||
rows[at.row as usize][at.col as usize] = glyph;
|
||||
}
|
||||
rows.into_iter().map(|row| row.into_iter().collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_join_borders_and_each_other() {
|
||||
let size = Size::new(12, 5);
|
||||
let mut layer = LineLayer::new(size);
|
||||
let paint = Paint { color: Color::Reset, owner: LineOwner::Border(NodeId::new(1)) };
|
||||
let box_rect = ScreenRect::new(0, 0, 5, 5);
|
||||
layer.add_border(box_rect, Border::Thick, paint, |_| true);
|
||||
let line = Paint { color: Color::Reset, owner: LineOwner::Branch(NodeId::new(2)) };
|
||||
let path = [ScreenPos::new(4, 2), ScreenPos::new(7, 2), ScreenPos::new(7, 4), ScreenPos::new(11, 4)];
|
||||
layer.add_path(&path, Stroke::Solid, line, |at| box_rect.contains(at));
|
||||
layer.add_arm(ScreenPos::new(4, 2), Direction::Right, Stroke::Solid, line);
|
||||
layer.add_path(&[ScreenPos::new(9, 0), ScreenPos::new(9, 4)], Stroke::Dashed, line, |_| false);
|
||||
assert_eq!(
|
||||
text(&layer, size),
|
||||
vec![
|
||||
"┏━━━┓ ╷ ", //
|
||||
"┃ ┃ ╎ ",
|
||||
"┃ ┠──┐ ╎ ",
|
||||
"┃ ┃ │ ╎ ",
|
||||
"┗━━━┛ └─┴─╴",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layer.owner(ScreenPos::new(4, 2)),
|
||||
Some(LineOwner::Border(NodeId::new(1))),
|
||||
"borders keep theirs"
|
||||
);
|
||||
assert_eq!(layer.owner(ScreenPos::new(5, 2)), Some(LineOwner::Branch(NodeId::new(2))));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user