diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db6499080..49b8d0f6bc23 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -124,6 +124,12 @@ impl Application { let backend = TestBackend::new(120, 150); let theme_mode = backend.get_theme_mode(); + + #[cfg(all(not(windows), not(feature = "integration")))] + let kitty_multi_cursor_support = backend.supports_kitty_multi_cursor(); + #[cfg(any(windows, feature = "integration"))] + let kitty_multi_cursor_support = false; + let terminal = Terminal::new(backend)?; let area = terminal.size(); let mut compositor = Compositor::new(area); @@ -138,6 +144,8 @@ impl Application { })), handlers, ); + editor.kitty_multi_cursor_support = kitty_multi_cursor_support; + Self::load_configured_theme( &mut editor, &config.load(), @@ -298,7 +306,43 @@ impl Application { self.editor.cursor_cache.reset(); let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); + + use helix_view::graphics::CursorKind; + let secondary_cursors = if !matches!(kind, CursorKind::Block | CursorKind::Hidden) { + self.get_secondary_cursor_positions() + } else { + Vec::new() + }; + self.terminal.draw(pos, kind).unwrap(); + // Always update kitty cursors (clears if empty, sets if not) + self.terminal + .set_multiple_cursors(&secondary_cursors) + .unwrap(); + } + + fn get_secondary_cursor_positions(&self) -> Vec<(u16, u16)> { + use helix_view::current_ref; + + let (view, doc) = current_ref!(&self.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let primary_idx = selection.primary_index(); + let inner = view.inner_area(doc); + + selection + .iter() + .enumerate() + .filter(|(idx, _)| *idx != primary_idx) + .filter_map(|(_, range)| { + let cursor = range.cursor(text); + view.screen_coords_at_pos(doc, text, cursor).map(|pos| { + let x = (pos.col + inner.x as usize) as u16; + let y = (pos.row + inner.y as usize) as u16; + (x, y) + }) + }) + .collect() } pub async fn event_loop(&mut self, input_stream: &mut S) diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107d796..51e0f6707da4 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -25,7 +25,7 @@ use helix_core::{ use helix_view::{ annotations::diagnostics::DiagnosticFilter, document::{Mode, SCRATCH_BUFFER_NAME}, - editor::{CompleteAction, CursorShapeConfig}, + editor::CompleteAction, graphics::{Color, CursorKind, Modifier, Rect, Style}, input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, @@ -146,11 +146,10 @@ impl EditorView { overlays.push(tabstops); } overlays.push(Self::doc_selection_highlights( - editor.mode(), + editor, doc, view, theme, - &config.cursor_shape, self.terminal_focused, )); if let Some(overlay) = Self::highlight_focused_view_elements(view, doc, theme) { @@ -461,20 +460,24 @@ impl EditorView { /// Get highlight spans for selections in a document view. pub fn doc_selection_highlights( - mode: Mode, + editor: &Editor, doc: &Document, view: &View, theme: &Theme, - cursor_shape_config: &CursorShapeConfig, is_terminal_focused: bool, ) -> OverlayHighlights { let text = doc.text().slice(..); let selection = doc.selection(view.id); let primary_idx = selection.primary_index(); + let mode = editor.mode(); + let cursor_shape_config = &editor.config().cursor_shape; let cursorkind = cursor_shape_config.from_mode(mode); let cursor_is_block = cursorkind == CursorKind::Block; + // Skip rendering secondary cursors when kitty protocol handles them + let skip_secondary_cursors = editor.kitty_multi_cursor_support && !cursor_is_block; + let selection_scope = theme .find_highlight_exact("ui.selection") .expect("could not find `ui.selection` scope in the theme!"); @@ -514,13 +517,10 @@ impl EditorView { // Special-case: cursor at end of the rope. if range.head == range.anchor && range.head == text.len_chars() { - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { - // Bar and underline cursors are drawn by the terminal - // BUG: If the editor area loses focus while having a bar or - // underline cursor (eg. when a regex prompt has focus) then - // the primary cursor will be invisible. This doesn't happen - // with block cursors since we manually draw *all* cursors. - spans.push((cursor_scope, range.head..range.head + 1)); + if selection_is_primary || !skip_secondary_cursors { + if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + spans.push((cursor_scope, range.head..range.head + 1)); + } } continue; } @@ -537,17 +537,17 @@ impl EditorView { cursor_start }; spans.push((selection_scope, range.anchor..selection_end)); - // add block cursors - // skip primary cursor if terminal is unfocused - terminal cursor is used in that case - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + if (selection_is_primary || !skip_secondary_cursors) + && (!selection_is_primary || (cursor_is_block && is_terminal_focused)) + { spans.push((cursor_scope, cursor_start..range.head)); } } else { // Reverse case. let cursor_end = next_grapheme_boundary(text, range.head); - // add block cursors - // skip primary cursor if terminal is unfocused - terminal cursor is used in that case - if !selection_is_primary || (cursor_is_block && is_terminal_focused) { + if (selection_is_primary || !skip_secondary_cursors) + && (!selection_is_primary || (cursor_is_block && is_terminal_focused)) + { spans.push((cursor_scope, range.head..cursor_end)); } // non block cursors look like they exclude the cursor diff --git a/helix-tui/src/backend/mod.rs b/helix-tui/src/backend/mod.rs index 368a1b660d44..16b02270eb02 100644 --- a/helix-tui/src/backend/mod.rs +++ b/helix-tui/src/backend/mod.rs @@ -37,6 +37,10 @@ pub trait Backend { fn show_cursor(&mut self, kind: CursorKind) -> Result<(), io::Error>; /// Sets the cursor to the given position fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; + /// Sets multiple cursors using terminal-specific protocols (e.g., kitty) + fn set_multiple_cursors(&mut self, _cursors: &[(u16, u16)]) -> Result<(), io::Error> { + Ok(()) + } /// Clears the terminal fn clear(&mut self) -> Result<(), io::Error>; /// Gets the size of the terminal in cells diff --git a/helix-tui/src/backend/termina.rs b/helix-tui/src/backend/termina.rs index ad1c7c6835b3..fc879bf1a0a9 100644 --- a/helix-tui/src/backend/termina.rs +++ b/helix-tui/src/backend/termina.rs @@ -49,6 +49,7 @@ fn vte_version() -> Option { #[derive(Debug, Default, Clone, Copy)] struct Capabilities { kitty_keyboard: KittyKeyboardSupport, + kitty_multi_cursor: bool, synchronized_output: bool, true_color: bool, extended_underlines: bool, @@ -125,7 +126,6 @@ impl TerminaBackend { ) -> io::Result<(Capabilities, String)> { use std::time::{Duration, Instant}; - // Colibri "midnight" const TEST_COLOR: RgbColor = RgbColor::new(59, 34, 76); terminal.enter_raw_mode()?; @@ -238,6 +238,15 @@ impl TerminaBackend { log::debug!("terminfo could not be read, using default cursor reset escape sequence: {reset_cursor_command:?}"); } + // Detect kitty multi-cursor support (available in kitty >= 0.43.0) + if matches!( + term_program().as_deref(), + Some("kitty") | Some("xterm-kitty") + ) { + capabilities.kitty_multi_cursor = true; + log::debug!("Detected kitty terminal - enabling multi-cursor protocol support"); + } + terminal.enter_cooked_mode()?; Ok((capabilities, reset_cursor_command)) @@ -544,6 +553,25 @@ impl Backend for TerminaBackend { self.flush() } + fn set_multiple_cursors(&mut self, cursors: &[(u16, u16)]) -> io::Result<()> { + if !self.capabilities.kitty_multi_cursor { + return Ok(()); + } + + // Always clear existing cursors first + write!(self.terminal, "\x1b[>0;4 q")?; + + if !cursors.is_empty() { + write!(self.terminal, "\x1b[>29")?; // Shape 29 = follow main cursor + for (x, y) in cursors { + write!(self.terminal, ";2:{}:{}", y + 1, x + 1)?; // 1-indexed coords + } + write!(self.terminal, " q")?; + } + + self.flush() + } + fn clear(&mut self) -> io::Result<()> { self.start_synchronized_render()?; write!( @@ -572,6 +600,12 @@ impl Backend for TerminaBackend { } } +impl TerminaBackend { + pub fn supports_kitty_multi_cursor(&self) -> bool { + self.capabilities.kitty_multi_cursor + } +} + impl Drop for TerminaBackend { fn drop(&mut self) { // Avoid resetting the terminal while panicking because we set a panic hook above in diff --git a/helix-tui/src/terminal.rs b/helix-tui/src/terminal.rs index 5e4007fc4b3c..113df9f92528 100644 --- a/helix-tui/src/terminal.rs +++ b/helix-tui/src/terminal.rs @@ -234,6 +234,10 @@ where self.backend.set_cursor(x, y) } + pub fn set_multiple_cursors(&mut self, cursors: &[(u16, u16)]) -> io::Result<()> { + self.backend.set_multiple_cursors(cursors) + } + /// Clear the terminal and force a full redraw on the next draw call. pub fn clear(&mut self) -> io::Result<()> { self.backend.clear()?; diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7f8cff9c3e44..d475c8f24bdd 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1219,6 +1219,7 @@ pub struct Editor { pub mouse_down_range: Option, pub cursor_cache: CursorCache, + pub kitty_multi_cursor_support: bool, } pub type Motion = Box; @@ -1340,6 +1341,7 @@ impl Editor { handlers, mouse_down_range: None, cursor_cache: CursorCache::default(), + kitty_multi_cursor_support: false, } }