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:
skkeyeandClaude Opus 5.5 committed 2026-10-01 01:17:38 -04:00
1 parent 69aa7eee58
commit 3b58d6494d
23 files changed
+3641 -7

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
//! The scene: everything the canvas draws in one frame, worked out from the
//! document, the cursor, the camera and the theme. Clicks are looked up in
//! the same scene, so what you click is what you see.
use std::collections::BTreeMap;
use ratatui::layout::Size;
use ratatui::style::Color;
use unicode_width::UnicodeWidthStr;
use super::boxes::{DEFAULT_MAX_WIDTH, NodeBox, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route};
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
use crate::ui::glyphs::Glyphs;
use crate::ui::theme::{LineStyle, Theme};
/// How many columns of a title show at Far zoom.
const FAR_TITLE_WIDTH: usize = 14;
/// What a scene needs besides the document, the cursor and the camera.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SceneOptions {
/// The width titles wrap at.
pub max_width: u16,
/// Focus mode: Open branches act like Auto ones.
pub focus: bool,
}
impl Default for SceneOptions {
fn default() -> SceneOptions {
SceneOptions { max_width: DEFAULT_MAX_WIDTH, focus: false }
}
}
/// How a node is drawn at the camera's zoom.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shape {
/// Normal zoom: a box with the title inside, and the tags and a badge in
/// its bottom border.
Box { lines: Vec<String>, tags: String, badge: String, border: Border },
/// Far zoom: a bullet and the title, on one line.
Label(String),
/// Overview: a dot.
Dot,
}
/// A node, as drawn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SceneNode {
pub id: NodeId,
/// Where it is on screen.
pub rect: ScreenRect,
pub shape: Shape,
/// Its color: the border, bullet or dot.
pub color: Color,
/// Whether the cursor is on it.
pub selected: bool,
}
/// Text drawn over the lines: an arrowhead or a label.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Mark {
pub(super) at: ScreenPos,
pub(super) text: String,
pub(super) color: Color,
}
/// Everything the canvas draws in one frame.
#[derive(Debug, Clone, Default)]
pub struct Scene {
pub(super) size: Size,
pub(super) zoom: Zoom,
pub(super) glyphs: Glyphs,
pub(super) accent: Color,
pub(super) muted: Color,
/// The nodes in drawing order: the cursor's last, on top.
pub(super) nodes: Vec<SceneNode>,
/// Where each node is in `nodes`.
index: BTreeMap<NodeId, usize>,
/// For each screen cell, the topmost node drawn on it (its index in
/// `nodes`).
covered: Vec<Option<usize>>,
pub(super) lines: LineLayer,
pub(super) marks: Vec<Mark>,
}
impl Scene {
/// Works out the frame: which nodes show (folding, the cursor), where
/// they are on screen, and every line between them.
pub fn build(
doc: &Document,
cursor: NodeId,
camera: Camera,
size: Size,
theme: &Theme,
options: SceneOptions,
) -> Scene {
let visible = Visible::new(doc, cursor, options.focus);
let mut scene = Scene {
size,
zoom: camera.zoom,
glyphs: theme.glyphs,
accent: theme.accent,
muted: theme.muted,
covered: vec![None; size.area() as usize],
lines: LineLayer::new(size),
..Scene::default()
};
for &id in &visible.order {
let Some(node) = doc.node(id) else { continue };
let style = theme.node_style(node, id == doc.root());
let badge = visible.hidden.get(&id).map(|count| format!("+{count}")).unwrap_or_default();
let (rect, shape) = shape(node, badge, style.border, camera, theme.glyphs, options.max_width);
scene.nodes.push(SceneNode { id, rect, shape, color: style.color, selected: id == cursor });
}
// The cursor's node goes last, on top of any it overlaps.
if let Some(index) = scene.nodes.iter().position(|node| node.selected) {
let node = scene.nodes.remove(index);
scene.nodes.push(node);
}
scene.index = scene.nodes.iter().enumerate().map(|(index, node)| (node.id, index)).collect();
scene.cover();
scene.add_borders();
scene.add_lines(doc, theme);
scene
}
/// The nodes on screen, in drawing order.
pub fn nodes(&self) -> &[SceneNode] {
&self.nodes
}
/// The size of the view the scene was built for.
pub fn size(&self) -> Size {
self.size
}
/// Where a node is on screen, if it's shown.
pub fn rect_of(&self, id: NodeId) -> Option<ScreenRect> {
self.index.get(&id).map(|&index| self.nodes[index].rect)
}
/// The node drawn on a cell (the topmost, where boxes overlap).
pub fn node_at(&self, at: ScreenPos) -> Option<NodeId> {
self.covered_at(at).map(|index| self.nodes[index].id)
}
/// What the line on a cell belongs to, if a line runs there.
pub fn line_at(&self, at: ScreenPos) -> Option<LineOwner> {
self.lines.owner(at)
}
/// The index (in `nodes`) of the topmost node on a cell.
pub(super) fn covered_at(&self, at: ScreenPos) -> Option<usize> {
covered_at(&self.covered, self.size, at)
}
/// Records which node is drawn on each cell. Later nodes are on top.
fn cover(&mut self) {
let (width, height) = (i32::from(self.size.width), i32::from(self.size.height));
for (index, node) in self.nodes.iter().enumerate() {
let rect = node.rect;
for row in rect.top().max(0)..rect.bottom().min(height) {
for col in rect.left().max(0)..rect.right().min(width) {
self.covered[(row * width + col) as usize] = Some(index);
}
}
}
}
/// Adds the boxes' borders to the line layer, so lines joining them get
/// the right characters. The cursor's box is thick, in the accent color.
fn add_borders(&mut self) {
let Scene { nodes, covered, lines, size, accent, .. } = self;
for (index, node) in nodes.iter().enumerate() {
let Shape::Box { border, .. } = &node.shape else { continue };
let (border, color) =
if node.selected { (Border::Thick, *accent) } else { (*border, node.color) };
let paint = Paint { color, owner: LineOwner::Border(node.id) };
lines.add_border(node.rect, border, paint, |at| covered_at(covered, *size, at) == Some(index));
}
}
/// Adds every branch (from a shown parent to a shown child) and every
/// cross-link between shown nodes.
fn add_lines(&mut self, doc: &Document, theme: &Theme) {
for child_index in 0..self.nodes.len() {
let child = &self.nodes[child_index];
let Some(Place::Child { parent, branch }) = doc.place(child.id) else { continue };
let Some(&parent_index) = self.index.get(parent) else { continue };
let style = theme.branch_style(branch, child.color);
let owner = LineOwner::Branch(child.id);
self.connect(parent_index, child_index, Bend::NearStart, style, owner, None);
}
for link in doc.links() {
let (Some(&start), Some(&end)) = (self.index.get(&link.start()), self.index.get(&link.end()))
else {
continue;
};
let style = theme.link_style(&link.look, self.nodes[start].color);
let label = link.label.as_ref().map(|label| label.as_str());
self.connect(start, end, Bend::Halfway, style, LineOwner::Link(link.key()), label);
}
}
/// Draws one line from node `from` to node `to` (indexes in `nodes`),
/// with its arrowheads and label.
fn connect(
&mut self,
from: usize,
to: usize,
bend: Bend,
style: LineStyle,
owner: LineOwner,
label: Option<&str>,
) {
let normal = self.zoom == Zoom::Normal;
// Zoomed out there are no borders to join, so lines stop a cell short
// of the text instead.
let pad = if normal { 0 } else { 1 };
let Some(route) = route(self.nodes[from].rect.widened(pad), self.nodes[to].rect.widened(pad), bend)
else {
return;
};
let arrow_at_start = matches!(style.arrows, Arrows::Start | Arrows::Both);
let arrow_at_end = matches!(style.arrows, Arrows::End | Arrows::Both);
let paint = Paint { color: style.color, owner };
let Scene { covered, lines, marks, size, glyphs, .. } = self;
let size = *size;
let free = |at: ScreenPos| covered_at(covered, size, at).is_none();
// Under boxes the line is hidden; zoomed out, its two end cells are
// the gap before the text.
let ends_hidden = !normal;
lines.add_path(&route.points(), style.stroke, paint, |at| {
!free(at) || (ends_hidden && (at == route.start || at == route.end))
});
// Join the line to the borders, unless an arrowhead touches the box.
if normal {
if !arrow_at_start
&& covered_at(covered, size, route.start) == Some(from)
&& let Some(way) = route.start_direction()
{
lines.add_arm(route.start, way, style.stroke, paint);
}
if !arrow_at_end
&& covered_at(covered, size, route.end) == Some(to)
&& let Some(way) = route.end_direction()
{
lines.add_arm(route.end, way.opposite(), style.stroke, paint);
}
}
let arrows = [(arrow_at_start, route.arrow_at_start()), (arrow_at_end, route.arrow_at_end())];
for (wanted, spot) in arrows {
if let (true, Some((at, pointing))) = (wanted, spot)
&& free(at)
{
marks.push(Mark { at, text: glyphs.arrow(pointing).to_string(), color: style.color });
}
}
if let Some(label) = label {
let text = format!(" {label} ");
let width = text.width() as i32;
if let Some(at) = route.label_spot(width)
&& (0..width).all(|offset| free(ScreenPos::new(at.col + offset, at.row)))
{
marks.push(Mark { at, text, color: style.color });
}
}
}
}
/// The index of the topmost node on a cell, in a `covered` grid.
fn covered_at(covered: &[Option<usize>], size: Size, at: ScreenPos) -> Option<usize> {
let (width, height) = (i32::from(size.width), i32::from(size.height));
if !(0..width).contains(&at.col) || !(0..height).contains(&at.row) {
return None;
}
covered.get((at.row * width + at.col) as usize).copied().flatten()
}
/// Where a node is on screen and how it's drawn, at the camera's zoom.
fn shape(
node: &Node,
badge: String,
border: Border,
camera: Camera,
glyphs: Glyphs,
max_width: u16,
) -> (ScreenRect, Shape) {
let at = camera.to_screen(node.pos);
match camera.zoom {
Zoom::Normal => {
let node_box = NodeBox::new(node, badge, max_width, glyphs);
let size = node_box.rect.size;
let rect = ScreenRect::new(at.col, at.row, i32::from(size.width), i32::from(size.height));
(rect, Shape::Box { lines: node_box.lines, tags: node_box.tags, badge: node_box.badge, border })
}
Zoom::Far => {
let title = match &node.content {
Content::Idea(idea) => idea.title.to_string(),
Content::Portal(portal) => format!("{} {}", glyphs.portal(), portal.title),
};
let mut text =
format!("{} {}", glyphs.bullet(), truncate(&title, FAR_TITLE_WIDTH, glyphs.ellipsis()));
if !badge.is_empty() {
text.push(' ');
text.push_str(&badge);
}
(ScreenRect::new(at.col, at.row, text.width() as i32, 1), Shape::Label(text))
}
Zoom::Overview => (ScreenRect::new(at.col, at.row, 1, 1), Shape::Dot),
}
}