From 49e471ae64a1dc9e15237b14cc69c3dbcef48ca1 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Mon, 10 Aug 2026 16:52:39 +0800 Subject: [PATCH] feat(gpui, gpui_widgets): i18n string-table hook + node-graph fit API - gpui::i18n: minimal string-table override (set_table/tr/clear_table) with built-in defaults; effect-stack and viewer strings now go through it so hosts can localize widget-baked labels without a full i18n framework. - gpui_widgets::i18n re-exports the hook (gpui_widgets::i18n::set_table). - node_graph: GraphViewState::fit_to_rect for fit-window/initial viewports (unit tested); NodeGraphView::viewport_size accessor for fit targets. --- crates/gpui/src/effect_stack/stack_view.rs | 4 +- crates/gpui/src/gpui.rs | 2 + crates/gpui/src/i18n.rs | 86 +++++++++++++++++++++ crates/gpui/src/node_graph/graph_view.rs | 9 ++- crates/gpui/src/node_graph/state.rs | 89 +++++++++++++++++++++- crates/gpui_widgets/src/i18n.rs | 31 ++++++++ crates/gpui_widgets/src/lib.rs | 1 + crates/gpui_widgets/src/viewer/mod.rs | 8 +- 8 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 crates/gpui/src/i18n.rs create mode 100644 crates/gpui_widgets/src/i18n.rs diff --git a/crates/gpui/src/effect_stack/stack_view.rs b/crates/gpui/src/effect_stack/stack_view.rs index 32f0eb0566..b82b3d3646 100644 --- a/crates/gpui/src/effect_stack/stack_view.rs +++ b/crates/gpui/src/effect_stack/stack_view.rs @@ -406,7 +406,7 @@ impl Render for EffectStackView { .justify_center() .text_sm() .text_color(colors.disabled) - .child("No selection"), + .child(crate::i18n::tr("effect_stack.empty", "No selection")), ); }; @@ -576,7 +576,7 @@ impl Render for EffectStackView { .py_2() .text_sm() .text_color(colors.text) - .child("+ Add Effect") + .child(crate::i18n::tr("effect_stack.add", "+ Add Effect")) .on_click(cx.listener(move |this, _event, _window, cx| this.add(cx))); root.child(column).child(add_button) diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 9b3dd74ce0..22be81f73f 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -22,6 +22,8 @@ pub mod colors; pub mod dock; /// Linear effect-stack inspector widget (companion to [`node_graph`]). pub mod effect_stack; +/// Minimal localization hook for widget-baked strings. +pub mod i18n; mod element; mod elements; mod executor; diff --git a/crates/gpui/src/i18n.rs b/crates/gpui/src/i18n.rs new file mode 100644 index 0000000000..e120000a88 --- /dev/null +++ b/crates/gpui/src/i18n.rs @@ -0,0 +1,86 @@ +//! Minimal localization hook for widget-baked strings. +//! +//! A few widgets embed small, user-visible strings directly (transport +//! buttons, empty states). Rather than shipping a full i18n framework into +//! the widget crates, they expose a single override point: a process-global +//! string table that maps a stable key to a localized string. Widget code +//! calls [`tr`] with the key plus the string it would otherwise show; when +//! the installed table has an entry for the key that entry wins, otherwise +//! the built-in default is used. +//! +//! This means: +//! +//! * An app that never calls [`set_table`] sees exactly the strings baked +//! into the widgets (no behavior change, all tests keep passing). +//! * An app that wants localized widgets installs a [`StringTable`] once per +//! language (e.g. on startup and on every language switch) and every +//! widget picks the new strings up on the next render. +//! +//! The table is a plain `HashMap` — no serde, no build step +//! — behind a single [`RwLock`], so any thread may install or read it. The +//! companion `gpui_widgets::i18n` module re-exports this API so hosts can +//! address it as `gpui_widgets::i18n::set_table(..)`. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use crate::SharedString; + +/// A key → localized-string mapping installed by the host application. +pub type StringTable = HashMap; + +/// The installed table, or `None` (built-in defaults) when unset. +fn table() -> &'static RwLock> { + static TABLE: OnceLock>> = OnceLock::new(); + TABLE.get_or_init(|| RwLock::new(None)) +} + +/// Installs `strings` as the string-table override, replacing any previously +/// installed table wholesale. +pub fn set_table(strings: StringTable) { + *table().write().unwrap() = Some(strings); +} + +/// Removes the override so every string falls back to its built-in default. +pub fn clear_table() { + *table().write().unwrap() = None; +} + +/// Returns the localized string for `key`, or `default` when the installed +/// table has no entry for it. +pub fn tr(key: &str, default: impl Into) -> SharedString { + if let Some(value) = table() + .read() + .unwrap() + .as_ref() + .and_then(|strings| strings.get(key)) + { + SharedString::from(value.clone()) + } else { + default.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_table_uses_defaults() { + clear_table(); + assert_eq!(tr("viewer.safe_frames", "安全框"), "安全框"); + assert_eq!(tr("viewer.zoom", "缩放"), "缩放"); + } + + #[test] + fn installed_table_overrides_defaults() { + let mut table = StringTable::new(); + table.insert("viewer.safe_frames".into(), "Safe Frames".into()); + set_table(table); + assert_eq!(tr("viewer.safe_frames", "安全框"), "Safe Frames"); + // Keys not in the table keep their defaults. + assert_eq!(tr("viewer.zoom", "缩放"), "缩放"); + clear_table(); + assert_eq!(tr("viewer.safe_frames", "安全框"), "安全框"); + } +} diff --git a/crates/gpui/src/node_graph/graph_view.rs b/crates/gpui/src/node_graph/graph_view.rs index 819fdbffe3..f730f3640a 100644 --- a/crates/gpui/src/node_graph/graph_view.rs +++ b/crates/gpui/src/node_graph/graph_view.rs @@ -11,7 +11,7 @@ use crate::{ App, BorderStyle, Bounds, Context, Corners, Edges, Entity, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, KeyUpEvent, MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, - Window, canvas, colors::DefaultColors, div, fill, hsla, point, prelude::*, px, size, + Size, Window, canvas, colors::DefaultColors, div, fill, hsla, point, prelude::*, px, size, }; use crate::node_graph::{ @@ -288,6 +288,13 @@ impl NodeGraphView { &self.state } + /// Returns the size of the canvas the graph was last painted into, or a + /// zero size before the first frame. Hosts use this to fit the viewport + /// to the graph (see [`GraphViewState::fit_to_rect`]). + pub fn viewport_size(&self) -> Size { + self.viewport.size + } + /// Returns a mutable reference to the viewport/selection state, e.g. to /// restore a persisted viewport or to sync selection with /// [`crate::effect_stack`]. Does not emit events; call `cx.notify()` on diff --git a/crates/gpui/src/node_graph/state.rs b/crates/gpui/src/node_graph/state.rs index 843adc0146..d770ea0aad 100644 --- a/crates/gpui/src/node_graph/state.rs +++ b/crates/gpui/src/node_graph/state.rs @@ -8,7 +8,7 @@ use std::collections::BTreeSet; -use crate::{Pixels, Point, point}; +use crate::{Bounds, Pixels, Point, Size, point}; use crate::node_graph::NodeId; @@ -130,6 +130,33 @@ impl GraphViewState { self.zoom = new_zoom; } + /// Fits the graph-space rectangle `rect` (typically the union of every + /// node's bounds) into the `viewport` screen-space size: zooms so the + /// rect occupies at most 95% of the viewport (clamped to + /// [`MIN_ZOOM`]..=[`MAX_ZOOM`]) and pans so the rect is centered. + /// + /// No-op when either size is non-positive. Used by hosts for a "fit + /// window" command and as the initial viewport after the first layout. + pub fn fit_to_rect(&mut self, rect: Bounds, viewport: Size) { + const PADDING: f32 = 40.0; + let (rw, rh) = (rect.size.width.0, rect.size.height.0); + let (vw, vh) = (viewport.width.0, viewport.height.0); + if rw <= 0.0 || rh <= 0.0 || vw <= 0.0 || vh <= 0.0 { + return; + } + // Fit the larger axis; the padding keeps a breathing margin. + let zoom = (vw / (rw + PADDING * 2.0)) + .min(vh / (rh + PADDING * 2.0)) + .clamp(MIN_ZOOM, MAX_ZOOM); + // Center the rect: offset = (viewport - rect_size * zoom) / 2 + // - rect_origin * zoom. + self.zoom = zoom; + self.offset = point( + Pixels((vw - rw * zoom) * 0.5 - rect.origin.x.0 * zoom), + Pixels((vh - rh * zoom) * 0.5 - rect.origin.y.0 * zoom), + ); + } + /// Maps a graph-space (document) point to screen space: /// `screen = graph * zoom + offset`. pub fn graph_to_screen(&self, graph: Point) -> Point { @@ -243,3 +270,63 @@ impl SelectionRect { (min, max) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{px, size}; + + /// Fitting a graph rect into a viewport centers it and picks a zoom that + /// fits the larger axis; the mapping must stay consistent afterwards. + #[test] + fn fit_centers_and_fits_the_rect() { + let mut state = GraphViewState::new(); + let rect = Bounds::new(point(px(40.0), px(60.0)), size(px(1040.0), px(230.0))); + state.fit_to_rect(rect, size(px(640.0), px(500.0))); + + // The rect's center must map to the viewport's center. + let graph_center = rect.center(); + let screen_center = state.graph_to_screen(graph_center); + assert!((screen_center.x.0 - 320.0).abs() < 0.5, "x center: {}", screen_center.x.0); + assert!((screen_center.y.0 - 250.0).abs() < 0.5, "y center: {}", screen_center.y.0); + + // The fitted rect must fit within the viewport (with the 40px padding). + let top_left = state.graph_to_screen(rect.origin); + let bottom_right = state.graph_to_screen(rect.bottom_right()); + assert!(top_left.x.0 >= 0.0 && bottom_right.x.0 <= 640.0); + assert!(top_left.y.0 >= 0.0 && bottom_right.y.0 <= 500.0); + } + + /// The width and height both shrink when the rect is tall and wide + /// (whichever axis is more constraining drives the zoom). + #[test] + fn fit_respects_both_axes() { + let mut state = GraphViewState::new(); + // A wide rect in a narrow viewport: width drives the zoom. + let rect = Bounds::new(point(px(0.0), px(0.0)), size(px(2000.0), px(100.0))); + state.fit_to_rect(rect, size(px(400.0), px(400.0))); + let fitted = state.graph_to_screen(rect.bottom_right()); + assert!(fitted.x.0 <= 400.0 && fitted.y.0 <= 400.0); + assert!(state.zoom() < 1.0); + } + + /// A rect smaller than the viewport zooms in (clamped to [`MAX_ZOOM`]). + #[test] + fn fit_zooms_in_for_small_graphs() { + let mut state = GraphViewState::new(); + let rect = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(60.0))); + state.fit_to_rect(rect, size(px(1000.0), px(800.0))); + assert_eq!(state.zoom(), MAX_ZOOM); + } + + /// Non-positive viewport or rect sizes are ignored. + #[test] + fn fit_ignores_non_positive_sizes() { + let mut state = GraphViewState::new(); + let before = state.clone(); + let rect = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(60.0))); + state.fit_to_rect(rect, size(px(0.0), px(800.0))); + assert_eq!(state.zoom(), before.zoom()); + assert_eq!(state.offset(), before.offset()); + } +} diff --git a/crates/gpui_widgets/src/i18n.rs b/crates/gpui_widgets/src/i18n.rs new file mode 100644 index 0000000000..8d155145f5 --- /dev/null +++ b/crates/gpui_widgets/src/i18n.rs @@ -0,0 +1,31 @@ +//! Localization hook for widget-baked strings. +//! +//! Re-exports the [`gpui::i18n`] string-table override API so hosts can +//! localize the widgets' built-in strings as +//! `gpui_widgets::i18n::set_table(..)`. See [`gpui::i18n`] for the full +//! contract: [`tr`] returns the installed override for a key, or the +//! widget's built-in default when none is set. +//! +//! [`tr`]: gpui::i18n::tr + +pub use gpui::i18n::*; + +#[cfg(test)] +mod tests { + use super::*; + + /// The hook is a thin re-export: installing a table through the widget + /// path is visible to `gpui::i18n::tr` and vice versa. + #[test] + fn widget_path_shares_the_gpui_table() { + clear_table(); + assert_eq!(tr("viewer.safe_frames", "安全框"), "安全框"); + + let mut table = StringTable::new(); + table.insert("viewer.safe_frames".into(), "Safe Frames".into()); + set_table(table); + + assert_eq!(gpui::i18n::tr("viewer.safe_frames", "安全框"), "Safe Frames"); + clear_table(); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index e8b0758539..d4f4edd0f6 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -23,6 +23,7 @@ pub mod color; pub mod combo_box; pub mod curve_editor; pub mod dialog; +pub mod i18n; pub mod keyable; pub mod menu; pub mod project_explorer; diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index 366d482f2d..c69c6ea907 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -221,7 +221,7 @@ impl Render for ViewerWidget { .items_center() .justify_center() .text_color(colors.disabled) - .child("No frame source"), + .child(crate::i18n::tr("viewer.no_frame_source", "No frame source")), ); } @@ -326,7 +326,7 @@ impl Render for ViewerWidget { ) .child(button( "gpui-widgets-viewer-safe", - "安全框", + crate::i18n::tr("viewer.safe_frames", "安全框"), cx.listener(|this, _event: &ClickEvent, _window, cx| { this.show_safe_frames = !this.show_safe_frames; this.emit( @@ -337,7 +337,7 @@ impl Render for ViewerWidget { )) .child(button( "gpui-widgets-viewer-zoom", - "缩放", + crate::i18n::tr("viewer.zoom", "缩放"), cx.listener(|this, _event: &ClickEvent, _window, cx| { this.zoom = !this.zoom; this.emit(ViewerEvent::ToggleZoomRequested { control: this.control }, cx); @@ -351,7 +351,7 @@ impl Render for ViewerWidget { /// A small labeled button. fn button( id: &'static str, - label: &'static str, + label: impl IntoElement, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { div()