diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index b9360b525bc8..3a7811dbf28d 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -5,21 +5,16 @@ pub use helix_stdx::range::Range; use serde::{Deserialize, Serialize}; /// Describes the severity level of a [`Diagnostic`]. -#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum Severity { + #[default] Hint, Info, Warning, Error, } -impl Default for Severity { - fn default() -> Self { - Self::Hint - } -} - #[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)] pub enum NumberOrString { Number(i32), diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index 4cbb5746491a..6ec9d9798e97 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -273,7 +273,7 @@ impl Drop for GraphemeStr<'_> { if self.len & Self::MASK_OWNED != 0 { // free allocation unsafe { - drop(Box::from_raw(slice::from_raw_parts_mut( + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( self.ptr.as_ptr(), self.compute_len(), ))); diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs index ab733f931238..6d4b0c3e50e6 100644 --- a/helix-core/tests/indent.rs +++ b/helix-core/tests/indent.rs @@ -222,7 +222,7 @@ fn test_treesitter_indent( .unwrap() .to_string(&indent_style, tab_width); assert!( - line.get_slice(..pos).map_or(false, |s| s == suggested_indent), + line.get_slice(..pos).is_some_and(|s| s == suggested_indent), "Wrong indentation for file {:?} on line {}:\n\"{}\" (original line)\n\"{}\" (suggested indentation)\n", test_name, i+1, 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/keymap.rs b/helix-term/src/keymap.rs index d8227b500ee7..ea2c52b1c470 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -483,14 +483,11 @@ mod tests { .len() > 1 ); - assert!( - merged_keyamp - .get(&Mode::Insert) - .and_then(|key_trie| key_trie.node()) - .unwrap() - .len() - > 0 - ); + assert!(!merged_keyamp + .get(&Mode::Insert) + .and_then(|key_trie| key_trie.node()) + .unwrap() + .is_empty()); } #[test] diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107d796..8b57c66c8b82 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,12 +517,9 @@ 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. + if (selection_is_primary || !skip_secondary_cursors) + && (!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..02361f05598c 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()?; @@ -149,13 +149,15 @@ impl TerminaBackend { // If we only receive the device attributes then we know it is not. write!( terminal, - "{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}{}", // Synchronized output Csi::Mode(csi::Mode::QueryDecPrivateMode(csi::DecPrivateMode::Code( csi::DecPrivateModeCode::SynchronizedOutput ))), // Mode 2031 theme updates. Query the current theme. Csi::Mode(csi::Mode::QueryTheme), + // Kitty multi-cursor protocol support query + Csi::Cursor(csi::Cursor::QueryCursorShape), // True color and while we're at it, extended underlines: // Csi::Sgr(csi::Sgr::Background(TEST_COLOR.into())), @@ -199,7 +201,21 @@ impl TerminaBackend { capabilities.extended_underlines = sgrs.contains(&csi::Sgr::UnderlineColor(TEST_COLOR.into())); } - _ => (), + Event::Csi(Csi::Cursor(csi::Cursor::QueryCursorShape)) => { + // Empty response to the query still means the protocol is supported + capabilities.kitty_multi_cursor = true; + log::debug!("Detected kitty multi-cursor support via protocol query"); + } + Event::Csi(Csi::Cursor(csi::Cursor::CursorShapeQueryResponse(shapes))) => { + capabilities.kitty_multi_cursor = true; + log::debug!( + "Detected kitty multi-cursor support via protocol query. Supported shapes: {:?}", + shapes + ); + } + event => { + log::trace!("Unhandled capability detection event: {event:?}"); + } } } @@ -544,6 +560,38 @@ 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, + "{}", + Csi::Cursor(csi::Cursor::ClearSecondaryCursors) + )?; + + if !cursors.is_empty() { + write!( + self.terminal, + "{}", + Csi::Cursor(csi::Cursor::SetMultipleCursors { + shape: csi::MultiCursorShape::FollowMainCursor, + positions: cursors + .iter() + .map(|(x, y)| ( + OneBased::from_zero_based(*y), + OneBased::from_zero_based(*x), + )) + .collect(), + }) + )?; + } + + self.flush() + } + fn clear(&mut self) -> io::Result<()> { self.start_synchronized_render()?; write!( @@ -572,6 +620,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, } } diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index 3a4eee3db7ba..807702c00c09 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -5,11 +5,12 @@ use std::{ str::FromStr, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] /// UNSTABLE pub enum CursorKind { /// █ + #[default] Block, /// | Bar, @@ -19,12 +20,6 @@ pub enum CursorKind { Hidden, } -impl Default for CursorKind { - fn default() -> Self { - Self::Block - } -} - #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub struct Margin { pub horizontal: u16, diff --git a/helix-view/src/tree.rs b/helix-view/src/tree.rs index d596b35a7554..c2643eded2d2 100644 --- a/helix-view/src/tree.rs +++ b/helix-view/src/tree.rs @@ -957,8 +957,7 @@ mod test { assert_eq!(10, tree.views().count()); assert_eq!( - std::iter::repeat(7) - .take(9) + std::iter::repeat_n(7, 9) .chain(Some(8)) // Rounding in `recalculate`. .collect::>(), tree.views()