Files
vectorcraft/crates/ui-egui/src/titlebar.rs
T
WildfootWandClaude Fable 5.1 902c3ae8c1 i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
Strings in code stay English and are the lookup keys; `crates/ui-egui/src/i18n`
maps them to display text at render time from one `*.tsv` catalog per language
(`LANGUAGES` registry, `tl!`/`t`/`tr`/`tr_ctx`/`tr_id`/`tn`/`fmt`). Command ids,
menu paths, `ui.menu.list`, the control channel, the CLI, MCP, status messages
and undo labels never see translated text, so agents, scripts and the existing
tests are unaffected (under `cargo test` the UI is always English).

Wired: in-window menus and the macOS native menu (rebuilt on a language change),
dynamic labels (Undo X, Reset X, Last Effect: X as templates), the command
palette (matches English and translated text), dock tabs, panel tooltips, the
toolbar (category names now cut by characters, not bytes), the dialog frame, the
shared widgets, the Preferences dialog, the shortcut editor, the hint and status
bars, the Home screen, and the literals of every panel and dialog.

Preference `interfaceLanguage` (User Interface › Language): `auto` or a code.
`auto` follows `VECTORCRAFT_LOCALE`, `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages and, on Windows (which sets no `LANG`), the user
locale from `HKCU\Control Panel\International`. `zh-TW`/`zh-HK`/`zh-MO`/
`zh-Hant-*` resolve to `zh-hant`; Simplified locales stay English until a
`zh-hans` catalog exists.

`zh-hant.tsv` (~2,850 rows) is clean-room, in the vocabulary used in Taiwan;
terms shared with PhotoCraft reuse its wording, vector terms use everyday
vector-drawing vocabulary. Tests: catalog well-formedness (placeholders,
ellipses, command ids, no Simplified characters), locale resolution, and two
coverage tests that fail when a menu string, panel/tool/preference label,
dialog button, hint-bar fragment or `tl!` literal has no row in a language
marked `complete_menus`. `VECTORCRAFT_I18N_DUMP=file cargo test -p
vectorcraft-ui-egui dump_source_strings` lists the strings for translators.

Also: two clippy findings under rustc 1.97 fixed in passing.

Integrated with main (squashed onto it, 42 commits on): the Czech interface
(#213) moves from its Rust table to `i18n/cs.tsv` on the registry (all 556
rows, Czech plural rule, its menu-coverage and glyph tests kept); Find Font's
resolution notes and the canvas context menu's labels are translated and
covered by the catalog tests (`menus::CONTEXT_LABELS`); the glyph test checks
CJK text only when the craft-fonts build input is embedded (#220 moved the
Japanese fonts out of this repository); a language saved by an older version
carries over to `interfaceLanguage`; Windows' locale lookup runs reg.exe by its
full path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:37:05 +07:00

365 lines
16 KiB
Rust

//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`VectorcraftApp::custom_titlebar`]): empty space in the app bar drags the window and a
//! double-click there maximizes it, caption buttons at the far right minimize, maximize/restore and
//! close, and thin invisible zones along the window's edges resize it.
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use serde_json::json;
use crate::VectorcraftApp;
use crate::theme::Tokens;
/// One caption button: as wide as the native Windows ones.
pub const BUTTON_WIDTH: f32 = 46.0;
/// The three caption buttons together.
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
/// Resize zone thickness along an edge, and the side of a corner zone.
const EDGE: f32 = 5.0;
const CORNER: f32 = 12.0;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Caption button ids (stable, so tests and agents can find them).
fn caption_id(c: Caption) -> Id {
Id::new(("titlebar-caption", c as u8))
}
fn maximized(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
fn toggle_maximize(ctx: &egui::Context) {
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
}
/// The bar's background: a press-and-drag on empty space moves the window, a double-click toggles
/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks.
pub fn drag_area(ui: &Ui, rect: Rect) {
let ctx = ui.ctx();
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
// egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing
// background below it, so remember whether the press itself landed on bare background.
if ctx.input(|i| i.pointer.any_pressed()) {
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
}
if resp.double_clicked() {
toggle_maximize(ctx);
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
}
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the app bar's outer rect), full
/// bar height down to its bottom border, flush with the window's edge so a maximized window's
/// corner hits Close. Close runs `app.quit`, which asks about unsaved documents first.
pub fn caption_buttons(app: &mut VectorcraftApp, ui: &mut Ui, bar: Rect) {
let t = Tokens::get(ui.ctx());
let max = maximized(ui.ctx());
let mut clicked = None;
for (i, c) in CAPTIONS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
let resp = ui.interact(r, caption_id(c), Sense::click());
let close = c == Caption::Close;
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
(true, _) if close => t.caption_close.gamma_multiply(0.8),
(_, true) if close => t.caption_close,
(true, _) => t.tool_active,
(_, true) => t.hover,
_ => egui::Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
let tip = match c {
Caption::Minimize => tl!("Minimize"),
Caption::Maximize if max => tl!("Restore"),
Caption::Maximize => tl!("Maximize"),
Caption::Close => tl!("Close"),
};
if resp.on_hover_text(tip).clicked() {
clicked = Some(c);
}
}
match clicked {
Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),
Some(Caption::Maximize) => toggle_maximize(ui.ctx()),
Some(Caption::Close) => {
app.run("app.quit", json!({})).ok();
}
None => {}
}
}
/// The 10 pt line glyphs of the caption buttons.
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
let p = ui.painter();
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
match c {
Caption::Minimize => {
p.line_segment([g.left_center(), g.right_center()], s);
}
// Restore: a front square with the corner of the one behind it.
Caption::Maximize if maximized => {
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
}
Caption::Maximize => {
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
}
Caption::Close => {
p.line_segment([g.left_top(), g.right_bottom()], s);
p.line_segment([g.right_top(), g.left_bottom()], s);
}
}
}
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners
/// come last so they win where they overlap the edges.
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
use ResizeDirection::*;
let (e, c) = (EDGE, CORNER);
[
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
]
}
/// Invisible resize zones along the window's edges, above everything else; a press in one starts
/// an OS resize in that direction. None while the window is maximized or fullscreen.
pub fn resize_zones(ui: &mut Ui) {
let ctx = ui.ctx().clone();
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let w = ctx.content_rect();
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, Pos2, RawInput, ViewportId};
use vectorcraft_engine::Session;
/// A headless window `width` pt wide showing the app bar (as the title bar) and the resize zones.
struct Win {
ctx: egui::Context,
app: VectorcraftApp,
width: f32,
time: f64,
maximized: bool,
}
impl Win {
fn new(width: f32) -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.custom_titlebar = true;
Self { ctx, app, width, time: 0.0, maximized: false }
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.time += 0.05;
let mut input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))),
time: Some(self.time),
events,
..Default::default()
};
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
v.maximized = Some(self.maximized);
}
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
crate::chrome::app_bar(app, ui);
resize_zones(ui);
});
out.textures_delta.clear();
out
}
}
fn texts(out: &egui::FullOutput) -> Vec<(String, Rect)> {
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
}
fn button(p: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
impl Win {
/// Frames for a pointer gesture: move to `path[0]`, press, move along `path`, release. The
/// viewport commands the frames sent.
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
}
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
self.gesture(&[p])
}
fn caption(&self, c: Caption) -> Rect {
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
}
/// The bar's clickable widgets other than its background and the caption buttons.
fn bar_widgets(&self) -> Vec<Rect> {
let skip: Vec<Id> = std::iter::once(Id::new("titlebar-drag")).chain(CAPTIONS.map(caption_id)).collect();
self.ctx.viewport(|v| {
v.prev_pass
.widgets
.layers()
.filter(|(l, _)| l.order != Order::Foreground)
.flat_map(|(_, ws)| ws.iter())
.filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id))
.map(|w| w.rect)
.collect()
})
}
}
#[test]
fn caption_buttons_take_the_right_end_and_nothing_overlaps_down_to_800_pt() {
for width in [800.0, 1100.0, 1440.0] {
let mut w = Win::new(width);
w.frame(vec![]);
let out = w.frame(vec![]);
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
assert!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
let widgets = w.bar_widgets();
assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}");
for (i, a) in widgets.iter().enumerate() {
assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}");
for b in &widgets[i + 1..] {
assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}");
}
}
// Wide windows show the whole group; narrow ones drop Discord and shorten the search.
let texts: Vec<String> = texts(&out).into_iter().map(|(t, _)| t).collect();
let has = |s: &str| texts.iter().any(|t| t == s);
assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}");
}
}
#[test]
fn close_runs_the_quit_flow_instead_of_closing_the_window() {
let mut w = Win::new(1000.0);
w.app.run("file.new", serde_json::json!({"width": 100, "height": 100})).unwrap();
w.app.run("shape.rectangle", serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
w.frame(vec![]);
let close = w.caption(Caption::Close).center();
let cmds = w.click(close);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::Close)), "{cmds:?}");
let dialog = w.app.ui.dialog.as_ref().map(|d| d.kind.clone());
assert_eq!(dialog.as_deref(), Some(crate::unsaved::KIND), "a modified document asks first");
assert_ne!(w.app.ui.status, "quit");
// Nothing to save: the host is told to quit.
w.app.ui.dialog = None;
w.app.session.doc_mut().unwrap().mark_saved();
w.click(close);
assert_eq!(w.app.ui.status, "quit");
}
#[test]
fn minimize_and_maximize_follow_the_window_state() {
let mut w = Win::new(1000.0);
w.frame(vec![]);
let (min, max) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center());
assert!(w.click(min).contains(&ViewportCommand::Minimized(true)));
assert!(w.click(max).contains(&ViewportCommand::Maximized(true)));
w.maximized = true;
assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore");
}
#[test]
fn empty_bar_space_drags_and_double_click_maximizes() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
let empty = pos2(700.0, 22.0);
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
// Double-click toggles maximize.
let mut cmds = w.click(empty);
cmds.extend(w.click(empty));
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}");
w.maximized = true;
w.time += 1.0;
let mut cmds = w.click(empty);
cmds.extend(w.click(empty));
assert!(cmds.contains(&ViewportCommand::Maximized(false)), "{cmds:?}");
// Widgets keep their clicks: dragging from a menu title doesn't move the window.
let file = texts(&w.frame(vec![])).into_iter().find(|(t, _)| t == "File").map(|(_, r)| r.center());
let file = file.expect("the File menu title");
let cmds = w.gesture(&[file, file + vec2(12.0, 4.0), file + vec2(30.0, 8.0)]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
}
#[test]
fn edge_zones_resize_unless_maximized() {
use ResizeDirection::*;
let mut w = Win::new(1000.0);
w.frame(vec![]);
for (p, dir) in [
(pos2(1.0, 300.0), West),
(pos2(998.0, 300.0), East),
(pos2(500.0, 1.0), North),
(pos2(500.0, 598.0), South),
(pos2(2.0, 2.0), NorthWest),
(pos2(997.0, 3.0), NorthEast),
(pos2(3.0, 597.0), SouthWest),
(pos2(998.0, 598.0), SouthEast),
] {
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
}
let out = w.frame(vec![Event::PointerMoved(pos2(1.0, 300.0))]);
assert_eq!(out.platform_output.cursor_icon, CursorIcon::ResizeHorizontal);
// Maximized: no zones, and the window's top-right corner is Close.
w.maximized = true;
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
assert!(w.click(pos2(999.5, 0.5)).is_empty());
assert_eq!(w.app.ui.status, "quit");
}
}