From 30328408481c8d7949dcfabb9d032a380c6f93b5 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 21:03:49 -0700 Subject: [PATCH] feat: drag a notebook's sidebar row to reorder the notebooks As a section's row moves among its folder's: the dragged notebook folds to its row, the others open a gap where it would land, and the order is kept in the settings, as Move Up and Move Down keep it. Not undoable, as Move Up is not. Assisted-by: claude-opus-5.5 --- crates/snowbound/src/menus.rs | 2 + crates/snowbound/src/sidebar.rs | 225 ++++++++++++++++++++++++------- crates/snowbound/tests/replay.rs | 27 ++++ 3 files changed, 203 insertions(+), 51 deletions(-) diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index aa19fdf425bb59f0a9874a67a994a86c53d8a5c5..31e07f43dc740ba3b45d6c5f7bd8d3967fc92acb 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -23,6 +23,8 @@ pub enum Dragged { }, /// A section tab, by index. Tab(usize), + /// A notebook's sidebar row, by its index among the notebooks. + Notebook(crate::sidebar::Entry), } /// Something held down to drag: where the pointer took it, whether it has moved far enough diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index faa578bc6a95970bbe77be09f93219f9423a440e..c23409bf9c2c6c8d7c7d9f47034ac9a66525993e 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -67,6 +67,12 @@ pub struct Tree<'a> { rows: Vec, /// The section or group held down, which a drag moves. held: Option, + /// The notebook row held down, which a drag moves among the notebooks. + held_notebook: Option, + /// The notebook dragged, by index, and the place among the others it would land in. + lifted_notebook: Option<(usize, usize)>, + /// Each notebook's rows but the dragged one's, in order. + blocks: Vec<[f32; 4]>, /// The row dragged, which the tree leaves out for a gap where it would land. lifted: Option<(&'a Entry, &'a Landing)>, /// The dragged row as it was built, for drawing it over the others. @@ -95,6 +101,19 @@ struct Ghost { bold: bool, } +impl Ghost { + fn of(row: &Row) -> Self { + Self { + label: row.label.to_owned(), + icon: row.icon, + depth: row.depth, + dim: row.dim, + selected: row.selected, + bold: row.bold, + } + } +} + impl<'a> Tree<'a> { fn new( theme: &'a Theme, @@ -110,6 +129,9 @@ impl<'a> Tree<'a> { action: None, rows: Vec::new(), held: None, + held_notebook: None, + lifted_notebook: None, + blocks: Vec::new(), lifted: None, ghost: None, gap: None, @@ -118,7 +140,8 @@ impl<'a> Tree<'a> { } } -/// A section or group row, for dragging one onto or between the others. +/// A section or group row, for dragging one onto or between the others; or a notebook's, +/// with an empty `path`, among the notebooks. #[derive(Clone, PartialEq)] pub struct Entry { pub notebook: usize, @@ -212,32 +235,46 @@ fn sidebar( ..Spec::default() }, ); + let mut place = 0; for (index, library) in notebooks.iter().enumerate() { let key = library.key(""); let unfolded = !folded.contains(&key); let renamed = (tree.renaming.as_ref()).is_some_and(|renaming| renaming.notebook(library)); - let (row, fold) = tree_row( - ui, - tree, - ui.id(("notebook", index)), - Row { - label: &library.name, - icon: Leading::Notebook( - if library.in_icloud() { - art::NOTEBOOK_ICLOUD - } else { - art::NOTEBOOK - }, - library.color(), - ), - depth: 0, - dim: library.notebook.is_err(), - fold: Some(unfolded), - selected: None, - renamed, - bold: tree.unread.contains(&key), + let row = Row { + label: &library.name, + icon: Leading::Notebook( + if library.in_icloud() { + art::NOTEBOOK_ICLOUD + } else { + art::NOTEBOOK + }, + library.color(), + ), + depth: 0, + dim: library.notebook.is_err(), + fold: Some(unfolded), + selected: None, + renamed, + bold: tree.unread.contains(&key), + }; + if let Some((lifted, slot)) = tree.lifted_notebook { + if lifted == index { + tree.ghost = Some(Ghost::of(&row)); + continue; + } + opening(ui, tree, ("notebook gap", &library.location), slot == place); + place += 1; + } + let block = ui.open( + ("notebook block", index), + Spec { + axis: Axis::Y, + size: [fill(), ui::children()], + ..Spec::default() }, ); + let id = ui.id(("notebook", index)); + let (row, fold) = tree_row(ui, tree, id, row); let unsigned = library.notebook.is_err() && crate::library::server_address(&library.location).is_some(); if row.clicked && unsigned { @@ -251,6 +288,18 @@ fn sidebar( if let Some(point) = row.context { tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point)); } + if row.dragging + && !renamed + && let Some(rect) = ui.rect(id) + { + tree.held_notebook = Some(Entry { + notebook: index, + path: String::new(), + group: false, + rect, + id, + }); + } if let Some(catalog) = library.catalog() && unfolding(ui, ("notebook rows", index), unfolded) { @@ -258,6 +307,11 @@ fn sidebar( ui.close(); ui.close(); } + ui.close(); + tree.blocks.extend(ui.rect(block)); + } + if let Some((_, slot)) = tree.lifted_notebook { + opening(ui, tree, ("notebook gap", ""), slot == place); } ui.close(); ui.open( @@ -549,14 +603,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { .lifted .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path); if lifted { - tree.ghost = Some(Ghost { - label: row.label.to_owned(), - icon: row.icon, - depth: row.depth, - dim: row.dim, - selected: row.selected, - bold: row.bold, - }); + tree.ghost = Some(Ghost::of(row)); } lifted } @@ -568,14 +615,20 @@ fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) { return; }; let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before; - let id = ui.id(("gap", before)); + opening(ui, tree, ("gap", before), here); +} + +/// A gap, `part`, that eases open a row tall where a dragged row would land, `here`, and +/// shut elsewhere. +fn opening(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash + Copy, here: bool) { + let id = ui.id(part); let height = ui.animate(id, if here { ROW } else { 0.0 }); if here { tree.gap = ui.rect(id); } if height > 0.0 { ui.leaf( - ("gap", before), + part, Spec { size: [fill(), px(height)], ..Spec::default() @@ -836,6 +889,14 @@ impl crate::State { Dragged::Entry { entry, landing, .. } => Some((entry, landing)), _ => None, }); + tree.lifted_notebook = (self.drag.as_ref()) + .filter(|drag| drag.lifted) + .and_then(|drag| match &drag.what { + Dragged::Notebook(entry) => { + Some((entry.notebook, drag.slot.unwrap_or(entry.notebook))) + } + _ => None, + }); sidebar( &mut self.ui, &mut tree, @@ -848,7 +909,9 @@ impl crate::State { let Tree { action, rows, - mut held, + held, + held_notebook, + blocks, ghost, gap, .. @@ -856,8 +919,13 @@ impl crate::State { let corner = self.ui.rect(rows_id).unwrap_or_default(); let found = ghost.is_some(); let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner); - held = held.or(ghost_held); - self.drag_entries(held, &rows, gap, settled || !found, corner, theme); + let settled = settled || !found; + let (held, held_notebook) = match ghost_held { + Some(entry) if entry.path.is_empty() => (held, Some(entry)), + ghost_held => (held.or(ghost_held), held_notebook), + }; + self.drag_entries(held, &rows, gap, settled, corner, theme); + self.drag_notebooks(held_notebook, &blocks, gap, settled); self.ui.close(); self.ui.close(); match action { @@ -976,30 +1044,25 @@ impl crate::State { rows: &[Entry], corner: [f32; 4], ) -> (Option, bool) { - let ( - Some(ghost), - Some( - drag @ crate::menus::Drag { - what: Dragged::Entry { entry, landing, .. }, - .. - }, - ), - ) = (ghost, &self.drag) - else { + let (Some(ghost), Some(drag)) = (ghost, &self.drag) else { return (None, true); }; + let (entry, onto) = match &drag.what { + Dragged::Entry { entry, landing, .. } => ( + entry, + (rows.iter()) + .find(|row| landing.into && row.path == landing.folder) + .map(|row| row.rect), + ), + Dragged::Notebook(entry) => (entry, None), + _ => return (None, true), + }; let key = self.ui.id("lifted"); let (top, settled) = if drag.live() { let top = drag.corner(self.pointer)[1] - corner[1]; (self.ui.hold(key, top), false) } else { - let place = gap - .or_else(|| { - rows.iter() - .find(|row| landing.into && row.path == landing.folder) - .map(|row| row.rect) - }) - .unwrap_or(entry.rect); + let place = gap.or(onto).unwrap_or(entry.rect); let target = place[1] - corner[1]; let top = self.ui.animate(key, target); (top, (top - target).abs() < 0.5) @@ -1164,6 +1227,66 @@ impl crate::State { .push(crate::Command::Structure(library, change)); } + /// Moves a notebook among the others by dragging its row, `held`: the others' rows, + /// `blocks`, open a `gap` where it would land, and let go it takes that place, which the + /// next launch keeps. The drag ends once the row, let go, is `settled`. + fn drag_notebooks( + &mut self, + held: Option, + blocks: &[[f32; 4]], + gap: Option<[f32; 4]>, + settled: bool, + ) { + let mine = |what: &Dragged| matches!(what, Dragged::Notebook(_)); + self.settle(mine, settled); + let held = held.map(|entry| { + let rect = entry.rect; + (Dragged::Notebook(entry), rect) + }); + let dropped = self.follow_drag(held, mine); + let pointer = self.pointer; + let Some( + drag @ crate::menus::Drag { + what: Dragged::Notebook(_), + .. + }, + ) = &mut self.drag + else { + return; + }; + if drag.cancelled { + drag.slot = None; + } else if drag.live() { + // Where the rows stand with no gap open, as `drag_entries` finds them. + let spans: Vec<[f32; 2]> = blocks + .iter() + .map(|&[_, top, _, bottom]| { + let shut = gap + .filter(|gap| top >= gap[3] - 0.5) + .map_or(0.0, |[_, start, _, end]| end - start); + [top - shut, bottom - top] + }) + .collect(); + let middle = drag.corner(pointer)[1] + ROW / 2.0; + drag.slot = Some(ui::drop_slot(&spans, usize::MAX, middle)); + } + let (true, Dragged::Notebook(entry)) = (dropped, &mut drag.what) else { + return; + }; + let from = entry.notebook; + let Some(to) = drag + .slot + .filter(|to| *to != from && from < self.notebooks.len()) + else { + return; + }; + // It eases into its new place from where it was let go. + entry.notebook = to; + let moved = self.notebooks.remove(from); + self.notebooks.insert(to, moved); + self.save_settings(); + } + /// Opens what the open panel chose: a notebook folder, a notebook's table of contents, /// or a section, in its notebook where it has one. pub(crate) fn open_path(&mut self, path: &std::path::Path) { diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index ae5b26fa1a43282ab5ca03377bf69fdd8b5e3bd9..8dbb76190657aa10f144eac426e6a396acb42354 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -849,3 +849,30 @@ fn a_double_click_renames_a_sidebar_row_in_place() { ); assert!(scratch.0.join("Projects/Soups.one").exists()); } + +/// A notebook's row dragged below another's moves the notebook there. +#[test] +fn dragging_a_notebook_row_reorders_the_notebooks() { + let scratch = Scratch::new("drag-notebook"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + copy_notebook(¬ebook, &scratch.0.join("icloud/Cloudy")); + let settings = r#"{"user_name": "Snowbound Test", "sidebar": true}"#; + std::fs::write(scratch.0.join("settings.json"), settings).unwrap(); + let mut steps = vec![ + "wait 1000", + "settle", + "accessibility before", + "move 60 76", + "press", + ]; + for y in ["move 60 84", "move 60 110", "move 60 140", "move 60 170"] { + steps.extend([y, "wait 50"]); + } + steps.extend(["release", "wait 400", "settle", "accessibility after"]); + let [before, after] = replay(&scratch, Some(¬ebook), &steps) + .try_into() + .unwrap(); + assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}"); + assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}"); +} -- 2.54.0