authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:03:49-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:05:30-07:00
log30328408481c8d7949dcfabb9d032a380c6f93b5
treee4f9ead57df494d23183b2a454dfbdb5c47dbd8c
parent0f9f47a89f43094dfe77f0be3b1d482230cb5728
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

3 files changed, 203 insertions(+), 51 deletions(-)

crates/snowbound/src/menus.rs+2
...@@ -23,6 +23,8 @@ pub enum Dragged {...@@ -23,6 +23,8 @@ pub enum Dragged {
23 },23 },
24 /// A section tab, by index.24 /// A section tab, by index.
25 Tab(usize),25 Tab(usize),
26 /// A notebook's sidebar row, by its index among the notebooks.
27 Notebook(crate::sidebar::Entry),
26}28}
2729
28/// Something held down to drag: where the pointer took it, whether it has moved far enough30/// Something held down to drag: where the pointer took it, whether it has moved far enough
crates/snowbound/src/sidebar.rs+174-51
...@@ -67,6 +67,12 @@ pub struct Tree<'a> {...@@ -67,6 +67,12 @@ pub struct Tree<'a> {
67 rows: Vec<Entry>,67 rows: Vec<Entry>,
68 /// The section or group held down, which a drag moves.68 /// The section or group held down, which a drag moves.
69 held: Option<Entry>,69 held: Option<Entry>,
70 /// The notebook row held down, which a drag moves among the notebooks.
71 held_notebook: Option<Entry>,
72 /// The notebook dragged, by index, and the place among the others it would land in.
73 lifted_notebook: Option<(usize, usize)>,
74 /// Each notebook's rows but the dragged one's, in order.
75 blocks: Vec<[f32; 4]>,
70 /// The row dragged, which the tree leaves out for a gap where it would land.76 /// The row dragged, which the tree leaves out for a gap where it would land.
71 lifted: Option<(&'a Entry, &'a Landing)>,77 lifted: Option<(&'a Entry, &'a Landing)>,
72 /// The dragged row as it was built, for drawing it over the others.78 /// The dragged row as it was built, for drawing it over the others.
...@@ -95,6 +101,19 @@ struct Ghost {...@@ -95,6 +101,19 @@ struct Ghost {
95 bold: bool,101 bold: bool,
96}102}
97103
104impl Ghost {
105 fn of(row: &Row) -> Self {
106 Self {
107 label: row.label.to_owned(),
108 icon: row.icon,
109 depth: row.depth,
110 dim: row.dim,
111 selected: row.selected,
112 bold: row.bold,
113 }
114 }
115}
116
98impl<'a> Tree<'a> {117impl<'a> Tree<'a> {
99 fn new(118 fn new(
100 theme: &'a Theme,119 theme: &'a Theme,
...@@ -110,6 +129,9 @@ impl<'a> Tree<'a> {...@@ -110,6 +129,9 @@ impl<'a> Tree<'a> {
110 action: None,129 action: None,
111 rows: Vec::new(),130 rows: Vec::new(),
112 held: None,131 held: None,
132 held_notebook: None,
133 lifted_notebook: None,
134 blocks: Vec::new(),
113 lifted: None,135 lifted: None,
114 ghost: None,136 ghost: None,
115 gap: None,137 gap: None,
...@@ -118,7 +140,8 @@ impl<'a> Tree<'a> {...@@ -118,7 +140,8 @@ impl<'a> Tree<'a> {
118 }140 }
119}141}
120142
121/// A section or group row, for dragging one onto or between the others.143/// A section or group row, for dragging one onto or between the others; or a notebook's,
144/// with an empty `path`, among the notebooks.
122#[derive(Clone, PartialEq)]145#[derive(Clone, PartialEq)]
123pub struct Entry {146pub struct Entry {
124 pub notebook: usize,147 pub notebook: usize,
...@@ -212,32 +235,46 @@ fn sidebar(...@@ -212,32 +235,46 @@ fn sidebar(
212 ..Spec::default()235 ..Spec::default()
213 },236 },
214 );237 );
238 let mut place = 0;
215 for (index, library) in notebooks.iter().enumerate() {239 for (index, library) in notebooks.iter().enumerate() {
216 let key = library.key("");240 let key = library.key("");
217 let unfolded = !folded.contains(&key);241 let unfolded = !folded.contains(&key);
218 let renamed = (tree.renaming.as_ref()).is_some_and(|renaming| renaming.notebook(library));242 let renamed = (tree.renaming.as_ref()).is_some_and(|renaming| renaming.notebook(library));
219 let (row, fold) = tree_row(243 let row = Row {
220 ui,244 label: &library.name,
221 tree,245 icon: Leading::Notebook(
222 ui.id(("notebook", index)),246 if library.in_icloud() {
223 Row {247 art::NOTEBOOK_ICLOUD
224 label: &library.name,248 } else {
225 icon: Leading::Notebook(249 art::NOTEBOOK
226 if library.in_icloud() {250 },
227 art::NOTEBOOK_ICLOUD251 library.color(),
228 } else {252 ),
229 art::NOTEBOOK253 depth: 0,
230 },254 dim: library.notebook.is_err(),
231 library.color(),255 fold: Some(unfolded),
232 ),256 selected: None,
233 depth: 0,257 renamed,
234 dim: library.notebook.is_err(),258 bold: tree.unread.contains(&key),
235 fold: Some(unfolded),259 };
236 selected: None,260 if let Some((lifted, slot)) = tree.lifted_notebook {
237 renamed,261 if lifted == index {
238 bold: tree.unread.contains(&key),262 tree.ghost = Some(Ghost::of(&row));
263 continue;
264 }
265 opening(ui, tree, ("notebook gap", &library.location), slot == place);
266 place += 1;
267 }
268 let block = ui.open(
269 ("notebook block", index),
270 Spec {
271 axis: Axis::Y,
272 size: [fill(), ui::children()],
273 ..Spec::default()
239 },274 },
240 );275 );
276 let id = ui.id(("notebook", index));
277 let (row, fold) = tree_row(ui, tree, id, row);
241 let unsigned = library.notebook.is_err()278 let unsigned = library.notebook.is_err()
242 && crate::library::server_address(&library.location).is_some();279 && crate::library::server_address(&library.location).is_some();
243 if row.clicked && unsigned {280 if row.clicked && unsigned {
...@@ -251,6 +288,18 @@ fn sidebar(...@@ -251,6 +288,18 @@ fn sidebar(
251 if let Some(point) = row.context {288 if let Some(point) = row.context {
252 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));289 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
253 }290 }
291 if row.dragging
292 && !renamed
293 && let Some(rect) = ui.rect(id)
294 {
295 tree.held_notebook = Some(Entry {
296 notebook: index,
297 path: String::new(),
298 group: false,
299 rect,
300 id,
301 });
302 }
254 if let Some(catalog) = library.catalog()303 if let Some(catalog) = library.catalog()
255 && unfolding(ui, ("notebook rows", index), unfolded)304 && unfolding(ui, ("notebook rows", index), unfolded)
256 {305 {
...@@ -258,6 +307,11 @@ fn sidebar(...@@ -258,6 +307,11 @@ fn sidebar(
258 ui.close();307 ui.close();
259 ui.close();308 ui.close();
260 }309 }
310 ui.close();
311 tree.blocks.extend(ui.rect(block));
312 }
313 if let Some((_, slot)) = tree.lifted_notebook {
314 opening(ui, tree, ("notebook gap", ""), slot == place);
261 }315 }
262 ui.close();316 ui.close();
263 ui.open(317 ui.open(
...@@ -549,14 +603,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool {...@@ -549,14 +603,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool {
549 .lifted603 .lifted
550 .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path);604 .is_some_and(|(lifted, _)| lifted.notebook == notebook && lifted.path == path);
551 if lifted {605 if lifted {
552 tree.ghost = Some(Ghost {606 tree.ghost = Some(Ghost::of(row));
553 label: row.label.to_owned(),
554 icon: row.icon,
555 depth: row.depth,
556 dim: row.dim,
557 selected: row.selected,
558 bold: row.bold,
559 });
560 }607 }
561 lifted608 lifted
562}609}
...@@ -568,14 +615,20 @@ fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) {...@@ -568,14 +615,20 @@ fn gap(ui: &mut Ui, tree: &mut Tree, folder: &str, before: Option<&str>) {
568 return;615 return;
569 };616 };
570 let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before;617 let here = !landing.into && landing.folder == folder && landing.before.as_deref() == before;
571 let id = ui.id(("gap", before));618 opening(ui, tree, ("gap", before), here);
619}
620
621/// A gap, `part`, that eases open a row tall where a dragged row would land, `here`, and
622/// shut elsewhere.
623fn opening(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash + Copy, here: bool) {
624 let id = ui.id(part);
572 let height = ui.animate(id, if here { ROW } else { 0.0 });625 let height = ui.animate(id, if here { ROW } else { 0.0 });
573 if here {626 if here {
574 tree.gap = ui.rect(id);627 tree.gap = ui.rect(id);
575 }628 }
576 if height > 0.0 {629 if height > 0.0 {
577 ui.leaf(630 ui.leaf(
578 ("gap", before),631 part,
579 Spec {632 Spec {
580 size: [fill(), px(height)],633 size: [fill(), px(height)],
581 ..Spec::default()634 ..Spec::default()
...@@ -836,6 +889,14 @@ impl crate::State {...@@ -836,6 +889,14 @@ impl crate::State {
836 Dragged::Entry { entry, landing, .. } => Some((entry, landing)),889 Dragged::Entry { entry, landing, .. } => Some((entry, landing)),
837 _ => None,890 _ => None,
838 });891 });
892 tree.lifted_notebook = (self.drag.as_ref())
893 .filter(|drag| drag.lifted)
894 .and_then(|drag| match &drag.what {
895 Dragged::Notebook(entry) => {
896 Some((entry.notebook, drag.slot.unwrap_or(entry.notebook)))
897 }
898 _ => None,
899 });
839 sidebar(900 sidebar(
840 &mut self.ui,901 &mut self.ui,
841 &mut tree,902 &mut tree,
...@@ -848,7 +909,9 @@ impl crate::State {...@@ -848,7 +909,9 @@ impl crate::State {
848 let Tree {909 let Tree {
849 action,910 action,
850 rows,911 rows,
851 mut held,912 held,
913 held_notebook,
914 blocks,
852 ghost,915 ghost,
853 gap,916 gap,
854 ..917 ..
...@@ -856,8 +919,13 @@ impl crate::State {...@@ -856,8 +919,13 @@ impl crate::State {
856 let corner = self.ui.rect(rows_id).unwrap_or_default();919 let corner = self.ui.rect(rows_id).unwrap_or_default();
857 let found = ghost.is_some();920 let found = ghost.is_some();
858 let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner);921 let (ghost_held, settled) = self.lifted_row(theme, ghost, gap, &rows, corner);
859 held = held.or(ghost_held);922 let settled = settled || !found;
860 self.drag_entries(held, &rows, gap, settled || !found, corner, theme);923 let (held, held_notebook) = match ghost_held {
924 Some(entry) if entry.path.is_empty() => (held, Some(entry)),
925 ghost_held => (held.or(ghost_held), held_notebook),
926 };
927 self.drag_entries(held, &rows, gap, settled, corner, theme);
928 self.drag_notebooks(held_notebook, &blocks, gap, settled);
861 self.ui.close();929 self.ui.close();
862 self.ui.close();930 self.ui.close();
863 match action {931 match action {
...@@ -976,30 +1044,25 @@ impl crate::State {...@@ -976,30 +1044,25 @@ impl crate::State {
976 rows: &[Entry],1044 rows: &[Entry],
977 corner: [f32; 4],1045 corner: [f32; 4],
978 ) -> (Option<Entry>, bool) {1046 ) -> (Option<Entry>, bool) {
979 let (1047 let (Some(ghost), Some(drag)) = (ghost, &self.drag) else {
980 Some(ghost),
981 Some(
982 drag @ crate::menus::Drag {
983 what: Dragged::Entry { entry, landing, .. },
984 ..
985 },
986 ),
987 ) = (ghost, &self.drag)
988 else {
989 return (None, true);1048 return (None, true);
990 };1049 };
1050 let (entry, onto) = match &drag.what {
1051 Dragged::Entry { entry, landing, .. } => (
1052 entry,
1053 (rows.iter())
1054 .find(|row| landing.into && row.path == landing.folder)
1055 .map(|row| row.rect),
1056 ),
1057 Dragged::Notebook(entry) => (entry, None),
1058 _ => return (None, true),
1059 };
991 let key = self.ui.id("lifted");1060 let key = self.ui.id("lifted");
992 let (top, settled) = if drag.live() {1061 let (top, settled) = if drag.live() {
993 let top = drag.corner(self.pointer)[1] - corner[1];1062 let top = drag.corner(self.pointer)[1] - corner[1];
994 (self.ui.hold(key, top), false)1063 (self.ui.hold(key, top), false)
995 } else {1064 } else {
996 let place = gap1065 let place = gap.or(onto).unwrap_or(entry.rect);
997 .or_else(|| {
998 rows.iter()
999 .find(|row| landing.into && row.path == landing.folder)
1000 .map(|row| row.rect)
1001 })
1002 .unwrap_or(entry.rect);
1003 let target = place[1] - corner[1];1066 let target = place[1] - corner[1];
1004 let top = self.ui.animate(key, target);1067 let top = self.ui.animate(key, target);
1005 (top, (top - target).abs() < 0.5)1068 (top, (top - target).abs() < 0.5)
...@@ -1164,6 +1227,66 @@ impl crate::State {...@@ -1164,6 +1227,66 @@ impl crate::State {
1164 .push(crate::Command::Structure(library, change));1227 .push(crate::Command::Structure(library, change));
1165 }1228 }
11661229
1230 /// Moves a notebook among the others by dragging its row, `held`: the others' rows,
1231 /// `blocks`, open a `gap` where it would land, and let go it takes that place, which the
1232 /// next launch keeps. The drag ends once the row, let go, is `settled`.
1233 fn drag_notebooks(
1234 &mut self,
1235 held: Option<Entry>,
1236 blocks: &[[f32; 4]],
1237 gap: Option<[f32; 4]>,
1238 settled: bool,
1239 ) {
1240 let mine = |what: &Dragged| matches!(what, Dragged::Notebook(_));
1241 self.settle(mine, settled);
1242 let held = held.map(|entry| {
1243 let rect = entry.rect;
1244 (Dragged::Notebook(entry), rect)
1245 });
1246 let dropped = self.follow_drag(held, mine);
1247 let pointer = self.pointer;
1248 let Some(
1249 drag @ crate::menus::Drag {
1250 what: Dragged::Notebook(_),
1251 ..
1252 },
1253 ) = &mut self.drag
1254 else {
1255 return;
1256 };
1257 if drag.cancelled {
1258 drag.slot = None;
1259 } else if drag.live() {
1260 // Where the rows stand with no gap open, as `drag_entries` finds them.
1261 let spans: Vec<[f32; 2]> = blocks
1262 .iter()
1263 .map(|&[_, top, _, bottom]| {
1264 let shut = gap
1265 .filter(|gap| top >= gap[3] - 0.5)
1266 .map_or(0.0, |[_, start, _, end]| end - start);
1267 [top - shut, bottom - top]
1268 })
1269 .collect();
1270 let middle = drag.corner(pointer)[1] + ROW / 2.0;
1271 drag.slot = Some(ui::drop_slot(&spans, usize::MAX, middle));
1272 }
1273 let (true, Dragged::Notebook(entry)) = (dropped, &mut drag.what) else {
1274 return;
1275 };
1276 let from = entry.notebook;
1277 let Some(to) = drag
1278 .slot
1279 .filter(|to| *to != from && from < self.notebooks.len())
1280 else {
1281 return;
1282 };
1283 // It eases into its new place from where it was let go.
1284 entry.notebook = to;
1285 let moved = self.notebooks.remove(from);
1286 self.notebooks.insert(to, moved);
1287 self.save_settings();
1288 }
1289
1167 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,1290 /// Opens what the open panel chose: a notebook folder, a notebook's table of contents,
1168 /// or a section, in its notebook where it has one.1291 /// or a section, in its notebook where it has one.
1169 pub(crate) fn open_path(&mut self, path: &std::path::Path) {1292 pub(crate) fn open_path(&mut self, path: &std::path::Path) {
crates/snowbound/tests/replay.rs+27
...@@ -849,3 +849,30 @@ fn a_double_click_renames_a_sidebar_row_in_place() {...@@ -849,3 +849,30 @@ fn a_double_click_renames_a_sidebar_row_in_place() {
849 );849 );
850 assert!(scratch.0.join("Projects/Soups.one").exists());850 assert!(scratch.0.join("Projects/Soups.one").exists());
851}851}
852
853/// A notebook's row dragged below another's moves the notebook there.
854#[test]
855fn dragging_a_notebook_row_reorders_the_notebooks() {
856 let scratch = Scratch::new("drag-notebook");
857 let notebook =
858 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
859 copy_notebook(&notebook, &scratch.0.join("icloud/Cloudy"));
860 let settings = r#"{"user_name": "Snowbound Test", "sidebar": true}"#;
861 std::fs::write(scratch.0.join("settings.json"), settings).unwrap();
862 let mut steps = vec![
863 "wait 1000",
864 "settle",
865 "accessibility before",
866 "move 60 76",
867 "press",
868 ];
869 for y in ["move 60 84", "move 60 110", "move 60 140", "move 60 170"] {
870 steps.extend([y, "wait 50"]);
871 }
872 steps.extend(["release", "wait 400", "settle", "accessibility after"]);
873 let [before, after] = replay(&scratch, Some(&notebook), &steps)
874 .try_into()
875 .unwrap();
876 assert_eq!(notebooks(&before), ["notebook", "Cloudy"], "{before}");
877 assert_eq!(notebooks(&after), ["Cloudy", "notebook"], "{after}");
878}