Drag cards out into columns, and columns among and into columns

The user wants to drag a card out between columns to make a new column,
and to drag columns to reorder them or put them inside other columns.

Their pick for where: the row of titles, the gaps between columns and
the space past the last one are among the columns. Something dragged
there becomes a column, before the column under the mouse on its left
half and after it on its right half, after the one to the left in a gap.
Anywhere else it goes into the column under the mouse by the card rules,
so a column dragged down into another one becomes a card, its own cards
its block. A column's title no longer means "its first card"; the top
half of its first card does that. The root's column stays first and
can't be dragged (the user's pick): it says so.

The board holds still around a drag. Besides the kept widths, the room a
dragged column took stays empty while it isn't a column (Drag.room):
without it, the columns to its right slid left under the mouse each
time it stopped being one, and the card landed a column too far.
Dropping it back in that room makes it that column again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 14:17:04 -04:00
1 parent 38eca3c8c3
commit 09f8b048d6
4 files changed
+210 -34

No files matched your search

+20 -9
View File
@@ -362,13 +362,22 @@ lines.
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. 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
- A drag moves a card, or a column by its title, live, as the canvas does
(the user's pick); the whole drag is one undo step. The row of titles,
the gaps between columns and the space past the last one are among the
columns (the user's pick): there it becomes a column, before the column
under the mouse on its left half, after it on its right half, after the
one to the left in a gap. Anywhere else it goes into the column under
the mouse, as a card, by the row: 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 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.
foot, after the block; below the cards, last. So a card dragged out
becomes a column, and a column dragged into another one becomes a card,
its cards its block. The root's column stays first and can't be dragged
(the user's pick: it's the document's root).
- The board holds still around a drag: the columns keep the widths they
had, and the room a dragged column took stays empty while it isn't one
(dropping it there makes it that column again). Otherwise the columns
beside would slide under the mouse, and the card would chase them.
- In visual mode the board selects in reading order, as the outline does.
**Views, panes and popups** (settled in stage 7)
@@ -568,9 +577,11 @@ 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 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.
the `+` and `-` keys. On the board, dragging a card, or a column by its
title, 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, to a
column's end, or among the columns (on the titles' row or in a gap) as a
column of its own.
## Modes and keys
+1 -1
View File
@@ -100,7 +100,7 @@ The plan for the everyday keys (all of them are in DESIGN.md):
| `<Space>o` | this branch out, into a document of its own, with a portal in its place (`:branchout`) |
| Enter or `gf` `gu` | through a portal / up to the portals that lead here |
| `<Space>` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note, `vb` board… |
| board | `<Space>vb` or `:board`: a column per node without a parent, its children as cards, a card with children wrapping them as Scratch's loop blocks do; `j` `k` cards, `h` `l` columns, `M` then `hjkl` (or a drag) moves a card |
| board | `<Space>vb` or `:board`: a column per node without a parent, its children as cards, a card with children wrapping them as Scratch's loop blocks do; `j` `k` cards, `h` `l` columns, `M` then `hjkl` (or a drag) moves a card; drag a card onto the titles' row or into a gap to make it a column, and a column by its title to reorder it or put it in another column |
| `Ctrl-w v` `Ctrl-w s` | split the pane side by side / stacked (`Ctrl-w hjkl` to move between them) |
| `gt` `gT` | the next / previous document |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) |
+111 -5
View File
@@ -165,11 +165,92 @@ fn a_dragged_card_goes_into_blocks_out_of_them_and_onto_columns() {
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"]);
// Below a column's cards: its last card.
let (col, row) = h.find("Basil");
drag_to(&mut h, kitchen, (col, row + 3));
let_go(&mut h, (col, row + 3));
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Basil", "Kitchen"]);
}
/// The titles of the columns, left to right.
fn columns(h: &Harness) -> Vec<String> {
h.doc().top_level().map(|id| h.title(id)).collect()
}
/// Cells on the left and the right half of a column's title row (its top
/// border), found by the corner its title follows (`╭`, `┌`, `┏`).
fn title_halves(h: &Harness, corner_and_title: &str) -> ((u16, u16), (u16, u16)) {
let (col, row) = h.find(corner_and_title);
let cells: Vec<char> = h.rows()[usize::from(row)].chars().collect();
let end = (usize::from(col) + 1..cells.len()).find(|&at| matches!(cells[at], '╮' | '┐' | '┓')).unwrap();
((col + 1, row), (u16::try_from(end).unwrap() - 1, row))
}
#[test]
fn a_card_dragged_among_the_columns_is_a_column_of_its_own() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
// Past the last column: the last column.
let tomatoes = pick_up(&mut h, "Tomatoes");
let (_, row) = h.find("Tomatoes");
let past = (h.rows()[0].chars().count() as u16 - 4, row + 2);
drag_to(&mut h, tomatoes, past);
let_go(&mut h, past);
assert_eq!(columns(&h), ["Garden plans", "Seeds to order", "ask about compost", "Tomatoes"]);
assert!(h.screen().contains("┏ Tomatoes "), "a column, with the cursor:\n{}", h.screen());
// Onto the right half of a column's title: a column after it.
let soil = pick_up(&mut h, "Soil");
let (_, right) = title_halves(&h, "┌ Seeds to order");
drag_to(&mut h, soil, right);
let_go(&mut h, right);
assert_eq!(columns(&h), ["Garden plans", "Seeds to order", "Soil", "ask about compost", "Tomatoes"]);
// Into the gap between two columns: a column between them.
let basil = pick_up(&mut h, "Basil");
let (col, row) = h.find("┌ Seeds to order");
drag_to(&mut h, basil, (col - 1, row + 4));
let_go(&mut h, (col - 1, row + 4));
assert_eq!(
columns(&h),
["Garden plans", "Basil", "Seeds to order", "Soil", "ask about compost", "Tomatoes"]
);
assert!(h.children("Seeds to order").is_empty());
h.type_keys("u");
assert_eq!(h.children("Seeds to order"), ["Basil"], "one drag, one undo step");
}
#[test]
fn a_column_is_dragged_by_its_title_among_and_into_columns() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
// Onto the left half of another column's title: before it.
let (compost, _) = title_halves(&h, "┌ ask about compost");
let at = Cell { col: compost.0, row: compost.1 };
h.mouse(Gesture::Press { at, ctrl: false });
let (left, _) = title_halves(&h, "┌ Seeds to order");
drag_to(&mut h, at, left);
let_go(&mut h, left);
assert_eq!(columns(&h), ["Garden plans", "ask about compost", "Seeds to order"]);
// Down into another column's cards: a card there, its cards its block.
let (seeds, _) = title_halves(&h, "┌ Seeds to order");
let at = Cell { col: seeds.0, row: seeds.1 };
h.mouse(Gesture::Press { at, ctrl: false });
let watering = bottom_half(&h, "Watering");
drag_to(&mut h, at, watering);
let_go(&mut h, watering);
assert_eq!(h.children("Watering"), ["Seeds to order", "Rain barrel", "Drip line"]);
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Basil"]);
assert_eq!(columns(&h), ["Garden plans", "ask about compost"]);
let (col, row) = h.find("Seeds to order");
assert_eq!(h.rows()[usize::from(row) + 1].chars().nth(usize::from(col) - 2), Some('┣'), "a block now");
// The root's column stays where it is.
let (root, _) = title_halves(&h, "╭ Garden plans");
let at = Cell { col: root.0, row: root.1 };
h.mouse(Gesture::Press { at, ctrl: false });
let (_, right) = title_halves(&h, "┌ ask about compost");
drag_to(&mut h, at, right);
let_go(&mut h, right);
assert_eq!(h.message(), "the root's column stays first");
assert_eq!(columns(&h), ["Garden plans", "ask about compost"]);
}
#[test]
@@ -186,3 +267,28 @@ fn cards_are_typed_in_and_selected_in_reading_order() {
h.type_keys("d");
assert_eq!(h.message(), "deleted 3 nodes · u to undo");
}
#[test]
fn the_columns_hold_still_under_a_drag() {
let mut h = Harness::new();
h.type_keys("<Space>vb");
// Through the gap: Soil is a column for now, and Seeds moves over.
let soil = pick_up(&mut h, "Soil");
let (col, row) = h.find("┌ Seeds to order");
drag_to(&mut h, soil, (col - 1, row + 4));
assert_eq!(columns(&h), ["Garden plans", "Soil", "Seeds to order", "ask about compost"]);
let seeds = h.find("┌ Seeds to order");
// On into Seeds: the room Soil's column took stays empty, so Seeds
// stays under the mouse instead of sliding away from it.
let tomatoes = bottom_half(&h, "Tomatoes");
drag_to(&mut h, soil, tomatoes);
assert_eq!(h.children("Seeds to order"), ["Tomatoes", "Soil", "Basil"]);
assert_eq!(h.find("┌ Seeds to order"), seeds, "Seeds held still:\n{}", h.screen());
// Back into that room: a column there again.
drag_to(&mut h, soil, (col + 2, row + 4));
assert_eq!(columns(&h), ["Garden plans", "Soil", "Seeds to order", "ask about compost"]);
let_go(&mut h, (col + 2, row + 4));
assert_eq!(h.find("┏ Soil ").0, col, "where the room was:\n{}", h.screen());
h.type_keys("u");
assert_eq!(h.children("Garden plans")[0], "Soil", "one undo step");
}
+78 -19
View File
@@ -28,9 +28,10 @@
//! 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. A drag moves it live, as the canvas does
//! (the user's pick). Every move goes through the tree, so every other view
//! follows.
//! into and out of the blocks. A drag moves a card, or a column by its
//! title, live, as the canvas does (the user's pick): dragged among the
//! columns it becomes one, dragged into a column it becomes a card. Every
//! move goes through the tree, so every other view follows.
use ratatui::Frame;
use ratatui::buffer::Buffer;
@@ -110,15 +111,26 @@ struct Editing {
at: (i32, i32),
}
/// 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.
/// A card or column being dragged. It moves through the board as the mouse
/// goes, all one undo step, and the board holds still around it: the
/// columns keep at least the widths they had when it was picked up, and
/// when it stops being a column, the room its column took stays empty.
/// Otherwise the columns beside would slide under the mouse each time, and
/// the card would chase them.
#[derive(Debug, Clone)]
struct Drag {
id: NodeId,
group: Group,
widths: Vec<(NodeId, i32)>,
/// The room it took the last time it was a column.
room: Option<Room>,
}
/// Where a column was, and how wide: after which column.
#[derive(Debug, Clone, Copy)]
struct Room {
after: NodeId,
width: i32,
}
/// The board view.
@@ -151,6 +163,11 @@ impl BoardView {
(self.columns, self.cards, self.editing) = (Vec::new(), Vec::new(), None);
let mut x = 0;
let mut bottom = 0;
// While a drag goes on: the room it took as a column, kept empty
// while it isn't one, and found again while it is.
let dragged = self.drag.as_ref().map(|drag| (drag.id, doc.parent(drag.id).is_none()));
let mut room = self.drag.as_ref().and_then(|drag| drag.room);
let mut previous = None;
for top in doc.top_level() {
let Some(node) = doc.node(top) else { continue };
let first_card = self.cards.len();
@@ -175,6 +192,17 @@ impl BoardView {
self.columns.push(Column { id: top, rect, order, title: lines.join(" "), tags, badge });
bottom = bottom.max(height);
x += width + GAP;
match (dragged, room) {
(Some((id, true)), _) if id == top => {
room = previous.map(|after| Room { after, width });
}
(Some((_, false)), Some(empty)) if empty.after == top => x += empty.width + GAP,
_ => {}
}
previous = Some(top);
}
if let Some(drag) = &mut self.drag {
drag.room = room;
}
self.size = (x, bottom);
}
@@ -396,17 +424,44 @@ impl BoardView {
Some(vec![Action::edit_in(EditOp::MoveTo { id, spot }, grab.group)])
}
/// 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.
/// Where the `dragged` card or column goes with the mouse on a board
/// cell. The row of titles, the gaps and the space past the last column
/// are among the columns (the user's pick): there it becomes a column
/// itself. Anywhere else it goes into the column under the mouse, as a
/// card.
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));
self.columns.iter().find(|column| (column.rect.left()..column.rect.right()).contains(&x));
match column {
Some(column) if y > column.rect.top() => self.in_column(doc, dragged, column, y),
_ => self.among_columns(doc, x),
}
}
/// Among the columns: before the column under the mouse on its left
/// half, after it on its right half, and in a gap or past the last
/// column, after the one to its left. Nothing goes before the root's
/// column, so its left half counts as its right.
fn among_columns(&self, doc: &Document, x: i32) -> Option<Spot> {
let column = self.columns.iter().rev().find(|column| column.rect.left() <= x)?;
let rect = column.rect;
let left_half = x < rect.right() && (x - rect.left()) * 2 < i32::from(rect.size.width);
Some(if left_half && column.id != doc.root() {
Spot::Before(column.id)
} else {
Spot::After(column.id)
})
}
/// Into a column, by the row: 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; below the cards, last. Nowhere over
/// what's dragged itself or its own cards (a whole column, when it's a
/// column that's dragged), so a card that's caught up with the mouse
/// stays put.
fn in_column(&self, doc: &Document, dragged: NodeId, column: &Column, y: i32) -> Option<Spot> {
if column.id == dragged {
return None;
}
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);
@@ -709,20 +764,24 @@ impl View for BoardView {
if ctrl {
return vec![Action::App(AppAction::ToggleSelected(id))];
}
// Cards can be dragged; in connect mode a click only aims.
if matches!(target, Target::Card(_)) && !matches!(ctx.mode, Mode::Connect(_)) {
// Cards and columns can be dragged; in connect mode a click
// only aims.
if !matches!(ctx.mode, Mode::Connect(_)) {
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 });
self.drag = Some(Drag { id, group: Group::new(), widths, room: None });
}
actions.push(Action::cursor(id));
actions
}
Gesture::Drag { at, .. } => {
let (Some(drag), Some(at)) = (&self.drag, self.local(at)) else { return Vec::new() };
if drag.id == ctx.doc.root() {
return vec![Action::say(Message::info("the root's column stays first"))];
}
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)]