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]>
280 lines
10 KiB
Rust
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))));
|
|
}
|
|
}
|