diff --git a/src/ui/app.rs b/src/ui/app.rs index bf5c4880..bcdadd88 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -34,9 +34,7 @@ use crate::ui::palette::{ }; use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot}; use crate::ui::presets::Fill; -use crate::ui::settings::{ - Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action, -}; +use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor}; use crate::ui::theme::{apply_theme, set_menus, window_background}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -4859,8 +4857,8 @@ impl Tty7App { t_fmt( L10nKey::AppKeybindingDisplacedNote, &[ - ("action", &humanize_action(&action)), - ("previous", &humanize_action(other)), + ("action", &crate::ui::keymap::action_entry(&action).1), + ("previous", &crate::ui::keymap::action_entry(other).1), ], ) }); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index f8767753..a3fe3f17 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -452,6 +452,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { "With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal." } L10nKey::SettingsRestoreAllDefaults => "Restore all defaults", + L10nKey::KeybindGoToTab => "Go to Tab {n}", + L10nKey::KeybindGoToWorkspace => "Go to Workspace {n}", + L10nKey::KeybindInsertNewline => "Insert Newline", + L10nKey::KeybindForkSessionRight => "Fork Session Right", + L10nKey::KeybindForkSessionLeft => "Fork Session Left", + L10nKey::KeybindForkSessionDown => "Fork Session Down", + L10nKey::KeybindForkSessionUp => "Fork Session Up", L10nKey::SettingsAboutDesc1 => { "A terminal workbench: persistent sessions, remote work, agents." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2641042d..f8b5343e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -450,6 +450,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "プレフィックスが有効な場合、プレフィックスキーを単独で押すと約 1 秒後にシェルに渡され、プレフィックス + 未割り当てのキーはターミナルへそのまま送信されます" } L10nKey::SettingsRestoreAllDefaults => "すべてのデフォルトを復元", + L10nKey::KeybindGoToTab => "タブ {n} へ移動", + L10nKey::KeybindGoToWorkspace => "ワークスペース {n} へ移動", + L10nKey::KeybindInsertNewline => "改行を挿入", + L10nKey::KeybindForkSessionRight => "右にセッションをフォーク", + L10nKey::KeybindForkSessionLeft => "左にセッションをフォーク", + L10nKey::KeybindForkSessionDown => "下にセッションをフォーク", + L10nKey::KeybindForkSessionUp => "上にセッションをフォーク", L10nKey::SettingsAboutDesc1 => { "ターミナルワークベンチ: 常駐セッション、リモート作業、エージェント" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index add96591..7118b42b 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -388,6 +388,13 @@ pub enum L10nKey { SettingsKeybindingsIntroDesc, SettingsPrefixNote, SettingsRestoreAllDefaults, + KeybindGoToTab, + KeybindGoToWorkspace, + KeybindInsertNewline, + KeybindForkSessionRight, + KeybindForkSessionLeft, + KeybindForkSessionDown, + KeybindForkSessionUp, SettingsAboutDesc1, SettingsVersion, SettingsUpdates, @@ -1405,6 +1412,13 @@ mod tests { L10nKey::SettingsKeybindingsIntroDesc, L10nKey::SettingsPrefixNote, L10nKey::SettingsRestoreAllDefaults, + L10nKey::KeybindGoToTab, + L10nKey::KeybindGoToWorkspace, + L10nKey::KeybindInsertNewline, + L10nKey::KeybindForkSessionRight, + L10nKey::KeybindForkSessionLeft, + L10nKey::KeybindForkSessionDown, + L10nKey::KeybindForkSessionUp, L10nKey::SettingsAboutDesc1, L10nKey::SettingsUpdates, L10nKey::SettingsUpdateAndRelaunch, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index cd02070f..fbca0203 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -399,6 +399,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "启用前缀后,单独按前缀键约 1 秒后会传给 shell,前缀 + 未绑定的按键会直接发送到终端。" } L10nKey::SettingsRestoreAllDefaults => "恢复全部默认值", + L10nKey::KeybindGoToTab => "跳到第 {n} 个标签页", + L10nKey::KeybindGoToWorkspace => "跳到第 {n} 个工作区", + L10nKey::KeybindInsertNewline => "插入换行", + L10nKey::KeybindForkSessionRight => "向右 Fork 会话", + L10nKey::KeybindForkSessionLeft => "向左 Fork 会话", + L10nKey::KeybindForkSessionDown => "向下 Fork 会话", + L10nKey::KeybindForkSessionUp => "向上 Fork 会话", L10nKey::SettingsAboutDesc1 => "终端工作台:常驻会话、远程工作、agent。", L10nKey::SettingsVersion => "版本", L10nKey::SettingsUpdates => "更新", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 55b2835f..22143987 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -6,6 +6,9 @@ use crate::terminal::view::{ ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, InsertNewlineFallback, PasteText, }; +use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::palette::CommandGroup; +use crate::ui::settings::humanize_action; use crate::ui::theme::set_menus; #[derive(Default)] @@ -319,6 +322,288 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ] } +/// Where an action sits on the Keybindings page, and what it is called there. +/// +/// The page used to render `humanize_action`'s CamelCase split — English in a +/// three-locale app, and a fourth vocabulary on top of the menu bar, the +/// palette and the docs ("Toggle Maximize Pane" for what everything else calls +/// Zoom Pane). This routes both the name and the section through the strings +/// the rest of the app already uses. +pub(crate) fn action_entry(action: &str) -> (CommandGroup, String) { + authored_entry(action).unwrap_or_else(|| { + // An action with no entry still renders, under Application and with its + // name split on capitals — a new action shows up on the page rather + // than disappearing from it. `every_action_has_an_authored_name` is + // what keeps that from being how the page looks. + (CommandGroup::Application, humanize_action(action)) + }) +} + +fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { + // The numbered families are one row each in nine copies; a templated label + // beats nine hand-written strings per locale. + if let Some(n) = action.strip_prefix("ActivateTab") { + return Some(( + CommandGroup::TabsPanes, + t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]), + )); + } + if let Some(n) = action.strip_prefix("SelectWorkspace") { + return Some(( + CommandGroup::Workspaces, + t_fmt(L10nKey::KeybindGoToWorkspace, &[("n", n)]), + )); + } + Some(match action { + "NewTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNewTab).to_string()), + "CloseActiveTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdClosePaneTab).to_string(), + ), + "RenameTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdRenameTab).to_string(), + ), + "NewWorktreeTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdNewWorktreeTab).to_string(), + ), + "CloseOtherTabs" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdCloseOtherTabs).to_string(), + ), + "CloseTabsToTheRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdCloseTabsToTheRight).to_string(), + ), + "CopyWorkingDirectory" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdCopyWorkingDirectory).to_string(), + ), + "MarkTabUnread" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdMarkTabAsUnread).to_string(), + ), + "ReopenClosedTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdReopenClosedTab).to_string(), + ), + "SplitRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdSplitRight).to_string(), + ), + "SplitDown" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdSplitDown).to_string(), + ), + "ToggleMaximizePane" => (CommandGroup::TabsPanes, t(L10nKey::CmdZoomPane).to_string()), + "FocusNextPane" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextPane).to_string()), + "FocusPrevPane" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdPreviousPane).to_string(), + ), + "FocusPaneLeft" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdFocusPaneLeft).to_string(), + ), + "FocusPaneRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdFocusPaneRight).to_string(), + ), + "FocusPaneUp" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdFocusPaneUp).to_string(), + ), + "FocusPaneDown" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdFocusPaneDown).to_string(), + ), + "ResizePaneLeft" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdResizePaneLeft).to_string(), + ), + "ResizePaneRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdResizePaneRight).to_string(), + ), + "ResizePaneUp" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdResizePaneUp).to_string(), + ), + "ResizePaneDown" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdResizePaneDown).to_string(), + ), + "SwapPaneNext" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdSwapPaneNext).to_string(), + ), + "SwapPanePrev" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdSwapPanePrevious).to_string(), + ), + "NextTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextTab).to_string()), + "PrevTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdPreviousTab).to_string(), + ), + "NewWorkspace" => ( + CommandGroup::Workspaces, + t(L10nKey::CmdNewWorkspace).to_string(), + ), + "RenameWorkspace" => ( + CommandGroup::Workspaces, + t(L10nKey::CmdRenameWorkspace).to_string(), + ), + "StopWorkspace" => ( + CommandGroup::Workspaces, + t(L10nKey::CmdStopWorkspace).to_string(), + ), + "DeleteWorkspace" => ( + CommandGroup::Workspaces, + t(L10nKey::CmdDeleteWorkspace).to_string(), + ), + "ToggleSwitcher" => ( + CommandGroup::Workspaces, + t(L10nKey::HomeSwitchWorkspace).to_string(), + ), + "IncreaseFontSize" => ( + CommandGroup::View, + t(L10nKey::AppMenuIncreaseFontSize).to_string(), + ), + "DecreaseFontSize" => ( + CommandGroup::View, + t(L10nKey::AppMenuDecreaseFontSize).to_string(), + ), + "ResetFontSize" => (CommandGroup::View, t(L10nKey::CmdResetFontSize).to_string()), + "ToggleFullscreen" => ( + CommandGroup::View, + t(L10nKey::CmdEnterFullScreen).to_string(), + ), + "ToggleTabSidebar" => ( + CommandGroup::View, + t(L10nKey::AppMenuTabBarPosition).to_string(), + ), + "ToggleLeftPanel" => ( + CommandGroup::View, + t(L10nKey::AppMenuLeftSidebar).to_string(), + ), + "ToggleRightPanel" => ( + CommandGroup::View, + t(L10nKey::AppMenuRightPanel).to_string(), + ), + "ToggleCodePanel" => (CommandGroup::View, t(L10nKey::AppMenuCodePanel).to_string()), + "FindInTerminal" => ( + CommandGroup::Terminal, + t(L10nKey::CmdFindInTerminal).to_string(), + ), + "FindNext" => (CommandGroup::Terminal, t(L10nKey::CmdFindNext).to_string()), + "FindPrevious" => ( + CommandGroup::Terminal, + t(L10nKey::CmdFindPrevious).to_string(), + ), + "ClearScrollback" => ( + CommandGroup::Terminal, + t(L10nKey::CmdClearScrollback).to_string(), + ), + "CopyText" => (CommandGroup::Terminal, t(L10nKey::CmdCopy).to_string()), + "PasteText" => (CommandGroup::Terminal, t(L10nKey::CmdPaste).to_string()), + "InsertNewline" => ( + CommandGroup::Terminal, + t(L10nKey::KeybindInsertNewline).to_string(), + ), + "EditorSave" => (CommandGroup::Terminal, t(L10nKey::Save).to_string()), + "OpenSshProfiles" => ( + CommandGroup::Ssh, + t(L10nKey::CmdSshManageProfiles).to_string(), + ), + "RestartSshSession" => (CommandGroup::Ssh, t(L10nKey::CmdSshReconnect).to_string()), + "ToggleSftp" => (CommandGroup::Ssh, t(L10nKey::CmdSshRemoteFiles).to_string()), + "ShowSshForwards" => ( + CommandGroup::Ssh, + t(L10nKey::CmdSshPortForwarding).to_string(), + ), + "ForkAgentSession" => (CommandGroup::Agents, t(L10nKey::CmdForkSession).to_string()), + "ForkAgentSessionRight" => ( + CommandGroup::Agents, + t(L10nKey::KeybindForkSessionRight).to_string(), + ), + "ForkAgentSessionLeft" => ( + CommandGroup::Agents, + t(L10nKey::KeybindForkSessionLeft).to_string(), + ), + "ForkAgentSessionDown" => ( + CommandGroup::Agents, + t(L10nKey::KeybindForkSessionDown).to_string(), + ), + "ForkAgentSessionUp" => ( + CommandGroup::Agents, + t(L10nKey::KeybindForkSessionUp).to_string(), + ), + "CopyAgentSessionId" => ( + CommandGroup::Agents, + t(L10nKey::CmdCopySessionId).to_string(), + ), + "TogglePalette" => ( + CommandGroup::Application, + t(L10nKey::AppMenuCommandPalette).to_string(), + ), + "OpenSettings" => ( + CommandGroup::Application, + t(L10nKey::CmdSettings).to_string(), + ), + "ShowKeyboardShortcuts" => ( + CommandGroup::Application, + t(L10nKey::CmdKeyboardShortcuts).to_string(), + ), + "About" => ( + CommandGroup::Application, + t(L10nKey::CmdAboutTty7).to_string(), + ), + "CheckForUpdates" => ( + CommandGroup::Application, + t(L10nKey::CmdCheckForUpdates).to_string(), + ), + "OpenDocumentation" => ( + CommandGroup::Application, + t(L10nKey::CmdDocumentation).to_string(), + ), + "OpenDiscord" => ( + CommandGroup::Application, + t(L10nKey::CmdJoinDiscord).to_string(), + ), + "ReportIssue" => ( + CommandGroup::Application, + t(L10nKey::CmdReportIssue).to_string(), + ), + "HideApp" => ( + CommandGroup::Application, + t(L10nKey::AppMenuHideApp).to_string(), + ), + "HideOthers" => ( + CommandGroup::Application, + t(L10nKey::AppMenuHideOthers).to_string(), + ), + "ShowAll" => ( + CommandGroup::Application, + t(L10nKey::AppMenuShowAll).to_string(), + ), + "MinimizeWindow" => ( + CommandGroup::Application, + t(L10nKey::AppMenuMinimize).to_string(), + ), + "ZoomWindow" => ( + CommandGroup::Application, + t(L10nKey::AppMenuZoom).to_string(), + ), + "Quit" => ( + CommandGroup::Application, + t(L10nKey::CmdQuitTty7).to_string(), + ), + _ => return None, + }) +} + pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { let cfg = cx.global::(); let mut effective: Vec<(String, String)> = default_bindings() @@ -627,6 +912,40 @@ fn make_binding(action: &str, keystroke: &str) -> Option { mod tests { use super::*; + #[test] + fn every_action_has_an_authored_name() { + crate::ui::i18n::set_locale("en"); + let missing: Vec<&str> = default_bindings() + .into_iter() + .map(|(a, _)| a) + .filter(|a| authored_entry(a).is_none()) + .collect(); + assert!( + missing.is_empty(), + "these actions would render on the Keybindings page as a CamelCase \ + split of their internal name, in English, in a three-locale app: {missing:?}" + ); + } + + #[test] + fn the_page_speaks_the_same_words_as_the_rest_of_the_app() { + crate::ui::i18n::set_locale("en"); + // The four names the critique found for one action; the menu bar, the + // palette and the docs all say Zoom Pane. + assert_eq!(action_entry("ToggleMaximizePane").1, "Zoom Pane"); + assert_eq!(action_entry("CloseActiveTab").1, "Close Pane / Tab"); + assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback"); + assert_eq!(action_entry("TogglePalette").1, "Command Palette…"); + // The numbered families are templated, not nine strings per locale. + assert_eq!(action_entry("ActivateTab3").1, "Go to Tab 3"); + assert_eq!(action_entry("SelectWorkspace7").1, "Go to Workspace 7"); + // And they land in the section they belong to. + assert_eq!(action_entry("ActivateTab3").0, CommandGroup::TabsPanes); + assert_eq!(action_entry("SelectWorkspace7").0, CommandGroup::Workspaces); + assert_eq!(action_entry("ToggleSftp").0, CommandGroup::Ssh); + assert_eq!(action_entry("ForkAgentSessionUp").0, CommandGroup::Agents); + } + #[cfg(target_os = "macos")] const SECONDARY: &str = "⌘"; #[cfg(not(target_os = "macos"))] diff --git a/src/ui/palette.rs b/src/ui/palette.rs index aa237815..23d20b95 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -284,7 +284,7 @@ pub enum CommandGroup { } impl CommandGroup { - const ORDER: [CommandGroup; 7] = [ + pub(crate) const ORDER: [CommandGroup; 7] = [ CommandGroup::TabsPanes, CommandGroup::Workspaces, CommandGroup::View, @@ -294,7 +294,7 @@ impl CommandGroup { CommandGroup::Application, ]; - fn title(self) -> &'static str { + pub(crate) fn title(self) -> &'static str { match self { CommandGroup::TabsPanes => t(L10nKey::CmdGroupTabsPanes), CommandGroup::Workspaces => t(L10nKey::CmdGroupWorkspaces), diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 3a893306..ddd8aa9d 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4530,9 +4530,47 @@ impl Tty7App { ) .child(h_flex().flex_shrink_0().child(prefix_control)); - let count = effective.len(); + // Eighty-nine undivided rows, in the order the binding table happens to + // be written. Read them in the same seven sections the command palette + // uses, so a shortcut is found by where it belongs rather than by + // scrolling. + let mut grouped: Vec<( + crate::ui::palette::CommandGroup, + Vec<(String, String, String)>, + )> = Vec::new(); + for (action, key) in effective { + let (group, label) = crate::ui::keymap::action_entry(&action); + let slot = match grouped.iter_mut().find(|(g, _)| *g == group) { + Some(slot) => slot, + None => { + grouped.push((group, Vec::new())); + grouped.last_mut().expect("just pushed") + } + }; + slot.1.push((action, key, label)); + } + grouped.sort_by_key(|(g, _)| { + crate::ui::palette::CommandGroup::ORDER + .iter() + .position(|o| o == g) + .unwrap_or(usize::MAX) + }); + let rows: Vec<(String, String, String)> = grouped + .iter() + .flat_map(|(_, rows)| rows.iter().cloned()) + .collect(); + let heading_at: std::collections::HashMap = { + let mut map = std::collections::HashMap::new(); + let mut at = 0usize; + for (group, rows) in &grouped { + map.insert(at, group.title()); + at += rows.len(); + } + map + }; + let count = rows.len(); let mut list = v_flex().mt_2(); - for (i, (action, key)) in effective.into_iter().enumerate() { + for (i, (action, key, label)) in rows.into_iter().enumerate() { let is_recording = recording.as_ref().is_some_and(|(a, _)| a == &action); let is_overridden = overridden.contains(&action); @@ -4609,18 +4647,25 @@ impl Tty7App { ) }); + if let Some(title) = heading_at.get(&i) { + list = list.child( + div() + .pt_5() + .pb_1p5() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child(*title), + ); + } + let last_in_group = heading_at.contains_key(&(i + 1)) || i + 1 == count; list = list.child( h_flex() .items_center() .justify_between() .py_1p5() - .when(i + 1 < count, |s| s.border_b_1().border_color(border)) - .child( - div() - .text_sm() - .text_color(foreground) - .child(humanize_action(&action)), - ) + .when(!last_in_group, |s| s.border_b_1().border_color(border)) + .child(div().text_sm().text_color(foreground).child(label)) .child(right), ); }