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
@@ -8,9 +8,13 @@
|
||||
//! |-------------|--------------------------------------------------------------|
|
||||
//! | [`model`] | the data and its rules: documents, nodes, links, undo, folds |
|
||||
//! | [`storage`] | files: documents (RON) |
|
||||
//! | [`view`] | the views, one module each: so far, the canvas |
|
||||
//! | [`ui`] | what views share: glyphs, the theme |
|
||||
|
||||
pub mod model;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
pub mod view;
|
||||
|
||||
/// The line `cumulus --version` prints: the version, and the git commit it was
|
||||
/// built from (recorded by `build.rs`).
|
||||
|
||||
@@ -84,6 +84,27 @@ impl Change {
|
||||
self.before.entries.keys().copied()
|
||||
}
|
||||
|
||||
/// This change followed by `next`, as one undo step: for anything both
|
||||
/// touched, the oldest value from `self` and the newest from `next`. A
|
||||
/// mouse drag is many small moves but undoes in one go.
|
||||
pub fn then(mut self, next: Change) -> Change {
|
||||
for (id, entry) in next.before.entries {
|
||||
self.before.entries.entry(id).or_insert(entry);
|
||||
}
|
||||
for (key, link) in next.before.links {
|
||||
self.before.links.entry(key).or_insert(link);
|
||||
}
|
||||
if self.before.loose.is_none() {
|
||||
self.before.loose = next.before.loose;
|
||||
}
|
||||
self.after.entries.extend(next.after.entries);
|
||||
self.after.links.extend(next.after.links);
|
||||
if next.after.loose.is_some() {
|
||||
self.after.loose = next.after.loose;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn before(&self) -> &Snapshot {
|
||||
&self.before
|
||||
}
|
||||
@@ -538,6 +559,22 @@ mod tests {
|
||||
assert_eq!(pos(g.soil), CanvasPos::new(22, 0), "outside the branch: untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_changes_make_one_undo_step() {
|
||||
let (mut doc, g) = garden();
|
||||
let start = doc.clone();
|
||||
let (_, first) = doc.edit(|e| e.shift_branch(g.beds, CanvasOffset::new(1, 0))).unwrap();
|
||||
let (_, second) = doc.edit(|e| e.shift_branch(g.beds, CanvasOffset::new(1, 1))).unwrap();
|
||||
let (_, third) = doc.edit(|e| e.add(idea("x"), Spot::Loose)).unwrap();
|
||||
let end = doc.clone();
|
||||
let mut history = crate::model::History::default();
|
||||
history.record(first.then(second).then(third));
|
||||
history.undo(&mut doc);
|
||||
assert_eq!(doc, start);
|
||||
history.redo(&mut doc);
|
||||
assert_eq!(doc, end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_looks_belong_to_children() {
|
||||
let (mut doc, g) = garden();
|
||||
|
||||
+65
-1
@@ -1,4 +1,4 @@
|
||||
//! Places on the canvas.
|
||||
//! Places and sizes on the canvas.
|
||||
//!
|
||||
//! The canvas is a grid of terminal cells (at Normal zoom) with no edges, so
|
||||
//! coordinates can be negative. `x` grows to the right and `y` downward, as in
|
||||
@@ -64,3 +64,67 @@ impl Neg for CanvasOffset {
|
||||
CanvasOffset::new(-self.dx, -self.dy)
|
||||
}
|
||||
}
|
||||
|
||||
/// A width and a height on the canvas, in cells.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct CanvasSize {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl CanvasSize {
|
||||
/// A size of `width` by `height` cells.
|
||||
pub const fn new(width: u16, height: u16) -> CanvasSize {
|
||||
CanvasSize { width, height }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle on the canvas: its top-left corner and its size.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct CanvasRect {
|
||||
pub pos: CanvasPos,
|
||||
pub size: CanvasSize,
|
||||
}
|
||||
|
||||
impl CanvasRect {
|
||||
/// The rectangle with its top-left corner at `pos`.
|
||||
pub const fn new(pos: CanvasPos, size: CanvasSize) -> CanvasRect {
|
||||
CanvasRect { pos, size }
|
||||
}
|
||||
|
||||
/// The first column inside.
|
||||
pub fn left(self) -> i32 {
|
||||
self.pos.x
|
||||
}
|
||||
|
||||
/// The first row inside.
|
||||
pub fn top(self) -> i32 {
|
||||
self.pos.y
|
||||
}
|
||||
|
||||
/// The first column past the right edge.
|
||||
pub fn right(self) -> i32 {
|
||||
self.pos.x + i32::from(self.size.width)
|
||||
}
|
||||
|
||||
/// The first row past the bottom edge.
|
||||
pub fn bottom(self) -> i32 {
|
||||
self.pos.y + i32::from(self.size.height)
|
||||
}
|
||||
|
||||
/// The middle cell (rounded toward the top left).
|
||||
pub fn center(self) -> CanvasPos {
|
||||
CanvasPos::new(
|
||||
self.pos.x + i32::from(self.size.width) / 2,
|
||||
self.pos.y + i32::from(self.size.height) / 2,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the two rectangles share at least one cell.
|
||||
pub fn overlaps(self, other: CanvasRect) -> bool {
|
||||
self.left() < other.right()
|
||||
&& other.left() < self.right()
|
||||
&& self.top() < other.bottom()
|
||||
&& other.top() < self.bottom()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ mod text;
|
||||
pub use document::{Document, NodeRecord, Place, StructureError};
|
||||
pub use edit::{Change, Edit, EditError, Spot};
|
||||
pub use fold::Visible;
|
||||
pub use geometry::{CanvasOffset, CanvasPos};
|
||||
pub use geometry::{CanvasOffset, CanvasPos, CanvasRect, CanvasSize};
|
||||
pub use history::{History, StateId};
|
||||
pub use ids::{DocUid, NodeId};
|
||||
pub use link::{Link, LinkKey};
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! The characters cumulus draws with: lines that join properly where they
|
||||
//! meet (`├ ┬ ┼ ┨`), arrowheads and bullets. Unicode box-drawing by default,
|
||||
//! and plain ASCII for fonts that don't have it.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::model::Stroke;
|
||||
|
||||
/// How heavy one arm of a line is, in one cell.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Weight {
|
||||
Light,
|
||||
Heavy,
|
||||
Double,
|
||||
}
|
||||
|
||||
impl Weight {
|
||||
/// The weight a stroke is drawn with. Dashed and dotted lines are light;
|
||||
/// their dashes are drawn separately (see [`Dash`]).
|
||||
pub fn of(stroke: Stroke) -> Weight {
|
||||
match stroke {
|
||||
Stroke::Solid | Stroke::Dashed | Stroke::Dotted => Weight::Light,
|
||||
Stroke::Thick => Weight::Heavy,
|
||||
Stroke::Double => Weight::Double,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four directions on screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Right,
|
||||
Down,
|
||||
Left,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// The direction pointing the other way.
|
||||
pub fn opposite(self) -> Direction {
|
||||
match self {
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Right => Direction::Left,
|
||||
Direction::Down => Direction::Up,
|
||||
Direction::Left => Direction::Right,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The arms of the lines that meet in one cell.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Arms {
|
||||
pub up: Option<Weight>,
|
||||
pub right: Option<Weight>,
|
||||
pub down: Option<Weight>,
|
||||
pub left: Option<Weight>,
|
||||
}
|
||||
|
||||
impl Arms {
|
||||
/// Adds an arm. Where there's one already, the heavier of the two stays.
|
||||
pub fn add(&mut self, direction: Direction, weight: Weight) {
|
||||
let arm = match direction {
|
||||
Direction::Up => &mut self.up,
|
||||
Direction::Right => &mut self.right,
|
||||
Direction::Down => &mut self.down,
|
||||
Direction::Left => &mut self.left,
|
||||
};
|
||||
*arm = (*arm).max(Some(weight));
|
||||
}
|
||||
|
||||
/// Whether no line runs through the cell.
|
||||
pub fn is_empty(self) -> bool {
|
||||
self == Arms::default()
|
||||
}
|
||||
|
||||
/// Whether this is a plain run from left to right (or top to bottom),
|
||||
/// the only shape that can be drawn dashed.
|
||||
fn straight(self) -> Option<(bool, Weight)> {
|
||||
match (self.up, self.right, self.down, self.left) {
|
||||
(None, Some(a), None, Some(b)) if a == b => Some((true, a)),
|
||||
(Some(a), None, Some(b), None) if a == b => Some((false, a)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same arms with some weights swapped (to find a character Unicode
|
||||
/// has, when it has none for the exact mix).
|
||||
fn with(self, swap: impl Fn(Weight) -> Weight) -> Arms {
|
||||
Arms {
|
||||
up: self.up.map(&swap),
|
||||
right: self.right.map(&swap),
|
||||
down: self.down.map(&swap),
|
||||
left: self.left.map(&swap),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number from 0 to 255 naming this combination (two bits per arm).
|
||||
fn key(self) -> usize {
|
||||
let bits = |arm: Option<Weight>| match arm {
|
||||
None => 0,
|
||||
Some(Weight::Light) => 1,
|
||||
Some(Weight::Heavy) => 2,
|
||||
Some(Weight::Double) => 3,
|
||||
};
|
||||
bits(self.up) | bits(self.right) << 2 | bits(self.down) << 4 | bits(self.left) << 6
|
||||
}
|
||||
}
|
||||
|
||||
/// How a straight run of line is broken up.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Dash {
|
||||
/// `╌╌╌`
|
||||
Dashed,
|
||||
/// `┈┈┈`
|
||||
Dotted,
|
||||
}
|
||||
|
||||
/// Which characters to draw with.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Glyphs {
|
||||
/// Box-drawing characters: `╭─┬─╮ ┃ ►`.
|
||||
#[default]
|
||||
Unicode,
|
||||
/// Plain ASCII, for fonts without box-drawing characters: `+-+-+ | >`.
|
||||
Ascii,
|
||||
}
|
||||
|
||||
impl Glyphs {
|
||||
/// The character for the lines meeting in a cell. `dash` applies to
|
||||
/// straight runs, and `rounded` to light corners.
|
||||
pub fn line(self, arms: Arms, dash: Option<Dash>, rounded: bool) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => unicode_line(arms, dash, rounded),
|
||||
Glyphs::Ascii => ascii_line(arms, dash),
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead pointing `direction`.
|
||||
pub fn arrow(self, direction: Direction) -> char {
|
||||
match (self, direction) {
|
||||
(Glyphs::Unicode, Direction::Up) => '▲',
|
||||
(Glyphs::Unicode, Direction::Right) => '►',
|
||||
(Glyphs::Unicode, Direction::Down) => '▼',
|
||||
(Glyphs::Unicode, Direction::Left) => '◄',
|
||||
(Glyphs::Ascii, Direction::Up) => '^',
|
||||
(Glyphs::Ascii, Direction::Right) => '>',
|
||||
(Glyphs::Ascii, Direction::Down) => 'v',
|
||||
(Glyphs::Ascii, Direction::Left) => '<',
|
||||
}
|
||||
}
|
||||
|
||||
/// The dot before a node's title at Far zoom, and a node at Overview.
|
||||
pub fn bullet(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '●',
|
||||
Glyphs::Ascii => 'o',
|
||||
}
|
||||
}
|
||||
|
||||
/// The mark in front of a portal's title.
|
||||
pub fn portal(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '»',
|
||||
Glyphs::Ascii => '>',
|
||||
}
|
||||
}
|
||||
|
||||
/// The mark at the end of text that was cut short.
|
||||
pub fn ellipsis(self) -> char {
|
||||
match self {
|
||||
Glyphs::Unicode => '…',
|
||||
Glyphs::Ascii => '~',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unicode_line(arms: Arms, dash: Option<Dash>, rounded: bool) -> char {
|
||||
if let (Some(dash), Some((horizontal, weight))) = (dash, arms.straight())
|
||||
&& weight != Weight::Double
|
||||
{
|
||||
let heavy = weight == Weight::Heavy;
|
||||
return match (dash, horizontal, heavy) {
|
||||
(Dash::Dashed, true, false) => '╌',
|
||||
(Dash::Dashed, true, true) => '╍',
|
||||
(Dash::Dashed, false, false) => '╎',
|
||||
(Dash::Dashed, false, true) => '╏',
|
||||
(Dash::Dotted, true, false) => '┈',
|
||||
(Dash::Dotted, true, true) => '┉',
|
||||
(Dash::Dotted, false, false) => '┊',
|
||||
(Dash::Dotted, false, true) => '┋',
|
||||
};
|
||||
}
|
||||
if rounded && let Some(corner) = rounded_corner(arms) {
|
||||
return corner;
|
||||
}
|
||||
// Unicode has every mix of light and heavy, and most mixes of light and
|
||||
// double. For anything else, draw it lighter.
|
||||
let attempts = [
|
||||
arms,
|
||||
arms.with(|w| if w == Weight::Heavy { Weight::Light } else { w }),
|
||||
arms.with(|w| if w == Weight::Double { Weight::Light } else { w }),
|
||||
arms.with(|_| Weight::Light),
|
||||
];
|
||||
attempts.into_iter().map(|arms| JUNCTIONS[arms.key()]).find(|&c| c != '\0').unwrap_or(' ')
|
||||
}
|
||||
|
||||
fn rounded_corner(arms: Arms) -> Option<char> {
|
||||
let light = Some(Weight::Light);
|
||||
match (arms.up, arms.right, arms.down, arms.left) {
|
||||
(None, r, d, None) if r == light && d == light => Some('╭'),
|
||||
(None, None, d, l) if d == light && l == light => Some('╮'),
|
||||
(u, None, None, l) if u == light && l == light => Some('╯'),
|
||||
(u, r, None, None) if u == light && r == light => Some('╰'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ascii_line(arms: Arms, dash: Option<Dash>) -> char {
|
||||
let horizontal = arms.left.is_some() || arms.right.is_some();
|
||||
let vertical = arms.up.is_some() || arms.down.is_some();
|
||||
let double = [arms.up, arms.right, arms.down, arms.left].contains(&Some(Weight::Double));
|
||||
match (horizontal, vertical) {
|
||||
(true, false) => match dash {
|
||||
Some(Dash::Dotted) => '.',
|
||||
_ if double => '=',
|
||||
_ => '-',
|
||||
},
|
||||
(false, true) => match dash {
|
||||
Some(_) => ':',
|
||||
None => '|',
|
||||
},
|
||||
(true, true) => '+',
|
||||
(false, false) => ' ',
|
||||
}
|
||||
}
|
||||
|
||||
/// Every box-drawing character, by the arms it draws. Each pattern lists the
|
||||
/// arms up, right, down and left: `.` none, `L` light, `H` heavy, `D` double.
|
||||
#[rustfmt::skip]
|
||||
const JUNCTION_PATTERNS: &[(&str, char)] = &[
|
||||
// Line ends.
|
||||
("L...", '╵'), (".L..", '╶'), ("..L.", '╷'), ("...L", '╴'),
|
||||
("H...", '╹'), (".H..", '╺'), ("..H.", '╻'), ("...H", '╸'),
|
||||
// Straight runs.
|
||||
(".L.L", '─'), (".H.H", '━'), (".H.L", '╼'), (".L.H", '╾'),
|
||||
("L.L.", '│'), ("H.H.", '┃'), ("L.H.", '╽'), ("H.L.", '╿'),
|
||||
// Corners.
|
||||
(".LL.", '┌'), (".HL.", '┍'), (".LH.", '┎'), (".HH.", '┏'),
|
||||
("..LL", '┐'), ("..LH", '┑'), ("..HL", '┒'), ("..HH", '┓'),
|
||||
("LL..", '└'), ("LH..", '┕'), ("HL..", '┖'), ("HH..", '┗'),
|
||||
("L..L", '┘'), ("L..H", '┙'), ("H..L", '┚'), ("H..H", '┛'),
|
||||
// Tees.
|
||||
("LLL.", '├'), ("LHL.", '┝'), ("HLL.", '┞'), ("LLH.", '┟'),
|
||||
("HLH.", '┠'), ("HHL.", '┡'), ("LHH.", '┢'), ("HHH.", '┣'),
|
||||
("L.LL", '┤'), ("L.LH", '┥'), ("H.LL", '┦'), ("L.HL", '┧'),
|
||||
("H.HL", '┨'), ("H.LH", '┩'), ("L.HH", '┪'), ("H.HH", '┫'),
|
||||
(".LLL", '┬'), (".LLH", '┭'), (".HLL", '┮'), (".HLH", '┯'),
|
||||
(".LHL", '┰'), (".LHH", '┱'), (".HHL", '┲'), (".HHH", '┳'),
|
||||
("LL.L", '┴'), ("LL.H", '┵'), ("LH.L", '┶'), ("LH.H", '┷'),
|
||||
("HL.L", '┸'), ("HL.H", '┹'), ("HH.L", '┺'), ("HH.H", '┻'),
|
||||
// Crossings.
|
||||
("LLLL", '┼'), ("LLLH", '┽'), ("LHLL", '┾'), ("LHLH", '┿'),
|
||||
("HLLL", '╀'), ("LLHL", '╁'), ("HLHL", '╂'), ("HLLH", '╃'),
|
||||
("HHLL", '╄'), ("LLHH", '╅'), ("LHHL", '╆'), ("HHLH", '╇'),
|
||||
("LHHH", '╈'), ("HLHH", '╉'), ("HHHL", '╊'), ("HHHH", '╋'),
|
||||
// Double lines, alone and with light ones.
|
||||
(".D.D", '═'), ("D.D.", '║'),
|
||||
(".DL.", '╒'), (".LD.", '╓'), (".DD.", '╔'),
|
||||
("..LD", '╕'), ("..DL", '╖'), ("..DD", '╗'),
|
||||
("LD..", '╘'), ("DL..", '╙'), ("DD..", '╚'),
|
||||
("L..D", '╛'), ("D..L", '╜'), ("D..D", '╝'),
|
||||
("LDL.", '╞'), ("DLD.", '╟'), ("DDD.", '╠'),
|
||||
("L.LD", '╡'), ("D.DL", '╢'), ("D.DD", '╣'),
|
||||
(".DLD", '╤'), (".LDL", '╥'), (".DDD", '╦'),
|
||||
("LD.D", '╧'), ("DL.L", '╨'), ("DD.D", '╩'),
|
||||
("LDLD", '╪'), ("DLDL", '╫'), ("DDDD", '╬'),
|
||||
];
|
||||
|
||||
/// [`JUNCTION_PATTERNS`] as a table indexed by [`Arms::key`] (`'\0'` where
|
||||
/// Unicode has no character).
|
||||
static JUNCTIONS: LazyLock<[char; 256]> = LazyLock::new(|| {
|
||||
let mut table = ['\0'; 256];
|
||||
for (pattern, glyph) in JUNCTION_PATTERNS {
|
||||
let mut arms = [None; 4];
|
||||
for (arm, mark) in arms.iter_mut().zip(pattern.chars()) {
|
||||
*arm = match mark {
|
||||
'L' => Some(Weight::Light),
|
||||
'H' => Some(Weight::Heavy),
|
||||
'D' => Some(Weight::Double),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let [up, right, down, left] = arms;
|
||||
table[Arms { up, right, down, left }.key()] = *glyph;
|
||||
}
|
||||
table
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use Weight::{Double as D, Heavy as H, Light as L};
|
||||
|
||||
fn arms(up: Option<Weight>, right: Option<Weight>, down: Option<Weight>, left: Option<Weight>) -> Arms {
|
||||
Arms { up, right, down, left }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_light_and_heavy_mix_has_a_character() {
|
||||
let weights = [None, Some(L), Some(H)];
|
||||
for up in weights {
|
||||
for right in weights {
|
||||
for down in weights {
|
||||
for left in weights {
|
||||
let mix = arms(up, right, down, left);
|
||||
if !mix.is_empty() {
|
||||
assert_ne!(JUNCTIONS[mix.key()], '\0', "{mix:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junctions_borders_and_dashes() {
|
||||
let g = Glyphs::Unicode;
|
||||
assert_eq!(
|
||||
g.line(arms(Some(H), None, Some(H), Some(L)), None, false),
|
||||
'┨',
|
||||
"a line meeting a thick border"
|
||||
);
|
||||
assert_eq!(g.line(arms(None, Some(L), Some(L), None), None, true), '╭');
|
||||
assert_eq!(g.line(arms(None, Some(L), Some(L), None), None, false), '┌');
|
||||
assert_eq!(g.line(arms(None, Some(L), None, Some(L)), Some(Dash::Dashed), false), '╌');
|
||||
assert_eq!(
|
||||
g.line(arms(Some(L), Some(L), Some(L), None), Some(Dash::Dotted), false),
|
||||
'├',
|
||||
"only runs are dashed"
|
||||
);
|
||||
assert_eq!(g.line(arms(Some(D), None, Some(D), Some(L)), None, false), '╢');
|
||||
assert_eq!(
|
||||
g.line(arms(Some(D), Some(H), None, None), None, false),
|
||||
'╙',
|
||||
"no double/heavy mix: heavy goes light"
|
||||
);
|
||||
assert_eq!(Glyphs::Ascii.line(arms(Some(L), Some(L), None, None), None, true), '+');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! What the views share: the characters they draw with ([`glyphs`]) and the
|
||||
//! colors and looks ([`theme`]). The which-key popup, the command line, the
|
||||
//! bars and the other shared widgets come in later stages.
|
||||
|
||||
pub mod glyphs;
|
||||
pub mod theme;
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
//! Colors, and how looks are worked out.
|
||||
//!
|
||||
//! A node's or a line's look ([`NodeLook`], [`LineLook`]) only holds
|
||||
//! overrides. The theme turns it into something drawable ([`NodeStyle`],
|
||||
//! [`LineStyle`]) in layers: the theme's default, then the style a tag asks
|
||||
//! for, then a named style, then the overrides themselves.
|
||||
//!
|
||||
//! Theme files (TOML) come with the config (DESIGN.md, stage 8). Until then
|
||||
//! [`Theme::default`] is the theme.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::glyphs::Glyphs;
|
||||
use crate::model::{Arrows, Border, ColorName, LineColor, LineLook, Node, NodeLook, Stroke, StyleName, Tag};
|
||||
|
||||
/// How to draw a node, all worked out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NodeStyle {
|
||||
/// The color of its border (`Color::Reset` when it has none: the
|
||||
/// terminal's normal text color).
|
||||
pub color: Color,
|
||||
pub border: Border,
|
||||
}
|
||||
|
||||
/// How to draw a line, all worked out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LineStyle {
|
||||
pub stroke: Stroke,
|
||||
pub arrows: Arrows,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
/// Every color and look the views draw with.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
pub glyphs: Glyphs,
|
||||
/// The accent: the cursor, the focused pane, which-key, the command line.
|
||||
pub accent: Color,
|
||||
/// Quiet things: lines, hints, counts.
|
||||
pub muted: Color,
|
||||
/// Node colors, by name.
|
||||
pub palette: BTreeMap<ColorName, Color>,
|
||||
/// How nodes look, unless something says otherwise.
|
||||
pub node: NodeLook,
|
||||
/// How the root looks, on top of `node`.
|
||||
pub root: NodeLook,
|
||||
/// How branch lines look, unless something says otherwise.
|
||||
pub branch: LineLook,
|
||||
/// How cross-links look, unless something says otherwise.
|
||||
pub link: LineLook,
|
||||
/// Named node styles. (A style's own `style` field is ignored: styles
|
||||
/// don't point at other styles.)
|
||||
pub node_styles: BTreeMap<StyleName, NodeLook>,
|
||||
/// Named line styles.
|
||||
pub line_styles: BTreeMap<StyleName, LineLook>,
|
||||
/// Tags that give their nodes a style, applied in this order.
|
||||
pub tag_styles: Vec<(Tag, StyleName)>,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
/// cumulus's own theme. The palette uses mid-tone colors from the
|
||||
/// standard 256, which read well on dark and light terminals alike; the
|
||||
/// accent and the muted color are terminal colors, so they follow your
|
||||
/// terminal's scheme.
|
||||
fn default() -> Theme {
|
||||
let palette = [
|
||||
("red", 167),
|
||||
("orange", 172),
|
||||
("yellow", 178),
|
||||
("green", 71),
|
||||
("teal", 37),
|
||||
("blue", 68),
|
||||
("purple", 134),
|
||||
("pink", 169),
|
||||
("brown", 137),
|
||||
("gray", 245),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, index)| Some((ColorName::parse(name).ok()?, Color::Indexed(index))))
|
||||
.collect();
|
||||
Theme {
|
||||
glyphs: Glyphs::Unicode,
|
||||
accent: Color::Cyan,
|
||||
muted: Color::DarkGray,
|
||||
palette,
|
||||
node: NodeLook { border: Some(Border::Square), ..NodeLook::default() },
|
||||
root: NodeLook { border: Some(Border::Rounded), ..NodeLook::default() },
|
||||
branch: LineLook { line: Some(Stroke::Solid), arrows: Some(Arrows::Off), ..LineLook::default() },
|
||||
link: LineLook { line: Some(Stroke::Dashed), arrows: Some(Arrows::End), ..LineLook::default() },
|
||||
node_styles: BTreeMap::new(),
|
||||
line_styles: BTreeMap::new(),
|
||||
tag_styles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// How to draw a node.
|
||||
pub fn node_style(&self, node: &Node, is_root: bool) -> NodeStyle {
|
||||
let mut color: Option<ColorName> = None;
|
||||
let mut border = Border::Square;
|
||||
let mut apply = |look: &NodeLook| {
|
||||
if let Some(name) = &look.color {
|
||||
color = Some(name.clone());
|
||||
}
|
||||
if let Some(kind) = look.border {
|
||||
border = kind;
|
||||
}
|
||||
};
|
||||
apply(&self.node);
|
||||
if is_root {
|
||||
apply(&self.root);
|
||||
}
|
||||
for (tag, style) in &self.tag_styles {
|
||||
if node.tags.contains(tag)
|
||||
&& let Some(look) = self.node_styles.get(style)
|
||||
{
|
||||
apply(look);
|
||||
}
|
||||
}
|
||||
if let Some(look) = node.look.style.as_ref().and_then(|style| self.node_styles.get(style)) {
|
||||
apply(look);
|
||||
}
|
||||
apply(&node.look);
|
||||
NodeStyle { color: color.map_or(Color::Reset, |name| self.color(&name)), border }
|
||||
}
|
||||
|
||||
/// How to draw a branch line, given the look it has and the color of
|
||||
/// its child (for lines that follow their node's color).
|
||||
pub fn branch_style(&self, look: &LineLook, child_color: Color) -> LineStyle {
|
||||
self.line_style(&self.branch, look, child_color)
|
||||
}
|
||||
|
||||
/// How to draw a cross-link, given the look it has and the color of the
|
||||
/// node it starts from.
|
||||
pub fn link_style(&self, look: &LineLook, start_color: Color) -> LineStyle {
|
||||
self.line_style(&self.link, look, start_color)
|
||||
}
|
||||
|
||||
/// A palette color by name. An unknown name (from a document made with
|
||||
/// another theme) shows as the normal text color.
|
||||
pub fn color(&self, name: &ColorName) -> Color {
|
||||
self.palette.get(name).copied().unwrap_or(Color::Reset)
|
||||
}
|
||||
|
||||
fn line_style(&self, default: &LineLook, look: &LineLook, node_color: Color) -> LineStyle {
|
||||
let mut style = LineStyle { stroke: Stroke::Solid, arrows: Arrows::Off, color: self.muted };
|
||||
let mut color: Option<LineColor> = None;
|
||||
let mut apply = |layer: &LineLook| {
|
||||
if let Some(stroke) = layer.line {
|
||||
style.stroke = stroke;
|
||||
}
|
||||
if let Some(arrows) = layer.arrows {
|
||||
style.arrows = arrows;
|
||||
}
|
||||
if let Some(layer_color) = &layer.color {
|
||||
color = Some(layer_color.clone());
|
||||
}
|
||||
};
|
||||
apply(default);
|
||||
if let Some(named) = look.style.as_ref().and_then(|name| self.line_styles.get(name)) {
|
||||
apply(named);
|
||||
}
|
||||
apply(look);
|
||||
style.color = match color {
|
||||
None => self.muted,
|
||||
// A node without a color has nothing to follow: stay quiet.
|
||||
Some(LineColor::FollowNode) if node_color == Color::Reset => self.muted,
|
||||
Some(LineColor::FollowNode) => node_color,
|
||||
Some(LineColor::Named(name)) => self.color(&name),
|
||||
};
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::idea;
|
||||
|
||||
fn name(text: &str) -> StyleName {
|
||||
StyleName::parse(text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_are_worked_out_in_layers() {
|
||||
let mut theme = Theme::default();
|
||||
let yellow = ColorName::parse("yellow").unwrap();
|
||||
let red = ColorName::parse("red").unwrap();
|
||||
theme.node_styles.insert(
|
||||
name("question"),
|
||||
NodeLook { color: Some(yellow.clone()), border: Some(Border::Dashed), style: None },
|
||||
);
|
||||
theme.tag_styles.push((Tag::parse("question").unwrap(), name("question")));
|
||||
|
||||
let mut node = idea("why?");
|
||||
assert_eq!(theme.node_style(&node, false), NodeStyle { color: Color::Reset, border: Border::Square });
|
||||
assert_eq!(theme.node_style(&node, true).border, Border::Rounded, "the root has its own border");
|
||||
|
||||
node.tags.insert(Tag::parse("question").unwrap());
|
||||
assert_eq!(
|
||||
theme.node_style(&node, false),
|
||||
NodeStyle { color: theme.color(&yellow), border: Border::Dashed }
|
||||
);
|
||||
|
||||
node.look.color = Some(red.clone());
|
||||
let style = theme.node_style(&node, false);
|
||||
assert_eq!((style.color, style.border), (theme.color(&red), Border::Dashed), "own color wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_can_follow_their_node() {
|
||||
let theme = Theme::default();
|
||||
let follow = LineLook { color: Some(LineColor::FollowNode), ..LineLook::default() };
|
||||
assert_eq!(theme.branch_style(&follow, Color::Indexed(71)).color, Color::Indexed(71));
|
||||
assert_eq!(theme.branch_style(&follow, Color::Reset).color, theme.muted);
|
||||
let link = theme.link_style(&LineLook::default(), Color::Reset);
|
||||
assert_eq!((link.stroke, link.arrows), (Stroke::Dashed, Arrows::End));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Node boxes at Normal zoom: how big each box is and what's written in it.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭─────────────╮ ┌────────────┐
|
||||
//! │ Raised beds │ │ » Kitchen │ a portal starts with »
|
||||
//! ╰─ #todo ─ +2 ╯ └───── 42 ───┘ tags on the left, a badge on the right
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::model::{CanvasRect, CanvasSize, Content, Document, Node, NodeId};
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
|
||||
/// The width titles wrap at, unless the config says otherwise.
|
||||
pub const DEFAULT_MAX_WIDTH: u16 = 30;
|
||||
|
||||
/// What a node's box holds at Normal zoom.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NodeBox {
|
||||
/// The box on the canvas, borders included.
|
||||
pub rect: CanvasRect,
|
||||
/// The title, wrapped.
|
||||
pub lines: Vec<String>,
|
||||
/// The tags, written into the bottom border on the left.
|
||||
pub tags: String,
|
||||
/// Written into the bottom border on the right: `+3` for a folded
|
||||
/// branch's hidden nodes, or a portal's node count.
|
||||
pub badge: String,
|
||||
}
|
||||
|
||||
impl NodeBox {
|
||||
/// Lays out a node's box. Titles wrap at `max_width` columns; the box
|
||||
/// grows wider than its title, up to that, when the tags and badge need
|
||||
/// the room.
|
||||
pub fn new(node: &Node, badge: String, max_width: u16, glyphs: Glyphs) -> NodeBox {
|
||||
let title = match &node.content {
|
||||
Content::Idea(idea) => idea.title.to_string(),
|
||||
Content::Portal(portal) => format!("{} {}", glyphs.portal(), portal.title),
|
||||
};
|
||||
let max_width = usize::from(max_width.max(4));
|
||||
let lines = wrap(&title, max_width);
|
||||
let tags = node.tags.to_string();
|
||||
let text_width = lines.iter().map(|line| line.width()).max().unwrap_or(0);
|
||||
let inner = text_width.max(footer_width(&tags, &badge).saturating_sub(2)).min(max_width);
|
||||
// A border and a space on each side of the text.
|
||||
let width = clamp_u16(inner + 4);
|
||||
let height = clamp_u16(lines.len() + 2);
|
||||
NodeBox { rect: CanvasRect::new(node.pos, CanvasSize::new(width, height)), lines, tags, badge }
|
||||
}
|
||||
}
|
||||
|
||||
/// The boxes of every node in a document at Normal zoom, without badges:
|
||||
/// enough to tell where nodes are and how much room they take.
|
||||
pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap<NodeId, CanvasRect> {
|
||||
doc.outline()
|
||||
.into_iter()
|
||||
.filter_map(|id| Some((id, NodeBox::new(doc.node(id)?, String::new(), max_width, glyphs).rect)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many cells the bottom border needs for the tags and the badge,
|
||||
/// between its corners: `─ #todo ─ +3 ─`.
|
||||
fn footer_width(tags: &str, badge: &str) -> usize {
|
||||
let part = |text: &str| if text.is_empty() { 0 } else { text.width() + 2 };
|
||||
let parts = part(tags) + part(badge);
|
||||
if parts == 0 { 0 } else { parts + if tags.is_empty() || badge.is_empty() { 2 } else { 3 } }
|
||||
}
|
||||
|
||||
/// Breaks text into lines at most `width` columns wide, between words when
|
||||
/// it can. A word longer than a line is cut.
|
||||
pub fn wrap(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split(' ').filter(|word| !word.is_empty()) {
|
||||
let needed = if line.is_empty() { word.width() } else { line.width() + 1 + word.width() };
|
||||
if needed <= width {
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
continue;
|
||||
}
|
||||
if !line.is_empty() {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
for c in word.chars() {
|
||||
if line.width() + c.width().unwrap_or(0) > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
line.push(c);
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || lines.is_empty() {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Text cut to at most `width` columns, ending with `ellipsis` when it was
|
||||
/// cut.
|
||||
pub fn truncate(text: &str, width: usize, ellipsis: char) -> String {
|
||||
if text.width() <= width {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut cut = String::new();
|
||||
for c in text.chars() {
|
||||
if cut.width() + c.width().unwrap_or(0) + 1 > width {
|
||||
break;
|
||||
}
|
||||
cut.push(c);
|
||||
}
|
||||
if width > 0 {
|
||||
cut.push(ellipsis);
|
||||
}
|
||||
cut
|
||||
}
|
||||
|
||||
fn clamp_u16(value: usize) -> u16 {
|
||||
u16::try_from(value).unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Tags;
|
||||
use crate::model::test_support::idea;
|
||||
|
||||
#[test]
|
||||
fn titles_wrap_between_words() {
|
||||
assert_eq!(wrap("a raised bed made of cedar", 10), vec!["a raised", "bed made", "of cedar"]);
|
||||
assert_eq!(wrap("supercalifragilistic", 8), vec!["supercal", "ifragili", "stic"]);
|
||||
assert_eq!(wrap("", 8), vec![""]);
|
||||
assert_eq!(wrap("日本語の題名", 6), vec!["日本語", "の題名"], "wide characters take two columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_fit_their_title_and_footer() {
|
||||
let mut node = idea("Soil");
|
||||
let plain = NodeBox::new(&node, String::new(), 30, Glyphs::Unicode);
|
||||
assert_eq!((plain.rect.size.width, plain.rect.size.height), (8, 3));
|
||||
node.tags = Tags::parse("#todo").unwrap();
|
||||
let tagged = NodeBox::new(&node, "+3".into(), 30, Glyphs::Unicode);
|
||||
assert_eq!(tagged.rect.size.width, 16, "╰─ #todo ─ +3 ─╯ fits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncating_marks_the_cut() {
|
||||
assert_eq!(truncate("Raised beds", 20, '…'), "Raised beds");
|
||||
assert_eq!(truncate("Raised beds", 7, '…'), "Raised…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! The camera: which part of the canvas a view shows, and how close. It
|
||||
//! converts between canvas positions and screen cells.
|
||||
|
||||
use ratatui::layout::Size;
|
||||
|
||||
use crate::model::{CanvasOffset, CanvasPos};
|
||||
|
||||
/// A cell on screen, counted from the top-left corner of the canvas's area.
|
||||
/// It can lie outside the area (negative, or past its size): nodes can be
|
||||
/// partly off screen.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct ScreenPos {
|
||||
pub col: i32,
|
||||
pub row: i32,
|
||||
}
|
||||
|
||||
impl ScreenPos {
|
||||
/// The cell at `col`, `row`.
|
||||
pub const fn new(col: i32, row: i32) -> ScreenPos {
|
||||
ScreenPos { col, row }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle of screen cells (see [`ScreenPos`]).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct ScreenRect {
|
||||
pub pos: ScreenPos,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
}
|
||||
|
||||
impl ScreenRect {
|
||||
/// The rectangle with its top-left cell at `col`, `row`.
|
||||
pub const fn new(col: i32, row: i32, width: i32, height: i32) -> ScreenRect {
|
||||
ScreenRect { pos: ScreenPos::new(col, row), width, height }
|
||||
}
|
||||
|
||||
/// The first column inside.
|
||||
pub fn left(self) -> i32 {
|
||||
self.pos.col
|
||||
}
|
||||
|
||||
/// The first row inside.
|
||||
pub fn top(self) -> i32 {
|
||||
self.pos.row
|
||||
}
|
||||
|
||||
/// The first column past the right edge.
|
||||
pub fn right(self) -> i32 {
|
||||
self.pos.col + self.width
|
||||
}
|
||||
|
||||
/// The first row past the bottom edge.
|
||||
pub fn bottom(self) -> i32 {
|
||||
self.pos.row + self.height
|
||||
}
|
||||
|
||||
/// The middle column (the right one of two, for an even width).
|
||||
pub fn mid_col(self) -> i32 {
|
||||
self.pos.col + self.width / 2
|
||||
}
|
||||
|
||||
/// The middle row.
|
||||
pub fn mid_row(self) -> i32 {
|
||||
self.pos.row + self.height / 2
|
||||
}
|
||||
|
||||
/// Whether a cell is inside.
|
||||
pub fn contains(self, cell: ScreenPos) -> bool {
|
||||
(self.left()..self.right()).contains(&cell.col) && (self.top()..self.bottom()).contains(&cell.row)
|
||||
}
|
||||
|
||||
/// The rectangle grown by `cols` on the left and right.
|
||||
pub fn widened(self, cols: i32) -> ScreenRect {
|
||||
ScreenRect::new(self.left() - cols, self.top(), self.width + 2 * cols, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far away the canvas is. A terminal can't shrink text, so zooming out
|
||||
/// shows less of each node instead.
|
||||
///
|
||||
/// A box is three rows tall but a label one, so zooming out mostly saves
|
||||
/// rows. Far keeps the full width, so titles still fit between nodes, and
|
||||
/// halves the height: two boxes stacked in a column always land on different
|
||||
/// rows, and lines keep the gaps between rows. Overview squeezes harder, and
|
||||
/// lets dots crowd.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Zoom {
|
||||
/// Boxes with their titles.
|
||||
#[default]
|
||||
Normal,
|
||||
/// One line per node: the same width, half the height.
|
||||
Far,
|
||||
/// One dot per node: a quarter of the width, a third of the height.
|
||||
Overview,
|
||||
}
|
||||
|
||||
impl Zoom {
|
||||
/// One step closer.
|
||||
pub fn closer(self) -> Zoom {
|
||||
match self {
|
||||
Zoom::Normal | Zoom::Far => Zoom::Normal,
|
||||
Zoom::Overview => Zoom::Far,
|
||||
}
|
||||
}
|
||||
|
||||
/// One step further away.
|
||||
pub fn further(self) -> Zoom {
|
||||
match self {
|
||||
Zoom::Normal => Zoom::Far,
|
||||
Zoom::Far | Zoom::Overview => Zoom::Overview,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many canvas cells one screen cell stands for: across, and down.
|
||||
pub fn scale(self) -> (i32, i32) {
|
||||
match self {
|
||||
Zoom::Normal => (1, 1),
|
||||
Zoom::Far => (1, 2),
|
||||
Zoom::Overview => (4, 3),
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom's name, for the status bar.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Zoom::Normal => "Normal",
|
||||
Zoom::Far => "Far",
|
||||
Zoom::Overview => "Overview",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the canvas a view shows.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Camera {
|
||||
/// The canvas point shown in the view's top-left cell.
|
||||
pub origin: CanvasPos,
|
||||
pub zoom: Zoom,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
/// The screen cell showing a canvas point.
|
||||
pub fn to_screen(self, pos: CanvasPos) -> ScreenPos {
|
||||
let (across, down) = self.zoom.scale();
|
||||
ScreenPos::new((pos.x - self.origin.x).div_euclid(across), (pos.y - self.origin.y).div_euclid(down))
|
||||
}
|
||||
|
||||
/// The canvas point a screen cell shows (its top-left one, when zoomed
|
||||
/// out and a cell stands for several).
|
||||
pub fn to_canvas(self, cell: ScreenPos) -> CanvasPos {
|
||||
self.origin + self.cells(cell.col, cell.row)
|
||||
}
|
||||
|
||||
/// Moves the view by whole screen cells (to the right and down when
|
||||
/// positive).
|
||||
pub fn pan(&mut self, cols: i32, rows: i32) {
|
||||
self.origin += self.cells(cols, rows);
|
||||
}
|
||||
|
||||
/// Puts a canvas point in the middle of a view of this size.
|
||||
pub fn center_on(&mut self, pos: CanvasPos, size: Size) {
|
||||
self.origin = pos + -self.cells(i32::from(size.width) / 2, i32::from(size.height) / 2);
|
||||
}
|
||||
|
||||
/// How far a number of screen columns and rows go on the canvas.
|
||||
pub fn cells(self, cols: i32, rows: i32) -> CanvasOffset {
|
||||
let (across, down) = self.zoom.scale();
|
||||
CanvasOffset::new(cols * across, rows * down)
|
||||
}
|
||||
|
||||
/// Pans just enough for `rect` to be on screen, `margin` cells from the
|
||||
/// edges when there's room (like vim's `scrolloff`). Returns whether the
|
||||
/// view moved.
|
||||
pub fn reveal(&mut self, rect: ScreenRect, size: Size, margin: i32) -> bool {
|
||||
let cols = shift_needed(rect.left(), rect.right(), size.width, margin);
|
||||
let rows = shift_needed(rect.top(), rect.bottom(), size.height, margin);
|
||||
self.pan(cols, rows);
|
||||
(cols, rows) != (0, 0)
|
||||
}
|
||||
|
||||
/// Changes the zoom, keeping `anchor` on the same screen cell.
|
||||
pub fn set_zoom(&mut self, zoom: Zoom, anchor: CanvasPos) {
|
||||
let cell = self.to_screen(anchor);
|
||||
self.zoom = zoom;
|
||||
self.origin = anchor + -self.cells(cell.col, cell.row);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far to pan along one axis for the span `start..end` to fit in
|
||||
/// `0..length`, `margin` cells from both ends. When it can't fit, its start
|
||||
/// is the part that shows.
|
||||
fn shift_needed(start: i32, end: i32, length: u16, margin: i32) -> i32 {
|
||||
let length = i32::from(length);
|
||||
let margin = margin.min((length - (end - start)) / 2).max(0);
|
||||
if start < margin {
|
||||
start - margin
|
||||
} else if end > length - margin {
|
||||
(end - (length - margin)).min(start - margin)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canvas_and_screen_convert_both_ways() {
|
||||
let mut camera = Camera { origin: CanvasPos::new(-10, 5), zoom: Zoom::Normal };
|
||||
assert_eq!(camera.to_screen(CanvasPos::new(0, 5)), ScreenPos::new(10, 0));
|
||||
assert_eq!(camera.to_canvas(ScreenPos::new(10, 0)), CanvasPos::new(0, 5));
|
||||
camera.zoom = Zoom::Overview;
|
||||
assert_eq!(camera.to_screen(CanvasPos::new(-11, 5)), ScreenPos::new(-1, 0), "rounds toward minus");
|
||||
assert_eq!(camera.to_screen(CanvasPos::new(-6, 8)), ScreenPos::new(1, 1));
|
||||
assert_eq!(camera.to_canvas(ScreenPos::new(1, 1)), CanvasPos::new(-6, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_keeps_the_anchor_in_place() {
|
||||
let mut camera = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Normal };
|
||||
let anchor = CanvasPos::new(40, 20);
|
||||
let before = camera.to_screen(anchor);
|
||||
camera.set_zoom(Zoom::Overview, anchor);
|
||||
assert_eq!(camera.to_screen(anchor), before);
|
||||
assert_eq!(Zoom::Overview.further(), Zoom::Overview);
|
||||
assert_eq!(Zoom::Overview.closer().closer(), Zoom::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revealing_moves_as_little_as_needed() {
|
||||
let size = Size::new(40, 10);
|
||||
let mut camera = Camera::default();
|
||||
assert!(!camera.reveal(ScreenRect::new(5, 3, 10, 3), size, 2), "already in view");
|
||||
assert!(camera.reveal(ScreenRect::new(35, 3, 10, 3), size, 2));
|
||||
assert_eq!(camera.origin.x, 7, "right edge now 2 cells from the view's edge");
|
||||
let mut camera = Camera::default();
|
||||
camera.reveal(ScreenRect::new(-20, 0, 80, 3), size, 2);
|
||||
assert_eq!(camera.origin.x, -20, "too wide: its start shows");
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! The canvas view: nodes placed freely in 2D, branches and cross-links
|
||||
//! drawn as right-angle lines, and three zoom levels.
|
||||
//!
|
||||
//! How a frame is made:
|
||||
//! 1. [`Scene::build`] works out what's drawn where: [`boxes`] sizes each
|
||||
//! node, the [`camera`] turns canvas positions into screen cells,
|
||||
//! [`route`] finds a path for every line, and [`lines`] joins them cell by
|
||||
//! cell.
|
||||
//! 2. Rendering the scene (in `render.rs`) writes it into the terminal.
|
||||
//! 3. Clicks are looked up in the same scene ([`Scene::node_at`]); hjkl uses
|
||||
//! [`navigate`], and [`placement`] decides where new nodes go.
|
||||
|
||||
pub mod boxes;
|
||||
pub mod camera;
|
||||
pub mod lines;
|
||||
pub mod navigate;
|
||||
pub mod placement;
|
||||
mod render;
|
||||
pub mod route;
|
||||
pub mod scene;
|
||||
|
||||
pub use camera::{Camera, ScreenPos, ScreenRect, Zoom};
|
||||
pub use lines::LineOwner;
|
||||
pub use scene::{Scene, SceneNode, SceneOptions, Shape};
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Moving between nodes with hjkl: the nearest node in a direction.
|
||||
|
||||
use crate::model::{CanvasRect, NodeId};
|
||||
use crate::ui::glyphs::Direction;
|
||||
|
||||
/// The node to go to from `from`, heading `direction`: the nearest one whose
|
||||
/// middle lies that way. Nodes within 45° of straight ahead come first, and
|
||||
/// being off to the side counts double against a node, so moving right
|
||||
/// prefers what's straight ahead. Rows count double too, because a terminal
|
||||
/// cell is about twice as tall as it is wide.
|
||||
///
|
||||
/// `candidates` are the nodes on show, with their boxes at Normal zoom
|
||||
/// (wherever the camera is, so the choice doesn't change with the zoom).
|
||||
pub fn nearest(
|
||||
from: CanvasRect,
|
||||
direction: Direction,
|
||||
candidates: impl IntoIterator<Item = (NodeId, CanvasRect)>,
|
||||
) -> Option<NodeId> {
|
||||
let origin = from.center();
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter_map(|(id, rect)| {
|
||||
let to = rect.center();
|
||||
let (across, down) = (to.x - origin.x, (to.y - origin.y) * 2);
|
||||
let (ahead, aside) = match direction {
|
||||
Direction::Right => (across, down),
|
||||
Direction::Left => (-across, down),
|
||||
Direction::Down => (down, across),
|
||||
Direction::Up => (-down, across),
|
||||
};
|
||||
let off_course = aside.abs() > ahead;
|
||||
(ahead > 0).then_some((off_course, ahead + 2 * aside.abs(), id))
|
||||
})
|
||||
.min()
|
||||
.map(|(_, _, id)| id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
use crate::view::canvas::boxes::box_rects;
|
||||
|
||||
#[test]
|
||||
fn hjkl_goes_to_the_nearest_node_that_way() {
|
||||
let (doc, g) = garden();
|
||||
let rects = box_rects(&doc, 30, Glyphs::Unicode);
|
||||
let go =
|
||||
|from, direction| nearest(rects[&from], direction, rects.iter().map(|(id, rect)| (*id, *rect)));
|
||||
assert_eq!(go(g.root, Direction::Right), Some(g.beds), "straight ahead beats above or below");
|
||||
assert_eq!(go(g.beds, Direction::Up), Some(g.soil));
|
||||
assert_eq!(go(g.beds, Direction::Down), Some(g.watering));
|
||||
assert_eq!(go(g.beds, Direction::Right), Some(g.cedar));
|
||||
assert_eq!(go(g.beds, Direction::Left), Some(g.root));
|
||||
assert_eq!(go(g.root, Direction::Down), Some(g.compost));
|
||||
assert_eq!(go(g.cedar, Direction::Right), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Where new nodes go on the canvas: next to their parent or sibling, in a
|
||||
//! free spot. Nothing else moves to make room.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::model::{CanvasPos, CanvasRect, CanvasSize, Document, NodeId};
|
||||
|
||||
/// Columns between a parent and its children: room for the branch lines.
|
||||
const BRANCH_GAP: i32 = 6;
|
||||
/// Rows kept free between boxes stacked one above another.
|
||||
const ROW_GAP: i32 = 1;
|
||||
|
||||
/// Where a new last child of `parent` goes: to the right of the parent, below
|
||||
/// its last child (level with the parent for a first child), then further
|
||||
/// down until it's clear of every other box.
|
||||
pub fn child_spot(
|
||||
doc: &Document,
|
||||
parent: NodeId,
|
||||
size: CanvasSize,
|
||||
rects: &BTreeMap<NodeId, CanvasRect>,
|
||||
) -> CanvasPos {
|
||||
let Some(parent_rect) = rects.get(&parent) else { return CanvasPos::default() };
|
||||
let start = match doc.children(parent).last().and_then(|child| rects.get(child)) {
|
||||
Some(last) => CanvasPos::new(last.left(), last.bottom() + ROW_GAP),
|
||||
None => CanvasPos::new(parent_rect.right() + BRANCH_GAP, parent_rect.top()),
|
||||
};
|
||||
free_spot(start, size, rects)
|
||||
}
|
||||
|
||||
/// Where a new node just after `sibling` goes: right below it, then further
|
||||
/// down until it's clear.
|
||||
pub fn below_spot(sibling: NodeId, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
||||
let Some(rect) = rects.get(&sibling) else { return CanvasPos::default() };
|
||||
free_spot(CanvasPos::new(rect.left(), rect.bottom() + ROW_GAP), size, rects)
|
||||
}
|
||||
|
||||
/// The first spot at `start` or below it where a box of `size` keeps clear of
|
||||
/// every box in `rects` (by a row above and below).
|
||||
pub fn free_spot(start: CanvasPos, size: CanvasSize, rects: &BTreeMap<NodeId, CanvasRect>) -> CanvasPos {
|
||||
let mut spot = start;
|
||||
// Each step jumps below the box in the way, and no box is in the way
|
||||
// twice, so there are at most as many steps as boxes.
|
||||
for _ in 0..=rects.len() {
|
||||
let wanted = CanvasRect::new(
|
||||
CanvasPos::new(spot.x, spot.y - ROW_GAP),
|
||||
CanvasSize::new(size.width, size.height + 2 * ROW_GAP as u16),
|
||||
);
|
||||
match rects.values().filter(|rect| rect.overlaps(wanted)).map(|rect| rect.bottom()).max() {
|
||||
Some(bottom) => spot.y = bottom + ROW_GAP,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
spot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::ui::glyphs::Glyphs;
|
||||
use crate::view::canvas::boxes::box_rects;
|
||||
|
||||
#[test]
|
||||
fn new_nodes_find_a_free_spot_nearby() {
|
||||
let (doc, g) = garden();
|
||||
let rects = box_rects(&doc, 30, Glyphs::Unicode);
|
||||
let size = CanvasSize::new(10, 3);
|
||||
assert_eq!(child_spot(&doc, g.root, size, &rects), CanvasPos::new(22, 12), "below the last child");
|
||||
assert_eq!(
|
||||
child_spot(&doc, g.cedar, size, &rects),
|
||||
CanvasPos::new(55, 4),
|
||||
"first child: level with it"
|
||||
);
|
||||
assert_eq!(
|
||||
below_spot(g.soil, size, &rects),
|
||||
CanvasPos::new(22, 12),
|
||||
"Raised beds and Watering are in the way"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Drawing a scene into the terminal's buffer: the lines first, then the
|
||||
//! arrowheads and labels over them, then the nodes on top.
|
||||
|
||||
use ratatui::buffer::{Buffer, Cell};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use super::boxes::truncate;
|
||||
use super::camera::ScreenPos;
|
||||
use super::scene::{Scene, SceneNode, Shape};
|
||||
|
||||
impl Widget for &Scene {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let mut painter = Painter { area, buf };
|
||||
for (at, glyph, color) in self.lines.cells(self.glyphs) {
|
||||
painter.put(at, glyph, Style::new().fg(color));
|
||||
}
|
||||
for mark in &self.marks {
|
||||
painter.text(mark.at, &mark.text, Style::new().fg(mark.color), |_| true);
|
||||
}
|
||||
for (index, node) in self.nodes.iter().enumerate() {
|
||||
// Only draw where this node is the topmost one.
|
||||
let mine = |at: ScreenPos| self.covered_at(at) == Some(index);
|
||||
match &node.shape {
|
||||
Shape::Box { lines, tags, badge, .. } => {
|
||||
self.draw_box(&mut painter, node, lines, tags, badge, mine)
|
||||
}
|
||||
Shape::Label(text) => self.draw_label(&mut painter, node, text),
|
||||
Shape::Dot => {
|
||||
let style =
|
||||
if node.selected { self.selected_style() } else { Style::new().fg(node.color) };
|
||||
painter.put(node.rect.pos, self.glyphs.bullet(), style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// A box: the border is already drawn (it's in the line layer); this
|
||||
/// clears the inside and writes the title, the tags and the badge.
|
||||
fn draw_box(
|
||||
&self,
|
||||
painter: &mut Painter<'_>,
|
||||
node: &SceneNode,
|
||||
lines: &[String],
|
||||
tags: &str,
|
||||
badge: &str,
|
||||
mine: impl Fn(ScreenPos) -> bool,
|
||||
) {
|
||||
let rect = node.rect;
|
||||
for row in rect.top() + 1..rect.bottom() - 1 {
|
||||
for col in rect.left() + 1..rect.right() - 1 {
|
||||
let at = ScreenPos::new(col, row);
|
||||
if mine(at) {
|
||||
painter.put(at, ' ', Style::reset());
|
||||
}
|
||||
}
|
||||
}
|
||||
let title_style =
|
||||
if node.selected { Style::new().add_modifier(Modifier::BOLD) } else { Style::new() };
|
||||
for (row, line) in (rect.top() + 1..).zip(lines) {
|
||||
painter.text(ScreenPos::new(rect.left() + 2, row), line, title_style, &mine);
|
||||
}
|
||||
|
||||
// The bottom border: ─ #tags ─ … ─ badge ─
|
||||
let bottom = rect.bottom() - 1;
|
||||
let badge_start = if badge.is_empty() {
|
||||
rect.right() - 1
|
||||
} else {
|
||||
let text = format!(" {badge} ");
|
||||
let start = rect.right() - 2 - text.width() as i32;
|
||||
painter.text(ScreenPos::new(start, bottom), &text, Style::new().fg(self.muted), &mine);
|
||||
start - 1
|
||||
};
|
||||
if !tags.is_empty() {
|
||||
let room = usize::try_from(badge_start - (rect.left() + 2) - 2).unwrap_or(0);
|
||||
let text = format!(" {} ", truncate(tags, room, self.glyphs.ellipsis()));
|
||||
let color = if node.color == Color::Reset { self.muted } else { node.color };
|
||||
painter.text(ScreenPos::new(rect.left() + 2, bottom), &text, Style::new().fg(color), &mine);
|
||||
}
|
||||
}
|
||||
|
||||
/// Far zoom: a colored bullet and the title on one line.
|
||||
fn draw_label(&self, painter: &mut Painter<'_>, node: &SceneNode, text: &str) {
|
||||
if node.selected {
|
||||
painter.text(node.rect.pos, text, self.selected_style(), |_| true);
|
||||
return;
|
||||
}
|
||||
let mut chars = text.chars();
|
||||
if let Some(bullet) = chars.next() {
|
||||
painter.put(node.rect.pos, bullet, Style::new().fg(node.color));
|
||||
let rest = ScreenPos::new(node.rect.left() + 1, node.rect.top());
|
||||
painter.text(rest, chars.as_str(), Style::new(), |_| true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's node when zoomed out: dark text on the accent color.
|
||||
fn selected_style(&self) -> Style {
|
||||
Style::new().fg(Color::Black).bg(self.accent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes into the part of the buffer the canvas covers, in screen cells
|
||||
/// counted from its top-left corner; anything outside is dropped.
|
||||
pub(super) struct Painter<'a> {
|
||||
pub(super) area: Rect,
|
||||
pub(super) buf: &'a mut Buffer,
|
||||
}
|
||||
|
||||
impl Painter<'_> {
|
||||
fn cell(&mut self, at: ScreenPos) -> Option<&mut Cell> {
|
||||
let col = u16::try_from(at.col).ok().filter(|&col| col < self.area.width)?;
|
||||
let row = u16::try_from(at.row).ok().filter(|&row| row < self.area.height)?;
|
||||
self.buf.cell_mut((self.area.x + col, self.area.y + row))
|
||||
}
|
||||
|
||||
/// Puts one character in a cell.
|
||||
pub(super) fn put(&mut self, at: ScreenPos, glyph: char, style: Style) {
|
||||
if let Some(cell) = self.cell(at) {
|
||||
cell.set_char(glyph).set_style(style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes text from `at` rightward, on the cells `allowed` lets through.
|
||||
pub(super) fn text(
|
||||
&mut self,
|
||||
at: ScreenPos,
|
||||
text: &str,
|
||||
style: Style,
|
||||
allowed: impl Fn(ScreenPos) -> bool,
|
||||
) {
|
||||
let mut col = at.col;
|
||||
let mut last: Option<ScreenPos> = None;
|
||||
for c in text.chars() {
|
||||
let width = c.width().unwrap_or(0) as i32;
|
||||
if width == 0 {
|
||||
// An accent written as its own character joins the previous cell.
|
||||
if let Some(cell) = last.and_then(|previous| self.cell(previous)) {
|
||||
let joined = format!("{}{c}", cell.symbol());
|
||||
cell.set_symbol(&joined);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let here = ScreenPos::new(col, at.row);
|
||||
last = None;
|
||||
if allowed(here) && self.cell(here).is_some() {
|
||||
if let Some(cell) = self.cell(here) {
|
||||
cell.set_char(c).set_style(style);
|
||||
}
|
||||
// A wide character covers the next cell too.
|
||||
for extra in 1..width {
|
||||
if let Some(cell) = self.cell(ScreenPos::new(col + extra, at.row)) {
|
||||
cell.reset();
|
||||
}
|
||||
}
|
||||
last = Some(here);
|
||||
}
|
||||
col += width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ratatui::layout::Size;
|
||||
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::model::{CanvasPos, Fold, Label, Link};
|
||||
use crate::ui::theme::Theme;
|
||||
use crate::view::canvas::{Camera, SceneOptions, Zoom};
|
||||
|
||||
/// Prints the example document (`examples/garden.cum`) at each zoom, to
|
||||
/// look at the canvas without a terminal:
|
||||
/// `cargo test show_canvas -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "prints the canvas; run it by name"]
|
||||
fn show_canvas() {
|
||||
let doc = crate::storage::document_file::parse(include_str!("../../../examples/garden.cum")).unwrap();
|
||||
for zoom in [Zoom::Normal, Zoom::Far, Zoom::Overview] {
|
||||
let camera = Camera { origin: CanvasPos::new(-1, 0), zoom };
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
doc.root(),
|
||||
camera,
|
||||
Size::new(84, 36),
|
||||
&Theme::default(),
|
||||
SceneOptions::default(),
|
||||
);
|
||||
println!("── {} {}", zoom.name(), "─".repeat(70));
|
||||
for row in snapshot(&scene) {
|
||||
println!("{row}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene as plain text, one string per row.
|
||||
fn snapshot(scene: &Scene) -> Vec<String> {
|
||||
let area = Rect::new(0, 0, scene.size().width, scene.size().height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
scene.render(area, &mut buf);
|
||||
(0..area.height)
|
||||
.map(|row| (0..area.width).map(|col| buf[(col, row)].symbol().to_string()).collect::<String>())
|
||||
.map(|row| row.trim_end().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_garden_at_normal_zoom() {
|
||||
let (doc, g) = garden();
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
g.beds,
|
||||
Camera::default(),
|
||||
Size::new(52, 17),
|
||||
&Theme::default(),
|
||||
SceneOptions::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&scene),
|
||||
vec![
|
||||
" ┌──────┐",
|
||||
" ┌───┤ Soil │",
|
||||
" │ └──────┘",
|
||||
" │",
|
||||
"╭──────────────╮ │ ┏━━━━━━━━━━━━━┓ ┌───────┐",
|
||||
"│ Garden plans ├──┼───┨ Raised beds ┠───┤ Cedar │",
|
||||
"╰──────────────╯ │ ┗━━━━━━━━━━━━━┛ └───────┘",
|
||||
" │",
|
||||
" │ ┌──────────┐",
|
||||
" └───┤ Watering │",
|
||||
" └──────────┘",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"┌───────────────────┐",
|
||||
"│ ask about compost │",
|
||||
"└───────────────────┘",
|
||||
]
|
||||
);
|
||||
assert_eq!(scene.node_at(crate::view::canvas::ScreenPos::new(30, 5)), Some(g.beds));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_branches_links_and_far_zoom() {
|
||||
let (mut doc, g) = garden();
|
||||
doc.edit(|e| {
|
||||
e.update(g.beds, |node| node.fold = Fold::Closed)?;
|
||||
let mut link = Link::new(g.compost, g.watering).unwrap();
|
||||
link.label = Some(Label::parse("feeds").unwrap());
|
||||
e.link(link)?;
|
||||
e.update(g.compost, |node| node.pos = CanvasPos::new(0, 16))
|
||||
})
|
||||
.unwrap();
|
||||
let scene = Scene::build(
|
||||
&doc,
|
||||
g.root,
|
||||
Camera::default(),
|
||||
Size::new(52, 19),
|
||||
&Theme::default(),
|
||||
SceneOptions::default(),
|
||||
);
|
||||
let text = snapshot(&scene);
|
||||
assert!(text[6].ends_with("── +1 ─┘"), "the hidden Cedar shows as +1: {text:#?}");
|
||||
assert!(text.iter().any(|row| row.contains("feeds")), "the link's label: {text:#?}");
|
||||
assert_eq!(scene.node_at(crate::view::canvas::ScreenPos::new(42, 5)), None, "Cedar is folded away");
|
||||
|
||||
// Far zoom: one line per node, half the height. The link climbs from
|
||||
// the compost note up into Watering, labeled where it runs level.
|
||||
let far = Camera { origin: CanvasPos::new(0, 0), zoom: Zoom::Far };
|
||||
let scene =
|
||||
Scene::build(&doc, g.root, far, Size::new(40, 9), &Theme::default(), SceneOptions::default());
|
||||
assert_eq!(
|
||||
snapshot(&scene),
|
||||
vec![
|
||||
" ┌─── ● Soil",
|
||||
" │",
|
||||
"● Garden plans ──┼─── ● Raised beds +1",
|
||||
" │",
|
||||
" └─── ● Watering",
|
||||
" ▲",
|
||||
" ┌╌╌╌╌╌ feeds ╌╌╌╌╌╌┘",
|
||||
" ╎",
|
||||
"● ask about com…",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Routes for lines between two boxes on screen: straight runs joined by
|
||||
//! right-angle bends, from an edge of one box to an edge of the other.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭────────╮ ┌──────┐ A box to the right: leave by the right edge,
|
||||
//! │ parent ├──┼──────┤ child bend once or twice, arrive by the left edge.
|
||||
//! ╰────────╯ └──────┤ child Branches bend close to the parent, so siblings
|
||||
//! share the bend like a bus.
|
||||
//! ```
|
||||
|
||||
use super::camera::{ScreenPos, ScreenRect};
|
||||
use super::lines::{direction_between, step};
|
||||
use crate::ui::glyphs::Direction;
|
||||
|
||||
/// Where a line that has to change rows (or columns) bends.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Bend {
|
||||
/// Close to the start: a parent's branches leave together and share one
|
||||
/// bend, like a bus.
|
||||
NearStart,
|
||||
/// Halfway between the boxes: for cross-links.
|
||||
Halfway,
|
||||
}
|
||||
|
||||
/// A line between two boxes, as the cells where it starts, bends and ends.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Route {
|
||||
/// The cell on the first box's edge where the line leaves.
|
||||
pub start: ScreenPos,
|
||||
/// The cells where the line turns.
|
||||
pub bends: Vec<ScreenPos>,
|
||||
/// The cell on the second box's edge where the line arrives.
|
||||
pub end: ScreenPos,
|
||||
}
|
||||
|
||||
/// How far from its start a [`Bend::NearStart`] line bends, across and down.
|
||||
const NEAR_START_COLS: i32 = 3;
|
||||
const NEAR_START_ROWS: i32 = 2;
|
||||
|
||||
/// The route from box `from` to box `to`, or `None` when the boxes touch or
|
||||
/// overlap and no line fits between them.
|
||||
///
|
||||
/// Branches leave sideways whenever there's a free column between the boxes,
|
||||
/// so all of a parent's branches share their bend. Cross-links go through the
|
||||
/// wider gap, sideways or up and down (rows count double: cells are tall).
|
||||
pub fn route(from: ScreenRect, to: ScreenRect, bend: Bend) -> Option<Route> {
|
||||
let cols_between = (to.left() - from.right()).max(from.left() - to.right());
|
||||
let rows_between = (to.top() - from.bottom()).max(from.top() - to.bottom());
|
||||
let go_sideways = cols_between > 0
|
||||
&& match bend {
|
||||
Bend::NearStart => true,
|
||||
Bend::Halfway => rows_between <= 0 || cols_between >= rows_between * 2,
|
||||
};
|
||||
if go_sideways {
|
||||
let route = if to.left() > from.right() {
|
||||
let start = ScreenPos::new(from.right() - 1, from.mid_row());
|
||||
sideways(start, ScreenPos::new(to.left(), to.mid_row()), bend)
|
||||
} else {
|
||||
let start = ScreenPos::new(from.left(), from.mid_row());
|
||||
sideways(start, ScreenPos::new(to.right() - 1, to.mid_row()), bend)
|
||||
};
|
||||
Some(route)
|
||||
} else if to.top() > from.bottom() {
|
||||
let start = ScreenPos::new(from.mid_col(), from.bottom() - 1);
|
||||
Some(upright(start, ScreenPos::new(to.mid_col(), to.top()), bend))
|
||||
} else if to.bottom() < from.top() {
|
||||
let start = ScreenPos::new(from.mid_col(), from.top());
|
||||
Some(upright(start, ScreenPos::new(to.mid_col(), to.bottom() - 1), bend))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A route that leaves and arrives sideways, bending in a column between.
|
||||
fn sideways(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route {
|
||||
if start.row == end.row {
|
||||
return Route { start, bends: Vec::new(), end };
|
||||
}
|
||||
let way = (end.col - start.col).signum();
|
||||
let near = start.col + way * NEAR_START_COLS;
|
||||
let col = match bend {
|
||||
Bend::NearStart if (end.col - near) * way > 0 => near,
|
||||
Bend::NearStart | Bend::Halfway => (start.col + end.col) / 2,
|
||||
};
|
||||
Route { start, bends: vec![ScreenPos::new(col, start.row), ScreenPos::new(col, end.row)], end }
|
||||
}
|
||||
|
||||
/// A route that leaves and arrives up or down, bending in a row between.
|
||||
fn upright(start: ScreenPos, end: ScreenPos, bend: Bend) -> Route {
|
||||
if start.col == end.col {
|
||||
return Route { start, bends: Vec::new(), end };
|
||||
}
|
||||
let way = (end.row - start.row).signum();
|
||||
let near = start.row + way * NEAR_START_ROWS;
|
||||
let row = match bend {
|
||||
Bend::NearStart if (end.row - near) * way > 0 => near,
|
||||
Bend::NearStart | Bend::Halfway => (start.row + end.row) / 2,
|
||||
};
|
||||
Route { start, bends: vec![ScreenPos::new(start.col, row), ScreenPos::new(end.col, row)], end }
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// Every corner of the line, start and end included.
|
||||
pub fn points(&self) -> Vec<ScreenPos> {
|
||||
let mut points = vec![self.start];
|
||||
points.extend(&self.bends);
|
||||
points.push(self.end);
|
||||
points
|
||||
}
|
||||
|
||||
/// The way the line leaves its start.
|
||||
pub fn start_direction(&self) -> Option<Direction> {
|
||||
direction_between(self.start, *self.bends.first().unwrap_or(&self.end))
|
||||
}
|
||||
|
||||
/// The way the line is going when it arrives at its end.
|
||||
pub fn end_direction(&self) -> Option<Direction> {
|
||||
direction_between(*self.bends.last().unwrap_or(&self.start), self.end)
|
||||
}
|
||||
|
||||
/// Where an arrowhead pointing into the end box goes: the cell just before
|
||||
/// the end, and the way it points.
|
||||
pub fn arrow_at_end(&self) -> Option<(ScreenPos, Direction)> {
|
||||
let way = self.end_direction()?;
|
||||
Some((step(self.end, way.opposite()), way))
|
||||
}
|
||||
|
||||
/// Where an arrowhead pointing back into the start box goes.
|
||||
pub fn arrow_at_start(&self) -> Option<(ScreenPos, Direction)> {
|
||||
let way = self.start_direction()?;
|
||||
Some((step(self.start, way), way.opposite()))
|
||||
}
|
||||
|
||||
/// Where a label `width` columns wide fits: centered on the longest level
|
||||
/// run of the line, keeping clear of its ends.
|
||||
pub fn label_spot(&self, width: i32) -> Option<ScreenPos> {
|
||||
let points = self.points();
|
||||
points
|
||||
.windows(2)
|
||||
.filter(|pair| pair[0].row == pair[1].row)
|
||||
.map(|pair| {
|
||||
let (low, high) = (pair[0].col.min(pair[1].col), pair[0].col.max(pair[1].col));
|
||||
// Keep a cell of line visible at each end of the run.
|
||||
(low + 2, high - 2, pair[0].row)
|
||||
})
|
||||
.filter(|(low, high, _)| high - low + 1 >= width)
|
||||
.max_by_key(|(low, high, _)| high - low)
|
||||
.map(|(low, high, row)| ScreenPos::new((low + high + 1 - width) / 2, row))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn points(route: &Route) -> Vec<(i32, i32)> {
|
||||
route.points().iter().map(|p| (p.col, p.row)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_side_by_side_join_sideways() {
|
||||
let parent = ScreenRect::new(0, 4, 16, 3);
|
||||
let level = route(parent, ScreenRect::new(22, 4, 15, 3), Bend::NearStart).unwrap();
|
||||
assert_eq!(points(&level), vec![(15, 5), (22, 5)]);
|
||||
let above = route(parent, ScreenRect::new(22, 0, 8, 3), Bend::NearStart).unwrap();
|
||||
assert_eq!(points(&above), vec![(15, 5), (18, 5), (18, 1), (22, 1)], "bends near the parent");
|
||||
let link = route(parent, ScreenRect::new(22, 0, 8, 3), Bend::Halfway).unwrap();
|
||||
assert_eq!(points(&link), vec![(15, 5), (18, 5), (18, 1), (22, 1)]);
|
||||
let left = route(ScreenRect::new(30, 0, 8, 3), ScreenRect::new(0, 0, 8, 3), Bend::Halfway).unwrap();
|
||||
assert_eq!(points(&left), vec![(30, 1), (7, 1)]);
|
||||
assert_eq!(left.arrow_at_end(), Some((ScreenPos::new(8, 1), Direction::Left)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_above_each_other_join_up_and_down() {
|
||||
let below = route(ScreenRect::new(0, 0, 9, 3), ScreenRect::new(4, 8, 9, 3), Bend::NearStart).unwrap();
|
||||
assert_eq!(points(&below), vec![(4, 2), (4, 4), (8, 4), (8, 8)]);
|
||||
assert_eq!(below.arrow_at_end(), Some((ScreenPos::new(8, 7), Direction::Down)));
|
||||
assert_eq!(below.arrow_at_start(), Some((ScreenPos::new(4, 3), Direction::Up)));
|
||||
assert!(route(ScreenRect::new(0, 0, 9, 3), ScreenRect::new(5, 2, 9, 3), Bend::Halfway).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_take_the_wider_gap_and_branches_stay_sideways() {
|
||||
// One free column, five free rows: a link goes up, a branch sideways.
|
||||
let (low, high) = (ScreenRect::new(0, 16, 21, 3), ScreenRect::new(22, 8, 12, 3));
|
||||
assert_eq!(
|
||||
points(&route(low, high, Bend::Halfway).unwrap()),
|
||||
vec![(10, 16), (10, 13), (28, 13), (28, 10)]
|
||||
);
|
||||
assert_eq!(route(low, high, Bend::NearStart).unwrap().start, ScreenPos::new(20, 17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_sit_on_the_longest_level_run() {
|
||||
let line = Route { start: ScreenPos::new(0, 0), bends: vec![], end: ScreenPos::new(20, 0) };
|
||||
assert_eq!(line.label_spot(7), Some(ScreenPos::new(7, 0)));
|
||||
assert_eq!(line.label_spot(18), None, "too long for the run");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The views, one module each. Stage 5 of the build adds the interface they
|
||||
//! all share; for now there's the canvas.
|
||||
|
||||
pub mod canvas;
|
||||
Reference in new issue
Block a user