From de9d2c8f627a4e216b5e8293126322a02e875ef8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:09:47 +0800 Subject: [PATCH 1/2] fix(keymap): let the code editor's multi-cursor chords beat the pane keys gpui-component now binds secondary-d, secondary-shift-l and secondary-alt-up/down in the Input context for multiple cursors. A context-free binding ranks as deep as the focused context and ties go to the one added last, so tty7's SplitRight and FocusPaneUp/Down took those keys inside the editor. Re-add the editor bindings after tty7's table; other text fields have no handler for them and fall through to the pane keys. --- src/ui/keymap.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e28d7b91..14497b41 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -70,6 +70,28 @@ fn fixed_bindings() -> Vec { let search = Some(crate::ui::search::KEY_CONTEXT); bindings.push(KeyBinding::new("tab", SearchNextTab, search)); bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search)); + // The code editor's multi-cursor chords, laid down again after tty7's + // own table. gpui ranks a context-free binding as deep as the focused + // context, and breaks the tie by whichever was added last — so the copies + // gpui-component installs lose ⌘D to `SplitRight` and ⌘⌥↑/↓ to + // `FocusPaneUp`/`Down`, whose handlers sit on the workspace root and take + // the key before the editor is asked. Re-added here they win inside an + // `Input`; only the code editor handles them, so in any other text field + // gpui falls through to the pane binding next in line. + { + use gpui_component::input::{ + AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence, + }; + let input = Some("Input"); + bindings.push(KeyBinding::new("secondary-d", SelectNextOccurrence, input)); + bindings.push(KeyBinding::new( + "secondary-shift-l", + SelectAllOccurrences, + input, + )); + bindings.push(KeyBinding::new("secondary-alt-up", AddCursorAbove, input)); + bindings.push(KeyBinding::new("secondary-alt-down", AddCursorBelow, input)); + } bindings } @@ -2833,4 +2855,52 @@ mod gpui_tests { assert_eq!(backspace(cx), inherited, "nor may a rebind"); }); } + + /// The code editor's multi-cursor chords sit on keys tty7 ships for panes + /// (⌘D split, ⌘⌥↑/↓ focus). `fixed_bindings` re-adds them after the + /// pane table so they rank first inside an `Input`; any other text field + /// has no handler for them, and gpui falls through to the pane binding + /// listed after. A terminal never sees them. + #[gpui::test] + fn editor_multi_cursor_chords_outrank_the_pane_bindings(cx: &mut TestAppContext) { + use gpui::Action as _; + use gpui_component::input::{ + AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence, + }; + cx.update(|cx| { + running_on_json(cx, "{}"); + let in_input = |keys: &str| -> Vec<&'static str> { + let input = [Keystroke::parse(keys).expect("the keystroke parses")]; + let context = [ + gpui::KeyContext::parse("Workspace").expect("the context parses"), + gpui::KeyContext::parse("Input").expect("the context parses"), + ]; + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &context) + .0 + .iter() + .map(|b| b.action().name()) + .collect() + }; + let cases = [ + ("secondary-d", SelectNextOccurrence::name_for_type()), + ("secondary-shift-l", SelectAllOccurrences::name_for_type()), + ("secondary-alt-up", AddCursorAbove::name_for_type()), + ("secondary-alt-down", AddCursorBelow::name_for_type()), + ]; + for (keys, editor_action) in cases { + let hits = in_input(keys); + assert_eq!(hits.first(), Some(&editor_action), "{keys} in the editor"); + } + if cfg!(target_os = "macos") { + assert!(in_input("secondary-d").contains(&SplitRight::name_for_type())); + assert_eq!( + fired(cx, "secondary-d").first(), + Some(&SplitRight::name_for_type()), + "a terminal still splits on ⌘D" + ); + } + }); + } } From 08d4ab9afd7b91d0f7e076f79198859aa738a220 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:09:47 +0800 Subject: [PATCH 2/2] feat(editor): show the selection count in the status bar With several cursors the Ln/Col readout (the primary caret's) is followed by "(N selections)". --- src/ui/code_editor.rs | 21 +++++++++++++++++---- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index b03f169e..22fff137 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -3238,15 +3238,28 @@ impl Tty7App { } }); let cursor: Option = active.map(|f| { - let pos = f.input.read(cx).cursor_position(); - t_fmt( + let input = f.input.read(cx); + let pos = input.cursor_position(); + let ln_col = t_fmt( L10nKey::EditorLnCol, &[ ("line", &(pos.line + 1).to_string()), ("column", &(pos.character + 1).to_string()), ], - ) - .into() + ); + // Multi-cursor: the position is the primary caret's, the count + // says there are more. + match input.selection_count() { + 1 => ln_col.into(), + n => { + let n = n.to_string(); + format!( + "{ln_col} {}", + t_fmt(L10nKey::EditorSelections, &[("n", &n)]) + ) + .into() + } + } }); let wrap: Option = active.map(|f| f.wrap); let is_markdown = active.is_some_and(|f| f.language() == "markdown"); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 22f913e7..c8125c04 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1179,6 +1179,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::EditorNoFileOpen => "No file open", L10nKey::EditorBackToTerminal => "Back to Terminal (Esc)", L10nKey::EditorLnCol => "Ln {line}, Col {column}", + L10nKey::EditorSelections => "({n} selections)", L10nKey::EditorEdit => "Edit", L10nKey::EditorPreview => "Preview", L10nKey::EditorWrapOn => "Wrap: on", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dfebc354..5d685cda 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1227,6 +1227,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::EditorNoFileOpen => "開かれているファイルはありません", L10nKey::EditorBackToTerminal => "ターミナルに戻る (Esc)", L10nKey::EditorLnCol => "行 {line}, 列 {column}", + L10nKey::EditorSelections => "({n} 個の選択範囲)", L10nKey::EditorEdit => "編集", L10nKey::EditorPreview => "プレビュー", L10nKey::EditorWrapOn => "折り返し: オン", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 746dbdb1..1467cf0c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -917,6 +917,7 @@ l10n_keys! { EditorNoFileOpen, EditorBackToTerminal, EditorLnCol, + EditorSelections, EditorEdit, EditorPreview, EditorWrapOn, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7c1fc618..b8b41075 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1099,6 +1099,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::EditorNoFileOpen => "没有打开的文件", L10nKey::EditorBackToTerminal => "返回终端 (Esc)", L10nKey::EditorLnCol => "行 {line},列 {column}", + L10nKey::EditorSelections => "({n} 个选区)", L10nKey::EditorEdit => "编辑", L10nKey::EditorPreview => "预览", L10nKey::EditorWrapOn => "自动换行:开",