| author | |
| committer | |
| log | 0f9f47a89f43094dfe77f0be3b1d482230cb5728 |
| tree | 31635b497c1b33b441159b5cde2d4c2f08612a7d |
| parent | 5e10e17cd5f73270850258fba1ea45a4b203de7d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.53 files changed, 89 insertions(+), 4 deletions(-)
crates/snowbound/src/rename.rs+22| ... | ... | @@ -30,6 +30,8 @@ pub enum Target { |
| 30 | 30 | }, |
| 31 | 31 | /// A page of the open section, by its title in its tab. |
| 32 | 32 | Page(ExGuid), |
| 33 | /// A notebook, in its sidebar row, as Notebook Properties renames it. | |
| 34 | Notebook(Arc<Library>), | |
| 33 | 35 | } |
| 34 | 36 | |
| 35 | 37 | impl Renaming { |
| ... | ... | @@ -39,6 +41,10 @@ impl Renaming { |
| 39 | 41 | if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab) |
| 40 | 42 | } |
| 41 | 43 | |
| 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 | 48 | pub fn page(&self, space: ExGuid) -> bool { |
| 43 | 49 | matches!(self.target, Target::Page(renamed) if renamed == space) |
| 44 | 50 | } |
| ... | ... | @@ -89,6 +95,10 @@ impl State { |
| 89 | 95 | .and_then(|session| session.pages.iter().find(|(page, ..)| page == space)) |
| 90 | 96 | .map(|(_, title, _)| title.clone()) |
| 91 | 97 | .unwrap_or_default(), |
| 98 | Target::Notebook(library) => { | |
| 99 | self.sidebar = true; | |
| 100 | library.name.clone() | |
| 101 | } | |
| 92 | 102 | }; |
| 93 | 103 | self.renaming = Some(Renaming { target, name }); |
| 94 | 104 | self.ui.focus_all(field()); |
| ... | ... | @@ -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 | 138 | Target::Page(space) => { |
| 117 | 139 | if let Err(error) = self.retitle(space, name) { |
| 118 | 140 | crate::platform::alert( |
crates/snowbound/src/sidebar.rs+38-4| ... | ... | @@ -46,6 +46,9 @@ pub enum Action { |
| 46 | 46 | Options, |
| 47 | 47 | /// A row's context menu, opened here. |
| 48 | 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 | 52 | /// Ends renaming, with the name typed or without. |
| 50 | 53 | Renamed(bool), |
| 51 | 54 | /// Explains why a section or group can't be opened: the alert's title and message. |
| ... | ... | @@ -212,6 +215,7 @@ fn sidebar( |
| 212 | 215 | for (index, library) in notebooks.iter().enumerate() { |
| 213 | 216 | let key = library.key(""); |
| 214 | 217 | let unfolded = !folded.contains(&key); |
| 218 | let renamed = (tree.renaming.as_ref()).is_some_and(|renaming| renaming.notebook(library)); | |
| 215 | 219 | let (row, fold) = tree_row( |
| 216 | 220 | ui, |
| 217 | 221 | tree, |
| ... | ... | @@ -230,7 +234,7 @@ fn sidebar( |
| 230 | 234 | dim: library.notebook.is_err(), |
| 231 | 235 | fold: Some(unfolded), |
| 232 | 236 | selected: None, |
| 233 | renamed: false, | |
| 237 | renamed, | |
| 234 | 238 | bold: tree.unread.contains(&key), |
| 235 | 239 | }, |
| 236 | 240 | ); |
| ... | ... | @@ -238,7 +242,10 @@ fn sidebar( |
| 238 | 242 | && crate::library::server_address(&library.location).is_some(); |
| 239 | 243 | if row.clicked && unsigned { |
| 240 | 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 | 249 | tree.action = Some(Action::Fold(key)); |
| 243 | 250 | } |
| 244 | 251 | if let Some(point) = row.context { |
| ... | ... | @@ -365,7 +372,14 @@ fn folder( |
| 365 | 372 | continue; |
| 366 | 373 | } |
| 367 | 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 | 383 | tree.action = Some(Action::Open { |
| 370 | 384 | notebook, |
| 371 | 385 | path: section.path.clone(), |
| ... | ... | @@ -405,7 +419,14 @@ fn folder( |
| 405 | 419 | let lifted = lifts(tree, notebook, &group.path, &row); |
| 406 | 420 | if !lifted { |
| 407 | 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 | 430 | tree.action = Some(Action::Fold(key)); |
| 410 | 431 | } |
| 411 | 432 | entry(ui, tree, notebook, &group.path, id, true, &row); |
| ... | ... | @@ -476,6 +497,11 @@ fn folder( |
| 476 | 497 | } |
| 477 | 498 | } |
| 478 | 499 | |
| 500 | /// Whether `row` was pressed a second time in a double click, which renames it in place. | |
| 501 | fn double(row: &Signal) -> bool { | |
| 502 | row.pressed && row.unit != draw::edit::SelectionUnit::Grapheme | |
| 503 | } | |
| 504 | ||
| 479 | 505 | /// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a |
| 480 | 506 | /// group's middle, into it; above or below a row's middle, before or after it in its |
| 481 | 507 | /// folder; into another folder, at its end. |
| ... | ... | @@ -849,6 +875,14 @@ impl crate::State { |
| 849 | 875 | self.menu = Some((target, point)); |
| 850 | 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 | 886 | Some(Action::Renamed(keep)) => self.finish_renaming(keep), |
| 853 | 887 | Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), |
| 854 | 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 | 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] | |
| 828 | fn 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 | } |