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:
1 parent
893edbda9f
commit
3716919db3
20 files changed
+946
-50
No files matched your search
Generated
+1
-1
@@ -148,7 +148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cumulus"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"directories",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user