Draw the board as the canvas draws boxes, and drag cards live

The user found the board's look off from the canvas's: a block's C was
gray around a white card, borders ignored the node's style, a folded
column said nothing, and dragging felt wrong.

Columns and cards now go through the canvas's line layer, with the same
rules as Scene::add_borders: the node's border style and color, thick in
the accent color for the cursor, thick in the visual color for the
selection. A block's bracket and foot are drawn in its card's own line
and color, so the C is one shape and its joint comes out as ├, ┣ or ╠.
Tags and badges sit in the bottom border through a footer() shared with
the canvas renderer. Columns show their tags and badge too, so a folded
column says +N, and a column's title can be typed in its top border
(it wasn't shown as typed before).

A drag now moves the card live, as on the canvas (the user's pick), all
one undo step: by the row under the mouse, before a card on its top
half, after it or into its block on its bottom half, out at a foot,
first on a column's title, last below the cards. Columns keep their
widths while dragging, or a column that narrowed would slide the one
under the mouse away and the card would chase it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 13:49:48 -04:00
1 parent 3716919db3
commit 38eca3c8c3
8 files changed
+424 -233

No files matched your search

+21 -8
View File
@@ -343,9 +343,15 @@ lines.
left past its cards (two cells further in), and a foot closing it. Blocks 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 nest as deep as the tree does. The columns by tag or by color the first
design had are dropped (the user's pick). 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 - A card is a node's box, and a column a node's box with the title in its
in the bottom border, its color on the border, thick for the cursor. top border, drawn exactly as the canvas draws them (the user asked for
Folds fold here too (a folded block is a card with `+N`). them to match): its border as its style says (rounded for the root,
dashed for a question, double…), in its color, thick in the accent color
for the cursor; the tags in its color and the badge in its bottom border.
The borders go through the canvas's line layer, so a block's "C" is in
its card's own line and color, joined where they meet (`├`, `┣`, `╠`).
Folds fold here too: a folded block is a card with `+N`, and a folded
column says `+N` in its bottom border.
- `j` and `k` go down and up a column in reading order (into the blocks), - `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 `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 height. Titles are typed right in their cards, and `<Space>vb` or
@@ -354,9 +360,15 @@ lines.
the end of the column beside, `j` and `k` past the cards around it, into 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 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 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 (the root's stays first). Enter is done, one undo step. Every move goes
same with the mouse: dropped on a card, it goes after it; on a block's through the tree.
card, into it; on a column, at its end. Every move goes through the tree. - A drag moves the card live, as the canvas does (the user's pick): it goes
through the board as the mouse goes, by the row the mouse is on in the
column under it. On a card's top half it goes before that card; on its
bottom half after it, or into its block as the first card; on a block's
foot, after the block; on a column's title, first; below the cards,
last. The whole drag is one undo step. The columns keep their widths
while a card is dragged, so the one under the mouse stays put.
- In visual mode the board selects in reading order, as the outline does. - In visual mode the board selects in reading order, as the outline does.
**Views, panes and popups** (settled in stage 7) **Views, panes and popups** (settled in stage 7)
@@ -556,8 +568,9 @@ pans up and down, and sideways with a wheel that tilts, a touchpad, or the
wheel with Shift or Alt held (Alt for terminals that keep Shift for selecting wheel with Shift or Alt held (Alt for terminals that keep Shift for selecting
text), Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node, text), Ctrl+drag selects a rectangle, Ctrl+click adds or removes one node,
right-click opens a menu built from the same actions as the keys. Zoom is on right-click opens a menu built from the same actions as the keys. Zoom is on
the `+` and `-` keys. On the board, dragging a card moves it in the tree: the `+` and `-` keys. On the board, dragging a card moves it through the
after the card it's dropped on, into a block, or to the end of a column. tree as the mouse goes: before or after the card under it (by which half),
into a block, out at its foot, or to a column's top or end.
## Modes and keys ## Modes and keys
+109 -18
View File
@@ -1,17 +1,39 @@
//! The board (part 9e), end to end: a column per node without a parent, //! 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, //! cards in them, blocks wrapping their cards, all drawn as the canvas draws
//! dragging, and typing in a card. //! boxes; getting around, move mode, dragging, and typing in a card.
use ratatui::style::Color;
use super::*; use super::*;
use crate::input::mouse::{Cell, Gesture}; use crate::input::mouse::{Cell, Gesture};
/// Drags the card showing `from` onto the one showing `to`. /// Picks up the card showing `title`.
fn drag(h: &mut Harness, from: &str, to: &str) { fn pick_up(h: &mut Harness, title: &str) -> Cell {
let (start, end) = (h.find(from), h.find(to)); let (col, row) = h.find(title);
let (start, end) = (Cell { col: start.0, row: start.1 }, Cell { col: end.0, row: end.1 }); let at = Cell { col, row };
h.mouse(Gesture::Press { at: start, ctrl: false }); h.mouse(Gesture::Press { at, ctrl: false });
h.mouse(Gesture::Drag { from: start, at: end, ctrl: false }); at
h.mouse(Gesture::Release { at: end, dragged: true }); }
/// Moves the mouse to a cell, holding the card picked up at `from`.
fn drag_to(h: &mut Harness, from: Cell, (col, row): (u16, u16)) {
h.mouse(Gesture::Drag { from, at: Cell { col, row }, ctrl: false });
}
fn let_go(h: &mut Harness, (col, row): (u16, u16)) {
h.mouse(Gesture::Release { at: Cell { col, row }, dragged: true });
}
/// A cell on the bottom half of the card showing `title`: its bottom
/// border. Its title's row is on the top half.
fn bottom_half(h: &Harness, title: &str) -> (u16, u16) {
let (col, row) = h.find(title);
(col, row + 1)
}
/// The color a cell is drawn in.
fn color_at(h: &Harness, (col, row): (u16, u16)) -> Color {
h.terminal.backend().buffer()[(col, row)].fg
} }
#[test] #[test]
@@ -20,7 +42,9 @@ fn space_vb_shows_the_board() {
h.type_keys("<Space>vb"); h.type_keys("<Space>vb");
let screen = h.screen(); let screen = h.screen();
assert!(screen.contains("┌ board · Garden plans"), "{screen}"); assert!(screen.contains("┌ board · Garden plans"), "{screen}");
for column in ["╭ Garden plans ", "┌ Seeds to order ", "┌ ask about compost "] { // The cursor's column is thick, as the cursor's box is on the canvas;
// ask about compost is dashed, as its style says.
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}"); 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. // Watering is a block: its card, its cards further in, and a foot.
@@ -28,7 +52,7 @@ fn space_vb_shows_the_board() {
let rows = h.rows(); let rows = h.rows();
assert!(rows[usize::from(row) + 1].contains("├──────────┘"), "{screen}"); assert!(rows[usize::from(row) + 1].contains("├──────────┘"), "{screen}");
assert!(rows[usize::from(row) + 3].contains("│ │ Rain barrel │"), "{screen}"); assert!(rows[usize::from(row) + 3].contains("│ │ Rain barrel │"), "{screen}");
assert!(rows.iter().any(|row| row.contains("│ └───────")), "the foot:\n{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. // j and k go down and up the column, h and l to the one beside.
h.type_keys("jj"); h.type_keys("jj");
assert_eq!(h.cursor(), "Raised beds"); assert_eq!(h.cursor(), "Raised beds");
@@ -44,6 +68,40 @@ fn space_vb_shows_the_board() {
assert!(h.screen().contains("┌ board · Garden plans")); assert!(h.screen().contains("┌ board · Garden plans"));
} }
#[test]
fn cards_and_columns_are_drawn_as_the_canvas_draws_boxes() {
let mut h = Harness::new();
// Drip line opened: a block with no color of its own, around a card
// with a double border.
h.type_keys("<Space>vb:find drip<CR>za");
let screen = h.screen();
assert!(screen.contains("╔══════════════════╗"), "Timer on the tap's double border:\n{screen}");
assert!(screen.contains("└─ #todo ─┘"), "Soil's tags in its bottom border:\n{screen}");
// The C is one shape with its card: the same line, the same color.
h.type_keys("k");
let (col, row) = h.find("│ Drip line │");
let card = color_at(&h, (col, row));
assert_eq!(card, Color::Reset, "a card without a color is in the text's color, as on the canvas");
assert_eq!(h.rows()[usize::from(row) + 1].chars().nth(usize::from(col)), Some('├'));
for below in 1..=4 {
assert_eq!(color_at(&h, (col, row + below)), card, "the bracket, {below} below:\n{screen}");
}
let (foot, foot_row) = h.find_in("└───────", usize::from(row)..=h.rows().len() - 3).unwrap();
assert_eq!((foot, color_at(&h, (foot, foot_row))), (col, card), "the foot");
// A folded column says what it hides, in its bottom border.
h.type_keys(":find seeds<CR>za");
let (_, top) = h.find("Seeds to order");
assert!(!h.screen().contains("Tomatoes"), "{}", h.screen());
assert!(h.rows()[usize::from(top) + 2].contains(" +2 ━┛"), "{}", h.screen());
// A column's title is typed in its top border.
h.type_keys("A, soon");
let (col, row) = h.find("Seeds to order, soon");
assert_eq!(row, top, "in the top border:\n{}", h.screen());
assert_eq!(h.text_cursor(), Some((col + 20, top)), "the text cursor after it");
h.type_keys("<Esc><Esc>");
assert_eq!(h.title(h.node("Seeds to order, soon")), "Seeds to order, soon");
}
#[test] #[test]
fn move_mode_moves_cards_through_the_tree() { fn move_mode_moves_cards_through_the_tree() {
let mut h = Harness::new(); let mut h = Harness::new();
@@ -70,15 +128,48 @@ fn move_mode_moves_cards_through_the_tree() {
} }
#[test] #[test]
fn a_card_drags_to_where_it_drops() { fn a_dragged_card_moves_as_the_mouse_goes() {
let mut h = Harness::new(); let mut h = Harness::new();
h.type_keys("<Space>vb"); h.type_keys("<Space>vb");
drag(&mut h, "Soil", "Tomatoes"); // Over the bottom half of Tomatoes: after it, before letting go.
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"], "after the card it dropped on"); let soil = pick_up(&mut h, "Soil");
drag(&mut h, "Kitchen", "Watering"); let below = bottom_half(&h, "Tomatoes");
assert_eq!(h.children("Watering")[0], "Kitchen", "dropped on a block: its first card"); drag_to(&mut h, soil, below);
drag(&mut h, "Basil", "ask about compost"); assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"]);
assert_eq!(h.children("ask about compost"), ["Basil"], "dropped on a column: its last card"); // Over its top half: before it.
let above = h.find("Tomatoes");
drag_to(&mut h, soil, above);
assert_eq!(h.children("Seeds to order"), ["Soil", "Tomatoes", "Basil"]);
// Over itself, it stays put; letting go leaves it there.
let itself = h.find("Soil");
drag_to(&mut h, soil, itself);
let_go(&mut h, itself);
assert_eq!(h.children("Seeds to order"), ["Soil", "Tomatoes", "Basil"]);
assert_eq!(h.cursor(), "Soil");
h.type_keys("u");
assert_eq!(h.children("Garden plans")[0], "Soil", "the whole drag is one undo step");
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Basil"]);
}
#[test]
fn a_dragged_card_goes_into_blocks_out_of_them_and_onto_columns() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
let kitchen = pick_up(&mut h, "» Kitchen");
// A block's bottom half: its first card.
let watering = bottom_half(&h, "Watering");
drag_to(&mut h, kitchen, watering);
assert_eq!(h.children("Watering"), ["Kitchen", "Rain barrel", "Drip line"]);
// Its foot, under its last card (folded): after the block, out of it.
let (col, row) = h.find("Drip line");
drag_to(&mut h, kitchen, (col, row + 2));
assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line"]);
assert_eq!(h.children("Garden plans").last().map(String::as_str), Some("Kitchen"));
// A column's title: its first card.
let compost = h.find("ask about compost");
drag_to(&mut h, kitchen, compost);
let_go(&mut h, compost);
assert_eq!(h.children("ask about compost"), ["Kitchen"]);
} }
#[test] #[test]
+15
View File
@@ -24,6 +24,21 @@ pub enum Border {
Hidden, Hidden,
} }
impl Border {
/// The line a border is drawn with (rounded corners aside), so whatever
/// goes on from a box, like a board block's bracket, looks like it.
/// `None` for no border.
pub fn stroke(self) -> Option<Stroke> {
match self {
Border::Rounded | Border::Square => Some(Stroke::Solid),
Border::Double => Some(Stroke::Double),
Border::Thick => Some(Stroke::Thick),
Border::Dashed => Some(Stroke::Dashed),
Border::Hidden => None,
}
}
}
/// What a line looks like along its length. /// What a line looks like along its length.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Stroke { pub enum Stroke {
-19
View File
@@ -176,25 +176,6 @@ impl Glyphs {
} }
} }
/// A card's border on the board: thick for the cursor's card and the
/// selected ones.
pub fn card(self, thick: bool) -> border::Set<'static> {
match (self, thick) {
(Glyphs::Unicode, true) => border::THICK,
(Glyphs::Unicode, false) => border::PLAIN,
(Glyphs::Ascii, _) => ASCII_FRAME,
}
}
/// A board card's bottom left corner when its block goes on below it.
pub fn card_joint(self, thick: bool) -> &'static str {
match (self, thick) {
(Glyphs::Unicode, true) => "┣",
(Glyphs::Unicode, false) => "├",
(Glyphs::Ascii, _) => "+",
}
}
/// What stands between the parts of the bars. /// What stands between the parts of the bars.
pub fn separator(self) -> &'static str { pub fn separator(self) -> &'static str {
match self { match self {
+231 -163
View File
@@ -4,7 +4,7 @@
//! wrap the blocks inside them (a "C"): //! wrap the blocks inside them (a "C"):
//! //!
//! ```text //! ```text
//! ┌ Garden plans ──────────┐ ┌ Seeds to order ─┐ //! ╭ Garden plans ──────────╮ ┌ Seeds to order ─┐
//! │ ┌──────┐ │ │ ┌──────────┐ │ //! │ ┌──────┐ │ │ ┌──────────┐ │
//! │ │ Soil │ │ │ │ Tomatoes │ │ //! │ │ Soil │ │ │ │ Tomatoes │ │
//! │ └──────┘ │ │ └──────────┘ │ //! │ └──────┘ │ │ └──────────┘ │
@@ -15,34 +15,45 @@
//! │ │ │ Rain barrel │ │ //! │ │ │ Rain barrel │ │
//! │ │ └─────────────┘ │ //! │ │ └─────────────┘ │
//! │ └─────── │ //! │ └─────── │
//! └────────────────────────┘ //! ╰────────────────────────╯
//! ``` //! ```
//! //!
//! A column and a card are drawn as the node's box is on the canvas (the
//! user asked for them to match): its border and color, the tags and badge
//! in the bottom border, thick for the cursor. The borders go through the
//! canvas's line layer, so a block's "C" is in its card's line, joined
//! where they meet.
//!
//! `j` and `k` go through a column in reading order, `h` and `l` to the //! `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 //! 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 //! 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, //! 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 //! into and out of the blocks. A drag moves it live, as the canvas does
//! move goes through the tree, so every other view follows. //! (the user's pick). Every move goes through the tree, so every other view
//! follows.
use ratatui::Frame; use ratatui::Frame;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect}; use ratatui::layout::{Position, Rect, Size};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use unicode_width::UnicodeWidthStr;
use super::canvas::boxes::{NodeBox, draft_box}; use super::canvas::boxes::{NodeBox, draft_box, footer};
use super::canvas::lines::{LineLayer, Paint};
use super::canvas::{LineOwner, ScreenPos, ScreenRect};
use crate::app::action::{Action, AppAction, Message}; use crate::app::action::{Action, AppAction, Message};
use crate::app::connect; use crate::app::connect;
use crate::app::mode::{Grab, Mode}; use crate::app::mode::{Grab, Mode};
use crate::app::popup::MenuFor; use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit}; use crate::app::text_edit::{EditAt, StartEdit, TextEdit};
use crate::app::visual::selection; use crate::app::visual::selection;
use crate::input::bindings::Context; use crate::input::bindings::Context;
use crate::input::command::{Command, Motion}; use crate::input::command::{Command, Motion};
use crate::input::engine::Typed; use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture}; use crate::input::mouse::{Cell, Gesture};
use crate::model::{CanvasPos, CanvasRect, CanvasSize, Direction, EditOp, NodeId, Spot, Visible}; use crate::model::{
Border, CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Group, Node, NodeId, Spot,
Visible,
};
use crate::ui::text_editor::layout; use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for}; use crate::view::{View, ViewContext, ViewKind, contexts_for};
@@ -62,13 +73,18 @@ const SCROLL_OFF: i32 = 2;
const SCROLL_COLS: i32 = 6; const SCROLL_COLS: i32 = 6;
const WHEEL_ROWS: i32 = 3; const WHEEL_ROWS: i32 = 3;
/// A column: a node without a parent, and where it is on the board. /// A column: a node without a parent, where it is on the board, and how
/// it's drawn.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Column { struct Column {
id: NodeId, id: NodeId,
rect: CanvasRect, rect: CanvasRect,
/// Its nodes on show in reading order, itself first. /// Its nodes on show in reading order, itself first.
order: Vec<NodeId>, order: Vec<NodeId>,
/// Its title, on one line in the top border.
title: String,
tags: String,
badge: String,
} }
/// A card: a node in a column, and how it's drawn. /// A card: a node in a column, and how it's drawn.
@@ -83,22 +99,26 @@ struct Card {
foot: Option<i32>, foot: Option<i32>,
} }
/// A title being typed in its card: what the text is, how it wraps, and /// A title being typed in its card or column: what the text is, how it
/// where its cursor is. /// wraps, where its cursor is, and the board cell the text starts on.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Editing { struct Editing {
id: NodeId, id: NodeId,
text: String, text: String,
rows: Vec<std::ops::Range<usize>>, rows: Vec<std::ops::Range<usize>>,
cursor: (usize, usize), cursor: (usize, usize),
at: (i32, i32),
} }
/// A drag in progress: the card picked up, and whether it has moved yet. /// A card being dragged. It moves through the board as the mouse goes, all
#[derive(Debug, Clone, Copy)] /// one undo step, and the columns keep at least the widths they had when
/// it was picked up: when a card left a column and it narrowed, the columns
/// beside would slide under the mouse, and the card would chase them.
#[derive(Debug, Clone)]
struct Drag { struct Drag {
id: NodeId, id: NodeId,
from: (i32, i32), group: Group,
moved: bool, widths: Vec<(NodeId, i32)>,
} }
/// The board view. /// The board view.
@@ -132,21 +152,50 @@ impl BoardView {
let mut x = 0; let mut x = 0;
let mut bottom = 0; let mut bottom = 0;
for top in doc.top_level() { for top in doc.top_level() {
let Some(node) = doc.node(top) else { continue };
let first_card = self.cards.len(); let first_card = self.cards.len();
let (end, right) = self.place(ctx, &visible, top, x + 2, 1); 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); // The column's box, for its title (one line, however long) and
let width = (right - x + 2).max(title_width + 6).max(MIN_WIDTH); // its bottom border: a folded column says what it hides there.
let badge = badge(ctx, &visible, top, node);
let node_box = match ctx.mode.title_edit().filter(|edit| edit.node == top) {
Some(edit) => self.draft(node, edit, badge, u16::MAX, (x + 2, 0)),
None => NodeBox::new(node, badge, u16::MAX, ctx.theme.glyphs),
};
let kept = self.drag.as_ref().and_then(|drag| drag.widths.iter().find(|(id, _)| *id == top));
let width = (right - x + 2)
.max(i32::from(node_box.rect.size.width) + 2)
.max(MIN_WIDTH)
.max(kept.map_or(0, |&(_, width)| width));
let height = end.max(2) + 1; let height = end.max(2) + 1;
let mut order = vec![top]; let mut order = vec![top];
order.extend(self.cards[first_card..].iter().map(|card| card.id)); order.extend(self.cards[first_card..].iter().map(|card| card.id));
let rect = CanvasRect::new(CanvasPos::new(x, 0), size(width, height)); let rect = CanvasRect::new(CanvasPos::new(x, 0), size(width, height));
self.columns.push(Column { id: top, rect, order }); let NodeBox { lines, tags, badge, .. } = node_box;
self.columns.push(Column { id: top, rect, order, title: lines.join(" "), tags, badge });
bottom = bottom.max(height); bottom = bottom.max(height);
x += width + GAP; x += width + GAP;
} }
self.size = (x, bottom); self.size = (x, bottom);
} }
/// The box of the node whose title is being typed, showing the text as
/// typed. Remembers how the text wraps, and that it starts at `at`.
fn draft(
&mut self,
node: &Node,
edit: &TextEdit,
badge: String,
max_width: u16,
at: (i32, i32),
) -> NodeBox {
let text = edit.editor.text();
let draft = draft_box(node, text, edit.editor.cursor(), badge, max_width);
let (rows, cursor) = (draft.rows, draft.cursor);
self.editing = Some(Editing { id: edit.node, text: text.to_string(), rows, cursor, at });
draft.node_box
}
/// Lays out the cards of `parent`'s children from (`x`, `y`), each under /// 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 /// 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 /// further in, and a foot closes the block. Returns the row after the
@@ -166,18 +215,9 @@ impl BoardView {
if !visible.contains(id) { if !visible.contains(id) {
continue; continue;
} }
let mut badge = ctx.badge(id, node).unwrap_or_default(); let badge = badge(ctx, visible, id, node);
if let Some(count) = visible.hidden.get(&id) { let node_box = match ctx.mode.title_edit().filter(|edit| edit.node == id) {
badge = if badge.is_empty() { format!("+{count}") } else { format!("{badge} +{count}") }; Some(edit) => self.draft(node, edit, badge, max_width, (x + 2, y + 1)),
}
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), None => NodeBox::new(node, badge, max_width, ctx.theme.glyphs),
}; };
let rect = CanvasRect::new(CanvasPos::new(x, y), node_box.rect.size); let rect = CanvasRect::new(CanvasPos::new(x, y), node_box.rect.size);
@@ -356,122 +396,119 @@ impl BoardView {
Some(vec![Action::edit_in(EditOp::MoveTo { id, spot }, grab.group)]) Some(vec![Action::edit_in(EditOp::MoveTo { id, spot }, grab.group)])
} }
/// Where a dragged card goes, dropped on `target`: into a block, as its /// Where the `dragged` card goes with the mouse on a board cell. It goes
/// first card; after another card; at the end of a column. /// by the row, in the column the mouse is over: on a card's top half,
fn drop_spot(&self, target: Target) -> Spot { /// before it; on its bottom half, after it, or into its block as the
match target { /// first card; on a block's foot, after the block; on the column's
Target::Card(id) if self.cards.iter().any(|card| card.id == id && card.foot.is_some()) => { /// title, first; below its cards, last. Nowhere over the card itself or
Spot::FirstChildOf(id) /// its own cards, so a card that's caught up with the mouse stays put.
} fn drop_spot(&self, doc: &Document, dragged: NodeId, (x, y): (i32, i32)) -> Option<Spot> {
Target::Card(id) => Spot::After(id), let column =
Target::Column(id) => Spot::LastChildOf(id), self.columns.iter().find(|column| (column.rect.left()..column.rect.right()).contains(&x))?;
if y <= column.rect.top() {
return Some(Spot::FirstChildOf(column.id));
} }
for card in self.cards.iter().filter(|card| column.order.contains(&card.id)) {
let its_own = card.id == dragged || doc.is_inside(card.id, dragged);
let rect = card.rect;
if (rect.top()..rect.bottom()).contains(&y) {
let top_half = (y - rect.top()) * 2 < i32::from(rect.size.height);
return (!its_own).then_some(match card.foot {
_ if top_half => Spot::Before(card.id),
Some(_) => Spot::FirstChildOf(card.id),
None => Spot::After(card.id),
});
}
if card.foot == Some(y) {
return (!its_own).then_some(Spot::After(card.id));
}
}
Some(Spot::LastChildOf(column.id))
} }
/// Where a click lands in the title being typed, if it's on its text. /// 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> { fn text_offset_at(&self, (x, y): (i32, i32)) -> Option<usize> {
let editing = self.editing.as_ref()?; let editing = self.editing.as_ref()?;
let rect = self.rect_of(editing.id)?; let rect = self.rect_of(editing.id)?;
let row = usize::try_from(y - rect.top() - 1).ok().filter(|&row| row < editing.rows.len())?; if !(rect.left()..rect.right()).contains(&x) {
let column = usize::try_from(x - rect.left() - 2).unwrap_or(0); return None;
}
let row = usize::try_from(y - editing.at.1).ok().filter(|&row| row < editing.rows.len())?;
let column = usize::try_from(x - editing.at.0).unwrap_or(0);
Some(layout::offset(&editing.text, &editing.rows, row, column)) Some(layout::offset(&editing.text, &editing.rows, row, column))
} }
/// Draws a column: its frame, with its node's title in the top border. /// Adds a node's border to the line layer, the way the canvas draws its
fn draw_column(&self, buf: &mut Buffer, column: &Column, ctx: &ViewContext<'_>, picked: &[NodeId]) { /// box: the cursor's thick in the accent color (the typing color while
let Some(node) = ctx.doc.node(column.id) else { return }; /// its title is typed), the selected ones thick in the visual color, the
let style = ctx.node_style(column.id, node); /// others as their style says. A block's card goes on down its left past
let here = column.id == ctx.cursor; /// its cards and closes with a foot, in the same line and color: the
let color = match (here, picked.contains(&column.id)) { /// "C" is one shape with its card.
(true, _) => ctx.theme.accent, fn add_border(
(false, true) => ctx.theme.visual, &self,
_ if style.color == Color::Reset => ctx.theme.muted, lines: &mut LineLayer,
_ => style.color, (id, rect, foot): (NodeId, CanvasRect, Option<i32>),
ctx: &ViewContext<'_>,
picked: &[NodeId],
) {
let Some(node) = ctx.doc.node(id) else { return };
let typing = ctx.mode.title_edit().is_some_and(|edit| edit.editor.is_typing());
let (border, color) = match (id == ctx.cursor, picked.contains(&id)) {
(true, _) => (Border::Thick, if typing { ctx.theme.insert } else { ctx.theme.accent }),
(false, true) => (Border::Thick, ctx.theme.visual),
(false, false) => {
let style = ctx.node_style(id, node);
(style.border, style.color)
}
}; };
let set = ctx.theme.glyphs.frame(column.id == ctx.doc.root()); let paint = Paint { color, owner: LineOwner::Border(id) };
let rect = column.rect; let (left, top) = (rect.left() - self.scroll.0, rect.top() - self.scroll.1);
let border = Style::new().fg(color); let (width, height) = (i32::from(rect.size.width), i32::from(rect.size.height));
let (left, right, top, bottom) = (rect.left(), rect.right() - 1, rect.top(), rect.bottom() - 1); lines.add_border(ScreenRect::new(left, top, width, height), border, paint, |_| true);
for x in left + 1..right { let (Some(foot), Some(stroke)) = (foot, border.stroke()) else { return };
self.put(buf, x, top, set.horizontal_top, border); let corner = ScreenPos::new(left, top + height - 1);
self.put(buf, x, bottom, set.horizontal_bottom, border); let bend = ScreenPos::new(left, foot - self.scroll.1);
let tip = ScreenPos::new(left + FOOT - 1, bend.row);
lines.add_path(&[corner, bend, tip], stroke, paint, |_| false);
// The foot runs right to its end, rather than tapering off.
lines.add_arm(tip, Direction::Right, stroke, paint);
if border == Border::Rounded {
lines.round(bend);
} }
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. /// Writes what's in a node's box, as the canvas does: its title (in the
fn draw_card(&self, buf: &mut Buffer, card: &Card, ctx: &ViewContext<'_>, picked: &[NodeId]) { /// top border, for a column), bold under the cursor and quiet when its
let Some(node) = ctx.doc.node(card.id) else { return }; /// style fades it; the tags in its color on the left of the bottom
let style = ctx.node_style(card.id, node); /// border; the badge on the right.
let (here, chosen) = (card.id == ctx.cursor, picked.contains(&card.id)); fn put_box_text(&self, buf: &mut Buffer, content: BoxText<'_>, ctx: &ViewContext<'_>) {
let typing = ctx.mode.title_edit().is_some_and(|edit| edit.editor.is_typing()); let Some(node) = ctx.doc.node(content.id) else { return };
let color = match (here, chosen) { let style = ctx.node_style(content.id, node);
(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 muted = Style::new().fg(ctx.theme.muted);
let width = right - left - 1; let title = match (content.id == ctx.cursor, style.faded) {
if !card.tags.is_empty() { (true, _) => Style::new().add_modifier(Modifier::BOLD),
let tags = truncated(&format!(" {} ", card.tags), usize::try_from(width - 2).unwrap_or(0)); (false, true) => muted,
self.put_text(buf, left + 2, bottom, &tags, muted); (false, false) => Style::new(),
} };
if !card.badge.is_empty() { let rect = content.rect;
let badge = format!(" {} ", card.badge); if content.in_border {
self.put_text(buf, right - 1 - badge.width() as i32, bottom, &badge, muted); let text = format!(" {} ", content.lines.join(" "));
} self.put_text(buf, rect.left() + 1, rect.top(), &text, title);
let title = if style.faded { muted } else { Style::new() }; } else {
let title = if here { title.add_modifier(Modifier::BOLD) } else { title }; for (line, y) in content.lines.iter().zip(rect.top() + 1..) {
for (line, y) in card.lines.iter().zip(top + 1..) { self.put_text(buf, rect.left() + 2, y, line, title);
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);
} }
} }
let bottom = rect.bottom() - 1;
let footer =
footer(rect.left(), rect.right(), content.tags, content.badge, ctx.theme.glyphs.ellipsis());
if let Some((x, text)) = footer.badge {
self.put_text(buf, x, bottom, &text, muted);
}
if let Some((x, text)) = footer.tags {
let color = if style.color == Color::Reset { ctx.theme.muted } else { style.color };
self.put_text(buf, x, bottom, &text, Style::new().fg(color));
}
} }
/// Puts a symbol on a board cell, if it's on screen. /// Puts a symbol on a board cell, if it's on screen.
@@ -530,20 +567,37 @@ fn size(width: i32, height: i32) -> CanvasSize {
) )
} }
/// Text cut to `width` columns, with `…` when it's cut. /// What's written in a node's box on the board.
fn truncated(text: &str, width: usize) -> String { struct BoxText<'a> {
if text.width() <= width { id: NodeId,
return text.to_string(); rect: CanvasRect,
lines: &'a [String],
/// A column's title is in its top border; a card's is inside it.
in_border: bool,
tags: &'a str,
badge: &'a str,
}
/// What a node's box says in its bottom right corner, as on the canvas:
/// its own badge, then how many nodes its fold hides.
fn badge(ctx: &ViewContext<'_>, visible: &Visible, id: NodeId, node: &Node) -> String {
let badge = ctx.badge(id, node).unwrap_or_default();
match visible.hidden.get(&id) {
Some(count) if badge.is_empty() => format!("+{count}"),
Some(count) => format!("{badge} +{count}"),
None => badge,
} }
let mut out = String::new(); }
for c in text.chars() {
if out.width() + unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) + 1 > width { /// Whether moving `id` to `spot` would leave it where it is.
break; fn stays(doc: &Document, id: NodeId, spot: Spot) -> bool {
} match spot {
out.push(c); Spot::Before(next) => next == id || doc.next_sibling(id) == Some(next),
Spot::After(previous) => previous == id || doc.previous_sibling(id) == Some(previous),
Spot::FirstChildOf(parent) => doc.children(parent).first() == Some(&id),
Spot::LastChildOf(parent) => doc.children(parent).last() == Some(&id),
Spot::Loose => false,
} }
out.push('…');
out
} }
impl View for BoardView { impl View for BoardView {
@@ -573,19 +627,31 @@ impl View for BoardView {
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) { fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
let picked = BoardView::picked(ctx); let picked = BoardView::picked(ctx);
let buf = frame.buffer_mut(); let mut lines = LineLayer::new(Size::new(self.area.width, self.area.height));
for column in &self.columns { for column in &self.columns {
self.draw_column(buf, column, ctx, &picked); self.add_border(&mut lines, (column.id, column.rect, None), ctx, &picked);
} }
for card in &self.cards { for card in &self.cards {
self.draw_card(buf, card, ctx, &picked); self.add_border(&mut lines, (card.id, card.rect, card.foot), ctx, &picked);
} }
if let Some(editing) = &self.editing let buf = frame.buffer_mut();
&& let Some(rect) = self.rect_of(editing.id) for (at, glyph, color) in lines.cells(ctx.theme.glyphs) {
{ let (x, y) = (at.col + self.scroll.0, at.row + self.scroll.1);
self.put(buf, x, y, glyph.encode_utf8(&mut [0; 4]), Style::new().fg(color));
}
for column in &self.columns {
let (lines, tags, badge) = (std::slice::from_ref(&column.title), &column.tags, &column.badge);
let (id, rect) = (column.id, column.rect);
self.put_box_text(buf, BoxText { id, rect, lines, in_border: true, tags, badge }, ctx);
}
for card in &self.cards {
let (id, rect, lines, tags, badge) =
(card.id, card.rect, &card.lines[..], &card.tags, &card.badge);
self.put_box_text(buf, BoxText { id, rect, lines, in_border: false, tags, badge }, ctx);
}
if let Some(editing) = &self.editing {
let (row, col) = editing.cursor; 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)) = self.screen(editing.at.0 + col as i32, editing.at.1 + row as i32) {
if let Some((col, row)) = at {
frame.set_cursor_position(Position::new(col, row)); frame.set_cursor_position(Position::new(col, row));
} }
} }
@@ -645,26 +711,28 @@ impl View for BoardView {
} }
// Cards can be dragged; in connect mode a click only aims. // Cards can be dragged; in connect mode a click only aims.
if matches!(target, Target::Card(_)) && !matches!(ctx.mode, Mode::Connect(_)) { if matches!(target, Target::Card(_)) && !matches!(ctx.mode, Mode::Connect(_)) {
self.drag = Some(Drag { id, from: at, moved: false }); let widths = self
.columns
.iter()
.map(|column| (column.id, i32::from(column.rect.size.width)))
.collect();
self.drag = Some(Drag { id, group: Group::new(), widths });
} }
actions.push(Action::cursor(id)); actions.push(Action::cursor(id));
actions actions
} }
Gesture::Drag { at, .. } => { Gesture::Drag { at, .. } => {
let here = self.local(at); let (Some(drag), Some(at)) = (&self.drag, self.local(at)) else { return Vec::new() };
if let (Some(drag), Some(here)) = (&mut self.drag, here) { match self.drop_spot(ctx.doc, drag.id, at) {
drag.moved |= here != drag.from; Some(spot) if !stays(ctx.doc, drag.id, spot) => {
vec![Action::edit_in(EditOp::MoveTo { id: drag.id, spot }, drag.group)]
}
_ => Vec::new(),
} }
Vec::new()
} }
Gesture::Release { at, .. } => { Gesture::Release { .. } => {
let Some(drag) = self.drag.take().filter(|drag| drag.moved) else { return Vec::new() }; self.drag = None;
let Some(target) = self.local(at).and_then(|at| self.at(at)) else { return Vec::new() }; 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 } => { Gesture::DoublePress { at } => {
let Some(at) = self.local(at) else { return Vec::new() }; let Some(at) = self.local(at) else { return Vec::new() };
+29
View File
@@ -89,6 +89,35 @@ pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap<Nod
.collect() .collect()
} }
/// What a box writes into its bottom border, each part with the column it
/// starts at: `─ #todo ─ +3 ─`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Footer {
pub tags: Option<(i32, String)>,
pub badge: Option<(i32, String)>,
}
/// Where the tags and the badge go in the bottom border of a box from column
/// `left` up to `right` (not included), as the canvas and the board both
/// draw them: the badge ends two cells in from the right corner, and the
/// tags start two in from the left, cut short to end before the badge.
pub fn footer(left: i32, right: i32, tags: &str, badge: &str, ellipsis: char) -> Footer {
let mut footer = Footer::default();
let badge_start = if badge.is_empty() {
right - 1
} else {
let text = format!(" {badge} ");
let start = right - 2 - text.width() as i32;
footer.badge = Some((start, text));
start - 1
};
if !tags.is_empty() {
let room = usize::try_from(badge_start - (left + 2) - 2).unwrap_or(0);
footer.tags = Some((left + 2, format!(" {} ", truncate(tags, room, ellipsis))));
}
footer
}
/// How many cells the bottom border needs for the tags and the badge, /// How many cells the bottom border needs for the tags and the badge,
/// between its corners: `─ #todo ─ +3 ─`. /// between its corners: `─ #todo ─ +3 ─`.
fn footer_width(tags: &str, badge: &str) -> usize { fn footer_width(tags: &str, badge: &str) -> usize {
+11 -11
View File
@@ -74,13 +74,7 @@ impl LineLayer {
paint: Paint, paint: Paint,
shown: impl Fn(ScreenPos) -> bool, shown: impl Fn(ScreenPos) -> bool,
) { ) {
let stroke = match border { let Some(stroke) = border.stroke() else { return };
Border::Rounded | Border::Square => Stroke::Solid,
Border::Double => Stroke::Double,
Border::Thick => Stroke::Thick,
Border::Dashed => Stroke::Dashed,
Border::Hidden => return,
};
if rect.width < 2 || rect.height < 2 { if rect.width < 2 || rect.height < 2 {
return; return;
} }
@@ -95,10 +89,8 @@ impl LineLayer {
if shown(at) { if shown(at) {
self.add_arm(at, one, stroke, paint); self.add_arm(at, one, stroke, paint);
self.add_arm(at, other, stroke, paint); self.add_arm(at, other, stroke, paint);
if border == Border::Rounded if border == Border::Rounded {
&& let Some(cell) = self.cell_mut(at) self.round(at);
{
cell.rounded = true;
} }
} }
} }
@@ -174,6 +166,14 @@ impl LineLayer {
} }
} }
/// Draws the light corner on a cell rounded, as a rounded box's corners
/// are.
pub fn round(&mut self, at: ScreenPos) {
if let Some(cell) = self.cell_mut(at) {
cell.rounded = true;
}
}
/// Every cell a line runs through, with its character and color. /// Every cell a line runs through, with its character and color.
pub fn cells(&self, glyphs: Glyphs) -> impl Iterator<Item = (ScreenPos, char, Color)> + '_ { pub fn cells(&self, glyphs: Glyphs) -> impl Iterator<Item = (ScreenPos, char, Color)> + '_ {
self.cells.iter().enumerate().filter(|(_, cell)| !cell.arms.is_empty()).map(move |(index, cell)| { self.cells.iter().enumerate().filter(|(_, cell)| !cell.arms.is_empty()).map(move |(index, cell)| {
+8 -14
View File
@@ -5,9 +5,9 @@ use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use unicode_width::UnicodeWidthChar;
use super::boxes::truncate; use super::boxes::footer;
use super::camera::ScreenPos; use super::camera::ScreenPos;
use super::scene::{Scene, SceneNode, Shape}; use super::scene::{Scene, SceneNode, Shape};
@@ -73,19 +73,13 @@ impl Scene {
// The bottom border: ─ #tags ─ … ─ badge ─ // The bottom border: ─ #tags ─ … ─ badge ─
let bottom = rect.bottom() - 1; let bottom = rect.bottom() - 1;
let badge_start = if badge.is_empty() { let footer = footer(rect.left(), rect.right(), tags, badge, self.glyphs.ellipsis());
rect.right() - 1 if let Some((col, text)) = footer.badge {
} else { painter.text(ScreenPos::new(col, bottom), &text, Style::new().fg(self.muted), &mine);
let text = format!(" {badge} "); }
let start = rect.right() - 2 - text.width() as i32; if let Some((col, text)) = footer.tags {
painter.text(ScreenPos::new(start, bottom), &text, Style::new().fg(self.muted), &mine);
start - 1
};
if !tags.is_empty() {
let room = usize::try_from(badge_start - (rect.left() + 2) - 2).unwrap_or(0);
let text = format!(" {} ", truncate(tags, room, self.glyphs.ellipsis()));
let color = if node.color == Color::Reset { self.muted } else { node.color }; let color = if node.color == Color::Reset { self.muted } else { node.color };
painter.text(ScreenPos::new(rect.left() + 2, bottom), &text, Style::new().fg(color), &mine); painter.text(ScreenPos::new(col, bottom), &text, Style::new().fg(color), &mine);
} }
} }