//! Design tokens. Colours were measured from the reference look (dark slate chrome, near-black //! navy model space) and are CADCraft's own values; every widget reads them from here. //! //! Two palettes: [`Tokens::DARK`] (the original look) and [`Tokens::LIGHT`]. The interface theme //! changes the chrome (title and tool bar, menus, palettes, tabs, command line, status bar, //! dialogs); the drawing area keeps its model-space colours in both, as drawing colours are not //! part of the interface theme. [`Tokens::get`] returns the palette of the theme in use, which //! [`CadApp::logic`](crate::CadApp::logic) resolves once per frame from the user's [`ThemePref`]. use std::cell::Cell; use egui::{Color32, FontFamily, FontId, Theme, Visuals}; use serde::{Deserialize, Serialize}; /// The interface theme the user chose. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThemePref { /// Follow the operating system's light/dark appearance, live. System, Light, /// The default: CADCraft's original look. #[default] Dark, } /// The theme `System` uses when the OS (or browser) reports no appearance. pub const SYSTEM_FALLBACK: Theme = Theme::Dark; impl ThemePref { pub const ALL: [ThemePref; 3] = [ThemePref::System, ThemePref::Light, ThemePref::Dark]; pub fn as_str(self) -> &'static str { match self { ThemePref::System => "system", ThemePref::Light => "light", ThemePref::Dark => "dark", } } pub fn parse(s: &str) -> Option { ThemePref::ALL.into_iter().find(|t| t.as_str().eq_ignore_ascii_case(s.trim())) } /// The theme to show, given what the OS reports (`ctx.system_theme()`). pub fn resolve(self, system: Option) -> Theme { match self { ThemePref::System => system.unwrap_or(SYSTEM_FALLBACK), ThemePref::Light => Theme::Light, ThemePref::Dark => Theme::Dark, } } } thread_local! { /// The theme in use on the UI thread (set by [`set_active`] each frame). static ACTIVE: Cell = const { Cell::new(Theme::Dark) }; } /// The theme [`Tokens::get`] returns colours for. pub fn active() -> Theme { ACTIVE.with(Cell::get) } pub fn set_active(theme: Theme) { ACTIVE.with(|a| a.set(theme)); } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { pub chrome: Color32, pub chrome_dark: Color32, pub panel: Color32, pub tab_active: Color32, pub control: Color32, pub control_hover: Color32, pub border: Color32, pub canvas: Color32, pub grid_minor: Color32, pub grid_major: Color32, pub axis_x: Color32, pub axis_y: Color32, pub text: Color32, pub text_dim: Color32, pub text_faint: Color32, pub icon: Color32, pub icon_accent: Color32, pub icon_point: Color32, pub accent: Color32, pub selection: Color32, pub hover: Color32, pub grip: Color32, pub grip_hot: Color32, /// Grips of dynamic block references (AutoCAD's GRIPDYNCOLOR). pub grip_dyn: Color32, pub snap: Color32, /// COMPARE: objects only in this drawing, only in the other one, and the areas of change. pub compare_this: Color32, pub compare_other: Color32, pub compare_area: Color32, pub window_stroke: Color32, pub crossing_stroke: Color32, pub cmd_bg: Color32, pub toggle_on: Color32, /// Text fields and other inset (extreme) backgrounds. pub field: Color32, /// Selected text and selected list rows. pub select_bg: Color32, /// Lines and labels drawn straight onto the drawing area (UCS icon, viewport label, rubber /// bands): they follow the model-space background, not the interface theme. pub canvas_ink: Color32, /// The command-line history lines behind the input bar. pub cmd_history: Color32, pub cmd_border: Color32, /// Command-line keywords (`[Undo/Close]`) and their hover background. pub cmd_keyword: Color32, pub cmd_keyword_hover: Color32, /// The command-line AutoComplete list: background and the highlighted first row. pub list_bg: Color32, pub list_row: Color32, /// Transient warnings (status bar messages). pub warn: Color32, /// The drawing-area crosshair cursor and its pick box. pub crosshair: Color32, /// The sheet of paper on a layout tab. pub paper: Color32, } impl Tokens { pub const DARK: Tokens = Tokens { chrome: Color32::from_rgb(0x3b, 0x44, 0x53), chrome_dark: Color32::from_rgb(0x31, 0x38, 0x44), panel: Color32::from_rgb(0x3c, 0x44, 0x52), tab_active: Color32::from_rgb(0x4e, 0x5a, 0x6e), control: Color32::from_rgb(0x50, 0x5a, 0x6d), control_hover: Color32::from_rgb(0x5c, 0x67, 0x7c), border: Color32::from_rgb(0x23, 0x29, 0x30), canvas: Color32::from_rgb(0x22, 0x28, 0x2f), grid_minor: Color32::from_rgb(0x29, 0x30, 0x3a), grid_major: Color32::from_rgb(0x31, 0x39, 0x45), axis_x: Color32::from_rgb(0x56, 0x28, 0x29), axis_y: Color32::from_rgb(0x28, 0x4a, 0x2e), text: Color32::from_rgb(0xd7, 0xdb, 0xe0), text_dim: Color32::from_rgb(0xa3, 0xaa, 0xb4), text_faint: Color32::from_rgb(0x87, 0x8d, 0x96), icon: Color32::from_rgb(0xcf, 0xd4, 0xdb), icon_accent: Color32::from_rgb(0x4f, 0xa3, 0xf7), icon_point: Color32::from_rgb(0xf0, 0x7a, 0x3a), accent: Color32::from_rgb(0x3d, 0x8b, 0xfd), selection: Color32::from_rgb(0x4a, 0x8f, 0xff), hover: Color32::from_rgb(0x9c, 0xc6, 0xff), grip: Color32::from_rgb(0x2f, 0x6b, 0xff), grip_hot: Color32::from_rgb(0xff, 0x3b, 0x3b), grip_dyn: Color32::from_rgb(0x26, 0xc6, 0xe8), snap: Color32::from_rgb(0x2f, 0xd0, 0x62), compare_this: Color32::from_rgb(0x4c, 0xd9, 0x7b), compare_other: Color32::from_rgb(0xff, 0x5c, 0x5c), compare_area: Color32::from_rgb(0xff, 0xb4, 0x3c), window_stroke: Color32::from_rgb(0x6f, 0xa8, 0xff), crossing_stroke: Color32::from_rgb(0x7f, 0xe0, 0x7f), cmd_bg: Color32::from_rgba_premultiplied(0x34, 0x3c, 0x4a, 0xf0), toggle_on: Color32::from_rgb(0x3d, 0x8b, 0xfd), field: Color32::from_rgb(0x31, 0x38, 0x44), select_bg: Color32::from_rgb(0x3d, 0x8b, 0xfd), canvas_ink: Color32::from_rgb(0xa3, 0xaa, 0xb4), cmd_history: Color32::from_rgba_unmultiplied_const(0x2a, 0x30, 0x3a, 170), cmd_border: Color32::from_rgb(0x55, 0x5f, 0x70), cmd_keyword: Color32::from_rgb(0x8f, 0xc1, 0xff), cmd_keyword_hover: Color32::from_rgb(0x2f, 0x5e, 0xa8), list_bg: Color32::from_rgb(0x2b, 0x31, 0x3b), list_row: Color32::from_rgb(0x3a, 0x42, 0x50), warn: Color32::from_rgb(0xff, 0xd0, 0x80), crosshair: Color32::from_rgb(0xe8, 0xe8, 0xe8), paper: Color32::WHITE, }; /// Light grey chrome with dark text. The drawing-area colours (canvas, grid, axes, selection, /// grips, snap markers, selection windows) are the same as [`Tokens::DARK`]'s. pub const LIGHT: Tokens = Tokens { chrome: Color32::from_rgb(0xe4, 0xe7, 0xeb), chrome_dark: Color32::from_rgb(0xd5, 0xd9, 0xdf), panel: Color32::from_rgb(0xee, 0xf0, 0xf3), tab_active: Color32::from_rgb(0xc8, 0xd3, 0xe2), control: Color32::from_rgb(0xdc, 0xe0, 0xe6), control_hover: Color32::from_rgb(0xcb, 0xd2, 0xdc), border: Color32::from_rgb(0xb3, 0xba, 0xc4), text: Color32::from_rgb(0x1d, 0x23, 0x2b), text_dim: Color32::from_rgb(0x47, 0x50, 0x5c), text_faint: Color32::from_rgb(0x62, 0x6a, 0x76), icon: Color32::from_rgb(0x34, 0x3c, 0x48), icon_accent: Color32::from_rgb(0x1b, 0x6f, 0xd6), icon_point: Color32::from_rgb(0xd3, 0x5a, 0x1c), accent: Color32::from_rgb(0x2f, 0x7c, 0xf6), cmd_bg: Color32::from_rgba_unmultiplied_const(0xf9, 0xfa, 0xfb, 0xf0), toggle_on: Color32::from_rgb(0x2f, 0x7c, 0xf6), field: Color32::from_rgb(0xff, 0xff, 0xff), select_bg: Color32::from_rgb(0xb6, 0xd3, 0xfb), cmd_history: Color32::from_rgba_unmultiplied_const(0xf0, 0xf2, 0xf5, 0xdc), cmd_border: Color32::from_rgb(0xa9, 0xb0, 0xba), cmd_keyword: Color32::from_rgb(0x15, 0x59, 0xc0), cmd_keyword_hover: Color32::from_rgb(0xcf, 0xe0, 0xfa), list_bg: Color32::from_rgb(0xfb, 0xfc, 0xfd), list_row: Color32::from_rgb(0xe3, 0xe8, 0xef), warn: Color32::from_rgb(0x8a, 0x53, 0x00), ..Tokens::DARK }; /// The palette of `theme`. pub fn of(theme: Theme) -> Tokens { match theme { Theme::Dark => Tokens::DARK, Theme::Light => Tokens::LIGHT, } } /// The palette of the theme in use, with the user's drawing-area colours ([`set_canvas`]). pub fn get() -> Tokens { CANVAS.with(Cell::get).over(Tokens::of(active())) } /// These tokens with `bg` as the model-space background: the grid, axes, crosshair and the /// lines drawn on the drawing area are recomputed to stay visible on it. pub fn with_model_background(self, bg: Color32) -> Tokens { let light = luminance(bg) > 0.5; let ink = if light { Color32::BLACK } else { Color32::WHITE }; Tokens { canvas: bg, grid_minor: mix(bg, ink, 0.05), grid_major: mix(bg, ink, 0.11), axis_x: mix(bg, Color32::from_rgb(0xd0, 0x30, 0x30), 0.3), axis_y: mix(bg, Color32::from_rgb(0x30, 0xb0, 0x40), 0.3), canvas_ink: if light { Color32::from_rgb(0x47, 0x50, 0x5c) } else { Tokens::DARK.canvas_ink }, crosshair: if light { Color32::from_rgb(0x20, 0x20, 0x20) } else { Tokens::DARK.crosshair }, ..self } } } /// The user's drawing-area preferences (Options ▸ Display and Drafting); `None` keeps the theme's /// colour. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Canvas { pub model: Option, pub paper: Option, /// The side of an object snap marker, in points. pub snap_marker: f32, /// The colour selected objects are highlighted in. pub selection: Option, } /// The default object snap marker size. pub const SNAP_MARKER: f32 = 11.0; impl Default for Canvas { fn default() -> Self { Canvas { model: None, paper: None, snap_marker: SNAP_MARKER, selection: None } } } impl Canvas { fn over(self, t: Tokens) -> Tokens { let t = match self.model { Some(bg) => t.with_model_background(bg), None => t, }; Tokens { paper: self.paper.unwrap_or(t.paper), selection: self.selection.unwrap_or(t.selection), ..t } } } thread_local! { /// The drawing-area preferences in use on the UI thread (set by [`set_canvas`] each frame). static CANVAS: Cell = Cell::new(Canvas::default()); } pub fn set_canvas(c: Canvas) { CANVAS.with(|v| v.set(c)); } /// Half the side of an object snap marker, in points. pub fn snap_marker_half() -> f32 { CANVAS.with(Cell::get).snap_marker / 2.0 } /// Relative luminance of `c` (0 black … 1 white). pub fn luminance(c: Color32) -> f32 { (0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b())) / 255.0 } /// `a` moved the fraction `f` towards `b`. fn mix(a: Color32, b: Color32, f: f32) -> Color32 { let m = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * f).round().clamp(0.0, 255.0) as u8; Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b())) } pub fn small() -> FontId { FontId::new(11.5, FontFamily::Proportional) } pub fn body() -> FontId { FontId::new(12.5, FontFamily::Proportional) } pub fn mono() -> FontId { FontId::new(12.0, FontFamily::Monospace) } /// Load a system UI font at run time when one is installed (not bundled), else keep egui's; /// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface /// language: file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a /// Japanese one, so names take their own glyph forms; in an Arabic interface an installed /// Arabic font follows. A font embedded from craft-fonts comes /// last, for systems without any (and the web build, which has no system fonts). A Korean /// interface adds a Hangul font only when the CJK font found doesn't cover Hangul /// ([`korean_font`]). pub fn install_fonts(ctx: &egui::Context, language: &str) { let mut fonts = egui::FontDefinitions::default(); let mut added: Vec<(String, std::sync::Arc)> = Vec::new(); #[cfg(not(target_arch = "wasm32"))] let chinese = language.starts_with("zh"); #[cfg(not(target_arch = "wasm32"))] { let candidates: &[&str] = if cfg!(target_os = "macos") { &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"] } else if cfg!(windows) { &["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"] } else { &[ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf", "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", ] }; for path in candidates { if let Ok(bytes) = std::fs::read(path) { fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes))); if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-ui".into()); } break; } } let mut paths: Vec<&std::path::Path> = Vec::new(); if !chinese { paths.extend(japanese_font()); } paths.extend(cjk_font()); paths.dedup(); added.extend( paths .into_iter() .filter_map(|p| read_capped(p, MAX_UI_FONT_BYTES)) .enumerate() .map(|(i, bytes)| (format!("system-cjk-{i}"), std::sync::Arc::new(egui::FontData::from_owned(bytes)))), ); // العربية: an installed Arabic face after the Latin ones (egui's bundled faces carry no // Arabic glyphs), for the Arabic interface. if language == "ar" && let Some(bytes) = arabic_font().and_then(|p| read_capped(p, MAX_UI_FONT_BYTES)) { added.push(("system-arabic".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)))); } } let mut embedded: Vec<_> = cadcraft_fonts::CRAFT_FONTS .iter() .filter(|f| f.style == "Regular" && f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant"))) .collect(); // A sans-serif face reads best in the interface. embedded.sort_by_key(|f| !f.family.contains("Gothic") && !f.family.contains("Sans")); added.extend(embedded.first().map(|f| (format!("craft-{}", f.stem()), std::sync::Arc::new(egui::FontData::from_static(f.bytes))))); // Korean: Noto Sans CJK and most CJK fonts above already cover Hangul; else a Hangul font. if language == "ko" && !cjk_covers_hangul() && let Some(face) = korean_font() { let mut data = egui::FontData::from_owned(face.bytes.as_ref().clone()); data.index = face.index; added.push(("korean-ui".into(), std::sync::Arc::new(data))); } for (name, data) in added { fonts.font_data.insert(name.clone(), data); for family in [FontFamily::Proportional, FontFamily::Monospace] { if let Some(f) = fonts.families.get_mut(&family) { f.push(name.clone()); } } } ctx.set_fonts(fonts); } /// Largest interface font file read or embedded face copied (CJK collections are 15–60 MB). const MAX_UI_FONT_BYTES: u64 = 96 * 1024 * 1024; #[cfg(not(target_arch = "wasm32"))] fn read_capped(path: &std::path::Path, max: u64) -> Option> { use std::io::Read as _; let file = std::fs::File::open(path).ok()?; let len = file.metadata().ok()?.len(); if len == 0 || len > max { return None; } // Enforce the cap during the read too, even if the file grows after metadata was read. let mut bytes = Vec::new(); file.take(max.saturating_add(1)).read_to_end(&mut bytes).ok()?; (!bytes.is_empty() && bytes.len() as u64 <= max).then_some(bytes) } /// An installed font with Chinese characters, for the Chinese interface; fonts are never bundled /// (only looked for where the OS or common packages put them). Checked once. pub fn cjk_font() -> Option<&'static std::path::Path> { #[cfg(not(target_arch = "wasm32"))] { static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); FOUND .get_or_init(|| { let candidates: &[&str] = if cfg!(target_os = "macos") { &["/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"] } else if cfg!(windows) { &["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\msyh.ttf", "C:\\Windows\\Fonts\\simsun.ttc"] } else { &[ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf", "/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/wqy-microhei/wqy-microhei.ttc", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", ] }; candidates.iter().map(std::path::PathBuf::from).find(|p| p.is_file()) }) .as_deref() } #[cfg(target_arch = "wasm32")] None } /// An installed font with Arabic glyphs, for the العربية interface; fonts are never bundled /// (only looked for where the OS or common packages put them). Checked once. pub fn arabic_font() -> Option<&'static std::path::Path> { #[cfg(not(target_arch = "wasm32"))] { static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); FOUND .get_or_init(|| { let candidates: &[&str] = if cfg!(target_os = "macos") { &[ "/System/Library/Fonts/GeezaPro.ttc", "/System/Library/Fonts/Supplemental/Geeza Pro.ttf", "/System/Library/Fonts/Supplemental/Al Nile.ttc", "/System/Library/Fonts/Supplemental/Baghdad.ttf", ] } else if cfg!(windows) { &[ "C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\tahoma.ttf", "C:\\Windows\\Fonts\\trado.ttf", "C:\\Windows\\Fonts\\arabtype.ttf", "C:\\Windows\\Fonts\\arial.ttf", ] } else { &[ "/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", "/usr/share/fonts/noto/NotoSansArabic-Regular.ttf", "/usr/share/fonts/opentype/noto/NotoSansArabic-Regular.ttf", "/usr/share/fonts/truetype/noto/NotoNaskhArabic-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/amiri/Amiri-Regular.ttf", "/usr/share/fonts/truetype/kacst/KacstOne.ttf", ] }; candidates.iter().map(std::path::PathBuf::from).find(|p| p.is_file()) }) .as_deref() } #[cfg(target_arch = "wasm32")] None } /// An installed Japanese font, for Japanese names in a non-Chinese interface. Checked once. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn japanese_font() -> Option<&'static std::path::Path> { #[cfg(not(target_arch = "wasm32"))] { static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); FOUND .get_or_init(|| { let candidates: &[&str] = if cfg!(target_os = "macos") { &["/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"] } else if cfg!(windows) { &["C:\\Windows\\Fonts\\YuGothR.ttc", "C:\\Windows\\Fonts\\meiryo.ttc", "C:\\Windows\\Fonts\\msgothic.ttc"] } else { &[ "/usr/share/fonts/opentype/ipafont-gothic/ipagp.ttf", "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", "/usr/share/fonts/ipa-pgothic/ipagp.ttf", "/usr/share/fonts/vl-gothic/VL-PGothic-Regular.ttf", ] }; candidates.iter().map(std::path::PathBuf::from).find(|p| p.is_file()) }) .as_deref() } #[cfg(target_arch = "wasm32")] None } /// The Hangul letters the Korean interface shows: its catalog and its own name. fn hangul_letters() -> &'static std::collections::BTreeSet { static LETTERS: std::sync::OnceLock> = std::sync::OnceLock::new(); LETTERS.get_or_init(|| { include_str!("i18n/ko.tsv") .lines() .filter_map(|line| line.split('\t').nth(2)) .flat_map(str::chars) .chain("한국어".chars()) .filter(|c| matches!(c, '\u{1100}'..='\u{11ff}' | '\u{3130}'..='\u{318f}' | '\u{a960}'..='\u{a97f}' | '\u{ac00}'..='\u{d7ff}')) .collect() }) } /// Whether `face` has every Hangul letter the Korean interface shows (rejects malformed fonts /// and Chinese- or Japanese-only faces). fn covers_hangul(face: &cadcraft_fonts::ttf::Face) -> bool { hangul_letters().iter().all(|c| face.has_glyph(*c)) } /// The first face of a font file (the one egui uses) when it covers the Korean interface. fn korean_face(bytes: std::sync::Arc>) -> Option { cadcraft_fonts::ttf::faces(&bytes).into_iter().find(covers_hangul) } /// Whether the CJK fonts [`install_fonts`] loads in every language already show Hangul (Noto Sans /// CJK, Microsoft YaHei, PingFang do). Only asked about Korean, so other interfaces never read the /// font twice. Checked once. pub fn cjk_covers_hangul() -> bool { static COVERS: std::sync::OnceLock = std::sync::OnceLock::new(); *COVERS.get_or_init(|| { #[cfg(not(target_arch = "wasm32"))] { // egui uses a collection's first face, and each letter from the first font with it. let first_covers = |p: &std::path::Path| { read_capped(p, MAX_UI_FONT_BYTES) .and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next()) .is_some_and(|face| covers_hangul(&face)) }; if cjk_font().is_some_and(first_covers) { return true; } } cadcraft_fonts::CRAFT_FONTS .iter() .filter(|f| f.style == "Regular" && f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant"))) .any(|f| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(f.bytes.to_vec())).first().is_some_and(covers_hangul)) }) } /// A Hangul font for the Korean interface when the CJK fonts don't cover it: a Korean face from /// the optional craft-fonts input, else one installed where the OS or common packages put it. /// Nothing is bundled or downloaded; without one the interface stays English. Only looked for /// when Korean is asked about. Checked once; collections are scanned with the fonts crate's /// face-count cap and reads are capped. pub fn korean_font() -> Option<&'static cadcraft_fonts::ttf::Face> { static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); FOUND .get_or_init(|| { for font in cadcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| matches!(*s, "Hang" | "Kore"))) { if font.bytes.len() as u64 > MAX_UI_FONT_BYTES { continue; } if let Some(face) = korean_face(std::sync::Arc::new(font.bytes.to_vec())) { return Some(face); } } #[cfg(not(target_arch = "wasm32"))] { let candidates: &[&str] = if cfg!(target_os = "macos") { &[ "/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf", "/Library/Fonts/NanumGothic.ttf", ] } else if cfg!(windows) { &["C:\\Windows\\Fonts\\malgun.ttf", "C:\\Windows\\Fonts\\gulim.ttc"] } else { &[ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", "/usr/share/fonts/nanum/NanumGothic.ttf", "/usr/share/fonts/opentype/noto/NotoSansCJKkr-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", "/usr/local/share/fonts/nanum/NanumGothic.ttf", "/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc", ] }; for path in candidates { if let Some(face) = read_capped(std::path::Path::new(path), MAX_UI_FONT_BYTES).and_then(|bytes| korean_face(std::sync::Arc::new(bytes))) { return Some(face); } } } None }) .as_ref() } /// Whether the interface in `language` shows Hangul (the Korean name in the language picker): /// in every language when the CJK fonts cover it, in a Korean interface with [`korean_font`]. pub fn shows_hangul(language: &str) -> bool { cjk_covers_hangul() || (language == "ko" && korean_font().is_some()) } /// Whether the interface can show `language` without missing-glyph boxes: Chinese needs an /// installed CJK font, Korean a font with Hangul, العربية an installed Arabic font. pub fn can_show(language: &str) -> bool { match language { "ko" => shows_hangul("ko"), "ar" => arabic_font().is_some(), l if l.starts_with("zh") => cjk_font().is_some(), _ => true, } } /// egui's visuals for `theme`, from its tokens. fn visuals(theme: Theme) -> Visuals { let t = Tokens::of(theme); let mut v = match theme { Theme::Dark => Visuals::dark(), Theme::Light => Visuals::light(), }; v.panel_fill = t.panel; v.window_fill = t.chrome; v.extreme_bg_color = t.field; v.faint_bg_color = t.chrome_dark; v.override_text_color = Some(t.text); v.widgets.noninteractive.bg_fill = t.panel; v.widgets.noninteractive.fg_stroke.color = t.text; v.widgets.noninteractive.bg_stroke.color = t.border; v.widgets.inactive.bg_fill = t.control; v.widgets.inactive.weak_bg_fill = t.control; v.widgets.inactive.fg_stroke.color = t.text; v.widgets.hovered.bg_fill = t.control_hover; v.widgets.hovered.weak_bg_fill = t.control_hover; v.widgets.active.bg_fill = t.accent; v.widgets.active.weak_bg_fill = t.accent; v.selection.bg_fill = t.select_bg; v.window_stroke = egui::Stroke::new(1.0, t.border); let shadow = Color32::from_black_alpha(if theme == Theme::Dark { 90 } else { 40 }); v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 12, spread: 0, color: shadow }; v.window_corner_radius = egui::CornerRadius::same(6); v.menu_corner_radius = egui::CornerRadius::same(5); v } /// Install the styles of both themes and show `theme` (egui's own widgets, popups and menus /// follow it; our own painting follows [`Tokens::get`]). `follow_system` is true for the /// [`ThemePref::System`] choice, whose `theme` was resolved from the OS report. pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) { set_active(theme); ctx.set_visuals_of(Theme::Dark, visuals(Theme::Dark)); ctx.set_visuals_of(Theme::Light, visuals(Theme::Light)); // A System choice keeps egui's own System preference: a concrete Light/Dark would pin the native // window appearance (winit then stops reporting OS changes) and the app would never follow again. ctx.set_theme(if follow_system { egui::ThemePreference::System } else { egui::ThemePreference::from(theme) }); ctx.all_styles_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 = 20.0; s.text_styles.insert(egui::TextStyle::Body, body()); s.text_styles.insert(egui::TextStyle::Button, body()); s.text_styles.insert(egui::TextStyle::Small, small()); s.text_styles.insert(egui::TextStyle::Monospace, mono()); }); } #[cfg(test)] mod tests { #[test] fn korean_font_rejects_invalid_or_glyphless_files() { for bytes in [vec![], vec![0xff; 32], vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { assert!(super::korean_face(std::sync::Arc::new(bytes)).is_none()); } } }