From 38eca3c8c3790c26c607107952e916a03296e646 Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 2 Oct 2026 13:49:48 -0400 Subject: [PATCH] Draw the board as the canvas draws boxes, and drag cards live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DESIGN.md | 29 ++- src/app/tests/board.rs | 127 ++++++++++-- src/model/look.rs | 15 ++ src/ui/glyphs.rs | 19 -- src/view/board.rs | 394 ++++++++++++++++++++++---------------- src/view/canvas/boxes.rs | 29 +++ src/view/canvas/lines.rs | 22 +-- src/view/canvas/render.rs | 22 +-- 8 files changed, 424 insertions(+), 233 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2df82fe..38c9580 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 `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 diff --git a/src/app/tests/board.rs b/src/app/tests/board.rs index 2aba0c2..0871ced 100644 --- a/src/app/tests/board.rs +++ b/src/app/tests/board.rs @@ -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("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("vb:find dripza"); + 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 seedsza"); + 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(""); + 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("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("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] diff --git a/src/model/look.rs b/src/model/look.rs index 886e0d9..de1247c 100644 --- a/src/model/look.rs +++ b/src/model/look.rs @@ -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 { + 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 { diff --git a/src/ui/glyphs.rs b/src/ui/glyphs.rs index e6d524d..8e49851 100644 --- a/src/ui/glyphs.rs +++ b/src/ui/glyphs.rs @@ -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 { diff --git a/src/view/board.rs b/src/view/board.rs index 19cd824..c25f03e 100644 --- a/src/view/board.rs +++ b/src/view/board.rs @@ -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, + /// 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, } -/// 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>, 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 { + 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 { 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), + 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() }; diff --git a/src/view/canvas/boxes.rs b/src/view/canvas/boxes.rs index 9a89ef9..cb50626 100644 --- a/src/view/canvas/boxes.rs +++ b/src/view/canvas/boxes.rs @@ -89,6 +89,35 @@ pub fn box_rects(doc: &Document, max_width: u16, glyphs: Glyphs) -> BTreeMap, + 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 { diff --git a/src/view/canvas/lines.rs b/src/view/canvas/lines.rs index 4e7df27..3c0336d 100644 --- a/src/view/canvas/lines.rs +++ b/src/view/canvas/lines.rs @@ -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 + '_ { self.cells.iter().enumerate().filter(|(_, cell)| !cell.arms.is_empty()).map(move |(index, cell)| { diff --git a/src/view/canvas/render.rs b/src/view/canvas/render.rs index 4d1eac0..d4bc2f3 100644 --- a/src/view/canvas/render.rs +++ b/src/view/canvas/render.rs @@ -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); } }