vectorcraft/crates/ui-egui/src/theme.rs

354 lines
14 KiB
Rust

//! Design tokens (Illustrator's four UI brightness levels), fonts and egui style.
use std::sync::Arc;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Brightness {
Dark,
#[default]
MediumDark,
MediumLight,
Light,
}
impl Brightness {
pub const ALL: [Brightness; 4] = [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light];
pub fn label(self) -> &'static str {
match self {
Brightness::Dark => "Dark",
Brightness::MediumDark => "Medium Dark",
Brightness::MediumLight => "Medium Light",
Brightness::Light => "Light",
}
}
pub fn id(self) -> &'static str {
match self {
Brightness::Dark => "dark",
Brightness::MediumDark => "mediumDark",
Brightness::MediumLight => "mediumLight",
Brightness::Light => "light",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s) || b.label().eq_ignore_ascii_case(s))
}
}
/// All colours and metrics the UI uses. Never hard-code a colour in a widget.
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
pub dark: bool,
pub app_bar: Color32,
pub panel: Color32,
pub panel_darker: Color32,
pub input: Color32,
pub input_border: Color32,
pub border: Color32,
pub divider: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_disabled: Color32,
pub icon: Color32,
pub hover: Color32,
pub tool_active: Color32,
pub accent: Color32,
pub accent_strong: Color32,
pub row_selected: Color32,
pub pasteboard: Color32,
pub ruler: Color32,
pub ruler_tick: Color32,
pub smart_guide: Color32,
pub guide: Color32,
pub measure_bg: Color32,
pub button: Color32,
pub radius: u8,
/// Field values, button labels, link labels.
pub text_strong: Color32,
/// Panel tab strips, doc-tab bar, dock header.
pub tab_strip: Color32,
/// Outline of secondary (outlined) buttons.
pub button_border: Color32,
/// Default Layer 1 selection colour and selected-anchor fill.
pub selection: Color32,
pub anchor_selected: Color32,
/// Active item text in tool flyouts.
pub flyout_active: Color32,
/// Hint-bar / tab-bar heights etc. scale with this base font size.
pub font_size: f32,
/// Window Close button (custom title bar) when hovered, and its glyph there.
pub caption_close: Color32,
pub caption_close_text: Color32,
/// What preview images mark (Flattener Preview highlights).
pub highlight: Color32,
/// The bleed outline around artboards (Document Setup → Bleed).
pub bleed: Color32,
/// Error marks (a missing linked file).
pub error: Color32,
/// Warning marks (a modified linked file).
pub warning: Color32,
}
impl Tokens {
pub fn for_brightness(b: Brightness) -> Self {
let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8);
let base = Tokens {
dark: true,
app_bar: hex(0x262626),
panel: hex(0x323232),
panel_darker: hex(0x282828),
input: hex(0x1f1f1f),
input_border: hex(0x464646),
border: hex(0x1b1b1b),
divider: hex(0x3f3f3f),
text: hex(0xd8d8d8),
text_dim: hex(0xa8a8a8),
text_disabled: hex(0x6e6e6e),
icon: hex(0xcfcfcf),
hover: hex(0x444444),
tool_active: hex(0x1d1d1d),
accent: hex(0x378ef0),
accent_strong: hex(0x1473e6),
row_selected: hex(0x484b50),
pasteboard: hex(0x1e1e1e),
ruler: hex(0x2f2f2f),
ruler_tick: hex(0x8a8a8a),
smart_guide: hex(0xff3dfc),
guide: hex(0x4affff),
measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235),
button: hex(0x444444),
radius: 2,
text_strong: hex(0xf5f5f5),
tab_strip: hex(0x282828),
button_border: hex(0x6a6a6a),
selection: hex(0x4f80ff),
anchor_selected: hex(0x3d82ff),
flyout_active: hex(0x54b2f7),
font_size: 13.0,
caption_close: hex(0xc42b1c),
caption_close_text: Color32::WHITE,
highlight: hex(0xff3030),
bleed: hex(0xf03030),
error: hex(0xe34850),
warning: hex(0xf0a330),
};
match b {
Brightness::Dark => base,
// Measured from Illustrator 2026 (plan/illustrator/10-observed-ui.md §1).
Brightness::MediumDark => Tokens {
app_bar: hex(0x535353),
panel: hex(0x535353),
panel_darker: hex(0x424242),
tab_strip: hex(0x424242),
input: hex(0x454545),
input_border: hex(0x5f5f5f),
border: hex(0x383838),
divider: hex(0x4b4b4b),
text: hex(0xd1d1d1),
text_strong: hex(0xffffff),
text_dim: hex(0xb0b0b0),
text_disabled: hex(0x7a7a7a),
icon: hex(0xc2c2c2),
hover: hex(0x606060),
tool_active: hex(0x303030),
row_selected: hex(0x52677c),
pasteboard: hex(0x606060),
ruler: hex(0x333333),
ruler_tick: hex(0x8a8a8a),
button: hex(0x535353),
button_border: hex(0x747474),
..base
},
Brightness::MediumLight => Tokens {
tab_strip: hex(0xa6a6a6),
text_strong: hex(0x000000),
button_border: hex(0x7a7a7a),
dark: false,
app_bar: hex(0xa9a9a9),
panel: hex(0xb8b8b8),
panel_darker: hex(0xaaaaaa),
input: hex(0xd6d6d6),
input_border: hex(0x8c8c8c),
border: hex(0x969696),
divider: hex(0x9e9e9e),
text: hex(0x1f1f1f),
text_dim: hex(0x3c3c3c),
text_disabled: hex(0x7c7c7c),
icon: hex(0x2a2a2a),
hover: hex(0xc8c8c8),
tool_active: hex(0x969696),
row_selected: hex(0x9fb4d6),
pasteboard: hex(0xa0a0a0),
ruler: hex(0xc2c2c2),
ruler_tick: hex(0x4a4a4a),
button: hex(0xcacaca),
..base
},
Brightness::Light => Tokens {
tab_strip: hex(0xdddddd),
text_strong: hex(0x000000),
button_border: hex(0x9a9a9a),
dark: false,
app_bar: hex(0xe4e4e4),
panel: hex(0xf0f0f0),
panel_darker: hex(0xe2e2e2),
input: hex(0xffffff),
input_border: hex(0xb4b4b4),
border: hex(0xcfcfcf),
divider: hex(0xd6d6d6),
text: hex(0x1f1f1f),
text_dim: hex(0x4b4b4b),
text_disabled: hex(0x9a9a9a),
icon: hex(0x303030),
hover: hex(0xdddddd),
tool_active: hex(0xcdcdcd),
row_selected: hex(0xc8daf7),
pasteboard: hex(0xdcdcdc),
ruler: hex(0xeaeaea),
ruler_tick: hex(0x5a5a5a),
button: hex(0xe0e0e0),
..base
},
}
}
pub fn get(ctx: &egui::Context) -> Tokens {
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::MediumDark))
}
pub fn cr(&self) -> CornerRadius {
CornerRadius::same(self.radius)
}
}
pub const FONT_UI: &str = "ui";
pub const FONT_UI_SEMIBOLD: &str = "ui-semibold";
pub const FONT_MONO: &str = "mono";
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(FONT_UI_SEMIBOLD.into()))
}
pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(FONT_MONO.into()))
}
pub fn install_fonts(ctx: &egui::Context) {
let mut fonts = FontDefinitions::default();
let add = |f: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
f.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
};
add(&mut fonts, "SourceSans3", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"));
add(&mut fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"));
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["SourceSans3".to_string()];
prop.extend(fallback.clone());
fonts.families.insert(FontFamily::Proportional, prop);
let mut semi = vec!["SourceSans3-Semibold".to_string()];
semi.extend(fallback.clone());
fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), semi);
let mut ui = vec!["SourceSans3".to_string()];
ui.extend(fallback.clone());
fonts.families.insert(FontFamily::Name(FONT_UI.into()), ui);
let mut mono = vec!["JetBrainsMono".to_string()];
mono.extend(fallback);
fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono);
add_craft_fonts(&mut fonts);
ctx.set_fonts(fonts);
}
/// The egui name of a craft-fonts face.
pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
format!("craft-fonts {} {}", f.family, f.style)
}
/// The Japanese craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at the end of
/// every family, after the app's own fonts and before the installed fonts `ui_fonts` adds: BIZ
/// UDPGothic first (bold first in the semibold family), then the Mincho faces.
fn add_craft_fonts(fonts: &mut FontDefinitions) {
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.scripts.contains(&"Hans")) {
let data = if f.scripts.contains(&"Hans") { crate::ui_fonts::craft_font_data(f) } else { FontData::from_static(f.bytes) };
fonts.font_data.insert(craft_font_name(f), Arc::new(data));
}
for (family, stack) in fonts.families.iter_mut() {
let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into());
let chinese: Vec<String> = vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans")).map(craft_font_name).collect();
if crate::i18n::current().code() == "zh-hans" {
stack.extend(chinese.iter().cloned());
}
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name));
if crate::i18n::current().code() != "zh-hans" {
stack.extend(chinese);
}
}
}
/// Apply tokens to egui's global style.
pub fn apply(ctx: &egui::Context, b: Brightness) {
let t = Tokens::for_brightness(b);
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
v.extreme_bg_color = t.input;
v.faint_bg_color = t.panel_darker;
v.window_stroke = Stroke::new(1.0, t.border);
v.window_corner_radius = CornerRadius::same(6);
v.menu_corner_radius = CornerRadius::same(6);
v.selection.bg_fill = t.accent.gamma_multiply(0.55);
v.selection.stroke = Stroke::new(1.0, t.accent);
v.hyperlink_color = t.accent;
v.override_text_color = Some(t.text);
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
let w = &mut v.widgets;
for (wv, fill) in [
(&mut w.noninteractive, t.panel),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.tool_active),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = t.cr();
wv.fg_stroke = Stroke::new(1.0, t.text);
}
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::NONE;
w.hovered.bg_stroke = Stroke::new(1.0, t.input_border);
w.active.bg_stroke = Stroke::new(1.0, t.accent);
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 5.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.spacing.interact_size = egui::vec2(24.0, 24.0);
s.spacing.menu_margin = egui::Margin::same(6);
s.spacing.slider_width = 120.0;
s.text_styles = [
(TextStyle::Small, FontId::proportional(11.0)),
(TextStyle::Body, FontId::proportional(13.0)),
(TextStyle::Button, FontId::proportional(13.0)),
(TextStyle::Heading, FontId::new(15.0, FontFamily::Name(FONT_UI_SEMIBOLD.into()))),
(TextStyle::Monospace, FontId::new(11.5, FontFamily::Name(FONT_MONO.into()))),
]
.into();
s.animation_time = 0.08;
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brightness_roundtrip() {
for b in Brightness::ALL {
assert_eq!(Brightness::parse(b.id()), Some(b));
assert_eq!(Brightness::parse(b.label()), Some(b));
}
assert!(!Tokens::for_brightness(Brightness::Light).dark);
}
}