use std::{collections::BTreeMap, str::FromStr, sync::Arc}; use itertools::Itertools; use regex::Regex; use zellij_tile::prelude::*; use crate::{ border::{parse_border_config, BorderConfig, BorderPosition}, render::FormattedPart, widgets::{command::CommandResult, notification, widget::Widget}, }; use chrono::{DateTime, Local}; #[derive(Default, Debug, Clone)] pub struct ZellijState { pub cols: usize, pub command_results: BTreeMap, pub pipe_results: BTreeMap, pub mode: ModeInfo, pub panes: PaneManifest, pub plugin_uuid: String, pub tabs: Vec, pub sessions: Vec, pub start_time: DateTime, pub incoming_notification: Option, pub cache_mask: u8, pub focused_pane_id: Option, pub focused_pane_cwd: Option, } #[derive(Clone, Debug, Ord, Eq, PartialEq, PartialOrd, Copy)] pub enum Part { Left, Center, Right, } impl FromStr for Part { fn from_str(part: &str) -> Result { match part { "l" => Ok(Part::Left), "c" => Ok(Part::Center), "r" => Ok(Part::Right), _ => anyhow::bail!("Invalid part: {}", part), } } type Err = anyhow::Error; } pub enum UpdateEventMask { Always = 0b10000000, Mode = 0b00000001, Tab = 0b00000011, Command = 0b00000100, Session = 0b00001000, None = 0b00000000, } pub fn event_mask_from_widget_name(name: &str) -> u8 { match name { "command" => UpdateEventMask::Always as u8, "datetime" => UpdateEventMask::Always as u8, "mode" => UpdateEventMask::Mode as u8, "notifications" => UpdateEventMask::Always as u8, "session" => UpdateEventMask::Mode as u8, "swap_layout" => UpdateEventMask::Tab as u8, "tabs" => UpdateEventMask::Tab as u8, "pipe" => UpdateEventMask::Always as u8, _ => UpdateEventMask::None as u8, } } #[derive(Default, Debug)] pub struct ModuleConfig { /// Position and time of the last LEFT CLICK, for double-click detection. /// /// Only Mouse::LeftClick updates this. Mouse::Release must NOT, even though /// handle_mouse_action also runs for it: a single click arrives as /// LeftClick followed by Release at the same position, so counting releases /// would make every single click look like a double click. Release is also /// not allowed to RESET it, because it legitimately falls between the two /// clicks of a real double click. pub last_left_click: Option<(usize, chrono::DateTime)>, pub left_parts_config: String, pub left_parts: Vec, pub center_parts_config: String, pub center_parts: Vec, pub right_parts_config: String, pub right_parts: Vec, pub format_space: FormattedPart, pub hide_frame_for_single_pane: bool, pub hide_frame_except_for_search: bool, pub hide_frame_except_for_fullscreen: bool, pub hide_frame_except_for_scroll: bool, pub border: BorderConfig, pub format_precedence: Vec, pub hide_on_overlength: bool, } /// 400ms, the same threshold zellij uses for text-selection double clicks, so /// the bar feels consistent with the panes. pub const DOUBLE_CLICK_MS: i64 = 400; /// Record a mouse event and report whether it completes a double click. /// /// Only Mouse::LeftClick participates. handle_mouse_action also runs for /// Mouse::Release, and a single click arrives as LeftClick then Release at the /// same position - counting releases would make every single click look like a /// double click. Release must not RESET the tracker either, since it falls /// between the two clicks of a real double click. pub fn register_click_for_double_click( last_left_click: &mut Option<(usize, chrono::DateTime)>, mouse: &Mouse, click_pos: usize, now: chrono::DateTime, ) -> bool { if !matches!(mouse, Mouse::LeftClick(..)) { return false; } let is_double = last_left_click .map(|(prev_pos, prev_time)| { prev_pos == click_pos && (now - prev_time).num_milliseconds() <= DOUBLE_CLICK_MS }) .unwrap_or(false); // consume the pair, so a third click starts a new sequence rather than // counting as another double *last_left_click = if is_double { None } else { Some((click_pos, now)) }; is_double } impl ModuleConfig { pub fn new(config: &BTreeMap) -> anyhow::Result { let format_space_config = match config.get("format_space") { Some(space_config) => space_config, None => "", }; let hide_frame_for_single_pane = match config.get("hide_frame_for_single_pane") { Some(toggle) => toggle == "true", None => false, }; let hide_frame_except_for_search = match config.get("hide_frame_except_for_search") { Some(toggle) => toggle == "true", None => false, }; let hide_frame_except_for_fullscreen = match config.get("hide_frame_except_for_fullscreen") { Some(toggle) => toggle == "true", None => false, }; let hide_frame_except_for_scroll = match config.get("hide_frame_except_for_scroll") { Some(toggle) => toggle == "true", None => false, }; let left_parts_config = match config.get("format_left") { Some(conf) => conf, None => "", }; let right_parts_config = match config.get("format_right") { Some(conf) => conf, None => "", }; let center_parts_config = match config.get("format_center") { Some(conf) => conf, None => "", }; let format_precedence = match config.get("format_precedence") { Some(conf) => { let prec = conf .chars() .map(|c| Part::from_str(&c.to_string())) .collect(); match prec { Ok(prec) => prec, Err(e) => { anyhow::bail!("Invalid format_precedence: {}", e); } } } None => vec![Part::Left, Part::Center, Part::Right], }; let hide_on_overlength = match config.get("format_hide_on_overlength") { Some(opt) => opt == "true", None => false, }; let border_config = parse_border_config(config).unwrap_or_default(); Ok(Self { left_parts_config: left_parts_config.to_owned(), left_parts: parts_from_config(Some(&left_parts_config.to_owned()), config), center_parts_config: center_parts_config.to_owned(), center_parts: parts_from_config(Some(¢er_parts_config.to_owned()), config), right_parts_config: right_parts_config.to_owned(), right_parts: parts_from_config(Some(&right_parts_config.to_owned()), config), format_space: FormattedPart::from_format_string(format_space_config, config), hide_frame_for_single_pane, hide_frame_except_for_search, hide_frame_except_for_fullscreen, hide_frame_except_for_scroll, border: border_config, format_precedence, hide_on_overlength, last_left_click: None, }) } pub fn handle_mouse_action( &mut self, state: ZellijState, mouse: Mouse, widget_map: BTreeMap>, ) { let click_pos = match mouse { Mouse::ScrollUp(_) => return, Mouse::ScrollDown(_) => return, Mouse::LeftClick(_, y) => y, Mouse::RightClick(_, y) => y, Mouse::Hold(_, y) => y, Mouse::Release(_, y) => y, Mouse::Hover(_, _) => return, }; let is_double_click = register_click_for_double_click( &mut self.last_left_click, &mouse, click_pos, chrono::Local::now(), ); let output_left = self.left_parts.iter_mut().fold("".to_owned(), |acc, part| { format!( "{}{}", acc, part.format_string_with_widgets(&widget_map, &state) ) }); let output_center = self .center_parts .iter_mut() .fold("".to_owned(), |acc, part| { format!( "{}{}", acc, part.format_string_with_widgets(&widget_map, &state) ) }); let output_right = self .right_parts .iter_mut() .fold("".to_owned(), |acc, part| { format!( "{}{}", acc, part.format_string_with_widgets(&widget_map, &state) ) }); let (output_left, output_center, output_right) = match self.hide_on_overlength { true => self.trim_output(&output_left, &output_center, &output_right, state.cols), false => (output_left, output_center, output_right), }; let mut offset = console::measure_text_width(&output_left); self.process_widget_click(click_pos, &self.left_parts, &widget_map, &state, 0, is_double_click); if click_pos <= offset { return; } if !output_center.is_empty() { tracing::debug!("widgetclick center"); offset += console::measure_text_width(&self.get_spacer_left( &output_left, &output_center, state.cols, )); offset += self.process_widget_click( click_pos, &self.center_parts, &widget_map, &state, offset, is_double_click, ); if click_pos <= offset { return; } offset += console::measure_text_width(&self.get_spacer_right( &output_right, &output_center, state.cols, )); } else { offset += console::measure_text_width(&self.get_spacer( &output_left, &output_right, state.cols, )); } self.process_widget_click( click_pos, &self.right_parts, &widget_map, &state, offset, is_double_click, ); } fn process_widget_click( &self, click_pos: usize, widgets: &[FormattedPart], widget_map: &BTreeMap>, state: &ZellijState, offset: usize, is_double_click: bool, ) -> usize { let widget_string = widgets.iter().fold(String::new(), |a, b| a + &b.content); let mut rendered_output = widget_string.clone(); let tokens: Vec = widget_map.keys().map(|k| k.to_owned()).collect(); let widgets_regex = Regex::new("(\\{[a-z_0-9]+\\})").unwrap(); for widget in widgets_regex.captures_iter(widget_string.as_str()) { let match_name = widget.get(0).unwrap().as_str(); let widget_key = match_name.trim_matches(|c| c == '{' || c == '}'); let mut widget_key_name = widget_key; if widget_key.starts_with("command_") { widget_key_name = "command"; } if widget_key.starts_with("pipe_") { widget_key_name = "pipe"; } if !tokens.contains(&widget_key_name.to_owned()) { continue; } let wid = match widget_map.get(widget_key_name) { Some(wid) => wid, None => continue, }; let pos = match rendered_output.find(match_name) { Some(_pos) => { let pref = rendered_output.split(match_name).collect::>()[0]; console::measure_text_width(pref) } None => continue, }; let wid_res = wid.process(widget_key, state); rendered_output = rendered_output.replace(match_name, &wid_res); if click_pos < pos + offset || click_pos > pos + offset + console::measure_text_width(&wid_res) { continue; } wid.process_click(widget_key, state, click_pos - (pos + offset), is_double_click); } console::measure_text_width(&rendered_output) } pub fn render_bar( &mut self, state: ZellijState, widget_map: BTreeMap>, ) -> String { if self.left_parts.is_empty() && self.center_parts.is_empty() && self.right_parts.is_empty() { return "No configuration found. See https://github.com/dj95/zjstatus/wiki/3-%E2%80%90-Configuration for more info".to_string(); } let output_left = self.left_parts.iter_mut().fold("".to_owned(), |acc, part| { format!( "{acc}{}", part.format_string_with_widgets(&widget_map, &state) ) }); let output_center = self .center_parts .iter_mut() .fold("".to_owned(), |acc, part| { format!( "{acc}{}", part.format_string_with_widgets(&widget_map, &state) ) }); let output_right = self .right_parts .iter_mut() .fold("".to_owned(), |acc, part| { format!( "{acc}{}", part.format_string_with_widgets(&widget_map, &state) ) }); let (output_left, output_center, output_right) = match self.hide_on_overlength { true => self.trim_output(&output_left, &output_center, &output_right, state.cols), false => (output_left, output_center, output_right), }; if self.border.enabled { let mut border_top = "".to_owned(); if self.border.enabled && self.border.position == BorderPosition::Top { border_top = format!("{}\n", self.border.draw(state.cols)); } let mut border_bottom = "".to_owned(); if self.border.enabled && self.border.position == BorderPosition::Bottom { border_bottom = format!("\n{}", self.border.draw(state.cols)); } if !output_center.is_empty() { return format!( "{}{}{}{}{}{}{}", border_top, output_left, self.get_spacer_left(&output_left, &output_center, state.cols), output_center, self.get_spacer_right(&output_right, &output_center, state.cols), output_right, border_bottom, ); } return format!( "{}{}{}{}{}", border_top, output_left, self.get_spacer(&output_left, &output_right, state.cols), output_right, border_bottom, ); } if !output_center.is_empty() { return format!( "{}{}{}{}{}", output_left, self.get_spacer_left(&output_left, &output_center, state.cols), output_center, self.get_spacer_right(&output_right, &output_center, state.cols), output_right, ); } format!( "{}{}{}", output_left, self.get_spacer(&output_left, &output_right, state.cols), output_right, ) } fn trim_output( &self, output_left: &str, output_center: &str, output_right: &str, cols: usize, ) -> (String, String, String) { let center_pos = (cols as f32 / 2.0).floor() as usize; let mut output = BTreeMap::from([ (Part::Left, output_left.to_owned()), (Part::Center, output_center.to_owned()), (Part::Right, output_right.to_owned()), ]); let combinations = [ (self.format_precedence[2], self.format_precedence[1]), (self.format_precedence[1], self.format_precedence[0]), (self.format_precedence[2], self.format_precedence[0]), ]; for win in combinations.iter() { let (a, b) = win; let part_a = output.get(a).unwrap(); let part_b = output.get(b).unwrap(); let a_count = console::measure_text_width(part_a); let b_count = console::measure_text_width(part_b); let overlap = match (a, b) { (Part::Left, Part::Right) => a_count + b_count > cols, (Part::Right, Part::Left) => a_count + b_count > cols, (Part::Left, Part::Center) => a_count > center_pos - (b_count / 2), (Part::Center, Part::Left) => b_count > center_pos - (a_count / 2), (Part::Right, Part::Center) => a_count > center_pos - (b_count / 2), (Part::Center, Part::Right) => b_count > center_pos - (a_count / 2), _ => false, }; if overlap { output.insert(*a, "".to_owned()); } } output.values().cloned().collect_tuple().unwrap() } #[tracing::instrument(skip_all)] fn get_spacer_left(&self, output_left: &str, output_center: &str, cols: usize) -> String { let text_count = console::measure_text_width(output_left) + (console::measure_text_width(output_center) as f32 / 2.0).floor() as usize; let center_pos = (cols as f32 / 2.0).floor() as usize; // verify we are able to count the difference, since zellij sometimes drops a col // count of 0 on tab creation let space_count = center_pos.saturating_sub(text_count); tracing::debug!("space_count: {:?}", space_count); self.format_space.format_string(&" ".repeat(space_count)) } #[tracing::instrument(skip_all)] fn get_spacer_right(&self, output_right: &str, output_center: &str, cols: usize) -> String { let text_count = console::measure_text_width(output_right) + (console::measure_text_width(output_center) as f32 / 2.0).ceil() as usize; let center_pos = (cols as f32 / 2.0).ceil() as usize; // verify we are able to count the difference, since zellij sometimes drops a col // count of 0 on tab creation let space_count = center_pos.saturating_sub(text_count); tracing::debug!("space_count: {:?}", space_count); self.format_space.format_string(&" ".repeat(space_count)) } fn get_spacer(&self, output_left: &str, output_right: &str, cols: usize) -> String { let text_count = console::measure_text_width(output_left) + console::measure_text_width(output_right); // verify we are able to count the difference, since zellij sometimes drops a col // count of 0 on tab creation let space_count = cols.saturating_sub(text_count); self.format_space.format_string(&" ".repeat(space_count)) } } fn parts_from_config( format: Option<&String>, config: &BTreeMap, ) -> Vec { match format { Some(format) => match format.is_empty() { true => vec![], false => format .split("#[") .map(|s| FormattedPart::from_format_string(s, config)) .collect(), }, None => vec![], } } #[cfg(test)] mod test { use super::*; use anstyle::{Effects, RgbColor}; #[test] fn test_formatted_part_from_string() { let input = "#[fg=#ff0000,bg=#00ff00,bold,italic]foo"; let part = FormattedPart::from_format_string(input, &BTreeMap::new()); assert_eq!( part, FormattedPart { fg: Some(RgbColor(255, 0, 0).into()), bg: Some(RgbColor(0, 255, 0).into()), effects: Effects::BOLD | Effects::ITALIC, content: "foo".to_owned(), ..Default::default() }, ) } } #[cfg(test)] mod sl0p_double_click_tests { use super::*; use chrono::Duration; fn t0() -> chrono::DateTime { chrono::Local::now() } #[test] fn two_quick_left_clicks_at_the_same_spot_are_a_double_click() { let mut last = None; let start = t0(); assert!(!register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start )); assert!(register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start + Duration::milliseconds(120) )); } #[test] fn the_release_between_two_clicks_is_ignored() { // REGRESSION GUARD: handle_mouse_action runs for Release too. If Release // were counted, a single click (LeftClick + Release) would register as a // double click; if it reset the tracker, a real double click never would. let mut last = None; let start = t0(); register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); assert!( !register_click_for_double_click( &mut last, &Mouse::Release(0, 12), 12, start + Duration::milliseconds(10) ), "a release must never count as a click" ); assert!( register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start + Duration::milliseconds(120) ), "the release must not have reset the sequence" ); } #[test] fn slow_clicks_are_not_a_double_click() { let mut last = None; let start = t0(); register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); assert!(!register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start + Duration::milliseconds(DOUBLE_CLICK_MS + 1) )); } #[test] fn clicks_at_different_positions_are_not_a_double_click() { let mut last = None; let start = t0(); register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); assert!(!register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 40), 40, start + Duration::milliseconds(50) )); } #[test] fn a_triple_click_does_not_fire_a_second_rename() { let mut last = None; let start = t0(); register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); assert!(register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start + Duration::milliseconds(100) )); assert!( !register_click_for_double_click( &mut last, &Mouse::LeftClick(0, 12), 12, start + Duration::milliseconds(200) ), "the pair is consumed, so a third click starts over" ); } }