mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-10 10:29:55 -06:00
The localization layer was hardcoded to English and Japanese: an enum with two
variants, an `is_japanese()` gate compiled into the format macro, and one
catalog pair. Adding a language meant editing the menus, the settings row, the
checkmark logic, the format macro and the font stack — each in a different place.
A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym,
ISO 15924 script and catalog. The Language menu, the settings row, the shortcut
sheet and the control channel are all built from that table, and `build.rs`
picks up `locales/<code>-formats.json` by name, so a further language is a table
entry plus two catalogs (docs/localization.md).
Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json`
(1019 entries, key-set and placeholder-identical to `ja.json`) and
`zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto
Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones
for a Chinese reader, so shared Han characters keep the language's forms
(`fonts::cjk_fallback`); the export watermark accepts every CJK face.
`tr_format!` keeps its compile-time check. It formats the English source at the
call site — a runtime template cannot be handed to `format!` — and names those
values so a translation can place them by name (`{n}`) or position (`{_1}`).
Every translation is still checked by `format!`, so a translation with an
unknown placeholder fails the build. The Japanese catalog was rewritten from the
old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged.
Also: settings persist the BCP-47 code (never the Rust variant name),
`LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`),
`xtask parity` reads the second command table, and the glyph tests cover every
catalog character plus the interface symbols (`▸`/`▾` turned out to have no
glyph in any installed font and are gone from the Chinese text).
Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still
embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past
Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md.
Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
206 lines
8.2 KiB
Rust
206 lines
8.2 KiB
Rust
//! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the
|
|
//! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
|
|
|
pub const FONT_SEMIBOLD: &str = "semibold";
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Tokens {
|
|
/// Top bar, side panels, bottom bar, tool strip.
|
|
pub chrome: Color32,
|
|
/// Photo canvas (detail view) and filmstrip.
|
|
pub canvas: Color32,
|
|
/// Grid background behind the cells.
|
|
pub grid_bg: Color32,
|
|
pub cell: Color32,
|
|
pub cell_selected: Color32,
|
|
pub divider: Color32,
|
|
pub inset: Color32,
|
|
pub field: Color32,
|
|
pub field_border: Color32,
|
|
pub button: Color32,
|
|
pub button_border: Color32,
|
|
pub hover: Color32,
|
|
pub pressed: Color32,
|
|
pub tool_active: Color32,
|
|
pub text: Color32,
|
|
pub text_label: Color32,
|
|
pub text_dim: Color32,
|
|
pub text_disabled: Color32,
|
|
/// Icon glyphs in the chrome.
|
|
pub icon: Color32,
|
|
pub track: Color32,
|
|
pub thumb: Color32,
|
|
pub thumb_hover: Color32,
|
|
pub accent: Color32,
|
|
pub star: Color32,
|
|
pub pick: Color32,
|
|
pub reject: Color32,
|
|
/// Cautionary notices (e.g. a raw shown from its embedded preview): a muted amber.
|
|
pub caution: Color32,
|
|
pub mask_overlay: Color32,
|
|
// metrics (points)
|
|
pub top_bar_h: f32,
|
|
pub bottom_bar_h: f32,
|
|
pub panel_w: f32,
|
|
pub strip_w: f32,
|
|
pub slider_row_h: f32,
|
|
pub section_h: f32,
|
|
pub film_h: f32,
|
|
}
|
|
|
|
impl Default for Tokens {
|
|
fn default() -> Self {
|
|
Tokens {
|
|
chrome: Color32::from_rgb(0x2d, 0x2d, 0x2d),
|
|
canvas: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
|
grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f),
|
|
cell: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
|
cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d),
|
|
divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
|
inset: Color32::from_rgb(0x23, 0x23, 0x23),
|
|
field: Color32::from_rgb(0x22, 0x22, 0x22),
|
|
field_border: Color32::from_rgb(0x3c, 0x3c, 0x3c),
|
|
button: Color32::from_rgb(0x24, 0x24, 0x24),
|
|
button_border: Color32::from_rgb(0x4c, 0x4c, 0x4c),
|
|
hover: Color32::from_rgb(0x3a, 0x3a, 0x3a),
|
|
pressed: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
|
tool_active: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
|
text: Color32::from_rgb(0xe2, 0xe2, 0xe2),
|
|
text_label: Color32::from_rgb(0xbc, 0xbc, 0xbc),
|
|
text_dim: Color32::from_rgb(0x8e, 0x8e, 0x8e),
|
|
text_disabled: Color32::from_rgb(0x5c, 0x5c, 0x5c),
|
|
icon: Color32::from_rgb(0x9a, 0x9a, 0x9a),
|
|
track: Color32::from_rgb(0x5a, 0x5a, 0x5a),
|
|
thumb: Color32::from_rgb(0xa0, 0xa0, 0xa0),
|
|
thumb_hover: Color32::from_rgb(0xe0, 0xe0, 0xe0),
|
|
accent: Color32::from_rgb(0x01, 0x65, 0xdd),
|
|
star: Color32::from_rgb(0xd8, 0xd8, 0xd8),
|
|
pick: Color32::from_rgb(0xf0, 0xf0, 0xf0),
|
|
reject: Color32::from_rgb(0xe0, 0x4a, 0x4a),
|
|
caution: Color32::from_rgb(0xe3, 0xa8, 0x3c),
|
|
mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110),
|
|
top_bar_h: 42.0,
|
|
bottom_bar_h: 48.0,
|
|
panel_w: 270.0,
|
|
strip_w: 48.0,
|
|
slider_row_h: 45.0,
|
|
section_h: 57.0,
|
|
film_h: 142.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Tokens {
|
|
pub fn get(ctx: &egui::Context) -> Tokens {
|
|
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_default()
|
|
}
|
|
pub fn font(&self, size: f32) -> FontId {
|
|
FontId::proportional(size)
|
|
}
|
|
pub fn semibold(&self, size: f32) -> FontId {
|
|
FontId::new(size, FontFamily::Name(FONT_SEMIBOLD.into()))
|
|
}
|
|
}
|
|
|
|
pub fn install_fonts(ctx: &egui::Context) {
|
|
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
|
|
}
|
|
|
|
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
|
|
/// fallback of every family — the active language's own script first, so shared Han characters keep
|
|
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
|
|
/// boxes.
|
|
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
|
|
let mut fonts = FontDefinitions::default();
|
|
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
|
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
|
|
// Craft-fonts faces in preference order for a family drawn in `style`.
|
|
let fallback = |style: &str| {
|
|
lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style)
|
|
.into_iter()
|
|
.map(craft_font_name)
|
|
.collect::<Vec<String>>()
|
|
};
|
|
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
|
|
for name in regular.iter().chain(bold.iter()) {
|
|
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
|
|
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
|
|
}
|
|
}
|
|
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
|
let mut prop = vec!["Inter".to_string()];
|
|
prop.extend(defaults.iter().cloned());
|
|
prop.extend(regular);
|
|
fonts.families.insert(FontFamily::Proportional, prop);
|
|
let mut semi = vec!["Inter-SemiBold".to_string()];
|
|
semi.extend(defaults);
|
|
semi.extend(bold);
|
|
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
|
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
|
|
fonts
|
|
}
|
|
|
|
/// The embedded font families for the About box: Inter, plus the craft-fonts families when built
|
|
/// with them.
|
|
pub fn font_credits() -> String {
|
|
let mut families = vec!["Inter"];
|
|
for f in lightcraft_engine::CRAFT_FONTS {
|
|
if !families.contains(&f.family) {
|
|
families.push(f.family);
|
|
}
|
|
}
|
|
families.join(" / ")
|
|
}
|
|
|
|
fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String {
|
|
format!("craft-fonts {} {}", f.family, f.style)
|
|
}
|
|
|
|
pub fn apply(ctx: &egui::Context) {
|
|
let t = Tokens::default();
|
|
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
|
let mut v = Visuals::dark();
|
|
v.panel_fill = t.chrome;
|
|
v.window_fill = t.chrome;
|
|
v.extreme_bg_color = t.field;
|
|
v.faint_bg_color = t.inset;
|
|
v.window_stroke = Stroke::new(1.0, t.button_border);
|
|
v.window_corner_radius = CornerRadius::same(6);
|
|
v.menu_corner_radius = CornerRadius::same(6);
|
|
v.selection.bg_fill = t.accent.gamma_multiply(0.6);
|
|
v.selection.stroke = Stroke::new(1.0, t.accent);
|
|
v.override_text_color = Some(t.text_label);
|
|
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
|
|
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) };
|
|
let w = &mut v.widgets;
|
|
for (wv, fill) in [
|
|
(&mut w.noninteractive, t.chrome),
|
|
(&mut w.inactive, t.button),
|
|
(&mut w.hovered, t.hover),
|
|
(&mut w.active, t.pressed),
|
|
(&mut w.open, t.hover),
|
|
] {
|
|
wv.bg_fill = fill;
|
|
wv.weak_bg_fill = fill;
|
|
wv.corner_radius = CornerRadius::same(4);
|
|
wv.fg_stroke = Stroke::new(1.0, t.text_label);
|
|
}
|
|
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
|
|
w.inactive.bg_stroke = Stroke::new(1.0, t.button_border);
|
|
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(8.0, 3.0);
|
|
s.spacing.interact_size.y = 22.0;
|
|
s.text_styles.insert(egui::TextStyle::Body, FontId::proportional(13.0));
|
|
s.text_styles.insert(egui::TextStyle::Button, FontId::proportional(13.0));
|
|
s.text_styles.insert(egui::TextStyle::Small, FontId::proportional(11.0));
|
|
s.text_styles.insert(egui::TextStyle::Heading, FontId::new(16.0, FontFamily::Name(FONT_SEMIBOLD.into())));
|
|
s.animation_time = 0.08;
|
|
});
|
|
}
|