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 {
14891489 },
14901490 )
14911491 }
1492 Id::NewPage => Work::NewPage { under: None },
1492 Id::NewPage => Work::NewPage {
1493 under: None,
1494 title: String::new(),
1495 },
14931496 Id::NewSubpage => Work::NewPage {
14941497 under: self.session.as_ref().map(|session| session.space),
1498 title: String::new(),
14951499 },
14961500 Id::PageVersions => {
14971501 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 {
693693 CloseNotebook(Arc<Library>),
694694 /// Changes a notebook's sections and groups.
695695 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.
698698 NewPage {
699699 under: Option<ExGuid>,
700 title: String,
700701 },
701702 /// Deletes pages of the open section to the notebook's recycle bin.
702703 DeletePages(Vec<ExGuid>),
......@@ -3414,8 +3415,8 @@ impl State {
34143415 self.restructure(library, change);
34153416 }
34163417 // 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)?;
34193420 self.edited(Vec::new());
34203421 }
34213422 Command::DeletePages(pages) => {
crates/snowbound/src/manage.rs+15-9
......@@ -101,13 +101,14 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String {
101101}
102102
103103impl 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.
106106 pub(crate) fn dated_page(
107107 &self,
108108 before: Option<ExGuid>,
109 title: &str,
109110 ) -> 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(
111112 &self.view.editor.default_font.face,
112113 self.view.editor.default_font.color,
113114 )?;
......@@ -115,9 +116,14 @@ impl State {
115116 Ok(creation.dated(&date, &time)?)
116117 }
117118
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>> {
121127 self.persist()?;
122128 let session = self.session.as_ref().ok_or("No section is open")?;
123129 // A subpage follows its page and the subpages already under it.
......@@ -136,7 +142,7 @@ impl State {
136142 } else {
137143 (None, 1)
138144 };
139 let creation = self.dated_page(None)?;
145 let creation = self.dated_page(None, title)?;
140146 let space = creation.space();
141147 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
142148 if under.is_some() {
......@@ -430,7 +436,7 @@ impl State {
430436 &mut self,
431437 root: std::path::PathBuf,
432438 ) -> Result<(), Box<dyn Error>> {
433 let page = self.dated_page(None)?;
439 let page = self.dated_page(None, "")?;
434440 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
435441 let theme = self.notebook_theme.clone();
436442 self.load(move || {
......@@ -651,7 +657,7 @@ impl State {
651657 /// section the change leaves open: a new or moved one, or the one shown before.
652658 pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) {
653659 let page = match change {
654 Structure::NewSection { .. } => match self.dated_page(None) {
660 Structure::NewSection { .. } => match self.dated_page(None, "") {
655661 Ok(page) => Some(page),
656662 Err(error) => {
657663 return platform::alert(
crates/snowbound/src/menus.rs+12-3
......@@ -74,6 +74,8 @@ pub enum Target {
7474 Closed(String),
7575 /// A server saved to reconnect to, by address.
7676 Server(String),
77 /// A page to add to the open section, by its title.
78 NewPage(String),
7779 Command(commands::Id),
7880}
7981
......@@ -531,7 +533,7 @@ impl State {
531533 Target::Closed(_) | Target::Server(_) => {
532534 vec![item(Action::Delete, "Remove from Recent", false, false)]
533535 }
534 Target::Command(_) => Vec::new(),
536 Target::Command(_) | Target::NewPage(_) => Vec::new(),
535537 }
536538 }
537539
......@@ -673,6 +675,7 @@ impl State {
673675 self.choose(Choice::Command(id));
674676 None
675677 }
678 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),
676679 (
677680 Target::Page {
678681 library,
......@@ -911,8 +914,14 @@ impl State {
911914 }
912915 None
913916 }
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 }),
916925 Action::Theme => {
917926 let session = self.session.as_ref()?;
918927 let identity = session.section.page(space).ok()?.identity?;
crates/snowbound/src/palette.rs+30
......@@ -31,6 +31,8 @@ struct Row {
3131 disabled: bool,
3232 /// Repeats a row listed further on, as the recent ones do.
3333 repeated: bool,
34 /// Offered only when nothing else matches the query.
35 fallback: bool,
3436 target: Option<Target>,
3537}
3638
......@@ -52,6 +54,7 @@ impl Row {
5254 heading: self.target.is_none(),
5355 separated: self.target.is_none(),
5456 repeated: self.repeated,
57 fallback: self.fallback,
5558 ..Item::default()
5659 }
5760 }
......@@ -155,6 +158,22 @@ impl State {
155158 })
156159 }));
157160 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 }
158177 let mut items =
159178 [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>());
160179 // The latest page or section starts highlighted, so Enter goes back to it.
......@@ -190,6 +209,17 @@ impl State {
190209 };
191210 // Run or Open leads, as Enter on the row does.
192211 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 ),
193223 Target::Command(id) => (
194224 commands::shortcut(id),
195225 (
crates/snowbound/src/undo.rs+1-1
......@@ -1046,7 +1046,7 @@ impl State {
10461046 replica: Arc::clone(session.section.replica()),
10471047 library: Arc::clone(&session.library),
10481048 author: self.author.clone(),
1049 fresh: self.dated_page(None)?,
1049 fresh: self.dated_page(None, "")?,
10501050 };
10511051 let shown = session.space;
10521052 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() {
196196 );
197197}
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
199229/// The page tabs listed in `tree`, the selected one marked with `*`.
200230fn page_tabs(tree: &str) -> Vec<String> {
201231 tree.lines()
crates/ui/src/popup.rs+10-3
......@@ -77,6 +77,8 @@ pub struct Item<'a> {
7777 pub heading: bool,
7878 /// Repeats an item listed further on, so shows only while unfiltered.
7979 pub repeated: bool,
80 /// Offered only when a query leaves nothing else, which it never filters out.
81 pub fallback: bool,
8082 /// A tag after the text, as `badge` draws one.
8183 pub badge: Option<&'a str>,
8284}
......@@ -1509,15 +1511,17 @@ impl<'a> Matches<'a> {
15091511 Normalization::Smart,
15101512 AtomKind::Fuzzy,
15111513 );
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()
15141518 } else {
15151519 let mut chars = Vec::new();
15161520 let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| {
15171521 items
15181522 .iter()
15191523 .enumerate()
1520 .filter(|(_, item)| !item.heading && !item.repeated)
1524 .filter(|(_, item)| !item.heading && !item.repeated && !item.fallback)
15211525 .filter_map(|(index, item)| {
15221526 let text = Utf32Str::new(item.text, &mut chars);
15231527 Some((pattern.score(text, matcher)?, index))
......@@ -1528,6 +1532,9 @@ impl<'a> Matches<'a> {
15281532 ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
15291533 ranked.into_iter().map(|(_, index)| index).collect()
15301534 };
1535 if order.is_empty() && !pattern.atoms.is_empty() {
1536 order.extend((0..items.len()).filter(|index| items[*index].fallback));
1537 }
15311538 let mut rows = vec![None; items.len()];
15321539 for (row, index) in order.iter().enumerate() {
15331540 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() {
22742274 assert!(ranked(&fonts, "Cafe").is_empty());
22752275}
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
22772288#[test]
22782289fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {
22792290 let fonts = fonts();