Skip to content

Commit 94eb3d2

Browse files
committed
Added Kitty Multicursor Support
Add support for kitty's multiple cursors protocol to show real terminal cursors at all selection positions when using bar/underline cursors.
1 parent 3f4a286 commit 94eb3d2

File tree

6 files changed

+102
-19
lines changed

6 files changed

+102
-19
lines changed

helix-term/src/application.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ impl Application {
124124
let backend = TestBackend::new(120, 150);
125125

126126
let theme_mode = backend.get_theme_mode();
127+
128+
#[cfg(all(not(windows), not(feature = "integration")))]
129+
let kitty_multi_cursor_support = backend.supports_kitty_multi_cursor();
130+
#[cfg(any(windows, feature = "integration"))]
131+
let kitty_multi_cursor_support = false;
132+
127133
let terminal = Terminal::new(backend)?;
128134
let area = terminal.size();
129135
let mut compositor = Compositor::new(area);
@@ -138,6 +144,8 @@ impl Application {
138144
})),
139145
handlers,
140146
);
147+
editor.kitty_multi_cursor_support = kitty_multi_cursor_support;
148+
141149
Self::load_configured_theme(
142150
&mut editor,
143151
&config.load(),
@@ -298,7 +306,41 @@ impl Application {
298306
self.editor.cursor_cache.reset();
299307

300308
let pos = pos.map(|pos| (pos.col as u16, pos.row as u16));
309+
310+
use helix_view::graphics::CursorKind;
311+
let secondary_cursors = if !matches!(kind, CursorKind::Block | CursorKind::Hidden) {
312+
self.get_secondary_cursor_positions()
313+
} else {
314+
Vec::new()
315+
};
316+
301317
self.terminal.draw(pos, kind).unwrap();
318+
// Always update kitty cursors (clears if empty, sets if not)
319+
self.terminal.set_multiple_cursors(&secondary_cursors).unwrap();
320+
}
321+
322+
fn get_secondary_cursor_positions(&self) -> Vec<(u16, u16)> {
323+
use helix_view::current_ref;
324+
325+
let (view, doc) = current_ref!(&self.editor);
326+
let text = doc.text().slice(..);
327+
let selection = doc.selection(view.id);
328+
let primary_idx = selection.primary_index();
329+
let inner = view.inner_area(doc);
330+
331+
selection
332+
.iter()
333+
.enumerate()
334+
.filter(|(idx, _)| *idx != primary_idx)
335+
.filter_map(|(_, range)| {
336+
let cursor = range.cursor(text);
337+
view.screen_coords_at_pos(doc, text, cursor).map(|pos| {
338+
let x = (pos.col + inner.x as usize) as u16;
339+
let y = (pos.row + inner.y as usize) as u16;
340+
(x, y)
341+
})
342+
})
343+
.collect()
302344
}
303345

304346
pub async fn event_loop<S>(&mut self, input_stream: &mut S)

helix-term/src/ui/editor.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use helix_core::{
2525
use helix_view::{
2626
annotations::diagnostics::DiagnosticFilter,
2727
document::{Mode, SCRATCH_BUFFER_NAME},
28-
editor::{CompleteAction, CursorShapeConfig},
28+
editor::CompleteAction,
2929
graphics::{Color, CursorKind, Modifier, Rect, Style},
3030
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
3131
keyboard::{KeyCode, KeyModifiers},
@@ -146,11 +146,10 @@ impl EditorView {
146146
overlays.push(tabstops);
147147
}
148148
overlays.push(Self::doc_selection_highlights(
149-
editor.mode(),
149+
editor,
150150
doc,
151151
view,
152152
theme,
153-
&config.cursor_shape,
154153
self.terminal_focused,
155154
));
156155
if let Some(overlay) = Self::highlight_focused_view_elements(view, doc, theme) {
@@ -461,20 +460,24 @@ impl EditorView {
461460

462461
/// Get highlight spans for selections in a document view.
463462
pub fn doc_selection_highlights(
464-
mode: Mode,
463+
editor: &Editor,
465464
doc: &Document,
466465
view: &View,
467466
theme: &Theme,
468-
cursor_shape_config: &CursorShapeConfig,
469467
is_terminal_focused: bool,
470468
) -> OverlayHighlights {
471469
let text = doc.text().slice(..);
472470
let selection = doc.selection(view.id);
473471
let primary_idx = selection.primary_index();
474472

473+
let mode = editor.mode();
474+
let cursor_shape_config = &editor.config().cursor_shape;
475475
let cursorkind = cursor_shape_config.from_mode(mode);
476476
let cursor_is_block = cursorkind == CursorKind::Block;
477477

478+
// Skip rendering secondary cursors when kitty protocol handles them
479+
let skip_secondary_cursors = editor.kitty_multi_cursor_support && !cursor_is_block;
480+
478481
let selection_scope = theme
479482
.find_highlight_exact("ui.selection")
480483
.expect("could not find `ui.selection` scope in the theme!");
@@ -514,13 +517,10 @@ impl EditorView {
514517

515518
// Special-case: cursor at end of the rope.
516519
if range.head == range.anchor && range.head == text.len_chars() {
517-
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
518-
// Bar and underline cursors are drawn by the terminal
519-
// BUG: If the editor area loses focus while having a bar or
520-
// underline cursor (eg. when a regex prompt has focus) then
521-
// the primary cursor will be invisible. This doesn't happen
522-
// with block cursors since we manually draw *all* cursors.
523-
spans.push((cursor_scope, range.head..range.head + 1));
520+
if selection_is_primary || !skip_secondary_cursors {
521+
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
522+
spans.push((cursor_scope, range.head..range.head + 1));
523+
}
524524
}
525525
continue;
526526
}
@@ -537,17 +537,17 @@ impl EditorView {
537537
cursor_start
538538
};
539539
spans.push((selection_scope, range.anchor..selection_end));
540-
// add block cursors
541-
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
542-
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
540+
if (selection_is_primary || !skip_secondary_cursors)
541+
&& (!selection_is_primary || (cursor_is_block && is_terminal_focused))
542+
{
543543
spans.push((cursor_scope, cursor_start..range.head));
544544
}
545545
} else {
546546
// Reverse case.
547547
let cursor_end = next_grapheme_boundary(text, range.head);
548-
// add block cursors
549-
// skip primary cursor if terminal is unfocused - terminal cursor is used in that case
550-
if !selection_is_primary || (cursor_is_block && is_terminal_focused) {
548+
if (selection_is_primary || !skip_secondary_cursors)
549+
&& (!selection_is_primary || (cursor_is_block && is_terminal_focused))
550+
{
551551
spans.push((cursor_scope, range.head..cursor_end));
552552
}
553553
// non block cursors look like they exclude the cursor

helix-tui/src/backend/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub trait Backend {
3737
fn show_cursor(&mut self, kind: CursorKind) -> Result<(), io::Error>;
3838
/// Sets the cursor to the given position
3939
fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>;
40+
/// Sets multiple cursors using terminal-specific protocols (e.g., kitty)
41+
fn set_multiple_cursors(&mut self, _cursors: &[(u16, u16)]) -> Result<(), io::Error> {
42+
Ok(())
43+
}
4044
/// Clears the terminal
4145
fn clear(&mut self) -> Result<(), io::Error>;
4246
/// Gets the size of the terminal in cells

helix-tui/src/backend/termina.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ fn vte_version() -> Option<usize> {
4949
#[derive(Debug, Default, Clone, Copy)]
5050
struct Capabilities {
5151
kitty_keyboard: KittyKeyboardSupport,
52+
kitty_multi_cursor: bool,
5253
synchronized_output: bool,
5354
true_color: bool,
5455
extended_underlines: bool,
@@ -125,7 +126,6 @@ impl TerminaBackend {
125126
) -> io::Result<(Capabilities, String)> {
126127
use std::time::{Duration, Instant};
127128

128-
// Colibri "midnight"
129129
const TEST_COLOR: RgbColor = RgbColor::new(59, 34, 76);
130130

131131
terminal.enter_raw_mode()?;
@@ -238,6 +238,12 @@ impl TerminaBackend {
238238
log::debug!("terminfo could not be read, using default cursor reset escape sequence: {reset_cursor_command:?}");
239239
}
240240

241+
// Detect kitty multi-cursor support (available in kitty >= 0.43.0)
242+
if matches!(term_program().as_deref(), Some("kitty") | Some("xterm-kitty")) {
243+
capabilities.kitty_multi_cursor = true;
244+
log::debug!("Detected kitty terminal - enabling multi-cursor protocol support");
245+
}
246+
241247
terminal.enter_cooked_mode()?;
242248

243249
Ok((capabilities, reset_cursor_command))
@@ -544,6 +550,25 @@ impl Backend for TerminaBackend {
544550
self.flush()
545551
}
546552

553+
fn set_multiple_cursors(&mut self, cursors: &[(u16, u16)]) -> io::Result<()> {
554+
if !self.capabilities.kitty_multi_cursor {
555+
return Ok(());
556+
}
557+
558+
// Always clear existing cursors first
559+
write!(self.terminal, "\x1b[>0;4 q")?;
560+
561+
if !cursors.is_empty() {
562+
write!(self.terminal, "\x1b[>29")?; // Shape 29 = follow main cursor
563+
for (x, y) in cursors {
564+
write!(self.terminal, ";2:{}:{}", y + 1, x + 1)?; // 1-indexed coords
565+
}
566+
write!(self.terminal, " q")?;
567+
}
568+
569+
self.flush()
570+
}
571+
547572
fn clear(&mut self) -> io::Result<()> {
548573
self.start_synchronized_render()?;
549574
write!(
@@ -572,6 +597,12 @@ impl Backend for TerminaBackend {
572597
}
573598
}
574599

600+
impl TerminaBackend {
601+
pub fn supports_kitty_multi_cursor(&self) -> bool {
602+
self.capabilities.kitty_multi_cursor
603+
}
604+
}
605+
575606
impl Drop for TerminaBackend {
576607
fn drop(&mut self) {
577608
// Avoid resetting the terminal while panicking because we set a panic hook above in

helix-tui/src/terminal.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ where
234234
self.backend.set_cursor(x, y)
235235
}
236236

237+
pub fn set_multiple_cursors(&mut self, cursors: &[(u16, u16)]) -> io::Result<()> {
238+
self.backend.set_multiple_cursors(cursors)
239+
}
240+
237241
/// Clear the terminal and force a full redraw on the next draw call.
238242
pub fn clear(&mut self) -> io::Result<()> {
239243
self.backend.clear()?;

helix-view/src/editor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ pub struct Editor {
12191219

12201220
pub mouse_down_range: Option<Range>,
12211221
pub cursor_cache: CursorCache,
1222+
pub kitty_multi_cursor_support: bool,
12221223
}
12231224

12241225
pub type Motion = Box<dyn Fn(&mut Editor)>;
@@ -1340,6 +1341,7 @@ impl Editor {
13401341
handlers,
13411342
mouse_down_range: None,
13421343
cursor_cache: CursorCache::default(),
1344+
kitty_multi_cursor_support: false,
13431345
}
13441346
}
13451347

0 commit comments

Comments
 (0)