Repository: zng-ui/zng (language: rust)

Issue:
Integrade l10n with commands
Command metadata is very easy to set to a value directly, unfortunately there is no integration with `l10n!` as it returns a var.
Even if we patch this the `l10n` key will become very verbose.

Maybe the l10n parser can be extended to generate a key from the command name and metadata name automatically?
If so the Command type or command metadata extensions need to integrate with l10n, but the `zng-ext-l10n` crate depends on the command crate (`zng-app`).


Discussion / hints:
Also localize shortcuts? Some apps do.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1d4aa70c..a7f05ee5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Unreleased
 
+* Fix localization scrapper only adding section comments to main file.
+* Add localization helper for commands.
+    - Set `l10n!: true` in `command!` declarations to localize metadata.
+    - Use `cargo zng l10n` to scrap metadata.
+* **Breaking** Use HashSet for `EVENTS.commands`.
+* Impl `std::hash::Hash` for `AppLocal<T>`, `AnyEvent` and `Command`.
 * Add `zng::button::PrimaryStyle`.
 * **Breaking** View API now groups color scheme with a new accent color config.
 * **Breaking** Refactored "color pair".
diff --git a/crates/cargo-zng/Cargo.toml b/crates/cargo-zng/Cargo.toml
index 0788637ce..0aa07986d 100644
--- a/crates/cargo-zng/Cargo.toml
+++ b/crates/cargo-zng/Cargo.toml
@@ -19,8 +19,9 @@ color-print = "0.3"
 # l10n
 rayon = "1.10"
 glob = "0.3"
-rustc_lexer = "0.1"
 fluent-pseudo = "0.3"
+proc-macro2 = { version = "1.0", features = ["span-locations"] }
+litrs = "0.4"
 
 # new
 convert_case = "0.6"
diff --git a/crates/cargo-zng/src/l10n/scraper.rs b/crates/cargo-zng/src/l10n/scraper.rs
index 1c643349b..88a619227 100644
--- a/crates/cargo-zng/src/l10n/scraper.rs
+++ b/crates/cargo-zng/src/l10n/scraper.rs
@@ -1,7 +1,9 @@
 //! Localization text scraping.
 
-use std::{borrow::Cow, fmt::Debug, io, mem, path::PathBuf, sync::Arc};
+use std::{borrow::Cow, fs, io, mem, path::PathBuf, sync::Arc};
 
+use litrs::StringLit;
+use proc_macro2::{Delimiter, Ident, Span, TokenStream, TokenTree};
 use rayon::prelude::*;
 
 /// Scrapes all use of the `l10n!` macro in Rust files selected by a glob pattern.
@@ -50,286 +52,394 @@ fn scrape_files(buf: &mut Vec<PathBuf>, custom_macro_names: &[&str]) -> FluentTe
         },
     )
 }
-fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> FluentTemplate {
-    let file = std::fs::read_to_string(&file).unwrap_or_else(|e| fatal!("cannot read `{}`, {e}", file.display()));
-    let mut s = file.as_str();
+fn scrape_file(rs_file: PathBuf, custom_macro_names: &[&str]) -> FluentTemplate {
+    let mut r = FluentTemplate::default();
 
-    const BOM: &str = "\u{feff}";
-    if s.starts_with(BOM) {
-        s = &s[BOM.len()..];
-    }
-    if let Some(i) = rustc_lexer::strip_shebang(s) {
-        s = &s[i..];
+    let file = fs::read_to_string(&rs_file).unwrap_or_else(|e| fatal!("cannot open `{}`, {e}", rs_file.display()));
+
+    if !["l10n!", "command!", "l10n-"]
+        .iter()
+        .chain(custom_macro_names)
+        .any(|h| file.contains(h))
+    {
+        return FluentTemplate::default();
     }
 
-    let mut l10n_notes = vec![];
-    let mut l10n_section = Arc::new(String::new());
-
-    let mut output: Vec<FluentEntry> = vec![];
-    let mut entry = FluentEntry {
-        section: l10n_section.clone(),
-        comments: String::new(),
-        file: String::new(),
-        id: String::new(),
-        attribute: String::new(),
-        message: String::new(),
+    // skip UTF-8 BOM
+    let file = file.strip_prefix('\u{feff}').unwrap_or(file.as_str());
+    // skip shebang line
+    let file = if file.starts_with("#!") {
+        &file[file.find('\n').unwrap_or(file.len())..]
+    } else {
+        file
     };
-    let mut last_comment_line = 0;
-    let mut last_entry_line = 0;
-    let mut line = 0;
-
-    #[derive(Clone, Copy)]
-    enum Expect {
-        CommentOrMacroName,
-        Bang,
-        OpenGroup,
-        StrLiteralId,
-        Comma,
-        StrLiteralMessage,
+
+    let mut sections = vec![(0, Arc::new(String::new()))];
+    let mut comments = vec![];
+
+    // parse comments
+    let mut str_lit = false;
+    for (ln, mut line) in file.lines().enumerate() {
+        if str_lit {
+            // seek end of multiline string literal.
+            while let Some(i) = line.find('"') {
+                let str_end = i == 0 || !line[..i].ends_with('\\');
+                line = &line[i + 1..];
+                if str_end {
+                    break;
+                }
+            }
+        }
+        let line = line.trim();
+        if let Some(line) = line.strip_prefix("//") {
+            let line = line.trim_start();
+            if let Some(c) = line.strip_prefix("l10n-") {
+                // l10n comment (// l10n-### note | // l10n-file-### note | // l10n-## section | // l10n-# comment)
+                if let Some(i) = c.find("###") {
+                    let file_name = c[..i].trim_end_matches('-');
+                    let c = &c[i + "###".len()..];
+
+                    r.notes.push(FluentNote {
+                        file: file_name.to_owned(),
+                        note: c.trim().to_owned(),
+                    });
+                } else if let Some(c) = c.strip_prefix("##") {
+                    sections.push((ln + 1, Arc::new(c.trim().to_owned())));
+                } else if let Some(c) = c.strip_prefix('#') {
+                    comments.push((ln + 1, c.trim()));
+                }
+            }
+        } else {
+            let mut line = line;
+            while !line.is_empty() {
+                if let Some((code, comment)) = line.split_once("//") {
+                    let mut escape = false;
+                    for c in code.chars() {
+                        if mem::take(&mut escape) {
+                            continue;
+                        }
+                        match c {
+                            '\\' => escape = true,
+                            '"' => str_lit = !str_lit,
+                            _ => {}
+                        }
+                    }
+                    if str_lit {
+                        line = comment;
+                    } else {
+                        if let Some(c) = comment.trim_start().strip_prefix("l10n-#") {
+                            if !c.starts_with('#') {
+                                comments.push((ln + 1, c.trim()));
+                            }
+                        }
+
+                        // comment end
+                        break;
+                    }
+                } else {
+                    // no potential comment in line
+                    break;
+                }
+            }
+        }
     }
-    let mut expect = Expect::CommentOrMacroName;
-
-    for token in rustc_lexer::tokenize(s) {
-        line += s[..token.len].chars().filter(|&a| a == '\n').count();
-
-        match expect {
-            Expect::CommentOrMacroName => match token.kind {
-                rustc_lexer::TokenKind::LineComment => {
-                    let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-
-                    if let Some(c) = c.strip_prefix("l10n-") {
-                        if let Some(i) = c.find("###") {
-                            let file_name = c[..i].trim_end_matches('-');
-                            let c = &c[i + "###".len()..];
-
-                            l10n_notes.push(FluentNote {
-                                file: file_name.to_owned(),
-                                note: c.trim().to_owned(),
-                            });
-                        } else if let Some(c) = c.strip_prefix("##") {
-                            l10n_section = Arc::new(c.trim().to_owned())
-                        } else if let Some(c) = c.strip_prefix('#') {
-                            let c = c.trim_start();
-
-                            // comment still on the last already inserted entry lines
-                            if last_entry_line == line && !output.is_empty() {
-                                let last = output.len() - 1;
-                                if !output[last].comments.is_empty() {
-                                    output[last].comments.push('\n');
+
+    let file: TokenStream = file.parse().unwrap_or_else(|e| fatal!("cannot parse `{}`, {e}", rs_file.display()));
+
+    // TokenTree::Group that are not matched to l10n macros are pushed on this stack
+    let mut stream_stack = vec![file.into_iter()];
+    let next = |stack: &mut Vec<proc_macro2::token_stream::IntoIter>| {
+        while !stack.is_empty() {
+            let tt = stack.last_mut().unwrap().next();
+            if tt.is_some() {
+                return tt;
+            }
+            stack.pop();
+        }
+        None
+    };
+
+    let mut tail2 = Vec::with_capacity(2);
+    while let Some(tt) = next(&mut stream_stack) {
+        match tt {
+            TokenTree::Group(g) => {
+                if matches!(g.delimiter(), Delimiter::Brace | Delimiter::Parenthesis | Delimiter::Bracket)
+                    && tail2.len() == 2
+                    && matches!(&tail2[0], TokenTree::Punct(p) if p.as_char() == '!')
+                    && matches!(&tail2[1], TokenTree::Ident(i) if ["l10n", "command"].iter().chain(custom_macro_names).any(|n| i == n))
+                {
+                    // matches #macro_name ! #g
+
+                    let macro_ln = match &tail2[1] {
+                        TokenTree::Ident(i) => i.span().start().line,
+                        _ => unreachable!(),
+                    };
+
+                    tail2.clear();
+
+                    if let Ok(args) = L10nMacroArgs::try_from(g.stream()) {
+                        let (file, id, attribute) = match parse_validate_id(&args.id) {
+                            Ok(t) => t,
+                            Err(e) => {
+                                let lc = args.id_span.start();
+                                error!("{e}\n     {}:{}:{}", rs_file.display(), lc.line, lc.column);
+                                continue;
+                            }
+                        };
+
+                        // first section before macro
+                        debug_assert!(!sections.is_empty()); // always an empty header section
+                        let section = sections.iter().position(|(l, _)| *l > macro_ln).unwrap_or(sections.len());
+                        let section = sections[section - 1].1.clone();
+
+                        // all comments on the line before macro or on the macro lines
+                        let last_ln = g.span_close().end().line;
+                        let mut t = String::new();
+                        let mut sep = "";
+                        for (l, c) in &comments {
+                            if *l <= last_ln {
+                                if (macro_ln - 1..=last_ln).contains(l) {
+                                    t.push_str(sep);
+                                    t.push_str(c);
+                                    sep = "\n";
                                 }
-                                output[last].comments.push_str(c);
                             } else {
-                                if !entry.comments.is_empty() {
-                                    if (line - last_comment_line) > 1 {
-                                        entry.comments.clear();
-                                    } else {
-                                        entry.comments.push('\n');
+                                break;
+                            }
+                        }
+
+                        r.entries.push(FluentEntry {
+                            section,
+                            comments: t,
+                            file,
+                            id,
+                            attribute,
+                            message: args.msg,
+                        })
+                    } else {
+                        match CommandMacroArgs::try_from(g.stream()) {
+                            Ok(cmds) => {
+                                for cmd in cmds.entries {
+                                    let (file, id, _attribute) = match parse_validate_id(&cmd.id) {
+                                        Ok(t) => t,
+                                        Err(e) => {
+                                            let lc = cmd.file_span.start();
+                                            error!("{e}\n     {}:{}:{}", rs_file.display(), lc.line, lc.column);
+                                            continue;
+                                        }
+                                    };
+                                    debug_assert!(_attribute.is_empty());
+
+                                    // first section before macro
+                                    let section = sections.iter().position(|(l, _)| *l > macro_ln).unwrap_or(sections.len());
+                                    let section = sections[section - 1].1.clone();
+
+                                    for meta in cmd.metadata {
+                                        // all comments on the line before meta entry and on the value string lines.
+                                        let ln = meta.name.span().start().line;
+                                        let last_ln = meta.value_span.end().line;
+
+                                        let mut t = String::new();
+                                        let mut sep = "";
+                                        for (l, c) in &comments {
+                                            if *l <= last_ln {
+                                                if (ln - 1..=last_ln).contains(l) {
+                                                    t.push_str(sep);
+                                                    t.push_str(c);
+                                                    sep = "\n";
+                                                }
+                                            } else {
+                                                break;
+                                            }
+                                        }
+
+                                        r.entries.push(FluentEntry {
+                                            section: section.clone(),
+                                            comments: t,
+                                            file: file.clone(),
+                                            id: id.clone(),
+                                            attribute: meta.name.to_string(),
+                                            message: meta.value,
+                                        })
                                     }
                                 }
-                                entry.comments.push_str(c);
-                                last_comment_line = line;
+                            }
+                            Err(e) => {
+                                if let Some((e, span)) = e {
+                                    let lc = span.start();
+                                    error!("{e}\n     {}:{}:{}", rs_file.display(), lc.line, lc.column);
+                                }
+                                stream_stack.push(g.stream().into_iter());
                             }
                         }
                     }
-                }
-                rustc_lexer::TokenKind::Ident => {
-                    if (line - last_comment_line) > 1 {
-                        entry.comments.clear();
-                    }
-
-                    let ident = &s[..token.len];
-                    if ["l10n"].iter().chain(custom_macro_names).any(|&i| i == ident) {
-                        expect = Expect::Bang;
-                    }
-                }
-                rustc_lexer::TokenKind::Whitespace => {}
-                _ => {}
-            },
-            Expect::Bang => {
-                if "!" == &s[..token.len] {
-                    expect = Expect::OpenGroup;
                 } else {
-                    entry.comments.clear();
-                    expect = Expect::CommentOrMacroName;
+                    stream_stack.push(g.stream().into_iter());
                 }
             }
-            Expect::OpenGroup => match token.kind {
-                rustc_lexer::TokenKind::OpenParen | rustc_lexer::TokenKind::OpenBrace | rustc_lexer::TokenKind::OpenBracket => {
-                    expect = Expect::StrLiteralId;
+            tt => {
+                if tail2.len() == 2 {
+                    tail2.pop();
                 }
-                rustc_lexer::TokenKind::Whitespace => {}
-                _ => {
-                    entry.comments.clear();
-                    expect = Expect::CommentOrMacroName;
+                tail2.insert(0, tt);
+            }
+        }
+    }
+
+    r
+}
+struct L10nMacroArgs {
+    id: String,
+    id_span: Span,
+    msg: String,
+}
+impl TryFrom<TokenStream> for L10nMacroArgs {
+    type Error = String;
+
+    fn try_from(macro_group_stream: TokenStream) -> Result<Self, Self::Error> {
+        let three: Vec<_> = macro_group_stream.into_iter().take(3).collect();
+        match &three[..] {
+            [TokenTree::Literal(l0), TokenTree::Punct(p), TokenTree::Literal(l1)] if p.as_char() == ',' => {
+                match (StringLit::try_from(l0), StringLit::try_from(l1)) {
+                    (Ok(s0), Ok(s1)) => Ok(Self {
+                        id: s0.into_value().into_owned(),
+                        id_span: l0.span(),
+                        msg: s1.into_value().into_owned(),
+                    }),
+                    _ => Err(String::new()),
                 }
-            },
-            Expect::StrLiteralId => match token.kind {
-                rustc_lexer::TokenKind::Literal { kind, .. } => match kind {
-                    rustc_lexer::LiteralKind::Str { .. } | rustc_lexer::LiteralKind::RawStr { .. } => {
-                        let message_id = s[..token.len]
-                            .trim_start_matches('r')
-                            .trim_matches('#')
-                            .trim_matches('"')
-                            .to_owned();
-                        let (file, id, attr) = parse_validate_id(&message_id);
-                        entry.file = file;
-                        entry.id = id;
-                        entry.attribute = attr;
-
-                        expect = Expect::Comma;
-                    }
-                    _ => {
-                        entry.comments.clear();
-                        expect = Expect::CommentOrMacroName;
-                    }
-                },
-                rustc_lexer::TokenKind::LineComment => {
-                    // comment inside macro
-
-                    let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-") {
-                        if let Some(i) = c.find("###") {
-                            let file_name = c[..i].trim_end_matches('-');
-                            let c = &c[i + "###".len()..];
-
-                            l10n_notes.push(FluentNote {
-                                file: file_name.to_owned(),
-                                note: c.trim().to_owned(),
-                            });
-                        } else if let Some(c) = c.strip_prefix("##") {
-                            l10n_section = Arc::new(c.trim().to_owned())
-                        } else if let Some(c) = c.strip_prefix('#') {
-                            let c = c.trim_start();
-
-                            if !entry.comments.is_empty() {
-                                entry.comments.push('\n');
+            }
+            _ => Err(String::new()),
+        }
+    }
+}
+
+struct CommandMacroArgs {
+    entries: Vec<CommandMacroEntry>,
+}
+impl TryFrom<TokenStream> for CommandMacroArgs {
+    type Error = Option<(String, Span)>;
+
+    fn try_from(macro_group_stream: TokenStream) -> Result<Self, Self::Error> {
+        let mut entries = vec![];
+        // seek and parse static IDENT = { .. }
+        let mut tail4 = Vec::with_capacity(4);
+        for tt in macro_group_stream.into_iter() {
+            tail4.push(tt);
+            if tail4.len() > 4 {
+                tail4.remove(0);
+                match &tail4[..] {
+                    [TokenTree::Ident(i0), TokenTree::Ident(id), TokenTree::Punct(p0), TokenTree::Group(g)]
+                        if i0 == "static"
+                            && p0.as_char() == '='
+                            && matches!(g.delimiter(), Delimiter::Brace | Delimiter::Parenthesis | Delimiter::Bracket) =>
+                    {
+                        match CommandMacroEntry::try_from(g.stream()) {
+                            Ok(mut entry) => {
+                                entry.id.push('/');
+                                entry.id.push_str(&id.to_string());
+                                entries.push(entry);
                             }
-                            entry.comments.push_str(c);
-                            last_comment_line = line;
-                        }
-                    }
-                }
-                rustc_lexer::TokenKind::Whitespace => {}
-                _ => {
-                    entry.comments.clear();
-                    expect = Expect::CommentOrMacroName;
-                }
-            },
-            Expect::Comma => match token.kind {
-                rustc_lexer::TokenKind::Comma => {
-                    expect = Expect::StrLiteralMessage;
-                }
-                rustc_lexer::TokenKind::LineComment => {
-                    // comment inside macro
-
-                    let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-") {
-                        if let Some(i) = c.find("###") {
-                            let file_name = c[..i].trim_end_matches('-');
-                            let c = &c[i + "###".len()..];
-
-                            l10n_notes.push(FluentNote {
-                                file: file_name.to_owned(),
-                                note: c.trim().to_owned(),
-                            });
-                        } else if let Some(c) = c.strip_prefix("##") {
-                            l10n_section = Arc::new(c.trim().to_owned())
-                        } else if let Some(c) = c.strip_prefix('#') {
-                            let c = c.trim_start();
-
-                            if !entry.comments.is_empty() {
-                                entry.comments.push('\n');
+                            Err(e) => {
+                                if e.is_some() {
+                                    return Err(e);
+                                }
                             }
-                            entry.comments.push_str(c);
-                            last_comment_line = line;
                         }
                     }
+                    _ => {}
                 }
-                rustc_lexer::TokenKind::Whitespace => {}
-                _ => {
-                    entry.comments.clear();
-                    entry.file.clear();
-                    entry.id.clear();
-                    entry.attribute.clear();
-                    expect = Expect::CommentOrMacroName;
-                }
-            },
-            Expect::StrLiteralMessage => match token.kind {
-                rustc_lexer::TokenKind::Literal { kind, .. } => match kind {
-                    rustc_lexer::LiteralKind::Str { .. } | rustc_lexer::LiteralKind::RawStr { .. } => {
-                        s[..token.len]
-                            .trim_start_matches('r')
-                            .trim_matches('#')
-                            .trim_matches('"')
-                            .clone_into(&mut entry.message);
-
-                        output.push(mem::replace(
-                            &mut entry,
-                            FluentEntry {
-                                section: l10n_section.clone(),
-                                comments: String::new(),
-                                file: String::new(),
-                                id: String::new(),
-                                attribute: String::new(),
-                                message: String::new(),
-                            },
-                        ));
-                        last_entry_line = line;
-
-                        expect = Expect::CommentOrMacroName;
+            }
+        }
+        if entries.is_empty() {
+            Err(None)
+        } else {
+            Ok(Self { entries })
+        }
+    }
+}
+struct CommandMacroEntry {
+    id: String,
+    file_span: Span,
+    metadata: Vec<CommandMetaEntry>,
+}
+impl TryFrom<TokenStream> for CommandMacroEntry {
+    type Error = Option<(String, Span)>;
+
+    fn try_from(command_meta_group_stream: TokenStream) -> Result<Self, Self::Error> {
+        // static FOO_CMD = { #command_meta_group_stream };
+        let mut tts = command_meta_group_stream.into_iter();
+
+        let mut r = CommandMacroEntry {
+            id: String::new(),
+            file_span: Span::call_site(),
+            metadata: vec![],
+        };
+
+        // parse l10n!: #lit
+        let mut buf: Vec<_> = (&mut tts).take(5).collect();
+        match &buf[..] {
+            [TokenTree::Ident(i), TokenTree::Punct(p0), TokenTree::Punct(p1), value, TokenTree::Punct(p2)]
+                if i == "l10n" && p0.as_char() == '!' && p1.as_char() == ':' && p2.as_char() == ',' =>
+            {
+                match litrs::Literal::try_from(value) {
+                    Ok(litrs::Literal::String(str)) => {
+                        r.id = str.into_value().into_owned();
+                        r.file_span = value.span();
+                    }
+                    Ok(litrs::Literal::Bool(b)) => {
+                        if !b.value() {
+                            return Err(None);
+                        }
                     }
                     _ => {
-                        entry.comments.clear();
-                        entry.file.clear();
-                        entry.id.clear();
-                        entry.attribute.clear();
-                        expect = Expect::CommentOrMacroName;
+                        return Err(Some((
+                            "unexpected l10n: value, must be string or bool literal".to_owned(),
+                            value.span(),
+                        )))
                     }
-                },
-                rustc_lexer::TokenKind::LineComment => {
-                    // comment inside macro
-
-                    let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-") {
-                        if let Some(i) = c.find("###") {
-                            let file_name = c[..i].trim_end_matches('-');
-                            let c = &c[i + "###".len()..];
-
-                            l10n_notes.push(FluentNote {
-                                file: file_name.to_owned(),
-                                note: c.trim().to_owned(),
-                            });
-                        } else if let Some(c) = c.strip_prefix("##") {
-                            l10n_section = Arc::new(c.trim().to_owned())
-                        } else if let Some(c) = c.strip_prefix('#') {
-                            let c = c.trim_start();
-
-                            if !entry.comments.is_empty() {
-                                entry.comments.push('\n');
-                            }
-                            entry.comments.push_str(c);
-                            last_comment_line = line;
+                }
+            }
+            _ => return Err(None),
+        }
+
+        // seek and parse meta: "lit",
+        buf.clear();
+        for tt in tts {
+            if buf.is_empty() && matches!(&tt, TokenTree::Punct(p) if p.as_char() == ',') {
+                continue;
+            }
+
+            buf.push(tt);
+            if buf.len() == 3 {
+                match &buf[..] {
+                    [TokenTree::Ident(i), TokenTree::Punct(p), TokenTree::Literal(l)] if p.as_char() == ':' => {
+                        if let Ok(s) = StringLit::try_from(l) {
+                            r.metadata.push(CommandMetaEntry {
+                                name: i.clone(),
+                                value: s.into_value().into_owned(),
+                                value_span: l.span(),
+                            })
                         }
                     }
+                    _ => {}
                 }
-                rustc_lexer::TokenKind::Whitespace => {}
-                _ => {
-                    entry.comments.clear();
-                    entry.file.clear();
-                    entry.id.clear();
-                    entry.attribute.clear();
-                    expect = Expect::CommentOrMacroName;
-                }
-            },
+                buf.clear();
+            }
         }
-        s = &s[token.len..];
-    }
 
-    FluentTemplate {
-        notes: l10n_notes,
-        entries: output,
+        if r.metadata.is_empty() {
+            Err(None)
+        } else {
+            Ok(r)
+        }
     }
 }
+struct CommandMetaEntry {
+    name: Ident,
+    value: String,
+    value_span: Span,
+}
 
 /// Represents a standalone note, declared using `// l10n-{file}-### {note}` or `l10n-### {note}`.
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -601,7 +711,7 @@ impl FluentTemplate {
 }
 
 // Returns "file", "id", "attribute"
-fn parse_validate_id(s: &str) -> (String, String, String) {
+fn parse_validate_id(s: &str) -> Result<(String, String, String), String> {
     let mut id = s;
     let mut file = "";
     let mut attribute = "";
@@ -627,7 +737,7 @@ fn parse_validate_id(s: &str) -> (String, String, String) {
             first = false;
         }
         if !valid {
-            fatal!("invalid file {file:?}, must be a single file name")
+            return Err(format!("invalid file {file:?}, must be a single file name"));
         }
     }
 
@@ -652,11 +762,15 @@ fn parse_validate_id(s: &str) -> (String, String, String) {
         true
     }
     if !validate(id) {
-        fatal!("invalid id {id:?}, must start with letter, followed by any letters, digits, `_` or `-`")
+        return Err(format!(
+            "invalid id {id:?}, must start with letter, followed by any letters, digits, `_` or `-`"
+        ));
     }
     if !attribute.is_empty() && !validate(attribute) {
-        fatal!("invalid id {attribute:?}, must start with letter, followed by any letters, digits, `_` or `-`")
+        return Err(format!(
+            "invalid id {attribute:?}, must start with letter, followed by any letters, digits, `_` or `-`"
+        ));
     }
 
-    (file.to_owned(), id.to_owned(), attribute.to_owned())
+    Ok((file.to_owned(), id.to_owned(), attribute.to_owned()))
 }
diff --git a/crates/zng-app-context/Cargo.toml b/crates/zng-app-context/Cargo.toml
index 2d1e084f2..1ceacbbbd 100644
--- a/crates/zng-app-context/Cargo.toml
+++ b/crates/zng-app-context/Cargo.toml
@@ -34,7 +34,7 @@ zng-unique-id = { path = "../zng-unique-id", version = "0.4.4" }
 zng-env = { path = "../zng-env", version = "0.3.0" }
 zng-txt = { path = "../zng-txt", version = "0.2.7" }
 parking_lot = "0.12"
-rustc-hash = "1.1"
+rustc-hash = "2.0"
 hashbrown = { version = "0.14", features = ["rayon"] }
 tracing = "0.1"
 serde = { version = "1.0", features = ["derive"] }
diff --git a/crates/zng-app-context/src/lib.rs b/crates/zng-app-context/src/lib.rs
index 2b9f10e49..5465fc503 100644
--- a/crates/zng-app-context/src/lib.rs
+++ b/crates/zng-app-context/src/lib.rs
@@ -791,6 +791,12 @@ impl<T: Send + Sync + 'static> PartialEq for AppLocal<T> {
     }
 }
 impl<T: Send + Sync + 'static> Eq for AppLocal<T> {}
+impl<T: Send + Sync + 'static> std::hash::Hash for AppLocal<T> {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        let a = AppLocalId((self.inner)() as *const dyn AppLocalImpl<T> as *const () as _);
+        std::hash::Hash::hash(&a, state)
+    }
+}
 
 /// Identifies an [`AppLocal<T>`] instance.
 ///
diff --git a/crates/zng-app/Cargo.toml b/crates/zng-app/Cargo.toml
index 9fbb98355..a2cb6c157 100644
--- a/crates/zng-app/Cargo.toml
+++ b/crates/zng-app/Cargo.toml
@@ -56,7 +56,12 @@ trace_widget = []
 trace_wgt_item = []
 
 # Allow app-process crash handler.
-crash_handler = ["dep:serde_json", "dep:breakpad-handler", "dep:minidump", "dep:linkme"]
+crash_handler = [
+    "dep:serde_json",
+    "dep:breakpad-handler",
+    "dep:minidump",
+    "dep:linkme",
+]
 
 # Enables IPC tasks and pre-build views and connecting to views running in another process.
 ipc = ["zng-view-api/ipc", "zng-task/ipc"]
@@ -104,6 +109,8 @@ serde_json = { version = "1.0", optional = true }
 breakpad-handler = { version = "0.2", optional = true }
 minidump = { version = "0.21", optional = true }
 linkme = { version = "=0.3.27", optional = true }
+hashbrown = { version = "*", features = ["rayon"] }
+rustc-hash = "2.0"
 
 dunce = "1.0"
 
diff --git a/crates/zng-app/src/event.rs b/crates/zng-app/src/event.rs
index 5059cbb3b..e0bed986b 100644
--- a/crates/zng-app/src/event.rs
+++ b/crates/zng-app/src/event.rs
@@ -474,6 +474,11 @@ impl PartialEq for AnyEvent {
     }
 }
 impl Eq for AnyEvent {}
+impl std::hash::Hash for AnyEvent {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        std::hash::Hash::hash(self.local, state)
+    }
+}
 impl<A: EventArgs> PartialEq<AnyEvent> for Event<A> {
     fn eq(&self, other: &AnyEvent) -> bool {
         self.local == other.local
diff --git a/crates/zng-app/src/event/command.rs b/crates/zng-app/src/event/command.rs
index 9c17caadb..99d5eb5a6 100644
--- a/crates/zng-app/src/event/command.rs
+++ b/crates/zng-app/src/event/command.rs
@@ -80,6 +80,51 @@ use super::*;
 ///
 /// For the first kind of metadata initialization a documentation section is also generated with a table of metadata.
 ///
+/// # Localization
+///
+/// If the first metadata is `l10n!:` the command init will attempt to localize the other string metadata. The `cargo zng l10n`
+/// command line tool scraps commands that set this special metadata.
+///
+/// ```
+/// # use zng_app::{event::{command, CommandNameExt, CommandInfoExt}, shortcut::{CommandShortcutExt, shortcut}};
+/// command! {
+///     pub static FOO_CMD = {
+///         l10n!: true,
+///         name: "Foo!",
+///         info: "Does the foo thing.",
+///     };
+/// }
+/// ```
+///
+/// The example above will be scrapped as:
+///
+/// ```ftl
+/// FOO_CMD =
+///     .name = Foo!
+///     .info = Does the foo thing.
+/// ```
+///
+/// The `l10n!:` meta can also be set to a localization file name:
+///
+/// ```
+/// # use zng_app::{event::{command, CommandNameExt, CommandInfoExt}, shortcut::{CommandShortcutExt, shortcut}};
+/// command! {
+///     pub static FOO_CMD = {
+///         l10n!: "file",
+///         name: "Foo!",
+///     };
+/// }
+/// ```
+///
+/// The example above is scrapped to `{l10n-dir}/{lang}/file.ftl` files.
+///
+/// ## Limitations
+///
+/// Interpolation is not supported in command localization strings.
+///
+/// The `l10n!:` value must be a *textual* literal, that is, it can be only a string literal or a `bool` literal, and it cannot be
+/// inside a macro expansion.
+///
 /// [`Command`]: crate::event::Command
 /// [`CommandArgs`]: crate::event::CommandArgs
 /// [`CommandNameExt`]: crate::event::CommandNameExt
@@ -91,7 +136,7 @@ use super::*;
 macro_rules! command {
     ($(
         $(#[$attr:meta])*
-        $vis:vis static $COMMAND:ident $(=> |$cmd:ident|$custom_meta_init:expr ;)? $(= { $($meta_ident:ident : $meta_init:expr),* $(,)? };)? $(;)?
+        $vis:vis static $COMMAND:ident $(=> |$cmd:ident|$custom_meta_init:expr ;)? $(= { $($meta_ident:ident $(!)? : $meta_init:expr),* $(,)? };)? $(;)?
     )+) => {
         $(
             $crate::__command! {
@@ -136,6 +181,37 @@ macro_rules! __command {
             $crate::event::Command::new(&EVENT, &DATA)
         };
     };
+    (
+        $(#[$attr:meta])*
+        $vis:vis static $COMMAND:ident = { l10n: $l10n_arg:expr, $($meta_ident:ident : $meta_init:expr),* $(,)? };
+    ) => {
+        $crate::event::paste! {
+            $crate::__command! {
+                $(#[$attr])*
+                ///
+                /// # Metadata
+                ///
+                /// This command has the following default metadata:
+                ///
+                /// <table>
+                /// <thead><tr><th>metadata</th><th>value</th></tr></thead>
+                /// <tbody>
+                $(#[doc = concat!("<tr> <td>", stringify!($meta_ident), "</td> <td>", stringify!($meta_init), "</td> </tr>")])+
+                ///
+                /// </tbody>
+                /// </table>
+                ///
+                /// Text metadata is localized.
+                $vis static $COMMAND => |cmd| {
+                    let __l10n_arg = $l10n_arg;
+                    $(
+                        cmd.[<init_ $meta_ident>]($meta_init);
+                        $crate::event::init_meta_l10n(&__l10n_arg, cmd, stringify!($meta_ident), &cmd.$meta_ident());
+                    )*
+                };
+            }
+        }
+    };
     (
         $(#[$attr:meta])*
         $vis:vis static $COMMAND:ident = { $($meta_ident:ident : $meta_init:expr),* $(,)? };
@@ -174,6 +250,26 @@ macro_rules! __command {
     };
 }
 
+#[doc(hidden)]
+pub fn init_meta_l10n(l10n_arg: &dyn Any, cmd: Command, meta_name: &'static str, meta_value: &dyn Any) {
+    if let Some(txt) = meta_value.downcast_ref::<CommandMetaVar<Txt>>() {
+        let mut l10n_file = "";
+
+        if let Some(&enabled) = l10n_arg.downcast_ref::<bool>() {
+            if !enabled {
+                return;
+            }
+        } else if let Some(&file) = l10n_arg.downcast_ref::<&'static str>() {
+            l10n_file = file;
+        } else {
+            tracing::error!("unknown l10n value in {}", cmd.event().as_any().name());
+            return;
+        }
+
+        EVENTS_L10N.init_meta_l10n(l10n_file, cmd, meta_name, txt.clone());
+    }
+}
+
 /// Identifies a command event.
 ///
 /// Use the [`command!`] to declare commands, it declares command static items with optional
@@ -194,7 +290,7 @@ macro_rules! __command {
 ///
 /// # Metadata
 ///
-/// Commands can have metadata associated with them, this metadata is extendable and can be used to enable
+/// Commands can have associated metadata, this metadata is extendable and can be used to enable
 /// command features such as command shortcuts. The metadata can be accessed using [`with_meta`], metadata
 /// extensions traits can use this metadata to store state. See [`CommandMeta`] for more details.
 ///
@@ -518,6 +614,12 @@ impl PartialEq for Command {
     }
 }
 impl Eq for Command {}
+impl std::hash::Hash for Command {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        std::hash::Hash::hash(&self.event.as_any(), state);
+        std::hash::Hash::hash(&self.scope, state);
+    }
+}
 
 struct CmdAppHandler<H> {
     handler: H,
diff --git a/crates/zng-app/src/event/events.rs b/crates/zng-app/src/event/events.rs
index 1277411f6..92049c98d 100644
--- a/crates/zng-app/src/event/events.rs
+++ b/crates/zng-app/src/event/events.rs
@@ -1,5 +1,7 @@
+use hashbrown::HashSet;
 use zng_app_context::app_local;
 use zng_time::INSTANT_APP;
+use zng_txt::Txt;
 
 use crate::update::{UpdatesTrace, UPDATES};
 
@@ -11,16 +13,21 @@ app_local! {
 
 pub(crate) struct EventsService {
     updates: Mutex<Vec<EventUpdate>>, // not locked, used to make service Sync.
-    commands: Vec<Command>,
+    commands: CommandSet,
     register_commands: Vec<Command>,
+    l10n: EventsL10n,
+}
+enum EventsL10n {
+    Pending(Vec<(&'static str, Command, &'static str, CommandMetaVar<Txt>)>),
+    Init(Box<dyn Fn(&'static str, Command, &'static str, CommandMetaVar<Txt>) + Send + Sync>),
 }
-
 impl EventsService {
     const fn new() -> Self {
         Self {
             updates: Mutex::new(vec![]),
-            commands: vec![],
+            commands: HashSet::with_hasher(BuildFxHasher),
             register_commands: vec![],
+            l10n: EventsL10n::Pending(vec![]),
         }
     }
 
@@ -46,16 +53,31 @@ impl EventsService {
     }
 }
 
+/// Const rustc-hash hasher.
+#[derive(Clone, Default)]
+pub struct BuildFxHasher;
+impl std::hash::BuildHasher for BuildFxHasher {
+    type Hasher = rustc_hash::FxHasher;
+
+    fn build_hasher(&self) -> Self::Hasher {
+        rustc_hash::FxHasher::default()
+    }
+}
+
+/// Registered commands set.
+pub type CommandSet = HashSet<Command, BuildFxHasher>;
+
 /// App events and commands service.
 pub struct EVENTS;
 impl EVENTS {
     /// Commands that had handles generated in this app.
     ///
     /// When [`Command::subscribe`] is called for the first time in an app, the command gets added
-    /// to this list after the current update.
+    /// to this list after the current update, if the command is app scoped it remains on the list for
+    /// the lifetime of the app, if it is window or widget scoped it only remains while there are handles.
     ///
     /// [`Command::subscribe`]: crate::event::Command::subscribe
-    pub fn commands(&self) -> Vec<Command> {
+    pub fn commands(&self) -> CommandSet {
         EVENTS_SV.read().commands.clone()
     }
 
@@ -76,12 +98,8 @@ impl EVENTS {
         {
             let ev = &mut *ev;
             for cmd in ev.register_commands.drain(..) {
-                if cmd.update_state() {
-                    if ev.commands.iter().any(|c| c == &cmd) {
-                        tracing::error!("command `{cmd:?}` is already registered")
-                    } else {
-                        ev.commands.push(cmd);
-                    }
+                if cmd.update_state() && !ev.commands.insert(cmd) {
+                    tracing::error!("command `{cmd:?}` is already registered")
                 }
             }
         }
@@ -100,3 +118,45 @@ impl EVENTS {
         updates
     }
 }
+
+/// EVENTS L10N integration.
+#[allow(non_camel_case_types)]
+pub struct EVENTS_L10N;
+impl EVENTS_L10N {
+    pub(crate) fn init_meta_l10n(&self, file: &'static str, cmd: Command, meta_name: &'static str, txt: CommandMetaVar<Txt>) {
+        {
+            let sv = EVENTS_SV.read();
+            if let EventsL10n::Init(f) = &sv.l10n {
+                f(file, cmd, meta_name, txt);
+                return;
+            }
+        }
+
+        let mut sv = EVENTS_SV.write();
+        match &mut sv.l10n {
+            EventsL10n::Pending(a) => a.push((file, cmd, meta_name, txt)),
+            EventsL10n::Init(f) => f(file, cmd, meta_name, txt),
+        }
+    }
+
+    /// Register a closure that is called to localize command metadata.
+    ///
+    /// The closure arguments are:
+    ///
+    /// * `file` is the command declaration `@l10n: "file"` value or is empty if `@l10n` was set to something else.
+    /// * `cmd` is the command, the command event name should be used as key.
+    /// * `meta` is the metadata name, for example `"name"`, should be used as attribute.
+    /// * `txt` is text variable that must be set with the translation.
+    pub fn init_l10n(&self, localize: impl Fn(&'static str, Command, &'static str, CommandMetaVar<Txt>) + Send + Sync + 'static) {
+        let mut sv = EVENTS_SV.write();
+        match &mut sv.l10n {
+            EventsL10n::Pending(a) => {
+                for (f, k, a, t) in a.drain(..) {
+                    localize(f, k, a, t);
+                }
+            }
+            EventsL10n::Init(_) => panic!("EVENTS_L10N already has a localizer"),
+        }
+        sv.l10n = EventsL10n::Init(Box::new(localize));
+    }
+}
diff --git a/crates/zng-app/src/running.rs b/crates/zng-app/src/running.rs
index 7df920912..ba71257fc 100644
--- a/crates/zng-app/src/running.rs
+++ b/crates/zng-app/src/running.rs
@@ -1153,6 +1153,7 @@ command! {
     ///
     /// [`exit`]: APP::exit
     pub static EXIT_CMD = {
+        l10n!: true,
         name: "Exit",
         info: "Close all windows and exit.",
         shortcut: shortcut!(Exit),
diff --git a/crates/zng-app/src/third_party.rs b/crates/zng-app/src/third_party.rs
index 60f409de7..d7073dbce 100644
--- a/crates/zng-app/src/third_party.rs
+++ b/crates/zng-app/src/third_party.rs
@@ -93,6 +93,7 @@ command! {
     ///
     /// [`on_pre_event`]: crate::event::Command::on_pre_event
     pub static OPEN_LICENSES_CMD = {
+        l10n!: true,
         name: "Third Party Licenses"
     };
 }
diff --git a/crates/zng-ext-clipboard/src/lib.rs b/crates/zng-ext-clipboard/src/lib.rs
index 0305be83d..de537b138 100644
--- a/crates/zng-ext-clipboard/src/lib.rs
+++ b/crates/zng-ext-clipboard/src/lib.rs
@@ -276,6 +276,7 @@ impl CLIPBOARD {
 command! {
     /// Represents the clipboard **cut** action.
     pub static CUT_CMD = {
+        l10n!: true,
         name: "Cut",
         info: "Remove the selection and place it in the clipboard.",
         shortcut: [shortcut!(CTRL+'X'), shortcut!(SHIFT+Delete), shortcut!(Cut)],
@@ -285,6 +286,7 @@ command! {
 
     /// Represents the clipboard **copy** action.
     pub static COPY_CMD = {
+        l10n!: true,
         name: "Copy",
         info: "Place a copy of the selection in the clipboard.",
         shortcut: [shortcut!(CTRL+'C'), shortcut!(CTRL+Insert), shortcut!(Copy)],
@@ -294,6 +296,7 @@ command! {
 
     /// Represents the clipboard **paste** action.
     pub static PASTE_CMD = {
+        l10n!: true,
         name: "Paste",
         info: "Insert content from the clipboard.",
         shortcut: [shortcut!(CTRL+'V'), shortcut!(SHIFT+Insert), shortcut!(Paste)],
diff --git a/crates/zng-ext-input/src/focus/cmd.rs b/crates/zng-ext-input/src/focus/cmd.rs
index 06dfd8ed1..fb5f03ae9 100644
--- a/crates/zng-ext-input/src/focus/cmd.rs
+++ b/crates/zng-ext-input/src/focus/cmd.rs
@@ -15,6 +15,7 @@ use super::*;
 command! {
     /// Represents the **focus next** action.
     pub static FOCUS_NEXT_CMD = {
+        l10n!: true,
         name: "Focus Next",
         info: "Focus next focusable.",
         shortcut: shortcut!(Tab),
@@ -22,6 +23,7 @@ command! {
 
     /// Represents the **focus previous** action.
     pub static FOCUS_PREV_CMD = {
+        l10n!: true,
         name: "Focus Previous",
         info: "Focus previous focusable.",
         shortcut: shortcut!(SHIFT+Tab),
@@ -29,6 +31,7 @@ command! {
 
     /// Represents the **focus/escape alt** action.
     pub static FOCUS_ALT_CMD = {
+        l10n!: true,
         name: "Focus Alt",
         info: "Focus alt scope.",
         shortcut: shortcut!(Alt),
@@ -36,6 +39,7 @@ command! {
 
     /// Represents the **focus enter** action.
     pub static FOCUS_ENTER_CMD = {
+        l10n!: true,
         name: "Focus Enter",
         info: "Focus child focusable.",
         shortcut: [shortcut!(Enter), shortcut!(ALT+Enter)],
@@ -43,6 +47,7 @@ command! {
 
     /// Represents the **focus exit** action.
     pub static FOCUS_EXIT_CMD = {
+        l10n!: true,
         name: "Focus Exit",
         info: "Focus parent focusable, or return focus.",
         shortcut: [shortcut!(Escape), shortcut!(ALT+Escape)],
@@ -50,6 +55,7 @@ command! {
 
     /// Represents the **focus up** action.
     pub static FOCUS_UP_CMD = {
+        l10n!: true,
         name: "Focus Up",
         info: "Focus closest focusable up.",
         shortcut: [shortcut!(ArrowUp), shortcut!(ALT+ArrowUp)],
@@ -57,6 +63,7 @@ command! {
 
     /// Represents the **focus down** action.
     pub static FOCUS_DOWN_CMD = {
+        l10n!: true,
         name: "Focus Down",
         info: "Focus closest focusable down.",
         shortcut: [shortcut!(ArrowDown), shortcut!(ALT+ArrowDown)],
@@ -64,6 +71,7 @@ command! {
 
     /// Represents the **focus left** action.
     pub static FOCUS_LEFT_CMD = {
+        l10n!: true,
         name: "Focus Left",
         info: "Focus closest focusable left.",
         shortcut: [shortcut!(ArrowLeft), shortcut!(ALT+ArrowLeft)],
@@ -71,6 +79,7 @@ command! {
 
     /// Represents the **focus right** action.
     pub static FOCUS_RIGHT_CMD = {
+        l10n!: true,
         name: "Focus Right",
         info: "Focus closest focusable right.",
         shortcut: [shortcut!(ArrowRight), shortcut!(ALT+ArrowRight)],
diff --git a/crates/zng-ext-l10n/src/lib.rs b/crates/zng-ext-l10n/src/lib.rs
index ada26301b..efb952814 100644
--- a/crates/zng-ext-l10n/src/lib.rs
+++ b/crates/zng-ext-l10n/src/lib.rs
@@ -12,6 +12,7 @@
 use std::{collections::HashMap, path::PathBuf, sync::Arc};
 
 use zng_app::{
+    event::{Command, CommandMetaVar, EVENTS_L10N},
     update::EventUpdate,
     view_process::{raw_events::RAW_LOCALE_CONFIG_CHANGED_EVENT, VIEW_PROCESS_INITED_EVENT},
     AppExtension,
@@ -53,6 +54,12 @@ pub struct L10N;
 #[derive(Default)]
 pub struct L10nManager {}
 impl AppExtension for L10nManager {
+    fn init(&mut self) {
+        EVENTS_L10N.init_l10n(|file, cmd, attr, txt| {
+            L10N.bind_command_meta(file, cmd, attr, txt);
+        });
+    }
+
     fn event_preview(&mut self, update: &mut EventUpdate) {
         if let Some(u) = RAW_LOCALE_CONFIG_CHANGED_EVENT
             .on(update)
@@ -117,9 +124,9 @@ impl AppExtension for L10nManager {
 ///
 /// The scrapper can also scrap comments, if the previous code line from a [`l10n!`] call is a comment starting with
 /// prefix `l10n-# ` the text the follows is collected, same for a comment in the same line of the [`l10n!`] call. Sections
-/// can be declared using `l10n-##` and standalone notes can be added to the top of the template file from anywhere using
-/// `l10n-{file_pattern}-###`, file pattern can be omitted, `l10n-###` is equivalent to `l10n--###` that matches the localization
-/// template used when no file is specified.
+/// can be declared using `l10n-## `, all entries after a section comment are added to that section. Standalone notes can be added to
+/// the top of the template file from anywhere using `l10n-{file_pattern}-### `, file pattern can be omitted, `l10n-### ` is equivalent
+/// to `l10n--### ` that matches the localization template used when no file is specified.
 ///
 /// ```
 /// # use zng_ext_l10n::*;
@@ -267,7 +274,7 @@ impl L10N {
         L10N_SV.read().sys_lang()
     }
 
-    /// Gets a variable that is a localized message in the localization context
+    /// Gets a read-only variable that is a localized message in the localization context
     /// where the variable is first used. The variable will update when the contextual language changes.
     ///
     /// If the message has variable arguments they must be provided using [`L10nMessageBuilder::arg`], the
@@ -401,6 +408,26 @@ impl L10N {
 
         (None, LangResources(vec![]))
     }
+
+    /// Bind the command metadata to a message.
+    ///
+    /// This is automatically called by [`command!`] instances that set the metadata `l10n!: true` or `l10n!: "file"`.
+    ///
+    /// [`command!`]: zng_app::event::command!
+    pub fn bind_command_meta(&self, file: impl Into<Txt>, cmd: Command, meta_name: impl Into<Txt>, meta_value: CommandMetaVar<Txt>) {
+        let msg = self.message(file, cmd.event().as_any().name(), meta_name, meta_value.get()).build();
+        meta_value.set_from(&msg).unwrap();
+
+        // bind only holds a weak ref to `meta_value`` in `msg`
+        msg.bind(&meta_value).perm();
+        meta_value
+            .hook(move |_| {
+                // keep `msg` alive to it continues updating `meta_value`
+                let _keep = &msg;
+                true
+            })
+            .perm();
+    }
 }
 
 /// <span data-del-macro-root></span> Compile-time validated [`Lang`] value.
diff --git a/crates/zng-ext-undo/src/lib.rs b/crates/zng-ext-undo/src/lib.rs
index 5a31f62d3..dc5a02391 100644
--- a/crates/zng-ext-undo/src/lib.rs
+++ b/crates/zng-ext-undo/src/lib.rs
@@ -780,6 +780,7 @@ command! {
     /// You can use [`CommandUndoExt::undo_scoped`] to get a command variable that is always scoped on the
     /// focused undo scope.
     pub static UNDO_CMD = {
+        l10n!: true,
         name: "Undo",
         shortcut: [shortcut!(CTRL+'Z')],
         icon: wgt_fn!(|_| ICONS.get("undo")),
@@ -795,6 +796,7 @@ command! {
     /// [`redo_select`]: UNDO::redo_select
     /// [`redo`]: UNDO::redo
     pub static REDO_CMD = {
+        l10n!: true,
         name: "Redo",
         shortcut: [shortcut!(CTRL+'Y')],
         icon: wgt_fn!(|_| ICONS.get("redo")),
@@ -806,6 +808,7 @@ command! {
     ///
     /// [`clear`]: UNDO::clear
     pub static CLEAR_HISTORY_CMD = {
+        l10n!: true,
         name: "Clear History",
     };
 }
diff --git a/crates/zng-ext-window/src/cmd.rs b/crates/zng-ext-window/src/cmd.rs
index 9cb95f977..41a3056a9 100644
--- a/crates/zng-ext-window/src/cmd.rs
+++ b/crates/zng-ext-window/src/cmd.rs
@@ -17,6 +17,7 @@ pub use zng_view_api::window::ResizeDirection;
 command! {
     /// Represents the window **close** action.
     pub static CLOSE_CMD = {
+        l10n!: true,
         name: "Close",
         info: "Close the window.",
         shortcut: [shortcut!(ALT+F4), shortcut!(CTRL+'W')],
@@ -25,6 +26,7 @@ command! {
 
     /// Represents the window **minimize** action.
     pub static MINIMIZE_CMD = {
+        l10n!: true,
         name: "Minimize",
         info: "Minimize the window.",
         icon: wgt_fn!(|_| ICONS.get("minimize")),
@@ -32,6 +34,7 @@ command! {
 
     /// Represents the window **maximize** action.
     pub static MAXIMIZE_CMD = {
+        l10n!: true,
         name: "Maximize",
         info: "Maximize the window.",
         icon: wgt_fn!(|_| ICONS.get("maximize")),
@@ -44,6 +47,7 @@ command! {
     /// This command is about the *windowed* fullscreen state ([`WindowState::Fullscreen`]),
     /// use the [`EXCLUSIVE_FULLSCREEN_CMD`] to toggle *exclusive* video mode fullscreen.
     pub static FULLSCREEN_CMD = {
+        l10n!: true,
         name: "Full-Screen",
         info: "Toggle full-screen mode on the window.",
         shortcut: {
@@ -64,6 +68,7 @@ command! {
     /// This command is about the *exclusive* fullscreen state ([`WindowState::Exclusive`]),
     /// use the [`FULLSCREEN_CMD`] to toggle *windowed* fullscreen.
     pub static EXCLUSIVE_FULLSCREEN_CMD = {
+        l10n!: true,
         name: "Exclusive Full-Screen",
         info: "Toggle exclusive full-screen mode on the window.",
         icon: wgt_fn!(|_| ICONS.get("fullscreen")),
@@ -73,6 +78,7 @@ command! {
     ///
     /// Restores the window to its previous non-minimized state or normal state.
     pub static RESTORE_CMD = {
+        l10n!: true,
         name: "Restore",
         info: "Restores the window to its previous non-minimized state or normal state.",
         icon: wgt_fn!(|_| ICONS.get("restore")),
diff --git a/crates/zng-view-api/Cargo.toml b/crates/zng-view-api/Cargo.toml
index 3c335b2b2..8d7d82891 100644
--- a/crates/zng-view-api/Cargo.toml
+++ b/crates/zng-view-api/Cargo.toml
@@ -41,7 +41,7 @@ serde_bytes = "0.11"
 tracing = "0.1"
 flume = { version = "0.11", default-features = false }
 num-traits = "0.2"
-rustc-hash = "1.1"
+rustc-hash = "2.0"
 
 bitflags = { version = "2.5", features = ["serde", "bytemuck"] }
 bytemuck = { version = "1.15", features = ["derive"] }
diff --git a/crates/zng-view/Cargo.toml b/crates/zng-view/Cargo.toml
index 81665dec8..4fbb1a161 100644
--- a/crates/zng-view/Cargo.toml
+++ b/crates/zng-view/Cargo.toml
@@ -85,11 +85,11 @@ image = { version = "0.25", default-features = false, features = [
 ] }
 img-parts = "0.3"
 byteorder = "1.5"
-rustc-hash = "1.1"
+rustc-hash = "2.0"
 rayon = "1.10"
 serde = "1.0"
-accesskit = "0.15"
-accesskit_winit = "0.21"
+accesskit = "0.16"
+accesskit_winit = "0.22"
 # rfd
 [target.'cfg(not(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd")))'.dependencies.rfd]
 version = "0.14"
diff --git a/crates/zng-wgt-input/src/cmd.rs b/crates/zng-wgt-input/src/cmd.rs
index a3771688c..454a9013a 100644
--- a/crates/zng-wgt-input/src/cmd.rs
+++ b/crates/zng-wgt-input/src/cmd.rs
@@ -10,6 +10,7 @@ command! {
     /// The command parameter can identify the new item type, otherwise the default (or single) type
     /// must be used.
     pub static NEW_CMD = {
+        l10n!: true,
         name: "New",
         shortcut: [shortcut!(CTRL+'N'), shortcut!(New)],
     };
@@ -19,6 +20,7 @@ command! {
     /// The command parameter can be an item path to open (like a `PathBuf`), otherwise the
     /// command implementer must identify the item, either by context or by prompting the user.
     pub static OPEN_CMD = {
+        l10n!: true,
         name: "Open…",
         shortcut: [shortcut!(CTRL+'O'), shortcut!(Open)],
         icon: wgt_fn!(|_| ICONS.get("file-open")),
@@ -29,6 +31,7 @@ command! {
     /// Usually this saves to the already defined item path (open or previous save path),
     /// otherwise the user is prompted like [`SAVE_AS_CMD`].
     pub static SAVE_CMD = {
+        l10n!: true,
         name: "Save",
         shortcut: [shortcut!(CTRL+'S'), shortcut!(Save)],
         icon: wgt_fn!(|_| ICONS.get("save")),
@@ -38,6 +41,7 @@ command! {
     ///
     /// Usually this prompts the user for a save path, even if a previous path is already known.
     pub static SAVE_AS_CMD = {
+        l10n!: true,
         name: "Save As…",
         shortcut: [shortcut!(CTRL|SHIFT+'S')],
     };
@@ -57,6 +61,7 @@ command! {
     /// The parameter can be a `Txt` that can match a `ConfigKey` or config metadata
     /// such as the display name or description.
     pub static SETTINGS_CMD = {
+        l10n!: true,
         name: "Settings",
         shortcut: [shortcut!(CTRL+',')],
         icon: wgt_fn!(|_| ICONS.get("settings")),
diff --git a/crates/zng-wgt-inspector/src/lib.rs b/crates/zng-wgt-inspector/src/lib.rs
index 79b59abf7..0e798364f 100644
--- a/crates/zng-wgt-inspector/src/lib.rs
+++ b/crates/zng-wgt-inspector/src/lib.rs
@@ -22,6 +22,7 @@ mod live;
 command! {
     /// Represent the window **inspect** action.
     pub static INSPECT_CMD = {
+        l10n!: true,
         name: "Debug Inspector",
         info: "Inspect the window.",
         shortcut: [shortcut!(CTRL|SHIFT+'I'), shortcut!(F12)],
diff --git a/crates/zng-wgt-scroll/src/cmd.rs b/crates/zng-wgt-scroll/src/cmd.rs
index 5f375c75b..55aba20b9 100644
--- a/crates/zng-wgt-scroll/src/cmd.rs
+++ b/crates/zng-wgt-scroll/src/cmd.rs
@@ -20,6 +20,7 @@ command! {
     ///
     /// [`v_line_unit`]: fn@crate::v_line_unit
     pub static SCROLL_UP_CMD = {
+        l10n!: true,
         name: "Scroll Up",
         info: "Scroll Up by one scroll unit.",
         shortcut: shortcut!(ArrowUp),
@@ -35,6 +36,7 @@ command! {
     ///
     /// [`v_line_unit`]: fn@crate::v_line_unit
     pub static SCROLL_DOWN_CMD = {
+        l10n!: true,
         name: "Scroll Down",
         info: "Scroll Down by one scroll unit.",
         shortcut: shortcut!(ArrowDown),
@@ -50,6 +52,7 @@ command! {
     ///
     /// [`h_line_unit`]: fn@crate::h_line_unit
     pub static SCROLL_LEFT_CMD = {
+        l10n!: true,
         name: "Scroll Left",
         info: "Scroll Left by one scroll unit.",
         shortcut: shortcut!(ArrowLeft),
@@ -65,6 +68,7 @@ command! {
     ///
     /// [`h_line_unit`]: fn@crate::h_line_unit
     pub static SCROLL_RIGHT_CMD = {
+        l10n!: true,
         name: "Scroll Right",
         info: "Scroll Right by one scroll unit.",
         shortcut: shortcut!(ArrowRight),
@@ -84,6 +88,7 @@ command! {
     /// [`shortcut`]: CommandShortcutExt
     /// [`v_page_unit`]: fn@crate::v_page_unit
     pub static PAGE_UP_CMD = {
+        l10n!: true,
         name: "Page Up",
         info: "Scroll Up by one page unit.",
         shortcut: shortcut!(PageUp),
@@ -99,6 +104,7 @@ command! {
     ///
     /// [`v_page_unit`]: fn@crate::v_page_unit
     pub static PAGE_DOWN_CMD = {
+        l10n!: true,
         name: "Page Down",
         info: "Scroll down by one page unit.",
         shortcut: shortcut!(PageDown),
@@ -114,6 +120,7 @@ command! {
     ///
     /// [`h_page_unit`]: fn@crate::h_page_unit
     pub static PAGE_LEFT_CMD = {
+        l10n!: true,
         name: "Page Left",
         info: "Scroll Left by one page unit.",
         shortcut: shortcut!(SHIFT+PageUp),
@@ -129,6 +136,7 @@ command! {
     ///
     /// [`h_page_unit`]: fn@crate::h_page_unit
     pub static PAGE_RIGHT_CMD = {
+        l10n!: true,
         name: "Page Right",
         info: "Scroll Right by one page unit.",
         shortcut: shortcut!(SHIFT+PageDown),
@@ -137,6 +145,7 @@ command! {
 
     /// Represents the **scroll to top** action.
     pub static SCROLL_TO_TOP_CMD = {
+        l10n!: true,
         name: "Scroll to Top",
         info: "Scroll up to the content top.",
         shortcut: [shortcut!(Home), shortcut!(CTRL+Home)],
@@ -146,6 +155,7 @@ command! {
 
     /// Represents the **scroll to bottom** action.
     pub static SCROLL_TO_BOTTOM_CMD = {
+        l10n!: true,
         name: "Scroll to Bottom",
         info: "Scroll down to the content bottom.",
         shortcut: [shortcut!(End), shortcut!(CTRL+End)],
@@ -155,6 +165,7 @@ command! {
 
     /// Represents the **scroll to leftmost** action.
     pub static SCROLL_TO_LEFTMOST_CMD = {
+        l10n!: true,
         name: "Scroll to Leftmost",
         info: "Scroll left to the content left edge.",
         shortcut: [shortcut!(SHIFT+Home), shortcut!(CTRL|SHIFT+Home)],
@@ -163,6 +174,7 @@ command! {
 
     /// Represents the **scroll to rightmost** action.
     pub static SCROLL_TO_RIGHTMOST_CMD = {
+        l10n!: true,
         name: "Scroll to Rightmost",
         info: "Scroll right to the content right edge.",
         shortcut: [shortcut!(SHIFT+End), shortcut!(CTRL|SHIFT+End)],
@@ -197,6 +209,7 @@ command! {
     ///
     /// [`Point`]: zng_wgt::prelude::Point
     pub static ZOOM_IN_CMD = {
+        l10n!: true,
         name: "Zoom In",
         shortcut: shortcut!(CTRL+'+'),
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
@@ -213,6 +226,7 @@ command! {
     ///
     /// [`Point`]: zng_wgt::prelude::Point
     pub static ZOOM_OUT_CMD = {
+        l10n!: true,
         name: "Zoom Out",
         shortcut: shortcut!(CTRL+'-'),
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
@@ -223,6 +237,7 @@ command! {
     ///
     /// The content is scaled to fit the viewport, the equivalent to `ImageFit::Contain`.
     pub static ZOOM_TO_FIT_CMD = {
+        l10n!: true,
         name: "Zoom to Fit",
         shortcut: shortcut!(CTRL+'0'),
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
@@ -232,6 +247,7 @@ command! {
     ///
     /// The content is scaled back to 100%, without adjusting the scroll.
     pub static ZOOM_RESET_CMD = {
+        l10n!: true,
         name: "Reset Zoom",
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
     };
diff --git a/crates/zng-wgt-text/src/cmd.rs b/crates/zng-wgt-text/src/cmd.rs
index 95b164684..9efeb65e0 100644
--- a/crates/zng-wgt-text/src/cmd.rs
+++ b/crates/zng-wgt-text/src/cmd.rs
@@ -29,6 +29,7 @@ command! {
     ///
     /// The request is the same as [`SELECT_CMD`] with [`TextSelectOp::select_all`].
     pub static SELECT_ALL_CMD = {
+        l10n!: true,
         name: "Select All",
         shortcut: shortcut!(CTRL+'A'),
         shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
diff --git a/crates/zng/src/event.rs b/crates/zng/src/event.rs
index f9ca54004..99190297c 100644
--- a/crates/zng/src/event.rs
+++ b/crates/zng/src/event.rs
@@ -219,6 +219,7 @@
 //! command! {
 //!     /// Foo docs.
 //!     pub static FOO_CMD = {
+//!         l10n!: true,
 //!         name: "Foo",
 //!         info: "foo bar",
 //!         shortcut: shortcut![CTRL+'F'],
@@ -259,6 +260,13 @@
 //! [command extensions]: Command#extensions
 //! [`CommandShortcutExt`]: crate::gesture::CommandShortcutExt
 //!
+//! ### Localization
+//!
+//! The special `l10n!:` metadata enables localization for the other text metadata of the command. It must be the first
+//! metadata assign and the value must be a literal `bool` or string `""`, the string defines the localization file.
+//!
+//! See the [`l10n`](crate::zng::l10n#commands) module docs om commands for more details.
+//!
 //! ## Scopes
 //!
 //! Commands can be scoped to a window or widget, a scoped command is a different instance of [`Command`], it
diff --git a/crates/zng/src/l10n.rs b/crates/zng/src/l10n.rs
index 19f6831b9..030abb3ee 100644
--- a/crates/zng/src/l10n.rs
+++ b/crates/zng/src/l10n.rs
@@ -60,7 +60,7 @@
 //!
 //! # Scraper
 //!
-//! The `cargo-zng` tool can be used to generate a Fluent file from source code, the Fluent file can be
+//! The `cargo zng l10n` tool can be used to generate a Fluent file from source code, the Fluent file can be
 //! used as a template for translators, it will include the fallback text and comments written close the key
 //! declaration.
 //!
@@ -97,18 +97,52 @@
 //!
 //! [`l10n!`]: crate::l10n::l10n
 //!
-//! # Localize Commands
+//! # Commands
 //!
-//! Currently [`command!`](zng::event::command) declarations are not integrated with the localization
-//! service yet, you will need to manually bind localization to command metadata to localize it.
+//! Commands metadata can be localized and scrapped, to enable this set `l10n!:` on the [`command!`](zng::event::command) declarations.
+//!
+//! If the first metadata is `l10n!:` the command init will attempt to localize the other string metadata. The `cargo zng l10n`
+//! command line tool scraps commands that set this special metadata.
 //!
 //! ```
-//! use zng::prelude::*;
-//! # let _scope = APP.defaults();
+//! # use zng_app::{event::{command, CommandNameExt, CommandInfoExt}, shortcut::{CommandShortcutExt, shortcut}};
+//! command! {
+//!     pub static FOO_CMD = {
+//!         l10n!: true,
+//!         name: "Foo!",
+//!         info: "Does the foo thing.",
+//!     };
+//! }
+//! ```
+//!
+//! The example above will be scrapped as:
 //!
-//! l10n!("COPY_CMD.name", "Copy").set_bind(&zng::clipboard::COPY_CMD.name()).perm();
+//! ```ftl
+//! FOO_CMD =
+//!     .name = Foo!
+//!     .info = Does the foo thing.
 //! ```
 //!
+//! The `l10n!:` meta can also be set to a localization file name:
+//!
+//! ```
+//! # use zng_app::{event::{command, CommandNameExt, CommandInfoExt}, shortcut::{CommandShortcutExt, shortcut}};
+//! command! {
+//!     pub static FOO_CMD = {
+//!         l10n!: "file",
+//!         name: "Foo!",
+//!     };
+//! }
+//! ```
+//!
+//! The example above is scrapped to `{l10n-dir}/{lang}/file.ftl` files.
+//!
+//! ## Limitations
+//!
+//! Interpolation is not supported in command localization strings.
+//!
+//! The `l10n!:` value must be a *textual* literal, that is, it can be only a string literal or a `bool` literal, and it cannot be
+//! inside a macro expansion.
 //! # Full API
 //!
 //! See [`zng_ext_l10n`] for the full localization API.
diff --git a/examples/localize/res/ar.ftl b/examples/localize/res/ar.ftl
index 212a3f5f3..59faa5505 100644
--- a/examples/localize/res/ar.ftl
+++ b/examples/localize/res/ar.ftl
@@ -4,3 +4,13 @@ button = زر
 
 window = 
     .title = ترجمة المثال (ar)
+
+example-cmds = أوامر المثال:
+
+LOCALIZED_CMD =
+    .info = مترجم في الملف الافتراضي.
+    .name = مترجم
+
+PRIVATE_LOCALIZED_CMD =
+    .info = أمر خاص، نص الترجمة العامة.
+    .name = خاص
\ No newline at end of file
diff --git a/examples/localize/res/ar/msg.ftl b/examples/localize/res/ar/msg.ftl
index a0f60f719..0e041ad34 100644
--- a/examples/localize/res/ar/msg.ftl
+++ b/examples/localize/res/ar/msg.ftl
@@ -3,4 +3,8 @@
 click-count = {$n ->
     [one] النقر {$n} مرة
     *[other] تم النقر عليها {$n} مرة
-}
\ No newline at end of file
+}
+
+LOCALIZED_FILE_CMD =
+    .info = مترجم في ملف مسمى 'msg'.
+    .name = ملف مترجم
\ No newline at end of file
diff --git a/examples/localize/res/pseudo-mirr.ftl b/examples/localize/res/pseudo-mirr.ftl
index 952c7d6a8..088d25001 100644
--- a/examples/localize/res/pseudo-mirr.ftl
+++ b/examples/localize/res/pseudo-mirr.ftl
@@ -11,7 +11,19 @@ window =
     .icon = ⅂o
     .title = ⅂oɔɐʅızǝ Ǝxɐɯdʅǝ (ʇǝɯdʅɐʇǝ)
 
+## Commands
+
+LOCALIZED_CMD =
+    .info = ⅂oɔɐʅızǝp ıu ʇɥǝ pǝɟɐnʅʇ ɟıʅǝ.
+    .name = ⅂oɔɐʅızǝp
+
+PRIVATE_LOCALIZED_CMD =
+    .info = Ԁɹıʌɐʇǝ ɔoɯɯɐup, dnqʅıɔ ʅoɔɐʅızɐʇıou ʇǝxʇ.
+    .name = Ԁɹıʌɐʇǝ
+
 ## Example Section
 
 # button sets "click-count"
 button = Ԑnʇʇou
+
+example-cmds = Ǝxɐɯdʅǝ Ↄoɯɯɐups:
diff --git a/examples/localize/res/pseudo-mirr/msg.ftl b/examples/localize/res/pseudo-mirr/msg.ftl
index ce5ec7937..0440aa5c2 100644
--- a/examples/localize/res/pseudo-mirr/msg.ftl
+++ b/examples/localize/res/pseudo-mirr/msg.ftl
@@ -2,4 +2,12 @@
 ### This standalone comment is added to all scraped template files.
 ### This standalone comment is only added to the `msg` file.
 
+## Commands
+
+LOCALIZED_FILE_CMD =
+    .info = ⅂oɔɐʅızǝp ıu ɐ uɐɯǝp ɟıʅǝ 'ɯsƃ'.
+    .name = ⅂oɔɐʅızǝp Ⅎıʅǝ
+
+## Example Section
+
 click-count = Ↄʅıɔʞǝp {$n} ʇıɯǝs
diff --git a/examples/localize/res/pseudo-wide.ftl b/examples/localize/res/pseudo-wide.ftl
index 40d4f56ab..e3a340599 100644
--- a/examples/localize/res/pseudo-wide.ftl
+++ b/examples/localize/res/pseudo-wide.ftl
@@ -11,7 +11,19 @@ window =
     .icon = Ŀoo
     .title = Ŀooƈaaŀiẑee Eẋaaḿƥŀee (ŧeeḿƥŀaaŧee)
 
+## Commands
+
+LOCALIZED_CMD =
+    .info = Ŀooƈaaŀiẑeeḓ iƞ ŧħee ḓeeƒaauuŀŧ ƒiŀee.
+    .name = Ŀooƈaaŀiẑeeḓ
+
+PRIVATE_LOCALIZED_CMD =
+    .info = Ƥřiṽaaŧee ƈooḿḿaaƞḓ, ƥuuƀŀiƈ ŀooƈaaŀiẑaaŧiooƞ ŧeeẋŧ.
+    .name = Ƥřiṽaaŧee
+
 ## Example Section
 
 # button sets "click-count"
 button = Ɓuuŧŧooƞ
+
+example-cmds = Eẋaaḿƥŀee Ƈooḿḿaaƞḓş:
diff --git a/examples/localize/res/pseudo-wide/msg.ftl b/examples/localize/res/pseudo-wide/msg.ftl
index 992406c17..ff8af4e86 100644
--- a/examples/localize/res/pseudo-wide/msg.ftl
+++ b/examples/localize/res/pseudo-wide/msg.ftl
@@ -2,4 +2,12 @@
 ### This standalone comment is added to all scraped template files.
 ### This standalone comment is only added to the `msg` file.
 
+## Commands
+
+LOCALIZED_FILE_CMD =
+    .info = Ŀooƈaaŀiẑeeḓ iƞ aa ƞaaḿeeḓ ƒiŀee 'ḿşɠ'.
+    .name = Ŀooƈaaŀiẑeeḓ Ƒiŀee
+
+## Example Section
+
 click-count = Ƈŀiƈķeeḓ {$n} ŧiḿeeş
diff --git a/examples/localize/res/pseudo.ftl b/examples/localize/res/pseudo.ftl
index 2faca8463..749509601 100644
--- a/examples/localize/res/pseudo.ftl
+++ b/examples/localize/res/pseudo.ftl
@@ -11,7 +11,19 @@ window =
     .icon = Ŀo
     .title = Ŀoƈaŀiẑe Eẋaḿƥŀe (ŧeḿƥŀaŧe)
 
+## Commands
+
+LOCALIZED_CMD =
+    .info = Ŀoƈaŀiẑeḓ iƞ ŧħe ḓeƒauŀŧ ƒiŀe.
+    .name = Ŀoƈaŀiẑeḓ
+
+PRIVATE_LOCALIZED_CMD =
+    .info = Ƥřiṽaŧe ƈoḿḿaƞḓ, ƥuƀŀiƈ ŀoƈaŀiẑaŧioƞ ŧeẋŧ.
+    .name = Ƥřiṽaŧe
+
 ## Example Section
 
 # button sets "click-count"
 button = Ɓuŧŧoƞ
+
+example-cmds = Eẋaḿƥŀe Ƈoḿḿaƞḓş:
diff --git a/examples/localize/res/pseudo/msg.ftl b/examples/localize/res/pseudo/msg.ftl
index a1ca683e5..e57b0b2e8 100644
--- a/examples/localize/res/pseudo/msg.ftl
+++ b/examples/localize/res/pseudo/msg.ftl
@@ -2,4 +2,12 @@
 ### This standalone comment is added to all scraped template files.
 ### This standalone comment is only added to the `msg` file.
 
+## Commands
+
+LOCALIZED_FILE_CMD =
+    .info = Ŀoƈaŀiẑeḓ iƞ a ƞaḿeḓ ƒiŀe 'ḿşɠ'.
+    .name = Ŀoƈaŀiẑeḓ Ƒiŀe
+
+## Example Section
+
 click-count = Ƈŀiƈķeḓ {$n} ŧiḿeş
diff --git a/examples/localize/res/pt-BR.ftl b/examples/localize/res/pt-BR.ftl
index c5bcd1f45..2ee5a2964 100644
--- a/examples/localize/res/pt-BR.ftl
+++ b/examples/localize/res/pt-BR.ftl
@@ -2,3 +2,13 @@ button = Botão
 
 window = 
     .title = Exemplo Localizar (pt-BR)
+
+example-cmds = Exemplos de Comandos:
+
+LOCALIZED_CMD =
+    .info = Localizado no arquivo padrão.
+    .name = Localizado
+
+PRIVATE_LOCALIZED_CMD =
+    .info = Comando privado, texto de localização público.
+    .name = Privado
\ No newline at end of file
diff --git a/examples/localize/res/pt-BR/msg.ftl b/examples/localize/res/pt-BR/msg.ftl
index 0cff324ff..d6dac1e3f 100644
--- a/examples/localize/res/pt-BR/msg.ftl
+++ b/examples/localize/res/pt-BR/msg.ftl
@@ -1,4 +1,8 @@
 click-count = {$n ->
     [one] Clicou {$n} vez
     *[other] Clicou {$n} vezes
-}
\ No newline at end of file
+}
+
+LOCALIZED_FILE_CMD =
+    .info = Localizado em um arquivo nomeado 'msg'.
+    .name = Arquivo Localizado
\ No newline at end of file
diff --git a/examples/localize/res/template.ftl b/examples/localize/res/template.ftl
index edaa60d0d..beb3bc56f 100644
--- a/examples/localize/res/template.ftl
+++ b/examples/localize/res/template.ftl
@@ -11,7 +11,19 @@ window =
     .icon = Lo
     .title = Localize Example (template)
 
+## Commands
+
+LOCALIZED_CMD =
+    .info = Localized in the default file.
+    .name = Localized
+
+PRIVATE_LOCALIZED_CMD =
+    .info = Private command, public localization text.
+    .name = Private
+
 ## Example Section
 
 # button sets "click-count"
 button = Button
+
+example-cmds = Example Commands:
diff --git a/examples/localize/res/template/msg.ftl b/examples/localize/res/template/msg.ftl
index f2236ef63..9ed6c2121 100644
--- a/examples/localize/res/template/msg.ftl
+++ b/examples/localize/res/template/msg.ftl
@@ -2,4 +2,12 @@
 ### This standalone comment is added to all scraped template files.
 ### This standalone comment is only added to the `msg` file.
 
+## Commands
+
+LOCALIZED_FILE_CMD =
+    .info = Localized in a named file 'msg'.
+    .name = Localized File
+
+## Example Section
+
 click-count = Clicked {$n} times
diff --git a/examples/localize/src/main.rs b/examples/localize/src/main.rs
index 53312ae65..528e39993 100644
--- a/examples/localize/src/main.rs
+++ b/examples/localize/src/main.rs
@@ -21,7 +21,7 @@ use zng::{
 // l10n-msg-### This standalone comment is only added to the `msg` file.
 
 // Run this command to scrap template:
-// cargo run -p cargo-zng -- zng l10n "examples/localize/src" "examples/localize/res"
+// cargo do zng l10n "examples/localize/src" "examples/localize/res"
 
 fn main() {
     zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
@@ -43,12 +43,8 @@ fn main() {
             // l10n-# Main window title
             title = l10n!("window.title", "Localize Example (template)");
             icon = WindowIcon::render(window_icon);
-            child = Stack! {
-                children = ui_vec![
-                    locale_menu(),
-                    window_content(),
-                ]
-            }
+            child_top = locale_menu(), 0;
+            child = window_content();
         }
     })
 }
@@ -75,6 +71,18 @@ fn window_icon() -> impl UiNode {
 fn window_content() -> impl UiNode {
     let click_count = var(0u32);
     let click_msg = l10n!("msg/click-count", "Clicked {$n} times", n = click_count.clone());
+
+    let test_cmds = [
+        NO_META_CMD,
+        NOT_LOCALIZED_CMD,
+        LOCALIZED_CMD,
+        PRIVATE_LOCALIZED_CMD,
+        L10N_FALSE_CMD,
+        LOCALIZED_FILE_CMD,
+    ];
+    let handles: Vec<_> = test_cmds.iter().map(|c| c.subscribe(true)).collect();
+    handles.leak(); // perm enable commands for test
+
     Stack! {
         align = Align::CENTER;
         direction = StackDirection::top_to_bottom();
@@ -93,7 +101,21 @@ fn window_content() -> impl UiNode {
             Text! {
                 txt = click_msg;
             },
-        ]
+
+            text::Text! {
+                layout::margin = (20, 0, 0, 0);
+                txt = l10n!("example-cmds", "Example Commands:");
+                font_weight = FontWeight::SEMIBOLD;
+            },
+            Wrap! {
+                children = test_cmds.into_iter().map(|c| Button!(c)).collect::<UiNodeVec>();
+                spacing = 4;
+                zng::button::style_fn = Style! {
+                    layout::padding = 2;
+                };
+                layout::max_width = 200;
+            }
+        ];
     }
 }
 
@@ -158,3 +180,36 @@ fn locale_menu() -> impl UiNode {
 }
 
 // l10n--### Another standalone comment, also added to the top of the template file.
+
+// l10n-## Commands
+
+zng::event::command! {
+    pub static NO_META_CMD;
+
+    pub static NOT_LOCALIZED_CMD = {
+        name: "Not Localized",
+    };
+
+    pub static LOCALIZED_CMD = {
+        l10n!: true,
+        name: "Localized",
+        info: "Localized in the default file.",
+    };
+
+    static PRIVATE_LOCALIZED_CMD = {
+        l10n!: true,
+        name: "Private",
+        info: "Private command, public localization text.",
+    };
+
+    pub static L10N_FALSE_CMD = {
+        l10n!: false,
+        name: "No L10n",
+    };
+
+    pub static LOCALIZED_FILE_CMD = {
+        l10n!: "msg",
+        name: "Localized File",
+        info: "Localized in a named file 'msg'."
+    };
+}


Test patch: