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:
1 parent
38eca3c8c3
commit
09f8b048d6
4 files changed
+210
-34
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user