authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 17:52:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:46-07:00
log8bfedcad86c706463601020a107b0cad26ad75ae
tree9bd9eaa74ddd169cc208aca8c8f3039b3f6ad8d1
parentba13c9e71bde40e0327a4ca1afabe2810c585c79
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the Styles gallery ends in Customize…, and saving a theme asks where it goes

The gallery's This Page…, This Section… and This Notebook… become one Customize…, as Format's Theme menu becomes Customize Theme…. The Themes dialog's Save and No Theme ask This Page, This Section or This Notebook, This Page first; opened from a tab's or the notebook's Theme… they apply there at once. Assisted-by: claude-opus-5.5

4 files changed, 103 insertions(+), 79 deletions(-)

crates/snowbound/src/commands.rs+9-23
...@@ -112,8 +112,9 @@ pub enum Id {...@@ -112,8 +112,9 @@ pub enum Id {
112 Style(usize),112 Style(usize),
113 /// The Styles gallery.113 /// The Styles gallery.
114 Styles,114 Styles,
115 /// The Themes dialog, assigning a theme to the page, its section or its notebook.115 /// The Themes dialog, editing themes and giving one to the page, its section or its
116 Theme(crate::themes::Scope),116 /// notebook.
117 Themes,
117 /// The tag at this place in the user's tag list.118 /// The tag at this place in the user's tag list.
118 Tag(usize),119 Tag(usize),
119 CustomizeTags,120 CustomizeTags,
...@@ -595,24 +596,7 @@ pub const COMMANDS: &[Command] = &[...@@ -595,24 +596,7 @@ pub const COMMANDS: &[Command] = &[
595 row!(Id::Style(8), "Quote", NONE, NONE),596 row!(Id::Style(8), "Quote", NONE, NONE),
596 row!(Id::Style(9), "Code", NONE, NONE),597 row!(Id::Style(9), "Code", NONE, NONE),
597 row!(Id::Style(10), "Normal", NONE, NONE),598 row!(Id::Style(10), "Normal", NONE, NONE),
598 row!(599 row!(Id::Themes, "Customize Theme…", NONE, NONE),
599 Id::Theme(crate::themes::Scope::Page),
600 "Page Theme…",
601 NONE,
602 NONE
603 ),
604 row!(
605 Id::Theme(crate::themes::Scope::Section),
606 "Section Theme…",
607 NONE,
608 NONE
609 ),
610 row!(
611 Id::Theme(crate::themes::Scope::Notebook),
612 "Notebook Theme…",
613 NONE,
614 NONE
615 ),
616 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),600 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),
617 row!(601 row!(
618 Id::RemoveTags,602 Id::RemoveTags,
...@@ -1349,7 +1333,9 @@ impl State {...@@ -1349,7 +1333,9 @@ impl State {
1349 .get(place)1333 .get(place)
1350 .map(|(name, _)| *name),1334 .map(|(name, _)| *name),
1351 ),1335 ),
1352 Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()),1336 Id::Themes => {
1337 enabled(!modal && self.theme_target(crate::themes::Scope::Notebook).is_some())
1338 }
1353 // A selected picture or file takes tags too, as in OneNote.1339 // A selected picture or file takes tags too, as in OneNote.
1354 Id::RemoveTags => enabled(text || tagged),1340 Id::RemoveTags => enabled(text || tagged),
1355 Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()),1341 Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()),
...@@ -1761,8 +1747,8 @@ impl State {...@@ -1761,8 +1747,8 @@ impl State {
1761 self.ui.open_popup(crate::toolbar_popup("styles"));1747 self.ui.open_popup(crate::toolbar_popup("styles"));
1762 return Ok(());1748 return Ok(());
1763 }1749 }
1764 Id::Theme(scope) => {1750 Id::Themes => {
1765 self.open_themes(scope);1751 self.open_themes();
1766 return Ok(());1752 return Ok(());
1767 }1753 }
1768 Id::Tag(place) => {1754 Id::Tag(place) => {
crates/snowbound/src/menubar.rs+1-8
...@@ -187,14 +187,7 @@ const MENUS: &[Item] = &[...@@ -187,14 +187,7 @@ const MENUS: &[Item] = &[
187 C(Id::Style(10)),187 C(Id::Style(10)),
188 ],188 ],
189 ),189 ),
190 Item::Menu(190 C(Id::Themes),
191 "Theme",
192 &[
193 C(Id::Theme(crate::themes::Scope::Page)),
194 C(Id::Theme(crate::themes::Scope::Section)),
195 C(Id::Theme(crate::themes::Scope::Notebook)),
196 ],
197 ),
198 S,191 S,
199 Item::Menu("Font", &[Item::Fonts]),192 Item::Menu("Font", &[Item::Fonts]),
200 Item::Menu("Size", &[Item::Sizes]),193 Item::Menu("Size", &[Item::Sizes]),
crates/snowbound/src/menus.rs+12-5
...@@ -709,14 +709,20 @@ impl State {...@@ -709,14 +709,20 @@ impl State {
709 (Target::Section { library, path }, Action::Theme) => {709 (Target::Section { library, path }, Action::Theme) => {
710 if let Some(identity) = library.section_identity(&path) {710 if let Some(identity) = library.section_identity(&path) {
711 let scope = notebook::sidecar::themes::Scope::section(identity);711 let scope = notebook::sidecar::themes::Scope::section(identity);
712 let color = library.section_color(&path);712 let (wearing, color) = (
713 self.show_themes(crate::themes::Scope::Section, library, scope, color);713 library.themes().assigned(&scope),
714 library.section_color(&path),
715 );
716 let targets = vec![(crate::themes::Scope::Section, scope)];
717 self.show_themes(library, targets, wearing, color);
714 }718 }
715 None719 None
716 }720 }
717 (Target::Notebook(library), Action::Theme) => {721 (Target::Notebook(library), Action::Theme) => {
718 let scope = notebook::sidecar::themes::Scope::Notebook;722 let scope = notebook::sidecar::themes::Scope::Notebook;
719 self.show_themes(crate::themes::Scope::Notebook, library, scope, None);723 let wearing = library.themes().assigned(&scope);
724 let targets = vec![(crate::themes::Scope::Notebook, scope)];
725 self.show_themes(library, targets, wearing, None);
720 None726 None
721 }727 }
722 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(728 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(
...@@ -844,8 +850,9 @@ impl State {...@@ -844,8 +850,9 @@ impl State {
844 let identity = session.section.page(space).ok()?.identity?;850 let identity = session.section.page(space).ok()?.identity?;
845 let library = Arc::clone(&session.library);851 let library = Arc::clone(&session.library);
846 let scope = notebook::sidecar::themes::Scope::page(identity);852 let scope = notebook::sidecar::themes::Scope::page(identity);
847 let color = self.section_color();853 let (wearing, color) = (library.themes().assigned(&scope), self.section_color());
848 self.show_themes(crate::themes::Scope::Page, library, scope, color);854 let targets = vec![(crate::themes::Scope::Page, scope)];
855 self.show_themes(library, targets, wearing, color);
849 None856 None
850 }857 }
851 Action::Versions(show) => Some(Command::History { page: space, show }),858 Action::Versions(show) => Some(Command::History { page: space, show }),
crates/snowbound/src/themes.rs+81-43
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its1//! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its
2//! section's, else its notebook's, as its paragraph style objects; the Styles gallery2//! section's, else its notebook's, as its paragraph style objects; the Styles gallery
3//! applies a style in the page's theme; the Themes dialog picks a theme for the page, the3//! applies a style in the page's theme, and its Customize… opens the Themes dialog, which
4//! section or the notebook, and edits a notebook's own themes with a live preview.4//! edits a notebook's own themes with a live preview and gives one to the page, the section
5//! or the notebook.
56
6use crate::{Library, State, filetime};7use crate::{Library, State, filetime};
7use accesskit::Role;8use accesskit::Role;
...@@ -32,9 +33,9 @@ impl Scope {...@@ -32,9 +33,9 @@ impl Scope {
3233
33/// The Themes dialog while it is open.34/// The Themes dialog while it is open.
34pub struct Dialog {35pub struct Dialog {
35 scope: Scope,
36 library: Arc<Library>,36 library: Arc<Library>,
37 target: stored::Scope,37 /// Where Save can give the theme, asked with the first offered first; one alone takes it.
38 targets: Vec<(Scope, stored::Scope)>,
38 /// The built-in themes, then the notebook's own as the dialog edits them.39 /// The built-in themes, then the notebook's own as the dialog edits them.
39 themes: Vec<Theme>,40 themes: Vec<Theme>,
40 /// The notebook's own themes changed here, by id.41 /// The notebook's own themes changed here, by id.
...@@ -164,40 +165,48 @@ impl State {...@@ -164,40 +165,48 @@ impl State {
164 self.edit_page(ops)165 self.edit_page(ops)
165 }166 }
166167
167 /// Opens the Themes dialog choosing a theme for `scope` of the open page.168 /// Opens the Themes dialog on the open page's theme, saving to the page, its section or
168 pub(crate) fn open_themes(&mut self, scope: Scope) {169 /// its notebook.
169 let Some((library, target)) = self.theme_target(scope) else {170 pub(crate) fn open_themes(&mut self) {
171 let targets: Vec<(Arc<Library>, (Scope, stored::Scope))> =
172 [Scope::Page, Scope::Section, Scope::Notebook]
173 .into_iter()
174 .filter_map(|scope| {
175 let (library, target) = self.theme_target(scope)?;
176 Some((library, (scope, target)))
177 })
178 .collect();
179 let Some((library, _)) = targets.first() else {
170 return;180 return;
171 };181 };
172 let section = self.section_color();182 let library = Arc::clone(library);
173 self.show_themes(scope, library, target, section);183 let (wearing, section) = (self.page_theme(), self.section_color());
184 let targets = targets.into_iter().map(|(_, target)| target).collect();
185 self.show_themes(library, targets, wearing, section);
174 }186 }
175187
176 /// Opens the Themes dialog choosing a theme for `target`, which `scope` names, in a188 /// Opens the Themes dialog on theme `wearing`, saving to `targets` of `library`, in a
177 /// section coloured `section`.189 /// section coloured `section`.
178 pub(crate) fn show_themes(190 pub(crate) fn show_themes(
179 &mut self,191 &mut self,
180 scope: Scope,
181 library: Arc<Library>,192 library: Arc<Library>,
182 target: stored::Scope,193 targets: Vec<(Scope, stored::Scope)>,
194 wearing: Option<Theme>,
183 section: Option<u32>,195 section: Option<u32>,
184 ) {196 ) {
185 let themes = library.themes();197 let mut all = library.themes().all();
186 let assigned = themes.assigned(&target);198 // A retired built-in worn here is listed while it is.
187 let mut all = themes.all();199 if let Some(wearing) = &wearing
188 // A retired built-in the scope wears is listed while it does.200 && !all.contains(wearing)
189 if let Some(assigned) = &assigned
190 && !all.contains(assigned)
191 {201 {
192 all.push(assigned.clone());202 all.push(wearing.clone());
193 }203 }
194 let selected = assigned204 let selected = wearing
195 .and_then(|assigned| all.iter().position(|theme| theme.id == assigned.id))205 .and_then(|wearing| all.iter().position(|theme| theme.id == wearing.id))
196 .unwrap_or(0);206 .unwrap_or(0);
197 self.themes = Some(Dialog {207 self.themes = Some(Dialog {
198 scope,
199 library,208 library,
200 target,209 targets,
201 themes: all,210 themes: all,
202 changed: Vec::new(),211 changed: Vec::new(),
203 deleted: Vec::new(),212 deleted: Vec::new(),
...@@ -208,9 +217,9 @@ impl State {...@@ -208,9 +217,9 @@ impl State {
208 self.ui.open_popup(id());217 self.ui.open_popup(id());
209 }218 }
210219
211 /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme220 /// Builds the Themes dialog while it is open. Save keeps the edits and gives the theme to
212 /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press221 /// the scope it then asks for, This Page first; No Theme takes that scope's own away;
213 /// outside leave everything as it was.222 /// Cancel, Escape or a press outside leave everything as it was.
214 pub(crate) fn themes_dialog(&mut self) {223 pub(crate) fn themes_dialog(&mut self) {
215 let Some(dialog) = &mut self.themes else {224 let Some(dialog) = &mut self.themes else {
216 return;225 return;
...@@ -241,7 +250,10 @@ impl State {...@@ -241,7 +250,10 @@ impl State {
241 if let Some(node) = ui.access(id()) {250 if let Some(node) = ui.access(id()) {
242 node.set_label("Themes");251 node.set_label("Themes");
243 }252 }
244 let title = format!("Theme for {}", dialog.scope.name());253 let title = match &dialog.targets[..] {
254 [(scope, _)] => format!("Theme for {}", scope.name()),
255 _ => "Themes".to_owned(),
256 };
245 ui.leaf(257 ui.leaf(
246 "title",258 "title",
247 Spec {259 Spec {
...@@ -568,7 +580,30 @@ impl State {...@@ -568,7 +580,30 @@ impl State {
568 ..Spec::default()580 ..Spec::default()
569 },581 },
570 );582 );
571 let none = ui::button(ui, "none", "No Theme").clicked;583 // Each button gives its choice to the one place there is, else asks where.
584 let targets = &dialog.targets;
585 let place = |ui: &mut Ui, part: &str, label: &str| -> Option<usize> {
586 let clicked = ui::button(ui, part, label).clicked;
587 if targets.len() == 1 {
588 return clicked.then_some(0);
589 }
590 let menu = popup(part);
591 if clicked {
592 ui.open_popup(menu);
593 }
594 let items: Vec<Item> = targets
595 .iter()
596 .enumerate()
597 .map(|(at, (scope, _))| Item {
598 text: scope.name(),
599 current: at == 0,
600 ..Item::default()
601 })
602 .collect();
603 let anchor = Anchor::Below(ui.rect(ui.id(part)).unwrap_or_default());
604 ui::popup::menu(ui, menu, anchor, &items, None)
605 };
606 let none = place(ui, "none", "No Theme");
572 ui.leaf(607 ui.leaf(
573 "space",608 "space",
574 Spec {609 Spec {
...@@ -577,7 +612,7 @@ impl State {...@@ -577,7 +612,7 @@ impl State {
577 },612 },
578 );613 );
579 let cancel = ui::button(ui, "cancel", "Cancel").clicked;614 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
580 let apply = ui::button(ui, "apply", "Apply").clicked;615 let save = place(ui, "save", "Save");
581 ui.close();616 ui.close();
582 ui.close();617 ui.close();
583618
...@@ -617,10 +652,11 @@ impl State {...@@ -617,10 +652,11 @@ impl State {
617 dialog.deleted.push(gone);652 dialog.deleted.push(gone);
618 dialog.selected = 0;653 dialog.selected = 0;
619 }654 }
620 if !(apply || none || cancel) {655 let chosen = save.map(|at| (at, true)).or(none.map(|at| (at, false)));
656 if chosen.is_none() && !cancel {
621 return;657 return;
622 }658 }
623 if !cancel {659 if let Some((at, apply)) = chosen {
624 let themes = dialog660 let themes = dialog
625 .themes661 .themes
626 .iter()662 .iter()
...@@ -633,7 +669,7 @@ impl State {...@@ -633,7 +669,7 @@ impl State {
633 .collect();669 .collect();
634 let chosen = &dialog.themes[dialog.selected];670 let chosen = &dialog.themes[dialog.selected];
635 let assignment = Assignment {671 let assignment = Assignment {
636 scope: dialog.target.clone(),672 scope: dialog.targets[at].1.clone(),
637 theme: apply.then(|| chosen.id.clone()),673 theme: apply.then(|| chosen.id.clone()),
638 assigned: now,674 assigned: now,
639 };675 };
...@@ -644,7 +680,9 @@ impl State {...@@ -644,7 +680,9 @@ impl State {
644 }680 }
645 self.ui.close_popup(id());681 self.ui.close_popup(id());
646 self.themes = None;682 self.themes = None;
647 if !cancel && let Err(error) = self.wear_theme() {683 if chosen.is_some()
684 && let Err(error) = self.wear_theme()
685 {
648 eprintln!("Restyling the page failed: {error}");686 eprintln!("Restyling the page failed: {error}");
649 }687 }
650 }688 }
...@@ -690,7 +728,8 @@ fn fresh_id(now: u64, count: usize) -> String {...@@ -690,7 +728,8 @@ fn fresh_id(now: u64, count: usize) -> String {
690}728}
691729
692/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in730/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in
693/// `sheet` in a section coloured `section`, the one at the caret outlined, then the theme commands. Returns the command chosen.731/// `sheet` in a section coloured `section`, the one at the caret outlined, then Customize….
732/// Returns the command chosen.
694pub(crate) fn gallery(733pub(crate) fn gallery(
695 ui: &mut Ui,734 ui: &mut Ui,
696 menu: Id,735 menu: Id,
...@@ -700,7 +739,6 @@ pub(crate) fn gallery(...@@ -700,7 +739,6 @@ pub(crate) fn gallery(
700 current: Option<&str>,739 current: Option<&str>,
701) -> Option<crate::commands::Id> {740) -> Option<crate::commands::Id> {
702 use crate::commands::Id as Cmd;741 use crate::commands::Id as Cmd;
703 let scopes = [Scope::Page, Scope::Section, Scope::Notebook];
704 let groups = [742 let groups = [
705 ui::popup::Group {743 ui::popup::Group {
706 heading: &sheet.name,744 heading: &sheet.name,
...@@ -709,8 +747,8 @@ pub(crate) fn gallery(...@@ -709,8 +747,8 @@ pub(crate) fn gallery(
709 size: [240.0, 34.0],747 size: [240.0, 34.0],
710 },748 },
711 ui::popup::Group {749 ui::popup::Group {
712 heading: "Theme",750 heading: "",
713 cells: scopes.len(),751 cells: 1,
714 columns: 1,752 columns: 1,
715 size: [240.0, 24.0],753 size: [240.0, 24.0],
716 },754 },
...@@ -730,20 +768,20 @@ pub(crate) fn gallery(...@@ -730,20 +768,20 @@ pub(crate) fn gallery(
730 }768 }
731 }769 }
732 None => {770 None => {
733 let label = format!("{}…", scopes[index - STYLES.len()].name());
734 ui.leaf(771 ui.leaf(
735 "scope",772 "customize",
736 Spec {773 Spec {
737 size: [fill(), fill()],774 size: [fill(), fill()],
738 text: Some(&label),775 text: Some("Customize…"),
739 ..Spec::default()776 ..Spec::default()
740 },777 },
741 );778 );
742 }779 }
743 })?;780 })?;
744 Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) {781 Some(if chosen < STYLES.len() {
745 Some(scope) => Cmd::Theme(*scope),782 Cmd::Style(chosen)
746 None => Cmd::Style(chosen),783 } else {
784 Cmd::Themes
747 })785 })
748}786}
749787