mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
543 lines
25 KiB
Rust
543 lines
25 KiB
Rust
//! Design tokens and egui style (plan/acrobat/02-ui-ux.md §1: neutral chrome, white panels,
|
||
//! one blue accent; Dark Gray keeps pages white). Every custom widget reads `Tokens::get`.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use egui::epaint::text::{FallbackAlign, FontTweak, HintingTarget, SmoothHinting};
|
||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals};
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||
pub enum ThemeKind {
|
||
#[default]
|
||
Light,
|
||
Dark,
|
||
}
|
||
|
||
/// The saved user choice, independent of the light/dark colours currently displayed. A fresh
|
||
/// install follows the system (#1248); a desktop that says nothing gets Light.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||
pub enum ThemePreference {
|
||
#[default]
|
||
System,
|
||
Light,
|
||
Dark,
|
||
}
|
||
|
||
impl ThemePreference {
|
||
/// The colours to draw. `system` is what the operating system says, `None` when it says
|
||
/// nothing; `fallback` is supplied by the caller. PdfCraft uses Light for an unknown desktop.
|
||
pub fn resolve(self, system: Option<bool>, fallback: ThemeKind) -> ThemeKind {
|
||
match self {
|
||
Self::Light => ThemeKind::Light,
|
||
Self::Dark => ThemeKind::Dark,
|
||
Self::System => match system {
|
||
Some(true) => ThemeKind::Dark,
|
||
Some(false) => ThemeKind::Light,
|
||
None => fallback,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Tokens {
|
||
pub kind: ThemeKind,
|
||
/// Title/tab strip.
|
||
pub titlebar: Color32,
|
||
/// Mode bar, panels.
|
||
pub chrome: Color32,
|
||
pub panel: Color32,
|
||
/// Document area behind pages.
|
||
pub pasteboard: Color32,
|
||
pub card: Color32,
|
||
pub border: Color32,
|
||
pub divider: Color32,
|
||
pub text: Color32,
|
||
pub text_muted: Color32,
|
||
pub text_faint: Color32,
|
||
pub icon: Color32,
|
||
pub hover: Color32,
|
||
pub pressed: Color32,
|
||
pub selected: Color32,
|
||
pub accent: Color32,
|
||
pub accent_text: Color32,
|
||
pub accent_soft: Color32,
|
||
pub field: Color32,
|
||
pub badge_new: Color32,
|
||
pub page_shadow: Color32,
|
||
pub radius: u8,
|
||
}
|
||
|
||
impl Tokens {
|
||
pub fn for_kind(kind: ThemeKind) -> Self {
|
||
match kind {
|
||
ThemeKind::Light => Self {
|
||
kind,
|
||
titlebar: Color32::from_rgb(0xE9, 0xE9, 0xEB),
|
||
chrome: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||
panel: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||
pasteboard: Color32::from_rgb(0xF1, 0xF1, 0xF3),
|
||
card: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||
border: Color32::from_rgb(0xDA, 0xDA, 0xDE),
|
||
divider: Color32::from_rgb(0xE8, 0xE8, 0xEB),
|
||
text: Color32::from_rgb(0x22, 0x22, 0x26),
|
||
text_muted: Color32::from_rgb(0x5E, 0x5E, 0x66),
|
||
text_faint: Color32::from_rgb(0x6B, 0x6B, 0x73),
|
||
icon: Color32::from_rgb(0x44, 0x44, 0x4B),
|
||
hover: Color32::from_rgb(0xF0, 0xF0, 0xF3),
|
||
pressed: Color32::from_rgb(0xE4, 0xE4, 0xE9),
|
||
selected: Color32::from_rgb(0xE6, 0xEE, 0xFD),
|
||
accent: Color32::from_rgb(0x1B, 0x63, 0xE0),
|
||
accent_text: Color32::from_rgb(0x17, 0x55, 0xC4),
|
||
accent_soft: Color32::from_rgb(0xE3, 0xEC, 0xFD),
|
||
field: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||
badge_new: Color32::from_rgb(0x1B, 0x63, 0xE0),
|
||
page_shadow: Color32::from_black_alpha(34),
|
||
radius: 6,
|
||
},
|
||
ThemeKind::Dark => Self {
|
||
kind,
|
||
titlebar: Color32::from_rgb(0x1B, 0x1B, 0x1E),
|
||
chrome: Color32::from_rgb(0x26, 0x26, 0x2A),
|
||
panel: Color32::from_rgb(0x26, 0x26, 0x2A),
|
||
pasteboard: Color32::from_rgb(0x19, 0x19, 0x1C),
|
||
card: Color32::from_rgb(0x2E, 0x2E, 0x33),
|
||
border: Color32::from_rgb(0x3C, 0x3C, 0x43),
|
||
divider: Color32::from_rgb(0x33, 0x33, 0x39),
|
||
text: Color32::from_rgb(0xEC, 0xEC, 0xEF),
|
||
text_muted: Color32::from_rgb(0xAE, 0xAE, 0xB6),
|
||
text_faint: Color32::from_rgb(0x97, 0x97, 0x9E),
|
||
icon: Color32::from_rgb(0xD4, 0xD4, 0xDA),
|
||
hover: Color32::from_rgb(0x34, 0x34, 0x3A),
|
||
pressed: Color32::from_rgb(0x3E, 0x3E, 0x45),
|
||
selected: Color32::from_rgb(0x23, 0x3A, 0x63),
|
||
accent: Color32::from_rgb(0x4B, 0x8B, 0xF5),
|
||
accent_text: Color32::from_rgb(0x8C, 0xB6, 0xFA),
|
||
accent_soft: Color32::from_rgb(0x24, 0x36, 0x57),
|
||
field: Color32::from_rgb(0x1E, 0x1E, 0x22),
|
||
badge_new: Color32::from_rgb(0x3D, 0x7D, 0xEE),
|
||
page_shadow: Color32::from_black_alpha(120),
|
||
radius: 6,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Return the cached theme tokens, falling back to the light palette before the first apply.
|
||
pub fn get(ctx: &egui::Context) -> Self {
|
||
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::unique("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light))
|
||
}
|
||
|
||
pub fn dark(&self) -> bool {
|
||
self.kind == ThemeKind::Dark
|
||
}
|
||
}
|
||
|
||
/// Hover and press amounts for chrome controls. The wash eases in; holding the pointer
|
||
/// darkens it and drops the content one pixel, so a click reads as a key settling.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Press {
|
||
pub hover: f32,
|
||
pub down: f32,
|
||
}
|
||
|
||
impl Press {
|
||
pub fn track(ui: &egui::Ui, resp: &Response) -> Self {
|
||
let hot = resp.hovered() || resp.is_pointer_button_down_on();
|
||
let ease = egui::emath::easing::quadratic_out;
|
||
// With animation effects off the wash and the press are simply there.
|
||
let (hover, down) = if crate::motion::animated(ui.ctx()) { (0.12, 0.06) } else { (0.0, 0.0) };
|
||
Self {
|
||
hover: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-hover"), hot, hover, ease),
|
||
down: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-down"), resp.is_pointer_button_down_on(), down, ease),
|
||
}
|
||
}
|
||
|
||
pub fn fill(self, rest: Color32, hover: Color32, pressed: Color32) -> Color32 {
|
||
rest.lerp_to_gamma(hover, self.hover).lerp_to_gamma(pressed, self.down)
|
||
}
|
||
|
||
/// One pixel down at a full press.
|
||
pub fn offset(self) -> Vec2 {
|
||
Vec2::new(0.0, self.down)
|
||
}
|
||
|
||
/// The colour [`Self::wash`] paints.
|
||
pub fn wash_color(self, t: &Tokens, selected: bool) -> Color32 {
|
||
if selected {
|
||
self.fill(t.accent_soft, t.accent_soft.lerp_to_gamma(t.accent, 0.16), t.selected)
|
||
} else {
|
||
self.fill(Color32::TRANSPARENT, t.hover, t.pressed)
|
||
}
|
||
}
|
||
|
||
/// Soft wash behind a control. `selected` keeps the accent fill.
|
||
pub fn wash(self, ui: &egui::Ui, rect: Rect, radius: impl Into<CornerRadius>, selected: bool) {
|
||
let fill = self.wash_color(&Tokens::get(ui.ctx()), selected);
|
||
if fill.a() > 0 {
|
||
ui.painter().rect_filled(rect, radius, fill);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pointing hand, the cursor for a control that clicks.
|
||
pub fn hand(resp: Response) -> Response {
|
||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||
}
|
||
|
||
pub fn install_fonts(ctx: &egui::Context) {
|
||
install_fonts_for(ctx, CjkPreference::Default);
|
||
}
|
||
|
||
/// The interface language's claim on the CJK fallback faces.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum CjkPreference {
|
||
/// Japanese faces first (every non-Chinese language).
|
||
#[default]
|
||
Default,
|
||
SimplifiedChinese,
|
||
TraditionalChinese,
|
||
}
|
||
|
||
impl CjkPreference {
|
||
/// The preference for a UI language code (`zh-hans`, `zh-hant`, anything else).
|
||
pub fn for_language(code: &str) -> Self {
|
||
match code {
|
||
"zh-hans" => Self::SimplifiedChinese,
|
||
"zh-hant" => Self::TraditionalChinese,
|
||
_ => Self::Default,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Install the interface fonts with the CJK fallback order for the UI language (Chinese
|
||
/// first for Chinese, Japanese first otherwise). Call it when the language changes; the new
|
||
/// faces take effect next frame.
|
||
pub fn install_fonts_for(ctx: &egui::Context, cjk: CjkPreference) {
|
||
ctx.set_fonts(installed_font_definitions(cjk));
|
||
}
|
||
|
||
/// The name of the installed face [`installed_font_definitions`] may add after the embedded ones.
|
||
pub const SYSTEM_FALLBACK: &str = "system-fallback";
|
||
/// The name of the installed Devanagari (Hindi) face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback";
|
||
/// The name of the installed Telugu face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_TELUGU_FALLBACK: &str = "system-telugu-fallback";
|
||
/// The name of the installed Sinhala face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_SINHALA_FALLBACK: &str = "system-sinhala-fallback";
|
||
/// The name of the installed Tamil face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_TAMIL_FALLBACK: &str = "system-tamil-fallback";
|
||
/// The name of the installed Hebrew face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_HEBREW_FALLBACK: &str = "system-hebrew-fallback";
|
||
/// The name of the installed Hangul (Korean) face [`installed_font_definitions`] may add.
|
||
pub const SYSTEM_HANGUL_FALLBACK: &str = "system-hangul-fallback";
|
||
|
||
/// Which installed face an interface language needs, given whether the build embeds a `Hans`
|
||
/// face.
|
||
///
|
||
/// A bundled `Hans` face serves Simplified Chinese, so the installed face stays last there. It
|
||
/// cannot serve Traditional Chinese: a Traditional interface keeps an installed `Hant` face
|
||
/// before the Japanese faces even when a Simplified-only face is embedded, or Traditional-only
|
||
/// characters would fall through to a Japanese face first.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn installed_fallback_need(cjk: CjkPreference, has_embedded_hans: bool) -> crate::system_fonts::Need {
|
||
use crate::system_fonts::Need;
|
||
match cjk {
|
||
CjkPreference::SimplifiedChinese if has_embedded_hans => Need::Other,
|
||
CjkPreference::SimplifiedChinese => Need::Hans,
|
||
CjkPreference::TraditionalChinese => Need::Hant,
|
||
CjkPreference::Default => Need::Other,
|
||
}
|
||
}
|
||
|
||
/// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face
|
||
/// already installed on this machine. It only draws characters no embedded face has.
|
||
///
|
||
/// - In a Simplified Chinese interface built without an embedded `Hans` face (#826), it is a
|
||
/// Simplified Chinese system face (Microsoft YaHei, PingFang SC, …), placed before the Japanese
|
||
/// craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), and one
|
||
/// line drawn from two faces with different vertical metrics would not sit on one baseline.
|
||
/// - A Traditional Chinese interface always uses an installed `Hant` face before the Japanese
|
||
/// faces, even when a Simplified-only `Hans` face is embedded: that face cannot supply
|
||
/// Traditional-only characters (說, 體, …).
|
||
/// - Otherwise it is the last fallback of every family (an Arabic file name in a build without
|
||
/// craft-fonts).
|
||
///
|
||
/// Installed Devanagari, Telugu, Sinhala, Tamil, Hebrew and Hangul faces, when there are, end
|
||
/// every family just before that one, so Hindi, Telugu (#1531), Sinhala and Tamil (#1688) and
|
||
/// Korean text, such as a file name or a tab title, and Hebrew file names (#586) draw in a build
|
||
/// without such faces.
|
||
///
|
||
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out.
|
||
pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
|
||
#[cfg_attr(target_arch = "wasm32", expect(unused_mut))]
|
||
let mut fonts = font_definitions_for(cjk == CjkPreference::SimplifiedChinese);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
use crate::system_fonts::{Need, fallback};
|
||
// Before the general installed face, which stays the last fallback outside Chinese.
|
||
for (name, data) in [
|
||
(SYSTEM_DEVANAGARI_FALLBACK, crate::system_fonts::devanagari_fallback()),
|
||
(SYSTEM_TELUGU_FALLBACK, crate::system_fonts::telugu_fallback()),
|
||
(SYSTEM_SINHALA_FALLBACK, crate::system_fonts::sinhala_fallback()),
|
||
(SYSTEM_TAMIL_FALLBACK, crate::system_fonts::tamil_fallback()),
|
||
(SYSTEM_HEBREW_FALLBACK, crate::system_fonts::hebrew_fallback()),
|
||
(SYSTEM_HANGUL_FALLBACK, crate::system_fonts::hangul_fallback()),
|
||
] {
|
||
let Some(data) = data else { continue };
|
||
fonts.font_data.insert(name.to_owned(), data);
|
||
for stack in fonts.families.values_mut() {
|
||
stack.push(name.to_owned());
|
||
}
|
||
}
|
||
let need = installed_fallback_need(cjk, !pdfcraft_fonts::ui_chinese_fonts().is_empty());
|
||
if let Some(data) = fallback(need) {
|
||
fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data);
|
||
let japanese: Vec<String> = pdfcraft_fonts::ui_japanese_fonts().iter().map(|f| f.name()).collect();
|
||
for stack in fonts.families.values_mut() {
|
||
let at = match need {
|
||
Need::Other => None,
|
||
Need::Hans | Need::Hant => stack.iter().position(|n| japanese.contains(n)),
|
||
};
|
||
stack.insert(at.unwrap_or(stack.len()), SYSTEM_FALLBACK.to_owned());
|
||
}
|
||
}
|
||
}
|
||
fonts
|
||
}
|
||
|
||
/// The interface fonts: Inter (and JetBrains Mono for code) first, then egui's defaults, then
|
||
/// the CJK, Arabic, Telugu, Hebrew and Thai faces of the optional craft-fonts build input as the
|
||
/// last fallback in every family. Without craft-fonts there is no Japanese, Chinese, Arabic,
|
||
/// Telugu, Hebrew or Thai face here.
|
||
pub fn font_definitions() -> FontDefinitions {
|
||
font_definitions_for(false)
|
||
}
|
||
|
||
/// [`font_definitions`] with the CJK fallback order for the UI language. Simplified Chinese
|
||
/// must come first in Chinese mode: otherwise shared characters render in the Japanese face
|
||
/// while Simplified-only characters (e.g. U+6B22 欢) fall through to the Chinese face, and
|
||
/// the mixed vertical metrics sink them below the line.
|
||
pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions {
|
||
font_definitions_for_platform(prefer_hans, current_font_policy_platform())
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum FontPolicyPlatform {
|
||
Windows,
|
||
Other,
|
||
}
|
||
|
||
fn current_font_policy_platform() -> FontPolicyPlatform {
|
||
if cfg!(target_os = "windows") { FontPolicyPlatform::Windows } else { FontPolicyPlatform::Other }
|
||
}
|
||
|
||
fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak {
|
||
let mut tweak = FontTweak::default();
|
||
if platform == FontPolicyPlatform::Windows && name == "JetBrainsMono" {
|
||
tweak.hinting_target = HintingTarget::Smooth(SmoothHinting { symmetric_rendering: false, ..SmoothHinting::default() });
|
||
}
|
||
tweak
|
||
}
|
||
|
||
/// `data` with its glyphs placed on the row's baseline (#1676). epaint 0.37 otherwise centres a
|
||
/// fallback face's line box on the primary's, so Chinese next to Latin in one label sits higher or
|
||
/// lower than the Latin by a few percent of the font size, depending on the installed or embedded
|
||
/// face (PingFang, Microsoft JhengHei, LXGW WenKai Lite…). `FallbackAlign::Baseline` makes epaint
|
||
/// position every face's glyphs from the primary face's ascent, which is exactly the existing
|
||
/// baseline-alignment intent without metric-derived offsets that go stale across egui versions.
|
||
pub(crate) fn on_interface_baseline(mut data: FontData) -> FontData {
|
||
data.tweak.fallback_align = FallbackAlign::Baseline;
|
||
data
|
||
}
|
||
|
||
fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform) -> FontDefinitions {
|
||
let mut fonts = FontDefinitions::default();
|
||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||
let data = FontData::from_static(bytes);
|
||
let data = if ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name) {
|
||
data.tweak(primary_font_tweak(platform, name))
|
||
} else {
|
||
on_interface_baseline(data)
|
||
};
|
||
fonts.font_data.insert(name.to_owned(), Arc::new(data));
|
||
};
|
||
// Shared with typed signatures' fallback glyphs, so the font is embedded once.
|
||
add(&mut fonts, "Inter", pdfcraft_fonts::INTER_REGULAR);
|
||
add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
|
||
add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
|
||
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
|
||
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
|
||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned());
|
||
// Keep missing Latin/Cyrillic letters in the app's own faces (for example Bulgarian ѝ,
|
||
// absent from JetBrains Mono), before egui's defaults or optional system/CJK fallbacks.
|
||
fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "Inter".to_owned());
|
||
// The same static bytes pdfcraft-fonts uses for Japanese/Chinese text in PDFs: one copy, not two.
|
||
for face in pdfcraft_fonts::ui_cjk_fonts(prefer_hans) {
|
||
let name = face.name();
|
||
// Japanese and Chinese faces have distinct family names, so no collision here.
|
||
if !fonts.font_data.contains_key(&name) {
|
||
add(&mut fonts, &name, face.bytes);
|
||
}
|
||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||
fonts.families.entry(family).or_default().push(name.clone());
|
||
}
|
||
}
|
||
// Arabic-script faces (file names, document titles), Telugu faces (the Telugu interface,
|
||
// file names), Hebrew faces (file names, text being edited), Thai faces and Devanagari faces
|
||
// (the Hindi interface) after the CJK ones; the ranges don't overlap.
|
||
for face in pdfcraft_fonts::ui_arabic_fonts()
|
||
.into_iter()
|
||
.chain(pdfcraft_fonts::ui_telugu_fonts())
|
||
.chain(pdfcraft_fonts::ui_hebrew_fonts())
|
||
.chain(pdfcraft_fonts::ui_thai_fonts())
|
||
.chain(pdfcraft_fonts::ui_devanagari_fonts())
|
||
{
|
||
let name = face.name();
|
||
if !fonts.font_data.contains_key(&name) {
|
||
add(&mut fonts, &name, face.bytes);
|
||
}
|
||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||
let stack = fonts.families.entry(family).or_default();
|
||
if !stack.contains(&name) {
|
||
stack.push(name.clone());
|
||
}
|
||
}
|
||
}
|
||
let fallback: Vec<String> = fonts.families[&FontFamily::Proportional].clone();
|
||
for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
|
||
let mut stack = vec![primary.to_owned()];
|
||
stack.extend(fallback.iter().cloned());
|
||
fonts.families.insert(FontFamily::Name(fam.into()), stack);
|
||
}
|
||
fonts
|
||
}
|
||
|
||
pub fn regular(size: f32) -> FontId {
|
||
FontId::proportional(size)
|
||
}
|
||
pub fn medium(size: f32) -> FontId {
|
||
FontId::new(size, FontFamily::Name("medium".into()))
|
||
}
|
||
pub fn semibold(size: f32) -> FontId {
|
||
FontId::new(size, FontFamily::Name("semibold".into()))
|
||
}
|
||
|
||
/// Install the palette, widget styling, text sizes and right-to-left flag for the chosen theme.
|
||
pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||
// Keep the native window inherited. A concrete egui theme pins macOS's
|
||
// NSWindow appearance and prevents winit from delivering ThemeChanged.
|
||
// Our saved choice owns the palette; both egui branches receive it below.
|
||
ctx.set_theme(egui::ThemePreference::System);
|
||
let rtl = crate::i18n::current().rtl();
|
||
let t = Tokens::for_kind(kind);
|
||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("pdfcraft-theme"), t));
|
||
let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() };
|
||
v.panel_fill = t.panel;
|
||
v.window_fill = t.card;
|
||
v.window_stroke = Stroke::new(1.0, t.border);
|
||
v.extreme_bg_color = t.field;
|
||
v.faint_bg_color = t.hover;
|
||
v.selection.bg_fill = t.accent_soft;
|
||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||
v.hyperlink_color = t.accent_text;
|
||
v.override_text_color = Some(t.text);
|
||
v.window_corner_radius = CornerRadius::same(10);
|
||
v.menu_corner_radius = CornerRadius::same(8);
|
||
v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark() { 110 } else { 38 }) };
|
||
v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 90 } else { 30 }) };
|
||
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(t.radius);
|
||
w.fg_stroke.color = t.text;
|
||
}
|
||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
|
||
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||
v.widgets.inactive.bg_fill = t.field;
|
||
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;
|
||
// Stock buttons (menus, dialogs) use the same pointing hand as the custom chrome.
|
||
v.interact_cursor = Some(egui::CursorIcon::PointingHand);
|
||
v.widgets.open.weak_bg_fill = t.hover;
|
||
// Crisper text at 100–150 % scaling (#76): glyphs sit on whole pixels instead of being
|
||
// rendered at quarter-pixel offsets, which egui notes blurs them. In the light theme, a mild
|
||
// gamma darkens the antialiased edges of dark text (egui's default is linear, which reads thin
|
||
// and grey next to the system's text); the dark theme keeps egui's own curve. At 200 % both
|
||
// make little difference.
|
||
v.text_options.subpixel_binning = false;
|
||
if !t.dark() {
|
||
v.text_options.color_transfer_function = egui::epaint::FontColorTransferFunction::Gamma(0.75);
|
||
}
|
||
ctx.set_visuals(v);
|
||
ctx.global_style_mut(|s| {
|
||
// RTL languages mirror stock chrome (menus, combo boxes, scroll bars, headers).
|
||
// Labels still get their paragraph direction explicitly from `bidi::ui_direction`.
|
||
s.right_to_left = rtl;
|
||
s.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||
s.spacing.button_padding = egui::vec2(10.0, 5.0);
|
||
s.spacing.menu_margin = egui::Margin::same(6);
|
||
s.spacing.scroll.bar_width = 8.0;
|
||
s.spacing.scroll.floating = true;
|
||
s.spacing.scroll.floating_width = 3.0;
|
||
s.spacing.scroll.foreground_color = true;
|
||
s.text_styles.insert(egui::TextStyle::Body, regular(13.0));
|
||
s.text_styles.insert(egui::TextStyle::Button, regular(13.0));
|
||
s.text_styles.insert(egui::TextStyle::Small, regular(11.0));
|
||
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
|
||
s.interaction.tooltip_delay = 0.35;
|
||
});
|
||
let style = ctx.global_style();
|
||
ctx.set_style_of(egui::Theme::Light, style.clone());
|
||
ctx.set_style_of(egui::Theme::Dark, style);
|
||
ctx.data_mut(|d| d.insert_temp(direction_key(), rtl));
|
||
}
|
||
|
||
/// Return the stable egui id holding the current right-to-left style flag.
|
||
fn direction_key() -> egui::Id {
|
||
egui::Id::unique("pdfcraft-style-right-to-left")
|
||
}
|
||
|
||
/// Keep stock egui chrome's direction in step with the drawing language, after a language
|
||
/// change as well as at startup. Setting a flag on both concrete themes is cheap; this avoids
|
||
/// rebuilding the whole visuals and font state every frame.
|
||
pub fn sync_direction(ctx: &egui::Context) {
|
||
let rtl = crate::i18n::current().rtl();
|
||
let changed = ctx.data_mut(|d| {
|
||
if d.get_temp::<bool>(direction_key()) == Some(rtl) {
|
||
false
|
||
} else {
|
||
d.insert_temp(direction_key(), rtl);
|
||
true
|
||
}
|
||
});
|
||
if changed {
|
||
ctx.all_styles_mut(|s| s.right_to_left = rtl);
|
||
}
|
||
}
|
||
|
||
/// WCAG 2 contrast ratio between two opaque colours.
|
||
pub fn contrast(a: Color32, b: Color32) -> f32 {
|
||
let lum = |c: Color32| {
|
||
let lin = |v: u8| {
|
||
let s = v as f32 / 255.0;
|
||
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
|
||
};
|
||
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
|
||
};
|
||
let (x, y) = (lum(a) + 0.05, lum(b) + 0.05);
|
||
x.max(y) / x.min(y)
|
||
}
|
||
|
||
/// `color` as text on `background`: itself when it reads (a WCAG 2 contrast of at least 3:1, the
|
||
/// level for large or bold text), otherwise moved towards `text` until it does. A document's own
|
||
/// colours (a bookmark's `/C`) were chosen for a white panel, and dark blue on a dark theme would
|
||
/// be lost.
|
||
pub fn readable(color: Color32, background: Color32, text: Color32) -> Color32 {
|
||
(0..=4).map(|step| color.lerp_to_gamma(text, step as f32 / 4.0)).find(|c| contrast(*c, background) >= 3.0).unwrap_or(text)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|