From a6fd26d15a51f170dd938023fd64198f6f0dc0c9 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 9 May 2026 22:41:06 +0000 Subject: [PATCH] =?UTF-8?q?ADR-0019=20=C2=A79=20sweep=20(3/3):=20ui.rs=20p?= =?UTF-8?q?rose=20strings=20(caught=20in=20manual=20sanity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surprise gap from the post-sweep sanity check — `ui.rs` had a substantial set of TUI-rendered strings that the previous two sweep passes didn't cover. Caught by grepping for capitalised literals in `ui.rs` after running the binary smoke check. ## Migrated - **modal.*** — load picker title / empty state / path prompt; rebuild confirm title / "Continue?" prompt. (modal.path_entry's title comes from `save.*` since it's the save / save-as dialog.) - **save.*** — `save` no-op hint, modal titles for Save / Save as, modal prompt body. - **status.*** — status bar `Project:` label and the `(no project)` placeholder. - **panel.*** — `Tables` panel title, `(none yet)` placeholder for empty tables, `(no active hint)` placeholder for the hint panel. - **shortcut.*** — the bottom-bar keyboard hint labels (submit, confirm, cancel, yes, no, load, select, browse_path, back_to_list, switch, advanced_once, cancel_one_shot, quit). Each is a translatable label paired with a key name (Enter / Esc / Ctrl-C / etc.) at the call site. Keystroke names are deliberately left as literals — translating them would mean retraining users away from what their keyboard says. The `push_shortcut` closure's parameter type changed from `&'static str` to `&str` so it accepts the catalog-returned String. ## Deliberately left - **Echo prefix tags**: `[simple] `, `[advanced] `, `[system] `, `[error] `. Their column widths are hardcoded into the wrap-width calculation in `render_output_panel`; translating them would silently break alignment. Worth a follow-up pass if a future locale needs different prefixes (would need `mode.label()` and the echo-tag widths to live behind a single locale-aware function). - **Mode labels**: `SIMPLE` / `ADVANCED` / `Advanced:` rendered in the input panel border. Same alignment reasoning as the echo tags — also they're keywords (the user types `mode simple` to switch), so translating the display label without translating the command word would be confusing. Left as is. - **Visual decoration**: `[Y]`, `[N]`, `[TEMP] `, `>` cursor markers, `█` cursor block, `↑↓` arrow glyph, `›` selection marker. Universal symbols / labels rather than translatable prose. ## Catalog totals The catalog now has ~170 entries across 16 categories. `tests/engine_vocabulary_audit` passes — no engine vocabulary leaks anywhere user-reachable. ## Tally 610 tests passing (no change — pure refactor with identical-output catalog substitutions). Clippy clean with nursery lints. Release builds at 7.8 MB. ADR-0019 §9 is now genuinely complete. --- src/app.rs | 14 +++--- src/friendly/keys.rs | 30 +++++++++++ src/friendly/strings/en-US.yaml | 42 ++++++++++++++++ src/ui.rs | 88 ++++++++++++++++++++++----------- 4 files changed, 138 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2f4d873..85ff562 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1130,15 +1130,17 @@ impl App { /// the named project is already persistent (ADR-0015 §11). fn handle_save_command(&mut self, force_save_as: bool) -> Vec { if !force_save_as && !self.project_is_temp { - self.note_system( - "already auto-saved; use `save as` to copy to a different location", - ); + self.note_system(crate::t!("save.already_saved")); return Vec::new(); } - let title = if force_save_as { "Save as" } else { "Save" }; + let title = if force_save_as { + crate::t!("save.title_as") + } else { + crate::t!("save.title_save") + }; self.modal = Some(Modal::PathEntry(PathEntryModal { - title: title.to_string(), - prompt: "Name (under data dir/projects) or absolute path:".to_string(), + title, + prompt: crate::t!("save.path_prompt"), input: String::new(), cursor: 0, purpose: PathEntryPurpose::SaveAs, diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e4d7946..20024eb 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -148,10 +148,40 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // ---- Modal labels ---- ("modal.generic_cancelled", &["title"]), ("modal.load_cancelled", &[]), + ("modal.load_picker_empty", &[]), ("modal.load_picker_nothing", &[]), + ("modal.load_picker_path_prompt", &[]), + ("modal.load_picker_title", &[]), ("modal.path_entry_empty_name", &[]), ("modal.path_entry_empty_path", &[]), ("modal.rebuild_cancelled", &[]), + ("modal.rebuild_confirm_prompt", &[]), + ("modal.rebuild_confirm_title", &[]), + // ---- Status bar + panels ---- + ("panel.hint_empty", &[]), + ("panel.tables_empty", &[]), + ("panel.tables_title", &[]), + ("status.no_project", &[]), + ("status.project_label", &[]), + // ---- Save / save-as surfaces ---- + ("save.already_saved", &[]), + ("save.path_prompt", &[]), + ("save.title_as", &[]), + ("save.title_save", &[]), + // ---- Shortcut hint labels ---- + ("shortcut.advanced_once", &[]), + ("shortcut.back_to_list", &[]), + ("shortcut.browse_path", &[]), + ("shortcut.cancel", &[]), + ("shortcut.cancel_one_shot", &[]), + ("shortcut.confirm", &[]), + ("shortcut.load", &[]), + ("shortcut.no", &[]), + ("shortcut.quit", &[]), + ("shortcut.select", &[]), + ("shortcut.submit", &[]), + ("shortcut.switch", &[]), + ("shortcut.yes", &[]), // ---- mode / messages banners ---- ("messages.set_short", &[]), ("messages.set_verbose", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index cec208d..50b6985 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -309,6 +309,48 @@ modal: path_entry_empty_name: "path entry: empty name" path_entry_empty_path: "path entry: empty path" load_picker_nothing: "nothing to load" + # Modal titles + body prose rendered by ui.rs. + load_picker_title: "Load project" + load_picker_empty: "(no projects in data directory)" + load_picker_path_prompt: "Path to project directory:" + rebuild_confirm_title: "Rebuild project" + rebuild_confirm_prompt: "Continue?" + +# ---- Save / save-as command surfaces --------------------------------- +save: + # `save` on a named project is a no-op with a friendly hint. + already_saved: "already auto-saved; use `save as` to copy to a different location" + # Modal titles for `save` (on a temp) vs `save as`. + title_as: "Save as" + title_save: "Save" + # Prompt body for the path-entry modal opened by save / save as. + path_prompt: "Name (under data dir/projects) or absolute path:" + +# ---- Status bar (project label) and panels --------------------------- +status: + no_project: "(no project)" + project_label: "Project: " + +panel: + tables_title: "Tables" + tables_empty: "(none yet)" + hint_empty: "(no active hint)" + +# ---- Shortcut hints (paired with key names in the bottom bar) ------- +shortcut: + submit: "submit" + confirm: "confirm" + cancel: "cancel" + yes: "yes" + no: "no" + load: "load" + select: "select" + browse_path: "browse path" + back_to_list: "back to list" + switch: "switch" + advanced_once: "advanced once" + cancel_one_shot: "cancel one-shot" + quit: "quit" # ---- mode / messages banners (app-level commands) ------------------- mode: diff --git a/src/ui.rs b/src/ui.rs index 7439854..8ff08b1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -120,9 +120,12 @@ fn render_path_entry( text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" confirm "), + Span::raw(format!(" {} ", crate::t!("shortcut.confirm"))), Span::styled("Esc", Style::default().fg(theme.muted)), - Span::styled(" cancel", Style::default().fg(theme.muted)), + Span::styled( + format!(" {}", crate::t!("shortcut.cancel")), + Style::default().fg(theme.muted), + ), ])); let paragraph = Paragraph::new(text_lines) @@ -157,7 +160,10 @@ fn render_load_picker( .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) - .title(Line::from(vec![Span::styled(" Load project ", title_style)])) + .title(Line::from(vec![Span::styled( + format!(" {} ", crate::t!("modal.load_picker_title")), + title_style, + )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); @@ -166,7 +172,7 @@ fn render_load_picker( match &m.sub_mode { LoadPickerSubMode::List => { if m.entries.is_empty() { - text_lines.push(Line::from("(no projects in data directory)")); + text_lines.push(Line::from(crate::t!("modal.load_picker_empty"))); } else { for (i, entry) in m.entries.iter().enumerate() { let marker = if i == m.selected { "›" } else { " " }; @@ -189,17 +195,20 @@ fn render_load_picker( text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" select "), + Span::raw(format!(" {} ", crate::t!("shortcut.select"))), Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" load "), + Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("b", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" browse path "), + Span::raw(format!(" {} ", crate::t!("shortcut.browse_path"))), Span::styled("Esc", Style::default().fg(theme.muted)), - Span::styled(" cancel", Style::default().fg(theme.muted)), + Span::styled( + format!(" {}", crate::t!("shortcut.cancel")), + Style::default().fg(theme.muted), + ), ])); } LoadPickerSubMode::PathEntry { input, cursor } => { - text_lines.push(Line::from("Path to project directory:")); + text_lines.push(Line::from(crate::t!("modal.load_picker_path_prompt"))); text_lines.push(Line::from("")); let cursor_marker = "█"; let display_input = if *cursor == input.len() { @@ -215,9 +224,12 @@ fn render_load_picker( text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" load "), + Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("Esc", Style::default().fg(theme.muted)), - Span::styled(" back to list", Style::default().fg(theme.muted)), + Span::styled( + format!(" {}", crate::t!("shortcut.back_to_list")), + Style::default().fg(theme.muted), + ), ])); } } @@ -262,7 +274,10 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) - .title(Line::from(vec![Span::styled(" Rebuild project ", title_style)])) + .title(Line::from(vec![Span::styled( + format!(" {} ", crate::t!("modal.rebuild_confirm_title")), + title_style, + )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); @@ -271,7 +286,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); - text_lines.push(Line::from("Continue?")); + text_lines.push(Line::from(crate::t!("modal.rebuild_confirm_prompt"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled( @@ -280,16 +295,19 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a .fg(theme.fg) .add_modifier(Modifier::BOLD), ), - Span::raw(" yes "), + Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::styled( "[N]", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), ), - Span::raw(" no "), + Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), - Span::styled(" cancel", Style::default().fg(theme.muted)), + Span::styled( + format!(" {}", crate::t!("shortcut.cancel")), + Style::default().fg(theme.muted), + ), ])); let paragraph = Paragraph::new(text_lines) @@ -332,8 +350,12 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R .add_modifier(Modifier::BOLD); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); - let display = app.project_name.as_deref().unwrap_or("(no project)"); - let mut spans: Vec> = vec![Span::styled("Project: ", label_style)]; + let no_project = crate::t!("status.no_project"); + let display = app.project_name.as_deref().unwrap_or(no_project.as_str()); + let mut spans: Vec> = vec![Span::styled( + crate::t!("status.project_label"), + label_style, + )]; if app.project_is_temp { spans.push(Span::styled( "[TEMP] ", @@ -374,7 +396,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( - " Tables ", + format!(" {} ", crate::t!("panel.tables_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), @@ -383,7 +405,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( - "(none yet)", + crate::t!("panel.tables_empty"), Style::default() .fg(theme.muted) .add_modifier(Modifier::ITALIC), @@ -597,9 +619,10 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect )) .style(Style::default().bg(theme.bg).fg(theme.fg)); - let body = app.hint.as_deref().unwrap_or("(no active hint)"); + let empty_hint = crate::t!("panel.hint_empty"); + let body = app.hint.as_deref().unwrap_or(empty_hint.as_str()); let paragraph = Paragraph::new(Line::from(Span::styled( - body, + body.to_string(), Style::default().fg(theme.muted), ))) .block(block) @@ -619,29 +642,34 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect let separator = Span::styled(" · ", sep_style); let mut spans: Vec> = Vec::new(); - let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &'static str| { + let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &str| { if !spans.is_empty() { spans.push(separator.clone()); } spans.push(Span::styled(key, key_style)); spans.push(Span::raw(" ")); - spans.push(Span::styled(label, label_style)); + spans.push(Span::styled(label.to_string(), label_style)); }; - push_shortcut(&mut spans, "Enter", "submit"); + let submit = crate::t!("shortcut.submit"); + push_shortcut(&mut spans, "Enter", &submit); + let switch = crate::t!("shortcut.switch"); + let advanced_once = crate::t!("shortcut.advanced_once"); + let cancel_one_shot = crate::t!("shortcut.cancel_one_shot"); + let quit = crate::t!("shortcut.quit"); match app.effective_mode() { EffectiveMode::Simple => { - push_shortcut(&mut spans, ":", "advanced once"); - push_shortcut(&mut spans, "mode advanced", "switch"); + push_shortcut(&mut spans, ":", &advanced_once); + push_shortcut(&mut spans, "mode advanced", &switch); } EffectiveMode::AdvancedPersistent => { - push_shortcut(&mut spans, "mode simple", "switch"); + push_shortcut(&mut spans, "mode simple", &switch); } EffectiveMode::AdvancedOneShot => { - push_shortcut(&mut spans, "Backspace", "cancel one-shot"); + push_shortcut(&mut spans, "Backspace", &cancel_one_shot); } } - push_shortcut(&mut spans, "Ctrl-C", "quit"); + push_shortcut(&mut spans, "Ctrl-C", &quit); let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); frame.render_widget(paragraph, area);