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.
This commit is contained in:
@@ -406,7 +406,7 @@ impl<D: EffectStackDataSource> Render for EffectStackView<D> {
|
||||
.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<D: EffectStackDataSource> Render for EffectStackView<D> {
|
||||
.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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, String>` — 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<String, String>;
|
||||
|
||||
/// The installed table, or `None` (built-in defaults) when unset.
|
||||
fn table() -> &'static RwLock<Option<StringTable>> {
|
||||
static TABLE: OnceLock<RwLock<Option<StringTable>>> = 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>) -> 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", "安全框"), "安全框");
|
||||
}
|
||||
}
|
||||
@@ -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<D: NodeGraphDataSource + 'static> NodeGraphView<D> {
|
||||
&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<Pixels> {
|
||||
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
|
||||
|
||||
@@ -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<Pixels>, viewport: Size<Pixels>) {
|
||||
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<Pixels>) -> Point<Pixels> {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -221,7 +221,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
.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<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
)
|
||||
.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<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
))
|
||||
.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<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
/// 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()
|
||||
|
||||
Reference in New Issue
Block a user