authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:49:03-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 01:50:33-07:00
loge173ad0c64864c7d5c3c1cfb7cc5e5c6fa931707
tree7bf47642140c5a79dc091e1caaced5beaa9ff6f4
parentc09590a9c59a6645ff9cbe96e0037000405a8bf7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: the palette offers Create Page “query” when nothing matches what was typed

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.5

9 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 its696 /// 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}
102102
103impl State {103impl State {
104 /// A page OneNote 2010 would create now: titled in the Default font's face, with its date104 /// 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 }
117118
118 /// Adds a page at the end of the open section, or a subpage of page `under`, and opens119 /// 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}
7981
...@@ -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 }
537539
...@@ -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 None676 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 None915 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}
3638
...@@ -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}
198198
199#[test]
200fn 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 `*`.
200fn page_tabs(tree: &str) -> Vec<String> {230fn 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 items1521 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}
22762276
2277#[test]
2278fn 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]
2278fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {2289fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {
2279 let fonts = fonts();2290 let fonts = fonts();