aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-05 21:13:02 +0100
committerGitHub <noreply@github.com>2026-03-05 21:13:02 +0100
commit903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch)
tree9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb /zellij-server
parentbuild: make test mirror build command for plugins (#4801) (diff)
downloadsl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.gz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.xz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.zip
Click file paths to open in editor, allow plugins to change styles and hover of viewport (#4802)
* initial implementation * implement hover * add styles and foreground/background emphases, fix upsert hover collisions * initial implementation of link plugin * suppress highlighting + clicks on unfocused and mouse mode panes * also make italic * fix env var and ~ expansions * fix regex issues and add defensive validations * fix tests and add plugin assets * add tests * refactor all the things * add pr
Diffstat (limited to 'zellij-server')
-rw-r--r--zellij-server/src/output/mod.rs59
-rw-r--r--zellij-server/src/panes/grid.rs449
-rw-r--r--zellij-server/src/panes/terminal_pane.rs34
-rw-r--r--zellij-server/src/panes/unit/grid_tests.rs299
-rw-r--r--zellij-server/src/plugins/mod.rs26
-rw-r--r--zellij-server/src/plugins/unit/plugin_tests.rs271
-rw-r--r--zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap7
-rw-r--r--zellij-server/src/plugins/wasm_bridge.rs5
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs41
-rw-r--r--zellij-server/src/screen.rs52
-rw-r--r--zellij-server/src/tab/mod.rs50
-rw-r--r--zellij-server/src/tab/mouse_handler.rs79
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap24
-rw-r--r--zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap24
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs467
17 files changed, 1890 insertions, 45 deletions
diff --git a/zellij-server/src/output/mod.rs b/zellij-server/src/output/mod.rs
index 9297be11..5fd4ff2d 100644
--- a/zellij-server/src/output/mod.rs
+++ b/zellij-server/src/output/mod.rs
@@ -40,23 +40,44 @@ fn vte_hide_cursor_instruction(vte_output: &mut String) -> Result<()> {
write!(vte_output, "\u{1b}[?25l").context("failed to execute VTE instruction to hide cursor")
}
+/// A selection region with associated styling for highlights and text selection.
+#[derive(Debug, Clone, Copy)]
+pub struct HighlightSelection {
+ pub selection: Selection,
+ pub bg: Option<AnsiCode>,
+ pub fg: Option<AnsiCode>,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
fn adjust_styles_for_possible_selection(
- chunk_selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>,
+ chunk_selection_and_colors: &[HighlightSelection],
character_styles: CharacterStyles,
chunk_y: usize,
chunk_width: usize,
) -> CharacterStyles {
chunk_selection_and_colors
.iter()
- .find(|(selection, _background_color, _foreground_color)| {
- selection.contains(chunk_y, chunk_width)
- })
- .map(|(_selection, background_color, foreground_color)| {
- let mut character_styles = character_styles.background(Some(*background_color));
- if let Some(foreground_color) = foreground_color {
- character_styles = character_styles.foreground(Some(*foreground_color));
+ .find(|hs| hs.selection.contains(chunk_y, chunk_width))
+ .map(|hs| {
+ let mut styles = character_styles;
+ if let Some(bg) = hs.bg {
+ styles = styles.background(Some(bg));
+ }
+ if let Some(fg) = hs.fg {
+ styles = styles.foreground(Some(fg));
+ }
+ if hs.bold {
+ styles = styles.bold(Some(AnsiCode::On));
+ }
+ if hs.italic {
+ styles = styles.italic(Some(AnsiCode::On));
+ }
+ if hs.underline {
+ styles = styles.underline(Some(AnsiCode::Underline(None)));
}
- character_styles
+ styles
})
.unwrap_or(character_styles)
}
@@ -982,9 +1003,9 @@ pub struct CharacterChunk {
pub x: usize,
pub y: usize,
pub changed_colors: Option<[Option<AnsiCode>; 256]>,
- selection_and_colors: Vec<(Selection, AnsiCode, Option<AnsiCode>)>, // Selection, background color, optional foreground color
pub pane_default_fg: Option<AnsiCode>,
pub pane_default_bg: Option<AnsiCode>,
+ selection_and_colors: Vec<HighlightSelection>,
}
#[derive(Debug, Clone, Copy, Default)]
@@ -1009,21 +1030,17 @@ impl CharacterChunk {
}
pub fn add_selection_and_colors(
&mut self,
- selection: Selection,
- background_color: AnsiCode,
- foreground_color: Option<AnsiCode>,
+ highlight: HighlightSelection,
offset_x: usize,
offset_y: usize,
) {
- self.selection_and_colors.push((
- selection.offset(offset_x, offset_y),
- background_color,
- foreground_color,
- ));
+ self.selection_and_colors.push(HighlightSelection {
+ selection: highlight.selection.offset(offset_x, offset_y),
+ ..highlight
+ });
}
- pub fn selection_and_colors(&self) -> Vec<(Selection, AnsiCode, Option<AnsiCode>)> {
- // Selection, background color, optional foreground color
- self.selection_and_colors.clone()
+ pub fn selection_and_colors(&self) -> &[HighlightSelection] {
+ &self.selection_and_colors
}
pub fn add_changed_colors(&mut self, changed_colors: Option<[Option<AnsiCode>; 256]>) {
self.changed_colors = changed_colors;
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index c7eee368..cb629ac9 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -1,9 +1,11 @@
use super::sixel::{PixelRect, SixelGrid, SixelImageStore};
use std::borrow::Cow;
use std::cell::RefCell;
+use std::collections::BTreeMap;
use std::collections::HashMap;
use std::rc::Rc;
-use zellij_utils::data::Style;
+use unicode_width::UnicodeWidthChar;
+use zellij_utils::data::{HighlightStyle, RegexHighlight, Style};
use zellij_utils::errors::prelude::*;
use std::{
@@ -28,7 +30,7 @@ pub const MAX_TITLE_STACK_SIZE: usize = 1000;
use vte::{Params, Perform};
use zellij_utils::{consts::VERSION, shared::version_number};
-use crate::output::{CharacterChunk, OutputBuffer, SixelImageChunk};
+use crate::output::{CharacterChunk, HighlightSelection, OutputBuffer, SixelImageChunk};
use crate::panes::alacritty_functions::{parse_number, xparse_color};
use crate::panes::hyperlink_tracker::HyperlinkTracker;
use crate::panes::link_handler::LinkHandler;
@@ -349,6 +351,128 @@ fn utf8_mouse_coordinates(column: usize, line: isize) -> Vec<u8> {
coordinates
}
+/// Find the canonical root row for a logical line group containing `row_idx`,
+/// collect the non-canonical tail rows, and build the concatenated text with
+/// a byte-offset boundary table.
+///
+/// Returns `(canonical_idx, group_len, text, boundaries)` where `group_len`
+/// is the total number of viewport rows in the group (1 + tail count).
+/// Returns `None` if `row_idx` is out of bounds.
+fn collect_and_build_logical_line(
+ viewport: &VecDeque<Row>,
+ row_idx: usize,
+) -> Option<(usize, usize, String, Vec<(usize, usize)>)> {
+ if row_idx >= viewport.len() {
+ return None;
+ }
+ // Walk backward to find the canonical root.
+ let mut canonical = row_idx;
+ while canonical > 0 {
+ match viewport.get(canonical) {
+ Some(r) if !r.is_canonical => canonical -= 1,
+ _ => break,
+ }
+ }
+ let canonical_row = viewport.get(canonical)?;
+ // Collect non-canonical tail rows.
+ let mut tail_count = 0;
+ loop {
+ let tail_idx = canonical + tail_count + 1;
+ match viewport.get(tail_idx) {
+ Some(r) if !r.is_canonical => tail_count += 1,
+ _ => break,
+ }
+ }
+ let group_len = 1 + tail_count;
+ // Build concatenated text and boundary table.
+ let mut text = String::new();
+ let mut boundaries: Vec<(usize, usize)> = Vec::with_capacity(group_len);
+ boundaries.push((canonical, 0));
+ for ch in &canonical_row.columns {
+ text.push(ch.character);
+ }
+ for i in 0..tail_count {
+ let idx = canonical + 1 + i;
+ boundaries.push((idx, text.len()));
+ if let Some(row) = viewport.get(idx) {
+ for ch in &row.columns {
+ text.push(ch.character);
+ }
+ }
+ }
+ Some((canonical, group_len, text, boundaries))
+}
+
+/// Map a byte offset in the concatenated logical-line string back to
+/// (viewport_row_idx, display_column).
+///
+/// display_column is the sum of character.width() for all characters
+/// before the target character in that row — NOT a char count or byte count.
+/// This correctly handles wide characters (CJK, emoji).
+///
+/// `boundaries` is the table produced by `collect_and_build_logical_line`.
+/// `viewport` is `&self.viewport`.
+fn byte_offset_to_display_col(
+ byte_offset: usize,
+ boundaries: &[(usize, usize)],
+ viewport: &VecDeque<Row>,
+) -> Option<(usize, usize)> {
+ if boundaries.is_empty() {
+ return None;
+ }
+ // Find which row the byte offset falls in (last boundary whose byte_start <= offset).
+ let boundary_idx = boundaries
+ .partition_point(|&(_, byte_start)| byte_start <= byte_offset)
+ .saturating_sub(1);
+ let &(row_idx, row_byte_start) = boundaries.get(boundary_idx)?;
+ let intra_byte_offset = byte_offset - row_byte_start;
+
+ // Count display columns up to (but not including) the character at intra_byte_offset.
+ let row = viewport.get(row_idx)?;
+ let mut display_col = 0usize;
+ let mut bytes_seen = 0usize;
+ for ch in &row.columns {
+ if bytes_seen >= intra_byte_offset {
+ break;
+ }
+ bytes_seen += ch.character.len_utf8();
+ display_col += ch.character.width().unwrap_or(1);
+ }
+ Some((row_idx, display_col))
+}
+
+/// Convert a regex match into a `Selection` spanning the matched display region.
+/// Returns `None` if the boundary table or viewport lookup fails.
+fn match_to_selection(
+ mat: &regex::Match,
+ boundaries: &[(usize, usize)],
+ viewport: &VecDeque<Row>,
+) -> Option<(Selection, usize, usize, usize, usize)> {
+ let (start_row, start_col) = byte_offset_to_display_col(mat.start(), boundaries, viewport)?;
+ let (end_row, end_col) = byte_offset_to_display_col(mat.end(), boundaries, viewport)?;
+ let mut sel = Selection::default();
+ sel.set_start_and_end_positions(
+ Position::new(start_row as i32, start_col as u16),
+ Position::new(end_row as i32, end_col as u16),
+ );
+ Some((sel, start_row, start_col, end_row, end_col))
+}
+
+/// Check whether a (row, col) position falls within a display span.
+/// The span is inclusive at start and exclusive at end.
+fn position_in_span(
+ row: usize,
+ col: usize,
+ start_row: usize,
+ start_col: usize,
+ end_row: usize,
+ end_col: usize,
+) -> bool {
+ let after_start = row > start_row || (row == start_row && col >= start_col);
+ let before_end = row < end_row || (row == end_row && col < end_col);
+ after_start && before_end
+}
+
#[derive(Clone)]
pub struct Grid {
pub(crate) lines_above: VecDeque<Row>,
@@ -406,6 +530,9 @@ pub struct Grid {
hyperlink_tracker: HyperlinkTracker,
pub pane_default_fg: Option<AnsiCode>,
pub pane_default_bg: Option<AnsiCode>,
+ pub plugin_highlights: HashMap<u32, Vec<(String, CompiledHighlight)>>,
+ // key: plugin_id (u32), inner vec: (pattern, compiled) pairs
+ pub hover_position: Option<Position>, // pane-relative cursor cell; None when outside pane
}
impl Grid {
@@ -431,6 +558,26 @@ fn ansi_code_to_color_string(code: AnsiCode) -> Option<String> {
}
}
+/// A compiled highlight entry for one plugin/pattern combination.
+#[derive(Clone)]
+pub struct CompiledHighlight {
+ pub regex: regex::Regex,
+ pub fg: Option<AnsiCode>,
+ pub bg: Option<AnsiCode>,
+ pub context: BTreeMap<String, String>,
+ pub on_hover: bool,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
+impl CompiledHighlight {
+ /// Whether this highlight would produce any visible styling change.
+ pub fn has_visual_effect(&self) -> bool {
+ self.bg.is_some() || self.fg.is_some() || self.bold || self.italic || self.underline
+ }
+}
+
const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold
#[derive(Clone, Debug, Default)]
@@ -483,7 +630,7 @@ impl Default for MouseMode {
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub enum MouseTracking {
Off,
Normal,
@@ -544,6 +691,49 @@ impl Debug for Grid {
}
}
+fn resolve_highlight_colors(
+ style_decl: &HighlightStyle,
+ style: &Style,
+) -> (Option<AnsiCode>, Option<AnsiCode>) {
+ let palette_to_ansi = |c: PaletteColor| -> AnsiCode {
+ match c {
+ PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb),
+ PaletteColor::EightBit(i) => AnsiCode::ColorIndex(i),
+ }
+ };
+ let tu = &style.colors.text_unselected;
+ let emphasis = [tu.emphasis_0, tu.emphasis_1, tu.emphasis_2, tu.emphasis_3];
+ match style_decl {
+ HighlightStyle::None => (None, None),
+ HighlightStyle::Emphasis0 => (Some(palette_to_ansi(emphasis[0])), None),
+ HighlightStyle::Emphasis1 => (Some(palette_to_ansi(emphasis[1])), None),
+ HighlightStyle::Emphasis2 => (Some(palette_to_ansi(emphasis[2])), None),
+ HighlightStyle::Emphasis3 => (Some(palette_to_ansi(emphasis[3])), None),
+ HighlightStyle::BackgroundEmphasis0 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[0])),
+ ),
+ HighlightStyle::BackgroundEmphasis1 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[1])),
+ ),
+ HighlightStyle::BackgroundEmphasis2 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[2])),
+ ),
+ HighlightStyle::BackgroundEmphasis3 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[3])),
+ ),
+ HighlightStyle::CustomRgb { fg, bg } => {
+ (fg.map(AnsiCode::RgbCode), bg.map(AnsiCode::RgbCode))
+ },
+ HighlightStyle::CustomIndex { fg, bg } => {
+ (fg.map(AnsiCode::ColorIndex), bg.map(AnsiCode::ColorIndex))
+ },
+ }
+}
+
impl Grid {
pub fn new(
rows: usize,
@@ -621,6 +811,8 @@ impl Grid {
hyperlink_tracker: HyperlinkTracker::new(),
pane_default_fg: None,
pane_default_bg: None,
+ plugin_highlights: HashMap::new(),
+ hover_position: None,
}
}
pub fn render_full_viewport(&mut self) {
@@ -1199,6 +1391,9 @@ impl Grid {
let mut raw_vte_output = String::new();
let (mut character_chunks, sixel_image_chunks) = self.read_changes(content_x, content_y);
+
+ let plugin_highlight_selections = self.compute_plugin_highlight_selections();
+
for character_chunk in character_chunks.iter_mut() {
character_chunk.add_changed_colors(self.changed_colors);
character_chunk.add_pane_defaults(self.pane_default_fg, self.pane_default_bg);
@@ -1216,9 +1411,14 @@ impl Grid {
};
character_chunk.add_selection_and_colors(
- self.selection,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: self.selection,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
@@ -1246,15 +1446,29 @@ impl Grid {
PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col),
};
character_chunk.add_selection_and_colors(
- *res,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: *res,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
}
}
}
+ // Apply pre-computed plugin highlight selections to this chunk.
+ for hs in &plugin_highlight_selections {
+ if hs
+ .selection
+ .contains_row(character_chunk.y.saturating_sub(content_y))
+ {
+ character_chunk.add_selection_and_colors(*hs, content_x, content_y);
+ }
+ }
}
return Ok(Some((
character_chunks,
@@ -1851,6 +2065,223 @@ impl Grid {
fn set_preceding_character(&mut self, terminal_character: TerminalCharacter) {
self.preceding_char = Some(terminal_character);
}
+ /// Called by the server-side handler for SetPaneRegexHighlights.
+ /// Upserts highlights keyed by pattern string for the given plugin.
+ pub fn set_plugin_regex_highlights(
+ &mut self,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ let slot = self
+ .plugin_highlights
+ .entry(plugin_id)
+ .or_insert_with(Vec::new);
+ for h in highlights {
+ let (fg, bg) = resolve_highlight_colors(&h.style, style);
+ if let Ok(regex) = regex::Regex::new(&h.pattern) {
+ // Upsert: replace existing entry with same pattern and on_hover flag, or push new
+ let on_hover = h.on_hover;
+ if let Some(existing) = slot
+ .iter_mut()
+ .find(|(p, c)| p == &h.pattern && c.on_hover == on_hover)
+ {
+ existing.1 = CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ };
+ } else {
+ slot.push((
+ h.pattern,
+ CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ },
+ ));
+ }
+ } else {
+ log::warn!(
+ "Plugin {} supplied invalid regex: {:?}",
+ plugin_id,
+ h.pattern
+ );
+ }
+ }
+ self.output_buffer.update_all_lines();
+ }
+
+ /// Called by the server-side handler for ClearPaneHighlights.
+ pub fn clear_plugin_highlights(&mut self, plugin_id: u32) {
+ if self.plugin_highlights.remove(&plugin_id).is_some() {
+ self.output_buffer.update_all_lines();
+ }
+ }
+
+ /// Returns (plugin_id, pattern, matched_string, context) if the given
+ /// pane-relative position falls inside any plugin highlight match, or None.
+ /// Uses logical-line grouping so multi-line (wrapped) matches are detected.
+ pub fn plugin_highlight_at(
+ &self,
+ position: &Position,
+ ) -> Option<(u32, String, String, BTreeMap<String, String>)> {
+ let click_row = position.line.0 as usize;
+ let click_col = position.column.0 as usize;
+
+ let (_canonical, _group_len, logical_text, boundaries) =
+ collect_and_build_logical_line(&self.viewport, click_row)?;
+
+ for (plugin_id, pattern_map) in &self.plugin_highlights {
+ for (pattern, compiled) in pattern_map {
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((_sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ click_row, click_col, start_row, start_col, end_row, end_col,
+ ) {
+ return Some((
+ *plugin_id,
+ pattern.clone(),
+ mat.as_str().to_string(),
+ compiled.context.clone(),
+ ));
+ }
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub fn set_hover_position(&mut self, new_pos: Option<Position>) {
+ if self.hover_position == new_pos {
+ return;
+ }
+
+ // Mark the canonical group containing the old hover row dirty.
+ if let Some(old_pos) = self.hover_position {
+ self.mark_logical_line_dirty(old_pos.line.0 as usize);
+ }
+ // Mark the canonical group containing the new hover row dirty.
+ if let Some(new_pos_inner) = new_pos {
+ self.mark_logical_line_dirty(new_pos_inner.line.0 as usize);
+ }
+
+ self.hover_position = new_pos;
+ }
+
+ /// Mark all physical rows belonging to the logical line group that contains
+ /// `row_idx` as dirty in the output buffer.
+ fn mark_logical_line_dirty(&mut self, row_idx: usize) {
+ if let Some((canonical, group_len, _, _)) =
+ collect_and_build_logical_line(&self.viewport, row_idx)
+ {
+ for r in canonical..canonical + group_len {
+ self.output_buffer.update_line(r);
+ }
+ }
+ }
+
+ /// Pre-compute plugin highlight selections across all logical line groups in
+ /// the viewport. Hover highlights are emitted first so they take priority
+ /// when the cursor overlaps a match; non-hover highlights follow.
+ fn compute_plugin_highlight_selections(&self) -> Vec<HighlightSelection> {
+ if self.plugin_highlights.is_empty() {
+ return vec![];
+ }
+ let mut selections = Vec::new();
+ let viewport_len = self.viewport.len();
+ let mut ridx = 0;
+ while ridx < viewport_len {
+ let (_canonical, group_len, logical_text, boundaries) =
+ match collect_and_build_logical_line(&self.viewport, ridx) {
+ Some(v) => v,
+ None => break,
+ };
+
+ // Hover highlights are pushed first so that `.find()` in
+ // `adjust_styles_for_possible_selection` returns the hover
+ // style when the cursor overlaps the match. They are suppressed
+ // when mouse tracking is active (events pass through to the app)
+ // and on unfocused panes (hover_position is not set for those).
+ if self.mouse_tracking == MouseTracking::Off {
+ if let Some(hover_pos) = self.hover_position {
+ let hover_row = hover_pos.line.0 as usize;
+ let group_end_row = ridx + group_len - 1;
+ if hover_row >= ridx && hover_row <= group_end_row {
+ let hover_col = hover_pos.column.0;
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if !compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ hover_row, hover_col, start_row, start_col, end_row,
+ end_col,
+ ) {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ break; // only one match per pattern per hover
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Non-hover highlights are pushed after hover so that hover
+ // takes precedence for overlapping regions.
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, _, _, _, _)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ }
+ }
+ }
+ }
+
+ ridx += group_len; // advance past this logical line group
+ }
+ selections
+ }
+
pub fn start_selection(&mut self, start: &Position) {
let old_selection = self.selection;
self.click.record_click(*start);
diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs
index a3f73a72..f0fd3949 100644
--- a/zellij-server/src/panes/terminal_pane.rs
+++ b/zellij-server/src/panes/terminal_pane.rs
@@ -22,7 +22,7 @@ use zellij_utils::pane_size::Offset;
use zellij_utils::{
data::{
BareKey, InputMode, KeyWithModifier, Palette, PaletteColor, PaneId as ZellijUtilsPaneId,
- Style, Styling,
+ RegexHighlight, Style, Styling,
},
errors::prelude::*,
input::layout::Run,
@@ -958,6 +958,38 @@ impl Pane for TerminalPane {
}
}
}
+ fn set_plugin_regex_highlights(
+ &mut self,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ self.grid
+ .set_plugin_regex_highlights(plugin_id, highlights, style);
+ self.set_should_render(true);
+ }
+ fn clear_plugin_highlights(&mut self, plugin_id: u32) {
+ self.grid.clear_plugin_highlights(plugin_id);
+ self.set_should_render(true);
+ }
+ fn set_hover_position(&mut self, position: Option<Position>) {
+ self.grid.set_hover_position(position);
+ self.set_should_render(true);
+ }
+ fn plugin_highlight_at(
+ &self,
+ position: &Position,
+ ) -> Option<(
+ u32,
+ String,
+ String,
+ std::collections::BTreeMap<String, String>,
+ )> {
+ self.grid.plugin_highlight_at(position)
+ }
+ fn terminal_emulator_wants_mouse(&self) -> bool {
+ self.grid.mouse_tracking != crate::panes::grid::MouseTracking::Off
+ }
}
impl TerminalPane {
diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs
index e942caa8..7010cefb 100644
--- a/zellij-server/src/panes/unit/grid_tests.rs
+++ b/zellij-server/src/panes/unit/grid_tests.rs
@@ -4556,3 +4556,302 @@ fn osc_11_set_bg_produces_ansi_in_render_output() {
);
}
}
+
+// =====================================================================
+// Plugin Highlight Engine Tests
+// =====================================================================
+
+use crate::panes::grid::MouseTracking;
+use crate::panes::terminal_character::AnsiCode;
+use std::collections::BTreeMap;
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+fn create_highlight(
+ pattern: &str,
+ on_hover: bool,
+ bold: bool,
+ italic: bool,
+ underline: bool,
+) -> RegexHighlight {
+ RegexHighlight {
+ pattern: pattern.to_string(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover,
+ bold,
+ italic,
+ underline,
+ }
+}
+
+#[test]
+fn set_plugin_regex_highlights_basic_match() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ let slot = grid.plugin_highlights.get(&1);
+ assert!(slot.is_some());
+ let entries = slot.unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(entries[0].1.regex.is_match("foo"));
+}
+
+#[test]
+fn set_plugin_regex_highlights_no_match() {
+ let mut grid = create_grid_with_content("hello world bar\n");
+ let highlights = vec![create_highlight("xyz123", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // No position in the viewport should match
+ for col in 0..15 {
+ assert!(grid.plugin_highlight_at(&Position::new(0, col)).is_none());
+ }
+}
+
+#[test]
+fn clear_plugin_highlights_removes_highlights() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![create_highlight("foo", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ assert!(grid.plugin_highlights.get(&1).is_some());
+
+ grid.clear_plugin_highlights(1);
+ assert!(grid.plugin_highlights.get(&1).is_none());
+}
+
+#[test]
+fn multiple_plugins_highlights_independent() {
+ let mut grid = create_grid_with_content("aaa bbb ccc\n");
+ let h1 = vec![create_highlight("aaa", false, false, false, false)];
+ let h2 = vec![create_highlight("bbb", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, h1, &Style::default());
+ grid.set_plugin_regex_highlights(2, h2, &Style::default());
+
+ assert!(grid.plugin_highlights.get(&1).is_some());
+ assert!(grid.plugin_highlights.get(&2).is_some());
+
+ grid.clear_plugin_highlights(1);
+ assert!(grid.plugin_highlights.get(&1).is_none());
+ assert!(grid.plugin_highlights.get(&2).is_some());
+}
+
+#[test]
+fn upsert_replaces_same_pattern() {
+ let mut grid = create_grid_with_content("foo bar\n");
+ let h1 = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, h1, &Style::default());
+
+ let h2 = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: false,
+ }];
+ grid.set_plugin_regex_highlights(1, h2, &Style::default());
+
+ let entries = grid.plugin_highlights.get(&1).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(!entries[0].1.underline);
+}
+
+#[test]
+fn invalid_regex_does_not_crash() {
+ let mut grid = create_grid_with_content("hello\n");
+ let highlights = vec![create_highlight("[invalid", false, false, false, false)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+ // Invalid regex should be skipped
+ let slot = grid.plugin_highlights.get(&1);
+ match slot {
+ None => {}, // acceptable
+ Some(entries) => assert_eq!(entries.len(), 0),
+ }
+}
+
+#[test]
+fn plugin_highlight_at_returns_match() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let mut context = BTreeMap::new();
+ context.insert("key".to_string(), "value".to_string());
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: context.clone(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "foo" starts at column 6 in "hello foo bar"
+ let result = grid.plugin_highlight_at(&Position::new(0, 6));
+ assert!(result.is_some());
+ let (plugin_id, pattern, matched_string, ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(pattern, "foo");
+ assert_eq!(matched_string, "foo");
+ assert_eq!(ctx.get("key").unwrap(), "value");
+}
+
+#[test]
+fn plugin_highlight_at_returns_none_on_miss() {
+ let mut grid = create_grid_with_content("hello foo bar\n");
+ let highlights = vec![create_highlight("foo", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Position 0 is in "hello", not "foo"
+ let result = grid.plugin_highlight_at(&Position::new(0, 0));
+ assert!(result.is_none());
+}
+
+#[test]
+fn plugin_highlight_at_wrapped_line() {
+ // Create a narrow grid (10 cols) so that a long string wraps
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let mut grid = Grid::new(
+ 5,
+ 10,
+ Rc::new(RefCell::new(Palette::default())),
+ terminal_emulator_color_codes,
+ Rc::new(RefCell::new(LinkHandler::new())),
+ Rc::new(RefCell::new(None)),
+ sixel_image_store,
+ Style::default(),
+ false,
+ true,
+ true,
+ true,
+ false,
+ );
+ // Feed a long string that wraps: "abcdefghij" fills row 0, "klmnopqrst" fills row 1
+ let content = "abcdefghijklmnopqrst";
+ let mut vte_parser = vte::Parser::new();
+ for &byte in content.as_bytes() {
+ vte_parser.advance(&mut grid, byte);
+ }
+
+ // Set a highlight for "jklm" which spans the wrap boundary
+ let highlights = vec![create_highlight("jklm", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "jklm" spans row 0 col 9 through row 1 col 3
+ // Position in the wrapped portion (row 1, col 1 = 'k')
+ let result = grid.plugin_highlight_at(&Position::new(1, 1));
+ assert!(result.is_some());
+ let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(matched_string, "jklm");
+}
+
+#[test]
+fn hover_position_triggers_on_hover_highlight() {
+ let mut grid = create_grid_with_content("hello link_text bar\n");
+ let highlights = vec![create_highlight("link_text", true, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Set hover position inside "link_text" (starts at col 6)
+ grid.set_hover_position(Some(Position::new(0, 8)));
+ assert!(grid.hover_position.is_some());
+ assert_eq!(grid.hover_position.unwrap(), Position::new(0, 8));
+
+ // The on_hover entry should exist in plugin_highlights
+ let entries = grid.plugin_highlights.get(&1).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert!(entries[0].1.on_hover);
+}
+
+#[test]
+fn hover_suppressed_when_mouse_tracking_on() {
+ let mut grid = create_grid_with_content("hello link_text bar\n");
+ let highlights = vec![create_highlight("link_text", true, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // Enable mouse tracking — the render path should skip hover highlights
+ grid.mouse_tracking = MouseTracking::Normal;
+ grid.set_hover_position(Some(Position::new(0, 8)));
+
+ // The hover position is set regardless (the guard is in the render path),
+ // but we verify that mouse_tracking is non-Off
+ assert!(grid.hover_position.is_some());
+ assert_ne!(grid.mouse_tracking, MouseTracking::Off);
+}
+
+#[test]
+fn wide_char_display_column_mapping() {
+ // CJK characters: "你好" = 2 chars, each 2 display cols wide, so "world" starts at display col 4
+ let mut grid = create_grid_with_content("你好world\n");
+ let highlights = vec![create_highlight("world", false, false, false, true)];
+ grid.set_plugin_regex_highlights(1, highlights, &Style::default());
+
+ // "你好" occupies display cols 0-3, "world" starts at display col 4
+ let result = grid.plugin_highlight_at(&Position::new(0, 4));
+ assert!(result.is_some());
+ let (plugin_id, _pattern, matched_string, _ctx) = result.unwrap();
+ assert_eq!(plugin_id, 1);
+ assert_eq!(matched_string, "world");
+
+ // Position 2 should be inside "你好", not "world"
+ let result_miss = grid.plugin_highlight_at(&Position::new(0, 2));
+ assert!(result_miss.is_none());
+}
+
+#[test]
+fn highlight_style_variants_resolve_colors() {
+ use super::resolve_highlight_colors;
+
+ let style = Style::default();
+
+ // HighlightStyle::None returns (None, None)
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) =
+ resolve_highlight_colors(&HighlightStyle::None, &style);
+ assert!(fg.is_none());
+ assert!(bg.is_none());
+
+ // HighlightStyle::CustomRgb with fg only
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors(
+ &HighlightStyle::CustomRgb {
+ fg: Some((255, 0, 0)),
+ bg: None,
+ },
+ &style,
+ );
+ assert_eq!(fg, Some(AnsiCode::RgbCode((255, 0, 0))));
+ assert!(bg.is_none());
+
+ // HighlightStyle::CustomIndex with bg only
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) = resolve_highlight_colors(
+ &HighlightStyle::CustomIndex {
+ fg: None,
+ bg: Some(42),
+ },
+ &style,
+ );
+ assert!(fg.is_none());
+ assert_eq!(bg, Some(AnsiCode::ColorIndex(42)));
+
+ // HighlightStyle::Emphasis0 should return a foreground color from the palette
+ let (fg, bg): (Option<AnsiCode>, Option<AnsiCode>) =
+ resolve_highlight_colors(&HighlightStyle::Emphasis0, &style);
+ assert!(fg.is_some());
+ assert!(bg.is_none());
+}
diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs
index d26fb7d2..4bd4a82e 100644
--- a/zellij-server/src/plugins/mod.rs
+++ b/zellij-server/src/plugins/mod.rs
@@ -212,6 +212,14 @@ pub enum PluginInstruction {
response_channel: crossbeam::channel::Sender<Option<u64>>,
},
DetectPluginConfigChanges(PluginAliases),
+ HighlightClicked {
+ plugin_id: u32,
+ client_id: ClientId,
+ pane_id: PaneId,
+ pattern: String,
+ matched_string: String,
+ context: BTreeMap<String, String>,
+ },
Exit,
}
@@ -275,6 +283,7 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::DetectPluginConfigChanges(..) => {
PluginContext::DetectPluginConfigChanges
},
+ PluginInstruction::HighlightClicked { .. } => PluginContext::HighlightClicked,
}
}
}
@@ -1232,6 +1241,23 @@ pub(crate) fn plugin_thread_main(
wasm_bridge
.detect_and_notify_plugin_config_changes(&new_plugins, shutdown_send.clone())?;
},
+ PluginInstruction::HighlightClicked {
+ plugin_id,
+ client_id,
+ pane_id,
+ pattern,
+ matched_string,
+ context,
+ } => {
+ let event = Event::HighlightClicked {
+ pane_id: pane_id.into(),
+ pattern,
+ matched_string,
+ context,
+ };
+ let updates = vec![(Some(plugin_id), Some(client_id), event)];
+ wasm_bridge.update_plugins(updates, shutdown_send.clone())?;
+ },
PluginInstruction::Exit => {
break;
},
diff --git a/zellij-server/src/plugins/unit/plugin_tests.rs b/zellij-server/src/plugins/unit/plugin_tests.rs
index a5f2e420..4e2ad047 100644
--- a/zellij-server/src/plugins/unit/plugin_tests.rs
+++ b/zellij-server/src/plugins/unit/plugin_tests.rs
@@ -12323,3 +12323,274 @@ pub fn get_session_environment_variables_plugin_command() {
});
assert_snapshot!(format!("{:#?}", plugin_bytes_event));
}
+
+// =====================================================================
+// Plugin Highlight API Integration Tests
+// =====================================================================
+
+use crate::panes::PaneId;
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+#[test]
+#[ignore]
+pub fn set_pane_regex_highlights_via_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!(
+ received_screen_instructions,
+ ScreenInstruction::SetPluginRegexHighlights,
+ screen_receiver,
+ 1,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send Super+g key event to trigger set_pane_regex_highlights in the fixture plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::Key(KeyWithModifier::new(BareKey::Char('g')).with_super_modifier()),
+ )]));
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let highlight_instruction = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id,
+ highlights,
+ } = i
+ {
+ Some((*pane_id, *plugin_id, highlights.clone()))
+ } else {
+ None
+ }
+ });
+
+ assert!(
+ highlight_instruction.is_some(),
+ "Expected SetPluginRegexHighlights instruction"
+ );
+ let (pane_id, _plugin_id, highlights) = highlight_instruction.unwrap();
+ assert_eq!(pane_id, PaneId::Terminal(1));
+ assert_eq!(highlights.len(), 1);
+ assert_eq!(highlights[0].pattern, "test_pattern");
+ assert!(highlights[0].italic);
+ assert!(highlights[0].underline);
+}
+
+#[test]
+#[ignore]
+pub fn clear_pane_highlights_via_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread_struct_variant!(
+ received_screen_instructions,
+ ScreenInstruction::ClearPluginHighlights,
+ screen_receiver,
+ 1,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send Super+h key event to trigger clear_pane_highlights in the fixture plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::Key(KeyWithModifier::new(BareKey::Char('h')).with_super_modifier()),
+ )]));
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let clear_instruction = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } = i {
+ Some((*pane_id, *plugin_id))
+ } else {
+ None
+ }
+ });
+
+ assert!(
+ clear_instruction.is_some(),
+ "Expected ClearPluginHighlights instruction"
+ );
+ let (pane_id, _plugin_id) = clear_instruction.unwrap();
+ assert_eq!(pane_id, PaneId::Terminal(1));
+}
+
+#[test]
+#[ignore]
+pub fn highlight_clicked_event_delivered_to_plugin() {
+ let temp_folder = tempdir().unwrap();
+ let plugin_host_folder = PathBuf::from(temp_folder.path());
+ let cache_path = plugin_host_folder.join("permissions_test.kdl");
+ let (plugin_thread_sender, screen_receiver, teardown) = create_plugin_thread(None, None);
+ let plugin_should_float = Some(false);
+ let plugin_title = Some("test_plugin".to_owned());
+ let run_plugin = RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from(&*PLUGIN_FIXTURE)),
+ configuration: Default::default(),
+ ..Default::default()
+ });
+ let tab_index = 1;
+ let client_id = 1;
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let received_screen_instructions = Arc::new(Mutex::new(vec![]));
+ let screen_thread = grant_permissions_and_log_actions_in_thread!(
+ received_screen_instructions,
+ ScreenInstruction::PluginBytes,
+ screen_receiver,
+ 2,
+ &PermissionType::ChangeApplicationState,
+ cache_path,
+ plugin_thread_sender,
+ client_id
+ );
+
+ let _ = plugin_thread_sender.send(PluginInstruction::AddClient(client_id));
+ let _ = plugin_thread_sender.send(PluginInstruction::Load(
+ plugin_should_float,
+ false,
+ false, // close_replaced_pane
+ plugin_title,
+ run_plugin,
+ Some(tab_index),
+ None,
+ client_id,
+ size,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(500));
+
+ // Send a HighlightClicked event directly to the plugin
+ let _ = plugin_thread_sender.send(PluginInstruction::HighlightClicked {
+ plugin_id: 0,
+ client_id,
+ pane_id: PaneId::Terminal(1),
+ pattern: "test".into(),
+ matched_string: "test_match".into(),
+ context: BTreeMap::new(),
+ });
+
+ screen_thread.join().unwrap();
+ teardown();
+
+ let plugin_bytes_event = received_screen_instructions
+ .lock()
+ .unwrap()
+ .iter()
+ .find_map(|i| {
+ if let ScreenInstruction::PluginBytes(plugin_render_assets) = i {
+ for plugin_render_asset in plugin_render_assets {
+ let plugin_bytes = plugin_render_asset.bytes.clone();
+ let plugin_bytes = String::from_utf8_lossy(plugin_bytes.as_slice()).to_string();
+ if plugin_bytes.contains("HighlightClicked") {
+ return Some(plugin_bytes);
+ }
+ }
+ }
+ None
+ });
+ assert!(
+ plugin_bytes_event.is_some(),
+ "Expected PluginBytes containing 'HighlightClicked'"
+ );
+ assert_snapshot!(format!("{:#?}", plugin_bytes_event));
+}
diff --git a/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap
new file mode 100644
index 00000000..f331b82a
--- /dev/null
+++ b/zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__highlight_clicked_event_delivered_to_plugin.snap
@@ -0,0 +1,7 @@
+---
+source: zellij-server/src/plugins/./unit/plugin_tests.rs
+expression: "format!(\"{:#?}\", plugin_bytes_event)"
+---
+Some(
+ "Rows: 20, Cols: 121, Received events: [HighlightClicked { pane_id: Terminal(1), pattern: \"test\", matched_string: \"test_match\", context: {} }]\n\r",
+)
diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs
index d82705ef..1c928eb1 100644
--- a/zellij-server/src/plugins/wasm_bridge.rs
+++ b/zellij-server/src/plugins/wasm_bridge.rs
@@ -514,6 +514,10 @@ impl WasmBridge {
.senders
.send_to_screen(ScreenInstruction::ClearKeyPressesIntercepts(client_id));
}
+ // Clear any regex highlights this plugin registered across all panes
+ let _ = self
+ .senders
+ .send_to_screen(ScreenInstruction::ClearAllPluginHighlights(plugin_id));
// Send worker exit messages
for (_worker_name, worker_sender) in workers {
@@ -2010,6 +2014,7 @@ fn check_event_permission(
| Event::CwdChanged(..)
| Event::AvailableLayoutInfo(..)
| Event::PluginConfigurationChanged(..)
+ | Event::HighlightClicked { .. }
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) => PermissionType::ReadPaneContents,
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index dd323977..d48a80b9 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -37,7 +37,7 @@ use zellij_utils::data::{
OpenTerminalInPlaceOfPluginResponse, OpenTerminalInPlaceResponse,
OpenTerminalNearPluginResponse, OpenTerminalPaneInPlaceOfPaneIdResponse, OpenTerminalResponse,
OriginatingPlugin, PaneScrollbackResponse, PermissionStatus, PermissionType, PluginPermission,
- RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
+ RegexHighlight, RenameLayoutResponse, SaveLayoutResponse, TabMetadata,
};
use zellij_utils::home::default_layout_dir;
use zellij_utils::input::permission::PermissionCache;
@@ -739,6 +739,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
),
PluginCommand::ShowFloatingPanes { tab_id } => show_floating_panes(env, tab_id),
PluginCommand::HideFloatingPanes { tab_id } => hide_floating_panes(env, tab_id),
+ PluginCommand::SetPaneRegexHighlights(pane_id, highlights) => {
+ set_pane_regex_highlights(env, pane_id, highlights)
+ },
+ PluginCommand::ClearPaneHighlights(pane_id) => {
+ clear_pane_highlights(env, pane_id)
+ },
},
(PermissionStatus::Denied, permission) => {
log::error!(
@@ -3387,6 +3393,35 @@ fn hide_floating_panes(env: &PluginEnv, tab_id: Option<usize>) {
let _ = wasi_write_object(env, &response.encode_to_vec());
}
+fn set_pane_regex_highlights(
+ env: &PluginEnv,
+ pane_id: zellij_utils::data::PaneId,
+ highlights: Vec<RegexHighlight>,
+) {
+ let err_context = || "failed to set pane regex highlights".to_string();
+ let pane_id: PaneId = pane_id.into();
+ env.senders
+ .send_to_screen(ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id: env.plugin_id,
+ highlights,
+ })
+ .with_context(err_context)
+ .non_fatal();
+}
+
+fn clear_pane_highlights(env: &PluginEnv, pane_id: zellij_utils::data::PaneId) {
+ let err_context = || "failed to clear pane highlights".to_string();
+ let pane_id: PaneId = pane_id.into();
+ env.senders
+ .send_to_screen(ScreenInstruction::ClearPluginHighlights {
+ pane_id,
+ plugin_id: env.plugin_id,
+ })
+ .with_context(err_context)
+ .non_fatal();
+}
+
fn current_session_last_saved_time(env: &PluginEnv) {
use zellij_utils::plugin_api::plugin_command::ProtobufCurrentSessionLastSavedTimeResponse;
@@ -5086,7 +5121,9 @@ fn check_command_permission(
| PluginCommand::RenameLayout { .. }
| PluginCommand::EditLayout { .. }
| PluginCommand::ShowFloatingPanes { .. }
- | PluginCommand::HideFloatingPanes { .. } => PermissionType::ChangeApplicationState,
+ | PluginCommand::HideFloatingPanes { .. }
+ | PluginCommand::SetPaneRegexHighlights(..)
+ | PluginCommand::ClearPaneHighlights(..) => PermissionType::ChangeApplicationState,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 6a18042d..b84c2058 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -43,7 +43,8 @@ use zellij_utils::data::{
CommandOrPlugin, Direction, FloatingPaneCoordinates, GetFocusedPaneInfoResponse,
KeyWithModifier, LayoutInfo, LayoutWithError, ListPanesResponse, ListTabsResponse,
NewPanePlacement, PaneContents, PaneInfo, PaneListEntry, PaneManifest, PaneScrollbackResponse,
- PluginPermission, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo, WebSharing,
+ PluginPermission, RegexHighlight, Resize, ResizeStrategy, SessionInfo, Styling, TabInfo,
+ WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -670,6 +671,16 @@ pub enum ScreenInstruction {
WatcherTerminalResize(ClientId, Size),
ClearMouseHelpText(ClientId),
UpdateAvailableLayouts(Vec<LayoutInfo>, Vec<LayoutWithError>),
+ SetPluginRegexHighlights {
+ pane_id: PaneId,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ },
+ ClearPluginHighlights {
+ pane_id: PaneId,
+ plugin_id: u32,
+ },
+ ClearAllPluginHighlights(u32), // plugin_id — clears across all panes
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -933,6 +944,11 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::WatcherTerminalResize(..) => ScreenContext::WatcherTerminalResize,
ScreenInstruction::ClearMouseHelpText(..) => ScreenContext::ClearMouseHelpText,
ScreenInstruction::UpdateAvailableLayouts(..) => ScreenContext::UpdateAvailableLayouts,
+ ScreenInstruction::SetPluginRegexHighlights { .. } => {
+ ScreenContext::SetPluginRegexHighlights
+ },
+ ScreenInstruction::ClearPluginHighlights { .. } => ScreenContext::ClearPluginHighlights,
+ ScreenInstruction::ClearAllPluginHighlights(..) => ScreenContext::ClearPluginHighlights,
}
}
}
@@ -7645,6 +7661,40 @@ pub(crate) fn screen_thread_main(
screen.render(None)?;
}
},
+ ScreenInstruction::SetPluginRegexHighlights {
+ pane_id,
+ plugin_id,
+ highlights,
+ } => {
+ let style = screen.style.clone();
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.set_plugin_regex_highlights_for_pane(
+ pane_id, plugin_id, highlights, &style,
+ );
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearPluginHighlights { pane_id, plugin_id } => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.clear_plugin_highlights_for_pane(pane_id, plugin_id);
+ break;
+ }
+ }
+ screen.render(None)?;
+ },
+ ScreenInstruction::ClearAllPluginHighlights(plugin_id) => {
+ let all_tabs = screen.get_tabs_mut();
+ for tab in all_tabs.values_mut() {
+ tab.clear_all_plugin_highlights(plugin_id);
+ }
+ screen.render(None)?;
+ },
}
}
Ok(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 49b232a3..861a3103 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -16,7 +16,7 @@ use uuid::Uuid;
use zellij_utils::data::PaneContents;
use zellij_utils::data::{
Direction, KeyWithModifier, NewPanePlacement, PaneInfo, PermissionStatus, PermissionType,
- PluginPermission, ResizeStrategy, WebSharing,
+ PluginPermission, RegexHighlight, ResizeStrategy, Style, WebSharing,
};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::command::RunCommand;
@@ -55,9 +55,7 @@ use std::{
str,
};
use zellij_utils::{
- data::{
- Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Style, Styling,
- },
+ data::{Event, FloatingPaneCoordinates, InputMode, ModeInfo, Palette, PaletteColor, Styling},
input::{
command::TerminalAction,
layout::{
@@ -608,6 +606,24 @@ pub trait Pane {
_get_full_scrollback: bool,
) -> PaneContents;
fn update_exit_status(&mut self, _exit_status: i32) {}
+ fn set_plugin_regex_highlights(
+ &mut self,
+ _plugin_id: u32,
+ _highlights: Vec<RegexHighlight>,
+ _style: &Style,
+ ) {
+ }
+ fn clear_plugin_highlights(&mut self, _plugin_id: u32) {}
+ fn set_hover_position(&mut self, _position: Option<Position>) {}
+ fn plugin_highlight_at(
+ &self,
+ _position: &Position,
+ ) -> Option<(u32, String, String, BTreeMap<String, String>)> {
+ None
+ }
+ fn terminal_emulator_wants_mouse(&self) -> bool {
+ false
+ }
}
#[derive(Clone, Debug)]
@@ -4726,6 +4742,32 @@ impl Tab {
pane.clear_pane_frame_color_override(client_id);
}
}
+ pub fn set_plugin_regex_highlights_for_pane(
+ &mut self,
+ pane_id: PaneId,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.set_plugin_regex_highlights(plugin_id, highlights, style);
+ }
+ }
+
+ pub fn clear_all_plugin_highlights(&mut self, plugin_id: u32) {
+ for pane_id in self.get_all_pane_ids() {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.clear_plugin_highlights(plugin_id);
+ }
+ }
+ }
+
+ pub fn clear_plugin_highlights_for_pane(&mut self, pane_id: PaneId, plugin_id: u32) {
+ if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.clear_plugin_highlights(plugin_id);
+ }
+ }
+
pub fn update_plugin_loading_stage(&mut self, pid: u32, loading_indication: LoadingIndication) {
if let Some(plugin_pane) = self
.tiled_panes
diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs
index 037c5acd..f98dc3f4 100644
--- a/zellij-server/src/tab/mouse_handler.rs
+++ b/zellij-server/src/tab/mouse_handler.rs
@@ -7,10 +7,25 @@ use zellij_utils::position::Position;
use crate::background_jobs::BackgroundJob;
use crate::panes::PaneId;
+use crate::plugins::PluginInstruction;
use crate::ClientId;
use super::{Pane, Tab};
+/// Remove the hover pane tracking for `client_id` and clear the hover position
+/// on the previously hovered pane (if any). Returns `true` if a pane was
+/// cleared.
+fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool {
+ if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) {
+ if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) {
+ pane.set_hover_position(None);
+ }
+ true
+ } else {
+ false
+ }
+}
+
#[derive(Debug, Default, Copy, Clone)]
pub struct MouseEffect {
pub state_changed: bool,
@@ -136,6 +151,7 @@ enum MouseAction {
},
UpdateHover {
pane_id: Option<PaneId>,
+ position: Option<Position>,
},
SendToTerminal {
pane_id: PaneId,
@@ -636,7 +652,7 @@ impl MouseHandler {
is_floating: _,
position,
} => {
- tab.mouse_hover_pane_id.remove(&client_id);
+ clear_hover_for_client(tab, client_id);
Self::start_pane_resize_with_mouse(tab, pane_id, edge, position, client_id)
.with_context(err_context)?;
Ok(MouseEffect::state_changed())
@@ -667,6 +683,24 @@ impl MouseHandler {
.get_pane_with_id_mut(pane_id)
.ok_or_else(|| anyhow!("Failed to find pane {pane_id:?}"))?;
let relative_position = pane.relative_position(&position);
+
+ // Check if click lands on a plugin highlight
+ if let Some((hit_plugin_id, pattern, matched_string, context)) =
+ pane.plugin_highlight_at(&relative_position)
+ {
+ let _ = tab
+ .senders
+ .send_to_plugin(PluginInstruction::HighlightClicked {
+ plugin_id: hit_plugin_id,
+ client_id,
+ pane_id,
+ pattern,
+ matched_string,
+ context,
+ });
+ return Ok(MouseEffect::state_changed());
+ }
+
let mut leave_clipboard_message = false;
pane.start_selection(&relative_position, client_id);
if pane.get_selected_text(client_id).is_some() {
@@ -718,8 +752,8 @@ impl MouseHandler {
MouseAction::ResizeScrollDown { pane_id } => {
Self::handle_resize_scroll_down(tab, pane_id, client_id).with_context(err_context)
},
- MouseAction::UpdateHover { pane_id } => {
- Self::execute_update_hover(tab, pane_id, client_id)
+ MouseAction::UpdateHover { pane_id, position } => {
+ Self::execute_update_hover(tab, pane_id, position, client_id)
},
MouseAction::SendToTerminal { pane_id, event } => {
Self::execute_send_to_terminal(tab, pane_id, event, client_id)
@@ -762,7 +796,7 @@ impl MouseHandler {
client_id: ClientId,
) -> Result<MouseEffect> {
let err_context = || "failed to focus pane";
- tab.mouse_hover_pane_id.remove(&client_id);
+ clear_hover_for_client(tab, client_id);
let active_pane_id_before = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
@@ -894,9 +928,11 @@ impl MouseHandler {
fn execute_update_hover(
tab: &mut Tab,
pane_id: Option<PaneId>,
+ _position: Option<Position>,
client_id: ClientId,
) -> Result<MouseEffect> {
let mut should_render = false;
+ let previous_hover_pane_id = tab.mouse_hover_pane_id.get(&client_id).copied();
match pane_id {
Some(pid) => {
if let Some(pane) = tab.get_pane_with_id(pid) {
@@ -917,6 +953,18 @@ impl MouseHandler {
},
}
+ // Clear hover position on previously hovered pane when the hovered
+ // pane has changed (or cursor left all panes). Hover position is
+ // intentionally not set on unfocused panes so that hover-only plugin
+ // highlights only activate on the focused pane.
+ if let Some(prev_pane_id) = previous_hover_pane_id {
+ if Some(prev_pane_id) != pane_id {
+ if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) {
+ pane.set_hover_position(None);
+ }
+ }
+ }
+
if tab.mouse_help_text_visible.remove(&client_id).is_some() {
should_render = true;
}
@@ -954,10 +1002,23 @@ impl MouseHandler {
.with_context(err_context)?;
}
}
- let removed_hover = tab.mouse_hover_pane_id.remove(&client_id);
- if removed_hover.is_some() {
+ if clear_hover_for_client(tab, client_id) {
should_render = true;
}
+ // Update hover position on the active pane during motion events
+ // so that on_hover highlights work for the focused pane too.
+ // Skip when the terminal application has mouse tracking enabled,
+ // because hover highlights are suppressed in that case and the
+ // update would only trigger unnecessary re-renders.
+ if event.event_type == MouseEventType::Motion {
+ if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
+ if !pane.terminal_emulator_wants_mouse() {
+ let relative = pane.relative_position(&event.position);
+ pane.set_hover_position(Some(relative));
+ should_render = true;
+ }
+ }
+ }
if event.event_type == MouseEventType::Motion && tab.mouse_hover_effects {
tab.last_mouse_activity_time
@@ -1203,7 +1264,10 @@ impl MouseHandler {
&& !event.middle;
if is_buttonless_motion {
let Some(pane_id) = ctx.pane_id_at_position else {
- return Ok(MouseAction::UpdateHover { pane_id: None });
+ return Ok(MouseAction::UpdateHover {
+ pane_id: None,
+ position: None,
+ });
};
let is_active_pane = Some(pane_id) == ctx.active_pane_id;
if is_active_pane {
@@ -1214,6 +1278,7 @@ impl MouseHandler {
}
return Ok(MouseAction::UpdateHover {
pane_id: Some(pane_id),
+ position: Some(event.position),
});
}
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap
new file mode 100644
index 00000000..8070d41a
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_on_unfocused_pane_no_highlight.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+01 (C): │link_text ││right pane │
+02 (C): │ ││ │
+03 (C): │ ││ │
+04 (C): │ ││ │
+05 (C): │ ││ │
+06 (C): │ ││ │
+07 (C): │ ││ │
+08 (C): │ ││ │
+09 (C): │ ││ │
+10 (C): │ ││ │
+11 (C): │ ││ │
+12 (C): │ ││ │
+13 (C): │ ││ │
+14 (C): │ ││ │
+15 (C): │ ││ │
+16 (C): │ ││ │
+17 (C): │ ││ │
+18 (C): │ ││ │
+19 (C): └ Alt <Click> - group, Alt <Right-Click> - ungroup all ─────┘└──────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap
new file mode 100644
index 00000000..2c23c59d
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling-2.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot_without_hover
+---
+00 (C):
+01 (C): hello link_text bar
+02 (C):
+03 (C):
+04 (C):
+05 (C):
+06 (C):
+07 (C):
+08 (C):
+09 (C):
+10 (C):
+11 (C):
+12 (C):
+13 (C):
+14 (C):
+15 (C):
+16 (C):
+17 (C):
+18 (C):
+19 (C):
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap
new file mode 100644
index 00000000..d101a254
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__hover_over_highlight_shows_styling.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot_with_hover
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+01 (C): │hello link_text bar │
+02 (C): │ │
+03 (C): │ │
+04 (C): │ │
+05 (C): │ │
+06 (C): │ │
+07 (C): │ │
+08 (C): │ │
+09 (C): │ │
+10 (C): │ │
+11 (C): │ │
+12 (C): │ │
+13 (C): │ │
+14 (C): │ │
+15 (C): │ │
+16 (C): │ │
+17 (C): │ │
+18 (C): │ │
+19 (C): └───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap
new file mode 100644
index 00000000..8730fd6c
--- /dev/null
+++ b/zellij-server/src/tab/unit/snapshots/zellij_server__tab__tab_integration_tests__set_and_clear_highlights_across_tiled_and_floating.snap
@@ -0,0 +1,24 @@
+---
+source: zellij-server/src/tab/./unit/tab_integration_tests.rs
+expression: snapshot
+---
+00 (C): ┌ Pane #1 ──────────────────────────────────────────────────┐┌ Pane #2 ─────────────────────────────────────────────────┐
+01 (C): │aaa bbb ││aaa bbb │
+02 (C): │ ││ │
+03 (C): │ ││ │
+04 (C): │ ││ │
+05 (C): │ ┌ Pane #3 ──────────────────────────────────────── PIN [ ] ┐ │
+06 (C): │ │aaa bbb │ │
+07 (C): │ │ │ │
+08 (C): │ │ │ │
+09 (C): │ │ │ │
+10 (C): │ │ │ │
+11 (C): │ │ │ │
+12 (C): │ │ │ │
+13 (C): │ │ │ │
+14 (C): │ └──────────────────────────────────────────────────────────┘ │
+15 (C): │ ││ │
+16 (C): │ ││ │
+17 (C): │ ││ │
+18 (C): │ ││ │
+19 (C): └───────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────┘
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index bcd83279..ca68538c 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -12460,3 +12460,470 @@ fn in_place_pane_with_close_replaced_pane_true_closes_original() {
// Snapshot shows only pane 1 (pane 2 was permanently closed, not restored)
assert_snapshot!(snapshot);
}
+
+// =====================================================================
+// Plugin Highlight Mouse Integration Tests
+// =====================================================================
+
+use zellij_utils::data::{HighlightStyle, RegexHighlight};
+
+fn create_new_tab_with_plugin_receiver(
+ size: Size,
+ default_mode: ModeInfo,
+) -> (Tab, Receiver<(PluginInstruction, ErrorContext)>) {
+ set_session_name("test".into());
+ let index = 0;
+ let position = 0;
+ let name = String::new();
+ let os_api = Box::new(FakeInputOutput::default());
+ let mut senders = ThreadSenders::default().silently_fail_on_send();
+ let (mock_plugin_sender, mock_plugin_receiver): ChannelWithContext<PluginInstruction> =
+ channels::unbounded();
+ senders.replace_to_plugin(SenderWithContext::new(mock_plugin_sender));
+ let max_panes = None;
+ let mode_info = default_mode;
+ let style = Style::default();
+ let draw_pane_frames = true;
+ let auto_layout = true;
+ let client_id = 1;
+ let session_is_mirrored = true;
+ let mut connected_clients = HashMap::new();
+ connected_clients.insert(client_id, false);
+ let connected_clients = Rc::new(RefCell::new(connected_clients));
+ let character_cell_info = Rc::new(RefCell::new(None));
+ let stacked_resize = Rc::new(RefCell::new(true));
+ let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
+ let copy_options = CopyOptions::default();
+ let terminal_emulator_color_codes = Rc::new(RefCell::new(HashMap::new()));
+ let sixel_image_store = Rc::new(RefCell::new(SixelImageStore::default()));
+ let current_group = Rc::new(RefCell::new(PaneGroups::new(ThreadSenders::default())));
+ let currently_marking_pane_group = Rc::new(RefCell::new(HashMap::new()));
+ let debug = false;
+ let arrow_fonts = true;
+ let styled_underlines = true;
+ let osc8_hyperlinks = true;
+ let explicitly_disable_kitty_keyboard_protocol = false;
+ let advanced_mouse_actions = true;
+ let web_sharing = WebSharing::Off;
+ let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+ let web_server_port = 8080;
+ let mut tab = Tab::new(
+ index,
+ position,
+ name,
+ size,
+ character_cell_info,
+ stacked_resize,
+ sixel_image_store,
+ os_api,
+ senders,
+ max_panes,
+ style,
+ mode_info,
+ draw_pane_frames,
+ auto_layout,
+ connected_clients,
+ session_is_mirrored,
+ Some(client_id),
+ copy_options,
+ terminal_emulator_colors,
+ terminal_emulator_color_codes,
+ (vec![], vec![]),
+ PathBuf::from("my_default_shell"),
+ debug,
+ arrow_fonts,
+ styled_underlines,
+ osc8_hyperlinks,
+ explicitly_disable_kitty_keyboard_protocol,
+ None,
+ false,
+ web_sharing,
+ current_group,
+ currently_marking_pane_group,
+ advanced_mouse_actions,
+ true, // mouse_hover_effects
+ web_server_ip,
+ web_server_port,
+ );
+ tab.apply_layout(
+ TiledPaneLayout::default(),
+ vec![],
+ vec![(1, None)],
+ vec![],
+ HashMap::new(),
+ client_id,
+ None,
+ )
+ .unwrap();
+ (tab, mock_plugin_receiver)
+}
+
+#[test]
+fn click_on_plugin_highlight_sends_highlight_clicked() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ // Feed PTY bytes to pane 1
+ tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes()))
+ .unwrap();
+
+ // Set plugin highlight on pane 1 for pattern "foo"
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Left-click at position where "foo" appears
+ // With draw_pane_frames=true, content starts at row 1, col 1
+ // "click here foo bar" -> "foo" starts at offset 11, so col = 1 + 11 = 12
+ let click_position = Position::new(1, 12);
+ let effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ assert!(effect.state_changed);
+
+ // Check that HighlightClicked was sent to the plugin channel
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if let PluginInstruction::HighlightClicked {
+ plugin_id,
+ pane_id,
+ pattern,
+ matched_string,
+ ..
+ } = instruction
+ {
+ assert_eq!(plugin_id, 42);
+ assert_eq!(pane_id, PaneId::Terminal(1));
+ assert_eq!(pattern, "foo");
+ assert_eq!(matched_string, "foo");
+ found_highlight_clicked = true;
+ break;
+ }
+ }
+ assert!(
+ found_highlight_clicked,
+ "Expected HighlightClicked instruction to be sent"
+ );
+}
+
+#[test]
+fn click_outside_highlight_starts_normal_selection() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ tab.handle_pty_bytes(1, Vec::from("click here foo bar".as_bytes()))
+ .unwrap();
+
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Click on "click" (col 1), not on "foo"
+ let click_position = Position::new(1, 1);
+ let _effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ // No HighlightClicked should be sent
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if matches!(instruction, PluginInstruction::HighlightClicked { .. }) {
+ found_highlight_clicked = true;
+ }
+ }
+ assert!(
+ !found_highlight_clicked,
+ "HighlightClicked should NOT be sent for miss"
+ );
+
+ // Selection should have started
+ assert!(tab.selecting_with_mouse_in_pane.is_some());
+}
+
+#[test]
+fn hover_over_highlight_shows_styling() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let mut output = Output::default();
+
+ tab.handle_pty_bytes(1, Vec::from("hello link_text bar".as_bytes()))
+ .unwrap();
+
+ let highlights = vec![RegexHighlight {
+ pattern: "link_text".into(),
+ style: HighlightStyle::None,
+ context: BTreeMap::new(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default());
+
+ // Hover over the match (link_text starts at offset 6, with frame offset col = 7)
+ let hover_position = Position::new(1, 9);
+ let _effect = tab
+ .handle_mouse_event(
+ &MouseEvent::new_buttonless_motion(hover_position),
+ client_id,
+ )
+ .unwrap();
+
+ tab.render(&mut output, None).unwrap();
+ let snapshot_with_hover = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot_with_hover);
+
+ // Move hover away from the match
+ let mut output2 = Output::default();
+ let far_position = Position::new(1, 1);
+ let _effect2 = tab
+ .handle_mouse_event(&MouseEvent::new_buttonless_motion(far_position), client_id)
+ .unwrap();
+
+ tab.render(&mut output2, None).unwrap();
+ let snapshot_without_hover = take_snapshot(
+ output2.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot_without_hover);
+}
+
+#[test]
+fn hover_on_unfocused_pane_no_highlight() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let new_pane_id = PaneId::Terminal(2);
+ let mut output = Output::default();
+
+ // Create a vertical split
+ tab.vertical_split(new_pane_id, None, client_id, None, None)
+ .unwrap();
+
+ tab.handle_pty_bytes(1, Vec::from("link_text".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(2, Vec::from("right pane".as_bytes()))
+ .unwrap();
+
+ // Active pane is pane 2 (right)
+ assert_eq!(tab.get_active_pane_id(client_id), Some(PaneId::Terminal(2)));
+
+ // Set on_hover highlight on pane 1 (left, unfocused)
+ let highlights = vec![RegexHighlight {
+ pattern: "link_text".into(),
+ style: HighlightStyle::None,
+ context: BTreeMap::new(),
+ on_hover: true,
+ bold: false,
+ italic: true,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, highlights, &Style::default());
+
+ // Hover over left pane (unfocused)
+ let hover_position = Position::new(1, 5);
+ let _effect = tab
+ .handle_mouse_event(
+ &MouseEvent::new_buttonless_motion(hover_position),
+ client_id,
+ )
+ .unwrap();
+
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ // Hover highlight should NOT be visible on the unfocused pane
+ assert_snapshot!(snapshot);
+}
+
+#[test]
+fn highlight_click_in_mouse_mode_pane_suppressed() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let (mut tab, mock_plugin_receiver) =
+ create_new_tab_with_plugin_receiver(size, ModeInfo::default());
+
+ // Feed PTY bytes that enable mouse tracking (SGR mode + any-event tracking)
+ tab.handle_pty_bytes(1, Vec::from("\x1b[?1006h\x1b[?1003h".as_bytes()))
+ .unwrap();
+ // Then feed text with matchable content
+ tab.handle_pty_bytes(1, Vec::from("foo bar baz".as_bytes()))
+ .unwrap();
+
+ // Set plugin highlight
+ let highlights = vec![RegexHighlight {
+ pattern: "foo".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(
+ PaneId::Terminal(1),
+ 42,
+ highlights,
+ &Style::default(),
+ );
+
+ // Left-click on the highlight position
+ // With mouse tracking, the click goes through SendToTerminal, not StartSelection
+ let click_position = Position::new(1, 1);
+ let _effect = tab
+ .handle_mouse_event(&MouseEvent::new_left_press_event(click_position), client_id)
+ .unwrap();
+
+ // No HighlightClicked should be sent (the click is forwarded to the terminal)
+ let mut found_highlight_clicked = false;
+ while let Ok((instruction, _ctx)) = mock_plugin_receiver.try_recv() {
+ if matches!(instruction, PluginInstruction::HighlightClicked { .. }) {
+ found_highlight_clicked = true;
+ }
+ }
+ assert!(
+ !found_highlight_clicked,
+ "HighlightClicked should NOT be sent when terminal has mouse tracking"
+ );
+}
+
+#[test]
+fn set_and_clear_highlights_across_tiled_and_floating() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let client_id = 1;
+ let mut tab = create_new_tab(size, ModeInfo::default());
+ let pane2 = PaneId::Terminal(2);
+ let pane3 = PaneId::Terminal(3);
+
+ // Create a second tiled pane
+ tab.vertical_split(pane2, None, client_id, None, None)
+ .unwrap();
+
+ // Create a floating pane
+ tab.toggle_floating_panes(Some(client_id), None, None)
+ .unwrap();
+ tab.new_pane(
+ pane3,
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(client_id),
+ None,
+ )
+ .unwrap();
+
+ // Feed content to all panes
+ tab.handle_pty_bytes(1, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(2, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+ tab.handle_pty_bytes(3, Vec::from("aaa bbb".as_bytes()))
+ .unwrap();
+
+ // Set highlights from plugin 1 (pattern "aaa") on all panes
+ let h1 = vec![RegexHighlight {
+ pattern: "aaa".into(),
+ style: HighlightStyle::Emphasis0,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: true,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 1, h1.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane2, 1, h1.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane3, 1, h1.clone(), &Style::default());
+
+ // Set highlights from plugin 2 (pattern "bbb") on all panes
+ let h2 = vec![RegexHighlight {
+ pattern: "bbb".into(),
+ style: HighlightStyle::Emphasis1,
+ context: BTreeMap::new(),
+ on_hover: false,
+ bold: false,
+ italic: false,
+ underline: false,
+ }];
+ tab.set_plugin_regex_highlights_for_pane(PaneId::Terminal(1), 2, h2.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane2, 2, h2.clone(), &Style::default());
+ tab.set_plugin_regex_highlights_for_pane(pane3, 2, h2.clone(), &Style::default());
+
+ // Clear plugin 1 highlights across all panes
+ tab.clear_all_plugin_highlights(1);
+
+ // Verify plugin 2 highlights remain — we can use the tab render to snapshot,
+ // or directly check that plugin 2 pattern still matches on pane 1
+ // Since clear_all_plugin_highlights iterates all panes, we test via
+ // the set_plugin_regex_highlights_for_pane / clear flow:
+ // Plugin 2 "bbb" highlights should still be active.
+ let mut output = Output::default();
+ tab.render(&mut output, None).unwrap();
+ let snapshot = take_snapshot(
+ output.serialize().unwrap().get(&client_id).unwrap(),
+ size.rows,
+ size.cols,
+ Palette::default(),
+ );
+ assert_snapshot!(snapshot);
+}