From 0e5953741636d15d56801decaafa496aeb34259f Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Mon, 12 Oct 2026 10:43:59 -0300 Subject: [PATCH] feat(ui): Catppuccin themes (View > Theme, command palette); use the masi/craft-libs fork Co-Authored-By: Claude Sonnet 5.5 --- .debrand | 4 + .gitmodules | 3 + Cargo.lock | 9 +++ Cargo.toml | 1 + src/app.rs | 11 ++- src/macos.rs | 18 +++++ src/theme.rs | 188 +++++++++++++++++++++++++++++++++++++++++++-- src/ui/palette.rs | 17 +++- tools/craft-skills | 1 + 9 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 .debrand create mode 100644 .gitmodules create mode 160000 tools/craft-skills diff --git a/.debrand b/.debrand new file mode 100644 index 0000000..f125026 --- /dev/null +++ b/.debrand @@ -0,0 +1,4 @@ +# Upstream (michaelshimeles/paper-rs) carries no ArtCraft/Discord branding; keep it that way. Run: perl tools/craft-skills/skills/debrand/debrand.pl --check . +keep ^(LICENSE-(MIT|APACHE)|\.debrand)$ +forbid (?i)artcraft +forbid (?i)discord diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..47fd3c6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tools/craft-skills"] + path = tools/craft-skills + url = https://git.marcospaulo.dev.br/storytold/craft-skills.git diff --git a/Cargo.lock b/Cargo.lock index 8432158..b760333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,6 +556,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065" +dependencies = [ + "egui", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -2518,6 +2526,7 @@ dependencies = [ "arboard", "axum", "bytemuck", + "craft-ui", "dirs", "egui", "egui-wgpu", diff --git a/Cargo.toml b/Cargo.toml index 4a2e1dc..af86909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ env_logger = "0.11" rfd = "0.17" fastrand = "2" dirs = "6" +craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065" } i_overlay = "9.0.1" miniz_oxide = "0.9.1" usvg = "0.48" diff --git a/src/app.rs b/src/app.rs index 6e38965..49584e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,8 +73,7 @@ pub enum Start { impl App { pub fn new(start: Start, store: Store) -> App { let ctx = egui::Context::default(); - theme::set_accent(theme::system_accent()); - ui::setup_style(&ctx); + theme::apply_choice(&ctx, theme::Choice::load()); let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root)); let mut app = App { gfx: None, @@ -734,6 +733,9 @@ impl App { set(&m.snapping, self.ed.snapping); set(&m.grid, !self.st.show_grid_off); set(&m.help, self.st.show_help); + for (c, item) in &m.themes { + set(item, *c == theme::choice()); + } } } @@ -749,6 +751,10 @@ impl App { } MenuCmd::ToggleGrid => self.st.show_grid_off = !self.st.show_grid_off, MenuCmd::ToggleHelp => self.st.show_help = !self.st.show_help, + MenuCmd::Theme(c) => { + theme::apply_choice(&self.ctx, c); + c.save(); + } // Menu key equivalents never reach egui, so while a text field is focused, hand // the standard editing commands to it instead of the canvas. MenuCmd::Run(a @ (A::Undo | A::Redo | A::Cut | A::Copy | A::Paste | A::SelectAll)) @@ -954,7 +960,6 @@ impl ApplicationHandler for App { } WindowEvent::ThemeChanged(_) => { // Light/dark switch: egui-winit swaps the style; refresh the accent too. - theme::set_accent(theme::system_accent()); ui::setup_style(&self.ctx); redraw = true; } diff --git a/src/macos.rs b/src/macos.rs index a94c89a..d552429 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -112,6 +112,7 @@ pub enum MenuCmd { Quit, ToggleGrid, ToggleHelp, + Theme(crate::theme::Choice), } pub struct NativeMenu { @@ -120,6 +121,7 @@ pub struct NativeMenu { pub snapping: CheckMenuItem, pub grid: CheckMenuItem, pub help: CheckMenuItem, + pub themes: Vec<(crate::theme::Choice, CheckMenuItem)>, } impl NativeMenu { @@ -250,6 +252,20 @@ pub fn install_menu() -> NativeMenu { let help = CheckMenuItem::new("Keyboard Shortcuts", true, false, acc(cmd, Code::Slash)); cmds.insert(help.id().clone(), MenuCmd::ToggleHelp); + let themes: Vec<(crate::theme::Choice, CheckMenuItem)> = crate::theme::Choice::ALL + .into_iter() + .map(|c| { + let item = CheckMenuItem::new(c.label(), true, c == crate::theme::choice(), None); + cmds.insert(item.id().clone(), MenuCmd::Theme(c)); + (c, item) + }) + .collect(); + let theme_items: Vec<&dyn muda::IsMenuItem> = themes + .iter() + .map(|(_, i)| i as &dyn muda::IsMenuItem) + .collect(); + let theme_menu = Submenu::with_items("Theme", true, &theme_items).expect("theme menu"); + let sep = PredefinedMenuItem::separator; let app_menu = Submenu::with_items( "paper-rs", @@ -280,6 +296,7 @@ pub fn install_menu() -> NativeMenu { refs.push(&snapping); refs.push(&grid); refs.push(&help); + refs.push(&theme_menu); refs.push(&fullscreen); } if title == "File" { @@ -313,5 +330,6 @@ pub fn install_menu() -> NativeMenu { snapping, grid, help, + themes, } } diff --git a/src/theme.rs b/src/theme.rs index b5d14fd..ba91d54 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,12 +1,13 @@ //! macOS-flavoured look for the egui chrome: Inter font, system accent, light/dark palettes //! that follow the system appearance, and thin SF Symbols-style line icons. +use craft_ui::theme::catppuccin::Flavor; use egui::{ Color32, CornerRadius, FontData, FontFamily, FontId, Pos2, Rect, Shadow, Stroke, Vec2, pos2, vec2, }; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; pub static INTER_REGULAR: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); pub static INTER_MEDIUM: &[u8] = include_bytes!("../assets/fonts/Inter-Medium.ttf"); @@ -167,9 +168,8 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } -fn style_for(s: &mut egui::Style, dark: bool) { +fn style_for(s: &mut egui::Style, dark: bool, p: Palette) { use egui::TextStyle as T; - let p = palette(dark); let acc = accent(); s.visuals = if dark { egui::Visuals::dark() @@ -269,16 +269,153 @@ pub fn blend(base: Color32, over: Color32) -> Color32 { Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob)) } -/// Fonts plus light and dark styles. egui picks the one matching the system appearance -/// (reported by egui-winit); headless rendering falls back to light. +/// The appearance setting: follow the system, force light or dark, or a Catppuccin flavour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Choice { + #[default] + System, + Light, + Dark, + Catppuccin(Flavor), +} + +impl Choice { + pub const ALL: [Choice; 7] = [ + Choice::System, + Choice::Light, + Choice::Dark, + Choice::Catppuccin(Flavor::Latte), + Choice::Catppuccin(Flavor::Frappe), + Choice::Catppuccin(Flavor::Macchiato), + Choice::Catppuccin(Flavor::Mocha), + ]; + + pub fn id(self) -> &'static str { + match self { + Choice::System => "system", + Choice::Light => "light", + Choice::Dark => "dark", + Choice::Catppuccin(Flavor::Latte) => "catppuccinLatte", + Choice::Catppuccin(Flavor::Frappe) => "catppuccinFrappe", + Choice::Catppuccin(Flavor::Macchiato) => "catppuccinMacchiato", + Choice::Catppuccin(Flavor::Mocha) => "catppuccinMocha", + } + } + + pub fn label(self) -> &'static str { + match self { + Choice::System => "System", + Choice::Light => "Light", + Choice::Dark => "Dark", + Choice::Catppuccin(Flavor::Latte) => "Catppuccin Latte", + Choice::Catppuccin(Flavor::Frappe) => "Catppuccin Frappé", + Choice::Catppuccin(Flavor::Macchiato) => "Catppuccin Macchiato", + Choice::Catppuccin(Flavor::Mocha) => "Catppuccin Mocha", + } + } + + pub fn parse(id: &str) -> Option { + Choice::ALL.into_iter().find(|c| c.id() == id.trim()) + } + + fn index(self) -> u8 { + Choice::ALL.iter().position(|c| *c == self).unwrap_or(0) as u8 + } + + /// Persisted in the config dir; a missing or unreadable file means `System`. + fn path() -> Option { + dirs::config_dir().map(|d| d.join("paper-rs").join("theme")) + } + + pub fn load() -> Choice { + Choice::path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| Choice::parse(&s)) + .unwrap_or_default() + } + + pub fn save(self) { + if let Some(p) = Choice::path() { + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(p, self.id()); + } + } +} + +static CHOICE: AtomicU8 = AtomicU8::new(0); + +pub fn choice() -> Choice { + Choice::ALL + .get(CHOICE.load(Ordering::Relaxed) as usize) + .copied() + .unwrap_or_default() +} + +/// A Catppuccin flavour mapped onto the chrome palette. +pub fn catppuccin_palette(f: Flavor) -> Palette { + let p = f.palette(); + let veil = |c: Color32, a| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); + Palette { + window: p.base, + sidebar: p.mantle, + inspector: p.mantle, + control: p.surface0, + control_border: p.surface1, + field: p.crust, + text: p.text, + secondary: p.subtext0, + tertiary: p.overlay0, + separator: p.surface0, + hover: veil(p.overlay0, 50), + pressed: veil(p.overlay0, 90), + popover: p.surface0, + } +} + +/// The accent follows the chosen flavour (mauve), else the system accent. +pub fn sync_accent() { + set_accent(match choice() { + Choice::Catppuccin(f) => f.palette().mauve, + _ => system_accent(), + }); +} + +/// Fonts plus the light and dark styles. egui picks the one matching the system appearance +/// (reported by egui-winit) unless the choice forces one; headless rendering falls back to light. pub fn apply(ctx: &egui::Context) { + apply_choice(ctx, choice()); +} + +pub fn apply_choice(ctx: &egui::Context, c: Choice) { + CHOICE.store(c.index(), Ordering::Relaxed); + sync_accent(); install_fonts(ctx); + let (pref, flavor) = match c { + Choice::System => (egui::ThemePreference::System, None), + Choice::Light => (egui::ThemePreference::Light, None), + Choice::Dark => (egui::ThemePreference::Dark, None), + Choice::Catppuccin(f) => ( + if f.is_dark() { + egui::ThemePreference::Dark + } else { + egui::ThemePreference::Light + }, + Some(f), + ), + }; ctx.options_mut(|o| { - o.theme_preference = egui::ThemePreference::System; + o.theme_preference = pref; o.fallback_theme = egui::Theme::Light; }); - ctx.style_mut_of(egui::Theme::Light, |s| style_for(s, false)); - ctx.style_mut_of(egui::Theme::Dark, |s| style_for(s, true)); + for (theme, dark) in [(egui::Theme::Light, false), (egui::Theme::Dark, true)] { + let p = match flavor { + Some(f) if f.is_dark() == dark => catppuccin_palette(f), + _ => palette(dark), + }; + ctx.style_mut_of(theme, |s| style_for(s, dark, p)); + } } /// Thin line icons in the spirit of SF Symbols, drawn into a square `rect`. @@ -813,3 +950,38 @@ pub fn icon_button(ui: &mut egui::Ui, icon: Icon, size: f32, selected: bool) -> } resp } + +#[cfg(test)] +mod choice_tests { + use super::*; + + #[test] + fn choices_round_trip_and_catppuccin_uses_the_flavour_palette() { + for c in Choice::ALL { + assert_eq!(Choice::parse(c.id()), Some(c), "{}", c.id()); + if let Choice::Catppuccin(f) = c { + let p = catppuccin_palette(f); + assert_eq!((p.window, p.text), (f.palette().base, f.palette().text)); + } + } + assert_eq!(Choice::parse("nope"), None); + } + + #[test] + fn a_flavour_forces_its_brightness_on_the_context() { + let ctx = egui::Context::default(); + for c in Choice::ALL { + apply_choice(&ctx, c); + if let Choice::Catppuccin(f) = c { + let dark = ctx.style_of(if f.is_dark() { + egui::Theme::Dark + } else { + egui::Theme::Light + }); + assert_eq!(dark.visuals.window_fill, catppuccin_palette(f).popover); + assert_eq!(accent(), f.palette().mauve); + } + } + apply_choice(&ctx, Choice::System); + } +} diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 9b900ed..6ec08ca 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -32,6 +32,7 @@ pub enum Command { Layer(NodeId), Library(String), ToggleLibrary, + Theme(theme::Choice), } pub struct Entry { @@ -130,6 +131,13 @@ pub fn entries(ed: &Editor, st: &UiState) -> Vec { detail: String::new(), cmd: Command::ToggleLibrary, }); + for c in theme::Choice::ALL { + out.push(Entry { + label: format!("Theme: {}", c.label()), + detail: "Appearance".into(), + cmd: Command::Theme(c), + }); + } for (i, p) in ed.pages.iter().enumerate() { out.push(Entry { label: format!("Go to page: {}", p.name), @@ -190,6 +198,8 @@ pub fn run(ed: &mut Editor, st: &mut UiState, cmd: Command) { } } Command::ToggleLibrary => st.show_library = !st.show_library, + // Needs the egui context; handled where the palette is drawn. + Command::Theme(_) => {} } } @@ -297,7 +307,12 @@ pub fn draw(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui }); if let Some(c) = chosen { st.palette.open = false; - run(ed, st, c); + if let Command::Theme(t) = c { + theme::apply_choice(ctx, t); + t.save(); + } else { + run(ed, st, c); + } } } diff --git a/tools/craft-skills b/tools/craft-skills new file mode 160000 index 0000000..26c1c20 --- /dev/null +++ b/tools/craft-skills @@ -0,0 +1 @@ +Subproject commit 26c1c201a7ddc2784703a1ef367190c992920288