git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitdddb339e34f451211001f4ea12d84e53734edc6b
parent8691f0e41f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 11:56
feat(l10n): the toolkit's own words are translatable (accessibility RFC phase 5)

crate::l10n (tr, tr_args, catalog) over cce_core::l10n; the English is
locale/en-US/cce-ui.ftl, 27 messages: the standard context menu, the tree
list's rows, the plate dock's, the search placeholder, the copy and
ramp-delete buttons, the keybind recorder's prompt and empty value, and the
DocEditor's Properties table. every_message_the_toolkit_names_is_in_its_english
holds the ids looked up in the source to the file, both ways.

The standard menu knew a search box by its placeholder being "Search...";
TextBox::with_search says so now (the English placeholder still read for
apps that set it themselves).

Checked in a shadow: the demo under LANG=de_DE.UTF-8 with a German
cce-ui.ftl shows its text box's menu in German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                            | 10 ++++-
 Cargo.toml                           |  2 +-
 docs/rfc-accessibility-locale.md     | 23 ++++++++++
 locale/en-US/cce-ui.ftl              | 43 +++++++++++++++++++
 src/context.rs                       | 28 +++++++-----
 src/l10n.rs                          | 83 ++++++++++++++++++++++++++++++++++++
 src/lib.rs                           |  1 +
 src/widget/container/treelist.rs     | 15 ++++---
 src/widget/doc_editor/layout.rs      |  4 +-
 src/widget/input/button.rs           |  2 +-
 src/widget/input/keybind_recorder.rs |  4 +-
 src/widget/input/ramp.rs             |  2 +-
 src/widget/input/text_box.rs         | 12 ++++++
 src/widget/plate_dock.rs             |  8 ++--
 14 files changed, 206 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index fa430f2..590c988 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1585,8 +1585,12 @@ the ladder should be supplying, and the audit counts them.
 Accessibility reaches Linux screen readers only, and only for an app that opts in: the
 widget tree is published over AT-SPI through AccessKit (`backend::a11y_unix`, the `a11y`
 feature, `Application::publishes_accessibility` or `CCE_A11Y=1`; phase 2, proven on
-cce-data-editor). There is nothing yet on macOS or in the browser, no message catalogue,
-and right-to-left text is edited where it is drawn (carets, clicks and selections follow
+cce-data-editor). There is nothing yet on macOS or in the browser. The toolkit's own words
+are translatable (`crate::l10n`, phase 5): `tr("id")` looks a message up in
+`locale/en-US/cce-ui.ftl`'s English or a translation `<tag>/cce-ui.ftl` (`cce_core::l10n`
+for where), and a new string the toolkit shows goes there, never into the source;
+`every_message_the_toolkit_names_is_in_its_english` holds the two to each other. And
+right-to-left text is edited where it is drawn (carets, clicks and selections follow
 it, a right-to-left paragraph is set against the right, the `DocEditor` draws styled runs
 in bidi order, a multiline `TextBox` wraps by shaped width — phase 4). The RFC has
 the measured state and a phased plan. Until it lands, two rules keep the retrofit cheap:
