| 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 | 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 | 1496 | Id::NewSubpage => Work::NewPage { |
| 1494 | 1497 | under: self.session.as_ref().map(|session| session.space), |
| 1498 | title: String::new(), | |
| 1495 | 1499 | }, |
| 1496 | 1500 | Id::PageVersions => { |
| 1497 | 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 | 693 | CloseNotebook(Arc<Library>), |
| 694 | 694 | /// Changes a notebook's sections and groups. |
| 695 | 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 | |
| 697 | /// title. | |
| 696 | /// Adds a page at the end of the open section, or a subpage of a page, titled `title` | |
| 697 | /// or untitled, and edits its title. | |
| 698 | 698 | NewPage { |
| 699 | 699 | under: Option<ExGuid>, |
| 700 | title: String, | |
| 700 | 701 | }, |
| 701 | 702 | /// Deletes pages of the open section to the notebook's recycle bin. |
| 702 | 703 | DeletePages(Vec<ExGuid>), |
| ... | ... | @@ -3414,8 +3415,8 @@ impl State { |
| 3414 | 3415 | self.restructure(library, change); |
| 3415 | 3416 | } |
| 3416 | 3417 | // The index follows the section's page list. |
| 3417 | Command::NewPage { under } => { | |
| 3418 | self.new_page(under)?; | |
| 3418 | Command::NewPage { under, title } => { | |
| 3419 | self.new_page(under, &title)?; | |
| 3419 | 3420 | self.edited(Vec::new()); |
| 3420 | 3421 | } |
| 3421 | 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 | 101 | } |
| 102 | 102 | |
| 103 | 103 | impl State { |
| 104 | /// A page OneNote 2010 would create now: titled in the Default font's face, with its date | |
| 105 | /// and time. | |
| 104 | /// A page OneNote 2010 would create now, titled `title` in the Default font's face, with | |
| 105 | /// its date and time. | |
| 106 | 106 | pub(crate) fn dated_page( |
| 107 | 107 | &self, |
| 108 | 108 | before: Option<ExGuid>, |
| 109 | title: &str, | |
| 109 | 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 | 112 | &self.view.editor.default_font.face, |
| 112 | 113 | self.view.editor.default_font.color, |
| 113 | 114 | )?; |
| ... | ... | @@ -115,9 +116,14 @@ impl State { |
| 115 | 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 | /// it with its title ready for typing, as OneNote's New Page and New Subpage do. | |
| 120 | pub(crate) fn new_page(&mut self, under: Option<ExGuid>) -> Result<(), Box<dyn Error>> { | |
| 119 | /// Adds a page titled `title` at the end of the open section, or a subpage of page | |
| 120 | /// `under`, and opens it with its title ready for typing, as OneNote's New Page and New | |
| 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 | 127 | self.persist()?; |
| 122 | 128 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 123 | 129 | // A subpage follows its page and the subpages already under it. |
| ... | ... | @@ -136,7 +142,7 @@ impl State { |
| 136 | 142 | } else { |
| 137 | 143 | (None, 1) |
| 138 | 144 | }; |
| 139 | let creation = self.dated_page(None)?; | |
| 145 | let creation = self.dated_page(None, title)?; | |
| 140 | 146 | let space = creation.space(); |
| 141 | 147 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; |
| 142 | 148 | if under.is_some() { |
| ... | ... | @@ -430,7 +436,7 @@ impl State { |
| 430 | 436 | &mut self, |
| 431 | 437 | root: std::path::PathBuf, |
| 432 | 438 | ) -> Result<(), Box<dyn Error>> { |
| 433 | let page = self.dated_page(None)?; | |
| 439 | let page = self.dated_page(None, "")?; | |
| 434 | 440 | let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); |
| 435 | 441 | let theme = self.notebook_theme.clone(); |
| 436 | 442 | self.load(move || { |
| ... | ... | @@ -651,7 +657,7 @@ impl State { |
| 651 | 657 | /// section the change leaves open: a new or moved one, or the one shown before. |
| 652 | 658 | pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) { |
| 653 | 659 | let page = match change { |
| 654 | Structure::NewSection { .. } => match self.dated_page(None) { | |
| 660 | Structure::NewSection { .. } => match self.dated_page(None, "") { | |
| 655 | 661 | Ok(page) => Some(page), |
| 656 | 662 | Err(error) => { |
| 657 | 663 | return platform::alert( |
crates/snowbound/src/menus.rs+12-3| ... | ... | @@ -74,6 +74,8 @@ pub enum Target { |
| 74 | 74 | Closed(String), |
| 75 | 75 | /// A server saved to reconnect to, by address. |
| 76 | 76 | Server(String), |
| 77 | /// A page to add to the open section, by its title. | |
| 78 | NewPage(String), | |
| 77 | 79 | Command(commands::Id), |
| 78 | 80 | } |
| 79 | 81 | |
| ... | ... | @@ -531,7 +533,7 @@ impl State { |
| 531 | 533 | Target::Closed(_) | Target::Server(_) => { |
| 532 | 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 | 675 | self.choose(Choice::Command(id)); |
| 674 | 676 | None |
| 675 | 677 | } |
| 678 | (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }), | |
| 676 | 679 | ( |
| 677 | 680 | Target::Page { |
| 678 | 681 | library, |
| ... | ... | @@ -911,8 +914,14 @@ impl State { |
| 911 | 914 | } |
| 912 | 915 | None |
| 913 | 916 | } |
| 914 | Action::NewPage => Some(Command::NewPage { under: None }), | |
| 915 | Action::NewSubpage => Some(Command::NewPage { under: Some(space) }), | |
| 917 | Action::NewPage => Some(Command::NewPage { | |
| 918 | under: None, | |
| 919 | title: String::new(), | |
| 920 | }), | |
| 921 | Action::NewSubpage => Some(Command::NewPage { | |
| 922 | under: Some(space), | |
| 923 | title: String::new(), | |
| 924 | }), | |
| 916 | 925 | Action::Theme => { |
| 917 | 926 | let session = self.session.as_ref()?; |
| 918 | 927 | let identity = session.section.page(space).ok()?.identity?; |
crates/snowbound/src/palette.rs+30| ... | ... | @@ -31,6 +31,8 @@ struct Row { |
| 31 | 31 | disabled: bool, |
| 32 | 32 | /// Repeats a row listed further on, as the recent ones do. |
| 33 | 33 | repeated: bool, |
| 34 | /// Offered only when nothing else matches the query. | |
| 35 | fallback: bool, | |
| 34 | 36 | target: Option<Target>, |
| 35 | 37 | } |
| 36 | 38 | |
| ... | ... | @@ -52,6 +54,7 @@ impl Row { |
| 52 | 54 | heading: self.target.is_none(), |
| 53 | 55 | separated: self.target.is_none(), |
| 54 | 56 | repeated: self.repeated, |
| 57 | fallback: self.fallback, | |
| 55 | 58 | ..Item::default() |
| 56 | 59 | } |
| 57 | 60 | } |
| ... | ... | @@ -155,6 +158,22 @@ impl State { |
| 155 | 158 | }) |
| 156 | 159 | })); |
| 157 | 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 | 177 | let mut items = |
| 159 | 178 | [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>()); |
| 160 | 179 | // The latest page or section starts highlighted, so Enter goes back to it. |
| ... | ... | @@ -190,6 +209,17 @@ impl State { |
| 190 | 209 | }; |
| 191 | 210 | // Run or Open leads, as Enter on the row does. |
| 192 | 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 | 223 | Target::Command(id) => ( |
| 194 | 224 | commands::shortcut(id), |
| 195 | 225 | ( |
crates/snowbound/src/undo.rs+1-1| ... | ... | @@ -1046,7 +1046,7 @@ impl State { |
| 1046 | 1046 | replica: Arc::clone(session.section.replica()), |
| 1047 | 1047 | library: Arc::clone(&session.library), |
| 1048 | 1048 | author: self.author.clone(), |
| 1049 | fresh: self.dated_page(None)?, | |
| 1049 | fresh: self.dated_page(None, "")?, | |
| 1050 | 1050 | }; |
| 1051 | 1051 | let shown = session.space; |
| 1052 | 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 | 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 | 229 | /// The page tabs listed in `tree`, the selected one marked with `*`. |
| 200 | 230 | fn page_tabs(tree: &str) -> Vec<String> { |
| 201 | 231 | tree.lines() |
crates/ui/src/popup.rs+10-3| ... | ... | @@ -77,6 +77,8 @@ pub struct Item<'a> { |
| 77 | 77 | pub heading: bool, |
| 78 | 78 | /// Repeats an item listed further on, so shows only while unfiltered. |
| 79 | 79 | pub repeated: bool, |
| 80 | /// Offered only when a query leaves nothing else, which it never filters out. | |
| 81 | pub fallback: bool, | |
| 80 | 82 | /// A tag after the text, as `badge` draws one. |
| 81 | 83 | pub badge: Option<&'a str>, |
| 82 | 84 | } |
| ... | ... | @@ -1509,15 +1511,17 @@ impl<'a> Matches<'a> { |
| 1509 | 1511 | Normalization::Smart, |
| 1510 | 1512 | AtomKind::Fuzzy, |
| 1511 | 1513 | ); |
| 1512 | let order: Vec<usize> = if pattern.atoms.is_empty() { | |
| 1513 | (0..items.len()).collect() | |
| 1514 | let mut order: Vec<usize> = if pattern.atoms.is_empty() { | |
| 1515 | (0..items.len()) | |
| 1516 | .filter(|index| !items[*index].fallback) | |
| 1517 | .collect() | |
| 1514 | 1518 | } else { |
| 1515 | 1519 | let mut chars = Vec::new(); |
| 1516 | 1520 | let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| { |
| 1517 | 1521 | items |
| 1518 | 1522 | .iter() |
| 1519 | 1523 | .enumerate() |
| 1520 | .filter(|(_, item)| !item.heading && !item.repeated) | |
| 1524 | .filter(|(_, item)| !item.heading && !item.repeated && !item.fallback) | |
| 1521 | 1525 | .filter_map(|(index, item)| { |
| 1522 | 1526 | let text = Utf32Str::new(item.text, &mut chars); |
| 1523 | 1527 | Some((pattern.score(text, matcher)?, index)) |
| ... | ... | @@ -1528,6 +1532,9 @@ impl<'a> Matches<'a> { |
| 1528 | 1532 | ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); |
| 1529 | 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 | 1538 | let mut rows = vec![None; items.len()]; |
| 1532 | 1539 | for (row, index) in order.iter().enumerate() { |
| 1533 | 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 | 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 | 2288 | #[test] |
| 2278 | 2289 | fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { |
| 2279 | 2290 | let fonts = fonts(); |