Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions helix-core/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Comment on lines +8 to -22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this and other changes (like slice_from_raw_parts_mut below) are unrelated and are probably lints from newer versions of Rust. If you install Rust using rustup it will respect the version set in rust-toolchain.toml. We keep an MSRV that is intentionally older, see https://github.com/helix-editor/helix/blob/master/docs/CONTRIBUTING.md#minimum-stable-rust-version-msrv-policy

These extra changes should be reset

#[derive(Debug, Eq, Hash, PartialEq, Clone, Deserialize, Serialize)]
pub enum NumberOrString {
Number(i32),
Expand Down
2 changes: 1 addition & 1 deletion helix-core/src/graphemes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)));
Expand Down
2 changes: 1 addition & 1 deletion helix-core/tests/indent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions helix-term/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -138,6 +144,8 @@ impl Application {
})),
handlers,
);
editor.kitty_multi_cursor_support = kitty_multi_cursor_support;

Self::load_configured_theme(
&mut editor,
&config.load(),
Expand Down Expand Up @@ -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<S>(&mut self, input_stream: &mut S)
Expand Down
13 changes: 5 additions & 8 deletions helix-term/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
34 changes: 17 additions & 17 deletions helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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!");
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions helix-tui/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion helix-tui/src/backend/termina.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn vte_version() -> Option<usize> {
#[derive(Debug, Default, Clone, Copy)]
struct Capabilities {
kitty_keyboard: KittyKeyboardSupport,
kitty_multi_cursor: bool,
synchronized_output: bool,
true_color: bool,
extended_underlines: bool,
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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")

@Zapeth Zapeth Nov 16, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this detection should be changed to how its described in the kitty docs, otherwise other terminals supporting this feature will not be able to use it out of the box -> https://github.com/kovidgoyal/kitty/blob/master/docs/multiple-cursors-protocol.rst#querying-for-support

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe Termina lacks the capability at the moment. I'll work on a pull request there to add it.

) {
capabilities.kitty_multi_cursor = true;
log::debug!("Detected kitty terminal - enabling multi-cursor protocol support");
}

terminal.enter_cooked_mode()?;

Ok((capabilities, reset_cursor_command))
Expand Down Expand Up @@ -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")?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These commands belong in Termina too, we shouldn't be writing escape sequences as literal text

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, I'll update the termina pull request

}

self.flush()
}

fn clear(&mut self) -> io::Result<()> {
self.start_synchronized_render()?;
write!(
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions helix-tui/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
2 changes: 2 additions & 0 deletions helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,7 @@ pub struct Editor {

pub mouse_down_range: Option<Range>,
pub cursor_cache: CursorCache,
pub kitty_multi_cursor_support: bool,
}

pub type Motion = Box<dyn Fn(&mut Editor)>;
Expand Down Expand Up @@ -1340,6 +1341,7 @@ impl Editor {
handlers,
mouse_down_range: None,
cursor_cache: CursorCache::default(),
kitty_multi_cursor_support: false,
}
}

Expand Down
9 changes: 2 additions & 7 deletions helix-view/src/graphics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions helix-view/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(),
tree.views()
Expand Down