Files
cumulus/src/view/canvas/lines.rs
T
skkeyeandClaude Opus 5.5 38eca3c8c3 Draw the board as the canvas draws boxes, and drag cards live
The user found the board's look off from the canvas's: a block's C was
gray around a white card, borders ignored the node's style, a folded
column said nothing, and dragging felt wrong.

Columns and cards now go through the canvas's line layer, with the same
rules as Scene::add_borders: the node's border style and color, thick in
the accent color for the cursor, thick in the visual color for the
selection. A block's bracket and foot are drawn in its card's own line
and color, so the C is one shape and its joint comes out as ├, ┣ or ╠.
Tags and badges sit in the bottom border through a footer() shared with
the canvas renderer. Columns show their tags and badge too, so a folded
column says +N, and a column's title can be typed in its top border
(it wasn't shown as typed before).

A drag now moves the card live, as on the canvas (the user's pick), all
one undo step: by the row under the mouse, before a card on its top
half, after it or into its block on its bottom half, out at a foot,
first on a column's title, last below the cards. Columns keep their
widths while dragging, or a column that narrowed would slide the one
under the mouse away and the card would chase it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 13:49:48 -04:00

280 lines
10 KiB
Rust

//! 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::Direction;
use crate::model::{Border, LinkKey, NodeId, Stroke};
use crate::ui::glyphs::{Arms, Dash, 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 line connect mode shows from a node to the cursor's, before
/// Enter makes it.
Preview,
}
/// 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 Some(stroke) = border.stroke() else { 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 {
self.round(at);
}
}
}
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);
}
}
/// Draws the light corner on a cell rounded, as a rounded box's corners
/// are.
pub fn round(&mut self, at: ScreenPos) {
if let Some(cell) = self.cell_mut(at) {
cell.rounded = true;
}
}
/// 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))));
}
}