@@ -1736,6 +1740,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
   menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
   (`Input::a11y_items`). `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
   `docs/rfc-accessibility-locale.md`, phase 2).
+- `l10n.rs` — the toolkit's catalogue (`tr`, `tr_args`, `catalog`) over `cce_core::l10n`;
+  its English is `locale/en-US/cce-ui.ftl`.
 - `ime.rs` — input-method composition shared between the editing widget and the shell:
   `Preedit`, the composition and its generation, the reported caret, the reset request
   (see "Input-method composition is one model for every shell").
diff --git a/Cargo.toml b/Cargo.toml
index 5168224..9415f21 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,7 @@ a11y = ["dep:accesskit_unix"]
 [dependencies]
 # The GUI-free half: config, input, motion, units, ipc and the spec parsers,
 # re-exported at their old paths (`cce_ui::config`, …). See lib.rs.
-cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "0fb7c440d040259547aae1d711423f533ed52210" }
+cce-core = { git = "https://github.com/lsgalante/cce-core.git", rev = "0fb7c440d040259547aae1d711423f533ed52210", features = ["l10n"] }
 # The accessibility tree's schema (rfc-accessibility-locale § 5): pure data, built for
 # every target; `crate::a11y` produces its TreeUpdate. The platform adapters are separate.
 accesskit = "0.25"
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index ab602dd..b694b6d 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -272,6 +272,29 @@ editors on most platforms do.
 
 ### Phase 5 — translatable strings
 
+**Done (2026-10-08).** `cce_core::l10n` (the `l10n` feature, off by default so the compositor
+does not carry it; fluent-bundle and its seven crates) is a catalogue per domain in Project
+Fluent's format: the domain's English built in (`Catalog::new(domain, include_str!(…))`),
+a translation found on first use for the user's locale chain (`ja-JP`, then `ja`) as
+`<tag>/<domain>.ftl` under `CCE_LOCALE_DIR`, `$XDG_DATA_HOME/cce/locale` and each
+`$XDG_DATA_DIRS/*/cce/locale`, and a message looked up most specific first, then English,
+then its own id (a gap shows). Placeables are not wrapped in isolation marks, which the
+renderer would draw. A page reads no files (`Catalog::add_translation`); a suite reads no
+directory.
+
+cce-ui's words are its `locale/en-US/cce-ui.ftl` (27 messages): the standard context menu
+(Cut, Copy, Paste, Select All, Clear, Copy Path, the ramp's Collapse controls, the config
+header's File / Key rows), the tree list's rows, the plate dock's, the search placeholder,
+the copy and ramp-delete buttons, the keybind recorder's prompt and empty value, and the
+`DocEditor`'s Properties table — through `crate::l10n::tr` / `tr_args`.
+`every_message_the_toolkit_names_is_in_its_english` scans the source for every id looked up
+and holds them to the file both ways. One identity-by-text went with it: the standard menu
+knew a search box by its placeholder being "Search..."; a search box says so now
+(`TextBox::with_search`), the English placeholder read only for the apps that set it
+themselves. Checked in a shadow: the demo under `LANG=de_DE.UTF-8` with a German
+`cce-ui.ftl` shows its text box's menu as Ausschneiden, Kopieren, Einfügen, Alles auswählen.
+No translation ships yet; an app's own words are its own domain, adopted at its own pace.
+
 - The toolkit's ~20 strings through a small catalogue keyed by message ID, in `cce-core`
   so services can use it too. Fluent is the candidate format.
 - Apps adopt it at their own pace. Only phase 1's rule — actions keyed by ID, never by
diff --git a/locale/en-US/cce-ui.ftl b/locale/en-US/cce-ui.ftl
new file mode 100644
index 0000000..4b02094
--- /dev/null
+++ b/locale/en-US/cce-ui.ftl
@@ -0,0 +1,43 @@
+# cce-ui's own words, in English: the messages every translation of the toolkit gives in
+# its own language, as locale/<tag>/cce-ui.ftl in a translation directory (see
+# cce_core::l10n). An id names the message, never its text; what a choice does is keyed
+# elsewhere, so a translation changes only what it says.
+
+## The standard context menu
+menu-cut = Cut
+menu-copy = Copy
+menu-paste = Paste
+menu-select-all = Select All
+menu-clear = Clear
+menu-copy-path = Copy Path
+menu-collapse-controls = Collapse controls
+# The menu's header rows for a widget bound to a config key.
+menu-config-file = File: { $file }
+menu-config-key = Key: { $key }
+
+## The tree list
+tree-expand = Expand
+tree-collapse = Collapse
+tree-expand-all = Expand All
+tree-collapse-all = Collapse All
+tree-copy-key = Copy Key
+tree-copy-value = Copy Value
+tree-delete = Delete
+
+## A pane's corner dock
+dock-expand = Expand
+dock-collapse = Collapse
+dock-detach = Detach
+dock-reattach = Reattach
+
+## Controls
+# A search box's placeholder (`TextBox::with_search`): the tree list's, and any app's.
+search-placeholder = Search...
+button-copy = Copy
+ramp-delete-key = Delete
+keybind-recording = [ Press Keys... ]
+keybind-none = None
+
+## The document editor's Properties table
+doc-properties = Properties
+doc-empty = Empty
diff --git a/src/context.rs b/src/context.rs
index 73f78c5..f7fe0d5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1188,13 +1188,15 @@ impl UiContext {
         }
 
         use crate::widget::ContextAction as CA;
-        let row = |label: &str, action: CA| (label.to_string(), Some(action));
+        use crate::l10n::{tr, tr_args};
+        // Each row's label in the user's language; what it does is its action, not its words.
+        let row = |label: String, action: CA| (label, Some(action));
         let mut rows: Vec<(String, Option<CA>)> = vec![(header, None)];
         let mut header_count = 1;
 
         if let Some((file, key)) = config_info {
-            rows.push((format!("File: {}", file), None));
-            rows.push((format!("Key: {}", key), None));
+            rows.push((tr_args("menu-config-file", &[("file", &file)]), None));
+            rows.push((tr_args("menu-config-key", &[("key", &key)]), None));
             header_count = 3;
         }
 
@@ -1206,21 +1208,23 @@ impl UiContext {
                     .is_some_and(|tb| tb.is_password)
             };
             if !is_password {
-                rows.extend([row("Cut", CA::Cut), row("Copy", CA::Copy)]);
+                rows.extend([row(tr("menu-cut"), CA::Cut), row(tr("menu-copy"), CA::Copy)]);
             }
-            rows.extend([row("Paste", CA::Paste), row("Select All", CA::SelectAll)]);
+            rows.extend([row(tr("menu-paste"), CA::Paste), row(tr("menu-select-all"), CA::SelectAll)]);
+            // A search box says so (`with_search`); an English "Search..." placeholder is the
+            // older sign, still read for the apps that set one themselves.
             let is_search = unsafe {
                 if let Some(tb) = (*target).as_any().downcast_ref::<crate::widget::input::TextBox>() {
-                    tb.placeholder.as_deref() == Some("Search...")
+                    tb.is_search || tb.placeholder.as_deref() == Some("Search...")
                 } else {
                     false
                 }
             };
             if is_search {
-                rows.push(row("Clear", CA::ClearText));
+                rows.push(row(tr("menu-clear"), CA::ClearText));
             }
         } else if name == "Breadcrumb" {
-            rows.push(row("Copy Path", CA::CopyPath));
+            rows.push(row(tr("menu-copy-path"), CA::CopyPath));
         } else if name == "Ramp" {
             let collapsed = unsafe {
                 (*target)
@@ -1229,10 +1233,12 @@ impl UiContext {
                     .map(|r| r.controls_collapsed)
                     .unwrap_or(false)
             };
-            rows.push(row(if collapsed { "✓ Collapse controls" } else { "Collapse controls" }, CA::ToggleRampControls));
-            rows.extend([row("Copy", CA::Copy), row("Paste", CA::Paste)]);
+            let label = tr("menu-collapse-controls");
+            let label = if collapsed { format!("{}{label}", crate::widget::context_menu::MARK_CHECK) } else { label };
+            rows.push((label, Some(CA::ToggleRampControls)));
+            rows.extend([row(tr("menu-copy"), CA::Copy), row(tr("menu-paste"), CA::Paste)]);
         } else {
-            rows.extend([row("Copy", CA::Copy), row("Paste", CA::Paste)]);
+            rows.extend([row(tr("menu-copy"), CA::Copy), row(tr("menu-paste"), CA::Paste)]);
         }
 
         let scroll_y = crate::widget::hover_animation::get_scroll_offset();
diff --git a/src/l10n.rs b/src/l10n.rs
new file mode 100644
index 0000000..36a5195
--- /dev/null
+++ b/src/l10n.rs
@@ -0,0 +1,83 @@
+//! The toolkit's own words, translatable (`docs/rfc-accessibility-locale.md`, phase 5).
+//!
+//! Every string cce-ui shows of its own — a menu row, a placeholder, a dock's action — is
+//! a message in `locale/en-US/cce-ui.ftl`, looked up by id through [`tr`] / [`tr_args`] in
+//! the user's language (`cce_core::l10n`: a translation is `<tag>/cce-ui.ftl` in a
+//! translation directory). An app's own words are its own domain: a
+//! `static CATALOG: Catalog = Catalog::new("<app>", include_str!(…))` of its own.
+//! `every_message_the_toolkit_names_is_in_its_english` holds the ids to the file.
+
+pub use cce_core::l10n::*;
+
+static CATALOG: Catalog = Catalog::new("cce-ui", include_str!("../locale/en-US/cce-ui.ftl"));
+
+/// The toolkit's catalogue (to add a translation to, as a page or a test does).
+pub fn catalog() -> &'static Catalog {
+    &CATALOG
+}
+
+/// The toolkit's message `id` in the user's language.
+pub fn tr(id: &str) -> String {
+    CATALOG.get(id)
+}
+
+/// The toolkit's message `id`, its `{ $name }` placeables filled from `args`.
+pub fn tr_args(id: &str, args: &[(&str, &str)]) -> String {
+    CATALOG.format(id, args)
+}
+
+#[cfg(test)]
+mod tests {
+    /// Every id the toolkit's source looks up is in its English, and every message there
+    /// is looked up somewhere: a missing one shows as its id, a stray one is a string no
+    /// translator should be asked for.
+    #[test]
+    fn every_message_the_toolkit_names_is_in_its_english() {
+        let english = include_str!("../locale/en-US/cce-ui.ftl");
+        let defined: std::collections::BTreeSet<&str> = english
+            .lines()
+            .filter(|l| !l.starts_with('#') && !l.starts_with(' ') && l.contains(" = "))
+            .map(|l| l.split(" = ").next().unwrap().trim())
+            .collect();
+        let mut used = std::collections::BTreeSet::new();
+        let src = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
+        let mut stack = vec![std::path::PathBuf::from(src)];
+        while let Some(dir) = stack.pop() {
+            for entry in std::fs::read_dir(&dir).unwrap().flatten() {
+                let path = entry.path();
+                if path.is_dir() {
+                    stack.push(path);
+                } else if path.extension().is_some_and(|e| e == "rs") && !path.ends_with("l10n.rs") {
+                    let text = std::fs::read_to_string(&path).unwrap();
+                    for call in ["tr(\"", "tr_args(\""] {
+                        for (i, _) in text.match_indices(call) {
+                            // A call of ours, not the end of another name (`attr("`, `str("`).
+                            if text[..i].chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_') {
+                                continue;
+                            }
+                            let rest = &text[i + call.len()..];
+                            if let Some(end) = rest.find('"') {
+                                used.insert(rest[..end].to_string());
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        let missing: Vec<&String> = used.iter().filter(|u| !defined.contains(u.as_str())).collect();
+        assert!(missing.is_empty(), "looked up but not in locale/en-US/cce-ui.ftl: {missing:?}");
+        let unused: Vec<&&str> = defined.iter().filter(|d| !used.contains(**d)).collect();
+        assert!(unused.is_empty(), "in locale/en-US/cce-ui.ftl but looked up nowhere: {unused:?}");
+    }
+
+    /// A translation changes what a message says and falls back to English for what it
+    /// lacks.
+    #[test]
+    fn a_translation_is_used_where_it_has_the_message() {
+        let c = super::Catalog::new("cce-ui", include_str!("../locale/en-US/cce-ui.ftl"));
+        c.add_translation("de", "menu-copy = Kopieren\nmenu-config-key = Schlüssel: { $key }\n");
+        assert_eq!(c.get("menu-copy"), "Kopieren");
+        assert_eq!(c.format("menu-config-key", &[("key", "x.y")]), "Schlüssel: x.y");
+        assert_eq!(c.get("menu-paste"), "Paste");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 3065e70..a211116 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -35,6 +35,7 @@ pub mod mcp;
 pub use cce_core::motion;
 /// The user's locale (`locale::locale()`), what every font system is built with.
 pub use cce_core::locale;
+pub mod l10n;
 pub mod text_input;
 #[cfg(not(target_arch = "wasm32"))]
 pub mod vk;
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 205097e..cdff52a 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1,4 +1,5 @@
 use crate::widget::*;
+use crate::l10n::tr;
 use crate::widget::container::scroll_box::ScrollBox;
 use crate::widget::display::TextLabel;
 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
@@ -201,7 +202,7 @@ impl TreeList {
         Adapted::new(TreeList {
             base: Widget::new(),
             scroll_box,
-            search_box: TextBox::new(String::new()).with_placeholder("Search...").with_update_on_type(true),
+            search_box: TextBox::new(String::new()).with_search().with_update_on_type(true),
             add_key_btn: Button::new(0.0, 0.0, 80.0, 26.0).with_label("+ Add Key"),
             add_key_popover_open: false,
             add_key_popover_box: TextBox::new(String::new()).with_placeholder("new.key.path").with_multiline(false),
@@ -543,9 +544,9 @@ impl TreeList {
                             use crate::widget::ContextAction as CA;
                             let rows = vec![
                                 (path.clone(), None),
-                                if collapsed { ("Expand".to_string(), Some(CA::ExpandNode)) } else { ("Collapse".to_string(), Some(CA::CollapseNode)) },
-                                ("Expand All".to_string(), Some(CA::ExpandAll)),
-                                ("Collapse All".to_string(), Some(CA::CollapseAll)),
+                                if collapsed { (tr("tree-expand"), Some(CA::ExpandNode)) } else { (tr("tree-collapse"), Some(CA::CollapseNode)) },
+                                (tr("tree-expand-all"), Some(CA::ExpandAll)),
+                                (tr("tree-collapse-all"), Some(CA::CollapseAll)),
                             ];
 
                             let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
@@ -566,9 +567,9 @@ impl TreeList {
                             use crate::widget::ContextAction as CA;
                             let rows = vec![
                                 (path.clone(), None),
-                                ("Copy Key".to_string(), Some(CA::CopyKey)),
-                                ("Copy Value".to_string(), Some(CA::CopyValue)),
-                                ("Delete".to_string(), Some(CA::DeleteKey)),
+                                (tr("tree-copy-key"), Some(CA::CopyKey)),
+                                (tr("tree-copy-value"), Some(CA::CopyValue)),
+                                (tr("tree-delete"), Some(CA::DeleteKey)),
                             ];
                             let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
                             // SAFETY: as above — our own adapter, live while its event is routed.
diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index 16ec98b..7881226 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -441,7 +441,7 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
         };
         let label = |text: &str, x: f32| label_in(text, x, th.dim);
         match show {
-            PropShow::Header => decos.push(label("Properties", 0.0)),
+            PropShow::Header => decos.push(label(&crate::l10n::tr("doc-properties"), 0.0)),
             PropShow::Close => {
                 height = (row_h * 0.75).round();
                 decos.push(Deco::Quad(Rect { x: 0.0, y: (height / 2.0).round(), width, height: 1.0 }, th.rule));
@@ -460,7 +460,7 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
                 } else if *empty {
                     // Fainter than any value: text colour has no alpha.
                     let d = th.dim;
-                    decos.push(label_in("Empty", content_x, [d[0] * 0.45, d[1] * 0.45, d[2] * 0.5, d[3]]));
+                    decos.push(label_in(&crate::l10n::tr("doc-empty"), content_x, [d[0] * 0.45, d[1] * 0.45, d[2] * 0.5, d[3]]));
                 }
             }
         }
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index ac1032b..dcdbdf1 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -153,7 +153,7 @@ impl Button {
     }
 
     pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
-        Button::new_icon("copy", "Copy", x, y, w, h)
+        Button::new_icon("copy", &crate::l10n::tr("button-copy"), x, y, w, h)
     }
 
     /// A plateless icon button faced with the bundled cce-icons glyph
diff --git a/src/widget/input/keybind_recorder.rs b/src/widget/input/keybind_recorder.rs
index 63127b8..46b6c8b 100644
--- a/src/widget/input/keybind_recorder.rs
+++ b/src/widget/input/keybind_recorder.rs
@@ -109,9 +109,9 @@ impl Paint for KeybindRecorder {
 impl KeybindRecorder {
     fn paint_text(&self, rect: Rect, ctx: &mut PaintCtx) {
         let (display_text, color) = if self.recording {
-            ("[ Press Keys... ]".to_string(), [135, 135, 153])
+            (crate::l10n::tr("keybind-recording"), [135, 135, 153])
         } else if self.value.is_empty() {
-            ("None".to_string(), [127, 127, 127])
+            (crate::l10n::tr("keybind-none"), [127, 127, 127])
         } else {
             (self.value.clone(), [221, 221, 226])
         };
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 5da4064..36db72e 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -154,7 +154,7 @@ impl Ramp {
         // does not survive the renderer rebuild a reconnect performs, and the
         // widget outlives the renderer (see `Button::icon_name`).
         let del_button =
-            Button::new(0.0, 0.0, 22.0, 22.0).with_icon_name("x", "Delete");
+            Button::new(0.0, 0.0, 22.0, 22.0).with_icon_name("x", &crate::l10n::tr("ramp-delete-key"));
         // Short names on purpose: the strip's columns are narrow, and these
         // render inside param rows too ("Bevel (Raised)" used to clip).
         // Labeled: the dropdowns draw their own detached labels, sitting on
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index a61f1e9..66b3c34 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -104,6 +104,9 @@ pub struct TextBox {
     /// face of `font_family`.
     pub font_attrs: crate::scene::paint::TextAttrs,
     pub placeholder: Option<String>,
+    /// A search box (`with_search`): its menu offers Clear. Known by this flag, never by
+    /// its placeholder's text, which is translated.
+    pub is_search: bool,
     pub editor_state: TextEditorState,
     /// Edit history for the current editing session (cleared by
     /// `begin_editing`): a typed run is one step, a deleted run one, a
@@ -214,6 +217,7 @@ impl TextBox {
             font_family: style_family.clone(),
             font_attrs: crate::scene::paint::TextAttrs::default(),
             placeholder: None,
+            is_search: false,
             editor_state,
             history: History::new(),
             scroll_y: 0.0,
@@ -1679,6 +1683,14 @@ impl Adapted<TextBox> {
         self
     }
 
+    /// A search box: the toolkit's "Search..." placeholder, in the user's language, and a
+    /// menu with Clear.
+    pub fn with_search(mut self) -> Self {
+        self.is_search = true;
+        self.placeholder = Some(crate::l10n::tr("search-placeholder"));
+        self
+    }
+
     pub fn with_placeholder(mut self, placeholder: &str) -> Self {
         self.placeholder = Some(placeholder.to_string());
         self
diff --git a/src/widget/plate_dock.rs b/src/widget/plate_dock.rs
index a6ac524..ac6b031 100644
--- a/src/widget/plate_dock.rs
+++ b/src/widget/plate_dock.rs
@@ -110,16 +110,16 @@ pub fn draw_corner_dot(
 /// appends its own rows after these and owns dispatch.
 pub fn standard_menu(state: PlateDockState, can_detach: bool) -> Vec<(String, PlateDockAction)> {
     if state.detached {
-        return vec![("Reattach".to_string(), PlateDockAction::Reattach)];
+        return vec![(crate::l10n::tr("dock-reattach"), PlateDockAction::Reattach)];
     }
     let mut rows = Vec::new();
     if state.collapsed {
-        rows.push(("Expand".to_string(), PlateDockAction::Expand));
+        rows.push((crate::l10n::tr("dock-expand"), PlateDockAction::Expand));
     } else {
-        rows.push(("Collapse".to_string(), PlateDockAction::Collapse));
+        rows.push((crate::l10n::tr("dock-collapse"), PlateDockAction::Collapse));
     }
     if can_detach {
-        rows.push(("Detach".to_string(), PlateDockAction::Detach));
+        rows.push((crate::l10n::tr("dock-detach"), PlateDockAction::Detach));
     }
     rows
 }