Add the board, and finish v1.1 (stage 9e)

The board is the user's design: the document as lists and blocks, the
way Scratch nests blocks inside a loop. Each node without a parent (the
root, then each loose node) is a column with its title in the top border;
its children are cards in it, one under the other; a card with children
on show wraps them in a C: a joint at its bottom left, a bracket down the
left two cells further in, and a foot closing it. Blocks nest as deep as
the tree does. The columns by tag or color the first design had are
dropped (the user's pick), and :board takes no arguments any more.

j and k go down and up a column in reading order, into the blocks; h and
l go to the column beside, onto the card nearest the cursor's height.
Titles are typed right in their cards, and visual mode selects in
reading order as the outline does. <Space>vb or :board shows it.

Move mode (M, the user's pick) moves the card through the tree: h and l
to the end of the column beside, j and k past the cards around it, into
an open block and out of one at its end. A column's own node trades
places with the loose column beside it; the root's column stays first.
A drag does the same with the mouse: dropped on a card it goes after it,
on a block's card into it, on a column at its end.

The fallback message for a key a view ignores now names the view ("does
nothing in the board view"). The version goes to 1.1.0: stage 9 is done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 13:05:05 -04:00
1 parent 893edbda9f
commit 3716919db3
20 files changed
+946 -50

No files matched your search

Generated
+1 -1
View File
@@ -148,7 +148,7 @@ dependencies = [
[[package]]
name = "cumulus"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"arboard",
"directories",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cumulus"
version = "1.0.0"
version = "1.1.0"
edition = "2024"
# The version cumulus is built and tested with (pinned in rust-toolchain.toml).
rust-version = "1.90"
+32 -5
View File
@@ -334,6 +334,31 @@ lines.
list: the matches as they were when you searched. `/` and `:find` still
search the document shown, and `n` after them stays in it.
**The board** (settled in part 9e, the user's design)
- The board shows the document as lists and blocks, the way Scratch nests
blocks inside a loop: each node without a parent (the root, and each
loose node) is a column, with its title in the column's top border; its
children are cards in it, one under the other; and a card with children
on show wraps them in a "C": a `├` at its bottom left, a bracket down the
left past its cards (two cells further in), and a foot closing it. Blocks
nest as deep as the tree does. The columns by tag or by color the first
design had are dropped (the user's pick).
- A card is a node's box, as on the canvas: its title, its tags and badge
in the bottom border, its color on the border, thick for the cursor.
Folds fold here too (a folded block is a card with `+N`).
- `j` and `k` go down and up a column in reading order (into the blocks),
`h` and `l` to the column beside, onto the card nearest the cursor's
height. Titles are typed right in their cards, and `<Space>vb` or
`:board` shows it.
- Move mode (`M`, the user's pick) moves the card instead: `h` and `l` to
the end of the column beside, `j` and `k` past the cards around it, into
a block (as its first card going down, its last going up) and out of one
at its end; a column's own node trades places with the column beside
(the root's stays first). Enter is done, one undo step. A drag does the
same with the mouse: dropped on a card, it goes after it; on a block's
card, into it; on a column, at its end. Every move goes through the tree.
- In visual mode the board selects in reading order, as the outline does.
**Views, panes and popups** (settled in stage 7)
- The Note view is made of parts: the title, the tags, the body, each link
(out, then in) and each child. `j` and `k` step through them, with a bar
@@ -514,7 +539,7 @@ backlinks, and the backlink paths in every document it points to.
| **Canvas** | The 2D map: free placement, 3 zoom levels, right-angle lines and the mouse. |
| **Outline** | Reorganizing and reading: an indented tree with fold markers, `+N` for hidden nodes and `»` for portals, with loose nodes listed at the end. |
| **Note** | Writing: one node's title, tags, color and body (with vim motions inside), plus its links in and out and its children. It follows the cursor. |
| **Board** (v1.1) | Sorting: columns by tag or color. Moving a card to another column changes its tag. |
| **Board** (v1.1) | Lists and blocks: a column per node without a parent, its children as cards, and a card with children wrapping them, as Scratch's loop blocks do. |
- **Pickers:** documents, colors, borders, styles and tags in v1; Search in v1.1.
- **Popups:** which-key (bottom right, as in skyshift), `?` help, the `K` card,
@@ -531,7 +556,8 @@ pans up and down, and sideways with a wheel that tilts, a touchpad, or the
wheel with Shift or Alt held (Alt for terminals that keep Shift for selecting
text), Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node,
right-click opens a menu built from the same actions as the keys. Zoom is on
the `+` and `-` keys.
the `+` and `-` keys. On the board, dragging a card moves it in the tree:
after the card it's dropped on, into a block, or to the end of a column.
## Modes and keys
@@ -563,10 +589,11 @@ text).
| **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype |
| **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo |
| **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
| **Leader** `<Space>` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `/` search every document · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help |
| **Leader** `<Space>` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `/` search every document · `i` inbox · `b` documents · `v` then `c`/`o`/`n`/`b` for canvas / outline / note / board · `w` which-key on/off · `?` help |
| **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) |
| **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above |
| **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after |
| **Board** | `j` `k` cards in a column · `h` `l` the column beside · `M` then `h` `l` to the column beside, `j` `k` down and up (into and out of blocks) · drag a card |
| **Lists** (help, menu, pickers) | `j` `k` `gg` `G` `Ctrl-d` `Ctrl-u` move · `/` search · Enter pick or run · Esc or `q` close |
| **K card** | `j` `k` `Ctrl-d` `Ctrl-u` scroll · `i` `A` Enter edit the body (`I` from its start) · `K` `q` Esc close |
| **Typing** | `Backspace` `Delete` · `Ctrl-w` delete word · `Ctrl-u` delete line · arrows, Home, End · Enter (title: done, or after `o O a` the next idea; body: new line) · Tab Shift-Tab (a new idea: under the one above / out a level) · Esc to vim keys |
@@ -747,7 +774,7 @@ cumulus/
│ keymap (+ keys.toml), engine (counts, registers, operators), mouse
├─ command/ registry, parse, complete, history
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render,
│ navigate, placement, arrange (:tidy, :align), view), outline, note; later board
│ navigate, placement, arrange (:tidy, :align), view), outline, note, board
├─ ui/ text_input (a line of text and its cursor), text_editor/ (vim inside text:
│ motion, object, layout), list (the popup list help, menus and pickers
│ share), card (the K card), status_bar, document_bar, which_key,
@@ -776,7 +803,7 @@ cumulus/
The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`,
`app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`,
`app/tests/portals.rs`, `app/tests/arrange.rs`, `app/tests/branch_out.rs`,
`app/tests/export.rs` and `app/tests/everywhere.rs`):
`app/tests/export.rs`, `app/tests/everywhere.rs` and `app/tests/board.rs`):
they copy the sample
documents to a temporary folder, type keys and click on a test terminal, and
check the screen, the documents and the files.
+4 -3
View File
@@ -4,8 +4,8 @@ Brainstorm in the terminal: ideas as nodes on a 2D canvas, connected, folded
and color-coded, across as many documents as you like. Vim keys first; the
mouse works too. Runs on Windows and Linux.
> **Status:** v1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 list.
> v1.1 (the board, a search across documents, tidying, export) comes next.
> **Status:** v1.1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 and
> v1.1 lists. What comes later is listed there too.
## Build and run
@@ -99,7 +99,8 @@ The plan for the everyday keys (all of them are in DESIGN.md):
| `<Space>p` | a portal to another document (`:portal file`, or a new document's title) |
| `<Space>o` | this branch out, into a document of its own, with a portal in its place (`:branchout`) |
| Enter or `gf` `gu` | through a portal / up to the portals that lead here |
| `<Space>` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… |
| `<Space>` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note, `vb` board… |
| board | `<Space>vb` or `:board`: a column per node without a parent, its children as cards, a card with children wrapping them as Scratch's loop blocks do; `j` `k` cards, `h` `l` columns, `M` then `hjkl` (or a drag) moves a card |
| `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) |
| `gt` `gT` | the next / previous document |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) |
+1 -5
View File
@@ -40,7 +40,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec<Acti
Call::Split(view) => app(AppAction::Split { arrange: Arrange::Stacked, view }),
Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }),
Call::Only => app(AppAction::OnlyPane),
Call::Show(ViewKind::Board) | Call::Board(_) => later("the board comes in v1.1"),
Call::Board => app(AppAction::ShowView(ViewKind::Board)),
Call::Show(view) => app(AppAction::ShowView(view)),
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
@@ -109,10 +109,6 @@ fn say(message: Message) -> Vec<Action> {
vec![Action::say(message)]
}
fn later(text: &str) -> Vec<Action> {
say(Message::info(text))
}
/// `:in`: adds an idea under the Inbox, a loose node made the first time.
/// The cursor stays where it is: capturing shouldn't break your flow.
fn capture(ctx: &ViewContext<'_>, text: &str) -> Vec<Action> {
+1
View File
@@ -120,6 +120,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::ShowCanvas => app(AppAction::ShowView(ViewKind::Canvas)),
Command::ShowOutline => app(AppAction::ShowView(ViewKind::Outline)),
Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)),
Command::ShowBoard => app(AppAction::ShowView(ViewKind::Board)),
Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)),
Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)),
// ZZ saves (if there are changes) and closes the pane, as in vim: the
+2 -2
View File
@@ -59,8 +59,8 @@ impl App {
})
.or_else(|| commands::shared(&typed, &ctx));
let actions = actions.unwrap_or_else(|| {
let later = format!("\"{}\" comes in a later part of the build", describe(typed.command));
vec![Action::say(Message::info(later))]
let nothing = format!("\"{}\" does nothing in the {} view", describe(typed.command), kind.name());
vec![Action::say(Message::info(nothing))]
});
self.update(actions);
}
+3 -1
View File
@@ -66,6 +66,7 @@ use crate::input::mouse::MouseReader;
use crate::model::DocUid;
use crate::storage::memory::Memory;
use crate::ui::theme::Theme;
use crate::view::board::BoardView;
use crate::view::canvas::{Camera, CanvasView};
use crate::view::note::NoteView;
use crate::view::outline::OutlineView;
@@ -183,7 +184,8 @@ impl Pane {
let mut view: Box<dyn View> = match kind {
ViewKind::Outline => Box::new(OutlineView::new()),
ViewKind::Note => Box::new(NoteView::new()),
ViewKind::Canvas | ViewKind::Board => Box::new(CanvasView::new()),
ViewKind::Board => Box::new(BoardView::new()),
ViewKind::Canvas => Box::new(CanvasView::new()),
};
if let Some(camera) = camera {
view.set_camera(camera);
+7 -1
View File
@@ -182,13 +182,16 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
(Mode::Normal, ViewKind::Note) => {
"j/k parts · Enter edit or go · h/l siblings · a/o add · <C-w> panes · ? help"
}
(Mode::Normal, ViewKind::Board) => {
"j/k cards · h/l columns · i edit · a/o add · dd delete · M move a card · K body · ? help"
}
(Mode::Normal, _) => {
"hjkl move · i edit · a/o add · dd delete · K body · M move · <C-w> panes · <Space> menu · ? help"
}
(Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => {
text_hints(edit)
}
(Mode::Visual(_), ViewKind::Canvas | ViewKind::Board) => {
(Mode::Visual(_), ViewKind::Canvas) => {
"hjkl more · o other end · d delete · y yank · > < in/out · M move · <Space> color, align… · Esc stop"
}
(Mode::Visual(_), ViewKind::Note) => {
@@ -197,6 +200,9 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
(Mode::Visual(_), _) => {
"j/k more · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
}
(Mode::Move(_), ViewKind::Board) => {
"h/l to the column beside · j/k down or up, into and out of blocks · Enter done"
}
(Mode::Move(_), ViewKind::Outline | ViewKind::Note) => {
"j/k swap with a sibling (J/K: 5) · h out a level · l under the one above · Enter done"
}
+2 -3
View File
@@ -4,6 +4,7 @@
//! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod arrange;
mod board;
mod branch_out;
mod config;
mod connect;
@@ -507,10 +508,8 @@ fn indenting_and_outdenting_nodes() {
}
#[test]
fn v1_1_commands_say_so_and_portal_titles_stay() {
fn portal_titles_stay_their_documents_names() {
let mut h = Harness::new();
h.type_keys(":board<CR>");
assert_eq!(h.message(), "the board comes in v1.1");
// Kitchen is a portal: its title is the other document's name.
h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen");
+97
View File
@@ -0,0 +1,97 @@
//! The board (part 9e), end to end: a column per node without a parent,
//! cards in them, blocks wrapping their cards; getting around, move mode,
//! dragging, and typing in a card.
use super::*;
use crate::input::mouse::{Cell, Gesture};
/// Drags the card showing `from` onto the one showing `to`.
fn drag(h: &mut Harness, from: &str, to: &str) {
let (start, end) = (h.find(from), h.find(to));
let (start, end) = (Cell { col: start.0, row: start.1 }, Cell { col: end.0, row: end.1 });
h.mouse(Gesture::Press { at: start, ctrl: false });
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false });
h.mouse(Gesture::Release { at: end, dragged: true });
}
#[test]
fn space_vb_shows_the_board() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
let screen = h.screen();
assert!(screen.contains("┌ board · Garden plans"), "{screen}");
for column in ["╭ Garden plans ", "┌ Seeds to order ", "┌ ask about compost "] {
assert!(screen.contains(column), "a column per node without a parent: {column}\n{screen}");
}
// Watering is a block: its card, its cards further in, and a foot.
let row = h.find("│ Watering │").1;
let rows = h.rows();
assert!(rows[usize::from(row) + 1].contains("├──────────┘"), "{screen}");
assert!(rows[usize::from(row) + 3].contains("│ │ Rain barrel │"), "{screen}");
assert!(rows.iter().any(|row| row.contains("│ └───────")), "the foot:\n{screen}");
// j and k go down and up the column, h and l to the one beside.
h.type_keys("jj");
assert_eq!(h.cursor(), "Raised beds");
h.type_keys("j");
assert_eq!(h.cursor(), "Cedar, untreated", "into the block, opened while the cursor's inside");
h.type_keys("l");
assert_eq!(h.parent(&h.cursor()), "Seeds to order", "the nearest card in the next column");
h.type_keys("l");
assert_eq!(h.cursor(), "ask about compost");
h.type_keys("l");
assert_eq!(h.message(), "that's the last column");
h.type_keys(":canvas<CR>:board<CR>");
assert!(h.screen().contains("┌ board · Garden plans"));
}
#[test]
fn move_mode_moves_cards_through_the_tree() {
let mut h = Harness::new();
h.type_keys("<Space>vbjM");
assert_eq!(h.label(), "MOVE");
// Past Raised beds (folded), then into Watering's block, then past Rain barrel.
h.type_keys("j");
assert_eq!(h.children("Garden plans"), ["Raised beds", "Soil", "Watering", "Kitchen"]);
h.type_keys("j");
assert_eq!(h.children("Watering"), ["Soil", "Rain barrel", "Drip line"]);
h.type_keys("j");
assert_eq!(h.children("Watering"), ["Rain barrel", "Soil", "Drip line"]);
// l: to the end of the next column.
h.type_keys("l<CR>");
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Basil", "Soil"]);
h.type_keys("u");
assert_eq!(h.children("Garden plans"), ["Soil", "Raised beds", "Watering", "Kitchen"], "one undo step");
// A column trades places with the one beside it; the root's stays first.
h.type_keys(":find seeds<CR>Ml<CR>");
let loose: Vec<String> = h.doc().loose().iter().map(|&id| h.title(id)).collect();
assert_eq!(loose, ["ask about compost", "Seeds to order"]);
h.type_keys("ggMl");
assert_eq!(h.message(), "the root's column stays first");
}
#[test]
fn a_card_drags_to_where_it_drops() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
drag(&mut h, "Soil", "Tomatoes");
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"], "after the card it dropped on");
drag(&mut h, "Kitchen", "Watering");
assert_eq!(h.children("Watering")[0], "Kitchen", "dropped on a block: its first card");
drag(&mut h, "Basil", "ask about compost");
assert_eq!(h.children("ask about compost"), ["Basil"], "dropped on a column: its last card");
}
#[test]
fn cards_are_typed_in_and_selected_in_reading_order() {
let mut h = Harness::new();
h.type_keys("<Space>vbjaMul");
assert!(h.screen().contains("┃ Mul ┃"), "typed in its card:\n{}", h.screen());
assert!(h.text_cursor().is_some());
h.type_keys("ch<Esc><Esc>");
assert_eq!(h.children("Soil"), ["Mulch"]);
// v and j select down the column, in reading order.
h.type_keys("ggjvjj");
assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "{}", h.screen());
h.type_keys("d");
assert_eq!(h.message(), "deleted 3 nodes · u to undo");
}
-4
View File
@@ -247,7 +247,6 @@ impl App {
}
}
AppAction::EqualizePanes => self.panes.equalize(),
AppAction::ShowView(ViewKind::Board) => return Some(Message::info("the board comes in v1.1")),
AppAction::ShowView(kind) => {
self.keep_cameras();
let pane = self.panes.current_mut();
@@ -288,9 +287,6 @@ impl App {
}
let pane = self.panes.current();
let kind = view.unwrap_or(pane.view.kind());
if kind == ViewKind::Board {
return Some(Message::info("the board comes in v1.1"));
}
let buffer = pane.buffer;
let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera);
// The new pane starts with a copy of this one's jump list, as in vim
+2 -2
View File
@@ -28,7 +28,7 @@ pub fn selection(visual: &Visual, ctx: &ViewContext<'_>, kind: ViewKind) -> Vec<
let doc = ctx.doc;
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
let mut chosen: BTreeSet<NodeId> = match kind {
ViewKind::Canvas | ViewKind::Board => {
ViewKind::Canvas => {
let rects = ctx.box_rects();
let ends: Vec<CanvasRect> =
[visual.anchor, ctx.cursor].iter().filter_map(|id| rects.get(id).copied()).collect();
@@ -42,7 +42,7 @@ pub fn selection(visual: &Visual, ctx: &ViewContext<'_>, kind: ViewKind) -> Vec<
None => BTreeSet::from([ctx.cursor]),
}
}
ViewKind::Outline | ViewKind::Note => {
ViewKind::Outline | ViewKind::Note | ViewKind::Board => {
let order = doc.outline();
let at = |id: NodeId| order.iter().position(|&other| other == id);
let (from, to) = match (at(visual.anchor), at(ctx.cursor)) {
+3 -18
View File
@@ -18,13 +18,6 @@ pub enum ExportFormat {
Text,
}
/// What the board's columns are.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardColumns {
Tags,
Colors,
}
/// One change `:tag` makes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagChange {
@@ -79,7 +72,8 @@ pub enum Call {
Only,
ClosePane,
Show(ViewKind),
Board(Option<BoardColumns>),
/// `:board`: the focused pane shows the board.
Board,
Capture(String),
Link(NodeId),
Unlink(Option<NodeId>),
@@ -246,7 +240,7 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R
Id::Canvas => Call::Show(ViewKind::Canvas),
Id::Outline => Call::Show(ViewKind::Outline),
Id::Note => Call::Show(ViewKind::Note),
Id::Board => Call::Board(first.map(board_columns).transpose()?),
Id::Board => Call::Board,
Id::Capture => Call::Capture(text.to_string()),
Id::Link => Call::Link(node(text, &sources.nodes)?),
Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?),
@@ -380,14 +374,6 @@ fn view(word: &str) -> Result<ViewKind, CallError> {
.ok_or_else(|| not_a_choice("view", word, registry::VIEWS))
}
fn board_columns(word: &str) -> Result<BoardColumns, CallError> {
match word.to_ascii_lowercase().as_str() {
"tag" | "tags" => Ok(BoardColumns::Tags),
"color" | "colors" => Ok(BoardColumns::Colors),
_ => Err(not_a_choice("way to make columns", word, registry::BOARD_COLUMNS)),
}
}
/// A border by name, as `:border` takes it.
pub fn border(word: &str) -> Result<Border, CallError> {
match word.to_ascii_lowercase().as_str() {
@@ -530,7 +516,6 @@ mod tests {
check(registry::LAYOUTS, &|word| layout(word).is_ok());
check(registry::SIDES, &|word| side(word).is_ok());
check(registry::FORMATS, &|word| export_format(word).is_ok());
check(registry::BOARD_COLUMNS, &|word| board_columns(word).is_ok());
}
#[test]
+1 -4
View File
@@ -205,9 +205,6 @@ pub const SIDES: &[Choice] = &[
pub const FORMATS: &[Choice] = &[choice("md", "Markdown"), choice("txt", "plain text")];
pub const BOARD_COLUMNS: &[Choice] =
&[choice("tag", "a column per tag"), choice("color", "a column per color")];
const fn required(name: &'static str, kind: ArgKind) -> Arg {
Arg { name, kind, required: true }
}
@@ -280,7 +277,7 @@ pub const SPECS: &[Spec] = &[
spec(Id::Canvas, "canvas", "can", &[], "show the canvas"),
spec(Id::Outline, "outline", "out", &[], "show the outline"),
spec(Id::Note, "note", "not", &[], "show the Note view"),
spec(Id::Board, "board", "boa", &[optional("columns", K::Choice(BOARD_COLUMNS))], "show the board"),
spec(Id::Board, "board", "boa", &[], "show the board: a column per top node, blocks inside"),
// Nodes.
spec(Id::Capture, "in", "in", &[required("idea", K::Text)], "capture an idea to the inbox"),
spec(Id::Link, "link", "li", &[required("node", K::Node)], "link this node to another one"),
+1
View File
@@ -247,6 +247,7 @@ const NORMAL: &[DefaultBinding] = &[
bind("<leader>vc", C::ShowCanvas),
bind("<leader>vo", C::ShowOutline),
bind("<leader>vn", C::ShowNote),
bind("<leader>vb", C::ShowBoard),
bind("<leader>w", C::ToggleWhichKey),
bind("<leader>?", C::Help),
bind("?", C::Help),
+2
View File
@@ -354,6 +354,7 @@ pub enum Command {
ShowCanvas,
ShowOutline,
ShowNote,
ShowBoard,
// Help and the app.
Help,
@@ -564,6 +565,7 @@ pub const COMMANDS: &[CommandInfo] = &[
info("show-canvas", C::ShowCanvas, "canvas"),
info("show-outline", C::ShowOutline, "outline"),
info("show-note", C::ShowNote, "note"),
info("show-board", C::ShowBoard, "board"),
// Help and the app.
info("help", C::Help, "help: every key"),
info("toggle-which-key", C::ToggleWhichKey, "key hints on or off"),
+19
View File
@@ -176,6 +176,25 @@ impl Glyphs {
}
}
/// A card's border on the board: thick for the cursor's card and the
/// selected ones.
pub fn card(self, thick: bool) -> border::Set<'static> {
match (self, thick) {
(Glyphs::Unicode, true) => border::THICK,
(Glyphs::Unicode, false) => border::PLAIN,
(Glyphs::Ascii, _) => ASCII_FRAME,
}
}
/// A board card's bottom left corner when its block goes on below it.
pub fn card_joint(self, thick: bool) -> &'static str {
match (self, thick) {
(Glyphs::Unicode, true) => "┣",
(Glyphs::Unicode, false) => "├",
(Glyphs::Ascii, _) => "+",
}
}
/// What stands between the parts of the bars.
pub fn separator(self) -> &'static str {
match self {
+766
View File
@@ -0,0 +1,766 @@
//! The board (part 9e, the user's design): each node without a parent (the
//! root, and each loose node) is a column, its children are the cards in
//! it, and a card with children wraps them, the way Scratch's loop blocks
//! wrap the blocks inside them (a "C"):
//!
//! ```text
//! ┌ Garden plans ──────────┐ ┌ Seeds to order ─┐
//! │ ┌──────┐ │ │ ┌──────────┐ │
//! │ │ Soil │ │ │ │ Tomatoes │ │
//! │ └──────┘ │ │ └──────────┘ │
//! │ ┌──────────┐ │ └─────────────────┘
//! │ │ Watering │ │
//! │ ├──────────┘ │
//! │ │ ┌─────────────┐ │
//! │ │ │ Rain barrel │ │
//! │ │ └─────────────┘ │
//! │ └─────── │
//! └────────────────────────┘
//! ```
//!
//! `j` and `k` go through a column in reading order, `h` and `l` to the
//! column beside. In move mode (`M`) the card moves instead (the user's
//! pick): `h` and `l` take it to the end of the column beside (a column
//! itself trades places with it), `j` and `k` past the cards around it,
//! into and out of the blocks, and a drag drops it where it lands. Every
//! move goes through the tree, so every other view follows.
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use unicode_width::UnicodeWidthStr;
use super::canvas::boxes::{NodeBox, draft_box};
use crate::app::action::{Action, AppAction, Message};
use crate::app::connect;
use crate::app::mode::{Grab, Mode};
use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit};
use crate::app::visual::selection;
use crate::input::bindings::Context;
use crate::input::command::{Command, Motion};
use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture};
use crate::model::{CanvasPos, CanvasRect, CanvasSize, Direction, EditOp, NodeId, Spot, Visible};
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for};
/// Columns between the columns.
const GAP: i32 = 1;
/// How far a block's cards are in from its own card.
const NEST: i32 = 2;
/// How long a block's foot is.
const FOOT: i32 = 8;
/// The narrowest a column gets.
const MIN_WIDTH: i32 = 14;
/// How close the cursor's card may come to an edge before the board
/// scrolls, like vim's `scrolloff`.
const SCROLL_OFF: i32 = 2;
/// How far the wheel and `zh` `zl` scroll sideways, and the wheel up and
/// down.
const SCROLL_COLS: i32 = 6;
const WHEEL_ROWS: i32 = 3;
/// A column: a node without a parent, and where it is on the board.
#[derive(Debug, Clone)]
struct Column {
id: NodeId,
rect: CanvasRect,
/// Its nodes on show in reading order, itself first.
order: Vec<NodeId>,
}
/// A card: a node in a column, and how it's drawn.
#[derive(Debug, Clone)]
struct Card {
id: NodeId,
rect: CanvasRect,
lines: Vec<String>,
tags: String,
badge: String,
/// For a card with children on show (a block): the row of its foot.
foot: Option<i32>,
}
/// A title being typed in its card: what the text is, how it wraps, and
/// where its cursor is.
#[derive(Debug, Clone)]
struct Editing {
id: NodeId,
text: String,
rows: Vec<std::ops::Range<usize>>,
cursor: (usize, usize),
}
/// A drag in progress: the card picked up, and whether it has moved yet.
#[derive(Debug, Clone, Copy)]
struct Drag {
id: NodeId,
from: (i32, i32),
moved: bool,
}
/// The board view.
#[derive(Debug, Default)]
pub struct BoardView {
/// Where on screen the view is.
area: Rect,
/// The board cell at the view's top left.
scroll: (i32, i32),
columns: Vec<Column>,
cards: Vec<Card>,
/// The board's size, for scrolling.
size: (i32, i32),
editing: Option<Editing>,
/// The cursor at the last layout: when it moves, the board follows.
last_cursor: Option<NodeId>,
drag: Option<Drag>,
}
impl BoardView {
pub fn new() -> BoardView {
BoardView::default()
}
/// Lays the board out: the columns side by side, the cards in each one
/// under the other, a block's cards a little further in.
fn build(&mut self, ctx: &ViewContext<'_>) {
let doc = ctx.doc;
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
(self.columns, self.cards, self.editing) = (Vec::new(), Vec::new(), None);
let mut x = 0;
let mut bottom = 0;
for top in doc.top_level() {
let first_card = self.cards.len();
let (end, right) = self.place(ctx, &visible, top, x + 2, 1);
let title_width = doc.node(top).map_or(0, |node| node.title().as_str().width() as i32);
let width = (right - x + 2).max(title_width + 6).max(MIN_WIDTH);
let height = end.max(2) + 1;
let mut order = vec![top];
order.extend(self.cards[first_card..].iter().map(|card| card.id));
let rect = CanvasRect::new(CanvasPos::new(x, 0), size(width, height));
self.columns.push(Column { id: top, rect, order });
bottom = bottom.max(height);
x += width + GAP;
}
self.size = (x, bottom);
}
/// Lays out the cards of `parent`'s children from (`x`, `y`), each under
/// the last. A card with children on show wraps them: they go a little
/// further in, and a foot closes the block. Returns the row after the
/// last, and the furthest column right a card reaches.
fn place(
&mut self,
ctx: &ViewContext<'_>,
visible: &Visible,
parent: NodeId,
x: i32,
mut y: i32,
) -> (i32, i32) {
let (doc, max_width) = (ctx.doc, ctx.settings.max_node_width);
let mut right = x;
for &id in doc.children(parent) {
let Some(node) = doc.node(id) else { continue };
if !visible.contains(id) {
continue;
}
let mut badge = ctx.badge(id, node).unwrap_or_default();
if let Some(count) = visible.hidden.get(&id) {
badge = if badge.is_empty() { format!("+{count}") } else { format!("{badge} +{count}") };
}
let edit = ctx.mode.title_edit().filter(|edit| edit.node == id);
let node_box = match edit {
Some(edit) => {
let draft = draft_box(node, edit.editor.text(), edit.editor.cursor(), badge, max_width);
let text = edit.editor.text().to_string();
self.editing = Some(Editing { id, text, rows: draft.rows, cursor: draft.cursor });
draft.node_box
}
None => NodeBox::new(node, badge, max_width, ctx.theme.glyphs),
};
let rect = CanvasRect::new(CanvasPos::new(x, y), node_box.rect.size);
let NodeBox { lines, tags, badge, .. } = node_box;
right = right.max(rect.right());
y = rect.bottom();
let index = self.cards.len();
self.cards.push(Card { id, rect, lines, tags, badge, foot: None });
let open = !doc.children(id).is_empty() && !visible.hidden.contains_key(&id);
if open {
let (end, inner) = self.place(ctx, visible, id, x + NEST, y);
self.cards[index].foot = Some(end);
right = right.max(inner).max(x + FOOT);
y = end + 1;
}
}
(y, right)
}
/// Where a node is on the board: its card, or its column's top border.
fn rect_of(&self, id: NodeId) -> Option<CanvasRect> {
self.cards.iter().find(|card| card.id == id).map(|card| card.rect).or_else(|| {
let column = self.columns.iter().find(|column| column.id == id)?;
Some(CanvasRect::new(column.rect.pos, size(i32::from(column.rect.size.width), 1)))
})
}
/// The column a node is in.
fn column_of(&self, id: NodeId) -> Option<usize> {
self.columns.iter().position(|column| column.order.contains(&id))
}
/// Scrolls as little as it takes to show `rect`, a little in from the
/// edges.
fn reveal(&mut self, rect: CanvasRect) {
let (width, height) = (i32::from(self.area.width), i32::from(self.area.height));
let (mut x, mut y) = self.scroll;
let off = SCROLL_OFF.min(height / 4);
if rect.top() - off < y {
y = rect.top() - off;
} else if rect.bottom() + off > y + height {
y = rect.bottom() + off - height;
}
if rect.left() < x {
x = rect.left();
} else if rect.right() > x + width {
x = rect.right() - width;
}
self.scroll = (x.max(0), y.max(0));
}
fn scroll_by(&mut self, cols: i32, rows: i32) {
let most = (
(self.size.0 - i32::from(self.area.width)).max(0),
(self.size.1 - i32::from(self.area.height) / 2).max(0),
);
self.scroll = ((self.scroll.0 + cols).clamp(0, most.0), (self.scroll.1 + rows).clamp(0, most.1));
}
/// The board cell under a terminal cell.
fn local(&self, cell: Cell) -> Option<(i32, i32)> {
self.area.contains(Position::new(cell.col, cell.row)).then(|| {
(
i32::from(cell.col - self.area.x) + self.scroll.0,
i32::from(cell.row - self.area.y) + self.scroll.1,
)
})
}
/// What's under a board cell: a card (or the bracket of its block), or
/// a column, and which.
fn at(&self, (x, y): (i32, i32)) -> Option<Target> {
let inside = |rect: CanvasRect| {
(rect.left()..rect.right()).contains(&x) && (rect.top()..rect.bottom()).contains(&y)
};
if let Some(card) = self.cards.iter().find(|card| inside(card.rect)) {
return Some(Target::Card(card.id));
}
// A block's bracket and foot belong to its card.
let bracket = self.cards.iter().rev().find(|card| {
card.foot.is_some_and(|foot| x == card.rect.left() && y >= card.rect.bottom() && y <= foot)
});
if let Some(card) = bracket {
return Some(Target::Card(card.id));
}
let column = self.columns.iter().find(|column| inside(column.rect))?;
Some(Target::Column(column.id))
}
/// `j` and `k`: the next or previous node in the cursor's column.
fn step(&self, ctx: &ViewContext<'_>, down: bool, times: u32) -> Vec<Action> {
let Some(column) = self.column_of(ctx.cursor).map(|index| &self.columns[index]) else {
return Vec::new();
};
let Some(here) = column.order.iter().position(|&id| id == ctx.cursor) else { return Vec::new() };
let times = usize::try_from(times.max(1)).unwrap_or(1);
let there =
if down { (here + times).min(column.order.len() - 1) } else { here.saturating_sub(times) };
if there == here {
let text = if down { "that's the end of the column" } else { "that's the top of the column" };
return vec![Action::say(Message::info(text))];
}
vec![Action::cursor(column.order[there])]
}
/// `h` and `l`: the column beside, on the card nearest the cursor's
/// height (its top node when it has no cards).
fn beside(&self, ctx: &ViewContext<'_>, right: bool, times: u32) -> Vec<Action> {
let (Some(here), Some(from)) = (self.column_of(ctx.cursor), self.rect_of(ctx.cursor)) else {
return Vec::new();
};
let times = usize::try_from(times.max(1)).unwrap_or(1);
let there =
if right { (here + times).min(self.columns.len() - 1) } else { here.saturating_sub(times) };
if there == here {
let text = if right { "that's the last column" } else { "that's the first column" };
return vec![Action::say(Message::info(text))];
}
let column = &self.columns[there];
let middle = from.center().y;
let nearest = column
.order
.iter()
.filter_map(|&id| Some((id, self.rect_of(id)?)))
.min_by_key(|(_, rect)| (rect.center().y - middle).abs())
.map_or(column.id, |(id, _)| id);
vec![Action::cursor(nearest)]
}
/// Move mode: the card goes to the column beside (`h` `l`), or down or
/// up past the cards around it, into and out of blocks (`j` `k`). A
/// column's own node trades places with the column beside instead.
fn move_card(&self, typed: &Typed, grab: &Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let (doc, id) = (ctx.doc, ctx.cursor);
let say = |text: &str| Some(vec![Action::say(Message::info(text))]);
let open = |node: NodeId| {
!doc.children(node).is_empty()
&& self.cards.iter().any(|card| card.id == node && card.foot.is_some())
};
let spot = match typed.command {
Command::Confirm => return Some(vec![Action::App(AppAction::LeaveMode)]),
Command::ToggleWhole => return say("on the board, a card always moves with the cards in it"),
Command::Move(Motion::Left | Motion::Right) => {
let right = typed.command == Command::Move(Motion::Right);
let here = self.column_of(id)?;
let there = if right { here + 1 } else { here.checked_sub(1)? };
let Some(column) = self.columns.get(there) else { return say("that's the last column") };
if id == self.columns[here].id {
// A column trades places with the one beside it.
if id == doc.root() || column.id == doc.root() {
return say("the root's column stays first");
}
if right { Spot::After(column.id) } else { Spot::Before(column.id) }
} else {
Spot::LastChildOf(column.id)
}
}
Command::Move(Motion::Down) => match doc.next_sibling(id) {
Some(next) if open(next) => Spot::FirstChildOf(next),
Some(next) => Spot::After(next),
None => match doc.parent(id) {
Some(parent) if doc.parent(parent).is_some() => Spot::After(parent),
_ => return say("that's the end of the column: h and l go to another"),
},
},
Command::Move(Motion::Up) => match doc.previous_sibling(id) {
Some(before) if open(before) => Spot::LastChildOf(before),
Some(before) => Spot::Before(before),
None => match doc.parent(id) {
Some(parent) if doc.parent(parent).is_some() => Spot::Before(parent),
_ => return say("that's the top of the column: h and l go to another"),
},
},
_ => return None,
};
Some(vec![Action::edit_in(EditOp::MoveTo { id, spot }, grab.group)])
}
/// Where a dragged card goes, dropped on `target`: into a block, as its
/// first card; after another card; at the end of a column.
fn drop_spot(&self, target: Target) -> Spot {
match target {
Target::Card(id) if self.cards.iter().any(|card| card.id == id && card.foot.is_some()) => {
Spot::FirstChildOf(id)
}
Target::Card(id) => Spot::After(id),
Target::Column(id) => Spot::LastChildOf(id),
}
}
/// Where a click lands in the title being typed, if it's on its text.
fn text_offset_at(&self, (x, y): (i32, i32)) -> Option<usize> {
let editing = self.editing.as_ref()?;
let rect = self.rect_of(editing.id)?;
let row = usize::try_from(y - rect.top() - 1).ok().filter(|&row| row < editing.rows.len())?;
let column = usize::try_from(x - rect.left() - 2).unwrap_or(0);
Some(layout::offset(&editing.text, &editing.rows, row, column))
}
/// Draws a column: its frame, with its node's title in the top border.
fn draw_column(&self, buf: &mut Buffer, column: &Column, ctx: &ViewContext<'_>, picked: &[NodeId]) {
let Some(node) = ctx.doc.node(column.id) else { return };
let style = ctx.node_style(column.id, node);
let here = column.id == ctx.cursor;
let color = match (here, picked.contains(&column.id)) {
(true, _) => ctx.theme.accent,
(false, true) => ctx.theme.visual,
_ if style.color == Color::Reset => ctx.theme.muted,
_ => style.color,
};
let set = ctx.theme.glyphs.frame(column.id == ctx.doc.root());
let rect = column.rect;
let border = Style::new().fg(color);
let (left, right, top, bottom) = (rect.left(), rect.right() - 1, rect.top(), rect.bottom() - 1);
for x in left + 1..right {
self.put(buf, x, top, set.horizontal_top, border);
self.put(buf, x, bottom, set.horizontal_bottom, border);
}
for y in top + 1..bottom {
self.put(buf, left, y, set.vertical_left, border);
self.put(buf, right, y, set.vertical_right, border);
}
self.put(buf, left, top, set.top_left, border);
self.put(buf, right, top, set.top_right, border);
self.put(buf, left, bottom, set.bottom_left, border);
self.put(buf, right, bottom, set.bottom_right, border);
let mut title = Style::new().fg(color).add_modifier(Modifier::BOLD);
if here {
title = Style::new().fg(Color::Black).bg(ctx.theme.accent).add_modifier(Modifier::BOLD);
}
let text = format!(" {} ", node.title());
let room = usize::try_from(right - left - 2).unwrap_or(0);
self.put_text(buf, left + 1, top, &truncated(&text, room), title);
}
/// Draws a card, and the bracket and foot of its block.
fn draw_card(&self, buf: &mut Buffer, card: &Card, ctx: &ViewContext<'_>, picked: &[NodeId]) {
let Some(node) = ctx.doc.node(card.id) else { return };
let style = ctx.node_style(card.id, node);
let (here, chosen) = (card.id == ctx.cursor, picked.contains(&card.id));
let typing = ctx.mode.title_edit().is_some_and(|edit| edit.editor.is_typing());
let color = match (here, chosen) {
(true, _) if typing => ctx.theme.insert,
(true, _) => ctx.theme.accent,
(false, true) => ctx.theme.visual,
_ => style.color,
};
let glyphs = ctx.theme.glyphs;
let set = glyphs.card(here || chosen);
let border = Style::new().fg(color);
let rect = card.rect;
let (left, right, top, bottom) = (rect.left(), rect.right() - 1, rect.top(), rect.bottom() - 1);
for x in left + 1..right {
self.put(buf, x, top, set.horizontal_top, border);
self.put(buf, x, bottom, set.horizontal_bottom, border);
}
for y in top + 1..bottom {
self.put(buf, left, y, set.vertical_left, border);
self.put(buf, right, y, set.vertical_right, border);
}
self.put(buf, left, top, set.top_left, border);
self.put(buf, right, top, set.top_right, border);
let corner = if card.foot.is_some() { glyphs.card_joint(here || chosen) } else { set.bottom_left };
self.put(buf, left, bottom, corner, border);
self.put(buf, right, bottom, set.bottom_right, border);
// The tags on the left of the bottom border, the badge on the right.
let muted = Style::new().fg(ctx.theme.muted);
let width = right - left - 1;
if !card.tags.is_empty() {
let tags = truncated(&format!(" {} ", card.tags), usize::try_from(width - 2).unwrap_or(0));
self.put_text(buf, left + 2, bottom, &tags, muted);
}
if !card.badge.is_empty() {
let badge = format!(" {} ", card.badge);
self.put_text(buf, right - 1 - badge.width() as i32, bottom, &badge, muted);
}
let title = if style.faded { muted } else { Style::new() };
let title = if here { title.add_modifier(Modifier::BOLD) } else { title };
for (line, y) in card.lines.iter().zip(top + 1..) {
self.put_text(buf, left + 2, y, line, title);
}
// A block: the bracket down its left, closed by a foot.
if let Some(foot) = card.foot {
let line =
Style::new().fg(if style.color == Color::Reset { ctx.theme.muted } else { style.color });
let plain = glyphs.card(false);
for y in rect.bottom()..foot {
self.put(buf, left, y, plain.vertical_left, line);
}
self.put(buf, left, foot, plain.bottom_left, line);
for x in left + 1..left + FOOT {
self.put(buf, x, foot, plain.horizontal_bottom, line);
}
}
}
/// Puts a symbol on a board cell, if it's on screen.
fn put(&self, buf: &mut Buffer, x: i32, y: i32, symbol: &str, style: Style) {
if let Some((col, row)) = self.screen(x, y) {
buf[(col, row)].set_symbol(symbol).set_style(style);
}
}
/// Writes text from a board cell, as much as fits on screen.
fn put_text(&self, buf: &mut Buffer, x: i32, y: i32, text: &str, style: Style) {
let mut at = x;
for c in text.chars() {
let width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as i32;
if width > 0 {
self.put(buf, at, y, c.encode_utf8(&mut [0; 4]), style);
}
at += width;
}
}
/// The terminal cell over a board cell, if it's in the view.
fn screen(&self, x: i32, y: i32) -> Option<(u16, u16)> {
let (col, row) = (x - self.scroll.0, y - self.scroll.1);
let inside =
(0..i32::from(self.area.width)).contains(&col) && (0..i32::from(self.area.height)).contains(&row);
inside.then(|| {
(self.area.x + u16::try_from(col).unwrap_or(0), self.area.y + u16::try_from(row).unwrap_or(0))
})
}
/// The nodes drawn as chosen: a selection, connect mode's nodes, or what
/// a command line typed in visual mode acts on.
fn picked(ctx: &ViewContext<'_>) -> Vec<NodeId> {
match ctx.mode {
Mode::Visual(visual) => selection(visual, ctx, ViewKind::Board),
Mode::Connect(connecting) => connect::others(connecting, ctx.cursor),
Mode::Command(line) => line.selection.clone(),
_ => Vec::new(),
}
}
}
/// What's under the mouse on the board.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Target {
Card(NodeId),
Column(NodeId),
}
/// A size on the board.
fn size(width: i32, height: i32) -> CanvasSize {
CanvasSize::new(
u16::try_from(width.max(0)).unwrap_or(u16::MAX),
u16::try_from(height.max(0)).unwrap_or(u16::MAX),
)
}
/// Text cut to `width` columns, with `…` when it's cut.
fn truncated(text: &str, width: usize) -> String {
if text.width() <= width {
return text.to_string();
}
let mut out = String::new();
for c in text.chars() {
if out.width() + unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) + 1 > width {
break;
}
out.push(c);
}
out.push('…');
out
}
impl View for BoardView {
fn kind(&self) -> ViewKind {
ViewKind::Board
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(
mode,
&[Context::Normal, Context::Motions],
&[Context::Visual, Context::Motions],
&[Context::Connect, Context::Motions],
)
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
self.build(ctx);
if ((moved && self.drag.is_none()) || self.editing.is_some())
&& let Some(rect) = self.rect_of(ctx.cursor)
{
self.reveal(rect);
}
}
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
let picked = BoardView::picked(ctx);
let buf = frame.buffer_mut();
for column in &self.columns {
self.draw_column(buf, column, ctx, &picked);
}
for card in &self.cards {
self.draw_card(buf, card, ctx, &picked);
}
if let Some(editing) = &self.editing
&& let Some(rect) = self.rect_of(editing.id)
{
let (row, col) = editing.cursor;
let at = self.screen(rect.left() + 2 + col as i32, rect.top() + 1 + row as i32);
if let Some((col, row)) = at {
frame.set_cursor_position(Position::new(col, row));
}
}
}
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return self.move_card(typed, grab, ctx),
Mode::Normal | Mode::Visual(_) | Mode::Connect(_) => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let times = typed.times();
let steps = i32::try_from(times).unwrap_or(1);
let half = i32::from(self.area.height / 2).max(1);
match typed.command {
Command::Move(Motion::Down) => return Some(self.step(ctx, true, times)),
Command::Move(Motion::Up) => return Some(self.step(ctx, false, times)),
Command::Move(Motion::Left) => return Some(self.beside(ctx, false, times)),
Command::Move(Motion::Right) => return Some(self.beside(ctx, true, times)),
Command::ScrollDown => self.scroll_by(0, steps),
Command::ScrollUp => self.scroll_by(0, -steps),
Command::HalfPageDown => self.scroll_by(0, half * steps),
Command::HalfPageUp => self.scroll_by(0, -half * steps),
Command::ScrollLeft => self.scroll_by(-SCROLL_COLS * steps, 0),
Command::ScrollRight => self.scroll_by(SCROLL_COLS * steps, 0),
Command::Center => {
if let Some(rect) = self.rect_of(ctx.cursor) {
let (width, height) = (i32::from(self.area.width), i32::from(self.area.height));
self.scroll =
((rect.center().x - width / 2).max(0), (rect.center().y - height / 2).max(0));
}
}
_ => return None,
}
Some(Vec::new())
}
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
match gesture {
Gesture::Press { at, ctrl } => {
let Some(at) = self.local(at) else { return Vec::new() };
let mut actions = Vec::new();
if let Some(edit) = ctx.mode.title_edit() {
// A click in the title being typed moves its cursor;
// anywhere else, the edit is done first.
if let Some(offset) = self.text_offset_at(at) {
return vec![Action::App(AppAction::TextCursor(offset))];
}
actions.extend(edit.finish(ctx));
}
let Some(target) = self.at(at) else { return actions };
let id = match target {
Target::Card(id) | Target::Column(id) => id,
};
if ctrl {
return vec![Action::App(AppAction::ToggleSelected(id))];
}
// Cards can be dragged; in connect mode a click only aims.
if matches!(target, Target::Card(_)) && !matches!(ctx.mode, Mode::Connect(_)) {
self.drag = Some(Drag { id, from: at, moved: false });
}
actions.push(Action::cursor(id));
actions
}
Gesture::Drag { at, .. } => {
let here = self.local(at);
if let (Some(drag), Some(here)) = (&mut self.drag, here) {
drag.moved |= here != drag.from;
}
Vec::new()
}
Gesture::Release { at, .. } => {
let Some(drag) = self.drag.take().filter(|drag| drag.moved) else { return Vec::new() };
let Some(target) = self.local(at).and_then(|at| self.at(at)) else { return Vec::new() };
if target == Target::Card(drag.id) {
return Vec::new();
}
let spot = self.drop_spot(target);
vec![Action::edit(EditOp::MoveTo { id: drag.id, spot }), Action::cursor(drag.id)]
}
Gesture::DoublePress { at } => {
let Some(at) = self.local(at) else { return Vec::new() };
if let Some(offset) = self.text_offset_at(at) {
return vec![Action::App(AppAction::TextCursor(offset))];
}
let Some(Target::Card(id) | Target::Column(id)) = self.at(at) else { return Vec::new() };
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
actions.extend([
Action::cursor(id),
Action::App(AppAction::StartEdit(StartEdit::title(EditAt::End))),
]);
actions
}
Gesture::Scroll { direction, .. } => {
match direction {
Direction::Down => self.scroll_by(0, WHEEL_ROWS),
Direction::Up => self.scroll_by(0, -WHEEL_ROWS),
Direction::Left => self.scroll_by(-SCROLL_COLS, 0),
Direction::Right => self.scroll_by(SCROLL_COLS, 0),
}
Vec::new()
}
Gesture::RightPress { at: cell } => {
let Some(Target::Card(id) | Target::Column(id)) = self.local(cell).and_then(|at| self.at(at))
else {
return Vec::new();
};
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let (what, col, row) = (MenuFor::Node(id), cell.col, cell.row);
actions.extend([Action::cursor(id), Action::App(AppAction::OpenMenu { what, col, row })]);
actions
}
}
}
fn status(&self) -> String {
"board".to_string()
}
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
let rect = self.rect_of(ctx.cursor)?;
let (left, top) = self.screen(rect.left().max(self.scroll.0), rect.top().max(self.scroll.1))?;
let width = u16::try_from(rect.right() - self.scroll.0)
.ok()?
.min(self.area.width)
.saturating_sub(left - self.area.x);
let height = u16::try_from(rect.bottom() - self.scroll.1)
.ok()?
.min(self.area.height)
.saturating_sub(top - self.area.y);
(width > 0 && height > 0).then(|| Rect::new(left, top, width, height))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::test_support::garden;
use crate::view::test_support::Setup;
/// The board of the garden, on a terminal `width` by `height`.
fn board(width: u16, height: u16) -> (BoardView, Vec<String>) {
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.soil);
let mut view = BoardView::new();
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
view.layout(frame.area(), &ctx);
view.render(frame, &ctx);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rows = buffer
.content()
.chunks(usize::from(width))
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>().trim_end().to_string())
.collect();
(view, rows)
}
#[test]
fn columns_hold_cards_and_blocks_wrap_theirs() {
let (_, rows) = board(60, 14);
let text = rows.join("\n");
assert!(
rows[0].starts_with("╭ Garden plans ─") && rows[0].contains("┌ ask about compost ─"),
"{text}"
);
assert!(text.contains("┏━━━━━━┓"), "the cursor's card is thick:\n{text}");
// Raised beds is a block: its card, then Cedar further in, then a foot.
let beds = rows.iter().position(|row| row.contains("Raised beds")).unwrap();
assert!(rows[beds + 1].starts_with("│ ├─"), "the block's joint:\n{text}");
assert!(rows[beds + 3].contains("│ │ │ Cedar │"), "its card inside:\n{text}");
assert!(rows[beds + 5].starts_with("│ └───────"), "and its foot:\n{text}");
}
}
+1
View File
@@ -10,6 +10,7 @@
//! 2. [`View::render`] draws it,
//! 3. [`View::command`] and [`View::mouse`] turn input into actions.
pub mod board;
pub mod canvas;
pub mod note;
pub mod outline;