diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-03-05 21:13:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 21:13:02 +0100 |
| commit | 903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch) | |
| tree | 9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb /zellij-server | |
| parent | build: make test mirror build command for plugins (#4801) (diff) | |
| download | sl0ppij-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')
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: ®ex::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); +} |
