Files
deckcraft/crates/ui-egui/src/theme.rs
T
2026-10-12 22:26:34 -04:00

269 lines
11 KiB
Rust

//! Design tokens (light, PowerPoint-like; and dark) and fonts for the UI.
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, Stroke, Visuals};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Brightness {
#[default]
Light,
Dark,
}
/// Every colour the chrome uses.
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
pub dark: bool,
/// Window background (title bar, tab row, side panes).
pub chrome: Color32,
/// The ribbon card and popovers.
pub card: Color32,
pub card_border: Color32,
/// Canvas around the slide.
pub canvas: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_faint: Color32,
pub border: Color32,
pub separator: Color32,
pub hover: Color32,
pub pressed: Color32,
pub selected: Color32,
/// DeckCraft's accent (tabs underline, selected thumbnail, primary buttons).
pub accent: Color32,
pub accent_text: Color32,
pub contextual: Color32,
pub selection: Color32,
pub handle_fill: Color32,
pub handle_stroke: Color32,
pub guide: Color32,
pub caret: Color32,
pub text_selection: Color32,
pub field_bg: Color32,
pub shadow: Color32,
}
impl Tokens {
pub fn light() -> Self {
Tokens {
dark: false,
chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5),
card: Color32::WHITE,
card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0),
text: Color32::from_rgb(0x24, 0x24, 0x24),
text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C),
text_faint: Color32::from_rgb(0x99, 0x99, 0x99),
border: Color32::from_rgb(0xD6, 0xD6, 0xD6),
separator: Color32::from_rgb(0xE1, 0xE1, 0xE1),
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
pressed: Color32::from_rgb(0xDE, 0xDE, 0xDE),
selected: Color32::from_rgb(0xE6, 0xE6, 0xE6),
accent: Color32::from_rgb(0xD9, 0x5A, 0x1A),
accent_text: Color32::WHITE,
contextual: Color32::from_rgb(0xC2, 0x4E, 0x14),
selection: Color32::from_rgb(0x7A, 0x7A, 0x7A),
handle_fill: Color32::WHITE,
handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E),
guide: Color32::from_rgb(0xE8, 0x5D, 0x2A),
caret: Color32::BLACK,
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
field_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(28),
}
}
pub fn dark() -> Self {
Tokens {
dark: true,
chrome: Color32::from_rgb(0x26, 0x26, 0x26),
card: Color32::from_rgb(0x33, 0x33, 0x33),
card_border: Color32::from_rgb(0x44, 0x44, 0x44),
canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E),
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB),
text_faint: Color32::from_rgb(0x88, 0x88, 0x88),
border: Color32::from_rgb(0x4A, 0x4A, 0x4A),
separator: Color32::from_rgb(0x48, 0x48, 0x48),
hover: Color32::from_rgb(0x40, 0x40, 0x40),
pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C),
selected: Color32::from_rgb(0x47, 0x47, 0x47),
accent: Color32::from_rgb(0xF2, 0x6B, 0x1D),
accent_text: Color32::WHITE,
contextual: Color32::from_rgb(0xF5, 0x8A, 0x4B),
selection: Color32::from_rgb(0xAA, 0xAA, 0xAA),
handle_fill: Color32::WHITE,
handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E),
guide: Color32::from_rgb(0xF2, 0x8A, 0x4B),
caret: Color32::BLACK,
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A),
shadow: Color32::from_black_alpha(90),
}
}
pub fn for_brightness(b: Brightness) -> Self {
match b {
Brightness::Light => Self::light(),
Brightness::Dark => Self::dark(),
}
}
pub fn get(ctx: &egui::Context) -> Self {
if ctx.global_style().visuals.dark_mode { Self::dark() } else { Self::light() }
}
}
/// Apply tokens to egui's style.
pub fn apply(ctx: &egui::Context, t: &Tokens) {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.chrome;
v.window_fill = t.card;
v.extreme_bg_color = t.field_bg;
v.faint_bg_color = t.hover;
v.window_stroke = Stroke::new(1.0, t.card_border);
v.window_corner_radius = CornerRadius::same(10);
v.menu_corner_radius = CornerRadius::same(8);
v.selection.bg_fill = t.accent.gamma_multiply(0.85);
v.selection.stroke = Stroke::new(1.0, t.accent_text);
v.hyperlink_color = t.accent;
v.override_text_color = Some(t.text);
for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] {
w.corner_radius = CornerRadius::same(5);
}
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
v.widgets.inactive.bg_fill = t.field_bg;
v.widgets.inactive.bg_stroke = Stroke::NONE;
v.widgets.hovered.weak_bg_fill = t.hover;
v.widgets.hovered.bg_fill = t.hover;
v.widgets.hovered.bg_stroke = Stroke::NONE;
v.widgets.active.weak_bg_fill = t.pressed;
v.widgets.active.bg_fill = t.pressed;
v.widgets.open.weak_bg_fill = t.pressed;
v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow };
v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow };
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
s.spacing.interact_size.y = 22.0;
s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Button, egui::FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(11.0));
s.text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(18.0));
});
}
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
pub fn install_fonts(ctx: &egui::Context) {
install_fonts_with(ctx, &[]);
}
/// [`install_fonts`], with `extra` faces from the font database after every interface font: the
/// installed or embedded faces that draw text the interface faces lack (Hangul, Simplified
/// Chinese, Devanagari…, #101). See [`crate::uifonts`].
pub fn install_fonts_with(ctx: &egui::Context, extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
ctx.set_fonts(font_definitions(extra));
}
/// The interface font definitions; see [`install_fonts_with`].
pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) -> FontDefinitions {
let mut defs = FontDefinitions::default();
let mut names = vec![];
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
let wanted = (f.family == "Inter" && (f.style == "Regular" || f.style == "SemiBold" || f.style == "Bold"))
|| (f.family == "BIZ UDPGothic" && f.style == "Regular")
|| (f.family == "Noto Sans Arabic");
if !wanted {
continue;
}
let name = format!("craft-{i}-{}-{}", f.family, f.style);
defs.font_data.insert(name.clone(), std::sync::Arc::new(FontData::from_static(f.bytes)));
names.push((f.family, f.style, name));
}
let regular: Vec<String> = names.iter().filter(|(fam, st, _)| !(*fam == "Inter" && *st != "Regular")).map(|(_, _, n)| n.clone()).collect();
if let Some(list) = defs.families.get_mut(&FontFamily::Proportional) {
for (k, n) in regular.iter().enumerate() {
list.insert(k, n.clone());
}
}
// A bold family for headings and selected tabs.
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
add_fallback_faces(&mut defs, extra);
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
// unhinted, they draw.
for (name, d) in defs.font_data.iter_mut() {
if name.contains("BIZ UDPGothic") {
let mut data = (**d).clone();
data.tweak.hinting = Some(false);
*d = std::sync::Arc::new(data);
}
}
defs
}
/// Add `faces` after every other font of each interface family (the last fallbacks).
pub fn add_fallback_faces(defs: &mut FontDefinitions, faces: &[std::sync::Arc<deckcraft_fonts::FontFace>]) {
for f in faces {
let name = format!("fallback {} {} {}", f.family, f.style, f.id());
if defs.font_data.contains_key(&name) {
continue;
}
let mut data = FontData::from_owned(f.data().to_vec());
data.index = f.index();
defs.font_data.insert(name.clone(), std::sync::Arc::new(data));
for list in defs.families.values_mut() {
list.push(name.clone());
}
}
}
pub fn bold(size: f32) -> egui::FontId {
egui::FontId::new(size, FontFamily::Name("bold".into()))
}
pub fn font(size: f32) -> egui::FontId {
egui::FontId::proportional(size)
}
pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
}
#[cfg(test)]
mod tests {
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
/// has ink (a glyph image).
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
let ctx = egui::Context::default();
ctx.set_fonts(super::font_definitions(&[]));
let mut o = ctx.run_ui(Default::default(), |_| {});
o.textures_delta.clear();
let mut out = vec![];
ctx.fonts_mut(|f| {
for size in [11.0, 12.0, 13.0, 16.0] {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
}
}
}
});
let mut o = ctx.run_ui(Default::default(), |_| {});
o.textures_delta.clear();
out
}
#[test]
fn thin_punctuation_is_drawn_in_interface_text() {
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
// so notes and the outline showed gaps where they were.
for (c, size, ink) in drawn("\u{2013}\u{2014}-_\u{2026}\u{2022}\u{00b7}\u{2018}\u{2019}\u{201c}\u{201d}=~") {
assert!(ink, "{c:?} ({:04X}) draws nothing at {size} px", c as u32);
}
}
}