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]] [[package]]
name = "cumulus" name = "cumulus"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"directories", "directories",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cumulus" name = "cumulus"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
# The version cumulus is built and tested with (pinned in rust-toolchain.toml). # The version cumulus is built and tested with (pinned in rust-toolchain.toml).
rust-version = "1.90" 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 list: the matches as they were when you searched. `/` and `:find` still
search the document shown, and `n` after them stays in it. 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) **Views, panes and popups** (settled in stage 7)
- The Note view is made of parts: the title, the tags, the body, each link - 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 (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. | | **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. | | **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. | | **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. - **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, - **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 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, 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 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 ## 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 | | **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 | | **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 | | **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) | | **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 | | **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 | | **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 | | **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 | | **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 | | **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 │ keymap (+ keys.toml), engine (counts, registers, operators), mouse
├─ command/ registry, parse, complete, history ├─ command/ registry, parse, complete, history
├─ view/ mod.rs (the View trait), canvas/ (camera, boxes, lines, route, scene, render, ├─ 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: ├─ 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 │ motion, object, layout), list (the popup list help, menus and pickers
│ share), card (the K card), status_bar, document_bar, which_key, │ 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`, 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/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/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 they copy the sample
documents to a temporary folder, type keys and click on a test terminal, and documents to a temporary folder, type keys and click on a test terminal, and
check the screen, the documents and the files. 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 and color-coded, across as many documents as you like. Vim keys first; the
mouse works too. Runs on Windows and Linux. mouse works too. Runs on Windows and Linux.
> **Status:** v1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 list. > **Status:** v1.1 is built: everything on [DESIGN.md](DESIGN.md)'s v1 and
> v1.1 (the board, a search across documents, tidying, export) comes next. > v1.1 lists. What comes later is listed there too.
## Build and run ## 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>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`) | | `<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 | | 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) | | `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 | | `gt` `gT` | the next / previous document |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) | | `:` `?` | 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::Split(view) => app(AppAction::Split { arrange: Arrange::Stacked, view }),
Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }), Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }),
Call::Only => app(AppAction::OnlyPane), 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::Show(view) => app(AppAction::ShowView(view)),
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)), Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))), Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
@@ -109,10 +109,6 @@ fn say(message: Message) -> Vec<Action> {
vec![Action::say(message)] 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. /// `: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. /// The cursor stays where it is: capturing shouldn't break your flow.
fn capture(ctx: &ViewContext<'_>, text: &str) -> Vec<Action> { 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::ShowCanvas => app(AppAction::ShowView(ViewKind::Canvas)),
Command::ShowOutline => app(AppAction::ShowView(ViewKind::Outline)), Command::ShowOutline => app(AppAction::ShowView(ViewKind::Outline)),
Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)), Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)),
Command::ShowBoard => app(AppAction::ShowView(ViewKind::Board)),
Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)), Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)),
Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)), Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)),
// ZZ saves (if there are changes) and closes the pane, as in vim: the // 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)); .or_else(|| commands::shared(&typed, &ctx));
let actions = actions.unwrap_or_else(|| { let actions = actions.unwrap_or_else(|| {
let later = format!("\"{}\" comes in a later part of the build", describe(typed.command)); let nothing = format!("\"{}\" does nothing in the {} view", describe(typed.command), kind.name());
vec![Action::say(Message::info(later))] vec![Action::say(Message::info(nothing))]
}); });
self.update(actions); self.update(actions);
} }
+3 -1
View File
@@ -66,6 +66,7 @@ use crate::input::mouse::MouseReader;
use crate::model::DocUid; use crate::model::DocUid;
use crate::storage::memory::Memory; use crate::storage::memory::Memory;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
use crate::view::board::BoardView;
use crate::view::canvas::{Camera, CanvasView}; use crate::view::canvas::{Camera, CanvasView};
use crate::view::note::NoteView; use crate::view::note::NoteView;
use crate::view::outline::OutlineView; use crate::view::outline::OutlineView;
@@ -183,7 +184,8 @@ impl Pane {
let mut view: Box<dyn View> = match kind { let mut view: Box<dyn View> = match kind {
ViewKind::Outline => Box::new(OutlineView::new()), ViewKind::Outline => Box::new(OutlineView::new()),
ViewKind::Note => Box::new(NoteView::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 { if let Some(camera) = camera {
view.set_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) => { (Mode::Normal, ViewKind::Note) => {
"j/k parts · Enter edit or go · h/l siblings · a/o add · <C-w> panes · ? help" "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, _) => { (Mode::Normal, _) => {
"hjkl move · i edit · a/o add · dd delete · K body · M move · <C-w> panes · <Space> menu · ? help" "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), .. })), _) => { (Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => {
text_hints(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" "hjkl more · o other end · d delete · y yank · > < in/out · M move · <Space> color, align… · Esc stop"
} }
(Mode::Visual(_), ViewKind::Note) => { (Mode::Visual(_), ViewKind::Note) => {
@@ -197,6 +200,9 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
(Mode::Visual(_), _) => { (Mode::Visual(_), _) => {
"j/k more · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop" "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) => { (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" "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`. //! `tests/files.rs`, and the config folder in `tests/config.rs`.
mod arrange; mod arrange;
mod board;
mod branch_out; mod branch_out;
mod config; mod config;
mod connect; mod connect;
@@ -507,10 +508,8 @@ fn indenting_and_outdenting_nodes() {
} }
#[test] #[test]
fn v1_1_commands_say_so_and_portal_titles_stay() { fn portal_titles_stay_their_documents_names() {
let mut h = Harness::new(); 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. // Kitchen is a portal: its title is the other document's name.
h.type_keys("glgjgjgj"); h.type_keys("glgjgjgj");
assert_eq!(h.cursor(), "Kitchen"); 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::EqualizePanes => self.panes.equalize(),
AppAction::ShowView(ViewKind::Board) => return Some(Message::info("the board comes in v1.1")),
AppAction::ShowView(kind) => { AppAction::ShowView(kind) => {
self.keep_cameras(); self.keep_cameras();
let pane = self.panes.current_mut(); let pane = self.panes.current_mut();
@@ -288,9 +287,6 @@ impl App {
} }
let pane = self.panes.current(); let pane = self.panes.current();
let kind = view.unwrap_or(pane.view.kind()); 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 buffer = pane.buffer;
let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera); 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 // 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 doc = ctx.doc;
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus); let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
let mut chosen: BTreeSet<NodeId> = match kind { let mut chosen: BTreeSet<NodeId> = match kind {
ViewKind::Canvas | ViewKind::Board => { ViewKind::Canvas => {
let rects = ctx.box_rects(); let rects = ctx.box_rects();
let ends: Vec<CanvasRect> = let ends: Vec<CanvasRect> =
[visual.anchor, ctx.cursor].iter().filter_map(|id| rects.get(id).copied()).collect(); [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]), None => BTreeSet::from([ctx.cursor]),
} }
} }
ViewKind::Outline | ViewKind::Note => { ViewKind::Outline | ViewKind::Note | ViewKind::Board => {
let order = doc.outline(); let order = doc.outline();
let at = |id: NodeId| order.iter().position(|&other| other == id); let at = |id: NodeId| order.iter().position(|&other| other == id);
let (from, to) = match (at(visual.anchor), at(ctx.cursor)) { let (from, to) = match (at(visual.anchor), at(ctx.cursor)) {
+3 -18
View File
@@ -18,13 +18,6 @@ pub enum ExportFormat {
Text, Text,
} }
/// What the board's columns are.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardColumns {
Tags,
Colors,
}
/// One change `:tag` makes. /// One change `:tag` makes.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagChange { pub enum TagChange {
@@ -79,7 +72,8 @@ pub enum Call {
Only, Only,
ClosePane, ClosePane,
Show(ViewKind), Show(ViewKind),
Board(Option<BoardColumns>), /// `:board`: the focused pane shows the board.
Board,
Capture(String), Capture(String),
Link(NodeId), Link(NodeId),
Unlink(Option<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::Canvas => Call::Show(ViewKind::Canvas),
Id::Outline => Call::Show(ViewKind::Outline), Id::Outline => Call::Show(ViewKind::Outline),
Id::Note => Call::Show(ViewKind::Note), 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::Capture => Call::Capture(text.to_string()),
Id::Link => Call::Link(node(text, &sources.nodes)?), Id::Link => Call::Link(node(text, &sources.nodes)?),
Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?), 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)) .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. /// A border by name, as `:border` takes it.
pub fn border(word: &str) -> Result<Border, CallError> { pub fn border(word: &str) -> Result<Border, CallError> {
match word.to_ascii_lowercase().as_str() { match word.to_ascii_lowercase().as_str() {
@@ -530,7 +516,6 @@ mod tests {
check(registry::LAYOUTS, &|word| layout(word).is_ok()); check(registry::LAYOUTS, &|word| layout(word).is_ok());
check(registry::SIDES, &|word| side(word).is_ok()); check(registry::SIDES, &|word| side(word).is_ok());
check(registry::FORMATS, &|word| export_format(word).is_ok()); check(registry::FORMATS, &|word| export_format(word).is_ok());
check(registry::BOARD_COLUMNS, &|word| board_columns(word).is_ok());
} }
#[test] #[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 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 { const fn required(name: &'static str, kind: ArgKind) -> Arg {
Arg { name, kind, required: true } Arg { name, kind, required: true }
} }
@@ -280,7 +277,7 @@ pub const SPECS: &[Spec] = &[
spec(Id::Canvas, "canvas", "can", &[], "show the canvas"), spec(Id::Canvas, "canvas", "can", &[], "show the canvas"),
spec(Id::Outline, "outline", "out", &[], "show the outline"), spec(Id::Outline, "outline", "out", &[], "show the outline"),
spec(Id::Note, "note", "not", &[], "show the Note view"), 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. // Nodes.
spec(Id::Capture, "in", "in", &[required("idea", K::Text)], "capture an idea to the inbox"), 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"), 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>vc", C::ShowCanvas),
bind("<leader>vo", C::ShowOutline), bind("<leader>vo", C::ShowOutline),
bind("<leader>vn", C::ShowNote), bind("<leader>vn", C::ShowNote),
bind("<leader>vb", C::ShowBoard),
bind("<leader>w", C::ToggleWhichKey), bind("<leader>w", C::ToggleWhichKey),
bind("<leader>?", C::Help), bind("<leader>?", C::Help),
bind("?", C::Help), bind("?", C::Help),
+2
View File
@@ -354,6 +354,7 @@ pub enum Command {
ShowCanvas, ShowCanvas,
ShowOutline, ShowOutline,
ShowNote, ShowNote,
ShowBoard,
// Help and the app. // Help and the app.
Help, Help,
@@ -564,6 +565,7 @@ pub const COMMANDS: &[CommandInfo] = &[
info("show-canvas", C::ShowCanvas, "canvas"), info("show-canvas", C::ShowCanvas, "canvas"),
info("show-outline", C::ShowOutline, "outline"), info("show-outline", C::ShowOutline, "outline"),
info("show-note", C::ShowNote, "note"), info("show-note", C::ShowNote, "note"),
info("show-board", C::ShowBoard, "board"),
// Help and the app. // Help and the app.
info("help", C::Help, "help: every key"), info("help", C::Help, "help: every key"),
info("toggle-which-key", C::ToggleWhichKey, "key hints on or off"), 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. /// What stands between the parts of the bars.
pub fn separator(self) -> &'static str { pub fn separator(self) -> &'static str {
match self { 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, //! 2. [`View::render`] draws it,
//! 3. [`View::command`] and [`View::mouse`] turn input into actions. //! 3. [`View::command`] and [`View::mouse`] turn input into actions.
pub mod board;
pub mod canvas; pub mod canvas;
pub mod note; pub mod note;
pub mod outline; pub mod outline;