diff --git a/DESIGN.md b/DESIGN.md index 38c9580..96d2431 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/README.md b/README.md index 328428d..3458d90 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `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 | | `` | the action menu: `c` color, `t` tags, `b` documents, `i` inbox, `vo` outline, `vn` note, `vb` board… | -| board | `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 | `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) | diff --git a/src/app/tests/board.rs b/src/app/tests/board.rs index 0871ced..8917a10 100644 --- a/src/app/tests/board.rs +++ b/src/app/tests/board.rs @@ -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 { + 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 = 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("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("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("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"); +} diff --git a/src/view/board.rs b/src/view/board.rs index c25f03e..8717d7c 100644 --- a/src/view/board.rs +++ b/src/view/board.rs @@ -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, +} + +/// 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 { 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 { + 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 { + 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)]