diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index fc62ff27f030456b6b3afdd61db36866bcee7e71..461ccd3a2612702f5f061174126ccecb32aefbb8 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1489,9 +1489,13 @@ impl State { }, ) } - Id::NewPage => Work::NewPage { under: None }, + Id::NewPage => Work::NewPage { + under: None, + title: String::new(), + }, Id::NewSubpage => Work::NewPage { under: self.session.as_ref().map(|session| session.space), + title: String::new(), }, Id::PageVersions => { let session = self.session.as_ref().ok_or("No notebook is open")?; diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 34ad2534187cc2d2c6dfec016661dc09a3d4750a..54771122beb4f0d7f996990bc4842e69f816f318 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -693,10 +693,11 @@ enum Command { CloseNotebook(Arc), /// Changes a notebook's sections and groups. Structure(Arc, manage::Structure), - /// Adds a page at the end of the open section, or a subpage of a page, and edits its - /// title. + /// Adds a page at the end of the open section, or a subpage of a page, titled `title` + /// or untitled, and edits its title. NewPage { under: Option, + title: String, }, /// Deletes pages of the open section to the notebook's recycle bin. DeletePages(Vec), @@ -3414,8 +3415,8 @@ impl State { self.restructure(library, change); } // The index follows the section's page list. - Command::NewPage { under } => { - self.new_page(under)?; + Command::NewPage { under, title } => { + self.new_page(under, &title)?; self.edited(Vec::new()); } Command::DeletePages(pages) => { diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 61d8eae2d3c55b88d68784e96b00c2e6b09bd6e7..74947353eeb451ad704d4f1ccd56873d9e3af06e 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -101,13 +101,14 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String { } impl State { - /// A page OneNote 2010 would create now: titled in the Default font's face, with its date - /// and time. + /// A page OneNote 2010 would create now, titled `title` in the Default font's face, with + /// its date and time. pub(crate) fn dated_page( &self, before: Option, + title: &str, ) -> Result> { - let creation = PageCreation::new(before, Some(""), &self.author)?.titled_in( + let creation = PageCreation::new(before, Some(title), &self.author)?.titled_in( &self.view.editor.default_font.face, self.view.editor.default_font.color, )?; @@ -115,9 +116,14 @@ impl State { Ok(creation.dated(&date, &time)?) } - /// Adds a page at the end of the open section, or a subpage of page `under`, and opens - /// it with its title ready for typing, as OneNote's New Page and New Subpage do. - pub(crate) fn new_page(&mut self, under: Option) -> Result<(), Box> { + /// Adds a page titled `title` at the end of the open section, or a subpage of page + /// `under`, and opens it with its title ready for typing, as OneNote's New Page and New + /// Subpage do. + pub(crate) fn new_page( + &mut self, + under: Option, + title: &str, + ) -> Result<(), Box> { self.persist()?; let session = self.session.as_ref().ok_or("No section is open")?; // A subpage follows its page and the subpages already under it. @@ -136,7 +142,7 @@ impl State { } else { (None, 1) }; - let creation = self.dated_page(None)?; + let creation = self.dated_page(None, title)?; let space = creation.space(); let mut ops = vec![Op::Section(SectionOp::Create(creation))]; if under.is_some() { @@ -430,7 +436,7 @@ impl State { &mut self, root: std::path::PathBuf, ) -> Result<(), Box> { - let page = self.dated_page(None)?; + let page = self.dated_page(None, "")?; let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); let theme = self.notebook_theme.clone(); self.load(move || { @@ -651,7 +657,7 @@ impl State { /// section the change leaves open: a new or moved one, or the one shown before. pub(crate) fn restructure(&mut self, library: Arc, change: Structure) { let page = match change { - Structure::NewSection { .. } => match self.dated_page(None) { + Structure::NewSection { .. } => match self.dated_page(None, "") { Ok(page) => Some(page), Err(error) => { return platform::alert( diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index e38306b17fa26778dc99f1977d42c2fb7f0983c3..2a7be31cec4d65b9ec1d573f5328f02c23068ce7 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -74,6 +74,8 @@ pub enum Target { Closed(String), /// A server saved to reconnect to, by address. Server(String), + /// A page to add to the open section, by its title. + NewPage(String), Command(commands::Id), } @@ -531,7 +533,7 @@ impl State { Target::Closed(_) | Target::Server(_) => { vec![item(Action::Delete, "Remove from Recent", false, false)] } - Target::Command(_) => Vec::new(), + Target::Command(_) | Target::NewPage(_) => Vec::new(), } } @@ -673,6 +675,7 @@ impl State { self.choose(Choice::Command(id)); None } + (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }), ( Target::Page { library, @@ -911,8 +914,14 @@ impl State { } None } - Action::NewPage => Some(Command::NewPage { under: None }), - Action::NewSubpage => Some(Command::NewPage { under: Some(space) }), + Action::NewPage => Some(Command::NewPage { + under: None, + title: String::new(), + }), + Action::NewSubpage => Some(Command::NewPage { + under: Some(space), + title: String::new(), + }), Action::Theme => { let session = self.session.as_ref()?; let identity = session.section.page(space).ok()?.identity?; diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index 9102a4b9c519c4f959e5e594fc11260129f163fe..7eb3d5e4db1103fce94e1bc7b9e6bcbb0e8657c6 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -31,6 +31,8 @@ struct Row { disabled: bool, /// Repeats a row listed further on, as the recent ones do. repeated: bool, + /// Offered only when nothing else matches the query. + fallback: bool, target: Option, } @@ -52,6 +54,7 @@ impl Row { heading: self.target.is_none(), separated: self.target.is_none(), repeated: self.repeated, + fallback: self.fallback, ..Item::default() } } @@ -155,6 +158,22 @@ impl State { }) })); drop(index); + // Typed text matching nothing becomes the title of a new page here. + let typed = ui::popup::query(&self.ui, id()) + .map(str::trim) + .unwrap_or_default(); + if !typed.is_empty() && !typed.starts_with(COMMANDS) { + places.push(Row { + text: format!("Create Page “{typed}”"), + icon: Some(art::NEW_PAGE), + disabled: !self + .status(&Choice::Command(commands::Id::NewPage), &format) + .enabled, + fallback: true, + target: Some(Target::NewPage(typed.to_owned())), + ..Row::default() + }); + } let mut items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::>()); // The latest page or section starts highlighted, so Enter goes back to it. @@ -190,6 +209,17 @@ impl State { }; // Run or Open leads, as Enter on the row does. let (keys, first) = match target { + Target::NewPage(_) => ( + String::new(), + ( + Action::Run, + Item { + text: "Run", + icon: Some(art::NEW_PAGE), + ..Item::default() + }, + ), + ), Target::Command(id) => ( commands::shortcut(id), ( diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index e33127fcffb600d04f7423fc803c5df9e2dc2611..7c54603f2ee5b61cacde8eafa8d35f137cceedf1 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -1046,7 +1046,7 @@ impl State { replica: Arc::clone(session.section.replica()), library: Arc::clone(&session.library), author: self.author.clone(), - fresh: self.dated_page(None)?, + fresh: self.dated_page(None, "")?, }; let shown = session.space; let proxy = self.proxy.clone(); diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index e753863914c8465d54285a5a7743ecab6331173c..d132f1ea2c772ea49594b822ed315b4f9a98e534 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -196,6 +196,36 @@ fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() { ); } +#[test] +fn the_palette_makes_a_page_of_a_query_matching_nothing() { + let scratch = Scratch::new("palette-create"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let steps = [ + "modifiers command", + "key p", + "modifiers", + "settle", + "type Zebra crossings", + "settle", + "accessibility offered", + "key Enter", + "settle", + "accessibility made", + ]; + let [offered, made] = replay(&scratch, Some(¬ebook), &steps) + .try_into() + .unwrap(); + assert!( + offered.contains(r#""Create Page “Zebra crossings”""#), + "{offered}" + ); + assert!( + page_tabs(&made).contains(&"*Zebra crossings".to_owned()), + "{made}" + ); +} + /// The page tabs listed in `tree`, the selected one marked with `*`. fn page_tabs(tree: &str) -> Vec { tree.lines() diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 9877df46429111dc25210cb1eaacf449c6c10fd6..5761bb746725c2847967ff3cccd20dfd9b38038b 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -77,6 +77,8 @@ pub struct Item<'a> { pub heading: bool, /// Repeats an item listed further on, so shows only while unfiltered. pub repeated: bool, + /// Offered only when a query leaves nothing else, which it never filters out. + pub fallback: bool, /// A tag after the text, as `badge` draws one. pub badge: Option<&'a str>, } @@ -1509,15 +1511,17 @@ impl<'a> Matches<'a> { Normalization::Smart, AtomKind::Fuzzy, ); - let order: Vec = if pattern.atoms.is_empty() { - (0..items.len()).collect() + let mut order: Vec = if pattern.atoms.is_empty() { + (0..items.len()) + .filter(|index| !items[*index].fallback) + .collect() } else { let mut chars = Vec::new(); let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| { items .iter() .enumerate() - .filter(|(_, item)| !item.heading && !item.repeated) + .filter(|(_, item)| !item.heading && !item.repeated && !item.fallback) .filter_map(|(index, item)| { let text = Utf32Str::new(item.text, &mut chars); Some((pattern.score(text, matcher)?, index)) @@ -1528,6 +1532,9 @@ impl<'a> Matches<'a> { ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); ranked.into_iter().map(|(_, index)| index).collect() }; + if order.is_empty() && !pattern.atoms.is_empty() { + order.extend((0..items.len()).filter(|index| items[*index].fallback)); + } let mut rows = vec![None; items.len()]; for (row, index) in order.iter().enumerate() { rows[*index] = Some(row); diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index dc04cd03df62ac4d95c447a3ef6de1f9a09ef805..c6fe77fc019438f20938cf220ed57ad705efc26b 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -2274,6 +2274,17 @@ fn fuzzy_matches_rank_whole_words_first_and_ties_keep_their_order() { assert!(ranked(&fonts, "Cafe").is_empty()); } +#[test] +fn a_fallback_shows_only_when_a_query_leaves_nothing_else() { + let mut items = fonts(); + let last = items.len() - 1; + items[last].fallback = true; + let order = |query| popup::Matches::new(&items, query, 9.0).order; + assert!(!order("").contains(&last), "unfiltered"); + assert!(!order("Arial").contains(&last), "others match"); + assert_eq!(order("Cafe"), [last], "nothing else matches"); +} + #[test] fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { let fonts = fonts();