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
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`).
- A card is a node's box, and a column a node's box with the title in its
top border, drawn exactly as the canvas draws them (the user asked for
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),
`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
@@ -354,9 +360,15 @@ lines.
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.
(the root's stays first). Enter is done, one undo step. 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.
**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
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. 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.
the `+` and `-` keys. On the board, dragging a card moves it through the
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
+109 -18
View File
@@ -1,17 +1,39 @@
//! 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.
//! cards in them, blocks wrapping their cards, all drawn as the canvas draws
//! boxes; getting around, move mode, dragging, and typing in a card.
use ratatui::style::Color;
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 });
/// Picks up the card showing `title`.
fn pick_up(h: &mut Harness, title: &str) -> Cell {
let (col, row) = h.find(title);
let at = Cell { col, row };
h.mouse(Gesture::Press { at, ctrl: false });
at
}
/// 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]
@@ -20,7 +42,9 @@ fn space_vb_shows_the_board() {
h.type_keys("<Space>vb");
let screen = h.screen();
assert!(screen.contains("┌ board · Garden plans"), "{screen}");
for column in ["╭ Garden plans ", "┌ Seeds to order ", "┌ ask about compost "] {
// 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}");
}
// 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();
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}");
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");
@@ -44,6 +68,40 @@ fn space_vb_shows_the_board() {
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]
fn move_mode_moves_cards_through_the_tree() {
let mut h = Harness::new();
@@ -70,15 +128,48 @@ fn move_mode_moves_cards_through_the_tree() {
}
#[test]
fn a_card_drags_to_where_it_drops() {
fn a_dragged_card_moves_as_the_mouse_goes() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
drag(&mut h, "Soil", "Tomatoes");
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"], "after the card it dropped on");
drag(&mut h, "Kitchen", "Watering");
assert_eq!(h.children("Watering")[0], "Kitchen", "dropped on a block: its first card");
drag(&mut h, "Basil", "ask about compost");
assert_eq!(h.children("ask about compost"), ["Basil"], "dropped on a column: its last card");
// Over the bottom half of Tomatoes: after it, before letting go.
let soil = pick_up(&mut h, "Soil");
let below = bottom_half(&h, "Tomatoes");
drag_to(&mut h, soil, below);
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"]);
// 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]
+15
View File
@@ -24,6 +24,21 @@ pub enum Border {
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
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.
pub fn separator(self) -> &'static str {
match self {
+231 -163
View File
@@ -4,7 +4,7 @@
//! wrap the blocks inside them (a "C"):
//!
//! ```text
//! ┌ Garden plans ──────────┐ ┌ Seeds to order ─┐
//! ╭ Garden plans ──────────╮ ┌ Seeds to order ─┐
//! │ ┌──────┐ │ │ ┌──────────┐ │
//! │ │ Soil │ │ │ │ Tomatoes │ │
//! │ └──────┘ │ │ └──────────┘ │
@@ -15,34 +15,45 @@
//! │ │ │ 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
//! 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.
//! into and out of the blocks. A drag moves it live, as the canvas does
//! (the user's pick). 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::layout::{Position, Rect, Size};
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::connect;
use crate::app::mode::{Grab, Mode};
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::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::model::{
Border, CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Group, Node, NodeId, Spot,
Visible,
};
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for};
@@ -62,13 +73,18 @@ const SCROLL_OFF: i32 = 2;
const SCROLL_COLS: i32 = 6;
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)]
struct Column {
id: NodeId,
rect: CanvasRect,
/// Its nodes on show in reading order, itself first.
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.
@@ -83,22 +99,26 @@ struct Card {
foot: Option<i32>,
}
/// A title being typed in its card: what the text is, how it wraps, and
/// where its cursor is.
/// A title being typed in its card or column: what the text is, how it
/// wraps, where its cursor is, and the board cell the text starts on.
#[derive(Debug, Clone)]
struct Editing {
id: NodeId,
text: String,
rows: Vec<std::ops::Range<usize>>,
cursor: (usize, usize),
at: (i32, i32),
}
/// A drag in progress: the card picked up, and whether it has moved yet.
#[derive(Debug, Clone, Copy)]
/// A card being dragged. It moves through the board as the mouse goes, all
/// 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 {
id: NodeId,
from: (i32, i32),
moved: bool,
group: Group,
widths: Vec<(NodeId, i32)>,
}
/// The board view.
@@ -132,21 +152,50 @@ impl BoardView {
let mut x = 0;
let mut bottom = 0;
for top in doc.top_level() {
let Some(node) = doc.node(top) else { continue };
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);
// The column's box, for its title (one line, however long) and
// 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 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 });
let NodeBox { lines, tags, badge, .. } = node_box;
self.columns.push(Column { id: top, rect, order, title: lines.join(" "), tags, badge });
bottom = bottom.max(height);
x += width + GAP;
}
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
/// 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
@@ -166,18 +215,9 @@ impl BoardView {
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
}
let badge = badge(ctx, visible, id, node);
let node_box = match ctx.mode.title_edit().filter(|edit| edit.node == id) {
Some(edit) => self.draft(node, edit, badge, max_width, (x + 2, y + 1)),
None => NodeBox::new(node, badge, max_width, ctx.theme.glyphs),
};
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)])
}
/// 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 the `dragged` card goes with the mouse on a board cell. It goes
/// by the row, in the column the mouse is over: on a card's top half,
/// before it; on its bottom half, after it, or into its block as the
/// first card; on a block's foot, after the block; on the column's
/// title, first; below its cards, last. Nowhere over the card itself or
/// 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> {
let column =
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.
fn text_offset_at(&self, (x, y): (i32, i32)) -> Option<usize> {
let editing = self.editing.as_ref()?;
let rect = self.rect_of(editing.id)?;
let row = usize::try_from(y - rect.top() - 1).ok().filter(|&row| row < editing.rows.len())?;
let column = usize::try_from(x - rect.left() - 2).unwrap_or(0);
if !(rect.left()..rect.right()).contains(&x) {
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))
}
/// 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,
/// Adds a node's border to the line layer, the way the canvas draws its
/// box: the cursor's thick in the accent color (the typing color while
/// its title is typed), the selected ones thick in the visual color, the
/// others as their style says. A block's card goes on down its left past
/// its cards and closes with a foot, in the same line and color: the
/// "C" is one shape with its card.
fn add_border(
&self,
lines: &mut LineLayer,
(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 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);
let paint = Paint { color, owner: LineOwner::Border(id) };
let (left, top) = (rect.left() - self.scroll.0, rect.top() - self.scroll.1);
let (width, height) = (i32::from(rect.size.width), i32::from(rect.size.height));
lines.add_border(ScreenRect::new(left, top, width, height), border, paint, |_| true);
let (Some(foot), Some(stroke)) = (foot, border.stroke()) else { return };
let corner = ScreenPos::new(left, top + height - 1);
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.
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.
/// Writes what's in a node's box, as the canvas does: its title (in the
/// top border, for a column), bold under the cursor and quiet when its
/// style fades it; the tags in its color on the left of the bottom
/// border; the badge on the right.
fn put_box_text(&self, buf: &mut Buffer, content: BoxText<'_>, ctx: &ViewContext<'_>) {
let Some(node) = ctx.doc.node(content.id) else { return };
let style = ctx.node_style(content.id, node);
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);
let title = match (content.id == ctx.cursor, style.faded) {
(true, _) => Style::new().add_modifier(Modifier::BOLD),
(false, true) => muted,
(false, false) => Style::new(),
};
let rect = content.rect;
if content.in_border {
let text = format!(" {} ", content.lines.join(" "));
self.put_text(buf, rect.left() + 1, rect.top(), &text, title);
} else {
for (line, y) in content.lines.iter().zip(rect.top() + 1..) {
self.put_text(buf, rect.left() + 2, y, line, title);
}
}
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.
@@ -530,20 +567,37 @@ fn size(width: i32, height: i32) -> CanvasSize {
)
}
/// 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();
/// What's written in a node's box on the board.
struct BoxText<'a> {
id: NodeId,
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 {
break;
}
out.push(c);
}
/// Whether moving `id` to `spot` would leave it where it is.
fn stays(doc: &Document, id: NodeId, spot: Spot) -> bool {
match spot {
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 {
@@ -573,19 +627,31 @@ impl View for BoardView {
fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
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 {
self.draw_column(buf, column, ctx, &picked);
self.add_border(&mut lines, (column.id, column.rect, None), ctx, &picked);
}
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 Some(rect) = self.rect_of(editing.id)
{
let buf = frame.buffer_mut();
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 at = self.screen(rect.left() + 2 + col as i32, rect.top() + 1 + row as i32);
if let Some((col, row)) = at {
if let Some((col, row)) = self.screen(editing.at.0 + col as i32, editing.at.1 + row as i32) {
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.
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
}
Gesture::Drag { at, .. } => {
let here = self.local(at);
if let (Some(drag), Some(here)) = (&mut self.drag, here) {
drag.moved |= here != drag.from;
let (Some(drag), Some(at)) = (&self.drag, self.local(at)) else { return Vec::new() };
match self.drop_spot(ctx.doc, drag.id, at) {
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, .. } => {
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::Release { .. } => {
self.drag = None;
Vec::new()
}
Gesture::DoublePress { at } => {
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()
}
/// 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,
/// between its corners: `─ #todo ─ +3 ─`.
fn footer_width(tags: &str, badge: &str) -> usize {
+11 -11
View File
@@ -74,13 +74,7 @@ impl LineLayer {
paint: Paint,
shown: impl Fn(ScreenPos) -> bool,
) {
let stroke = match border {
Border::Rounded | Border::Square => Stroke::Solid,
Border::Double => Stroke::Double,
Border::Thick => Stroke::Thick,
Border::Dashed => Stroke::Dashed,
Border::Hidden => return,
};
let Some(stroke) = border.stroke() else { return };
if rect.width < 2 || rect.height < 2 {
return;
}
@@ -95,10 +89,8 @@ impl LineLayer {
if shown(at) {
self.add_arm(at, one, stroke, paint);
self.add_arm(at, other, stroke, paint);
if border == Border::Rounded
&& let Some(cell) = self.cell_mut(at)
{
cell.rounded = true;
if border == Border::Rounded {
self.round(at);
}
}
}
@@ -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.
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)| {
+8 -14
View File
@@ -5,9 +5,9 @@ use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
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::scene::{Scene, SceneNode, Shape};
@@ -73,19 +73,13 @@ impl Scene {
// The bottom border: ─ #tags ─ … ─ badge ─
let bottom = rect.bottom() - 1;
let badge_start = if badge.is_empty() {
rect.right() - 1
} else {
let text = format!(" {badge} ");
let start = rect.right() - 2 - text.width() as i32;
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 footer = footer(rect.left(), rect.right(), tags, badge, self.glyphs.ellipsis());
if let Some((col, text)) = footer.badge {
painter.text(ScreenPos::new(col, bottom), &text, Style::new().fg(self.muted), &mine);
}
if let Some((col, text)) = footer.tags {
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);
}
}