//! 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, } /// 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, } 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 + '_ { 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 { 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 { 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 { 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 { 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)))); } }