691 lines
30 KiB
Rust
691 lines
30 KiB
Rust
//! 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> {
|
||
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>) -> 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<Theme> = 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<Color32>,
|
||
pub paper: Option<Color32>,
|
||
/// The side of an object snap marker, in points.
|
||
pub snap_marker: f32,
|
||
/// The colour selected objects are highlighted in.
|
||
pub selection: Option<Color32>,
|
||
}
|
||
|
||
/// 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<Canvas> = 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<egui::FontData>)> = 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<Vec<u8>> {
|
||
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<Option<std::path::PathBuf>> = 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<Option<std::path::PathBuf>> = 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<Option<std::path::PathBuf>> = 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<char> {
|
||
static LETTERS: std::sync::OnceLock<std::collections::BTreeSet<char>> = 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<Vec<u8>>) -> Option<cadcraft_fonts::ttf::Face> {
|
||
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<bool> = 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<Option<cadcraft_fonts::ttf::Face>> = 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());
|
||
}
|
||
}
|
||
}
|