//! 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 std::ops::Range; use ratatui::layout::Size; use ratatui::style::Color; use unicode_width::UnicodeWidthStr; use super::boxes::{NodeBox, draft_box, truncate}; use super::camera::{Camera, ScreenPos, ScreenRect, Zoom}; use super::lines::{LineLayer, LineOwner, Paint}; use super::route::{Bend, route}; use crate::app::text_edit::TextEdit; use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible}; use crate::ui::glyphs::Glyphs; use crate::ui::text_editor::layout; use crate::ui::theme::{LineStyle, Theme}; use crate::view::ViewContext; /// How many columns of a title show at Far zoom. const FAR_TITLE_WIDTH: usize = 14; /// 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, 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, } /// The title being edited, as drawn: where the text cursor is, and how the /// text sits in its box, for clicks. #[derive(Debug, Clone, PartialEq, Eq)] struct Editing { rect: ScreenRect, text: String, rows: Vec>, cursor: ScreenPos, } /// 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 border of the cursor's box: the accent, or the typing color while /// its title is typed. cursor_color: Color, /// The nodes in drawing order: the cursor's last, on top. pub(super) nodes: Vec, /// Where each node is in `nodes`. index: BTreeMap, /// For each screen cell, the topmost node drawn on it (its index in /// `nodes`). covered: Vec>, pub(super) lines: LineLayer, pub(super) marks: Vec, editing: Option, } impl Scene { /// Works out the frame: which nodes show (folding, the cursor), where /// they are on screen, and every line between them. A title being edited /// shows as typed, with the text cursor in it. pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene { let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings); let edit = ctx.mode.title_edit(); let typing = edit.is_some_and(|edit| edit.editor.is_typing()); let visible = Visible::new(doc, cursor, settings.focus); let mut scene = Scene { size, zoom: camera.zoom, glyphs: theme.glyphs, accent: theme.accent, muted: theme.muted, cursor_color: if typing { theme.insert } else { theme.accent }, 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 max_width = settings.max_node_width; let (rect, shape) = match edit.filter(|edit| edit.node == id && camera.zoom == Zoom::Normal) { Some(edit) => scene.draft(node, edit, badge, style.border, camera, max_width), None => shape(node, badge, style.border, camera, theme.glyphs, 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 { 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 { 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 { self.lines.owner(at) } /// The cell the text cursor is on, while a title is edited. pub fn text_cursor(&self) -> Option { self.editing.as_ref().map(|editing| editing.cursor) } /// Where a click lands in the title being edited, if it's in its box. pub fn text_offset_at(&self, at: ScreenPos) -> Option { let editing = self.editing.as_ref().filter(|editing| editing.rect.contains(at))?; let row = usize::try_from(at.row - editing.rect.top() - 1).unwrap_or(0); let column = usize::try_from(at.col - editing.rect.left() - 2).unwrap_or(0); Some(layout::offset(&editing.text, &editing.rows, row, column)) } /// The box of the node whose title is being edited, showing the text as /// typed. Remembers where the text and its cursor are. fn draft( &mut self, node: &Node, edit: &TextEdit, badge: String, border: Border, camera: Camera, max_width: u16, ) -> (ScreenRect, Shape) { let text = edit.editor.text(); let draft = draft_box(node, text, edit.editor.cursor(), badge, max_width); let at = camera.to_screen(node.pos); let size = draft.node_box.rect.size; let rect = ScreenRect::new(at.col, at.row, i32::from(size.width), i32::from(size.height)); let (row, column) = draft.cursor; let cursor = ScreenPos::new( rect.left() + 2 + i32::try_from(column).unwrap_or(0), rect.top() + 1 + i32::try_from(row).unwrap_or(0), ); self.editing = Some(Editing { rect, text: text.to_string(), rows: draft.rows, cursor }); let NodeBox { lines, tags, badge, .. } = draft.node_box; (rect, Shape::Box { lines, tags, badge, border }) } /// The index (in `nodes`) of the topmost node on a cell. pub(super) fn covered_at(&self, at: ScreenPos) -> Option { 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 /// (the typing color while its title is typed). fn add_borders(&mut self) { let Scene { nodes, covered, lines, size, cursor_color, .. } = self; for (index, node) in nodes.iter().enumerate() { let Shape::Box { border, .. } = &node.shape else { continue }; let (border, color) = if node.selected { (Border::Thick, *cursor_color) } 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, self.nodes[parent_index].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], size: Size, at: ScreenPos) -> Option { 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), } }