aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server/src/panes/grid.rs
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-05 21:13:02 +0100
committerGitHub <noreply@github.com>2026-03-05 21:13:02 +0100
commit903cdf5d32476a8d6cc208d2c418a1f2c67e5a53 (patch)
tree9a4647d9dc3efcdea8149fd78fdfa956b3c8d8cb /zellij-server/src/panes/grid.rs
parentbuild: make test mirror build command for plugins (#4801) (diff)
downloadsl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.gz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.tar.xz
sl0ppij-903cdf5d32476a8d6cc208d2c418a1f2c67e5a53.zip
Click file paths to open in editor, allow plugins to change styles and hover of viewport (#4802)
* initial implementation * implement hover * add styles and foreground/background emphases, fix upsert hover collisions * initial implementation of link plugin * suppress highlighting + clicks on unfocused and mouse mode panes * also make italic * fix env var and ~ expansions * fix regex issues and add defensive validations * fix tests and add plugin assets * add tests * refactor all the things * add pr
Diffstat (limited to 'zellij-server/src/panes/grid.rs')
-rw-r--r--zellij-server/src/panes/grid.rs449
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: &regex::Match,
+ boundaries: &[(usize, usize)],
+ viewport: &VecDeque<Row>,
+) -> Option<(Selection, usize, usize, usize, usize)> {
+ let (start_row, start_col) = byte_offset_to_display_col(mat.start(), boundaries, viewport)?;
+ let (end_row, end_col) = byte_offset_to_display_col(mat.end(), boundaries, viewport)?;
+ let mut sel = Selection::default();
+ sel.set_start_and_end_positions(
+ Position::new(start_row as i32, start_col as u16),
+ Position::new(end_row as i32, end_col as u16),
+ );
+ Some((sel, start_row, start_col, end_row, end_col))
+}
+
+/// Check whether a (row, col) position falls within a display span.
+/// The span is inclusive at start and exclusive at end.
+fn position_in_span(
+ row: usize,
+ col: usize,
+ start_row: usize,
+ start_col: usize,
+ end_row: usize,
+ end_col: usize,
+) -> bool {
+ let after_start = row > start_row || (row == start_row && col >= start_col);
+ let before_end = row < end_row || (row == end_row && col < end_col);
+ after_start && before_end
+}
+
#[derive(Clone)]
pub struct Grid {
pub(crate) lines_above: VecDeque<Row>,
@@ -406,6 +530,9 @@ pub struct Grid {
hyperlink_tracker: HyperlinkTracker,
pub pane_default_fg: Option<AnsiCode>,
pub pane_default_bg: Option<AnsiCode>,
+ pub plugin_highlights: HashMap<u32, Vec<(String, CompiledHighlight)>>,
+ // key: plugin_id (u32), inner vec: (pattern, compiled) pairs
+ pub hover_position: Option<Position>, // pane-relative cursor cell; None when outside pane
}
impl Grid {
@@ -431,6 +558,26 @@ fn ansi_code_to_color_string(code: AnsiCode) -> Option<String> {
}
}
+/// A compiled highlight entry for one plugin/pattern combination.
+#[derive(Clone)]
+pub struct CompiledHighlight {
+ pub regex: regex::Regex,
+ pub fg: Option<AnsiCode>,
+ pub bg: Option<AnsiCode>,
+ pub context: BTreeMap<String, String>,
+ pub on_hover: bool,
+ pub bold: bool,
+ pub italic: bool,
+ pub underline: bool,
+}
+
+impl CompiledHighlight {
+ /// Whether this highlight would produce any visible styling change.
+ pub fn has_visual_effect(&self) -> bool {
+ self.bg.is_some() || self.fg.is_some() || self.bold || self.italic || self.underline
+ }
+}
+
const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold
#[derive(Clone, Debug, Default)]
@@ -483,7 +630,7 @@ impl Default for MouseMode {
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub enum MouseTracking {
Off,
Normal,
@@ -544,6 +691,49 @@ impl Debug for Grid {
}
}
+fn resolve_highlight_colors(
+ style_decl: &HighlightStyle,
+ style: &Style,
+) -> (Option<AnsiCode>, Option<AnsiCode>) {
+ let palette_to_ansi = |c: PaletteColor| -> AnsiCode {
+ match c {
+ PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb),
+ PaletteColor::EightBit(i) => AnsiCode::ColorIndex(i),
+ }
+ };
+ let tu = &style.colors.text_unselected;
+ let emphasis = [tu.emphasis_0, tu.emphasis_1, tu.emphasis_2, tu.emphasis_3];
+ match style_decl {
+ HighlightStyle::None => (None, None),
+ HighlightStyle::Emphasis0 => (Some(palette_to_ansi(emphasis[0])), None),
+ HighlightStyle::Emphasis1 => (Some(palette_to_ansi(emphasis[1])), None),
+ HighlightStyle::Emphasis2 => (Some(palette_to_ansi(emphasis[2])), None),
+ HighlightStyle::Emphasis3 => (Some(palette_to_ansi(emphasis[3])), None),
+ HighlightStyle::BackgroundEmphasis0 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[0])),
+ ),
+ HighlightStyle::BackgroundEmphasis1 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[1])),
+ ),
+ HighlightStyle::BackgroundEmphasis2 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[2])),
+ ),
+ HighlightStyle::BackgroundEmphasis3 => (
+ Some(palette_to_ansi(tu.background)),
+ Some(palette_to_ansi(emphasis[3])),
+ ),
+ HighlightStyle::CustomRgb { fg, bg } => {
+ (fg.map(AnsiCode::RgbCode), bg.map(AnsiCode::RgbCode))
+ },
+ HighlightStyle::CustomIndex { fg, bg } => {
+ (fg.map(AnsiCode::ColorIndex), bg.map(AnsiCode::ColorIndex))
+ },
+ }
+}
+
impl Grid {
pub fn new(
rows: usize,
@@ -621,6 +811,8 @@ impl Grid {
hyperlink_tracker: HyperlinkTracker::new(),
pane_default_fg: None,
pane_default_bg: None,
+ plugin_highlights: HashMap::new(),
+ hover_position: None,
}
}
pub fn render_full_viewport(&mut self) {
@@ -1199,6 +1391,9 @@ impl Grid {
let mut raw_vte_output = String::new();
let (mut character_chunks, sixel_image_chunks) = self.read_changes(content_x, content_y);
+
+ let plugin_highlight_selections = self.compute_plugin_highlight_selections();
+
for character_chunk in character_chunks.iter_mut() {
character_chunk.add_changed_colors(self.changed_colors);
character_chunk.add_pane_defaults(self.pane_default_fg, self.pane_default_bg);
@@ -1216,9 +1411,14 @@ impl Grid {
};
character_chunk.add_selection_and_colors(
- self.selection,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: self.selection,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
@@ -1246,15 +1446,29 @@ impl Grid {
PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col),
};
character_chunk.add_selection_and_colors(
- *res,
- background_color,
- Some(foreground_color),
+ HighlightSelection {
+ selection: *res,
+ bg: Some(background_color),
+ fg: Some(foreground_color),
+ bold: false,
+ italic: false,
+ underline: false,
+ },
content_x,
content_y,
);
}
}
}
+ // Apply pre-computed plugin highlight selections to this chunk.
+ for hs in &plugin_highlight_selections {
+ if hs
+ .selection
+ .contains_row(character_chunk.y.saturating_sub(content_y))
+ {
+ character_chunk.add_selection_and_colors(*hs, content_x, content_y);
+ }
+ }
}
return Ok(Some((
character_chunks,
@@ -1851,6 +2065,223 @@ impl Grid {
fn set_preceding_character(&mut self, terminal_character: TerminalCharacter) {
self.preceding_char = Some(terminal_character);
}
+ /// Called by the server-side handler for SetPaneRegexHighlights.
+ /// Upserts highlights keyed by pattern string for the given plugin.
+ pub fn set_plugin_regex_highlights(
+ &mut self,
+ plugin_id: u32,
+ highlights: Vec<RegexHighlight>,
+ style: &Style,
+ ) {
+ let slot = self
+ .plugin_highlights
+ .entry(plugin_id)
+ .or_insert_with(Vec::new);
+ for h in highlights {
+ let (fg, bg) = resolve_highlight_colors(&h.style, style);
+ if let Ok(regex) = regex::Regex::new(&h.pattern) {
+ // Upsert: replace existing entry with same pattern and on_hover flag, or push new
+ let on_hover = h.on_hover;
+ if let Some(existing) = slot
+ .iter_mut()
+ .find(|(p, c)| p == &h.pattern && c.on_hover == on_hover)
+ {
+ existing.1 = CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ };
+ } else {
+ slot.push((
+ h.pattern,
+ CompiledHighlight {
+ regex,
+ fg,
+ bg,
+ context: h.context,
+ on_hover: h.on_hover,
+ bold: h.bold,
+ italic: h.italic,
+ underline: h.underline,
+ },
+ ));
+ }
+ } else {
+ log::warn!(
+ "Plugin {} supplied invalid regex: {:?}",
+ plugin_id,
+ h.pattern
+ );
+ }
+ }
+ self.output_buffer.update_all_lines();
+ }
+
+ /// Called by the server-side handler for ClearPaneHighlights.
+ pub fn clear_plugin_highlights(&mut self, plugin_id: u32) {
+ if self.plugin_highlights.remove(&plugin_id).is_some() {
+ self.output_buffer.update_all_lines();
+ }
+ }
+
+ /// Returns (plugin_id, pattern, matched_string, context) if the given
+ /// pane-relative position falls inside any plugin highlight match, or None.
+ /// Uses logical-line grouping so multi-line (wrapped) matches are detected.
+ pub fn plugin_highlight_at(
+ &self,
+ position: &Position,
+ ) -> Option<(u32, String, String, BTreeMap<String, String>)> {
+ let click_row = position.line.0 as usize;
+ let click_col = position.column.0 as usize;
+
+ let (_canonical, _group_len, logical_text, boundaries) =
+ collect_and_build_logical_line(&self.viewport, click_row)?;
+
+ for (plugin_id, pattern_map) in &self.plugin_highlights {
+ for (pattern, compiled) in pattern_map {
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((_sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ click_row, click_col, start_row, start_col, end_row, end_col,
+ ) {
+ return Some((
+ *plugin_id,
+ pattern.clone(),
+ mat.as_str().to_string(),
+ compiled.context.clone(),
+ ));
+ }
+ }
+ }
+ }
+ }
+ None
+ }
+
+ pub fn set_hover_position(&mut self, new_pos: Option<Position>) {
+ if self.hover_position == new_pos {
+ return;
+ }
+
+ // Mark the canonical group containing the old hover row dirty.
+ if let Some(old_pos) = self.hover_position {
+ self.mark_logical_line_dirty(old_pos.line.0 as usize);
+ }
+ // Mark the canonical group containing the new hover row dirty.
+ if let Some(new_pos_inner) = new_pos {
+ self.mark_logical_line_dirty(new_pos_inner.line.0 as usize);
+ }
+
+ self.hover_position = new_pos;
+ }
+
+ /// Mark all physical rows belonging to the logical line group that contains
+ /// `row_idx` as dirty in the output buffer.
+ fn mark_logical_line_dirty(&mut self, row_idx: usize) {
+ if let Some((canonical, group_len, _, _)) =
+ collect_and_build_logical_line(&self.viewport, row_idx)
+ {
+ for r in canonical..canonical + group_len {
+ self.output_buffer.update_line(r);
+ }
+ }
+ }
+
+ /// Pre-compute plugin highlight selections across all logical line groups in
+ /// the viewport. Hover highlights are emitted first so they take priority
+ /// when the cursor overlaps a match; non-hover highlights follow.
+ fn compute_plugin_highlight_selections(&self) -> Vec<HighlightSelection> {
+ if self.plugin_highlights.is_empty() {
+ return vec![];
+ }
+ let mut selections = Vec::new();
+ let viewport_len = self.viewport.len();
+ let mut ridx = 0;
+ while ridx < viewport_len {
+ let (_canonical, group_len, logical_text, boundaries) =
+ match collect_and_build_logical_line(&self.viewport, ridx) {
+ Some(v) => v,
+ None => break,
+ };
+
+ // Hover highlights are pushed first so that `.find()` in
+ // `adjust_styles_for_possible_selection` returns the hover
+ // style when the cursor overlaps the match. They are suppressed
+ // when mouse tracking is active (events pass through to the app)
+ // and on unfocused panes (hover_position is not set for those).
+ if self.mouse_tracking == MouseTracking::Off {
+ if let Some(hover_pos) = self.hover_position {
+ let hover_row = hover_pos.line.0 as usize;
+ let group_end_row = ridx + group_len - 1;
+ if hover_row >= ridx && hover_row <= group_end_row {
+ let hover_col = hover_pos.column.0;
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if !compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, start_row, start_col, end_row, end_col)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ if position_in_span(
+ hover_row, hover_col, start_row, start_col, end_row,
+ end_col,
+ ) {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ break; // only one match per pattern per hover
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Non-hover highlights are pushed after hover so that hover
+ // takes precedence for overlapping regions.
+ for (_plugin_id, pattern_map) in &self.plugin_highlights {
+ for (_pattern, compiled) in pattern_map {
+ if compiled.on_hover || !compiled.has_visual_effect() {
+ continue;
+ }
+ for mat in compiled.regex.find_iter(&logical_text) {
+ if let Some((sel, _, _, _, _)) =
+ match_to_selection(&mat, &boundaries, &self.viewport)
+ {
+ selections.push(HighlightSelection {
+ selection: sel,
+ bg: compiled.bg,
+ fg: compiled.fg,
+ bold: compiled.bold,
+ italic: compiled.italic,
+ underline: compiled.underline,
+ });
+ }
+ }
+ }
+ }
+
+ ridx += group_len; // advance past this logical line group
+ }
+ selections
+ }
+
pub fn start_selection(&mut self, start: &Position) {
let old_selection = self.selection;
self.click.record_click(*start);