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/src/panes/grid.rs | |
| 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/src/panes/grid.rs')
| -rw-r--r-- | zellij-server/src/panes/grid.rs | 449 |
1 files changed, 440 insertions, 9 deletions
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); |
