fix(settings): let the Keybindings page speak the app's own words

The page the docs call *the* shortcut reference rendered 89 undivided
rows of 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,
"Close Active Tab" for Close Pane / Tab, "Toggle Palette" for Command
Palette.

Adds keymap::action_entry, which routes every action's name and section
through the strings the rest of the app already uses. The list is now
read in the same seven sections the command palette groups by, and the
displaced-binding notice uses the same names as the rows it is talking
about.

Seven new keys across en/zh/ja; the nine tab and nine workspace jumps
are templated rather than eighteen hand-written strings per locale.

every_action_has_an_authored_name fails if a new action would fall back
to the CamelCase split, so the page cannot drift back one action at a
time.
This commit is contained in:
l0ng-ai
2026-08-08 02:26:17 +08:00
parent 1f91719da9
commit c8f13faee0
8 changed files with 413 additions and 16 deletions
+3 -5
View File
@@ -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),
],
)
});
+7
View File
@@ -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."
}
+7
View File
@@ -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 => {
"ターミナルワークベンチ: 常駐セッション、リモート作業、エージェント"
}
+14
View File
@@ -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,
+7
View File
@@ -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 => "更新",
+319
View File
@@ -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::<Config>();
let mut effective: Vec<(String, String)> = default_bindings()
@@ -627,6 +912,40 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
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"))]
+2 -2
View File
@@ -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),
+54 -9
View File
@@ -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<usize, &'static str> = {
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),
);
}