Files
vectorcraft/crates/ui-egui/src/panels/character.rs
T
bflatasticandClaude Opus 5.5 0c4df274aa Fix #458: Font Size and Leading combo boxes in the Character and Properties panels and the Control bar
The Properties panel's Font Size and Leading were plain typed fields and the
Control bar had no type controls. All three places now use the shared
editable combo (`widgets::spin_field`: typing, arrow and spinner stepping,
label scrubbing, a preset dropdown in the type unit):

- Character panel: the size and leading grid is shared with the Properties
  panel (`character::metrics_grid`), which now shows the font style, Font
  Size, Leading, Kerning and Tracking as combos and applies them to the
  Type tool's selected characters as the Character panel does.
- Control bar: with type selected or edited, a Character link (the Character
  panel in a popover), the font family and style menus and the Font Size combo.
- Presets as Illustrator lists them: sizes 6-72 pt; Leading has Auto first
  (`widgets::spin_field_auto`, `text.setStyle {leading: "auto"}`) and then
  the size ladder instead of 120% values; Kerning and Tracking -100...200.
- Font Size, Leading, Kerning and Tracking show blank where the selected
  text's values differ (`character::shared`); the spinner does nothing on a
  blank field instead of jumping to the step size.

Each pick is one `text.setStyle` / `text.setRangeStyle` step, so MCP and the
control channel are unchanged. Translations for the two new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:10:14 +07:00

563 lines
23 KiB
Rust

//! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal
//! scale, baseline shift, character rotation and the caps / underline / strikethrough toggles.
use std::sync::{Mutex, OnceLock};
use egui::{Key, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
use vectorcraft_tools::{Mods, ToolKey};
use super::{first_selected, pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, SpinPick, menu_item};
/// The Font Size and Leading dropdowns' presets in points (Leading's after its Auto entry), shown
/// in the type unit.
pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 36.0, 48.0, 60.0, 72.0];
/// The Kerning and Tracking dropdowns' presets in 1/1000 em.
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run.
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
if let Some((id, a, b)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para.clone()));
}
let n = first_selected(app)?;
match &n.kind {
NodeKind::Text(t) => Some((t.first_style(), t.para.clone())),
_ => None,
}
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
let st = app.session.active()?;
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
};
let mut acc = Shared::None;
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
if b <= a {
return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b)));
}
let mut end = 0;
for r in &t.runs {
let start = end;
end += r.text.len();
if start < b && end > a {
acc.add(f(&r.style));
}
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
t.runs.iter().for_each(|r| acc.add(f(&r.style)));
}
});
}
}
acc.value()
}
/// The value a run of styles share so far ([`shared`]).
enum Shared<T> {
None,
Same(T),
Differ,
}
impl<T: PartialEq> Shared<T> {
fn add(&mut self, v: T) {
match self {
Shared::None => *self = Shared::Same(v),
Shared::Same(x) if *x != v => *self = Shared::Differ,
_ => {}
}
}
fn value(self) -> Option<T> {
match self {
Shared::Same(v) => Some(v),
_ => None,
}
}
}
fn style(app: &mut VectorcraftApp, p: Value) {
char_cmd(app, "text.setStyle", p);
}
fn format(app: &mut VectorcraftApp, p: Value) {
char_cmd(app, "text.setFormat", p);
}
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
/// object when nothing is selected), else to the selected text objects.
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
if text_editing(app).is_none() {
app.run(cmd, p).ok();
return;
}
range_style(app, p);
}
/// `text.setRangeStyle` with `p` on the range the Type tool has selected (the whole text when
/// nothing is selected). Does nothing unless the Type tool edits text.
pub(crate) fn range_style(app: &mut VectorcraftApp, p: Value) {
let Some((id, a, b)) = text_editing(app) else { return };
end_typing(app);
let mut q = p;
q["id"] = json!(id.0);
if b > a {
q["start"] = json!(a);
q["end"] = json!(b);
}
app.run("text.setRangeStyle", q).ok();
}
// ---------- Type tool editing glue (selection, clipboard, keys) ----------
/// The text object the Type tool is editing and its selection (clamped to the text).
pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize)> {
let o = app.session.tool_options();
let id = NodeId(o.get("editing")?.as_u64()?);
let len = match &app.session.active()?.doc.node(id)?.kind {
NodeKind::Text(t) => t.plain_text().len(),
_ => return None,
};
let g = |k: &str| o.get(k).and_then(Value::as_u64).map_or(0, |v| (v as usize).min(len));
Some((id, g("start"), g("end")))
}
/// Close the Type tool's typing session (one undo step) before another command edits the text.
pub(crate) fn end_typing(app: &mut VectorcraftApp) {
if app.session.tool_options().get("typing").and_then(Value::as_bool) == Some(true) {
app.session.set_tool_option("commitTyping", &json!(true));
app.session.commit_interaction().ok();
app.sync_views();
}
}
static CTX: OnceLock<egui::Context> = OnceLock::new();
/// Plain text of the last text copied inside the Type tool (paste from the native menu, which
/// can't read the system clipboard).
static CLIP: Mutex<String> = Mutex::new(String::new());
fn text_copy(app: &mut VectorcraftApp) -> bool {
let Some((id, a, b)) = text_editing(app) else { return false };
if a == b {
return false;
}
let Ok(r) = app.session.execute("text.getRange", &json!({"id": id.0, "start": a, "end": b})) else { return false };
app.session.set_tool_option("copy", &r["runs"]);
let text = r["text"].as_str().unwrap_or("").to_string();
if let Some(ctx) = CTX.get() {
ctx.copy_text(text.clone());
}
*CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text;
true
}
fn text_key(app: &mut VectorcraftApp, k: ToolKey, m: Mods) {
let v = app.view_info();
if let Err(e) = app.session.tool_key(k, m, v) {
app.status(e.to_string());
}
}
fn text_paste(app: &mut VectorcraftApp, s: Option<String>) {
let s = s.unwrap_or_else(|| CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone());
if !s.is_empty() {
let v = app.view_info();
if let Err(e) = app.session.tool_text(&s, v) {
app.status(e.to_string());
}
}
}
/// Edit-menu commands while the Type tool edits text act on the text (Cut/Copy/Paste/Clear;
/// Select All is the engine's `select.all`). `None` = not intercepted.
pub(crate) fn intercept_text_command(app: &mut VectorcraftApp, id: &str) -> Option<Result<Value, String>> {
if !app.session.tool_wants_text() {
return None;
}
match id {
"edit.copy" => {
text_copy(app);
}
"edit.cut" => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
}
"edit.paste" | "edit.pasteWithoutFormatting" => text_paste(app, None),
"edit.clear" => text_key(app, ToolKey::Delete, Mods::default()),
_ => return None,
}
app.sync_views();
Some(Ok(Value::Null))
}
enum TextInput {
Key(ToolKey, Mods),
Copy,
Cut,
Paste(Option<String>),
}
/// Route editing keys (with modifiers), clipboard events and Cmd+C/X/V to the Type tool (Cmd+A is
/// left to the Select All shortcut, which selects the text being edited).
pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
CTX.get_or_init(|| ctx.clone());
let mut todo = vec![];
ctx.input_mut(|i| {
i.events.retain(|e| match e {
egui::Event::Copy => {
todo.push(TextInput::Copy);
false
}
egui::Event::Cut => {
todo.push(TextInput::Cut);
false
}
egui::Event::Paste(s) => {
todo.push(TextInput::Paste(Some(s.clone())));
false
}
egui::Event::Key { key, pressed, modifiers: m, .. } => {
let mods = Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl && !m.command, space: false };
let tk = match key {
Key::ArrowLeft => Some(ToolKey::Left),
Key::ArrowRight => Some(ToolKey::Right),
Key::ArrowUp => Some(ToolKey::Up),
Key::ArrowDown => Some(ToolKey::Down),
Key::Home => Some(ToolKey::Home),
Key::End => Some(ToolKey::End),
Key::Backspace => Some(ToolKey::Backspace),
Key::Delete => Some(ToolKey::Delete),
Key::Tab if !m.command => Some(ToolKey::Tab),
_ => None,
};
if let Some(tk) = tk {
if *pressed {
todo.push(TextInput::Key(tk, mods));
}
return false;
}
if m.command && !m.shift && !m.alt && matches!(key, Key::C | Key::X | Key::V) {
if *pressed {
todo.push(match key {
Key::C => TextInput::Copy,
Key::X => TextInput::Cut,
_ => TextInput::Paste(None),
});
}
return false;
}
true
}
_ => true,
})
});
for t in todo {
match t {
TextInput::Key(k, m) => text_key(app, k, m),
TextInput::Copy => {
text_copy(app);
}
TextInput::Cut => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
}
TextInput::Paste(s) => text_paste(app, s),
}
}
}
/// A compact labelled cell: short glyph label + spinner field.
fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let l =
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
crate::scrub::note_label(ui, l.rect);
add(ui);
});
}
/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and
/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style
/// shown). The Properties panel shows its first rows.
pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) {
let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0);
let unit = app.session.type_unit();
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
cell(ui, "A↕", tl!("Leading"), |ui| {
let leading = shared(app, CharStyle::effective_leading);
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
Some(SpinPick::Auto) => style(app, json!({"leading": "auto"})),
None => {}
}
});
ui.end_row();
cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| {
let kerning = shared(app, |s| s.kerning.unwrap_or(0.0));
if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) {
format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) });
}
});
cell(ui, "VA↔", tl!("Tracking"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-track", shared(app, |s| s.tracking), "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) {
style(app, json!({"tracking": v}));
}
});
ui.end_row();
if !more {
return;
}
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"vScale": v}));
}
});
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"hScale": v}));
}
});
ui.end_row();
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
format(app, json!({"baselineShift": v}));
}
});
cell(ui, "⟲T", tl!("Character Rotation"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-rot", s.rotation, "°", 1, fw, 15.0, -360.0, &super::transform::ANGLE_PRESETS) {
format(app, json!({"rotation": v}));
}
});
ui.end_row();
});
}
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
let size = shared(app, |s| s.size);
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
style(app, json!({"size": v}));
}
}
/// The font family menu and the font style dropdown of style `s` (Character and Properties panels,
/// Control bar), with their `ids` and `widths`.
pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, ids: (&str, &str), widths: (f32, f32)) {
let sample = crate::font_menu::sample_text(app);
if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), crate::font_menu::MenuLook::of(app)) {
crate::font_menu::apply(app, ui.ctx(), pick);
}
let styles = vectorcraft_text::FontDb::global().styles(&s.font_family);
let names: Vec<&str> = styles.iter().map(String::as_str).collect();
if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) {
style(app, json!({"style": name}));
}
}
/// The Control bar's type controls while type is selected or edited: the Character link (the
/// Character panel in a popover), the font family and style menus and the Font Size combo.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else { return };
let link = ui
.link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline())
.on_hover_text(tl!("Character options"));
widgets::popover(&link, link.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0));
size_field(app, ui, "cb-font-size", 96.0);
ui.add_space(4.0);
ui.separator();
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else {
super::empty_state(ui, "type", tl!("No text selected"), tl!("Select a text object to edit its character attributes."));
return;
};
let w = ui.available_width();
font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0));
ui.add_space(2.0);
let hidden = pstate::<bool>(ui.ctx(), "ch-hide-options");
metrics_grid(app, ui, "ch-grid", &s, w, !hidden);
if hidden {
return;
}
ui.add_space(4.0);
ui.horizontal(|ui| {
for (kind, tip, key, on, enabled) in [
(Glyph::AllCaps, tl!("All Caps"), "allCaps", s.all_caps, true),
(Glyph::SmallCaps, tl!("Small Caps (on the roadmap)"), "", false, false),
(Glyph::Super, tl!("Superscript (on the roadmap)"), "", false, false),
(Glyph::Sub, tl!("Subscript (on the roadmap)"), "", false, false),
(Glyph::Underline, tl!("Underline"), "underline", s.underline, true),
(Glyph::Strike, tl!("Strikethrough"), "strikethrough", s.strikethrough, true),
] {
if style_toggle(ui, kind, tip, on, enabled) && !key.is_empty() {
format(app, json!({key: !on}));
}
}
});
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Glyph {
AllCaps,
SmallCaps,
Super,
Sub,
Underline,
Strike,
}
/// A drawn "T" style toggle (All Caps, Small Caps, Superscript, Subscript, Underline, Strikethrough).
fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), if enabled { egui::Sense::click() } else { egui::Sense::hover() });
if on {
ui.painter().rect_filled(r, 3, t.tool_active);
} else if resp.hovered() && enabled {
ui.painter().rect_filled(r, 3, t.hover);
}
let col = if enabled { t.text_strong } else { t.text_disabled };
let big = egui::FontId::proportional(15.0);
let small = egui::FontId::proportional(10.5);
let c = r.center();
let p = ui.painter();
match g {
Glyph::AllCaps => {
p.text(c - vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big.clone(), col);
p.text(c + vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
}
Glyph::SmallCaps => {
p.text(c - vec2(4.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, 2.0), egui::Align2::CENTER_CENTER, "T", small, col);
}
Glyph::Super => {
p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, -5.0), egui::Align2::CENTER_CENTER, "1", small, col);
}
Glyph::Sub => {
p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, 5.0), egui::Align2::CENTER_CENTER, "1", small, col);
}
Glyph::Underline => {
p.text(c - vec2(0.0, 1.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.line_segment([c + vec2(-5.0, 7.0), c + vec2(5.0, 7.0)], egui::Stroke::new(1.2, col));
}
Glyph::Strike => {
p.text(c, egui::Align2::CENTER_CENTER, "T", big, col);
p.line_segment([c + vec2(-6.0, 1.0), c + vec2(6.0, 1.0)], egui::Stroke::new(1.2, col));
}
}
let resp = resp.on_hover_text(tl!(tip));
enabled && resp.clicked()
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = text_style(app);
let has = st.is_some();
let hidden: bool = pstate(ui.ctx(), "ch-hide-options");
if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) {
set_pstate(ui.ctx(), "ch-hide-options", !hidden);
}
ui.separator();
let s = st.map(|x| x.0);
for (label, key, on) in [
(tl!("All Caps"), "allCaps", s.as_ref().is_some_and(|s| s.all_caps)),
(tl!("Underline"), "underline", s.as_ref().is_some_and(|s| s.underline)),
(tl!("Strikethrough"), "strikethrough", s.as_ref().is_some_and(|s| s.strikethrough)),
] {
if menu_item(ui, label, has, on) {
format(app, json!({key: !on}));
}
}
for l in ["Small Caps", "Superscript", "Subscript"] {
menu_item(ui, tl!(l), false, false);
}
ui.separator();
// Character Alignment: where characters smaller than the largest on their line line up, with
// the East Asian options.
if app.session.prefs.show_east_asian_options {
let align = s.as_ref().map(|s| s.char_align);
ui.add_enabled_ui(has, |ui| {
// Indented like the items beside it (their check column).
ui.menu_button(format!(" {}", tl!("Character Alignment")), |ui| {
use vectorcraft_doc::CharAlign;
for (label, a, key) in [
(tl!("Roman Baseline"), CharAlign::RomanBaseline, "romanBaseline"),
(tl!("Em Box Top/Right"), CharAlign::EmBoxTop, "emBoxTop"),
(tl!("Em Box Center"), CharAlign::EmBoxCenter, "emBoxCenter"),
(tl!("Em Box Bottom/Left"), CharAlign::EmBoxBottom, "emBoxBottom"),
] {
if menu_item(ui, label, true, align == Some(a)) {
format(app, json!({"charAlign": key}));
}
}
});
});
ui.separator();
}
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
menu_item(ui, tl!(l), false, l == "Fractional Widths");
}
ui.separator();
// The installed fonts are always listed; this picks up fonts installed since the app started.
#[cfg(not(target_arch = "wasm32"))]
if menu_item(ui, tl!("Refresh Font List"), true, false)
&& let Ok(r) = app.run("text.rescanFonts", json!({}))
{
app.status(format!("{} font families available", r["families"]));
}
if menu_item(ui, tl!("Reset Panel"), has, false) {
style(app, json!({"tracking": 0, "leading": "auto"}));
format(
app,
json!({"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false}),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
/// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set
/// and Top-to-Top Leading are in the Paragraph panel).
#[test]
fn character_alignment_shows_with_the_east_asian_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment");
assert!(!shown(&mut app));
app.session.prefs.show_east_asian_options = true;
assert!(shown(&mut app));
}
}