From 3716919db36ba522a596cefc52e71aff53fb3467 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 2 Oct 2026 13:05:05 -0400 Subject: [PATCH] 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. 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- DESIGN.md | 37 +- README.md | 7 +- src/app/calls.rs | 6 +- src/app/commands.rs | 1 + src/app/input.rs | 4 +- src/app/mod.rs | 4 +- src/app/render.rs | 8 +- src/app/tests.rs | 5 +- src/app/tests/board.rs | 97 +++++ src/app/update.rs | 4 - src/app/visual.rs | 4 +- src/command/parse.rs | 21 +- src/command/registry.rs | 5 +- src/input/bindings.rs | 1 + src/input/command.rs | 2 + src/ui/glyphs.rs | 19 + src/view/board.rs | 766 ++++++++++++++++++++++++++++++++++++++++ src/view/mod.rs | 1 + 20 files changed, 946 insertions(+), 50 deletions(-) create mode 100644 src/app/tests/board.rs create mode 100644 src/view/board.rs diff --git a/Cargo.lock b/Cargo.lock index 50de61e..2b4d509 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,7 +148,7 @@ dependencies = [ [[package]] name = "cumulus" -version = "1.0.0" +version = "1.1.0" dependencies = [ "arboard", "directories", diff --git a/Cargo.toml b/Cargo.toml index c36a295..d9accad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/DESIGN.md b/DESIGN.md index 33c509e..2df82fe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 `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** `` | `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** `` | `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. diff --git a/README.md b/README.md index b92b5c5..328428d 100644 --- a/README.md +++ b/README.md @@ -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): | `p` | a portal to another document (`:portal file`, or a new document's title) | | `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 | -| `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note… | +| `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note, `vb` board… | +| board | `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) | diff --git a/src/app/calls.rs b/src/app/calls.rs index 78e1dde..fb3d21a 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -40,7 +40,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec 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 { vec![Action::say(message)] } -fn later(text: &str) -> Vec { - 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 { diff --git a/src/app/commands.rs b/src/app/commands.rs index 2c2ba01..6103b84 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -120,6 +120,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { 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 diff --git a/src/app/input.rs b/src/app/input.rs index b61f6bf..a1ad8b4 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -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); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 6c86867..fb17f88 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 = 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); diff --git a/src/app/render.rs b/src/app/render.rs index acb94f3..5b4a54a 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -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 · 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 · panes · 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 · 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 · 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" } diff --git a/src/app/tests.rs b/src/app/tests.rs index d77287b..cb2db93 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -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"); - 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"); diff --git a/src/app/tests/board.rs b/src/app/tests/board.rs new file mode 100644 index 0000000..2aba0c2 --- /dev/null +++ b/src/app/tests/board.rs @@ -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("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:board"); + assert!(h.screen().contains("┌ board · Garden plans")); +} + +#[test] +fn move_mode_moves_cards_through_the_tree() { + let mut h = Harness::new(); + h.type_keys("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"); + 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 seedsMl"); + let loose: Vec = 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("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("vbjaMul"); + assert!(h.screen().contains("┃ Mul ┃"), "typed in its card:\n{}", h.screen()); + assert!(h.text_cursor().is_some()); + h.type_keys("ch"); + 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"); +} diff --git a/src/app/update.rs b/src/app/update.rs index ee08b47..d57231e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 diff --git a/src/app/visual.rs b/src/app/visual.rs index 216e457..61b9a3b 100644 --- a/src/app/visual.rs +++ b/src/app/visual.rs @@ -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 = match kind { - ViewKind::Canvas | ViewKind::Board => { + ViewKind::Canvas => { let rects = ctx.box_rects(); let ends: Vec = [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)) { diff --git a/src/command/parse.rs b/src/command/parse.rs index 560955b..82cb970 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -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), + /// `:board`: the focused pane shows the board. + Board, Capture(String), Link(NodeId), Unlink(Option), @@ -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 { .ok_or_else(|| not_a_choice("view", word, registry::VIEWS)) } -fn board_columns(word: &str) -> Result { - 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 { 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] diff --git a/src/command/registry.rs b/src/command/registry.rs index 3eccb98..f017b98 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -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"), diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 6ed5eec..485793a 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -247,6 +247,7 @@ const NORMAL: &[DefaultBinding] = &[ bind("vc", C::ShowCanvas), bind("vo", C::ShowOutline), bind("vn", C::ShowNote), + bind("vb", C::ShowBoard), bind("w", C::ToggleWhichKey), bind("?", C::Help), bind("?", C::Help), diff --git a/src/input/command.rs b/src/input/command.rs index 6398eda..969f10c 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -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"), diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs index 8e49851..e6d524d 100644 --- a/src/ui/glyphs.rs +++ b/src/ui/glyphs.rs @@ -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 { diff --git a/src/view/board.rs b/src/view/board.rs new file mode 100644 index 0000000..19cd824 --- /dev/null +++ b/src/view/board.rs @@ -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, +} + +/// A card: a node in a column, and how it's drawn. +#[derive(Debug, Clone)] +struct Card { + id: NodeId, + rect: CanvasRect, + lines: Vec, + tags: String, + badge: String, + /// For a card with children on show (a block): the row of its foot. + foot: Option, +} + +/// 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>, + 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, + cards: Vec, + /// The board's size, for scrolling. + size: (i32, i32), + editing: Option, + /// The cursor at the last layout: when it moves, the board follows. + last_cursor: Option, + drag: Option, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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 { + 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) { + 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::().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}"); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs index 2fa8d90..17ec0f4 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -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;