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:
1 parent
3716919db3
commit
38eca3c8c3
8 files changed
+420
-229
No files matched your search
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+227
-159
@@ -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,121 +396,118 @@ 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> {
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
Target::Card(id) => Spot::After(id),
|
for card in self.cards.iter().filter(|card| column.order.contains(&card.id)) {
|
||||||
Target::Column(id) => Spot::LastChildOf(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(),
|
||||||
|
};
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
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 bottom = rect.bottom() - 1;
|
||||||
let title = if here { title.add_modifier(Modifier::BOLD) } else { title };
|
let footer =
|
||||||
for (line, y) in card.lines.iter().zip(top + 1..) {
|
footer(rect.left(), rect.right(), content.tags, content.badge, ctx.theme.glyphs.ellipsis());
|
||||||
self.put_text(buf, left + 2, y, line, title);
|
if let Some((x, text)) = footer.badge {
|
||||||
}
|
self.put_text(buf, x, bottom, &text, muted);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
let mut out = String::new();
|
|
||||||
for c in text.chars() {
|
/// What a node's box says in its bottom right corner, as on the canvas:
|
||||||
if out.width() + unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) + 1 > width {
|
/// its own badge, then how many nodes its fold hides.
|
||||||
break;
|
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,
|
||||||
}
|
}
|
||||||
out.push(c);
|
|
||||||
}
|
}
|
||||||
out.push('…');
|
|
||||||
out
|
/// 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,27 +711,29 @@ 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Gesture::Release { .. } => {
|
||||||
|
self.drag = None;
|
||||||
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::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() };
|
||||||
if let Some(offset) = self.text_offset_at(at) {
|
if let Some(offset) = self.text_offset_at(at) {
|
||||||
|
|||||||
@@ -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
@@ -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)| {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user