use super::kitty_graphics::{ format_kitty_error, format_kitty_reply, KittyAction, KittyCommand, KittyCommandParser, KittyError, KittyErrorCode, KittyGrid, KittyHostSupport, KittyImageChunk, KittyImageStore, KittyPlacement, KittyReplyData, KittyRowsBelowTheViewport, KittyVerticalAnchor, }; use super::sixel::{PixelRect, SixelGrid, SixelImageStore}; use base64::alphabet::STANDARD as BASE64_STANDARD_ALPHABET; use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig}; use base64::engine::{DecodePaddingMode, Engine as _}; use std::borrow::Cow; use std::cell::RefCell; use std::collections::BTreeMap; use std::collections::HashMap; use std::rc::Rc; use unicode_width::UnicodeWidthChar; use zellij_utils::data::{ HighlightLayer, HighlightStyle, HostTerminalThemeMode, RegexHighlight, Style, }; use zellij_utils::errors::prelude::*; use std::{ cmp::Ordering, collections::{BTreeSet, VecDeque}, fmt::{self, Debug, Formatter}, str, }; use vte; use zellij_utils::{ consts::{DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE}, data::{Palette, PaletteColor, Styling}, input::mouse::{MouseEvent, MouseEventType}, nested_session::{self, NestedSessionMessage}, pane_size::SizeInPixels, position::Position, }; const TABSTOP_WIDTH: usize = 8; // TODO: is this always right? pub const MAX_TITLE_STACK_SIZE: usize = 1000; const BASE64_DECODER: GeneralPurpose = GeneralPurpose::new( &BASE64_STANDARD_ALPHABET, GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), ); /// Rewrites OSC 99 metadata for multiplexer forwarding: /// /// 1. Namespaces the `i=` value with a pane ID prefix and flags so responses /// can be routed back to the originating pane. /// Format: `i=p[r][q].` /// - `r` flag: app requested `a=report` — activation response should be /// written back to the pane's PTY /// - `q` flag: this is a capability query (`p=?`) — response must be /// written back to the pane's PTY /// - Neither: activation response used only for focus routing, not forwarded /// /// 2. Ensures `a=report` is always present so the host terminal sends the /// activation response back to Zellij (needed for pane focus routing). /// /// If no `i=` key is present, one is added with an empty original ID. pub(crate) fn namespace_notification_id(metadata: &str, pane_id: u32) -> String { let mut found_id = false; let mut found_action = false; let mut app_wants_report = false; let mut is_query = false; let result = metadata .split(':') .map(|kv| { if kv.starts_with("i=") { found_id = true; // Defer i= rewriting until we know the flags kv.to_string() } else if let Some(action_value) = kv.strip_prefix("a=") { found_action = true; app_wants_report = action_value.split(',').any(|v| v == "report"); if app_wants_report { kv.to_string() } else { format!("a={},report", action_value) } } else if kv == "p=?" { is_query = true; kv.to_string() } else { kv.to_string() } }) .collect::>() .join(":"); // Build flags suffix let mut flags = String::new(); if app_wants_report { flags.push('r'); } if is_query { flags.push('q'); } // Rewrite i= with pane ID and flags let result = result .split(':') .map(|kv| { if let Some(id_value) = kv.strip_prefix("i=") { format!("i=p{}{}.{}", pane_id, flags, id_value) } else { kv.to_string() } }) .collect::>() .join(":"); let result = if !found_id { format!("i=p{}{}.:{}", pane_id, flags, result) } else { result }; if !found_action { format!("{}:a=report", result) } else { result } } use vte::{Params, Perform}; use zellij_utils::{consts::VERSION, shared::version_number}; 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; use crate::panes::search::SearchResult; use crate::panes::terminal_character::{ AnsiCode, CharsetIndex, Cursor, CursorShape, RcCharacterStyles, StandardCharset, TerminalCharacter, EMPTY_TERMINAL_CHARACTER, }; use crate::panes::Selection; use crate::ui::components::UiComponentParser; use zellij_utils::data::PaneContents; fn get_top_non_canonical_rows(rows: &mut VecDeque) -> Vec { let mut index_of_last_non_canonical_row = None; for (i, row) in rows.iter().enumerate() { if row.is_canonical { break; } else { index_of_last_non_canonical_row = Some(i); } } match index_of_last_non_canonical_row { Some(index_of_last_non_canonical_row) => { rows.drain(..=index_of_last_non_canonical_row).collect() }, None => vec![], } } fn get_lines_above_bottom_canonical_row_and_wraps(rows: &mut VecDeque) -> Vec { let mut index_of_last_non_canonical_row = None; for (i, row) in rows.iter().enumerate().rev() { index_of_last_non_canonical_row = Some(i); if row.is_canonical { break; } } match index_of_last_non_canonical_row { Some(index_of_last_non_canonical_row) => { rows.drain(index_of_last_non_canonical_row..).collect() }, None => vec![], } } fn get_viewport_bottom_canonical_row_and_wraps(viewport: &mut VecDeque) -> Vec { let mut index_of_last_non_canonical_row = None; for (i, row) in viewport.iter().enumerate().rev() { index_of_last_non_canonical_row = Some(i); if row.is_canonical { break; } } match index_of_last_non_canonical_row { Some(index_of_last_non_canonical_row) => { viewport.drain(index_of_last_non_canonical_row..).collect() }, None => vec![], } } fn get_top_canonical_row_and_wraps(rows: &mut VecDeque) -> Vec { let mut index_of_first_non_canonical_row = None; let mut end_index_of_first_canonical_line = None; for (i, row) in rows.iter().enumerate() { if row.is_canonical && end_index_of_first_canonical_line.is_none() { index_of_first_non_canonical_row = Some(i); end_index_of_first_canonical_line = Some(i); continue; } if row.is_canonical && end_index_of_first_canonical_line.is_some() { break; } if index_of_first_non_canonical_row.is_some() { end_index_of_first_canonical_line = Some(i); continue; } } match ( index_of_first_non_canonical_row, end_index_of_first_canonical_line, ) { (Some(first_index), Some(last_index)) => rows.drain(first_index..=last_index).collect(), (Some(first_index), None) => rows.drain(first_index..).collect(), _ => vec![], } } fn transfer_rows_from_lines_above_to_viewport( lines_above: &mut VecDeque, viewport: &mut VecDeque, sixel_grid: &mut SixelGrid, kitty_grid: &mut KittyGrid, count: usize, max_viewport_width: usize, ) -> usize { let mut next_lines: Vec = vec![]; let mut lines_added_to_viewport: isize = 0; loop { if lines_added_to_viewport as usize == count { break; } if next_lines.is_empty() { match lines_above.pop_back() { Some(next_line) => { let mut top_non_canonical_rows_in_dst = get_top_non_canonical_rows(viewport); let merged_row_count = top_non_canonical_rows_in_dst.len(); lines_added_to_viewport -= merged_row_count as isize; next_lines.push(next_line); next_lines.append(&mut top_non_canonical_rows_in_dst); next_lines = Row::from_rows(next_lines).split_to_rows_of_length(max_viewport_width); if next_lines.is_empty() { // no more lines at lines_above, the line we popped was probably empty break; } let row_count_delta = next_lines.len() as isize - 1 - merged_row_count as isize; if row_count_delta > 0 { kitty_grid.split_line_start_into_rows( lines_above.len(), row_count_delta as usize, ); } else if row_count_delta < 0 { kitty_grid.merge_rows_into_line_start( lines_above.len(), row_count_delta.unsigned_abs(), ); } }, None => break, // no more rows } } viewport.push_front(next_lines.pop().unwrap()); lines_added_to_viewport += 1; } if !next_lines.is_empty() { kitty_grid.merge_rows_into_line_start(lines_above.len(), next_lines.len() - 1); let excess_row = Row::from_rows(next_lines); bounded_push(lines_above, sixel_grid, kitty_grid, excess_row); } match usize::try_from(lines_added_to_viewport) { Ok(n) => n, _ => 0, } } fn transfer_rows_from_viewport_to_lines_above( viewport: &mut VecDeque, lines_above: &mut VecDeque, sixel_grid: &mut SixelGrid, kitty_grid: &mut KittyGrid, count: usize, max_viewport_width: usize, ) -> isize { let mut transferred_rows_count: isize = 0; let drained_lines = std::cmp::min(count, viewport.len()); for next_line in viewport.drain(..drained_lines) { let mut next_lines: Vec = vec![]; transferred_rows_count += calculate_row_display_height(next_line.width(), max_viewport_width) as isize; if !next_line.is_canonical { let mut bottom_canonical_row_and_wraps_in_dst = get_lines_above_bottom_canonical_row_and_wraps(lines_above); kitty_grid.merge_rows_into_line_start( lines_above.len(), bottom_canonical_row_and_wraps_in_dst.len(), ); next_lines.append(&mut bottom_canonical_row_and_wraps_in_dst); } next_lines.push(next_line); let dropped_line_width = bounded_push( lines_above, sixel_grid, kitty_grid, Row::from_rows(next_lines), ); if let Some(width) = dropped_line_width { transferred_rows_count -= calculate_row_display_height(width, max_viewport_width) as isize; } } transferred_rows_count } fn transfer_rows_from_lines_below_to_viewport( lines_below: &mut VecDeque, viewport: &mut VecDeque, count: usize, max_viewport_width: usize, ) { let mut next_lines: Vec = vec![]; for _ in 0..count { let mut lines_pulled_from_viewport = 0; if next_lines.is_empty() { if !lines_below.is_empty() { let mut top_non_canonical_rows_in_lines_below = get_top_non_canonical_rows(lines_below); if !top_non_canonical_rows_in_lines_below.is_empty() { let mut canonical_line = get_viewport_bottom_canonical_row_and_wraps(viewport); lines_pulled_from_viewport += canonical_line.len(); canonical_line.append(&mut top_non_canonical_rows_in_lines_below); next_lines = Row::from_rows(canonical_line).split_to_rows_of_length(max_viewport_width); } else { let canonical_row = get_top_canonical_row_and_wraps(lines_below); next_lines = Row::from_rows(canonical_row).split_to_rows_of_length(max_viewport_width); } } else { break; // no more rows } } for _ in 0..(lines_pulled_from_viewport + 1) { if !next_lines.is_empty() { viewport.push_back(next_lines.remove(0)); } } } if !next_lines.is_empty() { let excess_row = Row::from_rows(next_lines); lines_below.push_front(excess_row); } } fn bounded_push( vec: &mut VecDeque, sixel_grid: &mut SixelGrid, kitty_grid: &mut KittyGrid, value: Row, ) -> Option { let mut dropped_line_width = None; if vec.len() >= *SCROLL_BUFFER_SIZE.get().unwrap() { let line = vec.pop_front(); if let Some(line) = line { sixel_grid.offset_grid_top(); kitty_grid.offset_grid_top(); if line.is_canonical { let first_canonical_row = if kitty_grid.has_placement_anchored_to_first_canonical_line() { vec.iter() .position(|row| row.is_canonical) .unwrap_or(vec.len()) } else { 0 }; kitty_grid.drop_first_canonical_anchor_line(first_canonical_row); } dropped_line_width = Some(line.width()); } } vec.push_back(value); dropped_line_width } pub fn create_horizontal_tabstops(columns: usize) -> BTreeSet { let mut i = TABSTOP_WIDTH; let mut horizontal_tabstops = BTreeSet::new(); loop { if i > columns { break; } horizontal_tabstops.insert(i); i += TABSTOP_WIDTH; } horizontal_tabstops } fn calculate_row_display_height(row_width: usize, viewport_width: usize) -> usize { if row_width <= viewport_width { return 1; } (row_width as f64 / viewport_width as f64).ceil() as usize } fn subtract_isize_from_usize(u: usize, i: isize) -> usize { if i.is_negative() { u - i.abs() as usize } else { u + i as usize } } pub fn parse_osc7_path(raw: &[u8]) -> Option { let s = std::str::from_utf8(raw).ok()?; let rest = s.strip_prefix("file://")?; let slash_idx = rest.find('/')?; let encoded_path = &rest[slash_idx..]; let mut out = Vec::with_capacity(encoded_path.len()); let bytes = encoded_path.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { let end = i.checked_add(3)?; if end > bytes.len() { return None; } let hex = std::str::from_utf8(&bytes[i + 1..end]).ok()?; let byte = u8::from_str_radix(hex, 16).ok()?; out.push(byte); i = end; } else { out.push(bytes[i]); i += 1; } } let decoded = String::from_utf8(out).ok()?; Some(std::path::PathBuf::from(decoded)) } macro_rules! dump_screen { ($lines:expr) => {{ let mut is_first = true; let mut buf = String::with_capacity($lines.iter().map(|l| l.len()).sum()); for line in &$lines { if line.is_canonical && !is_first { buf.push_str("\n"); } let s: String = (&line.columns).into_iter().map(|x| x.character).collect(); // Replace the spaces at the end of the line. Sometimes, the lines are // collected with spaces until the end of the panel. buf.push_str(&s.trim_end_matches(' ')); is_first = false; } buf }}; } macro_rules! dump_screen_with_ansi { ($lines:expr) => {{ use std::fmt::Write; let mut is_first = true; let mut buf = String::new(); let mut last_styles: Option = None; for line in &$lines { if line.is_canonical && !is_first { buf.push_str("\n"); last_styles = None; } let last_non_space = line .columns .iter() .rposition(|tc| { let space = tc.character == ' '; let styled = !matches!(tc.styles.background, Some(AnsiCode::Reset) | None); !space || styled // it's, something drawable }) .map(|i| i + 1) .unwrap_or(0); for tc in line.columns.iter().take(last_non_space) { // Only output style codes if style changed if last_styles.as_ref() != Some(&tc.styles) { write!(buf, "{}", tc.styles).unwrap(); last_styles = Some(tc.styles.clone()); } buf.push(tc.character); } is_first = false; } buf.push_str("\u{1b}[m"); buf }}; } fn utf8_mouse_coordinates(column: usize, line: isize) -> Vec { let mut coordinates = vec![]; let mouse_pos_encode = |pos: usize| -> Vec { let pos = 32 + pos; let first = 0xC0 + pos / 64; let second = 0x80 + (pos & 63); vec![first as u8, second as u8] }; if column > 95 { coordinates.append(&mut mouse_pos_encode(column)); } else { coordinates.push(32 + column as u8); } if line > 95 { coordinates.append(&mut mouse_pos_encode(line as usize)); } else { coordinates.push(32 + line as 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_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, ) -> 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, ) -> 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)) } /// Extract the effective match from a set of captures. /// If capture group 1 exists, it is used (allowing patterns to include /// context such as surrounding whitespace in the full match while /// highlighting only the content in group 1). Otherwise the full match /// (group 0) is returned. fn highlight_match<'t>(captures: ®ex::Captures<'t>) -> Option> { captures.get(1).or_else(|| captures.get(0)) } /// 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, Copy, PartialEq, Eq)] enum RegionRowsScrolled { IntoScrollback, Discarded, Nothing, } #[derive(Clone, Copy)] struct KittyRowSnapshot { lines_above_len: usize, front_drops: u64, merged_rows: u64, split_rows: u64, } #[derive(Clone)] pub struct Grid { pub(crate) lines_above: VecDeque, pub(crate) viewport: VecDeque, pub(crate) lines_below: VecDeque, horizontal_tabstops: BTreeSet, alternate_screen_state: Option, cursor: Cursor, cursor_is_hidden: bool, saved_cursor_position: Option, scroll_region: (usize, usize), active_charset: CharsetIndex, preceding_char: Option, #[allow(dead_code)] terminal_emulator_colors: Rc>, #[allow(dead_code)] terminal_emulator_color_codes: Rc>>, pub(crate) output_buffer: OutputBuffer, title_stack: Vec, character_cell_size: Rc>>, sixel_grid: SixelGrid, kitty_grid: KittyGrid, kitty_parser: KittyCommandParser, kitty_host_support: KittyHostSupport, pub changed_colors: Option<[Option; 256]>, pub should_render: bool, pub lock_renders: bool, pub cursor_key_mode: bool, // DECCKM - when set, cursor keys should send ANSI direction codes (eg. "OD") instead of the arrow keys (eg. "") pub bracketed_paste_mode: bool, // when set, paste instructions to the terminal should be escaped with a special sequence pub erasure_mode: bool, // ERM pub sixel_scrolling: bool, // DECSDM pub insert_mode: bool, pub disable_linewrap: bool, pub new_line_mode: bool, // Automatic newline LNM pub clear_viewport_before_rendering: bool, pub width: usize, pub height: usize, pub pending_messages_to_pty: Vec>, pub selection: Selection, pub title: Option, /// Text the program in this pane asked to show in its bottom frame row, via /// OSC 5577. Persistent like `title` rather than drained, so it survives /// re-renders, resizes and detach/reattach for as long as the pane lives. pub status_bar: Option, /// Clickable buttons for this pane's status bar (OSC 5577 `buttons`). pub status_bar_buttons: Vec, /// Opaque agent-facing hint for what this pane is FOR (OSC 5577 `purpose`, /// or the layout's `purpose=`). Zellij never interprets it. pub pane_purpose: Option, /// Was `pane_purpose` set from OUTSIDE the pane (a layout, or a resurrected /// session)? Such a purpose is authoritative and in-band OSC cannot change /// it -- see `declare_purpose_in_band`. pub pane_purpose_is_authoritative: bool, pub is_scrolled: bool, pub link_handler: Rc>, pub ring_bell: bool, scrollback_buffer_lines: usize, pub mouse_mode: MouseMode, pub mouse_tracking: MouseTracking, pub focus_event_tracking: bool, /// Has the app in this pane subscribed to host color-palette theme /// notifications via `CSI ? 2031 h`? When true, host-emitted DSR 997 /// notifications (received by the client and forwarded as /// `ScreenInstruction::HostTerminalThemeChanged`) are pushed onto /// `pending_messages_to_pty` for this pane. pub color_palette_notification_enabled: bool, pub search_results: SearchResult, pub pending_clipboard_update: Option, pub pending_osc7_cwd: Option, /// Pending desktop notifications: (payload, terminator) /// Payload is the semicolon-joined params after "99", terminator is "\x07" or "\x1b\\" pub pending_desktop_notifications: Vec<(String, String)>, /// Whitelisted host-terminal queries intercepted from the app running /// in this pane (CSI 14t / 16t pixel-dim queries, OSC 10;? / 11;? / /// 4;N;? color queries). Each entry is the raw byte sequence that /// Zellij should forward to the host terminal; the host's reply is /// later routed back to this pane's pty. pub pending_forwarded_queries: Vec, pub pending_nested_session_messages: Vec, ui_component_bytes: Option>, nested_frame_bytes: Option>, style: Style, debug: bool, arrow_fonts: bool, styled_underlines: bool, osc8_hyperlinks: bool, pub supports_kitty_keyboard_protocol: bool, // has the app requested kitty keyboard support? explicitly_disable_kitty_keyboard_protocol: bool, // has kitty keyboard support been explicitly // disabled by user config? click: Click, hyperlink_tracker: HyperlinkTracker, /// Pane-scoped override for the default foreground colour. Narrow /// to literal RGB by construction — the setters below refuse /// palette-indexed / named variants silently, so this field can be /// converted to an `OSC 10` reply without fallibility and the /// render path can wrap it unconditionally. pub pane_default_fg: Option<(u8, u8, u8)>, /// Pane-scoped override for the default background colour. Same /// invariant as `pane_default_fg`. pub pane_default_bg: Option<(u8, u8, u8)>, pub plugin_highlights: HashMap>, // key: plugin_id (u32), inner vec: (pattern, compiled) pairs pub hover_position: Option, // pane-relative cursor cell; None when outside pane pub cached_hover_tooltip: Option, } impl Grid { pub fn set_pane_default_colors(&mut self, fg: Option, bg: Option) { // Parse inputs; anything that isn't literal RGB (palette // index / named colour / parse failure) is silently dropped so // the invariant on `pane_default_{fg,bg}` is preserved. self.pane_default_fg = fg .as_ref() .and_then(|s| xparse_color(s.as_bytes())) .and_then(rgb_of_ansi_code); self.pane_default_bg = bg .as_ref() .and_then(|s| xparse_color(s.as_bytes())) .and_then(rgb_of_ansi_code); self.output_buffer.update_all_lines(); } pub fn get_pane_default_color_strings(&self) -> (Option, Option) { ( self.pane_default_fg.map(rgb_to_hex_string), self.pane_default_bg.map(rgb_to_hex_string), ) } } /// Extract the RGB triple from an `AnsiCode`, or `None` for any /// other variant. Used at the ingress points that populate /// `Grid::pane_default_{fg,bg}` to keep those fields narrow to /// literal RGB. fn rgb_of_ansi_code(code: AnsiCode) -> Option<(u8, u8, u8)> { match code { AnsiCode::RgbCode(rgb) => Some(rgb), _ => None, } } fn rgb_to_hex_string((r, g, b): (u8, u8, u8)) -> String { format!("#{:02x}{:02x}{:02x}", r, g, b) } /// Format an RGB triple as the body of an OSC 10/11 reply — /// `rgb:RRRR/GGGG/BBBB` per xterm's ctlseqs, where each 8-bit /// component is widened to the 16-bit hex form by repetition /// (`0xAB` → `0xABAB`). fn osc_color_reply_body((r, g, b): (u8, u8, u8)) -> String { let expand = |c: u8| (c as u16) * 0x0101; format!("rgb:{:04x}/{:04x}/{:04x}", expand(r), expand(g), expand(b)) } /// A compiled highlight entry for one plugin/pattern combination. #[derive(Clone)] pub struct CompiledHighlight { pub regex: regex::Regex, pub fg: Option, pub bg: Option, pub context: BTreeMap, pub on_hover: bool, pub bold: bool, pub italic: bool, pub underline: bool, pub tooltip_text: Option, pub layer: HighlightLayer, } 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 } } pub const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold /// Tracks repeated clicks at the same position within CLICK_TIME_THRESHOLD. /// Public so the frame/title double-click handling can reuse the exact same /// notion of "double click" as terminal text selection, rather than inventing a /// second threshold that could drift. #[derive(Clone, Debug, Default)] pub struct Click { position_and_time: Option<(Position, std::time::Instant)>, count: usize, } impl Click { pub fn record_click(&mut self, position: Position) { let click_is_same_position_as_last_click = self .position_and_time .map(|(p, _t)| p == position) .unwrap_or(false); let click_is_within_time_threshold = self .position_and_time .map(|(_p, t)| t.elapsed().as_millis() <= CLICK_TIME_THRESHOLD) .unwrap_or(false); if click_is_same_position_as_last_click && click_is_within_time_threshold { self.count += 1; } else { self.count = 1; } self.position_and_time = Some((position, std::time::Instant::now())); if self.count == 4 { self.reset(); } } pub fn is_double_click(&self) -> bool { self.count == 2 } pub fn is_triple_click(&self) -> bool { self.count == 3 } pub fn reset(&mut self) { self.count = 0; } } #[derive(Clone, Debug)] pub enum MouseMode { NoEncoding, Utf8, Sgr, } impl Default for MouseMode { fn default() -> Self { MouseMode::NoEncoding } } #[derive(Clone, Debug, PartialEq)] pub enum MouseTracking { Off, Normal, ButtonEventTracking, AnyEventTracking, } impl Default for MouseTracking { fn default() -> Self { MouseTracking::Off } } impl Debug for Grid { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let mut buffer: Vec = Vec::from(self.viewport.clone()); // pad buffer for _ in buffer.len()..self.height { buffer.push(Row::new().canonical()); } // display sixel placeholder let sixel_indication_character = |x| { let sixel_indication_word = "Sixel"; sixel_indication_word .chars() .nth(x % sixel_indication_word.len()) .unwrap() }; for image_coordinates in self .sixel_grid .image_cell_coordinates_in_viewport(self.height, self.lines_above.len()) { let (image_top_edge, image_bottom_edge, image_left_edge, image_right_edge) = image_coordinates; for y in image_top_edge..image_bottom_edge { let row = buffer.get_mut(y).unwrap(); for x in image_left_edge..image_right_edge { let fake_sixel_terminal_character = TerminalCharacter::new_singlewidth(sixel_indication_character(x)); row.add_character_at(fake_sixel_terminal_character, x); } } } let kitty_indication_character = |x| { let kitty_indication_word = "KittyImage"; kitty_indication_word .chars() .nth(x % kitty_indication_word.len()) .unwrap() }; for image_coordinates in self .kitty_grid .image_cell_coordinates_in_viewport(self.height, self.lines_above.len()) { let (image_top_edge, image_bottom_edge, image_left_edge, image_right_edge) = image_coordinates; for y in image_top_edge..image_bottom_edge { if let Some(row) = buffer.get_mut(y) { for x in image_left_edge..image_right_edge { let fake_kitty_terminal_character = TerminalCharacter::new_singlewidth(kitty_indication_character(x)); row.add_character_at(fake_kitty_terminal_character, x); } } } } // display terminal characters with stripped styles for (i, row) in buffer.iter().enumerate() { let mut cow_row = Cow::Borrowed(row); self.search_results .mark_search_results_in_row(&mut cow_row, i); if row.is_canonical { writeln!(f, "{:02?} (C): {:?}", i, cow_row)?; } else { writeln!(f, "{:02?} (W): {:?}", i, cow_row)?; } } Ok(()) } } fn resolve_highlight_colors( style_decl: &HighlightStyle, style: &Style, ) -> (Option, Option) { 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 { /// A program inside the pane declaring what the pane is for (OSC 5577). /// /// Refuses when the purpose is authoritative -- i.e. it came from a layout or /// a resurrected session. OSC arrives in-band from whatever the pane is /// showing, so `cat hostile.txt` in the human's editor pane could otherwise /// relabel it as the agent's scratch pane and invite an agent to write there. /// Declared purposes are written by a human in a file they can read; in-band /// ones can only fill a blank. /// /// Returns whether the purpose was accepted. pub fn declare_purpose_in_band(&mut self, raw: &str) -> bool { if self.pane_purpose_is_authoritative { return false; } self.pane_purpose = zellij_utils::input::layout::sanitize_pane_purpose(raw); true } /// Set the purpose from outside the pane (layout, CLI `--purpose`, /// resurrection). This wins over anything the pane itself declares, now or /// later. /// /// Sanitised here rather than at each caller: this is the one choke point /// every out-of-band source passes through, and a purpose reaching a /// consumer's shell or JSON pipeline unsanitised is the whole risk. A layout /// value has already been sanitised at parse time, which is idempotent. pub fn set_authoritative_purpose(&mut self, purpose: Option) { self.pane_purpose = purpose .as_deref() .and_then(zellij_utils::input::layout::sanitize_pane_purpose); self.pane_purpose_is_authoritative = true; } pub fn new( rows: usize, columns: usize, terminal_emulator_colors: Rc>, terminal_emulator_color_codes: Rc>>, link_handler: Rc>, character_cell_size: Rc>>, sixel_image_store: Rc>, kitty_image_store: Rc>, style: Style, // TODO: consolidate this with terminal_emulator_colors debug: bool, arrow_fonts: bool, styled_underlines: bool, osc8_hyperlinks: bool, explicitly_disable_kitty_keyboard_protocol: bool, ) -> Self { let sixel_grid = SixelGrid::new(character_cell_size.clone(), sixel_image_store); let kitty_grid = KittyGrid::new(character_cell_size.clone(), kitty_image_store); // make sure this is initialized as it is used internally // if it was already initialized (which should happen normally unless this is a test or // something changed since this comment was written), we get an Error which we ignore // I don't know why this needs to be a OneCell, but whatevs let _ = SCROLL_BUFFER_SIZE.set(DEFAULT_SCROLL_BUFFER_SIZE); Grid { lines_above: VecDeque::new(), viewport: VecDeque::from(vec![Row::new().canonical()]), lines_below: VecDeque::new(), horizontal_tabstops: create_horizontal_tabstops(columns), cursor: Cursor::new(0, 0, styled_underlines), cursor_is_hidden: false, saved_cursor_position: None, scroll_region: (0, rows.saturating_sub(1)), preceding_char: None, width: columns, height: rows, should_render: true, cursor_key_mode: false, bracketed_paste_mode: false, erasure_mode: false, sixel_scrolling: false, insert_mode: false, disable_linewrap: false, new_line_mode: false, alternate_screen_state: None, clear_viewport_before_rendering: false, active_charset: Default::default(), pending_messages_to_pty: vec![], terminal_emulator_colors, terminal_emulator_color_codes, output_buffer: Default::default(), selection: Default::default(), title_stack: vec![], title: None, status_bar: None, status_bar_buttons: vec![], pane_purpose: None, pane_purpose_is_authoritative: false, changed_colors: None, is_scrolled: false, link_handler, ring_bell: false, scrollback_buffer_lines: 0, mouse_mode: MouseMode::default(), mouse_tracking: MouseTracking::default(), focus_event_tracking: false, color_palette_notification_enabled: false, character_cell_size, search_results: Default::default(), sixel_grid, kitty_grid, kitty_parser: KittyCommandParser::new(), kitty_host_support: KittyHostSupport::Supported, pending_clipboard_update: None, pending_osc7_cwd: None, pending_desktop_notifications: Vec::new(), pending_forwarded_queries: Vec::new(), pending_nested_session_messages: Vec::new(), ui_component_bytes: None, nested_frame_bytes: None, style, debug, arrow_fonts, styled_underlines, osc8_hyperlinks, lock_renders: false, supports_kitty_keyboard_protocol: false, explicitly_disable_kitty_keyboard_protocol, click: Click::default(), hyperlink_tracker: HyperlinkTracker::new(), pane_default_fg: None, pane_default_bg: None, plugin_highlights: HashMap::new(), hover_position: None, cached_hover_tooltip: None, } } pub fn render_full_viewport(&mut self) { self.output_buffer.update_all_lines(); } pub fn update_line_for_rendering(&mut self, line_index: usize) { self.output_buffer.update_line(line_index); } pub fn advance_to_next_tabstop(&mut self, styles: RcCharacterStyles) { let next_tabstop = self .horizontal_tabstops .iter() .copied() .find(|&tabstop| tabstop > self.cursor.x && tabstop < self.width); match next_tabstop { Some(tabstop) => { self.cursor.x = tabstop; }, None => { self.cursor.x = self.width.saturating_sub(1); }, } let mut empty_character = EMPTY_TERMINAL_CHARACTER; empty_character.styles = styles; self.pad_current_line_until(self.cursor.x, empty_character); self.output_buffer.update_line(self.cursor.y); } pub fn move_to_previous_tabstop(&mut self) { let previous_tabstop = self .horizontal_tabstops .iter() .rev() .copied() .find(|&tabstop| tabstop < self.cursor.x); match previous_tabstop { Some(tabstop) => { self.cursor.x = tabstop; }, None => { self.cursor.x = 0; }, } } pub fn cursor_shape(&self) -> CursorShape { self.cursor.get_shape() } pub fn scrollback_position_and_length(&self) -> (usize, usize) { // (position, length) ( self.lines_below.len(), (self.scrollback_buffer_lines + self.lines_below.len()), ) } fn recalculate_scrollback_buffer_count(&mut self) -> usize { let mut scrollback_buffer_count = 0; for row in &mut self.lines_above { let row_width = row.width_cached(); // rows in lines_above are unwrapped, so we need to account for that if row_width > self.width { scrollback_buffer_count += calculate_row_display_height(row_width, self.width); } else { scrollback_buffer_count += 1; } } scrollback_buffer_count } fn set_horizontal_tabstop(&mut self) { self.horizontal_tabstops.insert(self.cursor.x); } fn clear_tabstop(&mut self, position: usize) { self.horizontal_tabstops.remove(&position); } fn clear_all_tabstops(&mut self) { self.horizontal_tabstops.clear(); } fn save_cursor_position(&mut self) { self.saved_cursor_position = Some(self.cursor.clone()); } fn restore_cursor_position(&mut self) { if let Some(saved_cursor_position) = &self.saved_cursor_position { self.cursor = saved_cursor_position.clone(); } } fn configure_charset(&mut self, charset: StandardCharset, index: CharsetIndex) { self.cursor.charsets[index] = charset; } fn set_active_charset(&mut self, index: CharsetIndex) { self.active_charset = index; } fn cursor_canonical_line_index(&self) -> usize { let mut cursor_canonical_line_index = 0; let mut canonical_lines_traversed = 0; for (i, line) in self.viewport.iter().enumerate() { if line.is_canonical { cursor_canonical_line_index = canonical_lines_traversed; canonical_lines_traversed += 1; } if i == self.cursor.y { break; } } cursor_canonical_line_index } // TODO: merge these two functions fn cursor_index_in_canonical_line(&self) -> usize { let mut cursor_canonical_line_index = 0; let mut cursor_index_in_canonical_line = 0; for (i, line) in self.viewport.iter().enumerate() { if line.is_canonical { cursor_canonical_line_index = i; } if i == self.cursor.y { let line_wraps = self.cursor.y.saturating_sub(cursor_canonical_line_index); cursor_index_in_canonical_line = (line_wraps * self.width) + self.cursor.x; break; } } cursor_index_in_canonical_line } fn saved_cursor_index_in_canonical_line(&self) -> Option { if let Some(saved_cursor_position) = self.saved_cursor_position.as_ref() { let mut cursor_canonical_line_index = 0; let mut cursor_index_in_canonical_line = 0; for (i, line) in self.viewport.iter().enumerate() { if line.is_canonical { cursor_canonical_line_index = i; } if i == saved_cursor_position.y { let line_wraps = saved_cursor_position.y - cursor_canonical_line_index; cursor_index_in_canonical_line = (line_wraps * self.width) + saved_cursor_position.x; break; } } Some(cursor_index_in_canonical_line) } else { None } } fn canonical_line_y_coordinates(&self, canonical_line_index: usize) -> usize { let mut canonical_lines_traversed = 0; let mut y_coordinates = 0; for (i, line) in self.viewport.iter().enumerate() { if line.is_canonical { canonical_lines_traversed += 1; y_coordinates = i; if canonical_lines_traversed == canonical_line_index + 1 { break; } } } y_coordinates } fn kitty_canonical_line_starts(&self) -> Vec { let mut starts = Vec::new(); let mut wrapped_row = 0usize; for row in self .lines_above .iter() .chain(self.viewport.iter()) .chain(self.lines_below.iter()) { if row.is_canonical { starts.push(wrapped_row); } wrapped_row += 1; } starts } fn kitty_anchor_from_pixel_y( pixel_y: isize, cell_height: isize, starts: &[usize], ) -> KittyVerticalAnchor { let wrapped_row = pixel_y.div_euclid(cell_height); let canonical_line = match starts.binary_search(&wrapped_row.max(0).try_into().unwrap_or(0)) { Ok(index) => index, Err(index) => index.saturating_sub(1), }; let line_start_px = *starts.get(canonical_line).unwrap_or(&0) as isize * cell_height; KittyVerticalAnchor { canonical_line, offset_px_from_line_start: pixel_y - line_start_px, } } fn kitty_pixel_y_from_anchor( anchor: &KittyVerticalAnchor, cell_height: isize, starts: &[usize], ) -> Option { starts .get(anchor.canonical_line) .map(|line_start| *line_start as isize * cell_height + anchor.offset_px_from_line_start) } fn kitty_rows_below_the_viewport(&self) -> Option { if self.lines_below.is_empty() { None } else { let first_row = self.lines_above.len() + self.viewport.len(); Some(KittyRowsBelowTheViewport { first_row, total_rows: first_row + self.lines_below.len(), }) } } fn kitty_settle_placements_below_the_viewport(&mut self) -> bool { let rows_below_the_viewport = self.kitty_rows_below_the_viewport(); self.kitty_grid .settle_placements_below_the_viewport(rows_below_the_viewport) } fn kitty_reanchor_all_from_pixels(&mut self) { self.kitty_settle_placements_below_the_viewport(); if self.kitty_grid.placement_count() == 0 { return; } let cell_height = match *self.character_cell_size.borrow() { Some(cell) => cell.height as isize, None => return, }; let starts = self.kitty_canonical_line_starts(); if starts.is_empty() { return; } for placement in self.kitty_grid.placements_mut() { placement.vertical_anchor = Self::kitty_anchor_from_pixel_y(placement.display_rect.y, cell_height, &starts); } } fn kitty_reproject_all_from_anchor(&mut self) { let rows_below_the_viewport = self.kitty_rows_below_the_viewport(); self.kitty_grid .note_rows_below_the_viewport(rows_below_the_viewport); if self.kitty_grid.placement_count() == 0 { return; } let cell_height = match *self.character_cell_size.borrow() { Some(cell) => cell.height as isize, None => return, }; let starts = self.kitty_canonical_line_starts(); let line_count = starts.len(); self.kitty_grid .retain_placements(|placement| placement.vertical_anchor.canonical_line < line_count); for placement in self.kitty_grid.placements_mut() { if let Some(pixel_y) = Self::kitty_pixel_y_from_anchor(&placement.vertical_anchor, cell_height, &starts) { placement.display_rect.y = pixel_y; } } } pub fn scroll_up_one_line(&mut self) -> bool { let mut found_something = false; if !self.lines_above.is_empty() && self.viewport.len() == self.height { self.is_scrolled = true; let line_to_push_down = self.viewport.pop_back().unwrap(); self.lines_below.push_front(line_to_push_down); let transferred_rows_height = transfer_rows_from_lines_above_to_viewport( &mut self.lines_above, &mut self.viewport, &mut self.sixel_grid, &mut self.kitty_grid, 1, self.width, ); self.kitty_reanchor_all_from_pixels(); self.scrollback_buffer_lines = self .scrollback_buffer_lines .saturating_sub(transferred_rows_height); self.selection.move_down(1); // Move all search-selections down one line as well found_something = self .search_results .move_down(1, &self.viewport, self.height); } self.output_buffer.update_all_lines(); found_something } pub fn scroll_down_one_line(&mut self) -> bool { let mut found_something = false; if !self.lines_below.is_empty() && self.viewport.len() == self.height && !self.viewport.is_empty() { let mut line_to_push_up = self.viewport.pop_front().unwrap(); self.scrollback_buffer_lines += calculate_row_display_height(line_to_push_up.width(), self.width); let line_to_push_up = if line_to_push_up.is_canonical { line_to_push_up } else { match self.lines_above.pop_back() { Some(mut last_line_above) => { self.kitty_grid .merge_rows_into_line_start(self.lines_above.len(), 1); last_line_above.append(&mut line_to_push_up.columns); last_line_above }, None => { // in this case, this line was not canonical but its beginning line was // dropped out of scope, so we make it canonical and push it up self.kitty_grid.insert_canonical_anchor_line_at_front(); line_to_push_up.canonical() }, } }; let dropped_line_width = bounded_push( &mut self.lines_above, &mut self.sixel_grid, &mut self.kitty_grid, line_to_push_up, ); if let Some(width) = dropped_line_width { let dropped_line_height = calculate_row_display_height(width, self.width); self.scrollback_buffer_lines = self .scrollback_buffer_lines .saturating_sub(dropped_line_height); } transfer_rows_from_lines_below_to_viewport( &mut self.lines_below, &mut self.viewport, 1, self.width, ); self.kitty_reanchor_all_from_pixels(); self.selection.move_up(1); // Move all search-selections up one line as well found_something = self.search_results .move_up(1, &self.viewport, &self.lines_below, self.height); self.output_buffer.update_all_lines(); } if self.lines_below.is_empty() { self.is_scrolled = false; } found_something } pub fn force_change_size(&mut self, new_rows: usize, new_columns: usize) { // this is an ugly hack - it's here because sometimes we need to change_size to the // existing size (eg. when resizing an alternative_grid to the current height/width) and // the change_size method is a no-op in that case. Should be fixed by making the // change_size method atomic let intermediate_rows = if new_rows == self.height { new_rows + 1 } else { new_rows }; let intermediate_columns = if new_columns == self.width { new_columns + 1 } else { new_columns }; self.change_size(intermediate_rows, intermediate_columns); self.change_size(new_rows, new_columns); } pub fn change_size(&mut self, new_rows: usize, new_columns: usize) { // Do nothing if this pane hasn't been given a proper size yet if new_columns == 0 || new_rows == 0 { return; } if self.alternate_screen_state.is_some() { // in alternate screen we do nothing but log the new size, the program in the terminal // is in control now... self.height = new_rows; self.width = new_columns; self.set_scroll_region_to_viewport_size(); self.output_buffer.update_all_lines(); return; } self.selection.reset(); self.sixel_grid.character_cell_size_possibly_changed(); self.kitty_grid.character_cell_size_possibly_changed(); self.kitty_reanchor_all_from_pixels(); let cursors = if new_columns != self.width { self.horizontal_tabstops = create_horizontal_tabstops(new_columns); let mut cursor_canonical_line_index = self.cursor_canonical_line_index(); let cursor_index_in_canonical_line = self.cursor_index_in_canonical_line(); let saved_cursor_index_in_canonical_line = self.saved_cursor_index_in_canonical_line(); let mut viewport_canonical_lines = vec![]; for mut row in self.viewport.drain(..) { if !row.is_canonical && viewport_canonical_lines.is_empty() && !self.lines_above.is_empty() { let mut first_line_above = self.lines_above.pop_back().unwrap(); first_line_above.append(&mut row.columns); viewport_canonical_lines.push(first_line_above); cursor_canonical_line_index += 1; } else if row.is_canonical { viewport_canonical_lines.push(row); } else { match viewport_canonical_lines.last_mut() { Some(last_line) => { last_line.append(&mut row.columns); }, None => { // the state is corrupted somehow // this is a bug and I'm not yet sure why it happens // usually it fixes itself and is a result of some race // TODO: investigate why this happens and solve it return; }, } } } // trim lines after the last empty space that has no following character, because // terminals don't trim empty lines for line in &mut viewport_canonical_lines { let mut trim_at = None; for (index, character) in line.columns.iter().enumerate() { let is_trimmable_space = character.character == EMPTY_TERMINAL_CHARACTER.character && matches!(character.styles.background, Some(AnsiCode::Reset) | None); if !is_trimmable_space { // we can't trim this character, meaning that if we had a previous // character that we marked as the trim_at point, we need to clear it trim_at = None; } else if trim_at.is_none() { // we CAN trim this character, set the trim_at point only if it's not set // because we want the trim_at point to be the EARLIEST trimmable character trim_at = Some(index); } } if let Some(trim_at) = trim_at { let excess_width_until_trim_at = line.excess_width_until(trim_at); line.truncate(trim_at + excess_width_until_trim_at); } } let mut new_viewport_rows = vec![]; for mut canonical_line in viewport_canonical_lines { let mut canonical_line_parts = canonical_line.split_to_rows_of_length(new_columns); // If a character is wider than the grid, split_to_rows_of_length returns an empty // vec — skip the line, matching the old `break` behavior if canonical_line_parts.is_empty() { continue; } new_viewport_rows.append(&mut canonical_line_parts); } self.viewport = VecDeque::from(new_viewport_rows); let mut new_cursor_y = self.canonical_line_y_coordinates(cursor_canonical_line_index) + (cursor_index_in_canonical_line / new_columns); let mut saved_cursor_y_coordinates = self.saved_cursor_position.as_ref().map(|saved_cursor| { self.canonical_line_y_coordinates(saved_cursor.y) + saved_cursor_index_in_canonical_line.as_ref().unwrap() / new_columns }); // A cursor at EOL has two equivalent positions - end of this line or beginning of // next. If not already at the beginning of line, bias to EOL so add character logic // doesn't create spurious canonical lines let mut new_cursor_x = cursor_index_in_canonical_line % new_columns; if self.cursor.x != 0 && new_cursor_x == 0 { new_cursor_y = new_cursor_y.saturating_sub(1); new_cursor_x = new_columns } let saved_cursor_x_coordinates = match ( saved_cursor_index_in_canonical_line.as_ref(), self.saved_cursor_position.as_mut(), saved_cursor_y_coordinates.as_mut(), ) { ( Some(saved_cursor_index_in_canonical_line), Some(saved_cursor_position), Some(saved_cursor_y_coordinates), ) => { let x = saved_cursor_position.x; let mut new_x = *saved_cursor_index_in_canonical_line % new_columns; let new_y = saved_cursor_y_coordinates; if x != 0 && new_x == 0 { *new_y = new_y.saturating_sub(1); new_x = new_columns } Some(new_x) }, _ => None, }; Some(( new_cursor_y, saved_cursor_y_coordinates, new_cursor_x, saved_cursor_x_coordinates, )) } else if new_rows != self.height { let saved_cursor_y_coordinates = self .saved_cursor_position .as_ref() .map(|saved_cursor| saved_cursor.y); let saved_cursor_x_coordinates = self .saved_cursor_position .as_ref() .map(|saved_cursor| saved_cursor.x); Some(( self.cursor.y, saved_cursor_y_coordinates, self.cursor.x, saved_cursor_x_coordinates, )) } else { None }; if let Some(cursors) = cursors { // At this point the x coordinates have been calculated, the y coordinates // will be updated within this block let ( mut new_cursor_y, mut saved_cursor_y_coordinates, new_cursor_x, saved_cursor_x_coordinates, ) = cursors; let current_viewport_row_count = self.viewport.len(); match current_viewport_row_count.cmp(&new_rows) { Ordering::Less => { let row_count_to_transfer = new_rows - current_viewport_row_count; transfer_rows_from_lines_above_to_viewport( &mut self.lines_above, &mut self.viewport, &mut self.sixel_grid, &mut self.kitty_grid, row_count_to_transfer, new_columns, ); let rows_pulled = self.viewport.len() - current_viewport_row_count; new_cursor_y += rows_pulled; if let Some(saved_cursor_y_coordinates) = saved_cursor_y_coordinates.as_mut() { *saved_cursor_y_coordinates += rows_pulled; }; }, Ordering::Greater => { let row_count_to_transfer = current_viewport_row_count - new_rows; if row_count_to_transfer > new_cursor_y { new_cursor_y = 0; } else { new_cursor_y -= row_count_to_transfer; } if let Some(saved_cursor_y_coordinates) = saved_cursor_y_coordinates.as_mut() { if row_count_to_transfer > *saved_cursor_y_coordinates { *saved_cursor_y_coordinates = 0; } else { *saved_cursor_y_coordinates -= row_count_to_transfer; } } transfer_rows_from_viewport_to_lines_above( &mut self.viewport, &mut self.lines_above, &mut self.sixel_grid, &mut self.kitty_grid, row_count_to_transfer, new_columns, ); }, Ordering::Equal => {}, } self.cursor.y = new_cursor_y; self.cursor.x = new_cursor_x; if let Some(saved_cursor_position) = self.saved_cursor_position.as_mut() { match (saved_cursor_x_coordinates, saved_cursor_y_coordinates) { (Some(saved_cursor_x_coordinates), Some(saved_cursor_y_coordinates)) => { saved_cursor_position.x = saved_cursor_x_coordinates; saved_cursor_position.y = saved_cursor_y_coordinates; }, _ => log::error!( "invalid state - cannot set saved cursor to {:?} {:?}", saved_cursor_x_coordinates, saved_cursor_y_coordinates ), } }; } self.kitty_reproject_all_from_anchor(); self.height = new_rows; self.width = new_columns; self.set_scroll_region_to_viewport_size(); self.scrollback_buffer_lines = self.recalculate_scrollback_buffer_count(); self.search_results.selections.clear(); self.search_viewport(); // If we have thrown out the active element, set it to None self.search_results.unset_active_selection_if_nonexistent(); self.output_buffer.update_all_lines(); } pub fn as_character_lines(&self) -> Vec> { // this is only used in the tests // it's not part of testing the app, but rather is used to interpret the snapshots created // by it let mut lines: Vec> = self .viewport .iter() .map(|r| { let excess_width = r.excess_width(); let mut line: Vec = r.columns.iter().cloned().collect(); // pad line line.resize( self.width.saturating_sub(excess_width), EMPTY_TERMINAL_CHARACTER, ); line }) .collect(); let empty_row = vec![EMPTY_TERMINAL_CHARACTER; self.width]; for _ in lines.len()..self.height { lines.push(empty_row.clone()); } lines } pub fn read_changes( &mut self, x_offset: usize, y_offset: usize, ) -> ( Vec, Vec, Vec, ) { let changed_character_chunks = self.output_buffer.changed_chunks_in_viewport( self.viewport.make_contiguous(), self.width, self.height, x_offset, y_offset, ); let changed_rects = self .output_buffer .changed_rects_in_viewport(self.viewport.len()); let changed_sixel_image_chunks = self.sixel_grid.changed_sixel_chunks_in_viewport( changed_rects, self.lines_above.len(), self.width, x_offset, y_offset, ); if let Some(image_ids_to_reap) = self.sixel_grid.drain_image_ids_to_reap() { self.sixel_grid.reap_images(image_ids_to_reap); } if self.kitty_settle_placements_below_the_viewport() { self.kitty_reanchor_all_from_pixels(); } let kitty_image_chunks = self.kitty_grid.viewport_kitty_chunks( self.height, self.lines_above.len(), self.width, x_offset, y_offset, ); self.output_buffer.clear(); ( changed_character_chunks, changed_sixel_image_chunks, kitty_image_chunks, ) } pub fn serialize(&self, scrollback_lines_to_serialize: Option) -> Option { match scrollback_lines_to_serialize { Some(scrollback_lines_to_serialize) => { let first_index = if scrollback_lines_to_serialize == 0 { 0 } else { self.lines_above .len() .saturating_sub(scrollback_lines_to_serialize) }; let mut to_serialize = vec![]; for line in self.lines_above.iter().skip(first_index) { to_serialize.push(line.clone()); } for line in &self.viewport { to_serialize.push(line.clone()) } self.output_buffer .serialize(to_serialize.as_slice(), self.osc8_hyperlinks, None) .ok() }, None => { let viewport_vec: Vec = self.viewport.iter().cloned().collect(); self.output_buffer .serialize(&viewport_vec, self.osc8_hyperlinks, None) .ok() }, } } pub fn render( &mut self, content_x: usize, content_y: usize, style: &Style, ) -> Result< Option<( Vec, Option, Vec, Vec, )>, > { if self.lock_renders { return Ok(None); } let raw_vte_output = String::new(); let (mut character_chunks, sixel_image_chunks, kitty_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); // CharacterChunk still carries `Option` because // `adjust_styles_for_custom_bg_fg` assigns the value into // `CharacterStyles.{foreground,background}` (themselves // `Option`). Re-wrap the narrow RGB at the // boundary so the downstream pipeline stays uniform. character_chunk.add_pane_defaults( self.pane_default_fg.map(AnsiCode::RgbCode), self.pane_default_bg.map(AnsiCode::RgbCode), ); if self .selection .contains_row(character_chunk.y.saturating_sub(content_y)) { let background_color = match style.colors.text_selected.background { PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), }; let foreground_color = match style.colors.text_selected.base { PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), }; character_chunk.add_selection_and_colors( HighlightSelection { selection: self.selection, bg: Some(background_color), fg: Some(foreground_color), bold: false, italic: false, underline: false, layer: HighlightLayer::ActionFeedback, }, content_x, content_y, ); } else if !self.search_results.selections.is_empty() { for res in self.search_results.selections.iter() { if res.contains_row(character_chunk.y.saturating_sub(content_y)) { let (select_background_palette, select_foreground_palette) = if Some(res) == self.search_results.active.as_ref() { ( style.colors.text_unselected.emphasis_0, style.colors.text_unselected.background, ) } else { ( style.colors.text_unselected.emphasis_2, style.colors.text_unselected.background, ) }; let background_color = match select_background_palette { PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), }; let foreground_color = match select_foreground_palette { PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), }; character_chunk.add_selection_and_colors( HighlightSelection { selection: *res, bg: Some(background_color), fg: Some(foreground_color), bold: false, italic: false, underline: false, layer: HighlightLayer::ActionFeedback, }, 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, Some(raw_vte_output), sixel_image_chunks, kitty_image_chunks, ))); } /// Returns the cursor position and whether it is visible. /// The position is returned unconditionally (as long as the cursor is within /// bounds) so that the host terminal can position the cursor for IME even /// when the app has hidden it. The bool is true when the cursor is visible. pub fn cursor_coordinates(&self) -> Option<(usize, usize, bool)> { if self.cursor.x >= self.width || self.cursor.y >= self.height { None } else { Some((self.cursor.x, self.cursor.y, !self.cursor_is_hidden)) } } pub fn is_mid_frame(&self) -> bool { self.lock_renders } /// Clears all buffers with text for a current screen pub fn clear_screen(&mut self) { if self.alternate_screen_state.is_some() { log::warn!("Tried to clear pane with alternate_screen_state"); return; } self.reset_terminal_state(); self.mark_for_rerender(); } /// Dumps all lines above terminal vieport and the viewport itself to a string pub fn dump_screen(&self, full: bool) -> String { let viewport: String = dump_screen!(self.viewport); if !full { return viewport; } let mut scrollback: String = dump_screen!(self.lines_above); if !scrollback.is_empty() { scrollback.push('\n'); } scrollback.push_str(&viewport); scrollback } /// Dumps all lines (with ansi) above terminal viewport and the viewport itself to a string pub fn dump_screen_with_ansi(&self, full: bool) -> String { let viewport: String = dump_screen_with_ansi!(self.viewport); if !full { return viewport; } let mut scrollback: String = dump_screen_with_ansi!(self.lines_above); if !scrollback.is_empty() { scrollback.push('\n'); } scrollback.push_str(&viewport); scrollback } pub fn move_viewport_up(&mut self, count: usize) { for _ in 0..count { self.scroll_up_one_line(); } self.output_buffer.update_all_lines(); } pub fn move_viewport_down(&mut self, count: usize) { for _ in 0..count { self.scroll_down_one_line(); } self.output_buffer.update_all_lines(); } pub fn reset_viewport(&mut self) { let max_lines_to_scroll = *SCROLL_BUFFER_SIZE.get().unwrap() * 2; // while not very elegant, this can prevent minor bugs from becoming showstoppers by sticking the whole app display in an endless loop let mut lines_scrolled = 0; let should_clear_output_buffer = self.is_scrolled; while self.is_scrolled && lines_scrolled < max_lines_to_scroll { self.scroll_down_one_line(); lines_scrolled += 1; } if should_clear_output_buffer { self.output_buffer.update_all_lines(); } } pub fn rotate_scroll_region_up(&mut self, count: usize) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; self.pad_lines_until(scroll_region_bottom, EMPTY_TERMINAL_CHARACTER); for _ in 0..count { if self.cursor.y >= scroll_region_top && self.cursor.y <= scroll_region_bottom { let mut pad_character = EMPTY_TERMINAL_CHARACTER; pad_character.styles = self.cursor.pending_styles.clone(); let columns = VecDeque::from(vec![pad_character; self.width]); self.scroll_region_content_down( scroll_region_top, scroll_region_bottom, Row::from_columns(columns).canonical(), ); } } self.output_buffer.update_all_lines(); // TODO: only update scroll region lines } pub fn rotate_scroll_region_down(&mut self, count: usize) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; self.pad_lines_until(scroll_region_bottom, EMPTY_TERMINAL_CHARACTER); let mut pad_character = EMPTY_TERMINAL_CHARACTER; pad_character.styles = self.cursor.pending_styles.clone(); for _ in 0..count { let columns = VecDeque::from(vec![pad_character.clone(); self.width]); self.scroll_region_content_up( scroll_region_top, scroll_region_top, scroll_region_bottom, Row::from_columns(columns).canonical(), ); } self.output_buffer.update_all_lines(); // TODO: only update scroll region lines } pub fn fill_viewport(&mut self, character: TerminalCharacter) { if self.alternate_screen_state.is_some() { self.viewport.clear(); } else { self.transfer_rows_to_lines_above(self.viewport.len()) }; for _ in 0..self.height { let columns = VecDeque::from(vec![character.clone(); self.width]); self.viewport .push_back(Row::from_columns(columns).canonical()); } self.output_buffer.update_all_lines(); } fn scroll_region_content_down(&mut self, at: usize, region_bottom: usize, new_row: Row) { let before = self.kitty_row_snapshot(); if region_bottom < self.viewport.len() { self.viewport.remove(region_bottom); } self.viewport.insert(at, new_row); self.kitty_region_scroll(at, region_bottom, -1, before, false); } fn scroll_region_content_up( &mut self, at: usize, region_top: usize, region_bottom: usize, new_row: Row, ) -> RegionRowsScrolled { let before = self.kitty_row_snapshot(); let outcome = if at == 0 && region_top == 0 && self.alternate_screen_state.is_none() && !self.viewport.is_empty() { self.transfer_rows_to_lines_above(1); self.selection.move_up(1); RegionRowsScrolled::IntoScrollback } else if at < self.viewport.len() { self.viewport.remove(at); RegionRowsScrolled::Discarded } else { RegionRowsScrolled::Nothing }; if self.viewport.len() >= region_bottom { self.viewport.insert(region_bottom, new_row); } else { self.viewport.push_back(new_row); } self.kitty_region_scroll( at, region_bottom, 1, before, outcome == RegionRowsScrolled::IntoScrollback, ); outcome } fn kitty_row_snapshot(&mut self) -> KittyRowSnapshot { self.kitty_settle_placements_below_the_viewport(); KittyRowSnapshot { lines_above_len: self.lines_above.len(), front_drops: self.kitty_grid.front_drops(), merged_rows: self.kitty_grid.merged_rows(), split_rows: self.kitty_grid.split_rows(), } } fn kitty_region_scroll( &mut self, region_top: usize, region_bottom: usize, n: isize, before: KittyRowSnapshot, preserve_above_region_top: bool, ) { let cell_size = { *self.character_cell_size.borrow() }; if let Some(character_cell_size) = cell_size { let cell_h = character_cell_size.height as isize; let already_shifted_rows = (self.kitty_grid.front_drops() - before.front_drops) as isize + (self.kitty_grid.merged_rows() - before.merged_rows) as isize - (self.kitty_grid.split_rows() - before.split_rows) as isize; let la_delta = (self.lines_above.len() as isize - before.lines_above_len as isize) + already_shifted_rows; let la_delta_px = la_delta * cell_h; let viewport_top_row = before.lines_above_len as isize - already_shifted_rows; let region_top_px = (viewport_top_row + region_top as isize) * cell_h; let region_bottom_px = (viewport_top_row + region_bottom as isize + 1) * cell_h; let viewport_top_px = viewport_top_row * cell_h; self.kitty_grid.apply_region_scroll( region_top_px, region_bottom_px, n * cell_h, la_delta_px, viewport_top_px, preserve_above_region_top, ); self.kitty_grid .note_rows_below_the_viewport_shifted(la_delta); self.kitty_reanchor_all_from_pixels(); } } fn scroll_region_up_at_bottom(&mut self, new_row: Row, offset_hyperlinks: bool) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; if scroll_region_top >= self.viewport.len() { return; } if scroll_region_bottom == self.height.saturating_sub(1) && scroll_region_top == 0 { if self.alternate_screen_state.is_none() { self.transfer_rows_to_lines_above(1); if offset_hyperlinks { self.hyperlink_tracker.offset_cursor_lines(1); } } else if !self.viewport.is_empty() { self.viewport.pop_front(); } self.viewport.push_back(new_row); self.selection.move_up(1); } else { let outcome = self.scroll_region_content_up( scroll_region_top, scroll_region_top, scroll_region_bottom, new_row, ); if offset_hyperlinks { match outcome { RegionRowsScrolled::IntoScrollback => { self.hyperlink_tracker.offset_cursor_lines(1) }, RegionRowsScrolled::Discarded => { self.hyperlink_tracker.offset_cursor_lines_in_range( scroll_region_top as isize, scroll_region_bottom as isize, 1, ) }, RegionRowsScrolled::Nothing => {}, } } } self.output_buffer.update_all_lines(); } pub fn add_canonical_line(&mut self) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; self.hyperlink_tracker.update( '\n', &self.cursor, &mut self.viewport, &mut self.lines_above, &mut self.link_handler.borrow_mut(), ); if self.cursor.y == scroll_region_bottom { if scroll_region_top >= self.viewport.len() { return; } let scroll_bg = self.cursor.pending_styles.background; let new_row = Row::new().canonical().with_bg_color(scroll_bg); self.scroll_region_up_at_bottom(new_row, false); self.kitty_reanchor_all_from_pixels(); return; } if self.viewport.len() <= self.cursor.y + 1 { // FIXME: this should add an empty line with the pad_character // but for some reason this breaks rendering in various situations // it needs to be investigated and fixed let new_row = Row::new().canonical(); self.viewport.push_back(new_row); } if self.cursor.y == self.height.saturating_sub(1) { self.output_buffer.update_all_lines(); } else { self.cursor.y += 1; self.output_buffer.update_line(self.cursor.y); } } pub fn move_cursor_to_beginning_of_line(&mut self) { self.cursor.x = 0; } pub fn add_character_at_cursor_position( &mut self, terminal_character: TerminalCharacter, should_insert_character: bool, ) { self.hyperlink_tracker.update( terminal_character.character, &self.cursor, &mut self.viewport, &mut self.lines_above, &mut self.link_handler.borrow_mut(), ); // this function assumes the current line has enough room for terminal_character (that its // width has been checked beforehand) match self.viewport.get_mut(self.cursor.y) { Some(row) => { if self.insert_mode || should_insert_character { row.insert_character_at(terminal_character, self.cursor.x); if row.width() > self.width { row.truncate(self.width); } } else { row.add_character_at(terminal_character, self.cursor.x); } if let Some(character_cell_size) = *self.character_cell_size.borrow() { let scrollback_size_in_pixels = self.lines_above.len() * character_cell_size.height; let absolute_x_in_pixels = self.cursor.x * character_cell_size.width; let absolute_y_in_pixels = scrollback_size_in_pixels + (self.cursor.y * character_cell_size.height); let rect_to_cut_out = PixelRect { x: absolute_x_in_pixels, y: absolute_y_in_pixels as isize, width: character_cell_size.width, height: character_cell_size.height, }; if let Some(images_to_cut_out) = self.sixel_grid.cut_off_rect_from_images(rect_to_cut_out) { for (image_id, rect_in_image_to_cut_out) in images_to_cut_out { self.sixel_grid .remove_pixels_from_image(image_id, rect_in_image_to_cut_out); } } } self.output_buffer.update_line(self.cursor.y); }, None => { // pad lines until cursor if they do not exist for _ in self.viewport.len()..self.cursor.y { self.viewport.push_back(Row::new().canonical()); } let mut new_row = Row::new().canonical(); new_row.add_character_at(terminal_character, self.cursor.x); self.viewport.push_back(new_row); self.output_buffer.update_line(self.cursor.y); }, } } pub fn add_character(&mut self, terminal_character: TerminalCharacter) { let character_width = terminal_character.width(); // Drop zero-width Unicode/UTF-8 codepoints, like for example Variation Selectors. // This breaks unicode grapheme segmentation, and is the reason why some characters // aren't displayed correctly. Refer to this issue for more information: // https://github.com/zellij-org/zellij/issues/1538 if character_width == 0 { return; } if self.cursor.x + character_width > self.width { if self.disable_linewrap { return; } self.line_wrap(); } self.add_character_at_cursor_position(terminal_character, false); self.move_cursor_forward_until_edge(character_width); } pub fn get_character_under_cursor(&self) -> Option { let absolute_x_in_line = self.get_absolute_character_index(self.cursor.x, self.cursor.y)?; self.viewport .get(self.cursor.y) .and_then(|current_line| current_line.columns.get(absolute_x_in_line)) .cloned() } pub fn get_absolute_character_index(&self, x: usize, y: usize) -> Option { Some(self.viewport.get(y)?.absolute_character_index(x)) } pub fn move_cursor_forward_until_edge(&mut self, count: usize) { let count_to_move = std::cmp::min(count, self.width.saturating_sub(self.cursor.x)); self.cursor.x += count_to_move; } pub fn replace_characters_in_line_after_cursor(&mut self, replace_with: TerminalCharacter) { if let Some(row) = self.viewport.get_mut(self.cursor.y) { row.replace_and_pad_end(self.cursor.x, self.width, replace_with); } self.output_buffer.update_line(self.cursor.y); } pub fn replace_characters_in_line_before_cursor(&mut self, replace_with: TerminalCharacter) { let row = self.viewport.get_mut(self.cursor.y).unwrap(); row.replace_and_pad_beginning(self.cursor.x, replace_with); self.output_buffer.update_line(self.cursor.y); } pub fn clear_all_after_cursor(&mut self, replace_with: TerminalCharacter) { if let Some(cursor_row) = self.viewport.get_mut(self.cursor.y) { cursor_row.truncate(self.cursor.x); let replace_with_columns = VecDeque::from(vec![replace_with.clone(); self.width]); self.replace_characters_in_line_after_cursor(replace_with); for row in self.viewport.iter_mut().skip(self.cursor.y + 1) { row.replace_columns(replace_with_columns.clone()); } self.output_buffer.update_all_lines(); // TODO: only update the changed lines } } pub fn clear_all_before_cursor(&mut self, replace_with: TerminalCharacter) { if self.viewport.get(self.cursor.y).is_some() { let replace_with_columns = VecDeque::from(vec![replace_with.clone(); self.width]); self.replace_characters_in_line_before_cursor(replace_with); for row in self.viewport.iter_mut().take(self.cursor.y) { row.replace_columns(replace_with_columns.clone()); } self.output_buffer.update_all_lines(); // TODO: only update the changed lines } } pub fn clear_cursor_line(&mut self) { if let Some(viewport_line) = self.viewport.get_mut(self.cursor.y) { viewport_line.truncate(0); self.output_buffer.update_line(self.cursor.y); } } pub fn clear_all(&mut self, replace_with: TerminalCharacter) { let replace_with_columns = VecDeque::from(vec![replace_with.clone(); self.width]); self.replace_characters_in_line_after_cursor(replace_with); for row in &mut self.viewport { row.replace_columns(replace_with_columns.clone()); } self.output_buffer.update_all_lines(); } fn line_wrap(&mut self) { self.cursor.x = 0; let (scroll_region_top, scroll_region_bottom) = self.scroll_region; if self.cursor.y == scroll_region_bottom { if scroll_region_top >= self.viewport.len() { return; } self.scroll_region_up_at_bottom(Row::new(), true); } else if self.cursor.y == self.height.saturating_sub(1) { // the cursor is on the last line of the screen but below the scroll region's // bottom margin: there is nowhere to scroll to, so we wrap onto the same line // (mirroring what add_canonical_line does in this situation) self.output_buffer.update_line(self.cursor.y); } else { self.cursor.y += 1; if self.viewport.len() <= self.cursor.y { let line_wrapped_row = Row::new(); self.viewport.push_back(line_wrapped_row); self.output_buffer.update_line(self.cursor.y); } else if let Some(current_line) = self.viewport.get_mut(self.cursor.y) { current_line.is_canonical = false; } } } fn clear_lines_above(&mut self) { self.lines_above.clear(); self.scrollback_buffer_lines = self.recalculate_scrollback_buffer_count(); } fn pad_current_line_until(&mut self, position: usize, pad_character: TerminalCharacter) { if self.viewport.get(self.cursor.y).is_none() { self.pad_lines_until(self.cursor.y, pad_character.clone()); } if let Some(current_row) = self.viewport.get_mut(self.cursor.y) { let mut effective_pad = pad_character; if let Some(bg_color) = current_row.bg_color { if matches!( effective_pad.styles.background, Some(AnsiCode::Reset) | None ) { effective_pad .styles .update(|styles| styles.background = Some(bg_color)); } } for _ in current_row.width()..position { current_row.push(effective_pad.clone()); } self.output_buffer.update_line(self.cursor.y); } } fn pad_lines_until(&mut self, position: usize, pad_character: TerminalCharacter) { for _ in self.viewport.len()..=position { let columns = VecDeque::from(vec![pad_character.clone(); self.width]); self.viewport .push_back(Row::from_columns(columns).canonical()); self.output_buffer.update_line(self.viewport.len() - 1); } } pub fn move_cursor_to(&mut self, x: usize, y: usize, pad_character: TerminalCharacter) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; self.cursor.x = std::cmp::min(self.width - 1, x); let y_offset = if self.erasure_mode { scroll_region_top } else { 0 }; if y >= scroll_region_top && y <= scroll_region_bottom { self.cursor.y = std::cmp::min(scroll_region_bottom, y + y_offset); } else { self.cursor.y = std::cmp::min(self.height.saturating_sub(1), y + y_offset); } self.pad_lines_until(self.cursor.y, pad_character.clone()); self.pad_current_line_until(self.cursor.x, pad_character); } pub fn move_cursor_up(&mut self, count: usize) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; if self.cursor.y >= scroll_region_top && self.cursor.y <= scroll_region_bottom { self.cursor.y = std::cmp::max(self.cursor.y.saturating_sub(count), scroll_region_top); return; } self.cursor.y = if self.cursor.y < count { 0 } else { self.cursor.y - count }; } pub fn move_cursor_up_with_scrolling(&mut self, count: usize) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; for _ in 0..count { let current_line_index = self.cursor.y; if current_line_index == scroll_region_top { // if we're at the top line, we create a new line and remove the last line that // would otherwise overflow self.scroll_region_content_down( current_line_index, scroll_region_bottom, Row::new().canonical(), ); } else if current_line_index > scroll_region_top && current_line_index <= scroll_region_bottom { self.move_cursor_up(count); } } self.output_buffer.update_all_lines(); } pub fn move_cursor_down_until_edge_of_screen( &mut self, count: usize, pad_character: TerminalCharacter, ) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; if self.cursor.y >= scroll_region_top && self.cursor.y <= scroll_region_bottom { self.cursor.y = std::cmp::min(self.cursor.y + count, scroll_region_bottom); return; } self.cursor.y = std::cmp::min(self.cursor.y + count, self.height - 1); self.pad_lines_until(self.cursor.y, pad_character); } pub fn move_cursor_back(&mut self, count: usize) { if self.cursor.x == self.width { // on the rightmost screen edge, backspace skips one character self.cursor.x -= 1; } if self.cursor.x < count { self.cursor.x = 0; } else { self.cursor.x -= count; } } pub fn hide_cursor(&mut self) { self.cursor_is_hidden = true; } pub fn show_cursor(&mut self) { self.cursor_is_hidden = false; } pub fn set_scroll_region(&mut self, top_line_index: usize, bottom_line_index: Option) { let bottom_line_index = bottom_line_index.unwrap_or(self.height.saturating_sub(1)); self.scroll_region = (top_line_index, bottom_line_index); let mut pad_character = EMPTY_TERMINAL_CHARACTER; pad_character.styles = self.cursor.pending_styles.clone(); self.move_cursor_to(0, 0, pad_character); // DECSTBM moves the cursor to column 1 line 1 of the page } pub fn set_scroll_region_to_viewport_size(&mut self) { self.scroll_region = (0, self.height.saturating_sub(1)); } pub fn delete_lines_in_scroll_region( &mut self, count: usize, pad_character: TerminalCharacter, ) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; let current_line_index = self.cursor.y; if current_line_index >= scroll_region_top && current_line_index <= scroll_region_bottom { // when deleting lines inside the scroll region, we must make sure it stays the // same size (and that other lines below it aren't shifted inside it) // so we delete the current line(s) and add an empty line at the end of the scroll // region for _ in 0..count { let columns = VecDeque::from(vec![pad_character.clone(); self.width]); self.scroll_region_content_up( current_line_index, scroll_region_top, scroll_region_bottom, Row::from_columns(columns).canonical(), ); } self.output_buffer.update_all_lines(); // TODO: move accurately } } pub fn add_empty_lines_in_scroll_region( &mut self, count: usize, pad_character: TerminalCharacter, ) { let (scroll_region_top, scroll_region_bottom) = self.scroll_region; let current_line_index = self.cursor.y; if current_line_index >= scroll_region_top && current_line_index <= scroll_region_bottom { // when adding empty lines inside the scroll region, we must make sure it stays the // same size and that lines don't "leak" outside of it // so we add an empty line where the cursor currently is, and delete the last line // of the scroll region for _ in 0..count { let columns = VecDeque::from(vec![pad_character.clone(); self.width]); self.scroll_region_content_down( current_line_index, scroll_region_bottom, Row::from_columns(columns).canonical(), ); } self.output_buffer.update_all_lines(); // TODO: move accurately } } pub fn move_cursor_to_column(&mut self, column: usize) { self.cursor.x = column; let pad_character = EMPTY_TERMINAL_CHARACTER; self.pad_current_line_until(self.cursor.x, pad_character); } pub fn move_cursor_to_line(&mut self, line: usize, pad_character: TerminalCharacter) { self.cursor.y = std::cmp::min(self.height - 1, line); self.pad_lines_until(self.cursor.y, pad_character); let pad_character = EMPTY_TERMINAL_CHARACTER; self.pad_current_line_until(self.cursor.x, pad_character); } pub fn replace_with_empty_chars(&mut self, count: usize, empty_char_style: RcCharacterStyles) { let mut empty_character = EMPTY_TERMINAL_CHARACTER; empty_character.styles = empty_char_style; let pad_until = std::cmp::min(self.width, self.cursor.x + count); self.pad_current_line_until(pad_until, empty_character.clone()); if let Some(current_row) = self.viewport.get_mut(self.cursor.y) { for i in 0..count { current_row.replace_character_at(empty_character.clone(), self.cursor.x + i); } self.output_buffer.update_line(self.cursor.y); } } fn erase_characters(&mut self, count: usize, empty_char_style: RcCharacterStyles) { let mut empty_character = EMPTY_TERMINAL_CHARACTER; empty_character.styles = empty_char_style; if let Some(current_row) = self.viewport.get_mut(self.cursor.y) { // pad row if needed if current_row.width_cached() < self.width { let padding_count = self.width - current_row.width_cached(); let mut columns_padding = VecDeque::from(vec![EMPTY_TERMINAL_CHARACTER; padding_count]); current_row.columns.append(&mut columns_padding); } for _ in 0..count { let deleted_character = current_row.delete_and_return_character(self.cursor.x); let excess_width = deleted_character .map(|terminal_character| terminal_character.width()) .unwrap_or(0) .saturating_sub(1); for _ in 0..excess_width { current_row.insert_character_at(empty_character.clone(), self.cursor.x); } current_row.push(empty_character.clone()); } self.output_buffer.update_line(self.cursor.y); } } fn add_newline(&mut self) { self.add_canonical_line(); self.mark_for_rerender(); } pub fn mark_for_rerender(&mut self) { self.should_render = true; } pub fn reset_terminal_state(&mut self) { if let Some(alternate_screen_state) = self.alternate_screen_state.as_mut() { alternate_screen_state.kitty_grid.clear_all_placements(); } self.lines_above = VecDeque::new(); self.lines_below = VecDeque::new(); self.is_scrolled = false; self.viewport = VecDeque::from(vec![Row::new().canonical()]); self.alternate_screen_state = None; self.cursor_key_mode = false; self.clear_viewport_before_rendering = true; self.cursor = Cursor::new(0, 0, self.styled_underlines); self.saved_cursor_position = None; self.active_charset = Default::default(); self.erasure_mode = false; self.disable_linewrap = false; self.new_line_mode = false; self.cursor.change_shape(CursorShape::Initial); self.output_buffer.update_all_lines(); self.changed_colors = None; self.scrollback_buffer_lines = 0; self.search_results = Default::default(); self.sixel_scrolling = false; self.mouse_mode = MouseMode::NoEncoding; self.mouse_tracking = MouseTracking::Off; self.focus_event_tracking = false; self.cursor_is_hidden = false; self.supports_kitty_keyboard_protocol = false; self.set_scroll_region_to_viewport_size(); self.pane_default_fg = None; self.pane_default_bg = None; if let Some(images_to_reap) = self.sixel_grid.clear() { self.sixel_grid.reap_images(images_to_reap); } self.kitty_grid.clear_all_placements(); let kitty_image_store = self.kitty_grid.kitty_image_store.clone(); self.kitty_grid = KittyGrid::new(self.character_cell_size.clone(), kitty_image_store); self.kitty_parser.abort_pending(); } 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, 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, tooltip_text: h.tooltip_text.clone(), layer: h.layer, }; } 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, tooltip_text: h.tooltip_text, layer: h.layer, }, )); } } else { log::warn!( "Plugin {} supplied invalid regex: {:?}", plugin_id, h.pattern ); } } self.output_buffer.update_all_lines(); self.recompute_hover_tooltip(); } /// 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(); self.recompute_hover_tooltip(); } } /// 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)> { 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)?; let mut best: Option<( HighlightLayer, u32, String, String, BTreeMap, )> = None; for (plugin_id, pattern_map) in &self.plugin_highlights { for (pattern, compiled) in pattern_map { for captures in compiled.regex.captures_iter(&logical_text) { let Some(mat) = highlight_match(&captures) else { continue; }; 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, ) { let dominated = match &best { Some((best_layer, ..)) => compiled.layer > *best_layer, None => true, }; if dominated { best = Some(( compiled.layer, *plugin_id, pattern.clone(), mat.as_str().to_string(), compiled.context.clone(), )); } } } } } } best.map(|(_layer, plugin_id, pattern, matched, ctx)| (plugin_id, pattern, matched, ctx)) } pub fn set_hover_position(&mut self, new_pos: Option) -> bool { if self.hover_position == new_pos { return false; } let has_hover_consumer = self .plugin_highlights .values() .any(|highlights| highlights.iter().any(|(_, c)| c.on_hover)); if !has_hover_consumer { self.hover_position = new_pos; return false; } // 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; self.recompute_hover_tooltip(); true } /// 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); } } } /// Recompute the cached hover tooltip from the current hover position and /// plugin highlights. Called whenever the hover position, highlight set, or /// highlight clearing changes. fn recompute_hover_tooltip(&mut self) { self.cached_hover_tooltip = None; let hover_pos = match self.hover_position { Some(p) => p, None => return, }; if self.mouse_tracking != MouseTracking::Off { return; } if self.plugin_highlights.is_empty() { return; } let hover_row = hover_pos.line.0 as usize; let hover_col = hover_pos.column.0 as usize; let (_canonical, _group_len, logical_text, boundaries) = match collect_and_build_logical_line(&self.viewport, hover_row) { Some(v) => v, None => return, }; let mut best_layer: Option = None; let mut best_tooltip: Option = None; for (_plugin_id, pattern_map) in &self.plugin_highlights { for (_pattern, compiled) in pattern_map { if !compiled.on_hover || compiled.tooltip_text.is_none() { continue; } for captures in compiled.regex.captures_iter(&logical_text) { let Some(mat) = highlight_match(&captures) else { continue; }; 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, ) { let dominated = match best_layer { Some(bl) => compiled.layer > bl, None => true, }; if dominated { best_layer = Some(compiled.layer); best_tooltip = compiled.tooltip_text.clone(); } } } } } } self.cached_hover_tooltip = best_tooltip; } /// 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 { 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 captures in compiled.regex.captures_iter(&logical_text) { let Some(mat) = highlight_match(&captures) else { continue; }; 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, layer: compiled.layer, }); 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 captures in compiled.regex.captures_iter(&logical_text) { let Some(mat) = highlight_match(&captures) else { continue; }; 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, layer: compiled.layer, }); } } } } ridx += group_len; // advance past this logical line group } // Sort by layer priority (highest first) and within the same layer, // hover highlights before non-hover (hover entries were pushed first, // so a stable sort preserves their relative order). selections.sort_by(|a, b| { use std::cmp::Reverse; Reverse(a.layer).cmp(&Reverse(b.layer)) }); selections } pub fn start_selection(&mut self, start: &Position) { let old_selection = self.selection; self.click.record_click(*start); if self.click.is_double_click() { let Some((start_position, end_position)) = self.word_around_position(&start) else { // no-op return; }; self.selection .set_start_and_end_positions(start_position, end_position); for i in std::cmp::min(start_position.line.0, end_position.line.0) ..=std::cmp::max(start_position.line.0, end_position.line.0) { self.output_buffer.update_line(i as usize); } self.mark_for_rerender(); return; } else if self.click.is_triple_click() { let Some((start_position, end_position)) = self.canonical_line_around_position(&start) else { // no-op return; }; self.selection .set_start_and_end_positions(start_position, end_position); for i in std::cmp::min(start_position.line.0, end_position.line.0) ..=std::cmp::max(start_position.line.0, end_position.line.0) { self.output_buffer.update_line(i as usize); } self.mark_for_rerender(); return; } self.selection.start(*start); self.update_selected_lines(&old_selection, &self.selection.clone()); self.mark_for_rerender(); } pub fn update_selection(&mut self, to: &Position) { let old_selection = self.selection; if &old_selection.end != to { if self.click.is_double_click() { let Some((word_start_position, word_end_position)) = self.word_around_position(&to) else { // no-op return; }; self.selection .add_word_to_position(word_start_position, word_end_position); let current_selection = self.selection; self.update_selected_lines(&old_selection, ¤t_selection); self.mark_for_rerender(); } else if self.click.is_triple_click() { let Some(last_index_in_line) = self.last_index_in_line(&to) else { return; }; self.selection .add_line_to_position(to.line.0, last_index_in_line); let current_selection = self.selection; self.update_selected_lines(&old_selection, ¤t_selection); self.mark_for_rerender(); } else { self.selection.to(*to); self.update_selected_lines(&old_selection, &self.selection.clone()); self.mark_for_rerender(); } } } pub fn end_selection(&mut self, end: &Position) { if !self.click.is_double_click() && !self.click.is_triple_click() { let old_selection = self.selection; self.selection.end(*end); self.update_selected_lines(&old_selection, &self.selection.clone()); } else { // we do this rather than using .end() so that the selection will be marked as inactive // (so we won't keep changing its start/end points as we scroll) but so we won't update // its end position to the above "end" position (which is incorrect behavior for // double/triple click - it will mean we won't mark until the end of the word/line) self.selection.finalize(); } self.mark_for_rerender(); } pub fn reset_selection(&mut self) { let old_selection = self.selection; self.selection.reset(); self.update_selected_lines(&old_selection, &self.selection.clone()); self.mark_for_rerender(); } pub fn get_selected_text(&self) -> Option { if self.selection.is_empty() { return None; } let mut selection: Vec = vec![]; let sorted_selection = self.selection.sorted(); let (start, end) = (sorted_selection.start, sorted_selection.end); for l in sorted_selection.line_indices() { let mut line_selection = String::new(); // on the first line of the selection, use the selection start column // otherwise, start at the beginning of the line let start_column = if l == start.line.0 { start.column.0 } else { 0 }; // same thing on the last line, but with the selection end column let end_column = if l == end.line.0 { end.column.0 } else { self.width }; if start_column == end_column { continue; } let empty_row = Row::from_columns(VecDeque::from(vec![EMPTY_TERMINAL_CHARACTER; self.width])); // get the row from lines_above, viewport, or lines below depending on index let row = if l < 0 && self.lines_above.len() > l.abs() as usize { let offset_from_end = l.abs(); &self.lines_above[self .lines_above .len() .saturating_sub(offset_from_end as usize)] } else if l >= 0 && (l as usize) < self.viewport.len() { &self.viewport[l as usize] } else if (l as usize) < self.height { // index is in viewport but there is no line &empty_row } else if self.lines_below.len() > (l as usize).saturating_sub(self.viewport.len()) { &self.lines_below[(l as usize) - self.viewport.len()] } else { // can't find the line, this probably it's on the pane border // is on the pane border continue; }; let mut terminal_col = 0; for terminal_character in &row.columns { if (start_column..end_column).contains(&terminal_col) { line_selection.push(terminal_character.character); } terminal_col += terminal_character.width(); } if row.is_canonical { selection.push(line_selection); } else { // rejoin wrapped lines if possible match selection.last_mut() { Some(previous_line) => previous_line.push_str(&line_selection), None => selection.push(line_selection), } } } // TODO: distinguish whitespace that was output explicitly vs implicitly (e.g add_newline) // for example: echo " " vs empty lines // for now trim after building the selection to handle whitespace in wrapped lines let selection: Vec<_> = selection.iter().map(|l| l.trim_end()).collect(); if selection.is_empty() { None } else { Some(selection.join("\n")) } } pub fn absolute_position_in_scrollback(&self) -> usize { self.lines_above.len() + self.cursor.y } pub fn last_index_in_line(&self, position: &Position) -> Option { let position_row = self.viewport.get(position.line.0 as usize)?; Some(position_row.last_index_in_line()) } pub fn word_around_position(&self, position: &Position) -> Option<(Position, Position)> { let position_row = self.viewport.get(position.line.0 as usize)?; let (index_start, index_end) = position_row.word_indices_around_character_index(position.column.0)?; let mut position_start = Position::new(position.line.0 as i32, index_start as u16); let mut position_end = Position::new(position.line.0 as i32, index_end as u16); let mut position_row_is_canonical = position_row.is_canonical; while !position_row_is_canonical && position_start.column.0 == 0 { if let Some(position_row_above) = self .viewport .get(position_start.line.0.saturating_sub(1) as usize) { let new_start_index = position_row_above.word_start_index_of_last_character(); position_start = Position::new( position_start.line.0.saturating_sub(1) as i32, new_start_index as u16, ); position_row_is_canonical = position_row_above.is_canonical; } else { break; } } let mut column_count_in_row = position_row.columns.len(); while position_end.column.0 == column_count_in_row { if let Some(position_row_below) = self.viewport.get(position_end.line.0 as usize + 1) { if position_row_below.is_canonical { break; } let new_end_index = position_row_below.word_end_index_of_first_character(); position_end = Position::new(position_end.line.0 as i32 + 1, new_end_index as u16); column_count_in_row = position_row_below.columns.len(); } else { break; } } Some((position_start, position_end)) } pub fn canonical_line_around_position( &self, position: &Position, ) -> Option<(Position, Position)> { let position_row = self.viewport.get(position.line.0 as usize)?; let mut position_start = Position::new(position.line.0 as i32, 0); let mut position_end = Position::new( position.line.0 as i32, (position_row.columns.len() + position_row.excess_width()) as u16, ); let mut found_canonical_row_start = position_row.is_canonical; while !found_canonical_row_start { if let Some(row_above) = self .viewport .get(position_start.line.0.saturating_sub(1) as usize) { position_start.line.0 = position_start.line.0.saturating_sub(1); found_canonical_row_start = row_above.is_canonical; } else { break; } } let mut found_canonical_row_end = false; while !found_canonical_row_end { if let Some(row_below) = self.viewport.get(position_end.line.0 as usize + 1) { if row_below.is_canonical { found_canonical_row_end = true; } else { position_end = Position::new( position_end.line.0 as i32 + 1, row_below.columns.len() as u16, ); } } else { break; } } Some((position_start, position_end)) } fn update_selected_lines(&mut self, old_selection: &Selection, new_selection: &Selection) { for l in old_selection.diff(new_selection, self.height) { self.output_buffer.update_line(l as usize); } } fn set_title(&mut self, title: String) { self.title = Some(title); } fn push_current_title_to_stack(&mut self) { if self.title_stack.len() > MAX_TITLE_STACK_SIZE { self.title_stack.remove(0); } if let Some(title) = &self.title { self.title_stack.push(title.clone()); } } fn pop_title_from_stack(&mut self) { if let Some(popped_title) = self.title_stack.pop() { self.title = Some(popped_title); } } fn transfer_rows_to_lines_above(&mut self, count: usize) { self.kitty_settle_placements_below_the_viewport(); let transferred_rows_count = transfer_rows_from_viewport_to_lines_above( &mut self.viewport, &mut self.lines_above, &mut self.sixel_grid, &mut self.kitty_grid, count, self.width, ); self.kitty_reanchor_all_from_pixels(); self.scrollback_buffer_lines = subtract_isize_from_usize(self.scrollback_buffer_lines, transferred_rows_count); } fn move_cursor_down_by_pixels(&mut self, pixel_count: usize) { if let Some(character_cell_size) = { let c = *self.character_cell_size.borrow(); c } { // thanks borrow checker let pixel_height = character_cell_size.height; let to_move = (pixel_count as f64 / pixel_height as f64).ceil() as usize; for _ in 0..to_move { self.add_canonical_line(); } } } fn current_cursor_pixel_coordinates(&self) -> Option<(usize, usize)> { // (x, y) if let Some(character_cell_size) = *self.character_cell_size.borrow() { let line_count_in_scrollback = self.lines_above.len(); let y_coordinates = (line_count_in_scrollback + self.cursor.y) * character_cell_size.height; let x_coordinates = self.cursor.x * character_cell_size.width; Some((x_coordinates, y_coordinates)) } else { None } } fn create_sixel_image(&mut self) { if let Some((x_pixel_coordinates, y_pixel_coordinates)) = self.current_cursor_pixel_coordinates() { let (x_pixel_coordinates, y_pixel_coordinates) = if self.sixel_scrolling { let scrollback_pixel_height = self.lines_above.len() * self.character_cell_size.borrow().unwrap().height; (0, scrollback_pixel_height) } else { (x_pixel_coordinates, y_pixel_coordinates) }; let new_image_id = self.sixel_grid.next_image_id(); let new_sixel_image = self.sixel_grid .end_image(new_image_id, x_pixel_coordinates, y_pixel_coordinates); if let Some(new_sixel_image) = new_sixel_image { let (image_pixel_height, _image_pixel_width) = new_sixel_image.pixel_size(); self.sixel_grid .new_sixel_image(new_image_id, new_sixel_image); if !self.sixel_scrolling { self.move_cursor_down_by_pixels(image_pixel_height); } self.render_full_viewport(); // TODO: this could be optimized if it's a performance bottleneck } } } fn advance_cursor_after_kitty_placement(&mut self, cols: usize, rows: usize) { let mut down_steps = rows.saturating_sub(1); let target_x = self.cursor.x + cols; if target_x >= self.width { self.cursor.x = 0; down_steps += 1; } else { self.cursor.x = target_x; } for _ in 0..down_steps { self.add_canonical_line(); } } pub fn handle_kitty_apc(&mut self, raw: &[u8]) -> Option> { if !self.kitty_host_support.protocol_is_enabled() { return None; } self.kitty_grid.note_command_handled(); let parsed = self.kitty_parser.parse(raw)?; let (result, was_query) = match parsed { Ok(command) => { let was_query = command.action == KittyAction::Query; (self.execute_kitty_command(command), was_query) }, Err(e) => (Err(e), false), }; let reply_bytes = match &result { Ok(reply) => format_kitty_reply(reply, was_query), Err(error) => format_kitty_error(error), }; if let Some(reply_bytes) = reply_bytes { self.pending_messages_to_pty.push(reply_bytes); } Some(result) } fn execute_kitty_command( &mut self, command: KittyCommand, ) -> Result { match command.action { KittyAction::Query => { if self.kitty_host_support.host_supports_graphics() { Ok(KittyReplyData::from_command(&command)) } else { Err(KittyError { code: KittyErrorCode::Enotsupported, message: "kitty graphics not supported by host terminal".to_owned(), image_id: command.image_id, image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }) } }, KittyAction::Delete => { let cursor_cell = (self.cursor.x, self.cursor.y); let viewport_cells = (self.width, self.height); let scrollback_rows = self.lines_above.len(); let result = self.kitty_grid .delete(&command, cursor_cell, viewport_cells, scrollback_rows); self.render_full_viewport(); self.mark_for_rerender(); result.map(|_| KittyReplyData::from_command(&command)) }, KittyAction::Transmit => { let image = match command.image.clone() { Some(image) => image, None => { return Err(KittyError { code: KittyErrorCode::Einval, message: "missing image data".to_owned(), image_id: command.image_id, image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }); }, }; match self.kitty_grid.transmit(&command, image) { Ok(assigned_id) => Ok(KittyReplyData { image_id: Some(assigned_id), image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }), Err(e) => Err(e), } }, KittyAction::TransmitAndDisplay | KittyAction::Display => { let resolved = if command.action == KittyAction::TransmitAndDisplay { match command.image.clone() { Some(image) => self.kitty_grid.transmit(&command, image).map(|id| { let internal = *self .kitty_grid .pane_image_id_map() .get(&id) .expect("freshly transmitted image is mapped"); (id, internal) }), None => Err(KittyError { code: KittyErrorCode::Einval, message: "missing image data".to_owned(), image_id: command.image_id, image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }), } } else { self.kitty_grid.resolve_display_target(&command) }; let (pane_image_id, internal) = match resolved { Ok(resolved) => resolved, Err(e) => return Err(e), }; let cursor_px = self.current_cursor_pixel_coordinates(); let cell = { *self.character_cell_size.borrow() }; let (cursor_px, cell) = match (cursor_px, cell) { (Some(cursor_px), Some(cell)) => (cursor_px, cell), _ => { return Err(KittyError { code: KittyErrorCode::Enotsupported, message: "cell size unknown".to_owned(), image_id: command.image_id, image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }); }, }; let placement_top_px = (cursor_px.1 + std::cmp::min(command.cell_offset_y as usize, cell.height - 1)) as isize; let starts = self.kitty_canonical_line_starts(); let vertical_anchor = Self::kitty_anchor_from_pixel_y( placement_top_px, cell.height as isize, &starts, ); match self.kitty_grid.place( pane_image_id, internal, &command, cursor_px, cell, vertical_anchor, ) { Ok((cols, rows)) => { if !command.suppress_cursor_movement { self.advance_cursor_after_kitty_placement(cols as usize, rows as usize); } self.kitty_reanchor_all_from_pixels(); self.render_full_viewport(); self.mark_for_rerender(); Ok(KittyReplyData { image_id: Some(pane_image_id), image_number: command.image_number, placement_id: command.placement_id, quiet: command.quiet, }) }, Err(e) => Err(e), } }, } } pub fn kitty_commands_handled(&self) -> u64 { self.kitty_grid.commands_handled() } pub fn kitty_placement_count(&self) -> usize { self.kitty_grid.placement_count() } pub fn kitty_placements(&self) -> &[KittyPlacement] { self.kitty_grid.placements() } fn mouse_buttons_value_x10(&self, event: &MouseEvent) -> u8 { let mut value = 35; // Default to no buttons down. if event.event_type == MouseEventType::Release { return value; } if event.left { value = 32; } else if event.middle { value = 33; } else if event.right { value = 34; } else if event.wheel_up { value = 68; } else if event.wheel_down { value = 69; } if event.event_type == MouseEventType::Motion { value += 32; } if event.shift { value |= 0x04; } if event.alt { value |= 0x08; } if event.ctrl { value |= 0x10; } value } fn mouse_buttons_value_sgr(&self, event: &MouseEvent) -> u8 { let mut value = 3; // Default to no buttons down. if event.left { value = 0; } else if event.middle { value = 1; } else if event.right { value = 2; } else if event.wheel_up { value = 64; } else if event.wheel_down { value = 65; } if event.event_type == MouseEventType::Motion { value += 32; } if event.shift { value |= 0x04; } if event.alt { value |= 0x08; } if event.ctrl { value |= 0x10; } value } pub fn mouse_event_signal(&self, event: &MouseEvent) -> Option { let emit = match (&self.mouse_tracking, event.event_type) { (MouseTracking::Off, _) => false, (MouseTracking::AnyEventTracking, _) => true, (_, MouseEventType::Press | MouseEventType::Release) => true, (MouseTracking::ButtonEventTracking, MouseEventType::Motion) => { event.left | event.right | event.middle | event.wheel_up | event.wheel_down }, (_, _) => false, }; match (emit, &self.mouse_mode) { (true, MouseMode::NoEncoding | MouseMode::Utf8) => { let mut msg: Vec = vec![27, b'[', b'M', self.mouse_buttons_value_x10(event)]; msg.append(&mut utf8_mouse_coordinates( event.position.column() + 1, event.position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (true, MouseMode::Sgr) => Some(format!( "\u{1b}[<{:?};{:?};{:?}{}", self.mouse_buttons_value_sgr(event), event.position.column() + 1, event.position.line() + 1, match event.event_type { MouseEventType::Press | MouseEventType::Motion => 'M', _ => 'm', } )), (_, _) => None, } } pub fn mouse_left_click_signal(&self, position: &Position, is_held: bool) -> Option { let utf8_event = || -> Option { let button_code = if is_held { b'@' } else { b' ' }; let mut msg: Vec = vec![27, b'[', b'M', button_code]; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }; let sgr_event = || -> Option { let button_code = if is_held { 32 } else { 0 }; Some(format!( "\u{1b}[<{:?};{:?};{:?}M", button_code, position.column() + 1, position.line() + 1 )) }; match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::Normal) if !is_held => { utf8_event() }, ( MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => utf8_event(), ( MouseMode::Sgr, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => sgr_event(), (MouseMode::Sgr, MouseTracking::Normal) if !is_held => sgr_event(), _ => None, } } pub fn mouse_left_click_release_signal(&self, position: &Position) -> Option { match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, _) => { let mut msg: Vec = vec![27, b'[', b'M', b'#']; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (MouseMode::Sgr, _) => { let mouse_event = format!( "\u{1b}[<0;{:?};{:?}m", position.column() + 1, position.line() + 1 ); Some(mouse_event) }, } } pub fn mouse_right_click_signal(&self, position: &Position, is_held: bool) -> Option { let utf8_event = || -> Option { let button_code = if is_held { b'B' } else { b'"' }; let mut msg: Vec = vec![27, b'[', b'M', button_code]; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }; let sgr_event = || -> Option { let button_code = if is_held { 34 } else { 2 }; Some(format!( "\u{1b}[<{:?};{:?};{:?}M", button_code, position.column() + 1, position.line() + 1 )) }; match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::Normal) if !is_held => { utf8_event() }, ( MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => utf8_event(), ( MouseMode::Sgr, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => sgr_event(), (MouseMode::Sgr, MouseTracking::Normal) if !is_held => sgr_event(), _ => None, } } pub fn mouse_right_click_release_signal(&self, position: &Position) -> Option { match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, _) => { let mut msg: Vec = vec![27, b'[', b'M', b'#']; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (MouseMode::Sgr, _) => { let mouse_event = format!( "\u{1b}[<2;{:?};{:?}m", position.column() + 1, position.line() + 1 ); Some(mouse_event) }, } } pub fn mouse_middle_click_signal(&self, position: &Position, is_held: bool) -> Option { let utf8_event = || -> Option { let button_code = if is_held { b'A' } else { b'!' }; let mut msg: Vec = vec![27, b'[', b'M', button_code]; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }; let sgr_event = || -> Option { let button_code = if is_held { 33 } else { 1 }; Some(format!( "\u{1b}[<{:?};{:?};{:?}M", button_code, position.column() + 1, position.line() + 1 )) }; match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::Normal) if !is_held => { utf8_event() }, ( MouseMode::NoEncoding | MouseMode::Utf8, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => utf8_event(), ( MouseMode::Sgr, MouseTracking::ButtonEventTracking | MouseTracking::AnyEventTracking, ) => sgr_event(), (MouseMode::Sgr, MouseTracking::Normal) if !is_held => sgr_event(), _ => None, } } pub fn mouse_middle_click_release_signal(&self, position: &Position) -> Option { match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, _) => { let mut msg: Vec = vec![27, b'[', b'M', b'#']; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (MouseMode::Sgr, _) => { // TODO: these don't add a +1 because it's done outside, we should change it to // happen here for consistency let mouse_event = format!( "\u{1b}[<1;{:?};{:?}m", position.column() + 1, position.line() + 1 ); Some(mouse_event) }, } } pub fn mouse_scroll_up_signal(&self, position: &Position) -> Option { match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, _) => { let mut msg: Vec = vec![27, b'[', b'M', b'`']; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (MouseMode::Sgr, _) => { let mouse_event = format!( "\u{1b}[<64;{:?};{:?}M", position.column.0 + 1, position.line.0 + 1 ); Some(mouse_event) }, } } pub fn mouse_scroll_down_signal(&self, position: &Position) -> Option { match (&self.mouse_mode, &self.mouse_tracking) { (_, MouseTracking::Off) => None, (MouseMode::NoEncoding | MouseMode::Utf8, _) => { let mut msg: Vec = vec![27, b'[', b'M', b'a']; msg.append(&mut utf8_mouse_coordinates( position.column() + 1, position.line() + 1, )); Some(String::from_utf8_lossy(&msg).into()) }, (MouseMode::Sgr, _) => { let mouse_event = format!( "\u{1b}[<65;{:?};{:?}M", position.column.0 + 1, position.line.0 + 1 ); Some(mouse_event) }, } } pub fn is_alternate_mode_active(&self) -> bool { self.alternate_screen_state.is_some() } pub fn focus_event(&self) -> Option { if self.focus_event_tracking { Some("\u{1b}[I".into()) } else { None } } pub fn unfocus_event(&self) -> Option { if self.focus_event_tracking { Some("\u{1b}[O".into()) } else { None } } pub fn delete_viewport_and_scroll(&mut self) { self.lines_above.clear(); self.viewport.clear(); self.lines_below.clear(); } pub fn reset_cursor_position(&mut self) { self.cursor = Cursor::new(0, 0, self.styled_underlines); } /// Queue a CSI ?997;{1|2}n DSR notification of host color-palette /// theme mode onto this grid's pty-write queue. No-op when the app /// has not opted in via `CSI ? 2031 h`. pub fn push_color_palette_dsr(&mut self, mode: HostTerminalThemeMode) { if !self.color_palette_notification_enabled { return; } let code = match mode { HostTerminalThemeMode::Dark => 1, HostTerminalThemeMode::Light => 2, }; self.pending_messages_to_pty .push(format!("\u{1b}[?997;{}n", code).into_bytes()); } pub fn lock_renders(&mut self) { self.lock_renders = true; } pub fn unlock_renders(&mut self) { self.lock_renders = false; } pub fn update_theme(&mut self, theme: Styling) { self.style.colors = theme.clone(); } pub fn update_arrow_fonts(&mut self, should_support_arrow_fonts: bool) { self.arrow_fonts = should_support_arrow_fonts; } pub fn update_kitty_host_support(&mut self, supported: KittyHostSupport) { self.kitty_host_support = supported; } pub fn has_selection(&self) -> bool { !self.selection.is_empty() } pub fn pane_contents( &self, get_full_scrollback: bool, max_scrollback_lines: Option, ) -> PaneContents { let mut viewport: Vec = Vec::with_capacity(self.viewport.len()); for row in &self.viewport { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); viewport.push(s); } let mut contents = if get_full_scrollback { let mut lines_above_viewport: Vec = Vec::with_capacity(self.lines_above.len()); for row in &self.lines_above { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); lines_above_viewport.push(s); } // Truncate to last N lines if max specified (Some(0) means "all" — no truncation) if let Some(max) = max_scrollback_lines { if max > 0 && lines_above_viewport.len() > max { let start = lines_above_viewport.len() - max; lines_above_viewport = lines_above_viewport.split_off(start); } } let mut lines_below_viewport: Vec = Vec::with_capacity(self.lines_below.len()); for row in &self.lines_below { let s: String = (&row.columns).into_iter().map(|x| x.character).collect(); lines_below_viewport.push(s); } PaneContents::new_with_scrollback( viewport, self.selection.start, self.selection.end, lines_above_viewport, lines_below_viewport, ) } else { PaneContents::new(viewport, self.selection.start, self.selection.end) }; contents.cursor = self.visible_cursor_in_viewport(); contents } pub fn pane_contents_with_ansi( &self, get_full_scrollback: bool, max_scrollback_lines: Option, ) -> PaneContents { use std::fmt::Write; let extract_row_with_ansi = |row: &Row| -> String { let mut buf = String::new(); let mut last_styles: Option = None; let last_non_space = row .columns .iter() .rposition(|tc| { let space = tc.character == ' '; let styled = !matches!(tc.styles.background, Some(AnsiCode::Reset) | None); !space || styled }) .map(|i| i + 1) .unwrap_or(0); for tc in row.columns.iter().take(last_non_space) { if last_styles.as_ref() != Some(&tc.styles) { write!(buf, "{}", tc.styles).unwrap(); last_styles = Some(tc.styles.clone()); } buf.push(tc.character); } if last_styles.is_some() { buf.push_str("\u{1b}[m"); } buf }; let mut viewport: Vec = Vec::with_capacity(self.viewport.len()); for row in &self.viewport { viewport.push(extract_row_with_ansi(row)); } let mut contents = if get_full_scrollback { let mut lines_above_viewport: Vec = Vec::with_capacity(self.lines_above.len()); for row in &self.lines_above { lines_above_viewport.push(extract_row_with_ansi(row)); } if let Some(max) = max_scrollback_lines { if max > 0 && lines_above_viewport.len() > max { let start = lines_above_viewport.len() - max; lines_above_viewport = lines_above_viewport.split_off(start); } } let mut lines_below_viewport: Vec = Vec::with_capacity(self.lines_below.len()); for row in &self.lines_below { lines_below_viewport.push(extract_row_with_ansi(row)); } PaneContents::new_with_scrollback( viewport, self.selection.start, self.selection.end, lines_above_viewport, lines_below_viewport, ) } else { PaneContents::new(viewport, self.selection.start, self.selection.end) }; contents.cursor = self.visible_cursor_in_viewport(); contents } fn visible_cursor_in_viewport(&self) -> Option<(usize, usize)> { self.cursor_coordinates() .and_then(|(x, y, is_visible)| if is_visible { Some((x, y)) } else { None }) } } impl Drop for Grid { fn drop(&mut self) { self.kitty_grid.clear_all_placements(); if let Some(alt) = self.alternate_screen_state.as_mut() { alt.kitty_grid.clear_all_placements(); } } } impl Perform for Grid { fn print(&mut self, c: char) { let c = self.cursor.charsets[self.active_charset].map(c); let terminal_character = TerminalCharacter::new_styled(c, self.cursor.pending_styles.clone()); self.set_preceding_character(terminal_character.clone()); self.add_character(terminal_character); } fn execute(&mut self, byte: u8) { match byte { 7 => { self.ring_bell = true; }, 8 => { // backspace self.move_cursor_back(1); }, 9 => { // tab self.advance_to_next_tabstop(self.cursor.pending_styles.clone()); }, 10 | 11 | 12 => { // 0a, newline // 0b, vertical tabulation // 0c, form feed self.add_newline(); }, 13 => { // 0d, carriage return self.move_cursor_to_beginning_of_line(); }, 14 => { self.set_active_charset(CharsetIndex::G1); }, 15 => { self.set_active_charset(CharsetIndex::G0); }, _ => { if self.debug { log::warn!("Unhandled execute: {:?}", byte); } }, } } fn hook(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, c: char) { if c == 'q' { // we only process sixel images if we know the pixel size of each character cell, // otherwise we can't reliably display them if self.current_cursor_pixel_coordinates().is_some() { let max_sixel_height_in_pixels = if self.sixel_scrolling { let character_cell_height = self.character_cell_size.borrow().unwrap().height; // unwrap here is safe because `current_cursor_pixel_coordinates` above is only Some if it exists Some(self.height * character_cell_height) } else { None }; self.sixel_grid.start_image( max_sixel_height_in_pixels, intermediates.iter().collect(), params.iter().collect(), ); } } else if c == 'z' { // UI-component (Zellij internal) self.ui_component_bytes = Some(vec![]); } else if c == 'n' && intermediates.is_empty() && params.len() == 1 && params.iter().next() == Some(&[nested_session::NESTED_DCS_PARAM][..]) { self.nested_frame_bytes = Some(vec![]); } } fn put(&mut self, byte: u8) { if self.sixel_grid.is_parsing() { self.sixel_grid.handle_byte(byte); // we explicitly set this to false here because in the context of Sixel, we only render the // image when it's done, i.e. in the unhook method self.should_render = false; } else if let Some(ui_component_bytes) = self.ui_component_bytes.as_mut() { ui_component_bytes.push(byte); } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.as_mut() { nested_frame_bytes.push(byte); } } fn unhook(&mut self) { if self.sixel_grid.is_parsing() { self.create_sixel_image(); } else if let Some(mut ui_component_bytes) = self.ui_component_bytes.take() { let component_bytes = ui_component_bytes.drain(..); let style = self.style.clone(); let arrow_fonts = self.arrow_fonts; UiComponentParser::new(self, style, arrow_fonts) .parse(component_bytes.collect()) .non_fatal(); } else if let Some(nested_frame_bytes) = self.nested_frame_bytes.take() { if let Some(message) = nested_session::decode_base64(&nested_frame_bytes) .and_then(|payload_bytes| nested_session::decode_payload(&payload_bytes)) { self.pending_nested_session_messages.push(message); } } self.mark_for_rerender(); } fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) { let terminator = if bell_terminated { "\x07" } else { "\x1b\\" }; if params.is_empty() || params[0].is_empty() { return; } match params[0] { // Set window title. b"0" | b"2" => { if params.len() >= 2 { let title = params[1..] .iter() .flat_map(|x| str::from_utf8(x)) .collect::>() .join(";") .trim() .to_owned(); self.set_title(title); } }, b"7" => { if let Some(raw) = params.get(1) { if let Some(path) = parse_osc7_path(raw) { self.pending_osc7_cwd = Some(path); } } }, // sl0p fork: per-pane status bar, rendered in the pane's bottom // frame row. // // OSC 5577 ; 1 ; status ; set the text // OSC 5577 ; 1 ; buttons ; :