diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 60c44f588b294105f00333c88ed30b9d200cb028..7e7ba93609dd97e7b2945770019649fac29663da 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -112,8 +112,9 @@ pub enum Id { Style(usize), /// The Styles gallery. Styles, - /// The Themes dialog, assigning a theme to the page, its section or its notebook. - Theme(crate::themes::Scope), + /// The Themes dialog, editing themes and giving one to the page, its section or its + /// notebook. + Themes, /// The tag at this place in the user's tag list. Tag(usize), CustomizeTags, @@ -595,24 +596,7 @@ pub const COMMANDS: &[Command] = &[ row!(Id::Style(8), "Quote", NONE, NONE), row!(Id::Style(9), "Code", NONE, NONE), row!(Id::Style(10), "Normal", NONE, NONE), - row!( - Id::Theme(crate::themes::Scope::Page), - "Page Theme…", - NONE, - NONE - ), - row!( - Id::Theme(crate::themes::Scope::Section), - "Section Theme…", - NONE, - NONE - ), - row!( - Id::Theme(crate::themes::Scope::Notebook), - "Notebook Theme…", - NONE, - NONE - ), + row!(Id::Themes, "Customize Theme…", NONE, NONE), row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE), row!( Id::RemoveTags, @@ -1349,7 +1333,9 @@ impl State { .get(place) .map(|(name, _)| *name), ), - Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()), + Id::Themes => { + enabled(!modal && self.theme_target(crate::themes::Scope::Notebook).is_some()) + } // A selected picture or file takes tags too, as in OneNote. Id::RemoveTags => enabled(text || tagged), Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()), @@ -1761,8 +1747,8 @@ impl State { self.ui.open_popup(crate::toolbar_popup("styles")); return Ok(()); } - Id::Theme(scope) => { - self.open_themes(scope); + Id::Themes => { + self.open_themes(); return Ok(()); } Id::Tag(place) => { diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index d44418043cd92602d7109d7e216354132b70b85b..e03f4cb562b66dee1d4af0faa4a3b7c672d4158d 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -187,14 +187,7 @@ const MENUS: &[Item] = &[ C(Id::Style(10)), ], ), - Item::Menu( - "Theme", - &[ - C(Id::Theme(crate::themes::Scope::Page)), - C(Id::Theme(crate::themes::Scope::Section)), - C(Id::Theme(crate::themes::Scope::Notebook)), - ], - ), + C(Id::Themes), S, Item::Menu("Font", &[Item::Fonts]), Item::Menu("Size", &[Item::Sizes]), diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 5b22e86dc0c59089c137281ee709ba9b3afaa992..07cc5337978dbfdfa739226fb0c961bfdac599fa 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -709,14 +709,20 @@ impl State { (Target::Section { library, path }, Action::Theme) => { if let Some(identity) = library.section_identity(&path) { let scope = notebook::sidecar::themes::Scope::section(identity); - let color = library.section_color(&path); - self.show_themes(crate::themes::Scope::Section, library, scope, color); + let (wearing, color) = ( + library.themes().assigned(&scope), + library.section_color(&path), + ); + let targets = vec![(crate::themes::Scope::Section, scope)]; + self.show_themes(library, targets, wearing, color); } None } (Target::Notebook(library), Action::Theme) => { let scope = notebook::sidecar::themes::Scope::Notebook; - self.show_themes(crate::themes::Scope::Notebook, library, scope, None); + let wearing = library.themes().assigned(&scope); + let targets = vec![(crate::themes::Scope::Notebook, scope)]; + self.show_themes(library, targets, wearing, None); None } (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure( @@ -844,8 +850,9 @@ impl State { let identity = session.section.page(space).ok()?.identity?; let library = Arc::clone(&session.library); let scope = notebook::sidecar::themes::Scope::page(identity); - let color = self.section_color(); - self.show_themes(crate::themes::Scope::Page, library, scope, color); + let (wearing, color) = (library.themes().assigned(&scope), self.section_color()); + let targets = vec![(crate::themes::Scope::Page, scope)]; + self.show_themes(library, targets, wearing, color); None } Action::Versions(show) => Some(Command::History { page: space, show }), diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index 5cbd818d42c4a6635b636eaea2f346777b27fa79..a15751acb1e92e5f716ad5f704baadf74301ddfe 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -1,7 +1,8 @@ //! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its //! section's, else its notebook's, as its paragraph style objects; the Styles gallery -//! applies a style in the page's theme; the Themes dialog picks a theme for the page, the -//! section or the notebook, and edits a notebook's own themes with a live preview. +//! applies a style in the page's theme, and its Customize… opens the Themes dialog, which +//! edits a notebook's own themes with a live preview and gives one to the page, the section +//! or the notebook. use crate::{Library, State, filetime}; use accesskit::Role; @@ -32,9 +33,9 @@ impl Scope { /// The Themes dialog while it is open. pub struct Dialog { - scope: Scope, library: Arc, - target: stored::Scope, + /// Where Save can give the theme, asked with the first offered first; one alone takes it. + targets: Vec<(Scope, stored::Scope)>, /// The built-in themes, then the notebook's own as the dialog edits them. themes: Vec, /// The notebook's own themes changed here, by id. @@ -164,40 +165,48 @@ impl State { self.edit_page(ops) } - /// Opens the Themes dialog choosing a theme for `scope` of the open page. - pub(crate) fn open_themes(&mut self, scope: Scope) { - let Some((library, target)) = self.theme_target(scope) else { + /// Opens the Themes dialog on the open page's theme, saving to the page, its section or + /// its notebook. + pub(crate) fn open_themes(&mut self) { + let targets: Vec<(Arc, (Scope, stored::Scope))> = + [Scope::Page, Scope::Section, Scope::Notebook] + .into_iter() + .filter_map(|scope| { + let (library, target) = self.theme_target(scope)?; + Some((library, (scope, target))) + }) + .collect(); + let Some((library, _)) = targets.first() else { return; }; - let section = self.section_color(); - self.show_themes(scope, library, target, section); + let library = Arc::clone(library); + let (wearing, section) = (self.page_theme(), self.section_color()); + let targets = targets.into_iter().map(|(_, target)| target).collect(); + self.show_themes(library, targets, wearing, section); } - /// Opens the Themes dialog choosing a theme for `target`, which `scope` names, in a + /// Opens the Themes dialog on theme `wearing`, saving to `targets` of `library`, in a /// section coloured `section`. pub(crate) fn show_themes( &mut self, - scope: Scope, library: Arc, - target: stored::Scope, + targets: Vec<(Scope, stored::Scope)>, + wearing: Option, section: Option, ) { - let themes = library.themes(); - let assigned = themes.assigned(&target); - let mut all = themes.all(); - // A retired built-in the scope wears is listed while it does. - if let Some(assigned) = &assigned - && !all.contains(assigned) + let mut all = library.themes().all(); + // A retired built-in worn here is listed while it is. + if let Some(wearing) = &wearing + && !all.contains(wearing) { - all.push(assigned.clone()); + all.push(wearing.clone()); } - let selected = assigned - .and_then(|assigned| all.iter().position(|theme| theme.id == assigned.id)) + let selected = wearing + .and_then(|wearing| all.iter().position(|theme| theme.id == wearing.id)) .unwrap_or(0); self.themes = Some(Dialog { - scope, library, - target, + targets, themes: all, changed: Vec::new(), deleted: Vec::new(), @@ -208,9 +217,9 @@ impl State { self.ui.open_popup(id()); } - /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme - /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press - /// outside leave everything as it was. + /// Builds the Themes dialog while it is open. Save keeps the edits and gives the theme to + /// the scope it then asks for, This Page first; No Theme takes that scope's own away; + /// Cancel, Escape or a press outside leave everything as it was. pub(crate) fn themes_dialog(&mut self) { let Some(dialog) = &mut self.themes else { return; @@ -241,7 +250,10 @@ impl State { if let Some(node) = ui.access(id()) { node.set_label("Themes"); } - let title = format!("Theme for {}", dialog.scope.name()); + let title = match &dialog.targets[..] { + [(scope, _)] => format!("Theme for {}", scope.name()), + _ => "Themes".to_owned(), + }; ui.leaf( "title", Spec { @@ -568,7 +580,30 @@ impl State { ..Spec::default() }, ); - let none = ui::button(ui, "none", "No Theme").clicked; + // Each button gives its choice to the one place there is, else asks where. + let targets = &dialog.targets; + let place = |ui: &mut Ui, part: &str, label: &str| -> Option { + let clicked = ui::button(ui, part, label).clicked; + if targets.len() == 1 { + return clicked.then_some(0); + } + let menu = popup(part); + if clicked { + ui.open_popup(menu); + } + let items: Vec = targets + .iter() + .enumerate() + .map(|(at, (scope, _))| Item { + text: scope.name(), + current: at == 0, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(ui.id(part)).unwrap_or_default()); + ui::popup::menu(ui, menu, anchor, &items, None) + }; + let none = place(ui, "none", "No Theme"); ui.leaf( "space", Spec { @@ -577,7 +612,7 @@ impl State { }, ); let cancel = ui::button(ui, "cancel", "Cancel").clicked; - let apply = ui::button(ui, "apply", "Apply").clicked; + let save = place(ui, "save", "Save"); ui.close(); ui.close(); @@ -617,10 +652,11 @@ impl State { dialog.deleted.push(gone); dialog.selected = 0; } - if !(apply || none || cancel) { + let chosen = save.map(|at| (at, true)).or(none.map(|at| (at, false))); + if chosen.is_none() && !cancel { return; } - if !cancel { + if let Some((at, apply)) = chosen { let themes = dialog .themes .iter() @@ -633,7 +669,7 @@ impl State { .collect(); let chosen = &dialog.themes[dialog.selected]; let assignment = Assignment { - scope: dialog.target.clone(), + scope: dialog.targets[at].1.clone(), theme: apply.then(|| chosen.id.clone()), assigned: now, }; @@ -644,7 +680,9 @@ impl State { } self.ui.close_popup(id()); self.themes = None; - if !cancel && let Err(error) = self.wear_theme() { + if chosen.is_some() + && let Err(error) = self.wear_theme() + { eprintln!("Restyling the page failed: {error}"); } } @@ -690,7 +728,8 @@ fn fresh_id(now: u64, count: usize) -> String { } /// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in -/// `sheet` in a section coloured `section`, the one at the caret outlined, then the theme commands. Returns the command chosen. +/// `sheet` in a section coloured `section`, the one at the caret outlined, then Customize…. +/// Returns the command chosen. pub(crate) fn gallery( ui: &mut Ui, menu: Id, @@ -700,7 +739,6 @@ pub(crate) fn gallery( current: Option<&str>, ) -> Option { use crate::commands::Id as Cmd; - let scopes = [Scope::Page, Scope::Section, Scope::Notebook]; let groups = [ ui::popup::Group { heading: &sheet.name, @@ -709,8 +747,8 @@ pub(crate) fn gallery( size: [240.0, 34.0], }, ui::popup::Group { - heading: "Theme", - cells: scopes.len(), + heading: "", + cells: 1, columns: 1, size: [240.0, 24.0], }, @@ -730,20 +768,20 @@ pub(crate) fn gallery( } } None => { - let label = format!("{}…", scopes[index - STYLES.len()].name()); ui.leaf( - "scope", + "customize", Spec { size: [fill(), fill()], - text: Some(&label), + text: Some("Customize…"), ..Spec::default() }, ); } })?; - Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) { - Some(scope) => Cmd::Theme(*scope), - None => Cmd::Style(chosen), + Some(if chosen < STYLES.len() { + Cmd::Style(chosen) + } else { + Cmd::Themes }) }