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

feat: double-click a sidebar row to rename it in place

Notebooks, section groups and sections rename in their rows, as the Finder renames a file. A notebook takes the name as OK in Notebook Properties gives it: its folder is renamed where it can be, else its display name. The double click's first click no longer leaves a notebook or group folded. Assisted-by: claude-opus-5.5

3 files changed, 89 insertions(+), 4 deletions(-)

crates/snowbound/src/rename.rs+22
...@@ -30,6 +30,8 @@ pub enum Target {...@@ -30,6 +30,8 @@ pub enum Target {
30 },30 },
31 /// A page of the open section, by its title in its tab.31 /// A page of the open section, by its title in its tab.
32 Page(ExGuid),32 Page(ExGuid),
33 /// A notebook, in its sidebar row, as Notebook Properties renames it.
34 Notebook(Arc<Library>),
33}35}
3436
35impl Renaming {37impl Renaming {
...@@ -39,6 +41,10 @@ impl Renaming {...@@ -39,6 +41,10 @@ impl Renaming {
39 if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab)41 if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab)
40 }42 }
4143
44 pub fn notebook(&self, library: &Arc<Library>) -> bool {
45 matches!(&self.target, Target::Notebook(renamed) if Arc::ptr_eq(renamed, library))
46 }
47
42 pub fn page(&self, space: ExGuid) -> bool {48 pub fn page(&self, space: ExGuid) -> bool {
43 matches!(self.target, Target::Page(renamed) if renamed == space)49 matches!(self.target, Target::Page(renamed) if renamed == space)
44 }50 }
...@@ -89,6 +95,10 @@ impl State {...@@ -89,6 +95,10 @@ impl State {
89 .and_then(|session| session.pages.iter().find(|(page, ..)| page == space))95 .and_then(|session| session.pages.iter().find(|(page, ..)| page == space))
90 .map(|(_, title, _)| title.clone())96 .map(|(_, title, _)| title.clone())
91 .unwrap_or_default(),97 .unwrap_or_default(),
98 Target::Notebook(library) => {
99 self.sidebar = true;
100 library.name.clone()
101 }
92 };102 };
93 self.renaming = Some(Renaming { target, name });103 self.renaming = Some(Renaming { target, name });
94 self.ui.focus_all(field());104 self.ui.focus_all(field());
...@@ -113,6 +123,18 @@ impl State {...@@ -113,6 +123,18 @@ impl State {
113 ));123 ));
114 }124 }
115 }125 }
126 // As OK in Notebook Properties: the folder takes the name where it can.
127 Target::Notebook(library) if name != library.name => {
128 if library.renamed_location(&name).is_some() {
129 self.rename_notebook(library, name, None);
130 } else {
131 self.commands.push(Command::Structure(
132 library,
133 Structure::Properties { name, color: None },
134 ));
135 }
136 }
137 Target::Notebook(_) => {}
116 Target::Page(space) => {138 Target::Page(space) => {
117 if let Err(error) = self.retitle(space, name) {139 if let Err(error) = self.retitle(space, name) {
118 crate::platform::alert(140 crate::platform::alert(
crates/snowbound/src/sidebar.rs+38-4
...@@ -46,6 +46,9 @@ pub enum Action {...@@ -46,6 +46,9 @@ pub enum Action {
46 Options,46 Options,
47 /// A row's context menu, opened here.47 /// A row's context menu, opened here.
48 Menu(Target, [f32; 2]),48 Menu(Target, [f32; 2]),
49 /// Opens the rename field on a row double-clicked, folding back by `Library::key` the
50 /// row its first click folded.
51 Rename(rename::Target, Option<String>),
49 /// Ends renaming, with the name typed or without.52 /// Ends renaming, with the name typed or without.
50 Renamed(bool),53 Renamed(bool),
51 /// Explains why a section or group can't be opened: the alert's title and message.54 /// Explains why a section or group can't be opened: the alert's title and message.
...@@ -212,6 +215,7 @@ fn sidebar(...@@ -212,6 +215,7 @@ fn sidebar(
212 for (index, library) in notebooks.iter().enumerate() {215 for (index, library) in notebooks.iter().enumerate() {
213 let key = library.key("");216 let key = library.key("");
214 let unfolded = !folded.contains(&key);217 let unfolded = !folded.contains(&key);
218 let renamed = (tree.renaming.as_ref()).is_some_and(|renaming| renaming.notebook(library));
215 let (row, fold) = tree_row(219 let (row, fold) = tree_row(
216 ui,220 ui,
217 tree,221 tree,
...@@ -230,7 +234,7 @@ fn sidebar(...@@ -230,7 +234,7 @@ fn sidebar(
230 dim: library.notebook.is_err(),234 dim: library.notebook.is_err(),
231 fold: Some(unfolded),235 fold: Some(unfolded),
232 selected: None,236 selected: None,
233 renamed: false,237 renamed,
234 bold: tree.unread.contains(&key),238 bold: tree.unread.contains(&key),
235 },239 },
236 );240 );
...@@ -238,7 +242,10 @@ fn sidebar(...@@ -238,7 +242,10 @@ fn sidebar(
238 && crate::library::server_address(&library.location).is_some();242 && crate::library::server_address(&library.location).is_some();
239 if row.clicked && unsigned {243 if row.clicked && unsigned {
240 tree.action = Some(Action::SignIn(library.location.clone()));244 tree.action = Some(Action::SignIn(library.location.clone()));
241 } else if row.clicked || fold {245 } else if double(&row) && library.catalog().is_some() {
246 let target = rename::Target::Notebook(Arc::clone(library));
247 tree.action = Some(Action::Rename(target, Some(key)));
248 } else if row.clicked && !renamed || fold {
242 tree.action = Some(Action::Fold(key));249 tree.action = Some(Action::Fold(key));
243 }250 }
244 if let Some(point) = row.context {251 if let Some(point) = row.context {
...@@ -365,7 +372,14 @@ fn folder(...@@ -365,7 +372,14 @@ fn folder(
365 continue;372 continue;
366 }373 }
367 let (row, _) = tree_row(ui, tree, id, row);374 let (row, _) = tree_row(ui, tree, id, row);
368 if row.clicked && readable && open != Some(section.path.as_str()) {375 if double(&row) && readable {
376 let target = rename::Target::Entry {
377 library: Arc::clone(library),
378 path: section.path.clone(),
379 in_tab: false,
380 };
381 tree.action = Some(Action::Rename(target, None));
382 } else if row.clicked && readable && open != Some(section.path.as_str()) {
369 tree.action = Some(Action::Open {383 tree.action = Some(Action::Open {
370 notebook,384 notebook,
371 path: section.path.clone(),385 path: section.path.clone(),
...@@ -405,7 +419,14 @@ fn folder(...@@ -405,7 +419,14 @@ fn folder(
405 let lifted = lifts(tree, notebook, &group.path, &row);419 let lifted = lifts(tree, notebook, &group.path, &row);
406 if !lifted {420 if !lifted {
407 let (row, fold) = tree_row(ui, tree, id, row);421 let (row, fold) = tree_row(ui, tree, id, row);
408 if row.clicked || fold {422 if double(&row) {
423 let target = rename::Target::Entry {
424 library: Arc::clone(library),
425 path: group.path.clone(),
426 in_tab: false,
427 };
428 tree.action = Some(Action::Rename(target, Some(key)));
429 } else if row.clicked && !renaming || fold {
409 tree.action = Some(Action::Fold(key));430 tree.action = Some(Action::Fold(key));
410 }431 }
411 entry(ui, tree, notebook, &group.path, id, true, &row);432 entry(ui, tree, notebook, &group.path, id, true, &row);
...@@ -476,6 +497,11 @@ fn folder(...@@ -476,6 +497,11 @@ fn folder(
476 }497 }
477}498}
478499
500/// Whether `row` was pressed a second time in a double click, which renames it in place.
501fn double(row: &Signal) -> bool {
502 row.pressed && row.unit != draw::edit::SelectionUnit::Grapheme
503}
504
479/// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a505/// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a
480/// group's middle, into it; above or below a row's middle, before or after it in its506/// group's middle, into it; above or below a row's middle, before or after it in its
481/// folder; into another folder, at its end.507/// folder; into another folder, at its end.
...@@ -849,6 +875,14 @@ impl crate::State {...@@ -849,6 +875,14 @@ impl crate::State {
849 self.menu = Some((target, point));875 self.menu = Some((target, point));
850 self.ui.open_popup(crate::menus::id());876 self.ui.open_popup(crate::menus::id());
851 }877 }
878 Some(Action::Rename(target, refold)) => {
879 if let Some(key) = refold
880 && !self.folded.remove(&key)
881 {
882 self.folded.insert(key);
883 }
884 self.rename(target);
885 }
852 Some(Action::Renamed(keep)) => self.finish_renaming(keep),886 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
853 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),887 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
854 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),888 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
crates/snowbound/tests/replay.rs+29
...@@ -820,3 +820,32 @@ fn switching_renderers_keeps_the_window_as_it_was() {...@@ -820,3 +820,32 @@ fn switching_renderers_keeps_the_window_as_it_was() {
820 "the caret stayed put: {after}"820 "the caret stayed put: {after}"
821 );821 );
822}822}
823
824/// A double click on a sidebar row renames it in place, as the Finder renames a file: the
825/// notebook's folder and the section each take the name typed, and the notebook stays
826/// unfolded through the double click's two clicks.
827#[test]
828fn a_double_click_renames_a_sidebar_row_in_place() {
829 let scratch = Scratch::new("double-click-rename");
830 let notebook =
831 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
832 let settings = r#"{"user_name": "Snowbound Test", "sidebar": true}"#;
833 std::fs::write(scratch.0.join("settings.json"), settings).unwrap();
834 let double = |y: &'static str| [y, "press", "release", "press", "release", "settle"];
835 let mut steps = Vec::from(double("move 60 76"));
836 steps.extend(["type Projects", "key Enter", "settle", "wait 500", "settle"]);
837 steps.extend(double("move 80 100"));
838 steps.extend(["type Soups", "key Enter", "settle", "accessibility renamed"]);
839 let [renamed] = replay(&scratch, Some(&notebook), &steps)
840 .try_into()
841 .unwrap();
842 assert!(
843 renamed.contains(r#"TreeItem "Projects" [expanded]"#),
844 "{renamed}"
845 );
846 assert!(
847 renamed.contains(r#"TreeItem "Soups" [selected]"#),
848 "{renamed}"
849 );
850 assert!(scratch.0.join("Projects/Soups.one").exists());
851}