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
@@ -0,0 +1,443 @@
|
||||
//! A playground for the canvas engine (stage 2 of the build): a sample
|
||||
//! document you can move around, zoom, fold and rearrange. The real keys,
|
||||
//! modes and command line come in later stages; this has just enough to try
|
||||
//! the canvas.
|
||||
//!
|
||||
//! Run it with `cargo run --example canvas`.
|
||||
//!
|
||||
//! | Keys | What they do |
|
||||
//! |----------------|-----------------------------------------------------|
|
||||
//! | `hjkl`, arrows | move to the nearest node that way |
|
||||
//! | `gh gl gj gk` | parent, first child, next / previous sibling |
|
||||
//! | `gg` | the root |
|
||||
//! | `HJKL` | pan the view |
|
||||
//! | `+` `-` | zoom in / out |
|
||||
//! | `za` `zf` `zi` | fold or unfold, auto-fold, focus |
|
||||
//! | `zz` | center the view on the cursor |
|
||||
//! | `a` `o` | add a child / a sibling below ("new idea") |
|
||||
//! | `x` `dd` | delete the node (its children move up) / the branch |
|
||||
//! | `M` | move mode: hjkl moves the branch, Enter or Esc ends |
|
||||
//! | `u` `Ctrl-r` | undo / redo |
|
||||
//! | `q` | quit |
|
||||
//!
|
||||
//! Mouse: click selects, dragging a node moves its branch, dragging empty
|
||||
//! space pans, the wheel scrolls.
|
||||
|
||||
use std::io;
|
||||
|
||||
use cumulus::model::{
|
||||
CanvasOffset, Change, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, Visible,
|
||||
};
|
||||
use cumulus::storage::document_file;
|
||||
use cumulus::ui::glyphs::Direction;
|
||||
use cumulus::ui::theme::Theme;
|
||||
use cumulus::view::canvas::boxes::{NodeBox, box_rects};
|
||||
use cumulus::view::canvas::navigate::nearest;
|
||||
use cumulus::view::canvas::placement::{below_spot, child_spot};
|
||||
use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos};
|
||||
use ratatui::crossterm::event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
|
||||
MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let doc = document_file::parse(include_str!("garden.cum")).map_err(io::Error::other)?;
|
||||
let mut terminal = ratatui::try_init()?;
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
// On a crash, turn the mouse off too; ratatui's own hook restores the rest.
|
||||
let restore_the_rest = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
restore_the_rest(info);
|
||||
}));
|
||||
let result = Playground::new(doc).run(&mut terminal);
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
/// A mouse drag in progress.
|
||||
enum Drag {
|
||||
/// Moving a node's branch: where the drag started, how far the branch has
|
||||
/// moved so far, and the undo step for the whole drag.
|
||||
Node { id: NodeId, from: ScreenPos, moved: CanvasOffset, change: Option<Change> },
|
||||
/// Panning the view: the cell the mouse was last on.
|
||||
Pan { last: ScreenPos },
|
||||
}
|
||||
|
||||
struct Playground {
|
||||
doc: Document,
|
||||
history: History,
|
||||
cursor: NodeId,
|
||||
camera: Camera,
|
||||
options: SceneOptions,
|
||||
theme: Theme,
|
||||
/// The first key of a two-key command (`g`, `z`, `d`), waiting for the
|
||||
/// second.
|
||||
pending: Option<char>,
|
||||
/// In move mode: the undo step for the whole move so far.
|
||||
moving: Option<Option<Change>>,
|
||||
drag: Option<Drag>,
|
||||
/// The last frame drawn, to match clicks against.
|
||||
scene: Scene,
|
||||
canvas_area: Rect,
|
||||
/// Set when the cursor moved, so the next frame brings it into view.
|
||||
follow: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Playground {
|
||||
fn new(doc: Document) -> Playground {
|
||||
Playground {
|
||||
cursor: doc.root(),
|
||||
doc,
|
||||
history: History::default(),
|
||||
camera: Camera::default(),
|
||||
options: SceneOptions::default(),
|
||||
theme: Theme::default(),
|
||||
pending: None,
|
||||
moving: None,
|
||||
drag: None,
|
||||
scene: Scene::default(),
|
||||
canvas_area: Rect::default(),
|
||||
follow: true,
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
match event::read()? {
|
||||
// Windows reports key releases too: only presses and repeats count.
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if !self.key(key) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse) => self.mouse(mouse),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let [canvas, status] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
|
||||
self.canvas_area = canvas;
|
||||
self.scene = self.build_scene(canvas);
|
||||
if self.follow {
|
||||
self.follow = false;
|
||||
if let Some(rect) = self.scene.rect_of(self.cursor)
|
||||
&& self.camera.reveal(rect, canvas.as_size(), 3)
|
||||
{
|
||||
self.scene = self.build_scene(canvas);
|
||||
}
|
||||
}
|
||||
frame.render_widget(&self.scene, canvas);
|
||||
frame.render_widget(self.status_line(), status);
|
||||
}
|
||||
|
||||
fn build_scene(&self, area: Rect) -> Scene {
|
||||
Scene::build(&self.doc, self.cursor, self.camera, area.as_size(), &self.theme, self.options)
|
||||
}
|
||||
|
||||
fn status_line(&self) -> Line<'static> {
|
||||
let mode = if self.moving.is_some() { " MOVE " } else { " CANVAS " };
|
||||
let title = self.doc.node(self.cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
let focus = if self.options.focus { " · focus" } else { "" };
|
||||
let hints = if self.message.is_empty() {
|
||||
"hjkl move · gh/gl tree · +/- zoom · za/zf/zi fold · a/o add · x/dd delete · M move · u undo · q quit"
|
||||
.to_string()
|
||||
} else {
|
||||
self.message.clone()
|
||||
};
|
||||
let pending = self.pending.map(|key| format!(" {key}")).unwrap_or_default();
|
||||
Line::from(vec![
|
||||
mode.black().on_cyan().bold(),
|
||||
format!(" {}{focus} │ {title} │ ", self.camera.zoom.name()).into(),
|
||||
hints.dark_gray(),
|
||||
pending.cyan().bold(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Handles a key. Returns false to quit.
|
||||
fn key(&mut self, key: KeyEvent) -> bool {
|
||||
self.message.clear();
|
||||
if self.moving.is_some() {
|
||||
self.move_key(key);
|
||||
return true;
|
||||
}
|
||||
if let Some(first) = self.pending.take() {
|
||||
if let KeyCode::Char(second) = key.code {
|
||||
self.sequence(first, second);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return false,
|
||||
KeyCode::Char('c') if ctrl => return false,
|
||||
KeyCode::Char('r') if ctrl => self.redo(),
|
||||
KeyCode::Char('h') | KeyCode::Left => self.go(Direction::Left),
|
||||
KeyCode::Char('j') | KeyCode::Down => self.go(Direction::Down),
|
||||
KeyCode::Char('k') | KeyCode::Up => self.go(Direction::Up),
|
||||
KeyCode::Char('l') | KeyCode::Right => self.go(Direction::Right),
|
||||
KeyCode::Char('H') => self.camera.pan(-8, 0),
|
||||
KeyCode::Char('J') => self.camera.pan(0, 4),
|
||||
KeyCode::Char('K') => self.camera.pan(0, -4),
|
||||
KeyCode::Char('L') => self.camera.pan(8, 0),
|
||||
KeyCode::Char('+' | '=') => self.zoom(true),
|
||||
KeyCode::Char('-') => self.zoom(false),
|
||||
KeyCode::Char('a') => self.add_child(),
|
||||
KeyCode::Char('o') => self.add_below(),
|
||||
KeyCode::Char('x') => self.delete_node(),
|
||||
KeyCode::Char('u') => self.undo(),
|
||||
KeyCode::Char('M') => {
|
||||
self.moving = Some(None);
|
||||
self.message = "hjkl moves the branch (HJKL by 5) · Enter or Esc ends".into();
|
||||
}
|
||||
KeyCode::Char(first @ ('g' | 'z' | 'd')) => self.pending = Some(first),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The second key of `g…`, `z…` and `d…`.
|
||||
fn sequence(&mut self, first: char, second: char) {
|
||||
match (first, second) {
|
||||
('g', 'g') => self.jump(Some(self.doc.root())),
|
||||
('g', 'h') => self.jump(self.doc.parent(self.cursor)),
|
||||
('g', 'l') => self.jump(self.doc.children(self.cursor).first().copied()),
|
||||
('g', 'j') => self.jump(self.doc.next_sibling(self.cursor)),
|
||||
('g', 'k') => self.jump(self.doc.previous_sibling(self.cursor)),
|
||||
('z', 'a') => self.toggle_fold(),
|
||||
('z', 'f') => self.set_fold(Fold::Auto),
|
||||
('z', 'i') => {
|
||||
self.options.focus = !self.options.focus;
|
||||
self.message = format!("focus {}", if self.options.focus { "on" } else { "off" });
|
||||
}
|
||||
('z', 'z') => self.center(),
|
||||
('d', 'd') => self.delete_branch(),
|
||||
_ => self.message = format!("{first}{second} does nothing here"),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_key(&mut self, key: KeyEvent) {
|
||||
let (cols, rows) = match key.code {
|
||||
KeyCode::Char('h') | KeyCode::Left => (-1, 0),
|
||||
KeyCode::Char('l') | KeyCode::Right => (1, 0),
|
||||
KeyCode::Char('j') | KeyCode::Down => (0, 1),
|
||||
KeyCode::Char('k') | KeyCode::Up => (0, -1),
|
||||
KeyCode::Char('H') => (-5, 0),
|
||||
KeyCode::Char('L') => (5, 0),
|
||||
KeyCode::Char('J') => (0, 5),
|
||||
KeyCode::Char('K') => (0, -5),
|
||||
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('M') => {
|
||||
if let Some(Some(change)) = self.moving.take() {
|
||||
self.history.record(change);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let (id, offset) = (self.cursor, self.camera.cells(cols, rows));
|
||||
if let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, offset)) {
|
||||
let so_far = self.moving.take().flatten();
|
||||
self.moving = Some(Some(match so_far {
|
||||
Some(change) => change.then(next),
|
||||
None => next,
|
||||
}));
|
||||
self.follow = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, mouse: MouseEvent) {
|
||||
let area = self.canvas_area;
|
||||
let at = ScreenPos::new(
|
||||
i32::from(mouse.column) - i32::from(area.x),
|
||||
i32::from(mouse.row) - i32::from(area.y),
|
||||
);
|
||||
match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.drag = Some(match self.scene.node_at(at) {
|
||||
Some(id) => {
|
||||
self.cursor = id;
|
||||
Drag::Node { id, from: at, moved: CanvasOffset::default(), change: None }
|
||||
}
|
||||
None => Drag::Pan { last: at },
|
||||
});
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => match &mut self.drag {
|
||||
Some(Drag::Node { id, from, moved, change }) => {
|
||||
let total = self.camera.cells(at.col - from.col, at.row - from.row);
|
||||
let step = CanvasOffset::new(total.dx - moved.dx, total.dy - moved.dy);
|
||||
let id = *id;
|
||||
if step != CanvasOffset::default()
|
||||
&& let Ok(((), next)) = self.doc.edit(|edit| edit.shift_branch(id, step))
|
||||
{
|
||||
*moved = total;
|
||||
*change = Some(match change.take() {
|
||||
Some(so_far) => so_far.then(next),
|
||||
None => next,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Drag::Pan { last }) => {
|
||||
self.camera.pan(last.col - at.col, last.row - at.row);
|
||||
*last = at;
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
if let Some(Drag::Node { change: Some(change), .. }) = self.drag.take() {
|
||||
self.history.record(change);
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => self.camera.pan(0, 3),
|
||||
MouseEventKind::ScrollUp => self.camera.pan(0, -3),
|
||||
MouseEventKind::ScrollRight => self.camera.pan(6, 0),
|
||||
MouseEventKind::ScrollLeft => self.camera.pan(-6, 0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the cursor to the nearest node on show in a direction.
|
||||
fn go(&mut self, direction: Direction) {
|
||||
let visible = Visible::new(&self.doc, self.cursor, self.options.focus);
|
||||
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||
let Some(&from) = rects.get(&self.cursor) else { return };
|
||||
let candidates = visible.order.iter().filter_map(|id| Some((*id, *rects.get(id)?)));
|
||||
match nearest(from, direction, candidates) {
|
||||
Some(id) => self.jump(Some(id)),
|
||||
None => self.message = "nothing that way".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn jump(&mut self, target: Option<NodeId>) {
|
||||
match target {
|
||||
Some(id) => {
|
||||
self.cursor = id;
|
||||
self.follow = true;
|
||||
}
|
||||
None => self.message = "nothing there".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom(&mut self, closer: bool) {
|
||||
let zoom = if closer { self.camera.zoom.closer() } else { self.camera.zoom.further() };
|
||||
if let Some(node) = self.doc.node(self.cursor) {
|
||||
self.camera.set_zoom(zoom, node.pos);
|
||||
}
|
||||
self.follow = true;
|
||||
}
|
||||
|
||||
fn center(&mut self) {
|
||||
if let Some(node) = self.doc.node(self.cursor) {
|
||||
self.camera.center_on(node.pos, self.canvas_area.as_size());
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fold(&mut self) {
|
||||
let Some(node) = self.doc.node(self.cursor) else { return };
|
||||
let fold = match node.fold {
|
||||
Fold::Closed => Fold::Open,
|
||||
Fold::Open | Fold::Auto => Fold::Closed,
|
||||
};
|
||||
self.set_fold(fold);
|
||||
}
|
||||
|
||||
fn set_fold(&mut self, fold: Fold) {
|
||||
let id = self.cursor;
|
||||
self.edit(|edit| edit.update(id, |node| node.fold = fold));
|
||||
self.message = format!("fold: {fold:?}");
|
||||
}
|
||||
|
||||
fn add_child(&mut self) {
|
||||
let Some(node) = self.new_idea() else { return };
|
||||
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
|
||||
let parent = self.cursor;
|
||||
let node = Node { pos: child_spot(&self.doc, parent, size, &rects), ..node };
|
||||
let added = self.edit(|edit| edit.add(node, Spot::LastChildOf(parent)));
|
||||
self.jump(added);
|
||||
}
|
||||
|
||||
fn add_below(&mut self) {
|
||||
let Some(node) = self.new_idea() else { return };
|
||||
let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs);
|
||||
let size = NodeBox::new(&node, String::new(), self.options.max_width, self.theme.glyphs).rect.size;
|
||||
let sibling = self.cursor;
|
||||
let node = Node { pos: below_spot(sibling, size, &rects), ..node };
|
||||
let added = self.edit(|edit| edit.add(node, Spot::After(sibling)));
|
||||
self.jump(added);
|
||||
}
|
||||
|
||||
fn new_idea(&self) -> Option<Node> {
|
||||
Title::parse("new idea").ok().map(|title| Node::idea(title, Default::default()))
|
||||
}
|
||||
|
||||
fn delete_node(&mut self) {
|
||||
let id = self.cursor;
|
||||
let next = self.doc.children(id).first().copied().or_else(|| self.doc.parent(id));
|
||||
if self.edit(|edit| edit.remove_keeping_children(id)).is_some() {
|
||||
self.cursor = next.unwrap_or(self.doc.root());
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_branch(&mut self) {
|
||||
let id = self.cursor;
|
||||
let next = self.doc.parent(id).or_else(|| self.doc.previous_sibling(id));
|
||||
if let Some(removed) = self.edit(|edit| edit.remove_branch(id)) {
|
||||
self.cursor = next.unwrap_or(self.doc.root());
|
||||
self.message = format!("deleted {} node(s) · u to undo", removed.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(&mut self) {
|
||||
let touched: Vec<NodeId> = match self.history.undo(&mut self.doc) {
|
||||
Some(change) => change.nodes().collect(),
|
||||
None => {
|
||||
self.message = "nothing to undo".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.land_on(&touched);
|
||||
}
|
||||
|
||||
fn redo(&mut self) {
|
||||
let touched: Vec<NodeId> = match self.history.redo(&mut self.doc) {
|
||||
Some(change) => change.nodes().collect(),
|
||||
None => {
|
||||
self.message = "nothing to redo".into();
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.land_on(&touched);
|
||||
}
|
||||
|
||||
/// After undo or redo: the cursor goes where the change happened.
|
||||
fn land_on(&mut self, touched: &[NodeId]) {
|
||||
let target = touched.iter().copied().find(|id| self.doc.contains(*id)).unwrap_or(self.doc.root());
|
||||
self.jump(Some(target));
|
||||
}
|
||||
|
||||
/// Runs an edit and records it for undo; shows the error if it's refused.
|
||||
fn edit<T>(&mut self, steps: impl FnOnce(&mut Edit<'_>) -> Result<T, EditError>) -> Option<T> {
|
||||
match self.doc.edit(steps) {
|
||||
Ok((value, change)) => {
|
||||
self.history.record(change);
|
||||
Some(value)
|
||||
}
|
||||
Err(error) => {
|
||||
self.message = error.to_string();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// cumulus document. Hand edits are fine: cumulus reloads it when it changes.
|
||||
(
|
||||
format: 1,
|
||||
uid: "7f3c9a1e52b04d18",
|
||||
root: 1,
|
||||
nodes: [
|
||||
(
|
||||
id: 1,
|
||||
title: "Garden plans",
|
||||
pos: (0, 12),
|
||||
color: "green",
|
||||
),
|
||||
(
|
||||
id: 2,
|
||||
parent: 1,
|
||||
title: "Soil",
|
||||
pos: (22, 2),
|
||||
tags: "#todo",
|
||||
),
|
||||
(
|
||||
id: 3,
|
||||
parent: 1,
|
||||
title: "Raised beds",
|
||||
pos: (22, 7),
|
||||
color: "orange",
|
||||
fold: Auto,
|
||||
),
|
||||
(
|
||||
id: 4,
|
||||
parent: 3,
|
||||
title: "Cedar, untreated",
|
||||
pos: (43, 5),
|
||||
),
|
||||
(
|
||||
id: 5,
|
||||
parent: 3,
|
||||
title: "40 cm high",
|
||||
pos: (43, 9),
|
||||
),
|
||||
(
|
||||
id: 6,
|
||||
parent: 1,
|
||||
title: "Watering",
|
||||
pos: (22, 12),
|
||||
color: "teal",
|
||||
branch: (
|
||||
color: "node",
|
||||
),
|
||||
),
|
||||
(
|
||||
id: 7,
|
||||
parent: 6,
|
||||
title: "Rain barrel",
|
||||
pos: (40, 12),
|
||||
),
|
||||
(
|
||||
id: 8,
|
||||
parent: 6,
|
||||
title: "Drip line",
|
||||
pos: (40, 16),
|
||||
fold: Closed,
|
||||
),
|
||||
(
|
||||
id: 9,
|
||||
parent: 8,
|
||||
title: "Timer on the tap",
|
||||
pos: (59, 16),
|
||||
border: Double,
|
||||
),
|
||||
(
|
||||
id: 10,
|
||||
parent: 1,
|
||||
portal: (
|
||||
path: "kitchen.cum",
|
||||
uid: "c01d5e7aa0f3b912",
|
||||
title: "Kitchen",
|
||||
),
|
||||
pos: (22, 17),
|
||||
),
|
||||
(
|
||||
id: 11,
|
||||
title: "Seeds to order",
|
||||
pos: (0, 26),
|
||||
color: "purple",
|
||||
),
|
||||
(
|
||||
id: 12,
|
||||
parent: 11,
|
||||
title: "Tomatoes",
|
||||
pos: (24, 24),
|
||||
),
|
||||
(
|
||||
id: 13,
|
||||
parent: 11,
|
||||
title: "Basil",
|
||||
pos: (24, 28),
|
||||
branch: (
|
||||
line: Dotted,
|
||||
),
|
||||
),
|
||||
(
|
||||
id: 14,
|
||||
title: "ask about compost",
|
||||
pos: (52, 26),
|
||||
color: "pink",
|
||||
border: Dashed,
|
||||
body: "The neighbor has a bin.
|
||||
Bring a bucket.",
|
||||
),
|
||||
],
|
||||
links: [
|
||||
(
|
||||
from: 11,
|
||||
to: 14,
|
||||
label: "needs",
|
||||
),
|
||||
],
|
||||
)
|
||||
Reference in new issue
Block a user