| author | |
| committer | |
| log | e173ad0c64864c7d5c3c1cfb7cc5e5c6fa931707 |
| tree | 7bf47642140c5a79dc091e1caaced5beaa9ff6f4 |
| parent | c09590a9c59a6645ff9cbe96e0037000405a8bf7 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Clover: a search that finds nothing ends in a call to action. Typed text that
matches no page, section or notebook offers a page titled with it at the end
of the open section. A popup item can be a fallback, listed only when a query
leaves nothing else.
Assisted-by: claude-opus-5.59 files changed, 119 insertions(+), 21 deletions(-)
crates/snowbound/src/commands.rs+5-1| ... | @@ -1489,9 +1489,13 @@ impl State { | ... | @@ -1489,9 +1489,13 @@ impl State { |
| 1489 | }, | 1489 | }, |
| 1490 | ) | 1490 | ) |
| 1491 | } | 1491 | } |
| 1492 | Id::NewPage => Work::NewPage { under: None }, | 1492 | Id::NewPage => Work::NewPage { |
| 1493 | under: None, | ||
| 1494 | title: String::new(), | ||
| 1495 | }, | ||
| 1493 | Id::NewSubpage => Work::NewPage { | 1496 | Id::NewSubpage => Work::NewPage { |
| 1494 | under: self.session.as_ref().map(|session| session.space), | 1497 | under: self.session.as_ref().map(|session| session.space), |
| 1498 | title: String::new(), | ||
| 1495 | }, | 1499 | }, |
| 1496 | Id::PageVersions => { | 1500 | Id::PageVersions => { |
| 1497 | let session = self.session.as_ref().ok_or("No notebook is open")?; | 1501 | let session = self.session.as_ref().ok_or("No notebook is open")?; |
crates/snowbound/src/main.rs+5-4| ... | @@ -693,10 +693,11 @@ enum Command { | ... | @@ -693,10 +693,11 @@ enum Command { |
| 693 | CloseNotebook(Arc<Library>), | 693 | CloseNotebook(Arc<Library>), |
| 694 | /// Changes a notebook's sections and groups. | 694 | /// Changes a notebook's sections and groups. |
| 695 | Structure(Arc<Library>, manage::Structure), | 695 | Structure(Arc<Library>, manage::Structure), |
| 696 | /// Adds a page at the end of the open section, or a subpage of a page, and edits its | 696 | /// Adds a page at the end of the open section, or a subpage of a page, titled `title` |
| 697 | /// title. | 697 | /// or untitled, and edits its title. |
| 698 | NewPage { | 698 | NewPage { |
| 699 | under: Option<ExGuid>, | 699 | under: Option<ExGuid>, |
| 700 | title: String, | ||
| 700 | }, | 701 | }, |
| 701 | /// Deletes pages of the open section to the notebook's recycle bin. | 702 | /// Deletes pages of the open section to the notebook's recycle bin. |
| 702 | DeletePages(Vec<ExGuid>), | 703 | DeletePages(Vec<ExGuid>), |
| ... | @@ -3414,8 +3415,8 @@ impl State { | ... | @@ -3414,8 +3415,8 @@ impl State { |
| 3414 | self.restructure(library, change); | 3415 | self.restructure(library, change); |
| 3415 | } | 3416 | } |
| 3416 | // The index follows the section's page list. | 3417 | // The index follows the section's page list. |
| 3417 | Command::NewPage { under } => { | 3418 | Command::NewPage { under, title } => { |
| 3418 | self.new_page(under)?; | 3419 | self.new_page(under, &title)?; |
| 3419 | self.edited(Vec::new()); | 3420 | self.edited(Vec::new()); |
| 3420 | } | 3421 | } |
| 3421 | Command::DeletePages(pages) => { | 3422 | Command::DeletePages(pages) => { |
crates/snowbound/src/manage.rs+15-9| ... | @@ -101,13 +101,14 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { | ... | @@ -101,13 +101,14 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | impl State { | 103 | impl State { |
| 104 | /// A page OneNote 2010 would create now: titled in the Default font's face, with its date | 104 | /// A page OneNote 2010 would create now, titled `title` in the Default font's face, with |
| 105 | /// and time. | 105 | /// its date and time. |
| 106 | pub(crate) fn dated_page( | 106 | pub(crate) fn dated_page( |
| 107 | &self, | 107 | &self, |
| 108 | before: Option<ExGuid>, | 108 | before: Option<ExGuid>, |
| 109 | title: &str, | ||
| 109 | ) -> Result<PageCreation, Box<dyn Error>> { | 110 | ) -> Result<PageCreation, Box<dyn Error>> { |
| 110 | let creation = PageCreation::new(before, Some(""), &self.author)?.titled_in( | 111 | let creation = PageCreation::new(before, Some(title), &self.author)?.titled_in( |
| 111 | &self.view.editor.default_font.face, | 112 | &self.view.editor.default_font.face, |
| 112 | self.view.editor.default_font.color, | 113 | self.view.editor.default_font.color, |
| 113 | )?; | 114 | )?; |
| ... | @@ -115,9 +116,14 @@ impl State { | ... | @@ -115,9 +116,14 @@ impl State { |
| 115 | Ok(creation.dated(&date, &time)?) | 116 | Ok(creation.dated(&date, &time)?) |
| 116 | } | 117 | } |
| 117 | 118 | ||
| 118 | /// Adds a page at the end of the open section, or a subpage of page `under`, and opens | 119 | /// Adds a page titled `title` at the end of the open section, or a subpage of page |
| 119 | /// it with its title ready for typing, as OneNote's New Page and New Subpage do. | 120 | /// `under`, and opens it with its title ready for typing, as OneNote's New Page and New |
| 120 | pub(crate) fn new_page(&mut self, under: Option<ExGuid>) -> Result<(), Box<dyn Error>> { | 121 | /// Subpage do. |
| 122 | pub(crate) fn new_page( | ||
| 123 | &mut self, | ||
| 124 | under: Option<ExGuid>, | ||
| 125 | title: &str, | ||
| 126 | ) -> Result<(), Box<dyn Error>> { | ||
| 121 | self.persist()?; | 127 | self.persist()?; |
| 122 | let session = self.session.as_ref().ok_or("No section is open")?; | 128 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 123 | // A subpage follows its page and the subpages already under it. | 129 | // A subpage follows its page and the subpages already under it. |
| ... | @@ -136,7 +142,7 @@ impl State { | ... | @@ -136,7 +142,7 @@ impl State { |
| 136 | } else { | 142 | } else { |
| 137 | (None, 1) | 143 | (None, 1) |
| 138 | }; | 144 | }; |
| 139 | let creation = self.dated_page(None)?; | 145 | let creation = self.dated_page(None, title)?; |
| 140 | let space = creation.space(); | 146 | let space = creation.space(); |
| 141 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; | 147 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; |
| 142 | if under.is_some() { | 148 | if under.is_some() { |
| ... | @@ -430,7 +436,7 @@ impl State { | ... | @@ -430,7 +436,7 @@ impl State { |
| 430 | &mut self, | 436 | &mut self, |
| 431 | root: std::path::PathBuf, | 437 | root: std::path::PathBuf, |
| 432 | ) -> Result<(), Box<dyn Error>> { | 438 | ) -> Result<(), Box<dyn Error>> { |
| 433 | let page = self.dated_page(None)?; | 439 | let page = self.dated_page(None, "")?; |
| 434 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); | 440 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); |
| 435 | let theme = self.notebook_theme.clone(); | 441 | let theme = self.notebook_theme.clone(); |
| 436 | self.load(move || { | 442 | self.load(move || { |
| ... | @@ -651,7 +657,7 @@ impl State { | ... | @@ -651,7 +657,7 @@ impl State { |
| 651 | /// section the change leaves open: a new or moved one, or the one shown before. | 657 | /// section the change leaves open: a new or moved one, or the one shown before. |
| 652 | pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) { | 658 | pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) { |
| 653 | let page = match change { | 659 | let page = match change { |
| 654 | Structure::NewSection { .. } => match self.dated_page(None) { | 660 | Structure::NewSection { .. } => match self.dated_page(None, "") { |
| 655 | Ok(page) => Some(page), | 661 | Ok(page) => Some(page), |
| 656 | Err(error) => { | 662 | Err(error) => { |
| 657 | return platform::alert( | 663 | return platform::alert( |
crates/snowbound/src/menus.rs+12-3| ... | @@ -74,6 +74,8 @@ pub enum Target { | ... | @@ -74,6 +74,8 @@ pub enum Target { |
| 74 | Closed(String), | 74 | Closed(String), |
| 75 | /// A server saved to reconnect to, by address. | 75 | /// A server saved to reconnect to, by address. |
| 76 | Server(String), | 76 | Server(String), |
| 77 | /// A page to add to the open section, by its title. | ||
| 78 | NewPage(String), | ||
| 77 | Command(commands::Id), | 79 | Command(commands::Id), |
| 78 | } | 80 | } |
| 79 | 81 | ||
| ... | @@ -531,7 +533,7 @@ impl State { | ... | @@ -531,7 +533,7 @@ impl State { |
| 531 | Target::Closed(_) | Target::Server(_) => { | 533 | Target::Closed(_) | Target::Server(_) => { |
| 532 | vec![item(Action::Delete, "Remove from Recent", false, false)] | 534 | vec![item(Action::Delete, "Remove from Recent", false, false)] |
| 533 | } | 535 | } |
| 534 | Target::Command(_) => Vec::new(), | 536 | Target::Command(_) | Target::NewPage(_) => Vec::new(), |
| 535 | } | 537 | } |
| 536 | } | 538 | } |
| 537 | 539 | ||
| ... | @@ -673,6 +675,7 @@ impl State { | ... | @@ -673,6 +675,7 @@ impl State { |
| 673 | self.choose(Choice::Command(id)); | 675 | self.choose(Choice::Command(id)); |
| 674 | None | 676 | None |
| 675 | } | 677 | } |
| 678 | (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }), | ||
| 676 | ( | 679 | ( |
| 677 | Target::Page { | 680 | Target::Page { |
| 678 | library, | 681 | library, |
| ... | @@ -911,8 +914,14 @@ impl State { | ... | @@ -911,8 +914,14 @@ impl State { |
| 911 | } | 914 | } |
| 912 | None | 915 | None |
| 913 | } | 916 | } |
| 914 | Action::NewPage => Some(Command::NewPage { under: None }), | 917 | Action::NewPage => Some(Command::NewPage { |
| 915 | Action::NewSubpage => Some(Command::NewPage { under: Some(space) }), | 918 | under: None, |
| 919 | title: String::new(), | ||
| 920 | }), | ||
| 921 | Action::NewSubpage => Some(Command::NewPage { | ||
| 922 | under: Some(space), | ||
| 923 | title: String::new(), | ||
| 924 | }), | ||
| 916 | Action::Theme => { | 925 | Action::Theme => { |
| 917 | let session = self.session.as_ref()?; | 926 | let session = self.session.as_ref()?; |
| 918 | let identity = session.section.page(space).ok()?.identity?; | 927 | let identity = session.section.page(space).ok()?.identity?; |
crates/snowbound/src/palette.rs+30| ... | @@ -31,6 +31,8 @@ struct Row { | ... | @@ -31,6 +31,8 @@ struct Row { |
| 31 | disabled: bool, | 31 | disabled: bool, |
| 32 | /// Repeats a row listed further on, as the recent ones do. | 32 | /// Repeats a row listed further on, as the recent ones do. |
| 33 | repeated: bool, | 33 | repeated: bool, |
| 34 | /// Offered only when nothing else matches the query. | ||
| 35 | fallback: bool, | ||
| 34 | target: Option<Target>, | 36 | target: Option<Target>, |
| 35 | } | 37 | } |
| 36 | 38 | ||
| ... | @@ -52,6 +54,7 @@ impl Row { | ... | @@ -52,6 +54,7 @@ impl Row { |
| 52 | heading: self.target.is_none(), | 54 | heading: self.target.is_none(), |
| 53 | separated: self.target.is_none(), | 55 | separated: self.target.is_none(), |
| 54 | repeated: self.repeated, | 56 | repeated: self.repeated, |
| 57 | fallback: self.fallback, | ||
| 55 | ..Item::default() | 58 | ..Item::default() |
| 56 | } | 59 | } |
| 57 | } | 60 | } |
| ... | @@ -155,6 +158,22 @@ impl State { | ... | @@ -155,6 +158,22 @@ impl State { |
| 155 | }) | 158 | }) |
| 156 | })); | 159 | })); |
| 157 | drop(index); | 160 | drop(index); |
| 161 | // Typed text matching nothing becomes the title of a new page here. | ||
| 162 | let typed = ui::popup::query(&self.ui, id()) | ||
| 163 | .map(str::trim) | ||
| 164 | .unwrap_or_default(); | ||
| 165 | if !typed.is_empty() && !typed.starts_with(COMMANDS) { | ||
| 166 | places.push(Row { | ||
| 167 | text: format!("Create Page “{typed}”"), | ||
| 168 | icon: Some(art::NEW_PAGE), | ||
| 169 | disabled: !self | ||
| 170 | .status(&Choice::Command(commands::Id::NewPage), &format) | ||
| 171 | .enabled, | ||
| 172 | fallback: true, | ||
| 173 | target: Some(Target::NewPage(typed.to_owned())), | ||
| 174 | ..Row::default() | ||
| 175 | }); | ||
| 176 | } | ||
| 158 | let mut items = | 177 | let mut items = |
| 159 | [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); | 178 | [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); |
| 160 | // The latest page or section starts highlighted, so Enter goes back to it. | 179 | // The latest page or section starts highlighted, so Enter goes back to it. |
| ... | @@ -190,6 +209,17 @@ impl State { | ... | @@ -190,6 +209,17 @@ impl State { |
| 190 | }; | 209 | }; |
| 191 | // Run or Open leads, as Enter on the row does. | 210 | // Run or Open leads, as Enter on the row does. |
| 192 | let (keys, first) = match target { | 211 | let (keys, first) = match target { |
| 212 | Target::NewPage(_) => ( | ||
| 213 | String::new(), | ||
| 214 | ( | ||
| 215 | Action::Run, | ||
| 216 | Item { | ||
| 217 | text: "Run", | ||
| 218 | icon: Some(art::NEW_PAGE), | ||
| 219 | ..Item::default() | ||
| 220 | }, | ||
| 221 | ), | ||
| 222 | ), | ||
| 193 | Target::Command(id) => ( | 223 | Target::Command(id) => ( |
| 194 | commands::shortcut(id), | 224 | commands::shortcut(id), |
| 195 | ( | 225 | ( |
crates/snowbound/src/undo.rs+1-1| ... | @@ -1046,7 +1046,7 @@ impl State { | ... | @@ -1046,7 +1046,7 @@ impl State { |
| 1046 | replica: Arc::clone(session.section.replica()), | 1046 | replica: Arc::clone(session.section.replica()), |
| 1047 | library: Arc::clone(&session.library), | 1047 | library: Arc::clone(&session.library), |
| 1048 | author: self.author.clone(), | 1048 | author: self.author.clone(), |
| 1049 | fresh: self.dated_page(None)?, | 1049 | fresh: self.dated_page(None, "")?, |
| 1050 | }; | 1050 | }; |
| 1051 | let shown = session.space; | 1051 | let shown = session.space; |
| 1052 | let proxy = self.proxy.clone(); | 1052 | let proxy = self.proxy.clone(); |
crates/snowbound/tests/replay.rs+30| ... | @@ -196,6 +196,36 @@ fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() { | ... | @@ -196,6 +196,36 @@ fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() { |
| 196 | ); | 196 | ); |
| 197 | } | 197 | } |
| 198 | 198 | ||
| 199 | #[test] | ||
| 200 | fn the_palette_makes_a_page_of_a_query_matching_nothing() { | ||
| 201 | let scratch = Scratch::new("palette-create"); | ||
| 202 | let notebook = | ||
| 203 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | ||
| 204 | let steps = [ | ||
| 205 | "modifiers command", | ||
| 206 | "key p", | ||
| 207 | "modifiers", | ||
| 208 | "settle", | ||
| 209 | "type Zebra crossings", | ||
| 210 | "settle", | ||
| 211 | "accessibility offered", | ||
| 212 | "key Enter", | ||
| 213 | "settle", | ||
| 214 | "accessibility made", | ||
| 215 | ]; | ||
| 216 | let [offered, made] = replay(&scratch, Some(&notebook), &steps) | ||
| 217 | .try_into() | ||
| 218 | .unwrap(); | ||
| 219 | assert!( | ||
| 220 | offered.contains(r#""Create Page “Zebra crossings”""#), | ||
| 221 | "{offered}" | ||
| 222 | ); | ||
| 223 | assert!( | ||
| 224 | page_tabs(&made).contains(&"*Zebra crossings".to_owned()), | ||
| 225 | "{made}" | ||
| 226 | ); | ||
| 227 | } | ||
| 228 | |||
| 199 | /// The page tabs listed in `tree`, the selected one marked with `*`. | 229 | /// The page tabs listed in `tree`, the selected one marked with `*`. |
| 200 | fn page_tabs(tree: &str) -> Vec<String> { | 230 | fn page_tabs(tree: &str) -> Vec<String> { |
| 201 | tree.lines() | 231 | tree.lines() |
crates/ui/src/popup.rs+10-3| ... | @@ -77,6 +77,8 @@ pub struct Item<'a> { | ... | @@ -77,6 +77,8 @@ pub struct Item<'a> { |
| 77 | pub heading: bool, | 77 | pub heading: bool, |
| 78 | /// Repeats an item listed further on, so shows only while unfiltered. | 78 | /// Repeats an item listed further on, so shows only while unfiltered. |
| 79 | pub repeated: bool, | 79 | pub repeated: bool, |
| 80 | /// Offered only when a query leaves nothing else, which it never filters out. | ||
| 81 | pub fallback: bool, | ||
| 80 | /// A tag after the text, as `badge` draws one. | 82 | /// A tag after the text, as `badge` draws one. |
| 81 | pub badge: Option<&'a str>, | 83 | pub badge: Option<&'a str>, |
| 82 | } | 84 | } |
| ... | @@ -1509,15 +1511,17 @@ impl<'a> Matches<'a> { | ... | @@ -1509,15 +1511,17 @@ impl<'a> Matches<'a> { |
| 1509 | Normalization::Smart, | 1511 | Normalization::Smart, |
| 1510 | AtomKind::Fuzzy, | 1512 | AtomKind::Fuzzy, |
| 1511 | ); | 1513 | ); |
| 1512 | let order: Vec<usize> = if pattern.atoms.is_empty() { | 1514 | let mut order: Vec<usize> = if pattern.atoms.is_empty() { |
| 1513 | (0..items.len()).collect() | 1515 | (0..items.len()) |
| 1516 | .filter(|index| !items[*index].fallback) | ||
| 1517 | .collect() | ||
| 1514 | } else { | 1518 | } else { |
| 1515 | let mut chars = Vec::new(); | 1519 | let mut chars = Vec::new(); |
| 1516 | let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| { | 1520 | let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| { |
| 1517 | items | 1521 | items |
| 1518 | .iter() | 1522 | .iter() |
| 1519 | .enumerate() | 1523 | .enumerate() |
| 1520 | .filter(|(_, item)| !item.heading && !item.repeated) | 1524 | .filter(|(_, item)| !item.heading && !item.repeated && !item.fallback) |
| 1521 | .filter_map(|(index, item)| { | 1525 | .filter_map(|(index, item)| { |
| 1522 | let text = Utf32Str::new(item.text, &mut chars); | 1526 | let text = Utf32Str::new(item.text, &mut chars); |
| 1523 | Some((pattern.score(text, matcher)?, index)) | 1527 | Some((pattern.score(text, matcher)?, index)) |
| ... | @@ -1528,6 +1532,9 @@ impl<'a> Matches<'a> { | ... | @@ -1528,6 +1532,9 @@ impl<'a> Matches<'a> { |
| 1528 | ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); | 1532 | ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); |
| 1529 | ranked.into_iter().map(|(_, index)| index).collect() | 1533 | ranked.into_iter().map(|(_, index)| index).collect() |
| 1530 | }; | 1534 | }; |
| 1535 | if order.is_empty() && !pattern.atoms.is_empty() { | ||
| 1536 | order.extend((0..items.len()).filter(|index| items[*index].fallback)); | ||
| 1537 | } | ||
| 1531 | let mut rows = vec![None; items.len()]; | 1538 | let mut rows = vec![None; items.len()]; |
| 1532 | for (row, index) in order.iter().enumerate() { | 1539 | for (row, index) in order.iter().enumerate() { |
| 1533 | rows[*index] = Some(row); | 1540 | rows[*index] = Some(row); |
crates/ui/src/tests.rs+11| ... | @@ -2274,6 +2274,17 @@ fn fuzzy_matches_rank_whole_words_first_and_ties_keep_their_order() { | ... | @@ -2274,6 +2274,17 @@ fn fuzzy_matches_rank_whole_words_first_and_ties_keep_their_order() { |
| 2274 | assert!(ranked(&fonts, "Cafe").is_empty()); | 2274 | assert!(ranked(&fonts, "Cafe").is_empty()); |
| 2275 | } | 2275 | } |
| 2276 | 2276 | ||
| 2277 | #[test] | ||
| 2278 | fn a_fallback_shows_only_when_a_query_leaves_nothing_else() { | ||
| 2279 | let mut items = fonts(); | ||
| 2280 | let last = items.len() - 1; | ||
| 2281 | items[last].fallback = true; | ||
| 2282 | let order = |query| popup::Matches::new(&items, query, 9.0).order; | ||
| 2283 | assert!(!order("").contains(&last), "unfiltered"); | ||
| 2284 | assert!(!order("Arial").contains(&last), "others match"); | ||
| 2285 | assert_eq!(order("Cafe"), [last], "nothing else matches"); | ||
| 2286 | } | ||
| 2287 | |||
| 2277 | #[test] | 2288 | #[test] |
| 2278 | fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { | 2289 | fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { |
| 2279 | let fonts = fonts(); | 2290 | let fonts = fonts(); |