feat(ui): Catppuccin themes (View > Theme, command palette); use the masi/craft-libs fork

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-12 10:43:59 -03:00
parent 0b1b91da7c
commit 0e59537416
9 changed files with 240 additions and 12 deletions
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
Generated
+9
View File
@@ -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",
+1
View File
@@ -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"
+8 -3
View File
@@ -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<UserEvent> 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;
}
+18
View File
@@ -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,
}
}
+180 -8
View File
@@ -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> {
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<std::path::PathBuf> {
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);
}
}
+16 -1
View File
@@ -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<Entry> {
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);
}
}
}
Submodule tools/craft-skills added at 26c1c201a7