diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs index 0c64c728319d6b0c2f2ac0724696d42b64510e70..6bf1bf1ca7aa818f6ca9a38dcf6e0514ecdd8060 100644 --- a/crates/snowbound/src/rename.rs +++ b/crates/snowbound/src/rename.rs @@ -30,6 +30,8 @@ pub enum Target { }, /// A page of the open section, by its title in its tab. Page(ExGuid), + /// A notebook, in its sidebar row, as Notebook Properties renames it. + Notebook(Arc), } impl Renaming { @@ -39,6 +41,10 @@ impl Renaming { if Arc::ptr_eq(renamed, library) && at == path && *in_tab == tab) } + pub fn notebook(&self, library: &Arc) -> bool { + matches!(&self.target, Target::Notebook(renamed) if Arc::ptr_eq(renamed, library)) + } + pub fn page(&self, space: ExGuid) -> bool { matches!(self.target, Target::Page(renamed) if renamed == space) } @@ -89,6 +95,10 @@ impl State { .and_then(|session| session.pages.iter().find(|(page, ..)| page == space)) .map(|(_, title, _)| title.clone()) .unwrap_or_default(), + Target::Notebook(library) => { + self.sidebar = true; + library.name.clone() + } }; self.renaming = Some(Renaming { target, name }); self.ui.focus_all(field()); @@ -113,6 +123,18 @@ impl State { )); } } + // As OK in Notebook Properties: the folder takes the name where it can. + Target::Notebook(library) if name != library.name => { + if library.renamed_location(&name).is_some() { + self.rename_notebook(library, name, None); + } else { + self.commands.push(Command::Structure( + library, + Structure::Properties { name, color: None }, + )); + } + } + Target::Notebook(_) => {} Target::Page(space) => { if let Err(error) = self.retitle(space, name) { crate::platform::alert( diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 6ad54be9e89e4232c20191e03b2c48f691e886cb..faa578bc6a95970bbe77be09f93219f9423a440e 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -46,6 +46,9 @@ pub enum Action { Options, /// A row's context menu, opened here. Menu(Target, [f32; 2]), + /// Opens the rename field on a row double-clicked, folding back by `Library::key` the + /// row its first click folded. + Rename(rename::Target, Option), /// Ends renaming, with the name typed or without. Renamed(bool), /// Explains why a section or group can't be opened: the alert's title and message. @@ -212,6 +215,7 @@ fn sidebar( 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, @@ -230,7 +234,7 @@ fn sidebar( dim: library.notebook.is_err(), fold: Some(unfolded), selected: None, - renamed: false, + renamed, bold: tree.unread.contains(&key), }, ); @@ -238,7 +242,10 @@ fn sidebar( && crate::library::server_address(&library.location).is_some(); if row.clicked && unsigned { tree.action = Some(Action::SignIn(library.location.clone())); - } else if row.clicked || fold { + } else if double(&row) && library.catalog().is_some() { + let target = rename::Target::Notebook(Arc::clone(library)); + tree.action = Some(Action::Rename(target, Some(key))); + } else if row.clicked && !renamed || fold { tree.action = Some(Action::Fold(key)); } if let Some(point) = row.context { @@ -365,7 +372,14 @@ fn folder( continue; } let (row, _) = tree_row(ui, tree, id, row); - if row.clicked && readable && open != Some(section.path.as_str()) { + if double(&row) && readable { + let target = rename::Target::Entry { + library: Arc::clone(library), + path: section.path.clone(), + in_tab: false, + }; + tree.action = Some(Action::Rename(target, None)); + } else if row.clicked && readable && open != Some(section.path.as_str()) { tree.action = Some(Action::Open { notebook, path: section.path.clone(), @@ -405,7 +419,14 @@ fn folder( let lifted = lifts(tree, notebook, &group.path, &row); if !lifted { let (row, fold) = tree_row(ui, tree, id, row); - if row.clicked || fold { + if double(&row) { + let target = rename::Target::Entry { + library: Arc::clone(library), + path: group.path.clone(), + in_tab: false, + }; + tree.action = Some(Action::Rename(target, Some(key))); + } else if row.clicked && !renaming || fold { tree.action = Some(Action::Fold(key)); } entry(ui, tree, notebook, &group.path, id, true, &row); @@ -476,6 +497,11 @@ fn folder( } } +/// Whether `row` was pressed a second time in a double click, which renames it in place. +fn double(row: &Signal) -> bool { + row.pressed && row.unit != draw::edit::SelectionUnit::Grapheme +} + /// Where the row `lifted` lands with the pointer at height `y` among `rows`: onto a /// group's middle, into it; above or below a row's middle, before or after it in its /// folder; into another folder, at its end. @@ -849,6 +875,14 @@ impl crate::State { self.menu = Some((target, point)); self.ui.open_popup(crate::menus::id()); } + Some(Action::Rename(target, refold)) => { + if let Some(key) = refold + && !self.folded.remove(&key) + { + self.folded.insert(key); + } + self.rename(target); + } Some(Action::Renamed(keep)) => self.finish_renaming(keep), Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 9556dc2797f3410c8fdf7dab753779ce04b125ea..ae5b26fa1a43282ab5ca03377bf69fdd8b5e3bd9 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -820,3 +820,32 @@ fn switching_renderers_keeps_the_window_as_it_was() { "the caret stayed put: {after}" ); } + +/// A double click on a sidebar row renames it in place, as the Finder renames a file: the +/// notebook's folder and the section each take the name typed, and the notebook stays +/// unfolded through the double click's two clicks. +#[test] +fn a_double_click_renames_a_sidebar_row_in_place() { + let scratch = Scratch::new("double-click-rename"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let settings = r#"{"user_name": "Snowbound Test", "sidebar": true}"#; + std::fs::write(scratch.0.join("settings.json"), settings).unwrap(); + let double = |y: &'static str| [y, "press", "release", "press", "release", "settle"]; + let mut steps = Vec::from(double("move 60 76")); + steps.extend(["type Projects", "key Enter", "settle", "wait 500", "settle"]); + steps.extend(double("move 80 100")); + steps.extend(["type Soups", "key Enter", "settle", "accessibility renamed"]); + let [renamed] = replay(&scratch, Some(¬ebook), &steps) + .try_into() + .unwrap(); + assert!( + renamed.contains(r#"TreeItem "Projects" [expanded]"#), + "{renamed}" + ); + assert!( + renamed.contains(r#"TreeItem "Soups" [selected]"#), + "{renamed}" + ); + assert!(scratch.0.join("Projects/Soups.one").exists()); +}