diff --git a/ASSETS.md b/ASSETS.md index e70c06d9..6f5aa75f 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -46,6 +46,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/fonts/OFL-JetBrainsMono.txt` | (licence text) | upstream project | — | | | `assets/fonts/OFL-SourceSans3.txt` | (licence text) | upstream project | — | | | `assets/fonts/OFL-SourceSerif4.txt` | (licence text) | upstream project | — | | +| `assets/fonts/ShipporiMincho-Regular.ttf` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho | OFL-1.1 (`assets/fonts/OFL-ShipporiMincho.txt`) | Japanese UI/document fallback; SHA-256 `769b5269f0f9bc6534b352c0e6bd856a566e03ff788f107191c2d835863570b2` | +| `assets/fonts/OFL-ShipporiMincho.txt` | The Shippori Mincho Project Authors (FONTDASU) | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt | OFL-1.1 | Licence for Shippori Mincho | | `assets/icons/LICENSE-lucide.txt` | (licence text) | upstream project | — | | | `assets/icons/align-center-horizontal.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/align-center-vertical.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/NOTICE b/NOTICE index 10c8c554..089ff379 100644 --- a/NOTICE +++ b/NOTICE @@ -29,3 +29,7 @@ based in part on the work of the Independent JPEG Group. WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is under the Apache License 2.0 with the LLVM exception. + +Shippori Mincho: Copyright 2021 The Shippori Mincho Project Authors +(https://github.com/fontdasu/ShipporiMincho). SIL Open Font License 1.1; +see assets/fonts/OFL-ShipporiMincho.txt. diff --git a/ROADMAP.md b/ROADMAP.md index 62bab7d0..523ffaef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,3 +221,15 @@ command, gesture and dialog drivable by agents (MCP, CLI, control channel). ## How to update this file After each milestone task lands, update the status column and remaining estimates, and move items into "Shipped so far". Keep estimates honest: re-derive them from what remains, never from wishful velocity. + +### Interface localization + +The VectorCraft > Language submenu switches English/Japanese and persists in UI state (`app.language {lang: en|ja}`). Both macOS native and in-window menus translate core labels; the native menu refreshes after a language change. Bundled OFL Shippori Mincho provides Japanese UI/document glyph fallback without system fonts. Untranslated labels use English. Broader dialog/panel translation and vertical/CJK composition remain open. + +Vertical point/area/path type tools now create persisted vertical text. Type > Orientation +switches existing objects. Point/area text flows down columns progressing right to left, with +upright Japanese glyphs and vert/vrt2 alternates. Hit testing, caret/selection and arrow keys +use the writing axes. Vertical path type turns glyphs across the baseline path. SVG exports +vertical text as outlines to preserve appearance; native documents retain editable text. +This is initial vertical support, without kinsoku, ruby, tate-chu-yoko, vertical font metrics +or full mixed-script Unicode vertical orientation. diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index 9783a6ef..d85d5f0d 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -18,6 +18,7 @@ enum Handle { } pub struct NativeMenu { + language: vectorcraft_ui_egui::i18n::Language, _menu: Menu, items: HashMap, last_refresh: f64, @@ -47,7 +48,7 @@ impl NativeMenu { let mut items = HashMap::new(); let mut counter = 0usize; for (title, entries) in menus::menu_tree() { - let sub = Submenu::new(title, true); + let sub = Submenu::new(app.ui.language.tr(title), true); build(app, &sub, &entries, &mut items, &mut counter); let _ = menu.append(&sub); } @@ -58,7 +59,15 @@ impl NativeMenu { .filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some()) .map(|(_, _, _, c)| c.clone()) .collect(); - Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() } + Self { + _menu: menu, + items, + last_refresh: 0.0, + generation, + slots: menus::listed_slots(app), + plugins: menus::plugin_revision(), + language: app.ui.language, + } } /// Dispatch clicked items and refresh state. @@ -79,6 +88,7 @@ impl NativeMenu { if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation || menus::listed_slots(app) != self.slots || menus::plugin_revision() != self.plugins + || app.ui.language != self.language { *self = NativeMenu::install(app); return; @@ -111,7 +121,7 @@ impl NativeMenu { "view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters" ) { - i.set_text(menus::dynamic_label(app, cmd, "")); + i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, ""))); } } Handle::Check(c) => { @@ -136,13 +146,13 @@ fn build( let _ = parent.append(&PredefinedMenuItem::separator()); } Item::Header(h) => { - let _ = parent.append(&MenuItem::new(*h, false, None)); + let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None)); } Item::Todo(label, sc) => { - let _ = parent.append(&MenuItem::new(*label, false, accel(sc))); + let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc))); } Item::Sub(label, children) => { - let sub = Submenu::new(*label, true); + let sub = Submenu::new(app.ui.language.tr(label), true); build(app, &sub, children, items, counter); let _ = parent.append(&sub); } @@ -153,11 +163,11 @@ fn build( let id = format!("dc{counter}"); let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None }; let handle = if menus::checked(app, cmd, params).is_some() { - let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc); + let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc); let _ = parent.append(&c); Handle::Check(c) } else { - let i = MenuItem::with_id(id.clone(), *label, true, sc); + let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc); let _ = parent.append(&i); Handle::Plain(i) }; diff --git a/assets/fonts/OFL-ShipporiMincho.txt b/assets/fonts/OFL-ShipporiMincho.txt new file mode 100644 index 00000000..a3f24cb5 --- /dev/null +++ b/assets/fonts/OFL-ShipporiMincho.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Shippori Mincho Project Authors (https://github.com/fontdasu/ShipporiMincho) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/ShipporiMincho-Regular.ttf b/assets/fonts/ShipporiMincho-Regular.ttf new file mode 100644 index 00000000..cfc25ceb Binary files /dev/null and b/assets/fonts/ShipporiMincho-Regular.ttf differ diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 387c1414..7a2ff704 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -414,6 +414,9 @@ pub enum TextKind { /// A text object. `runs` split into paragraphs at `\n`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextObject { + /// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub vertical: bool, pub kind: TextKind, /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, @@ -437,6 +440,7 @@ pub struct TextObject { impl TextObject { pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { + vertical: false, kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 5597406a..480cf9c1 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}", has_doc, text_create ), @@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let y = f64_req(p, "y", "text.create")?; let text = str_param(p, "text").unwrap_or(""); let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); if let Some(a) = p.get("area") { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 57def78b..68195b5b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), @@ -368,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { }; let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } }; let mut t = TextObject { + vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false), kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], @@ -404,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { let head = edit::slice_runs(&t.runs, 0, end); let measure = |tr: f64| { let mut h = TextObject { + vertical: t.vertical, kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7eaf908e..5cc62b58 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -13,6 +13,18 @@ use super::*; pub fn specs() -> Vec { vec![ + cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| { + orientation(s, p, true) + }), + cmd!( + "type.orientation.horizontal", + "Horizontal", + ["Type", "Orientation"], + None, + "{ids?|id?} set horizontal writing", + has_selection, + |s, p| orientation(s, p, false) + ), cmd!( "type.createOutlines", "Create Outlines", @@ -61,6 +73,21 @@ pub fn specs() -> Vec { ] } +fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { + let ids = text_targets(s, p, "Text Orientation")?; + s.edit("Text Orientation", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { + return Err(EngineError::NoNode(*id)); + }; + t.vertical = vertical; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) +} + /// Recompute the layout bounds cache after a text edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index d89d4645..cd98697b 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -378,3 +378,22 @@ fn type_tool_state_does_not_leak_across_documents() { assert_eq!(obj(&s, id).plain_text(), "First"); assert!(!s.in_interaction()); } + +#[test] +fn vertical_text_creation_orientation_and_persistence() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let t = obj(&s, id); + assert!(t.vertical); + let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap(); + assert!(round.vertical); + s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap(); + assert!(!obj(&s, id).vertical); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(obj(&s, id).vertical); + let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap(); + assert!(old.get("vertical").is_none()); + let old: TextObject = serde_json::from_value(old).unwrap(); + assert!(!old.vertical); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 0b5917e0..253394e0 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1576,7 +1576,7 @@ impl Writer<'_> { /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those /// below the Characters row under the characters, the others over them. fn text_node(&mut self, n: &Node, t: &TextObject) { - let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) }; + let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) }; if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { return chars(self, n); } diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 64af1706..ade5c8a1 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -7,6 +7,8 @@ use vectorcraft_doc::CharStyle; /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OtFeatures { + /// Vertical glyph alternates (vert/vrt2), set by the writing direction. + pub vertical: bool, /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero. pub ligatures: bool, /// Contextual alternates (`calt`). @@ -30,6 +32,7 @@ pub struct OtFeatures { impl Default for OtFeatures { fn default() -> Self { Self { + vertical: false, ligatures: true, contextual: true, discretionary_ligatures: false, @@ -138,6 +141,10 @@ impl OtFeatures { v.push(f(tag, true)); } } + if self.vertical { + v.push(f(b"vert", true)); + v.push(f(b"vrt2", true)); + } v } } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8a48f7e9..08765f4a 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -16,6 +16,9 @@ use skrifa::{GlyphId, MetadataProvider}; /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; +/// Bundled Japanese font bytes, shared with UI glyph fallback. +pub static SHIPPORI_MINCHO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf"); + static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"), include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"), @@ -26,6 +29,7 @@ static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/Inter-Medium.ttf"), include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"), include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), + SHIPPORI_MINCHO_REGULAR, ]; enum FontBytes { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c13bd2f5..afaef60f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -14,6 +14,8 @@ use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, T const EPS: f64 = 1e-6; struct Ctx<'a> { + vertical: bool, + on_path: bool, db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, @@ -37,7 +39,22 @@ impl Ctx<'_> { fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { let src = self.db.outline(&g.face, g.gid); let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); - let m = pre * local; + let mut m = pre * local; + let mut origin = origin; + let mut angle = angle; + if self.vertical { + let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + if self.on_path { + // Vertical path type keeps the baseline path and turns each glyph across it. + m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m; + } else { + let physical = writing * origin; + let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f); + m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m }; + origin = physical; + angle += std::f64::consts::FRAC_PI_2; + } + } // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() { BezPath::new() @@ -99,16 +116,43 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay } } paras.push(s..text.len()); - let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() }; + let mut vertical_opts = opts.clone(); + vertical_opts.features.vertical = t.vertical; + let opts = &vertical_opts; + let is_on_path = matches!(&t.kind, TextKind::OnPath { .. }); + let mut cx = Ctx { + vertical: t.vertical, + on_path: is_on_path, + db, + text: &text, + runs, + default: CharStyle::default(), + opts, + out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() }, + }; match &t.kind { TextKind::Point => flow(&mut cx, ¶s, &t.para, None), TextKind::Area { frame } => { - let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap); + let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() }; + let mut wrap = t.wrap.clone(); + if t.vertical { + for w in &mut wrap { + w.path.transform(inverse); + } + } + let regions = Region::cells(&frame.to_bezpath(), opts, &wrap); cx.out.frames = regions.iter().map(|r| r.cell).collect(); flow(&mut cx, ¶s, &t.para, Some(®ions)); } TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect), } + if cx.out.vertical { + let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + for frame in &mut cx.out.frames { + *frame = writing.transform_rect_bbox(*frame); + } + } finish_bounds(&mut cx.out); cx.out } @@ -123,7 +167,8 @@ fn finish_bounds(out: &mut TextLayout) { } if !out.on_path { for l in &out.lines { - add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)); + let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent); + add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r }); } } out.bounds = b.unwrap_or_default(); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b3268c4a..cdcdfa28 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -20,7 +20,7 @@ mod shape; pub mod thread; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, SHIPPORI_MINCHO_REGULAR, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; @@ -124,6 +124,8 @@ pub struct LineInfo { #[derive(Clone, Debug, Default)] pub struct TextLayout { + /// Lines retain inline/block coordinates; glyph geometry is in physical text space. + pub vertical: bool, pub glyphs: Vec, pub lines: Vec, /// Ink/advance bounds in text space. @@ -138,6 +140,13 @@ pub struct TextLayout { } impl TextLayout { + /// Convert physical text coordinates to inline/block coordinates. + pub fn logical_point(&self, p: Point) -> Point { + if self.vertical { Point::new(p.y, -p.x) } else { p } + } + pub fn physical_point(&self, p: Point) -> Point { + if self.vertical { Point::new(-p.y, p.x) } else { p } + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); @@ -192,7 +201,7 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { } else if let Some(g) = glyphs.last() { (g.origin + dir(g.angle) * g.advance, g.angle) } else { - (Point::new(line.x0, line.baseline), 0.0) + (layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }) }; // Up vector in y-down space, rotated with the baseline. let d = dir(angle); @@ -214,6 +223,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { None => layout.lines.first().map_or(0, |l| l.start), }; } + let p = layout.logical_point(p); // Nearest line band (vertical distance first, then distance to the line's column). let dist = |l: &LineInfo| { let dy = if p.y < l.baseline - l.ascent { @@ -247,7 +257,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) { + if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) { return g.byte; } line_end(layout, li) @@ -281,9 +291,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) { let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - return g.origin.x + g.advance * frac; + return layout.logical_point(g.origin).x + g.advance * frac; } - glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance) + glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance) } /// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x` @@ -345,14 +355,14 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 let mut xb = x_in_line(layout, li, b.min(l.end)); if b > l.end { // The selection continues past the line: include the line break / trailing space. - let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance); + let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance); xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25; } if xb - xa <= 1e-9 { continue; } let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent); - out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]); + out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p))); } out } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 588bc19c..28dfd379 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -421,3 +421,34 @@ fn tab_stops_position_text() { p.para.tabs = vec![stop(50.0, TabAlign::Left)]; assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } + +#[test] +fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() { + let mut t = point("日本語\n縦書き", style(30.0)); + t.vertical = true; + let l = layout(db(), &t); + assert!(l.vertical); + assert_eq!(l.lines.len(), 2); + assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty())); + assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x); + assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y); + for g in &l.glyphs { + let p = g.origin + dir(g.angle) * (g.advance * 0.1); + assert_eq!(hit_byte(&l, p), g.byte); + let (a, b) = caret_position(&l, g.byte); + assert!((a.y - b.y).abs() < 1e-6); + assert!(a.x > b.x); + } + let selection = selection_quads(&l, 0, "日本語".len()); + assert_eq!(selection.len(), 1); +} + +#[test] +fn vertical_area_type_wraps_into_columns_inside_the_frame() { + let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left); + t.vertical = true; + let l = layout(db(), &t); + assert!(l.lines.len() > 1); + assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0)); + assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3bbc8a01..4e30ed00 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -414,7 +414,7 @@ pub fn create(id: &str) -> Box { "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), "pen" => Box::new(pen::PenTool::default()), - "type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)), + "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), // Not in the toolbar: `file.place.queue` loads it. "place" => Box::new(place::PlaceTool::default()), diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 4586943f..e97ae4bd 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -40,6 +40,7 @@ struct Typing { #[derive(Default)] pub struct TypeTool { + vertical: bool, mode: Mode, /// Text object being edited. editing: Option, @@ -65,11 +66,11 @@ pub struct TypeTool { impl TypeTool { pub fn new(id: &str) -> Self { let mode = match id { - "areaType" => Mode::Area, - "typeOnPath" => Mode::OnPath, + "areaType" | "verticalAreaType" => Mode::Area, + "typeOnPath" | "verticalTypeOnPath" => Mode::OnPath, _ => Mode::Type, }; - Self { mode, ..Default::default() } + Self { mode, vertical: id.starts_with("vertical"), ..Default::default() } } fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> { @@ -236,16 +237,20 @@ impl TypeTool { let on_path = self.mode == Mode::OnPath; if let Some(pid) = Self::path_at(cx, start, !on_path) { let mode = if on_path { "onPath" } else { "area" }; - out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]}))); + out.push(Action::Exec( + "text.createInPath".into(), + json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}), + )); out.push(Action::Notify("text.editNew".into())); return out; } } let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); - let params = match area { + let mut params = match area { Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}), None => json!({"x": start.x, "y": start.y, "text": ""}), }; + params["vertical"] = json!(self.vertical); out.push(Action::Exec("text.create".into(), params)); out.push(Action::Notify("text.editNew".into())); out @@ -294,10 +299,13 @@ fn common_affixes(a: &str, b: &str) -> (usize, usize) { impl Tool for TypeTool { fn id(&self) -> &'static str { - match self.mode { - Mode::Type => "type", - Mode::Area => "areaType", - Mode::OnPath => "typeOnPath", + match (self.mode, self.vertical) { + (Mode::Type, false) => "type", + (Mode::Type, true) => "verticalType", + (Mode::Area, false) => "areaType", + (Mode::Area, true) => "verticalAreaType", + (Mode::OnPath, false) => "typeOnPath", + (Mode::OnPath, true) => "verticalTypeOnPath", } } fn busy(&self) -> bool { @@ -397,6 +405,17 @@ impl Tool for TypeTool { let (a, b) = self.sel(); let word = mods.cmd || mods.alt; let lay = self.layout(&t); + let key = if lay.vertical { + match key { + ToolKey::Up => ToolKey::Left, + ToolKey::Down => ToolKey::Right, + ToolKey::Right => ToolKey::Up, + ToolKey::Left => ToolKey::Down, + other => other, + } + } else { + key + }; let vertical = matches!(key, ToolKey::Up | ToolKey::Down); if !vertical { self.goal_x = None; @@ -437,7 +456,7 @@ impl Tool for TypeTool { ToolKey::Up | ToolKey::Down => { let d = if key == ToolKey::Up { -1 } else { 1 }; let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret }; - let x = self.goal_x.unwrap_or_else(|| vectorcraft_text::caret_position(&lay, from).0.x); + let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x); let to = if mods.cmd { // Cmd+Up/Down: paragraph start / end. let p = edit::paragraph_at(&text, from); diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 16fabcd4..42747181 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -218,3 +218,12 @@ fn area_and_path_tools_convert_clicked_paths() { let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0)); assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}"); } + +#[test] +fn vertical_tools_preserve_their_ids_and_create_vertical_text() { + for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] { + let tool = TypeTool::new(id); + assert_eq!(tool.id(), id); + assert!(tool.vertical); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 00000000..308b3d0a --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,171 @@ +//! Interface translations. Command ids, document text and file names remain stable. +//! Untranslated labels fall back to English so coverage can grow incrementally. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, +} + +impl Language { + pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + _ => None, + } + } + + pub fn tr(self, text: &str) -> &str { + if self == Self::Ja + && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) + { + return japanese; + } + text + } +} + +const JAPANESE: &[(&str, &str)] = &[ + ("Object", "オブジェクト"), + ("Effect", "効果"), + ("Settings…", "環境設定…"), + ("Image", "画像"), + ("Layer", "レイヤー"), + ("Type", "書式"), + ("Select", "選択"), + ("Filter", "フィルター"), + ("Window", "ウィンドウ"), + ("Language", "表示言語"), + ("Save As…", "別名で保存…"), + ("Exit", "終了"), + ("New…", "新規…"), + ("New", "新規"), + ("Horizontal", "横書き"), + ("Vertical", "縦書き"), + ("Orientation", "組み方向"), + ("Type Orientation", "組み方向"), + ("Layers", "レイヤー"), + ("History", "履歴"), + ("Properties", "プロパティ"), + ("Color", "カラー"), + ("Brush Settings", "ブラシ設定"), + ("Tools", "ツール"), + ("Options", "オプション"), + ("Zoom In", "ズームイン"), + ("Zoom Out", "ズームアウト"), + ("Fit on Screen", "画面に合わせる"), + ("Copy", "コピー"), + ("Cut", "切り取り"), + ("Paste", "貼り付け"), + ("Select All", "すべて選択"), + ("Deselect", "選択を解除"), + ("Export", "書き出し"), + ("Export As…", "形式を指定して書き出し…"), + ("Search…", "検索…"), + ("Theme", "テーマ"), + ("Menu", "メニュー"), + ("File", "ファイル"), + ("Edit", "編集"), + ("Pages", "ページ"), + ("View", "表示"), + ("Help", "ヘルプ"), + ("Preferences", "環境設定"), + ("Preferences…", "環境設定…"), + ("Interface language", "表示言語"), + ("Open…", "開く…"), + ("New blank PDF", "空白の PDF を作成"), + ("Create PDF from file…", "ファイルから PDF を作成…"), + ("Create PDF from images…", "画像から PDF を作成…"), + ("Create PDF from clipboard", "クリップボードから PDF を作成"), + ("Combine files…", "ファイルを結合…"), + ("Save", "保存"), + ("Save as…", "別名で保存…"), + ("Close file", "ファイルを閉じる"), + ("Close all", "すべて閉じる"), + ("Revert", "保存済みの状態に戻す"), + ("Print…", "印刷…"), + ("Document properties…", "文書のプロパティ…"), + ("Undo", "取り消し"), + ("Redo", "やり直し"), + ("Find…", "検索…"), + ("Advanced search…", "高度な検索…"), + ("Copy pages", "ページをコピー"), + ("Cut pages", "ページを切り取り"), + ("Paste pages", "ページを貼り付け"), + ("Fit visible", "表示範囲に合わせる"), + ("Marquee zoom", "範囲指定ズーム"), + ("Take a snapshot", "スナップショットを作成"), + ("Full screen mode", "全画面表示"), + ("Read mode", "閲覧モード"), + ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), + ("Comments panel", "コメントパネル"), + ("Form fields panel", "フォームフィールドパネル"), + ("Clear form", "フォームをクリア"), + ("Find tools and commands…", "ツールとコマンドを検索…"), + ("Zoom", "ズーム"), + ("Actual size", "実際のサイズ"), + ("Zoom to page level", "ページ全体を表示"), + ("Fit to width", "幅に合わせる"), + ("Display theme", "表示テーマ"), + ("Side panels", "サイドパネル"), + ("OK", "OK"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translations_are_unique_and_preserve_unknown_text() { + for (i, (en, ja)) in JAPANESE.iter().enumerate() { + assert!(!ja.is_empty()); + assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); + assert_eq!(Language::En.tr(en), *en); + } + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("xx"), None); + } + + #[test] + fn language_command_validates_and_persists_without_a_document() { + let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "ja"})).unwrap().unwrap(); + assert_eq!(app.ui.language, Language::Ja); + assert_eq!(crate::menus::checked(&app, "app.language", &serde_json::json!({"lang": "ja"})), Some(true)); + assert!(crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "xx"})).unwrap().is_err()); + assert_eq!(app.ui.language, Language::Ja); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + } + + #[test] + fn japanese_glyphs_are_available_without_system_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let families: Vec<_> = fonts.definitions().families.keys().cloned().collect(); + for family in families { + let font = egui::FontId::new(13.0, family); + for ch in "日本語ファイル編集".chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); + } + } + }); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21ad7cc3..e4b87c9e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod cursors; pub mod dialogs; pub mod dock; pub mod find_font; +pub mod i18n; pub mod icon_data; pub mod icons; pub mod io; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c880f684..e00ff340 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -50,6 +50,7 @@ use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"), ("file.open", "Open…", "Cmd+O", "{path?}"), ( "file.save", @@ -671,6 +672,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ /// Handle a UI command. `None` = not a UI command (the engine handles it). pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + if id == "app.language" { + let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse); + let Some(language) = language else { return Some(Err("lang must be en or ja".into())) }; + app.ui.language = language; + return Some(Ok(json!(language))); + } if let Some(r) = crate::panels::character::intercept_text_command(app, id) { return Some(r); } @@ -1102,8 +1109,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Option { + if id == "app.language" { + return Some(p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse) == Some(app.ui.language)); + } let v = &app.ui.view; Some(match id { + "type.orientation.vertical" | "type.orientation.horizontal" => { + let st = app.session.active()?; + let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { + Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t), + _ => None, + })?; + t.vertical == (id == "type.orientation.vertical") + } "view.outline" => v.outline, "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, @@ -1432,6 +1450,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Join Our Discord", "help.discord"), Sep, c("Settings…", "edit.preferences"), + sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, @@ -1837,7 +1856,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Fill with Placeholder Text", "type.fillPlaceholder"), Sep, c("Show Hidden Characters", "type.hiddenCharacters"), - sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]), + sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), ], ), ( @@ -2094,9 +2113,9 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { .ui(ui, |ui| { for (i, (title, items)) in tree.iter().enumerate() { let text = if i == 0 { - egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) + egui::RichText::new(app.ui.language.tr(title)).font(theme::semibold(13.0)).color(t.text) } else { - egui::RichText::new(*title).size(13.0).color(t.text) + egui::RichText::new(app.ui.language.tr(title)).size(13.0).color(t.text) }; ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)); } @@ -2126,10 +2145,10 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked ui.separator(); } Item::Header(h) => { - ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr(h)).size(11.0).color(t.text_dim)); } Item::Sub(label, children) => { - ui.menu_button(*label, |ui| { + ui.menu_button(app.ui.language.tr(label), |ui| { widgets::menu_scroll(ui, |ui| { ui.set_min_width(200.0); render_items(app, ui, children, clicked); @@ -2138,7 +2157,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } Item::Todo(label, sc) => { ui.add_enabled_ui(false, |ui| { - ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc))); + ui.add(egui::Button::new(app.ui.language.tr(label)).shortcut_text(pretty_shortcut(sc))); }) .response .on_disabled_hover_text("Coming soon — tracked in the parity plan"); @@ -2150,6 +2169,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked continue; } let label = dynamic_label(app, id, label); + let label = app.ui.language.tr(&label).to_string(); let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default(); let chk = checked(app, id, p); let text = match chk { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aeb19207..e718e1b6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -207,6 +207,8 @@ impl Dialog { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct UiState { + #[serde(default)] + pub language: crate::i18n::Language, pub brightness: Brightness, pub dock_tab: DockTab, /// Icon panel currently popped out of the collapsed column. @@ -342,6 +344,7 @@ impl UiState { impl Default for UiState { fn default() -> Self { Self { + language: crate::i18n::Language::default(), brightness: Brightness::MediumDark, dock_tab: DockTab::Properties, open_panel: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e4b95f72..b0f6240b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -254,6 +254,10 @@ pub fn install_fonts(ctx: &egui::Context) { let mut mono = vec!["JetBrainsMono".to_string()]; mono.extend(fallback); fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono); + add(&mut fonts, "japanese", vectorcraft_text::SHIPPORI_MINCHO_REGULAR); + for stack in fonts.families.values_mut() { + stack.push("japanese".into()); + } ctx.set_fonts(fonts); }