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 {
112112 Style(usize),
113113 /// The Styles gallery.
114114 Styles,
115 /// The Themes dialog, assigning a theme to the page, its section or its notebook.
116 Theme(crate::themes::Scope),
115 /// The Themes dialog, editing themes and giving one to the page, its section or its
116 /// notebook.
117 Themes,
117118 /// The tag at this place in the user's tag list.
118119 Tag(usize),
119120 CustomizeTags,
......@@ -595,24 +596,7 @@ pub const COMMANDS: &[Command] = &[
595596 row!(Id::Style(8), "Quote", NONE, NONE),
596597 row!(Id::Style(9), "Code", NONE, NONE),
597598 row!(Id::Style(10), "Normal", NONE, NONE),
598 row!(
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 ),
599 row!(Id::Themes, "Customize Theme…", NONE, NONE),
616600 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),
617601 row!(
618602 Id::RemoveTags,
......@@ -1349,7 +1333,9 @@ impl State {
13491333 .get(place)
13501334 .map(|(name, _)| *name),
13511335 ),
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 }
13531339 // A selected picture or file takes tags too, as in OneNote.
13541340 Id::RemoveTags => enabled(text || tagged),
13551341 Id::ToDoList => enabled(text && format.listed && self.to_do().is_some()),
......@@ -1761,8 +1747,8 @@ impl State {
17611747 self.ui.open_popup(crate::toolbar_popup("styles"));
17621748 return Ok(());
17631749 }
1764 Id::Theme(scope) => {
1765 self.open_themes(scope);
1750 Id::Themes => {
1751 self.open_themes();
17661752 return Ok(());
17671753 }
17681754 Id::Tag(place) => {
crates/snowbound/src/menubar.rs+1-8
......@@ -187,14 +187,7 @@ const MENUS: &[Item] = &[
187187 C(Id::Style(10)),
188188 ],
189189 ),
190 Item::Menu(
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 ),
190 C(Id::Themes),
198191 S,
199192 Item::Menu("Font", &[Item::Fonts]),
200193 Item::Menu("Size", &[Item::Sizes]),
crates/snowbound/src/menus.rs+12-5
......@@ -709,14 +709,20 @@ impl State {
709709 (Target::Section { library, path }, Action::Theme) => {
710710 if let Some(identity) = library.section_identity(&path) {
711711 let scope = notebook::sidecar::themes::Scope::section(identity);
712 let color = library.section_color(&path);
713 self.show_themes(crate::themes::Scope::Section, library, scope, color);
712 let (wearing, 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);
714718 }
715719 None
716720 }
717721 (Target::Notebook(library), Action::Theme) => {
718722 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);
720726 None
721727 }
722728 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(
......@@ -844,8 +850,9 @@ impl State {
844850 let identity = session.section.page(space).ok()?.identity?;
845851 let library = Arc::clone(&session.library);
846852 let scope = notebook::sidecar::themes::Scope::page(identity);
847 let color = self.section_color();
848 self.show_themes(crate::themes::Scope::Page, library, scope, color);
853 let (wearing, color) = (library.themes().assigned(&scope), self.section_color());
854 let targets = vec![(crate::themes::Scope::Page, scope)];
855 self.show_themes(library, targets, wearing, color);
849856 None
850857 }
851858 Action::Versions(show) => Some(Command::History { page: space, show }),
crates/snowbound/src/themes.rs+81-43
......@@ -1,7 +1,8 @@
11//! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its
22//! 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, the
4//! section or the notebook, and edits a notebook's own themes with a live preview.
3//! applies a style in the page's theme, and its Customize… opens the Themes dialog, which
4//! edits a notebook's own themes with a live preview and gives one to the page, the section
5//! or the notebook.
56
67use crate::{Library, State, filetime};
78use accesskit::Role;
......@@ -32,9 +33,9 @@ impl Scope {
3233
3334/// The Themes dialog while it is open.
3435pub struct Dialog {
35 scope: Scope,
3636 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)>,
3839 /// The built-in themes, then the notebook's own as the dialog edits them.
3940 themes: Vec<Theme>,
4041 /// The notebook's own themes changed here, by id.
......@@ -164,40 +165,48 @@ impl State {
164165 self.edit_page(ops)
165166 }
166167
167 /// Opens the Themes dialog choosing a theme for `scope` of the open page.
168 pub(crate) fn open_themes(&mut self, scope: Scope) {
169 let Some((library, target)) = self.theme_target(scope) else {
168 /// Opens the Themes dialog on the open page's theme, saving to the page, its section or
169 /// its notebook.
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 {
170180 return;
171181 };
172 let section = self.section_color();
173 self.show_themes(scope, library, target, section);
182 let library = Arc::clone(library);
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);
174186 }
175187
176 /// Opens the Themes dialog choosing a theme for `target`, which `scope` names, in a
188 /// Opens the Themes dialog on theme `wearing`, saving to `targets` of `library`, in a
177189 /// section coloured `section`.
178190 pub(crate) fn show_themes(
179191 &mut self,
180 scope: Scope,
181192 library: Arc<Library>,
182 target: stored::Scope,
193 targets: Vec<(Scope, stored::Scope)>,
194 wearing: Option<Theme>,
183195 section: Option<u32>,
184196 ) {
185 let themes = library.themes();
186 let assigned = themes.assigned(&target);
187 let mut all = themes.all();
188 // A retired built-in the scope wears is listed while it does.
189 if let Some(assigned) = &assigned
190 && !all.contains(assigned)
197 let mut all = library.themes().all();
198 // A retired built-in worn here is listed while it is.
199 if let Some(wearing) = &wearing
200 && !all.contains(wearing)
191201 {
192 all.push(assigned.clone());
202 all.push(wearing.clone());
193203 }
194 let selected = assigned
195 .and_then(|assigned| all.iter().position(|theme| theme.id == assigned.id))
204 let selected = wearing
205 .and_then(|wearing| all.iter().position(|theme| theme.id == wearing.id))
196206 .unwrap_or(0);
197207 self.themes = Some(Dialog {
198 scope,
199208 library,
200 target,
209 targets,
201210 themes: all,
202211 changed: Vec::new(),
203212 deleted: Vec::new(),
......@@ -208,9 +217,9 @@ impl State {
208217 self.ui.open_popup(id());
209218 }
210219
211 /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme
212 /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press
213 /// outside leave everything as it was.
220 /// Builds the Themes dialog while it is open. Save keeps the edits and gives the theme to
221 /// the scope it then asks for, This Page first; No Theme takes that scope's own away;
222 /// Cancel, Escape or a press outside leave everything as it was.
214223 pub(crate) fn themes_dialog(&mut self) {
215224 let Some(dialog) = &mut self.themes else {
216225 return;
......@@ -241,7 +250,10 @@ impl State {
241250 if let Some(node) = ui.access(id()) {
242251 node.set_label("Themes");
243252 }
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 };
245257 ui.leaf(
246258 "title",
247259 Spec {
......@@ -568,7 +580,30 @@ impl State {
568580 ..Spec::default()
569581 },
570582 );
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");
572607 ui.leaf(
573608 "space",
574609 Spec {
......@@ -577,7 +612,7 @@ impl State {
577612 },
578613 );
579614 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
580 let apply = ui::button(ui, "apply", "Apply").clicked;
615 let save = place(ui, "save", "Save");
581616 ui.close();
582617 ui.close();
583618
......@@ -617,10 +652,11 @@ impl State {
617652 dialog.deleted.push(gone);
618653 dialog.selected = 0;
619654 }
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 {
621657 return;
622658 }
623 if !cancel {
659 if let Some((at, apply)) = chosen {
624660 let themes = dialog
625661 .themes
626662 .iter()
......@@ -633,7 +669,7 @@ impl State {
633669 .collect();
634670 let chosen = &dialog.themes[dialog.selected];
635671 let assignment = Assignment {
636 scope: dialog.target.clone(),
672 scope: dialog.targets[at].1.clone(),
637673 theme: apply.then(|| chosen.id.clone()),
638674 assigned: now,
639675 };
......@@ -644,7 +680,9 @@ impl State {
644680 }
645681 self.ui.close_popup(id());
646682 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 {
648686 eprintln!("Restyling the page failed: {error}");
649687 }
650688 }
......@@ -690,7 +728,8 @@ fn fresh_id(now: u64, count: usize) -> String {
690728}
691729
692730/// 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.
694733pub(crate) fn gallery(
695734 ui: &mut Ui,
696735 menu: Id,
......@@ -700,7 +739,6 @@ pub(crate) fn gallery(
700739 current: Option<&str>,
701740) -> Option<crate::commands::Id> {
702741 use crate::commands::Id as Cmd;
703 let scopes = [Scope::Page, Scope::Section, Scope::Notebook];
704742 let groups = [
705743 ui::popup::Group {
706744 heading: &sheet.name,
......@@ -709,8 +747,8 @@ pub(crate) fn gallery(
709747 size: [240.0, 34.0],
710748 },
711749 ui::popup::Group {
712 heading: "Theme",
713 cells: scopes.len(),
750 heading: "",
751 cells: 1,
714752 columns: 1,
715753 size: [240.0, 24.0],
716754 },
......@@ -730,20 +768,20 @@ pub(crate) fn gallery(
730768 }
731769 }
732770 None => {
733 let label = format!("{}…", scopes[index - STYLES.len()].name());
734771 ui.leaf(
735 "scope",
772 "customize",
736773 Spec {
737774 size: [fill(), fill()],
738 text: Some(&label),
775 text: Some("Customize…"),
739776 ..Spec::default()
740777 },
741778 );
742779 }
743780 })?;
744 Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) {
745 Some(scope) => Cmd::Theme(*scope),
746 None => Cmd::Style(chosen),
781 Some(if chosen < STYLES.len() {
782 Cmd::Style(chosen)
783 } else {
784 Cmd::Themes
747785 })
748786}
749787