use ratatui::layout::{Constraint, Rect};
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::{Block, Clear};
use ratatui::Frame;
use crate::model::edges::Related;
use crate::model::join::BeadKey;
use crate::model::snapshot::Node;
use crate::model::types::{Edge, Status};
use crate::view::draw::tone::status_style;
use crate::view::fitted::{indent, Fitted};
use crate::view::forest::Forest;
use crate::view::markdown;
use crate::view::palette;
use crate::view::phrase;
use crate::view::row::{agent_marker, status_glyph};
use crate::view::{Motion, Notch};
const DESCRIPTION: &str = "DESCRIPTION";
const NOTES: &str = "NOTES";
const PARENT: &str = "PARENT";
const DEPENDS_ON: &str = "DEPENDS ON";
const BLOCKS: &str = "BLOCKS";
const UP: char = '↑';
const OUT: char = '→';
const BACK: char = '←';
const SHARE: (u16, u16) = (4, 5);
const BORDERS: u16 = 2;
const FLOOR_WIDTH: u16 = 80 + BORDERS;
const FLOOR_HEIGHT: u16 = 24;
fn offered(screen: u16, floor: u16) -> u16 {
let share = u32::from(screen) * u32::from(SHARE.0) / u32::from(SHARE.1);
u16::try_from(share)
.unwrap_or(u16::MAX)
.max(floor)
.min(screen)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Show {
from: usize,
room: usize,
total: usize,
on: Option<String>,
}
impl Show {
pub fn scroll(&mut self, motion: Motion) -> bool {
let furthest = self.total.saturating_sub(self.room);
let half = (self.room / 2).max(1);
let to = match motion {
Motion::PreviousRow => self.from.saturating_sub(1),
Motion::NextRow => self.from + 1,
Motion::HalfScreenUp => self.from.saturating_sub(half),
Motion::HalfScreenDown => self.from + half,
Motion::FirstRow => 0,
Motion::LastRow => furthest,
}
.min(furthest);
let moved = to != self.from;
self.from = to;
moved
}
pub fn scrolled(&mut self, notch: Notch, lines: usize) -> bool {
let to = match notch {
Notch::Up => self.from.saturating_sub(lines),
Notch::Down => (self.from + lines).min(self.total.saturating_sub(self.room)),
};
let moved = to != self.from;
self.from = to;
moved
}
fn fit(&mut self, total: usize, room: usize) {
self.total = total;
self.room = room;
self.from = self.from.min(total.saturating_sub(room));
}
fn scrolls(&self) -> bool {
self.total > self.room
}
pub fn on(&self) -> Option<&str> {
self.on.as_deref()
}
pub fn go_to(&mut self, on: &str) {
self.on = Some(on.to_string());
}
pub fn go_nowhere(&mut self) {
self.on = None;
}
fn reveal(&mut self, row: usize) {
if row < self.from {
self.from = row;
} else if self.room > 0 && row >= self.from + self.room {
self.from = row + 1 - self.room;
}
}
}
pub fn stepped(
from: Option<usize>,
count: usize,
followable: impl Fn(usize) -> bool,
) -> Option<usize> {
if count == 0 {
return None;
}
let at = from.unwrap_or(count - 1);
(1..=count)
.map(|step| (at + step) % count)
.find(|at| followable(*at))
}
pub fn at(node: &Node, id: &str) -> Option<usize> {
related(node).iter().position(|named| named.id == id)
}
pub fn related(node: &Node) -> Vec<&Related> {
node.parent
.iter()
.chain(&node.depends_on)
.chain(&node.blocks)
.collect()
}
pub fn key_of(forest: &Forest, related: &Related) -> Option<BeadKey> {
Some(BeadKey {
project: forest.place()?.tree.project.clone(),
id: related.id.clone(),
})
}
pub fn followable(forest: &Forest, related: &Related) -> bool {
key_of(forest, related).is_some_and(|key| forest.draws(&key))
}
pub struct Page {
pub rows: Vec<Vec<Span<'static>>>,
pub related: Vec<usize>,
}
pub fn selected(forest: &Forest) -> Option<&Node> {
forest
.lines()
.get(forest.selected_line())?
.bead()
.and_then(|key| forest.snapshot().node(key))
}
struct Laid {
page: Page,
window: Rect,
inner: Rect,
}
fn lay_out(area: Rect, node: &Node) -> Laid {
let width = offered(area.width, FLOOR_WIDTH);
let height = offered(area.height, FLOOR_HEIGHT);
let page = said(
node,
width.saturating_sub(BORDERS) as usize,
height.saturating_sub(BORDERS) as usize,
);
let window = show_window(area, width, page.rows.len());
Laid {
inner: Block::bordered().inner(window),
page,
window,
}
}
#[cfg_attr(test, derive(Debug, PartialEq))]
pub enum Drawn<'a> {
Related(&'a str),
Page,
Beyond,
}
pub fn drawn_at<'a>(area: Rect, node: &'a Node, view: &Show, row: u16) -> Drawn<'a> {
let Laid { page, inner, .. } = lay_out(area, node);
if !(inner.y..inner.bottom()).contains(&row) {
return Drawn::Beyond;
}
let at = view.from + usize::from(row - inner.y);
related(node)
.into_iter()
.zip(page.related)
.find(|(_, drawn)| *drawn == at)
.map_or(Drawn::Page, |(named, _)| Drawn::Related(&named.id))
}
fn show_window(area: Rect, width: u16, rows: usize) -> Rect {
let wanted = u16::try_from(rows)
.unwrap_or(u16::MAX)
.saturating_add(BORDERS);
area.centered(
Constraint::Length(width),
Constraint::Length(wanted.min(offered(area.height, FLOOR_HEIGHT))),
)
}
pub fn said(node: &Node, width: usize, window: usize) -> Page {
let name = vec![
glyph(&node.status),
Span::raw(" "),
Span::styled(node.id.clone(), palette::IDENTITY),
Span::raw(indent()),
];
let where_the_title_starts = name.iter().map(Span::width).sum::<usize>();
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
for line in title_of(node, width.saturating_sub(where_the_title_starts), window) {
let mut row = if rows.is_empty() {
name.clone()
} else {
vec![Span::raw(" ".repeat(where_the_title_starts))]
};
row.extend(line);
rows.push(row);
}
let mut facts = vec![format!("P{}", node.priority), node.issue_type.clone()];
facts.extend(node.owner.clone());
rows.push(indented(vec![
Span::styled(
phrase::status_word(&node.status),
status_style(&node.status),
),
Span::raw(format!(" · {}", facts.join(" · "))),
]));
if let Some(agent) = &node.agent {
rows.push(indented(vec![Span::styled(
agent_marker(agent),
palette::AGENT,
)]));
}
let room = width.saturating_sub(indent().len());
let prose = |text: &str| {
markdown::rows(text, room)
.into_iter()
.map(indented)
.collect::<Vec<_>>()
};
let tied = |arrow: char, related: &[Related]| {
related
.iter()
.map(|related| {
let mut row = vec![Span::styled(format!("{arrow} "), palette::STRUCTURE)];
row.extend(related_row(related));
indented(row)
})
.collect::<Vec<_>>()
};
let mut related = Vec::new();
for (heading, body, names_beads) in [
(DESCRIPTION, prose(&node.description), false),
(NOTES, prose(&node.notes), false),
(PARENT, tied(UP, node.parent.as_slice()), true),
(DEPENDS_ON, tied(OUT, &node.depends_on), true),
(BLOCKS, tied(BACK, &node.blocks), true),
] {
if body.is_empty() {
continue;
}
rows.push(Vec::new());
rows.push(vec![Span::styled(heading, palette::SECTION)]);
if names_beads {
related.extend(rows.len()..rows.len() + body.len());
}
rows.extend(body);
}
Page { rows, related }
}
fn title_of(node: &Node, room: usize, window: usize) -> Vec<Vec<Span<'static>>> {
let as_written = vec![vec![Span::raw(node.title.clone())]];
if room == 0 {
return as_written;
}
let broken = markdown::wrapped(&node.title, room);
if broken.len() == 1 || broken.len() >= window {
return as_written;
}
broken
}
fn indented(row: Vec<Span<'static>>) -> Vec<Span<'static>> {
let mut said = vec![Span::raw(indent())];
said.extend(row);
said
}
fn glyph(status: &Status) -> Span<'static> {
Span::styled(status_glyph(status).to_string(), status_style(status))
}
fn related_row(related: &Related) -> Vec<Span<'static>> {
let Some(status) = &related.status else {
return vec![Span::raw(format!(
"{}{}{}",
related.id,
indent(),
phrase::not_in_the_answer()
))];
};
let mut said = format!(
" {}{}{}",
related.id,
indent(),
related.title.as_deref().unwrap_or_default()
);
if let Edge::Other(kind) = &related.edge {
said.push_str(&format!(" · {}", phrase::edge_kind(kind)));
}
vec![glyph(status), Span::styled(said, dimmed_if_closed(status))]
}
fn dimmed_if_closed(status: &Status) -> Style {
if status.is_closed() {
palette::TIER_FINISHED
} else {
Style::new()
}
}
pub fn show(frame: &mut Frame, area: Rect, node: &Node, view: &mut Show, follows: bool) {
let Laid {
page,
window,
inner,
} = lay_out(area, node);
if window.is_empty() {
return;
}
let block = Block::bordered();
view.fit(page.rows.len(), inner.height as usize);
let on = view
.on()
.and_then(|id| at(node, id))
.and_then(|at| page.related.get(at).copied());
if let Some(row) = on {
view.reveal(row);
}
let block = block.title(Span::styled(
phrase::way_back_from_bead(&node.id, view.scrolls(), follows),
palette::TITLE,
));
frame.render_widget(Clear, window);
frame.render_widget(block, window);
for (n, row) in page
.rows
.into_iter()
.skip(view.from)
.take(inner.height as usize)
.enumerate()
{
let at = view.from + n;
let drawn = Fitted::new(row, Vec::new(), Vec::new()).toned(palette::PAGE);
let drawn = if on == Some(at) {
drawn.selected()
} else {
drawn
};
frame.render_widget(
drawn,
Rect {
y: inner.y + n as u16,
..inner
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::edges::Related;
use crate::model::join::{AgentRef, JoinSource};
use crate::model::types::testing::key;
use crate::model::types::{Edge, PaneStatus, Status};
use ratatui::style::Modifier;
use crate::view::painted::{Painted, Run};
use pretty_assertions::assert_eq;
use ratatui::style::Color;
fn related(id: &str, edge: Edge, status: Status, title: &str) -> Related {
Related {
id: id.to_string(),
edge,
status: Some(status),
title: Some(title.to_string()),
}
}
fn a_bead() -> Node {
Node {
id: "orb-7.1".to_string(),
title: "re-point the dish".to_string(),
status: Status::InProgress,
issue_type: "task".to_string(),
priority: 2,
ready: false,
blocked_by: Vec::new(),
started_at: None,
closed_at: None,
badges: Vec::new(),
agent: Some(AgentRef {
pane: key("w:p1"),
pane_status: PaneStatus::Working,
title: Some("lifting the mast".to_string()),
source: JoinSource::AgentPane,
}),
anomalies: Vec::new(),
description: "Point it at the new bird.\n\nThe old one is gone.".to_string(),
notes: "The crane is booked for Tuesday.".to_string(),
owner: Some("kim".to_string()),
parent: Some(related(
"orb-7",
Edge::ParentChild,
Status::InProgress,
"lift the ground station",
)),
depends_on: vec![related(
"orb-7.3",
Edge::Blocks,
Status::Closed,
"lay the feeder cable",
)],
blocks: vec![related(
"orb-7.4",
Edge::Blocks,
Status::Open,
"file the licence",
)],
}
}
fn drawn(node: &Node, view: &mut Show, width: u16, height: u16) -> Vec<String> {
drawn_where(node, view, width, height, false)
}
fn drawn_where(
node: &Node,
view: &mut Show,
width: u16,
height: u16,
follows: bool,
) -> Vec<String> {
Painted::drawn_by(width, height, |frame| {
show(frame, frame.area(), node, view, follows)
})
.rows()
.into_iter()
.map(|row| row.trim_end().to_string())
.collect()
}
#[test]
fn the_bead_is_shown_as_bd_show_shows_it() {
assert_eq!(
drawn(&a_bead(), &mut Show::default(), 44, 22),
vec![
"┌orb-7.1 · Esc to go back──────────────────┐",
"│◐ orb-7.1 re-point the dish │",
"│ in_progress · P2 · task · kim │",
"│ ◍ lifting the mast · working │",
"│ │",
"│DESCRIPTION │",
"│ Point it at the new bird. │",
"│ │",
"│ The old one is gone. │",
"│ │",
"│NOTES │",
"│ The crane is booked for Tuesday. │",
"│ │",
"│PARENT │",
"│ ↑ ◐ orb-7 lift the ground station │",
"│ │",
"│DEPENDS ON │",
"│ → ✓ orb-7.3 lay the feeder cable │",
"│ │",
"│BLOCKS │",
"│ ← ○ orb-7.4 file the licence │",
"└──────────────────────────────────────────┘",
]
);
}
#[test]
fn sections_the_bead_has_nothing_for_are_left_out() {
let bare = Node {
agent: None,
description: String::new(),
notes: String::new(),
owner: None,
parent: None,
depends_on: Vec::new(),
blocks: Vec::new(),
..a_bead()
};
assert_eq!(
drawn(&bare, &mut Show::default(), 44, 4),
vec![
"┌orb-7.1 · Esc to go back──────────────────┐",
"│◐ orb-7.1 re-point the dish │",
"│ in_progress · P2 · task │",
"└──────────────────────────────────────────┘",
]
);
}
#[test]
fn the_description_wraps_to_the_window_rather_than_being_cut() {
let long = Node {
description: "one two three four five six seven eight nine ten eleven twelve"
.to_string(),
..a_bead()
};
let rows = drawn(&long, &mut Show::default(), 30, 30);
let wrapped: Vec<&str> = rows
.iter()
.skip_while(|row| !row.starts_with("│DESCRIPTION"))
.skip(1)
.take(3)
.map(|row| row.trim_matches(['│', ' ']))
.collect();
assert_eq!(
wrapped,
[
"one two three four five",
"six seven eight nine ten",
"eleven twelve"
],
"{rows:#?}"
);
assert!(
!wrapped.iter().any(|row| row.contains('…')),
"prose is wrapped, never cut: {rows:#?}"
);
}
const A_LONG_TITLE: &str = "Thirty-three of thirty-four forest rows are the terminal's default, so the three an agent is on are found by reading rather than by looking";
fn a_bead_with_a_long_title() -> Node {
Node {
title: A_LONG_TITLE.to_string(),
..a_bead()
}
}
fn the_name_drawn(rows: &[String]) -> Vec<&str> {
rows.iter()
.skip_while(|row| !row.contains("◐ orb-7.1"))
.take_while(|row| !row.contains("in_progress"))
.map(String::as_str)
.collect()
}
#[test]
fn a_title_too_long_for_the_window_wraps_rather_than_being_cut() {
let rows = drawn(&a_bead_with_a_long_title(), &mut Show::default(), 44, 30);
let name = the_name_drawn(&rows)
.iter()
.map(|row| row.trim_matches(['│', ' ']))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(
name.strip_prefix("◐ orb-7.1 "),
Some(A_LONG_TITLE),
"{rows:#?}"
);
}
#[test]
fn the_rows_a_wrapped_title_takes_hang_where_the_title_starts() {
let rows = drawn(&a_bead_with_a_long_title(), &mut Show::default(), 44, 30);
let name = the_name_drawn(&rows);
let (first, wrapped) = name.split_first().expect("the bead is named");
let under = first
.find("Thirty-three")
.map(|at| first[..at].chars().count())
.expect("the title starts on the row the bead is named on");
assert!(!wrapped.is_empty(), "the title did not wrap: {rows:#?}");
for row in wrapped {
assert_eq!(
row.find(|glyph: char| !matches!(glyph, '│' | ' '))
.map(|at| row[..at].chars().count()),
Some(under),
"a row of the title does not hang under it: {rows:#?}"
);
}
}
#[test]
fn a_title_the_window_has_room_for_is_drawn_as_it_was_written() {
let spaced = Node {
title: "re-point the dish".to_string(),
..a_bead()
};
assert_eq!(
drawn(&spaced, &mut Show::default(), 44, 22)[1],
"│◐ orb-7.1 re-point the dish │"
);
}
#[test]
fn a_window_a_wrapped_name_would_fill_cuts_the_title() {
assert_eq!(
drawn(&a_bead_with_a_long_title(), &mut Show::default(), 44, 6),
vec![
"┌orb-7.1 · Esc to go back · j, k to scroll─┐",
"│◐ orb-7.1 Thirty-three of thirty-four fo…│",
"│ in_progress · P2 · task · kim │",
"│ ◍ lifting the mast · working │",
"│ │",
"└──────────────────────────────────────────┘",
]
);
}
#[test]
fn a_window_with_no_room_beside_the_name_cuts_the_title() {
let rows = drawn(&a_bead(), &mut Show::default(), 13, 30);
let named = rows
.iter()
.position(|row| row.contains("◐ orb-7.1"))
.expect("the bead is named");
assert_eq!(rows[named], "│◐ orb-7.1 …│", "{rows:#?}");
assert_eq!(rows[named + 1], "│ in_progr…│", "{rows:#?}");
}
#[test]
fn a_line_that_names_another_bead_is_cut_rather_than_wrapped() {
let named = Node {
agent: None,
description: String::new(),
notes: String::new(),
depends_on: Vec::new(),
blocks: Vec::new(),
..a_bead()
};
assert_eq!(
drawn(&named, &mut Show::default(), 24, 8),
vec![
"┌orb-7.1 · Esc to go ba┐",
"│◐ orb-7.1 re-point │",
"│ the dish │",
"│ in_progress · P2 · …│",
"│ │",
"│PARENT │",
"│ ↑ ◐ orb-7 lift the…│",
"└──────────────────────┘",
]
);
}
#[test]
fn a_window_too_short_for_the_bead_scrolls_by_motion() {
let mut view = Show::default();
let top = drawn(&a_bead(), &mut view, 44, 6);
assert_eq!(top[0], "┌orb-7.1 · Esc to go back · j, k to scroll─┐");
assert_eq!(top[1], "│◐ orb-7.1 re-point the dish │");
assert!(!view.scroll(Motion::PreviousRow), "already at the top");
assert!(view.scroll(Motion::NextRow));
let down_one = drawn(&a_bead(), &mut view, 44, 6);
assert_eq!(down_one[1], "│ in_progress · P2 · task · kim │");
assert!(view.scroll(Motion::LastRow));
}
#[test]
fn the_view_stops_at_the_last_row_of_the_bead() {
let mut view = Show::default();
drawn(&a_bead(), &mut view, 44, 6);
assert!(view.scroll(Motion::LastRow));
let bottom = drawn(&a_bead(), &mut view, 44, 6);
assert_eq!(bottom[4], "│ ← ○ orb-7.4 file the licence │");
assert!(!view.scroll(Motion::NextRow), "nothing below the last row");
assert!(!view.scroll(Motion::LastRow), "already there");
assert!(view.scroll(Motion::HalfScreenUp));
assert!(view.scroll(Motion::FirstRow));
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6)[1],
"│◐ orb-7.1 re-point the dish │"
);
}
#[test]
fn a_bead_that_fits_the_window_neither_scrolls_nor_says_it_does() {
let mut view = Show::default();
drawn(&a_bead(), &mut view, 44, 24);
assert!(!view.scroll(Motion::NextRow));
assert!(!view.scroll(Motion::LastRow));
}
#[test]
fn the_row_a_reference_was_drawn_on_names_the_bead_it_names() {
let bead = a_bead();
let mut view = Show::default();
let rows = drawn(&bead, &mut view, WIDE, TALL);
for (id, drawn_as) in [
("orb-7", "orb-7 lift the ground station"),
("orb-7.3", "orb-7.3 lay the feeder cable"),
("orb-7.4", "orb-7.4 file the licence"),
] {
assert_eq!(
drawn_at(over(WIDE, TALL), &bead, &view, drawn_on(&rows, drawn_as)),
Drawn::Related(id),
"the row {drawn_as:?} was drawn on names another bead: {rows:#?}"
);
}
}
#[test]
fn a_row_of_the_page_that_names_no_bead_is_the_page() {
let bead = a_bead();
let mut view = Show::default();
let rows = drawn(&bead, &mut view, WIDE, TALL);
for drawn_as in ["PARENT", "Point it at the new bird", "re-point the dish"] {
assert_eq!(
drawn_at(over(WIDE, TALL), &bead, &view, drawn_on(&rows, drawn_as)),
Drawn::Page,
"the row {drawn_as:?} was drawn on is not the page: {rows:#?}"
);
}
}
#[test]
fn the_windows_border_is_not_the_page() {
let bead = a_bead();
let mut view = Show::default();
let rows = drawn(&bead, &mut view, WIDE, TALL);
assert_eq!(
drawn_at(over(WIDE, TALL), &bead, &view, drawn_on(&rows, "┌orb-7.1")),
Drawn::Beyond,
"the title is drawn on the page: {rows:#?}"
);
assert_eq!(
drawn_at(over(WIDE, TALL), &bead, &view, drawn_on(&rows, "└─")),
Drawn::Beyond,
"the foot of the window is drawn on the page: {rows:#?}"
);
}
#[test]
fn a_row_the_window_is_not_drawn_on_is_not_the_page() {
let bead = a_bead();
let mut view = Show::default();
let over_a_taller_screen = over(WIDE, TALL * 2);
let rows = drawn(&bead, &mut view, WIDE, TALL * 2);
let above = drawn_on(&rows, "┌orb-7.1");
assert!(above > 0, "the window starts at the top of the screen");
assert_eq!(
drawn_at(over_a_taller_screen, &bead, &view, above - 1),
Drawn::Beyond,
"the row above the window is drawn on the page: {rows:#?}"
);
}
#[test]
fn a_scrolled_window_names_what_is_drawn_on_a_row_now() {
let bead = a_bead();
let mut view = Show::default();
let short = TALL / 2;
let before = drawn(&bead, &mut view, WIDE, short);
assert!(
!before.iter().any(|row| row.contains("file the licence")),
"the whole bead fits, so there is nothing to scroll: {before:#?}"
);
assert!(view.scroll(Motion::LastRow));
let after = drawn(&bead, &mut view, WIDE, short);
assert_eq!(
drawn_at(
over(WIDE, short),
&bead,
&view,
drawn_on(&after, "orb-7.4 file the licence")
),
Drawn::Related("orb-7.4"),
"the row the last reference was scrolled onto names another bead: {after:#?}"
);
}
#[test]
fn a_scrolled_window_can_draw_a_reference_under_its_border() {
let bead = a_bead();
let mut view = Show::default();
let barely_taller_than_the_sections = 6;
drawn(&bead, &mut view, WIDE, barely_taller_than_the_sections);
assert!(view.scroll(Motion::LastRow));
let rows = drawn(&bead, &mut view, WIDE, barely_taller_than_the_sections);
let under_the_border = drawn_on(&rows, "┌orb-7.1") + 1;
assert_eq!(
drawn_at(
over(WIDE, barely_taller_than_the_sections),
&bead,
&view,
under_the_border
),
Drawn::Related("orb-7.3"),
"no reference is drawn against the border, so this says nothing: {rows:#?}"
);
assert_eq!(
drawn_at(
over(WIDE, barely_taller_than_the_sections),
&bead,
&view,
under_the_border - 1
),
Drawn::Beyond,
"{rows:#?}"
);
}
const WIDE: u16 = 44;
const TALL: u16 = 22;
fn over(width: u16, height: u16) -> Rect {
Rect::new(0, 0, width, height)
}
fn drawn_on(rows: &[String], drawn_as: &str) -> u16 {
let on = rows
.iter()
.position(|row| row.contains(drawn_as))
.unwrap_or_else(|| panic!("{drawn_as:?} was drawn on no row of {rows:#?}"));
u16::try_from(on).expect("no screen is that tall")
}
#[test]
fn the_way_back_is_the_windows_title_however_short_the_screen() {
for height in [1, 2, 3, 8] {
let rows = drawn(&a_bead(), &mut Show::default(), 44, height);
assert!(
rows[0].contains("Esc to go back"),
"at {height} rows: {:?}",
rows[0]
);
}
}
#[test]
fn a_related_bead_the_answer_does_not_hold_is_named_as_such() {
let dangling = Node {
depends_on: vec![Related {
id: "orb-9".to_string(),
edge: Edge::Blocks,
status: None,
title: None,
}],
..a_bead()
};
let rows = drawn(&dangling, &mut Show::default(), 50, 24);
assert!(
rows.contains(&"│ → orb-9 not in the tracker's answer │".to_string()),
"{rows:#?}"
);
}
#[test]
fn an_edge_of_a_kind_bdi_does_not_know_says_which_kind() {
let odd = Node {
depends_on: vec![related(
"orb-2",
Edge::Other("relates-to".to_string()),
Status::Open,
"the survey",
)],
..a_bead()
};
let rows = drawn(&odd, &mut Show::default(), 50, 24);
assert!(
rows.contains(&"│ → ○ orb-2 the survey · “relates-to” │".to_string()),
"{rows:#?}"
);
}
#[test]
fn a_half_screen_motion_moves_the_view_half_the_window() {
let mut view = Show::default();
drawn(&a_bead(), &mut view, 44, 6);
assert!(view.scroll(Motion::HalfScreenDown));
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6)[1],
"│ ◍ lifting the mast · working │"
);
assert!(view.scroll(Motion::HalfScreenDown));
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6)[1],
"│DESCRIPTION │"
);
assert!(view.scroll(Motion::HalfScreenUp));
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6)[1],
"│ ◍ lifting the mast · working │"
);
}
#[test]
fn a_notch_moves_the_view_as_far_as_it_is_told() {
let mut view = Show::default();
drawn(&a_bead(), &mut view, 44, 6);
let mut stepped = Show::default();
drawn(&a_bead(), &mut stepped, 44, 6);
assert!(view.scrolled(Notch::Down, 3));
for _ in 0..3 {
stepped.scroll(Motion::NextRow);
}
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6),
drawn(&a_bead(), &mut stepped, 44, 6)
);
assert!(view.scrolled(Notch::Up, 3));
assert_eq!(
drawn(&a_bead(), &mut view, 44, 6)[1],
"│◐ orb-7.1 re-point the dish │"
);
assert!(!view.scrolled(Notch::Up, 3), "already at the top");
}
#[test]
fn on_a_wide_screen_the_window_is_four_fifths_of_it_and_the_prose_wraps_there() {
let long = Node {
description: "abcde ".repeat(60).trim().to_string(),
..a_bead()
};
let rows = drawn(&long, &mut Show::default(), 200, 60);
let top = rows
.iter()
.find(|row| row.contains('┌'))
.expect("the window's top edge is drawn");
assert_eq!(top.chars().nth(20), Some('┌'), "{top:?}");
assert_eq!(top.chars().nth(179), Some('┐'), "{top:?}");
let first = rows
.iter()
.find(|row| row.contains("abcde"))
.expect("the prose is drawn");
assert_eq!(
first.matches("abcde").count(),
26,
"twenty-six words of five, with a space between, is a hundred and \
fifty-five of the hundred and fifty-six columns left inside the \
indent: {first:?}"
);
assert!(
!first.contains('…'),
"prose wrapped to the window is never cut at it: {first:?}"
);
}
#[test]
fn a_screen_not_much_wider_than_eighty_columns_gives_the_window_eighty() {
let rows = drawn(&a_bead(), &mut Show::default(), 90, 30);
let top = rows
.iter()
.find(|row| row.contains('┌'))
.expect("the window's top edge is drawn");
assert_eq!(top.chars().nth(4), Some('┌'), "{top:?}");
assert_eq!(top.chars().nth(85), Some('┐'), "{top:?}");
}
#[test]
fn a_screen_not_much_taller_than_twenty_four_rows_gives_the_window_twenty_four() {
let tall = Node {
description: (1..=40)
.map(|n| format!("line {n}"))
.collect::<Vec<_>>()
.join("\n"),
..a_bead()
};
let rows = drawn(&tall, &mut Show::default(), 44, 28);
let top = rows
.iter()
.position(|row| row.contains('┌'))
.expect("the window's top edge is drawn");
let bottom = rows
.iter()
.position(|row| row.contains('└'))
.expect("the window's bottom edge is drawn");
assert_eq!((top, bottom), (2, 25));
}
#[test]
fn a_window_with_no_room_inside_it_draws_nothing_inside_it() {
assert_eq!(
drawn(&a_bead(), &mut Show::default(), 44, 2),
vec![
"┌orb-7.1 · Esc to go back · j, k to scroll─┐",
"└──────────────────────────────────────────┘",
]
);
}
fn run_saying(painted: &[Run], said: &str) -> Run {
painted
.iter()
.find(|run| run.said.contains(said))
.unwrap_or_else(|| panic!("{said:?} is drawn: {painted:?}"))
.clone()
}
fn painted(node: &Node, width: u16, height: u16) -> Painted {
Painted::drawn_by(width, height, |frame| {
show(frame, frame.area(), node, &mut Show::default(), false)
})
}
fn said_where(painted: &Painted, chosen: impl Fn(&Run) -> bool) -> Vec<String> {
(0..painted.rows().len())
.flat_map(|y| painted.row(y))
.filter(|run| chosen(run))
.map(|run| {
run.said
.replace(['│', '─', '┌', '┐', '└', '┘'], "")
.trim()
.to_string()
})
.filter(|said| !said.is_empty())
.collect()
}
fn every_coloured_status() -> [Status; 5] {
[
Status::InProgress,
Status::Blocked,
Status::Closed,
Status::Deferred,
Status::Other("triage".into()),
]
}
#[test]
fn the_glyph_is_painted_the_colour_the_forest_paints_it() {
for status in every_coloured_status() {
let bead = Node {
status: status.clone(),
..a_bead()
};
let top = painted(&bead, 44, 22).row(1);
let glyph = run_saying(&top, &status_glyph(&status).to_string());
assert_eq!(glyph.said, status_glyph(&status).to_string(), "{top:?}");
assert_eq!(
glyph.style.fg,
status_style(&status).fg,
"{status:?}: {top:?}"
);
}
}
#[test]
fn an_open_glyph_at_the_head_of_the_page_is_the_terminals_own() {
let open = Node {
status: Status::Open,
..a_bead()
};
let top = painted(&open, 44, 22).row(1);
assert_eq!(
run_saying(&top, "○").style.fg,
Some(Color::Reset),
"{top:?}"
);
}
#[test]
fn the_agent_marker_is_painted_live_as_the_forest_paints_it() {
let marker = painted(&a_bead(), 44, 22).row(3);
assert_eq!(
run_saying(&marker, "◍ lifting the mast · working").style.fg,
palette::AGENT.fg,
"{marker:?}"
);
}
#[test]
fn the_id_is_painted_the_blue_bd_show_paints_it() {
let top = painted(&a_bead(), 44, 22).row(1);
assert_eq!(
run_saying(&top, "orb-7.1").style.fg,
Some(Color::Rgb(89, 194, 255)),
"{top:?}"
);
assert_eq!(
run_saying(&top, "re-point the dish").style.fg,
Some(Color::Reset),
"the title is the terminal's own: {top:?}"
);
}
#[test]
fn the_facts_row_says_the_status_in_its_colour_and_the_rest_on_the_page() {
for status in every_coloured_status() {
let bead = Node {
status: status.clone(),
..a_bead()
};
let facts = painted(&bead, 44, 22).row(2);
let word = run_saying(&facts, &phrase::status_word(&status));
assert_eq!(
word.style.fg,
status_style(&status).fg,
"{status:?}: {facts:?}"
);
assert_eq!(
run_saying(&facts, "P2 · task · kim").style.fg,
palette::PAGE.fg,
"{status:?}: {facts:?}"
);
}
}
#[test]
fn a_related_beads_glyph_is_painted_the_colour_of_its_status() {
let painted = painted(&a_bead(), 44, 22);
let depends_on = painted.row(17);
let blocks = painted.row(20);
assert_eq!(
run_saying(&depends_on, "✓").style.fg,
status_style(&Status::Closed).fg,
"{depends_on:?}"
);
assert_eq!(
run_saying(&blocks, "○").style.fg,
palette::PAGE.fg,
"{blocks:?}"
);
}
#[test]
fn a_closed_related_bead_is_dimmed_as_bd_show_dims_one() {
let painted = painted(&a_bead(), 44, 22);
let depends_on = painted.row(17);
let blocks = painted.row(20);
assert_eq!(
run_saying(&depends_on, "orb-7.3 lay the feeder cable")
.style
.fg,
palette::TIER_FINISHED.fg,
"{depends_on:?}"
);
assert_eq!(
run_saying(&depends_on, "→").style.fg,
Some(Color::Reset),
"the arrow says the edge, not the state: {depends_on:?}"
);
assert_eq!(
run_saying(&blocks, "orb-7.4 file the licence").style.fg,
palette::PAGE.fg,
"{blocks:?}"
);
}
#[test]
fn a_heading_is_bold_and_no_colour_as_bd_show_prints_one() {
let heading = run_saying(&painted(&a_bead(), 44, 22).row(5), DESCRIPTION);
assert!(
heading.style.add_modifier.contains(Modifier::BOLD),
"{heading:?}"
);
assert_eq!(heading.style.fg, Some(Color::Reset), "{heading:?}");
}
#[test]
fn nothing_in_the_window_is_told_apart_by_colour_alone() {
for status in every_coloured_status().into_iter().chain([Status::Open]) {
let bead = Node {
status: status.clone(),
depends_on: vec![related(
"orb-7.3",
Edge::Blocks,
status.clone(),
"the cable",
)],
..a_bead()
};
let rows = painted(&bead, 44, 22).rows();
let glyph = status_glyph(&status).to_string();
assert!(
rows[1].contains(&glyph),
"{status:?} lost its glyph: {rows:#?}"
);
assert!(
rows[2].contains(&phrase::status_word(&status)),
"{status:?} lost its word: {rows:#?}"
);
assert!(
rows[17].contains(&format!("→ {glyph} orb-7.3")),
"{status:?} lost its glyph on a related row: {rows:#?}"
);
}
}
#[test]
fn what_the_window_draws_at_a_weight_is_the_way_back_and_its_section_names() {
let bead = a_bead();
let painted = painted(&bead, 44, 22);
let own: Vec<String> = said_where(&painted, |run| {
run.style.add_modifier.contains(Modifier::BOLD)
});
assert_eq!(
own,
vec![
phrase::way_back_from_bead(&bead.id, false, false),
DESCRIPTION.to_string(),
NOTES.to_string(),
PARENT.to_string(),
DEPENDS_ON.to_string(),
BLOCKS.to_string(),
],
"{painted:?}"
);
}
#[test]
fn the_only_tones_the_window_draws_are_the_ones_it_quotes() {
let painted = painted(&a_bead(), 44, 22);
let toned: Vec<String> = said_where(&painted, |run| run.style.fg != Some(Color::Reset));
assert_eq!(
toned,
vec![
"◐".to_string(),
"orb-7.1".to_string(),
phrase::status_word(&Status::InProgress),
"◍ lifting the mast · working".to_string(),
"◐".to_string(),
"✓".to_string(),
"orb-7.3 lay the feeder cable".to_string(),
],
"{painted:?}"
);
}
#[test]
fn the_page_is_drawn_at_the_terminals_own_foreground_as_the_head_is() {
let painted = painted(&a_bead(), 44, 22);
for (y, said) in [
(6, "Point it at the new bird."),
(11, "The crane is booked for Tuesday."),
(14, "orb-7 lift the ground station"),
] {
let run = run_saying(&painted.row(y), said);
assert_eq!(run.style.fg, Some(Color::Reset), "{said}: {run:?}");
}
}
#[test]
fn emphasis_in_the_prose_is_by_weight_and_a_code_span_by_its_own_colour() {
let bead = Node {
description: "Point it at the **new** bird, `now`.\n\nThe old one is gone.".to_string(),
..a_bead()
};
let prose = painted(&bead, 44, 22).row(6);
let bold = run_saying(&prose, "new");
assert!(
bold.style.add_modifier.contains(Modifier::BOLD),
"{prose:?}"
);
assert_eq!(bold.style.fg, palette::PAGE.fg, "{prose:?}");
assert_eq!(
run_saying(&prose, "now").style.fg,
Some(Color::Cyan),
"{prose:?}"
);
}
fn all(_at: usize) -> bool {
true
}
#[test]
fn the_beads_a_bead_names_are_listed_in_the_order_the_window_draws_them() {
assert_eq!(
super::related(&a_bead())
.iter()
.map(|it| it.id.as_str())
.collect::<Vec<_>>(),
vec!["orb-7", "orb-7.3", "orb-7.4"]
);
}
#[test]
fn the_rows_the_page_reports_are_the_beads_it_names_in_that_order() {
let bead = a_bead();
let page = said(&bead, 60, 60);
assert_eq!(
page.related.len(),
super::related(&bead).len(),
"a row for each bead named"
);
for (at, named) in super::related(&bead).iter().enumerate() {
let said: String = page.rows[page.related[at]]
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(
said.contains(named.id.as_str()),
"the row reported for {} says {said:?}",
named.id
);
}
}
#[test]
fn a_first_step_lands_on_the_first_bead_the_page_names() {
assert_eq!(stepped(None, 3, all), Some(0));
}
#[test]
fn stepping_past_the_last_comes_round_to_the_first() {
assert_eq!(stepped(Some(2), 3, all), Some(0));
}
#[test]
fn a_step_moves_one_bead_at_a_time() {
assert_eq!(stepped(Some(0), 3, all), Some(1));
assert_eq!(stepped(Some(1), 3, all), Some(2));
}
#[test]
fn a_bead_the_forest_cannot_go_to_is_stepped_over() {
let only_the_last = |at: usize| at == 2;
assert_eq!(stepped(None, 3, only_the_last), Some(2));
assert_eq!(stepped(Some(2), 3, only_the_last), Some(2));
assert_eq!(stepped(Some(0), 3, only_the_last), Some(2));
}
#[test]
fn a_bead_with_nowhere_to_go_has_no_ring() {
assert_eq!(stepped(None, 3, |_| false), None);
assert_eq!(stepped(None, 0, all), None);
assert_eq!(stepped(Some(1), 0, all), None);
}
#[test]
fn the_title_offers_the_keys_that_follow_only_where_something_can_be() {
let bead = a_bead();
let says = |drawn: Vec<String>, said: &str| drawn.iter().any(|row| row.contains(said));
assert!(
!says(drawn(&bead, &mut Show::default(), 60, 24), "to follow"),
"keys offered over a bead nothing can be followed from"
);
assert!(
says(
drawn_where(&bead, &mut Show::default(), 60, 24, true),
"Tab, Enter to follow"
),
"no keys offered where a reference can be followed"
);
}
#[test]
fn the_bead_the_window_is_on_is_drawn_as_a_selected_row_is() {
let bead = a_bead();
let mut view = Show::default();
view.go_to("orb-7");
let reversed = |said: &str, view: &mut Show| {
Painted::drawn_by(60, 24, |frame| show(frame, frame.area(), &bead, view, true))
.rows()
.iter()
.enumerate()
.find(|(_, row)| row.contains(said))
.map(|(at, _)| at)
.map(|at| {
Painted::drawn_by(60, 24, |frame| show(frame, frame.area(), &bead, view, true))
.row(at)
.iter()
.any(|run| run.style.add_modifier.contains(Modifier::REVERSED))
})
.unwrap_or_else(|| panic!("{said:?} is drawn"))
};
assert!(
reversed("lift the ground station", &mut view),
"the bead the window is on is not drawn as the selected row is"
);
assert!(
!reversed("file the licence", &mut view),
"a bead the window is not on is drawn as selected"
);
}
#[test]
fn stepping_on_to_a_bead_below_the_window_brings_it_into_view() {
let bead = a_bead();
let mut view = Show::default();
let page = said(&bead, 58, 60);
let last = *page.related.last().expect("a bead names beads");
view.go_to("orb-7.4");
let drawn = drawn_where(&bead, &mut view, 60, 8, true);
assert!(
drawn
.iter()
.any(|row| row.contains(&super::related(&bead)[2].id)),
"the bead the window is on is off the page it drew: {drawn:#?}"
);
assert!(
last + 2 > drawn.len(),
"a window with room for the whole bead does not test scrolling"
);
}
fn looking(from: usize, room: usize, total: usize) -> Show {
let mut view = Show::default();
view.fit(total, room);
view.from = from;
view
}
#[test]
fn a_row_above_the_window_is_brought_to_its_top() {
let mut view = looking(10, 5, 40);
view.reveal(3);
assert_eq!(view.from, 3);
}
#[test]
fn a_row_below_the_window_is_brought_to_its_foot() {
let mut view = looking(0, 5, 40);
view.reveal(9);
assert_eq!(view.from, 5, "row 9 is the last of rows 5 to 9");
}
#[test]
fn the_last_row_the_window_draws_is_not_below_it() {
let mut already = looking(0, 5, 40);
already.reveal(4);
assert_eq!(already.from, 0, "row 4 is the last of rows 0 to 4");
let mut just_past = looking(0, 5, 40);
just_past.reveal(5);
assert_eq!(just_past.from, 1);
}
#[test]
fn the_first_row_the_window_draws_is_not_above_it() {
let mut already = looking(3, 5, 40);
already.reveal(3);
assert_eq!(already.from, 3);
let mut just_before = looking(3, 5, 40);
just_before.reveal(2);
assert_eq!(just_before.from, 2);
}
#[test]
fn a_window_with_no_room_scrolls_nowhere() {
let mut view = looking(0, 0, 40);
view.reveal(9);
assert_eq!(view.from, 0);
}
}