The outline shows the tree as an indented list: a row per node with its fold marker, tree lines in the parent's color, tags, a ¶ for a body and +N for what a fold hides. zb shows every body under its title, to read a document like a page. j/k go row by row, h/l to the parent and the first child, a click on a marker folds, and titles are edited in their row, rapid entry included. In move mode, j/k swap the node with its siblings, h takes it out a level and l puts it under the one above. The Note view shows one node at full size, as parts: the title, the tags, the body, each link and each child. j/k step through them, Enter or i edits the part (or goes to that link or child), and h/l turn the page to the sibling before or after. Bodies and tags are edited with the same editor as titles: Enter starts a line in a body, Tab types spaces, and tags that don't parse come back as they were. cc, C and s now start typing on an empty line too, as in vim. Panes split the screen the way vim's windows do (Ctrl-w v s h j k l w c o =, :split, :vsplit, :only), each in a box with its view and document, and any pane can show any open document (gt, gT, :b, the documents picker, a click on the document bar). The status bar has two lines: the mode, the view, the breadcrumb or a message, the keys typed; then key hints for what you're doing, which <Space>w hides with which-key. Popups are a mode of their own, built on one list: ? lists every key and command, / searches it and Enter runs what you pick; the right-click menu lists what you can do with a node (with the keys that do the same) or adds a new idea on the empty canvas; the pickers set a node's color, border, style or tags. K opens the body in a card beside the node, where i edits it in place. The example now opens two documents (the garden, and the kitchen its portal leads to) and its tests drive all of the above end to end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
362 lines
14 KiB
Rust
362 lines
14 KiB
Rust
//! 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<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,
|
|
}
|
|
|
|
/// 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<Range<usize>>,
|
|
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<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>,
|
|
editing: Option<Editing>,
|
|
}
|
|
|
|
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<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 cell the text cursor is on, while a title is edited.
|
|
pub fn text_cursor(&self) -> Option<ScreenPos> {
|
|
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<usize> {
|
|
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<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
|
|
/// (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<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),
|
|
}
|
|
}
|