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 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 (the root's stays first). Enter is done, one undo step. Every move goes
through the tree. through the tree.
- A drag moves the card live, as the canvas does (the user's pick): it goes - A drag moves a card, or a column by its title, live, as the canvas does
through the board as the mouse goes, by the row the mouse is on in the (the user's pick); the whole drag is one undo step. The row of titles,
column under it. On a card's top half it goes before that card; on its 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 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, foot, after the block; below the cards, last. So a card dragged out
last. The whole drag is one undo step. The columns keep their widths becomes a column, and a column dragged into another one becomes a card,
while a card is dragged, so the one under the mouse stays put. 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. - 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)
@@ -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 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 through the the `+` and `-` keys. On the board, dragging a card, or a column by its
tree as the mouse goes: before or after the card under it (by which half), title, moves it through the tree as the mouse goes: before or after the
into a block, out at its foot, or to a column's top or end. 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 ## 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`) | | `<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 | | 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… | | `<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) | | `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 | | `gt` `gT` | the next / previous document |
| `:` `?` | command line, help (search it with `/`, Enter runs the key or command) | | `:` `?` | 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)); drag_to(&mut h, kitchen, (col, row + 2));
assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line"]); assert_eq!(h.children("Watering"), ["Rain barrel", "Drip line"]);
assert_eq!(h.children("Garden plans").last().map(String::as_str), Some("Kitchen")); assert_eq!(h.children("Garden plans").last().map(String::as_str), Some("Kitchen"));
// A column's title: its first card. // Below a column's cards: its last card.
let compost = h.find("ask about compost"); let (col, row) = h.find("Basil");
drag_to(&mut h, kitchen, compost); drag_to(&mut h, kitchen, (col, row + 3));
let_go(&mut h, compost); let_go(&mut h, (col, row + 3));
assert_eq!(h.children("ask about compost"), ["Kitchen"]); 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] #[test]
@@ -186,3 +267,28 @@ fn cards_are_typed_in_and_selected_in_reading_order() {
h.type_keys("d"); h.type_keys("d");
assert_eq!(h.message(), "deleted 3 nodes · u to undo"); 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 //! 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. A drag moves it live, as the canvas does //! into and out of the blocks. A drag moves a card, or a column by its
//! (the user's pick). Every move goes through the tree, so every other view //! title, live, as the canvas does (the user's pick): dragged among the
//! follows. //! 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::Frame;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
@@ -110,15 +111,26 @@ struct Editing {
at: (i32, i32), at: (i32, i32),
} }
/// A card being dragged. It moves through the board as the mouse goes, all /// A card or column being dragged. It moves through the board as the mouse
/// one undo step, and the columns keep at least the widths they had when /// goes, all one undo step, and the board holds still around it: the
/// it was picked up: when a card left a column and it narrowed, the columns /// columns keep at least the widths they had when it was picked up, and
/// beside would slide under the mouse, and the card would chase them. /// 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)] #[derive(Debug, Clone)]
struct Drag { struct Drag {
id: NodeId, id: NodeId,
group: Group, group: Group,
widths: Vec<(NodeId, i32)>, 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. /// The board view.
@@ -151,6 +163,11 @@ impl BoardView {
(self.columns, self.cards, self.editing) = (Vec::new(), Vec::new(), None); (self.columns, self.cards, self.editing) = (Vec::new(), Vec::new(), None);
let mut x = 0; let mut x = 0;
let mut bottom = 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() { for top in doc.top_level() {
let Some(node) = doc.node(top) else { continue }; let Some(node) = doc.node(top) else { continue };
let first_card = self.cards.len(); 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 }); 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;
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); self.size = (x, bottom);
} }
@@ -396,17 +424,44 @@ 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 the `dragged` card goes with the mouse on a board cell. It goes /// Where the `dragged` card or column goes with the mouse on a board
/// by the row, in the column the mouse is over: on a card's top half, /// cell. The row of titles, the gaps and the space past the last column
/// before it; on its bottom half, after it, or into its block as the /// are among the columns (the user's pick): there it becomes a column
/// first card; on a block's foot, after the block; on the column's /// itself. Anywhere else it goes into the column under the mouse, as a
/// title, first; below its cards, last. Nowhere over the card itself or /// card.
/// 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> { fn drop_spot(&self, doc: &Document, dragged: NodeId, (x, y): (i32, i32)) -> Option<Spot> {
let column = let column =
self.columns.iter().find(|column| (column.rect.left()..column.rect.right()).contains(&x))?; self.columns.iter().find(|column| (column.rect.left()..column.rect.right()).contains(&x));
if y <= column.rect.top() { match column {
return Some(Spot::FirstChildOf(column.id)); 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)) { 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 its_own = card.id == dragged || doc.is_inside(card.id, dragged);
@@ -709,20 +764,24 @@ impl View for BoardView {
if ctrl { if ctrl {
return vec![Action::App(AppAction::ToggleSelected(id))]; return vec![Action::App(AppAction::ToggleSelected(id))];
} }
// Cards can be dragged; in connect mode a click only aims. // Cards and columns can be dragged; in connect mode a click
if matches!(target, Target::Card(_)) && !matches!(ctx.mode, Mode::Connect(_)) { // only aims.
if !matches!(ctx.mode, Mode::Connect(_)) {
let widths = self let widths = self
.columns .columns
.iter() .iter()
.map(|column| (column.id, i32::from(column.rect.size.width))) .map(|column| (column.id, i32::from(column.rect.size.width)))
.collect(); .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.push(Action::cursor(id));
actions actions
} }
Gesture::Drag { at, .. } => { Gesture::Drag { at, .. } => {
let (Some(drag), Some(at)) = (&self.drag, self.local(at)) else { return Vec::new() }; 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) { match self.drop_spot(ctx.doc, drag.id, at) {
Some(spot) if !stays(ctx.doc, drag.id, spot) => { Some(spot) if !stays(ctx.doc, drag.id, spot) => {
vec![Action::edit_in(EditOp::MoveTo { id: drag.id, spot }, drag.group)] vec![Action::edit_in(EditOp::MoveTo { id: drag.id, spot }, drag.group)]