diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index e336018e..f921d835 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -3255,15 +3255,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 5badfcb1..345bea85 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 785e02cc..95e6a8d1 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 6e022afe..f34dd76f 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 d1c60559..e6f62300 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 => "自动换行:开", 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" + ); + } + }); + } }