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:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
Generated
+9
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+1
Submodule tools/craft-skills added at 26c1c201a7
Reference in New Issue
Block a user