use crate::output::CharacterChunk; use crate::panes::{ AnsiCode, CharacterStyles, RcCharacterStyles, TerminalCharacter, EMPTY_TERMINAL_CHARACTER, }; use crate::tab::GuestChoiceIndicator; use crate::ui::boundaries::boundary_type; use crate::ui::hint_text::{ exit_code_segments, hover_segments, rerun_segments, resize_segments, HintExitStatus, HintLevel, HintSegment, HintTier, }; use crate::ClientId; use zellij_utils::data::{client_id_to_colors, PaletteColor, PaneTitleAlignment, Style}; use zellij_utils::input::layout::{layout_status_bar, StatusBarButton}; use zellij_utils::input::theme::StackListStyle; use zellij_utils::errors::prelude::*; use zellij_utils::pane_size::{Offset, PaneGeom, Viewport}; use zellij_utils::position::Position; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; // Compact scroll indication (instead of the noisy "SCROLL: 42/1000"): // a single glyph plus how many lines we are scrolled up, eg. "↑42". // Set to "^" if your font renders the arrow double-width. const SCROLL_GLYPH: &str = "↑"; /// Blank out the "gap ring": the cells between a pane's allocated geometry /// (`outer`) and the smaller frame actually drawn inside it (`inner`) when a /// pane gap is configured. /// /// Without this, nothing ever writes those cells. They look correct on a fresh /// paint only because the screen had just been cleared - as soon as panes are /// moved or resized, whatever was previously drawn there survives as ghosting. /// Emitting explicit blanks makes every cell of the allocation owned by exactly /// one pane again, which is the invariant the renderer already relies on. pub fn gap_ring_chunks(outer: Viewport, inner: Viewport) -> Vec { let mut chunks = vec![]; if outer == inner || outer.cols == 0 || outer.rows == 0 { return chunks; } let blanks = |n: usize| vec![EMPTY_TERMINAL_CHARACTER; n]; let outer_bottom = outer.y.saturating_add(outer.rows); let outer_right = outer.x.saturating_add(outer.cols); let inner_bottom = inner.y.saturating_add(inner.rows); let inner_right = inner.x.saturating_add(inner.cols); for y in outer.y..outer_bottom { let row_intersects_inner = inner.rows > 0 && y >= inner.y && y < inner_bottom; if !row_intersects_inner { // a full-width blank row above or below the preserved rect chunks.push(CharacterChunk::new(blanks(outer.cols), outer.x, y)); continue; } // Emit the spans of this row that the preserved rect does NOT cover. // Everything is clamped to `outer` so this stays correct even if // `inner` is not fully contained in `outer` (it should be, but a // mis-clamped caller must not cause us to write outside the pane). let gap_left_end = inner.x.clamp(outer.x, outer_right); if gap_left_end > outer.x { chunks.push(CharacterChunk::new( blanks(gap_left_end - outer.x), outer.x, y, )); } let gap_right_start = inner_right.clamp(outer.x, outer_right); if outer_right > gap_right_start { chunks.push(CharacterChunk::new( blanks(outer_right - gap_right_start), gap_right_start, y, )); } } chunks } fn styled_characters( characters: &str, style_character: impl Fn(&mut CharacterStyles), ) -> Vec { characters .chars() .map(|character| { let mut styles = RcCharacterStyles::reset(); styles.update(|styles| style_character(styles)); TerminalCharacter::new_styled(character, styles) }) .collect() } fn foreground_color(characters: &str, color: Option) -> Vec { styled_characters(characters, |styles| { styles.bold = Some(AnsiCode::On); if let Some(palette_color) = color { styles.foreground = Some(AnsiCode::from(palette_color)); } }) } fn dimmed_foreground_color( characters: &str, color: Option, ) -> Vec { styled_characters(characters, |styles| { styles.dim = Some(AnsiCode::On); if let Some(palette_color) = color { styles.foreground = Some(AnsiCode::from(palette_color)); } }) } /// Foreground and background both set, so a run of text reads as a solid block. /// Used to mark the active picker entry without spending a column on a marker. fn inverted_color( characters: &str, background: PaletteColor, foreground: PaletteColor, ) -> Vec { styled_characters(characters, |styles| { styles.background = Some(AnsiCode::from(background)); styles.foreground = Some(AnsiCode::from(foreground)); styles.bold = Some(AnsiCode::On); }) } fn background_color(characters: &str, color: Option) -> Vec { styled_characters(characters, |styles| { if let Some(palette_color) = color { styles.background = Some(AnsiCode::from(palette_color)); styles.bold(Some(AnsiCode::On)); } }) } #[derive(Debug, Clone, Copy, PartialEq)] enum ExitStatus { Code(i32), Exited, } #[derive(Clone, PartialEq)] pub struct StackListEntry { pub width: usize, pub label: String, pub is_selected: bool, pub is_emphasized: bool, pub stack_is_focused: bool, /// 1-based position in the stack, rendered as a prefix. See /// stack_index_prefix. pub index: Option, pub style: StackListStyle, } /// Prefix shown before a stack list entry's title. /// /// Stack entries are otherwise identified by title alone, so entries that share /// a title -- or have none, which is the common case for freshly created panes -- /// are indistinguishable and unaddressable. The index keeps every row countable /// even when the titles do not help. /// /// Shared by the renderer and by the column-width calculation in Tab, which must /// agree or the list will misalign. pub fn stack_index_prefix(index: usize) -> String { format!("{}: ", index) } pub struct FrameParams { pub focused_client: Option, pub is_main_client: bool, // more accurately: is_focused_for_main_client pub other_focused_clients: Vec, pub style: Style, pub color: Option, pub other_cursors_exist_in_session: bool, pub pane_is_stacked_under: bool, pub pane_is_stacked_over: bool, pub pane_is_stacked: bool, pub should_draw_pane_frames: bool, pub pane_is_floating: bool, pub content_offset: Offset, pub mouse_is_hovering_over_pane: bool, pub pane_is_selectable: bool, pub show_help_text: bool, /// Program-supplied text for the bottom frame row (OSC 5577). pub status_bar: Option, pub status_bar_buttons: Vec, /// Button this client's pointer is over, drawn highlighted. pub hovered_status_bar_button: Option, pub highlight_tooltip: Option, pub omit_title: bool, pub frame_geom_override: Option, pub stack_list_entry: Option, pub blank_title: bool, pub mouse_scroll_resize: bool, pub dimmed: bool, pub guest_choice_indicator: Option, } #[derive(Default, PartialEq)] pub struct PaneFrame { pub geom: Viewport, pub title: String, pub scroll_position: (usize, usize), // (position, length) pub style: Style, pub color: Option, pub focused_client: Option, pub is_main_client: bool, pub other_cursors_exist_in_session: bool, pub other_focused_clients: Vec, exit_status: Option, is_first_run: bool, pane_is_stacked_over: bool, pane_is_stacked_under: bool, pane_is_stacked: bool, should_draw_pane_frames: bool, is_pinned: bool, is_floating: bool, content_offset: Offset, mouse_is_hovering_over_pane: bool, is_selectable: bool, show_help_text: bool, status_bar: Option, status_bar_buttons: Vec, hovered_status_bar_button: Option, highlight_tooltip: Option, omit_title: bool, stack_list_entry: Option, color_override: Option, mouse_scroll_resize: bool, dimmed: bool, guest_choice_indicator: Option, } impl PaneFrame { pub fn new( geom: Viewport, scroll_position: (usize, usize), main_title: String, frame_params: FrameParams, ) -> Self { PaneFrame { geom, title: main_title, scroll_position, style: frame_params.style, color: frame_params.color, focused_client: frame_params.focused_client, is_main_client: frame_params.is_main_client, other_focused_clients: frame_params.other_focused_clients, other_cursors_exist_in_session: frame_params.other_cursors_exist_in_session, exit_status: None, is_first_run: false, pane_is_stacked_over: frame_params.pane_is_stacked_over, pane_is_stacked_under: frame_params.pane_is_stacked_under, pane_is_stacked: frame_params.pane_is_stacked, should_draw_pane_frames: frame_params.should_draw_pane_frames, is_pinned: false, is_floating: frame_params.pane_is_floating, content_offset: frame_params.content_offset, mouse_is_hovering_over_pane: frame_params.mouse_is_hovering_over_pane, is_selectable: frame_params.pane_is_selectable, show_help_text: frame_params.show_help_text, status_bar: frame_params.status_bar, status_bar_buttons: frame_params.status_bar_buttons, hovered_status_bar_button: frame_params.hovered_status_bar_button, highlight_tooltip: frame_params.highlight_tooltip, omit_title: frame_params.omit_title, stack_list_entry: frame_params.stack_list_entry, color_override: None, mouse_scroll_resize: frame_params.mouse_scroll_resize, dimmed: frame_params.dimmed, guest_choice_indicator: frame_params.guest_choice_indicator, } } pub fn is_pinned(mut self, is_pinned: bool) -> Self { self.is_pinned = is_pinned; self } pub fn add_exit_status(&mut self, exit_status: Option) { self.exit_status = match exit_status { Some(exit_status) => Some(ExitStatus::Code(exit_status)), None => Some(ExitStatus::Exited), }; } pub fn indicate_first_run(&mut self) { self.is_first_run = true; } pub fn override_color(&mut self, color: PaletteColor) { self.color = Some(color); self.color_override = Some(color); } fn client_cursor(&self, client_id: ClientId) -> Vec { let color = client_id_to_colors(client_id, self.style.colors.multiplayer_user_colors); background_color(" ", color.map(|c| c.0)) } fn get_corner(&self, corner: &'static str) -> &'static str { let corner = if !self.should_draw_pane_frames && (corner == boundary_type::TOP_LEFT || corner == boundary_type::TOP_RIGHT) { boundary_type::HORIZONTAL } else if self.pane_is_stacked_under && corner == boundary_type::TOP_RIGHT { boundary_type::BOTTOM_RIGHT } else if self.pane_is_stacked_under && corner == boundary_type::TOP_LEFT { boundary_type::BOTTOM_LEFT } else { corner }; if self.style.rounded_corners { match corner { boundary_type::TOP_RIGHT => boundary_type::TOP_RIGHT_ROUND, boundary_type::TOP_LEFT => boundary_type::TOP_LEFT_ROUND, boundary_type::BOTTOM_RIGHT => boundary_type::BOTTOM_RIGHT_ROUND, boundary_type::BOTTOM_LEFT => boundary_type::BOTTOM_LEFT_ROUND, _ => corner, } } else { corner } } fn render_title_right_side( &self, max_length: usize, ) -> Option<(Vec, usize)> { self.render_title_right_side_inner(max_length) } fn render_title_right_side_inner( &self, max_length: usize, ) -> Option<(Vec, usize)> { // string and length because of color // only indicate scroll when actually scrolled up, not merely when // scrollback exists (scroll_position.1 > 0), which is almost always let has_scroll = self.scroll_position.0 > 0; if has_scroll && self.is_selectable { // TODO: don't show SCROLL at all for plugins let pin_indication = if self.is_floating && self.is_selectable { self.render_pinned_indication(max_length) } else { None }; // no pin indication for tiled panes let space_for_scroll_indication = pin_indication .as_ref() .map(|(_, length)| max_length.saturating_sub(*length + 1)) .unwrap_or(max_length); let scroll_indication = self.render_scroll_indication(space_for_scroll_indication); match (pin_indication, scroll_indication) { ( Some((mut pin_indication, pin_indication_len)), Some((mut scroll_indication, scroll_indication_len)), ) => { let mut characters: Vec<_> = scroll_indication.drain(..).collect(); let mut separator = foreground_color(&format!("|"), self.color); characters.append(&mut separator); characters.append(&mut pin_indication); Some((characters, pin_indication_len + scroll_indication_len + 1)) }, (Some(pin_indication), None) => Some(pin_indication), (None, Some(scroll_indication)) => Some(scroll_indication), _ => None, } } else if self.is_floating && self.is_selectable { self.render_pinned_indication(max_length) } else { None } } fn render_scroll_indication( &self, max_length: usize, ) -> Option<(Vec, usize)> { let full_indication = format!(" {}{} ", SCROLL_GLYPH, self.scroll_position.0); let short_indication = format!(" {} ", SCROLL_GLYPH); let full_indication_len = full_indication.width(); let short_indication_len = short_indication.width(); if full_indication_len <= max_length { Some(( foreground_color(&full_indication, self.color), full_indication_len, )) } else if short_indication_len <= max_length { Some(( foreground_color(&short_indication, self.color), short_indication_len, )) } else { None } } fn render_pinned_indication( &self, max_length: usize, ) -> Option<(Vec, usize)> { let is_checked = if self.is_pinned { '+' } else { ' ' }; let full_indication = format!(" PIN [{}] ", is_checked); let full_indication_len = full_indication.chars().count(); if full_indication_len <= max_length { Some(( foreground_color(&full_indication, self.color), full_indication_len, )) } else { None } } fn render_my_focus(&self, max_length: usize) -> Option<(Vec, usize)> { let mut left_separator = foreground_color(boundary_type::VERTICAL_LEFT, self.color); let mut right_separator = foreground_color(boundary_type::VERTICAL_RIGHT, self.color); let full_indication_text = "MY FOCUS"; let mut full_indication = vec![]; full_indication.append(&mut left_separator); full_indication.push(EMPTY_TERMINAL_CHARACTER); full_indication.append(&mut foreground_color(full_indication_text, self.color)); full_indication.push(EMPTY_TERMINAL_CHARACTER); full_indication.append(&mut right_separator); let full_indication_len = full_indication_text.width() + 4; // 2 for separators 2 for padding let short_indication_text = "ME"; let mut short_indication = vec![]; short_indication.append(&mut left_separator); short_indication.push(EMPTY_TERMINAL_CHARACTER); short_indication.append(&mut foreground_color(short_indication_text, self.color)); short_indication.push(EMPTY_TERMINAL_CHARACTER); short_indication.append(&mut right_separator); let short_indication_len = short_indication_text.width() + 4; // 2 for separators 2 for padding if full_indication_len <= max_length { Some((full_indication, full_indication_len)) } else if short_indication_len <= max_length { Some((short_indication, short_indication_len)) } else { None } } fn render_my_and_others_focus( &self, max_length: usize, ) -> Option<(Vec, usize)> { let mut left_separator = foreground_color(boundary_type::VERTICAL_LEFT, self.color); let mut right_separator = foreground_color(boundary_type::VERTICAL_RIGHT, self.color); let full_indication_text = "MY FOCUS AND:"; let short_indication_text = "+"; let mut full_indication = foreground_color(full_indication_text, self.color); let mut full_indication_len = full_indication_text.width(); let mut short_indication = foreground_color(short_indication_text, self.color); let mut short_indication_len = short_indication_text.width(); for client_id in &self.other_focused_clients { let mut text = self.client_cursor(*client_id); full_indication_len += 2; full_indication.push(EMPTY_TERMINAL_CHARACTER); full_indication.append(&mut text.clone()); short_indication_len += 2; short_indication.push(EMPTY_TERMINAL_CHARACTER); short_indication.append(&mut text); } if full_indication_len + 4 <= max_length { // 2 for separators, 2 for padding let mut ret = vec![]; ret.append(&mut left_separator); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut full_indication); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut right_separator); Some((ret, full_indication_len + 4)) } else if short_indication_len + 4 <= max_length { // 2 for separators, 2 for padding let mut ret = vec![]; ret.append(&mut left_separator); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut short_indication); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut right_separator); Some((ret, short_indication_len + 4)) } else { None } } fn render_other_focused_users( &self, max_length: usize, ) -> Option<(Vec, usize)> { let mut left_separator = foreground_color(boundary_type::VERTICAL_LEFT, self.color); let mut right_separator = foreground_color(boundary_type::VERTICAL_RIGHT, self.color); let full_indication_text = if self.other_focused_clients.len() == 1 { "FOCUSED USER:" } else { "FOCUSED USERS:" }; let middle_indication_text = "U:"; let mut full_indication = foreground_color(full_indication_text, self.color); let mut full_indication_len = full_indication_text.width(); let mut middle_indication = foreground_color(middle_indication_text, self.color); let mut middle_indication_len = middle_indication_text.width(); let mut short_indication = vec![]; let mut short_indication_len = 0; for client_id in &self.other_focused_clients { let mut text = self.client_cursor(*client_id); full_indication_len += 2; full_indication.push(EMPTY_TERMINAL_CHARACTER); full_indication.append(&mut text.clone()); middle_indication_len += 2; middle_indication.push(EMPTY_TERMINAL_CHARACTER); middle_indication.append(&mut text.clone()); short_indication_len += 2; short_indication.push(EMPTY_TERMINAL_CHARACTER); short_indication.append(&mut text); } if full_indication_len + 4 <= max_length { // 2 for separators, 2 for padding let mut ret = vec![]; ret.append(&mut left_separator); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut full_indication); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut right_separator); Some((ret, full_indication_len + 4)) } else if middle_indication_len + 4 <= max_length { // 2 for separators, 2 for padding let mut ret = vec![]; ret.append(&mut left_separator); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut middle_indication); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut right_separator); Some((ret, middle_indication_len + 4)) } else if short_indication_len + 3 <= max_length { // 2 for separators, 1 for padding let mut ret = vec![]; ret.append(&mut left_separator); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut short_indication); ret.push(EMPTY_TERMINAL_CHARACTER); ret.append(&mut right_separator); Some((ret, short_indication_len + 3)) } else { None } } fn render_title_middle(&self, max_length: usize) -> Option<(Vec, usize)> { // string and length because of color let renders_exit_status_in_title = self.pane_is_stacked_under || self.pane_is_stacked_over || !self.should_draw_pane_frames; if renders_exit_status_in_title && self.exit_status.is_some() { let (first_part, first_part_len) = self.first_exited_held_title_part_full(); return if first_part_len <= max_length { Some((first_part, first_part_len)) } else { None }; } if self.is_main_client && self.other_focused_clients.is_empty() && !self.other_cursors_exist_in_session { None } else if self.is_main_client && self.other_focused_clients.is_empty() && self.other_cursors_exist_in_session { self.render_my_focus(max_length) } else if self.is_main_client && !self.other_focused_clients.is_empty() { self.render_my_and_others_focus(max_length) } else if !self.other_focused_clients.is_empty() { self.render_other_focused_users(max_length) } else { None } } fn render_title_left_side(&self, max_length: usize) -> Option<(Vec, usize)> { let middle_truncated_sign = "[..]"; let middle_truncated_sign_long = "[...]"; let full_text = format!(" {} ", &self.title); if max_length <= 6 || self.title.is_empty() { None } else if full_text.width() <= max_length { Some((foreground_color(&full_text, self.color), full_text.width())) } else { let length_of_each_half = (max_length - middle_truncated_sign.width()) / 2; let mut first_part: String = String::new(); for char in full_text.chars() { if first_part.width() + char.width().unwrap_or(0) > length_of_each_half { break; } else { first_part.push(char); } } let mut second_part: String = String::new(); for char in full_text.chars().rev() { if second_part.width() + char.width().unwrap_or(0) > length_of_each_half { break; } else { second_part.insert(0, char); } } let (title_left_side, title_length) = if first_part.width() + middle_truncated_sign.width() + second_part.width() < max_length { // this means we lost 1 character when dividing the total length into halves ( format!( "{}{}{}", first_part, middle_truncated_sign_long, second_part ), first_part.width() + middle_truncated_sign_long.width() + second_part.width(), ) } else { ( format!("{}{}{}", first_part, middle_truncated_sign, second_part), first_part.width() + middle_truncated_sign.width() + second_part.width(), ) }; Some((foreground_color(&title_left_side, self.color), title_length)) } } fn three_part_title_line( &self, mut left_side: Vec, left_side_len: &usize, mut middle: Vec, middle_len: &usize, mut right_side: Vec, right_side_len: &usize, ) -> Vec { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut title_line = vec![]; let left_side_start_position = self.geom.x + 1; let middle_start_position = self.geom.x + (total_title_length / 2) - (middle_len / 2) + 1; let right_side_start_position = (self.geom.x + self.geom.cols - 1).saturating_sub(*right_side_len); let mut col = self.geom.x; loop { if col == self.geom.x { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_LEFT), self.color, )); } else if col == self.geom.x + self.geom.cols - 1 { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_RIGHT), self.color, )); } else if col == left_side_start_position { title_line.append(&mut left_side); col += left_side_len; continue; } else if col == middle_start_position { title_line.append(&mut middle); col += middle_len; continue; } else if col == right_side_start_position { title_line.append(&mut right_side); col += right_side_len; continue; } else { title_line.append(&mut foreground_color(boundary_type::HORIZONTAL, self.color)); } if col == self.geom.x + self.geom.cols - 1 { break; } col += 1; } title_line } fn left_and_middle_title_line( &self, mut left_side: Vec, left_side_len: &usize, mut middle: Vec, middle_len: &usize, ) -> Vec { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut title_line = vec![]; let left_side_start_position = self.geom.x + 1; let middle_start_position = self.geom.x + (total_title_length / 2) - (*middle_len / 2) + 1; let mut col = self.geom.x; loop { if col == self.geom.x { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_LEFT), self.color, )); } else if col == self.geom.x + self.geom.cols - 1 { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_RIGHT), self.color, )); } else if col == left_side_start_position { title_line.append(&mut left_side); col += *left_side_len; continue; } else if col == middle_start_position { title_line.append(&mut middle); col += *middle_len; continue; } else { title_line.append(&mut foreground_color(boundary_type::HORIZONTAL, self.color)); } if col == self.geom.x + self.geom.cols - 1 { break; } col += 1; } title_line } fn middle_only_title_line( &self, mut middle: Vec, middle_len: &usize, ) -> Vec { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut title_line = vec![]; let middle_start_position = self.geom.x + (total_title_length / 2) - (*middle_len / 2) + 1; let mut col = self.geom.x; loop { if col == self.geom.x { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_LEFT), self.color, )); } else if col == self.geom.x + self.geom.cols - 1 { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_RIGHT), self.color, )); } else if col == middle_start_position { title_line.append(&mut middle); col += *middle_len; continue; } else { title_line.append(&mut foreground_color(boundary_type::HORIZONTAL, self.color)); } if col == self.geom.x + self.geom.cols - 1 { break; } col += 1; } title_line } fn two_part_title_line( &self, mut left_side: Vec, left_side_len: &usize, mut right_side: Vec, right_side_len: &usize, ) -> Vec { let mut left_boundary = foreground_color(self.get_corner(boundary_type::TOP_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::TOP_RIGHT), self.color); let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut middle = String::new(); for _ in (left_side_len + right_side_len)..total_title_length { middle.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut left_side); ret.append(&mut foreground_color(&middle, self.color)); ret.append(&mut right_side); ret.append(&mut right_boundary); ret } fn left_only_title_line( &self, mut left_side: Vec, left_side_len: &usize, ) -> Vec { let mut left_boundary = foreground_color(self.get_corner(boundary_type::TOP_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::TOP_RIGHT), self.color); let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut middle_padding = String::new(); for _ in *left_side_len..total_title_length { middle_padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut left_side); ret.append(&mut foreground_color(&middle_padding, self.color)); ret.append(&mut right_boundary); ret } fn empty_title_line(&self) -> Vec { let mut left_boundary = foreground_color(self.get_corner(boundary_type::TOP_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::TOP_RIGHT), self.color); let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut middle_padding = String::new(); for _ in 0..total_title_length { middle_padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut foreground_color(&middle_padding, self.color)); ret.append(&mut right_boundary); ret } fn title_line_with_middle( &self, middle: Vec, middle_len: &usize, ) -> Vec { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let length_of_each_side = total_title_length.saturating_sub(*middle_len + 2) / 2; let left_side = self.render_title_left_side(length_of_each_side); let right_side = self.render_title_right_side(length_of_each_side); match (left_side, right_side) { (Some((left_side, left_side_len)), Some((right_side, right_side_len))) => self .three_part_title_line( left_side, &left_side_len, middle, middle_len, right_side, &right_side_len, ), (Some((left_side, left_side_len)), None) => { self.left_and_middle_title_line(left_side, &left_side_len, middle, middle_len) }, _ => self.middle_only_title_line(middle, middle_len), } } /// Compose a title line with the pane title placed at `alignment` rather than /// hard against the left corner. The geometry loop mirrors /// `three_part_title_line`; only the start position of the title differs. /// /// The right side (scroll / pin indicators) is anchored and laid out first, /// so the title only ever gets the space that is genuinely left over. fn aligned_title_line(&self, alignment: PaneTitleAlignment) -> Vec { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the corners let right_side = self.render_title_right_side(total_title_length); let right_side_len = right_side.as_ref().map(|(_, len)| *len).unwrap_or(0); // 1 cell of breathing room between title and the right-side indicators let separator = if right_side_len > 0 { 1 } else { 0 }; let space_for_title = total_title_length.saturating_sub(right_side_len + separator); let title = self.render_title_left_side(space_for_title); let title_len = title.as_ref().map(|(_, len)| *len).unwrap_or(0); let last_col = self.geom.x + self.geom.cols.saturating_sub(1); let right_side_start = last_col.saturating_sub(right_side_len); let min_title_start = self.geom.x + 1; // never let the title run into the right-side indicators or the corner let max_title_start = right_side_start .saturating_sub(title_len + separator) .max(min_title_start); let title_start = match alignment { PaneTitleAlignment::Center => { min_title_start + total_title_length.saturating_sub(title_len) / 2 }, PaneTitleAlignment::Right => max_title_start, PaneTitleAlignment::Left => min_title_start, } .clamp(min_title_start, max_title_start); let mut title_chars = title.map(|(chars, _)| chars).unwrap_or_default(); let mut right_chars = right_side.map(|(chars, _)| chars).unwrap_or_default(); let mut title_line = vec![]; let mut col = self.geom.x; loop { if col == self.geom.x { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_LEFT), self.color, )); } else if col >= last_col { title_line.append(&mut foreground_color( self.get_corner(boundary_type::TOP_RIGHT), self.color, )); } else if title_len > 0 && col == title_start { title_line.append(&mut title_chars); col += title_len; continue; } else if right_side_len > 0 && col == right_side_start { title_line.append(&mut right_chars); col += right_side_len; continue; } else { title_line.append(&mut foreground_color(boundary_type::HORIZONTAL, self.color)); } // `>=` rather than `==`: a mis-clamped segment must not spin forever if col >= last_col { break; } col += 1; } title_line } fn title_line_without_middle(&self) -> Vec { // Left keeps the original code path untouched, so the default config // renders byte-for-byte as before. if self.style.pane_title_alignment != PaneTitleAlignment::Left { return self.aligned_title_line(self.style.pane_title_alignment); } let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let left_side = self.render_title_left_side(total_title_length); let right_side = left_side.as_ref().and_then(|(_left_side, left_side_len)| { let space_left = total_title_length.saturating_sub(*left_side_len + 1); // 1 for a middle separator self.render_title_right_side(space_left) }); match (left_side, right_side) { (Some((left_side, left_side_len)), Some((right_side, right_side_len))) => { self.two_part_title_line(left_side, &left_side_len, right_side, &right_side_len) }, (Some((left_side, left_side_len)), None) => { self.left_only_title_line(left_side, &left_side_len) }, _ => self.empty_title_line(), } } fn render_title(&self) -> Result> { let total_title_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners self.render_title_middle(total_title_length) .map(|(middle, middle_length)| self.title_line_with_middle(middle, &middle_length)) .or_else(|| Some(self.title_line_without_middle())) .with_context(|| format!("failed to render title '{}'", self.title)) } fn render_stack_list_entry(&self, entry: &StackListEntry) -> Vec { let usable_cols = self.geom.cols; let minimal = entry.style == StackListStyle::Minimal; // Minimal drops the marker and the borders entirely: the active entry is // shown by inverting it instead, which needs no glyphs and no column. let selection_marker = if minimal { "" } else { "> " }; let marker_width = selection_marker.width(); let corner_padding = 1; let corner_overhead = if minimal { 2 * corner_padding } else { 2 * (1 + corner_padding) }; let inner_width = entry .width .min(usable_cols.saturating_sub(marker_width + corner_overhead)); let label = match entry.index { Some(index) => format!("{}{}", stack_index_prefix(index), entry.label), None => entry.label.clone(), }; let content = if label.width() <= inner_width { label.clone() } else { let truncation_budget = inner_width.saturating_sub(1); let mut truncated = String::new(); for character in label.chars() { if truncated.width() + character.width().unwrap_or(0) > truncation_budget { break; } truncated.push(character); } truncated.push('…'); truncated }; let full_width_entry_length = marker_width + corner_overhead + inner_width; let entry_start = usable_cols.saturating_sub(full_width_entry_length) / 2; let left_budget = entry_start; let (mut focus_part, focus_length) = self .bracketed_focus_indicator(left_budget) .unwrap_or((vec![], 0)); let mut line = Vec::with_capacity(usable_cols); line.append(&mut focus_part); for _ in focus_length..left_budget { line.push(EMPTY_TERMINAL_CHARACTER); } let unfocused_color = self.style.colors.frame_unselected.map(|frame| frame.base); // ribbon_selected, not text_selected: themes give ribbon_selected real // contrast because it marks the selected item in zellij's own UI, while // text_selected's background is often a hair off the terminal's own and // would read as no highlight at all. let selected_colors = self.style.colors.ribbon_selected; let style_entry_text = |text: &str| { if minimal && entry.is_selected { // inverted: the entry's own block IS the indicator inverted_color(text, selected_colors.background, selected_colors.base) } else if entry.is_selected || entry.is_emphasized { foreground_color(text, self.color) } else if entry.stack_is_focused { foreground_color(text, unfocused_color) } else { dimmed_foreground_color(text, unfocused_color) } }; if entry.is_selected { line.append(&mut foreground_color(selection_marker, None)); } else { for _ in 0..marker_width { line.push(EMPTY_TERMINAL_CHARACTER); } } if !minimal { line.append(&mut foreground_color(boundary_type::VERTICAL, None)); } let content_padding = inner_width.saturating_sub(content.width()); if minimal { // pad INSIDE the styled run, so the inverted block covers the whole // cell rather than leaving unstyled gaps at its edges let padded = format!( "{}{}{}{}", " ".repeat(corner_padding), content, " ".repeat(content_padding), " ".repeat(corner_padding) ); line.append(&mut style_entry_text(&padded)); } else { for _ in 0..corner_padding { line.push(EMPTY_TERMINAL_CHARACTER); } line.append(&mut style_entry_text(&content)); for _ in 0..content_padding { line.push(EMPTY_TERMINAL_CHARACTER); } for _ in 0..corner_padding { line.push(EMPTY_TERMINAL_CHARACTER); } line.append(&mut foreground_color(boundary_type::VERTICAL, None)); } let mut occupied_columns = entry_start + full_width_entry_length; let (mut scroll_part, scroll_length) = self .bracketed_scroll_indicator(usable_cols.saturating_sub(occupied_columns)) .unwrap_or((vec![], 0)); if self.exit_status.is_some() { let (mut exit_part, exit_length) = self.first_exited_held_title_part_full(); if occupied_columns + 1 + exit_length + scroll_length <= usable_cols { line.push(EMPTY_TERMINAL_CHARACTER); line.append(&mut exit_part); occupied_columns += 1 + exit_length; } } while occupied_columns < usable_cols.saturating_sub(scroll_length) { line.push(EMPTY_TERMINAL_CHARACTER); occupied_columns += 1; } line.append(&mut scroll_part); line } fn render_one_line_title(&self) -> Result> { if self.should_draw_pane_frames { let total_title_length = self.geom.cols.saturating_sub(2); return self .render_title_middle(total_title_length) .map(|(middle, middle_length)| self.title_line_with_middle(middle, &middle_length)) .or_else(|| Some(self.title_line_without_middle())) .with_context(|| format!("failed to render title '{}'", self.title)); } let width = self.geom.cols; let title = self.bracketed_pane_title(width); let title_length = title.as_ref().map(|(_, length)| *length).unwrap_or(0); let side_budget = width.saturating_sub(title_length) / 2; let focus = self.bracketed_focus_indicator(side_budget); let focus_length = focus.as_ref().map(|(_, length)| *length).unwrap_or(0); let right_budget = width.saturating_sub(focus_length + title_length); let right = self.bracketed_scroll_indicator(right_budget); Ok(self.compose_bracketed_title(focus, title, right)) } fn bracketed_title_part(&self, content: &str) -> (Vec, usize) { let text = format!(" [ {} ] ", content); (foreground_color(&text, self.color), text.width()) } fn bracketed_title_part_from_characters( &self, mut content: Vec, content_length: usize, ) -> (Vec, usize) { let mut part = foreground_color(" [ ", self.color); part.append(&mut content); part.append(&mut foreground_color(" ] ", self.color)); (part, content_length + 6) } fn plain_title_part(&self, content: &str) -> (Vec, usize) { let text = format!(" {} ", content); (foreground_color(&text, self.color), text.width()) } fn bracketed_pane_title(&self, max_length: usize) -> Option<(Vec, usize)> { let title_padding = 2; if self.title.is_empty() || max_length <= title_padding { return None; } let exit_part = self .exit_status .is_some() .then(|| self.first_exited_held_title_part_full()); let exit_length = exit_part.as_ref().map(|(_, length)| *length).unwrap_or(0); let content_max_length = max_length.saturating_sub(title_padding + exit_length); let content = if self.title.width() <= content_max_length { self.title.clone() } else { let truncation_budget = content_max_length.saturating_sub(1); let mut truncated = String::new(); for character in self.title.chars() { if truncated.width() + character.width().unwrap_or(0) > truncation_budget { break; } truncated.push(character); } truncated.push('…'); truncated }; let (mut part, mut length) = self.plain_title_part(&content); if let Some((mut exit, exit_length)) = exit_part { part.append(&mut exit); length += exit_length; } Some((part, length)) } fn bracketed_scroll_indicator( &self, max_length: usize, ) -> Option<(Vec, usize)> { let has_scroll = self.scroll_position.0 > 0; if !(has_scroll && self.is_selectable) { return None; } let full_indication = format!("{}{}", SCROLL_GLYPH, self.scroll_position.0); let (full_part, full_length) = self.bracketed_title_part(&full_indication); if full_length <= max_length { return Some((full_part, full_length)); } let short_indication = SCROLL_GLYPH.to_string(); let (short_part, short_length) = self.bracketed_title_part(&short_indication); if short_length <= max_length { return Some((short_part, short_length)); } None } fn bracketed_focus_indicator( &self, max_length: usize, ) -> Option<(Vec, usize)> { if self.is_main_client && self.other_focused_clients.is_empty() && !self.other_cursors_exist_in_session { None } else if self.is_main_client && self.other_focused_clients.is_empty() && self.other_cursors_exist_in_session { let (full_part, full_length) = self.bracketed_title_part("MY FOCUS"); if full_length <= max_length { Some((full_part, full_length)) } else { let (short_part, short_length) = self.bracketed_title_part("ME"); (short_length <= max_length).then_some((short_part, short_length)) } } else if self.is_main_client && !self.other_focused_clients.is_empty() { self.bracketed_focused_users("MY FOCUS AND:", "+", max_length) } else if !self.other_focused_clients.is_empty() { let full_label = if self.other_focused_clients.len() == 1 { "FOCUSED USER:" } else { "FOCUSED USERS:" }; self.bracketed_focused_users(full_label, "U:", max_length) } else { None } } fn focused_users_part(&self, label: &str) -> (Vec, usize) { let mut content = foreground_color(label, self.color); let mut content_length = label.width(); for client_id in &self.other_focused_clients { content.push(EMPTY_TERMINAL_CHARACTER); content.append(&mut self.client_cursor(*client_id)); content_length += 2; } self.bracketed_title_part_from_characters(content, content_length) } fn focused_users_cursors_part(&self) -> (Vec, usize) { let mut content = vec![]; let mut content_length = 0; for client_id in &self.other_focused_clients { if content_length > 0 { content.push(EMPTY_TERMINAL_CHARACTER); content_length += 1; } content.append(&mut self.client_cursor(*client_id)); content_length += 1; } self.bracketed_title_part_from_characters(content, content_length) } fn bracketed_focused_users( &self, full_label: &str, short_label: &str, max_length: usize, ) -> Option<(Vec, usize)> { let (full_part, full_length) = self.focused_users_part(full_label); if full_length <= max_length { return Some((full_part, full_length)); } let (short_part, short_length) = self.focused_users_part(short_label); if short_length <= max_length { return Some((short_part, short_length)); } let (cursors_part, cursors_length) = self.focused_users_cursors_part(); (cursors_length <= max_length).then_some((cursors_part, cursors_length)) } fn compose_bracketed_title( &self, left: Option<(Vec, usize)>, middle: Option<(Vec, usize)>, right: Option<(Vec, usize)>, ) -> Vec { let width = self.geom.cols; let left_length = left.as_ref().map(|(_, length)| *length).unwrap_or(0); let middle_length = middle.as_ref().map(|(_, length)| *length).unwrap_or(0); let right_length = right.as_ref().map(|(_, length)| *length).unwrap_or(0); let centered_start = (width / 2) .saturating_sub(middle_length / 2) .max(left_length); let middle_start = if middle.is_some() { if right_length > 0 { let scroll_start = width.saturating_sub(right_length); let centered_end = centered_start + middle_length; if centered_end > scroll_start { scroll_start.saturating_sub(middle_length).max(left_length) } else { centered_start } } else { centered_start } } else { left_length }; let middle_end = if middle.is_some() { middle_start + middle_length } else { left_length }; let right_start = width.saturating_sub(right_length).max(middle_end); let fill_character = if self.pane_is_stacked { foreground_color(boundary_type::HORIZONTAL, self.color) .into_iter() .next() .unwrap_or(EMPTY_TERMINAL_CHARACTER) } else { EMPTY_TERMINAL_CHARACTER }; let mut placements: Vec<(usize, Vec, usize)> = vec![]; if let Some((characters, length)) = left { placements.push((0, characters, length)); } if let Some((characters, length)) = middle { placements.push((middle_start, characters, length)); } if let Some((characters, length)) = right { placements.push((right_start, characters, length)); } let mut title_line = vec![]; let mut col = 0; while col < width { if let Some(index) = placements.iter().position(|(start, _, _)| *start == col) { let (_, mut characters, length) = placements.remove(index); title_line.append(&mut characters); col += length; } else { title_line.push(fill_character.clone()); col += 1; } } title_line } fn render_highlight_tooltip_undertitle(&self) -> Result> { let max_undertitle_length = self.geom.cols.saturating_sub(2); let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); let tooltip_text = self.highlight_tooltip.as_deref().unwrap_or(""); let text = format!(" Alt - {} ", tooltip_text); let text_len = text.chars().count(); if text_len > max_undertitle_length { return Ok(self.empty_undertitle(max_undertitle_length)); } let mut text_characters = foreground_color(&text, self.color); let padding_len = max_undertitle_length.saturating_sub(text_len); let mut padding = String::new(); for _ in 0..padding_len { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut text_characters); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); Ok(ret) } /// Render the program-supplied status text into the pane's bottom frame row. /// /// Deliberately reuses the frame row rather than reserving a row of its own: /// reserving would shrink the pane's content area and so resize its PTY every /// time the bar appeared or disappeared, firing SIGWINCH at the program that /// asked for it. The frame row costs nothing and is already a text surface. /// Render the pane's status bar into its bottom frame row. /// /// Deliberately reuses the frame row rather than reserving a row of its own: /// reserving would shrink the pane's content area and so resize its PTY every /// time the bar appeared or disappeared, firing SIGWINCH at the program that /// asked for it. The frame row costs nothing and is already a text surface. /// /// Positions come from `layout_status_bar`, the same function the mouse /// hit-test uses, so a button can never be drawn somewhere a click does not /// resolve. fn render_status_bar_undertitle(&self) -> Result> { let err_context = || "failed to render status bar undertitle"; let inner_cols = self.geom.cols.saturating_sub(2); let layout = layout_status_bar( inner_cols, self.status_bar.as_deref(), &self.status_bar_buttons, self.style.status_bar_button_style, ); let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); // build the inner row as a fill, then paint text and buttons into it let fill = foreground_color(boundary_type::HORIZONTAL, self.color) .pop() .with_context(err_context)?; let mut inner: Vec = vec![fill; inner_cols]; if !layout.text.is_empty() { let text = format!(" {} ", layout.text); for (i, character) in foreground_color(&text, self.color).into_iter().enumerate() { if let Some(slot) = inner.get_mut(i) { *slot = character; } } } let button_color = self.style.colors.text_unselected.emphasis_3; let hovered = self.hovered_status_bar_button.as_deref(); for span in &layout.buttons { // The hovered button is inverted rather than merely recoloured: a // button is a click target, so "the pointer is on THIS one" wants to // be unmistakable, and swapping fore/background reads regardless of // which colours a theme happens to use. let characters = if hovered == Some(span.id.as_str()) { inverted_color( &span.rendered, button_color, self.style.colors.text_unselected.background, ) } else { foreground_color(&span.rendered, Some(button_color)) }; for (i, character) in characters.into_iter().enumerate() { if let Some(slot) = inner.get_mut(span.start + i) { *slot = character; } } } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut inner); ret.append(&mut right_boundary); Ok(ret) } fn render_help_text_undertitle(&self) -> Result> { let max_undertitle_length = self.geom.cols.saturating_sub(2); let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); // Try different versions of the help text from longest to shortest let (mut help_text_characters, help_text_len) = if let Some((chars, len)) = self.help_text_version_full(max_undertitle_length) { (chars, len) } else if let Some((chars, len)) = self.help_text_version_medium(max_undertitle_length) { (chars, len) } else if let Some((chars, len)) = self.help_text_version_short(max_undertitle_length) { (chars, len) } else { return Ok(self.empty_undertitle(max_undertitle_length)); }; let padding_len = max_undertitle_length.saturating_sub(help_text_len); let mut padding = String::new(); for _ in 0..padding_len { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut help_text_characters); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); Ok(ret) } fn help_text_version_full(&self, max_length: usize) -> Option<(Vec, usize)> { self.help_text_version(max_length, HintTier::Full) } fn help_text_version_medium( &self, max_length: usize, ) -> Option<(Vec, usize)> { self.help_text_version(max_length, HintTier::Medium) } fn help_text_version_short( &self, max_length: usize, ) -> Option<(Vec, usize)> { self.help_text_version(max_length, HintTier::Minimal) } fn help_text_version( &self, max_length: usize, tier: HintTier, ) -> Option<(Vec, usize)> { let segments = resize_segments(self.is_floating, self.mouse_scroll_resize, tier); let (characters, length) = self.render_hint_segments(&segments); (length <= max_length).then_some((characters, length)) } fn render_held_undertitle(&self) -> Result> { let max_undertitle_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let (mut first_part, first_part_len) = self.first_exited_held_title_part_full(); let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); let res = if self.is_main_client { let (mut second_part, second_part_len) = self.second_held_title_part_full(); let full_text_len = first_part_len + second_part_len; if full_text_len <= max_undertitle_length { // render exit status and tips let mut padding = String::new(); for _ in full_text_len..max_undertitle_length { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut first_part); ret.append(&mut second_part); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); ret } else if first_part_len <= max_undertitle_length { // render only exit status let mut padding = String::new(); for _ in first_part_len..max_undertitle_length { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut first_part); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); ret } else { self.empty_undertitle(max_undertitle_length) } } else { if first_part_len <= max_undertitle_length { // render first part let full_text_len = first_part_len; let mut padding = String::new(); for _ in full_text_len..max_undertitle_length { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut first_part); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); ret } else { self.empty_undertitle(max_undertitle_length) } }; Ok(res) } fn render_mouse_shortcuts_undertitle(&self) -> Result> { let max_undertitle_length = self.geom.cols.saturating_sub(2); // 2 for the left and right corners let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); let res = if self.is_main_client { self.empty_undertitle(max_undertitle_length) } else { let (mut hover_shortcuts, hover_shortcuts_len) = self.hover_shortcuts_part_full(); if hover_shortcuts_len <= max_undertitle_length { // render exit status and tips let mut padding = String::new(); for _ in hover_shortcuts_len..max_undertitle_length { padding.push_str(boundary_type::HORIZONTAL); } let mut ret = vec![]; ret.append(&mut left_boundary); ret.append(&mut hover_shortcuts); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); ret } else { self.empty_undertitle(max_undertitle_length) } }; Ok(res) } pub fn clicked_on_pinned(&mut self, position: Position) -> bool { if self.is_floating { // TODO: this is not entirely accurate because our relative position calculation in // itself isn't - when that is fixed, we should adjust this as well let checkbox_center_position = self.geom.cols.saturating_sub(5); let checkbox_position_start = checkbox_center_position.saturating_sub(1); let checkbox_position_end = checkbox_center_position + 1; if position.line() == -1 && (position.column() >= checkbox_position_start && position.column() <= checkbox_position_end) { return true; } } false } pub fn render(&self) -> Result<(Vec, Option)> { let err_context = || "failed to render pane frame"; let mut character_chunks = vec![]; if self.omit_title { return Ok((character_chunks, None)); } if let Some(entry) = &self.stack_list_entry { character_chunks.push(CharacterChunk::new( self.render_stack_list_entry(entry), self.geom.x, self.geom.y, )); return Ok((character_chunks, None)); } if self.geom.rows == 1 || !self.should_draw_pane_frames { // we do this explicitly when not drawing pane frames because this should only happen // if this is a stacked pane with pane frames off (and it doesn't necessarily have only // 1 row because it could also be a flexible stacked pane) // in this case we should always draw the pane title line, and only the title line let mut one_line_title = self.render_one_line_title().with_context(err_context)?; if self.content_offset.right != 0 && !self.should_draw_pane_frames { // here what happens is that the title should be offset to the right // in order to give room to the boundaries between the panes to be drawn one_line_title.pop(); } let y_coords_of_title = if self.pane_is_stacked_under && !self.should_draw_pane_frames { // we only want to use the bottom offset in this case because panes that are // stacked above the flexible pane should actually appear exactly where they are on // screen, the content offset being "absorbed" by the flexible pane below them self.geom.y.saturating_sub(self.content_offset.bottom) } else { self.geom.y }; character_chunks.push(CharacterChunk::new( one_line_title, self.geom.x, y_coords_of_title, )); } else { for row in 0..self.geom.rows { if row == 0 { // top row let title = self.render_title().with_context(err_context)?; let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new(title, x, y)); } else if row == self.geom.rows - 1 { // bottom row // // The program's own status text wins over every hint we might // put here: those are things the user can discover elsewhere, // this was explicitly requested by what is running in the pane. if self.status_bar.is_some() || !self.status_bar_buttons.is_empty() { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( self.render_status_bar_undertitle() .with_context(err_context)?, x, y, )); } else if self.highlight_tooltip.is_some() && self.is_main_client { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( self.render_highlight_tooltip_undertitle() .with_context(err_context)?, x, y, )); } else if self.show_help_text && self.is_main_client { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( self.render_help_text_undertitle() .with_context(err_context)?, x, y, )); } else if self.mouse_is_hovering_over_pane && !self.is_main_client { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( self.render_mouse_shortcuts_undertitle() .with_context(err_context)?, x, y, )); } else if self.exit_status.is_some() || self.is_first_run { let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new( self.render_held_undertitle().with_context(err_context)?, x, y, )); } else { let mut bottom_row = vec![]; for col in 0..self.geom.cols { let boundary = if col == 0 { // bottom left corner self.get_corner(boundary_type::BOTTOM_LEFT) } else if col == self.geom.cols - 1 { // bottom right corner self.get_corner(boundary_type::BOTTOM_RIGHT) } else { boundary_type::HORIZONTAL }; let mut boundary_character = foreground_color(boundary, self.color); bottom_row.append(&mut boundary_character); } let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new(bottom_row, x, y)); } } else { let boundary_character_left = foreground_color(boundary_type::VERTICAL, self.color); let boundary_character_right = foreground_color(boundary_type::VERTICAL, self.color); let x = self.geom.x; let y = self.geom.y + row; character_chunks.push(CharacterChunk::new(boundary_character_left, x, y)); let x = (self.geom.x + self.geom.cols).saturating_sub(1); let y = self.geom.y + row; character_chunks.push(CharacterChunk::new(boundary_character_right, x, y)); } } } Ok((character_chunks, None)) } fn render_hint_segments(&self, segments: &[HintSegment]) -> (Vec, usize) { let mut characters = vec![]; let mut length = 0; for segment in segments { let color = match segment.level { HintLevel::Plain => self.color, HintLevel::Emphasis => Some(self.style.colors.text_unselected.emphasis_0), HintLevel::Emphasis2 => Some(self.style.colors.text_unselected.emphasis_2), HintLevel::Error => Some(self.style.colors.exit_code_error.base), HintLevel::Success => Some(self.style.colors.exit_code_success.base), }; characters.append(&mut foreground_color(&segment.text, color)); length += segment.text.width(); } (characters, length) } fn first_exited_held_title_part_full(&self) -> (Vec, usize) { match self.exit_status { Some(ExitStatus::Code(exit_code)) => { self.render_hint_segments(&exit_code_segments(HintExitStatus::Code(exit_code))) }, Some(ExitStatus::Exited) => { self.render_hint_segments(&exit_code_segments(HintExitStatus::Exited)) }, None => (foreground_color(boundary_type::HORIZONTAL, self.color), 1), } } fn second_held_title_part_full(&self) -> (Vec, usize) { self.render_hint_segments(&rerun_segments(self.is_first_run, HintTier::Full)) } fn hover_shortcuts_part_full(&self) -> (Vec, usize) { self.render_hint_segments(&hover_segments(HintTier::Full)) } fn empty_undertitle(&self, max_undertitle_length: usize) -> Vec { let mut left_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_LEFT), self.color); let mut right_boundary = foreground_color(self.get_corner(boundary_type::BOTTOM_RIGHT), self.color); let mut ret = vec![]; let mut padding = String::new(); for _ in 0..max_undertitle_length { padding.push_str(boundary_type::HORIZONTAL); } ret.append(&mut left_boundary); ret.append(&mut foreground_color(&padding, self.color)); ret.append(&mut right_boundary); ret } } #[cfg(test)] mod tests { use super::*; use zellij_utils::data::Style; use zellij_utils::pane_size::{Offset, Viewport}; fn pane_frame_with(mouse_scroll_resize: bool, is_floating: bool, cols: usize) -> PaneFrame { PaneFrame::new( Viewport { x: 0, y: 0, cols, rows: 10, }, (0, 0), String::new(), FrameParams { focused_client: None, is_main_client: true, other_focused_clients: vec![], style: Style::default(), color: None, other_cursors_exist_in_session: false, pane_is_stacked_over: false, pane_is_stacked_under: false, pane_is_stacked: false, should_draw_pane_frames: true, pane_is_floating: is_floating, content_offset: Offset::default(), mouse_is_hovering_over_pane: false, pane_is_selectable: true, show_help_text: true, status_bar: None, status_bar_buttons: vec![], hovered_status_bar_button: None, highlight_tooltip: None, omit_title: false, frame_geom_override: None, stack_list_entry: None, blank_title: false, mouse_scroll_resize, dimmed: false, guest_choice_indicator: None, }, ) } fn characters_to_string(chars: &[TerminalCharacter]) -> String { chars.iter().map(|c| c.character).collect() } fn titled_frame(title: &str, alignment: PaneTitleAlignment, cols: usize) -> PaneFrame { let mut frame = pane_frame_with(false, false, cols); frame.title = title.to_string(); frame.style.pane_title_alignment = alignment; frame } /// Column (in CHARACTERS, not bytes) at which the title text starts within /// the rendered title line. The frame is drawn with multi-byte box-drawing /// glyphs, so a byte offset here would be meaningless. fn title_offset(title: &str, alignment: PaneTitleAlignment, cols: usize) -> usize { let frame = titled_frame(title, alignment, cols); let rendered = characters_to_string(&frame.render_title().unwrap()); let byte_idx = rendered .find(title) .unwrap_or_else(|| panic!("title {:?} not found in {:?}", title, rendered)); rendered[..byte_idx].chars().count() } fn ring_cells(outer: Viewport, inner: Viewport) -> Vec<(usize, usize)> { let mut cells = vec![]; for chunk in gap_ring_chunks(outer, inner) { for i in 0..chunk.terminal_characters.len() { cells.push((chunk.x + i, chunk.y)); } } cells } #[test] fn padding_ring_blanks_between_frame_and_content() { // REGRESSION: with `padding 3` the cells between the frame border and // the content were written by nobody, so old titles/borders survived // there when panes were resized. let frame_interior = Viewport { x: 1, y: 1, cols: 20, rows: 10 }; let content = Viewport { x: 4, y: 4, cols: 14, rows: 4 }; let cells = ring_cells(frame_interior, content); let expected = frame_interior.cols * frame_interior.rows - content.cols * content.rows; assert_eq!(cells.len(), expected, "padding ring area wrong"); for &(x, y) in &cells { let in_content = x >= content.x && x < content.x + content.cols && y >= content.y && y < content.y + content.rows; assert!(!in_content, "padding ring overdrew content at ({},{})", x, y); } } #[test] fn ring_never_writes_outside_outer_even_if_inner_escapes() { // defensive: a mis-clamped caller must not make us scribble outside // the pane's own allocation let outer = Viewport { x: 10, y: 10, cols: 8, rows: 6 }; for inner in [ Viewport { x: 0, y: 0, cols: 40, rows: 40 }, // fully covers outer Viewport { x: 30, y: 30, cols: 4, rows: 4 }, // entirely elsewhere Viewport { x: 8, y: 8, cols: 6, rows: 6 }, // straddles the corner Viewport { x: 10, y: 10, cols: 0, rows: 0 }, // degenerate ] { for (x, y) in ring_cells(outer, inner) { assert!( x >= outer.x && x < outer.x + outer.cols && y >= outer.y && y < outer.y + outer.rows, "wrote ({},{}) outside outer for inner {:?}", x, y, inner ); } } } #[test] fn gap_ring_is_empty_without_a_gap() { let v = Viewport { x: 3, y: 4, cols: 20, rows: 10 }; assert!(gap_ring_chunks(v, v).is_empty()); } #[test] fn gap_ring_covers_exactly_the_difference() { // every cell of the allocation must be written by either the frame or // the ring - a hole is precisely what shows up as ghosting on resize for gap in 1..4usize { let outer = Viewport { x: 5, y: 2, cols: 30, rows: 14 }; let inner = outer.inset(gap); let cells = ring_cells(outer, inner); let mut sorted = cells.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(sorted.len(), cells.len(), "gap {}: ring overlaps itself", gap); let expected = outer.cols * outer.rows - inner.cols * inner.rows; assert_eq!(cells.len(), expected, "gap {}: ring area wrong", gap); for &(x, y) in &cells { assert!( x >= outer.x && x < outer.x + outer.cols && y >= outer.y && y < outer.y + outer.rows, "gap {}: ring cell ({},{}) escaped the allocation", gap, x, y ); let inside_frame = x >= inner.x && x < inner.x + inner.cols && y >= inner.y && y < inner.y + inner.rows; assert!(!inside_frame, "gap {}: ring cell ({},{}) overdraws the frame", gap, x, y); } } } #[test] fn gap_ring_plus_frame_tile_the_allocation_completely() { let outer = Viewport { x: 0, y: 0, cols: 12, rows: 8 }; let inner = outer.inset(1); let mut covered = std::collections::HashSet::new(); for c in ring_cells(outer, inner) { covered.insert(c); } for y in inner.y..inner.y + inner.rows { for x in inner.x..inner.x + inner.cols { covered.insert((x, y)); } } assert_eq!( covered.len(), outer.cols * outer.rows, "ring + frame must tile the whole allocation with no holes" ); } #[test] fn title_alignment_orders_left_center_right() { let (title, cols) = ("mytitle", 80); let left = title_offset(title, PaneTitleAlignment::Left, cols); let center = title_offset(title, PaneTitleAlignment::Center, cols); let right = title_offset(title, PaneTitleAlignment::Right, cols); assert!( left < center && center < right, "expected left({}) < center({}) < right({})", left, center, right ); // left stays flush against the corner, as it always has: // one corner glyph plus the leading space of " {title} " assert_eq!(left, 2, "left-aligned title should sit just after the corner"); // center is within a cell of true centre let ideal = (cols - title.len()) / 2; assert!( center.abs_diff(ideal) <= 1, "center({}) should be within 1 of ideal({})", center, ideal ); // right ends flush against the closing corner assert!( right + title.len() >= cols - 3, "right({}) + len should reach the right corner (cols {})", right, cols ); } #[test] fn title_alignment_preserves_frame_width() { // the whole point of the geometry loop: every alignment must emit // exactly `cols` characters, or the frame corrupts the grid. for cols in [20, 41, 80, 120] { for alignment in [ PaneTitleAlignment::Left, PaneTitleAlignment::Center, PaneTitleAlignment::Right, ] { let frame = titled_frame("mytitle", alignment, cols); let rendered = characters_to_string(&frame.render_title().unwrap()); assert_eq!( rendered.chars().count(), cols, "alignment {:?} at cols {} produced {:?}", alignment, cols, rendered ); } } } #[test] fn title_alignment_survives_narrow_panes() { // must not panic or spin: the geometry loop clamps rather than trusting // that the title fits. for cols in 1..24 { for alignment in [PaneTitleAlignment::Center, PaneTitleAlignment::Right] { let frame = titled_frame("a-rather-long-pane-title", alignment, cols); let rendered = characters_to_string(&frame.render_title().unwrap()); assert_eq!(rendered.chars().count(), cols, "cols {}", cols); } } } #[test] fn help_text_full_includes_ctrl_scroll_when_enabled_for_tiled_pane() { let frame = pane_frame_with(true, false, 80); let (chars, _) = frame.help_text_version_full(80).unwrap(); let text = characters_to_string(&chars); assert!(text.contains("Ctrl ")); assert!(text.contains("")); } #[test] fn help_text_full_omits_ctrl_scroll_when_disabled_for_tiled_pane() { let frame = pane_frame_with(false, false, 80); let (chars, _) = frame.help_text_version_full(80).unwrap(); let text = characters_to_string(&chars); assert!(!text.contains("MouseScroll")); assert!(text.contains(" to resize")); } #[test] fn help_text_full_includes_ctrl_scroll_when_enabled_for_floating_pane() { let frame = pane_frame_with(true, true, 80); let (chars, _) = frame.help_text_version_full(80).unwrap(); let text = characters_to_string(&chars); assert!(text.contains("Ctrl ")); assert!(text.contains("Ctrl ")); } #[test] fn help_text_full_omits_ctrl_scroll_when_disabled_for_floating_pane() { let frame = pane_frame_with(false, true, 80); let (chars, _) = frame.help_text_version_full(80).unwrap(); let text = characters_to_string(&chars); assert!(!text.contains("MouseScroll")); assert!(text.contains("Ctrl to resize")); } #[test] fn help_text_medium_omits_ctrl_scroll_when_disabled() { let frame = pane_frame_with(false, false, 40); let (chars, _) = frame.help_text_version_medium(40).unwrap(); let text = characters_to_string(&chars); assert!(!text.contains("MouseScroll")); assert!(text.contains(" resize")); } #[test] fn help_text_short_omits_ctrl_scroll_when_disabled() { let frame = pane_frame_with(false, false, 20); let (chars, _) = frame.help_text_version_short(20).unwrap(); let text = characters_to_string(&chars); assert!(!text.contains("MouseScroll")); assert!(text.contains("")); } }