use std::{collections::BTreeMap, str::FromStr, sync::Arc}; use itertools::Itertools; use regex::Regex; use zellij_tile::prelude::*; use crate::{ bar_frame::{parse_bar_frame_config, BarFrameConfig, FRAMED_ROWS}, 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, /// sl0p fork: the pane's height, which decides whether the bar is drawn as /// a framed box or as a single line. See `ModuleConfig::viewport`. pub rows: 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, /// sl0p fork: blank columns kept clear at each end of the bar. /// /// The bar is a full-width pane, but the panes above it are inset by the /// configured pane gap, so a bar that starts in column 0 does not line up /// with anything -- its ends stick out past every frame on screen. These /// pull its content in to match. /// /// Set them to the horizontal gap, which in sl0ppi's config is /// `gap` x `gap_aspect` (1 x 2 = 2 columns). They are left UNSTYLED on /// purpose: the space between panes is terminal background, and the bar's /// margins are the same space. pub margin_left: usize, pub margin_right: usize, /// sl0p fork: draw the bar as a box in the pane style when it has the rows. pub bar_frame: BarFrameConfig, } /// sl0p fork: where the bar's content sits inside its pane. /// /// Rendering and click routing both come from here, so the frame and the /// margins can only ever move the drawing and the hit-testing together. Get /// this wrong in one place only and you get a button you can see and not press, /// which this project has now paid for once. #[derive(Debug, PartialEq, Clone, Copy)] pub struct BarViewport { /// row of the pane the content line is drawn on (1 inside a box, else 0) pub line: usize, /// first column of the content line pub col: usize, /// how many columns the content gets pub cols: usize, /// whether the box is being drawn at all pub framed: 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(); let margin = |key: &str| -> usize { config .get(key) .and_then(|value| value.parse::().ok()) .unwrap_or(0) }; Ok(Self { margin_left: margin("margin_left"), margin_right: margin("margin_right"), bar_frame: parse_bar_frame_config(config), 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, }) } /// Where the content line goes, given the pane we were handed. /// /// The box is drawn only when there are rows for it: the layout decides the /// bar's height (three rows normally, one in the collapsed "mobile" /// layout), and this adapts. A framed bar loses two more columns to its /// vertical borders, which is why the click math cannot just subtract the /// margins. pub fn viewport(&self, rows: usize, cols: usize) -> BarViewport { let framed = self.bar_frame.enabled && rows >= FRAMED_ROWS; let margins = self.margin_left + self.margin_right; match framed { true => BarViewport { line: 1, col: self.margin_left + 1, cols: cols.saturating_sub(margins + 2), framed: true, }, false => BarViewport { line: 0, col: self.margin_left, cols: cols.saturating_sub(margins), framed: false, }, } } /// The bar's own viewport as the widgets see it: the columns they get to /// lay out in, whatever chrome is around them. fn inset_state(&self, mut state: ZellijState, viewport: &BarViewport) -> ZellijState { state.cols = viewport.cols; state } pub fn handle_mouse_action( &mut self, state: ZellijState, mouse: Mouse, widget_map: BTreeMap>, ) { let (click_line, click_pos) = match mouse { Mouse::ScrollUp(_) => return, Mouse::ScrollDown(_) => return, Mouse::LeftClick(x, y) => (x, y), Mouse::RightClick(x, y) => (x, y), Mouse::Hold(x, y) => (x, y), Mouse::Release(x, y) => (x as isize, y), Mouse::Hover(_, _) => return, }; // Coordinates are relative to the plugin's own pane, so a framed bar // puts the widgets on line 1 with borders above and below. A click on a // border is a click on the frame, not on whatever widget happens to be // below it. let viewport = self.viewport(state.rows, state.cols); if click_line != viewport.line as isize { return; } // likewise the margins and the vertical borders: they are not part of // any widget, and must not be counted as a click on the first one if click_pos < viewport.col { return; } let click_pos = click_pos - viewport.col; let state = self.inset_state(state, &viewport); if click_pos >= state.cols { 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 { let viewport = self.viewport(state.rows, state.cols); let mut bar = self.render_bar_content(self.inset_state(state, &viewport), widget_map); if viewport.framed { // pad the content to the full inner width first: the box's right // border has to land in a fixed column whatever the widgets did, // and a short line would otherwise pull it left let width = console::measure_text_width(&bar); if width < viewport.cols { bar.push_str(&" ".repeat(viewport.cols - width)); } bar = self.bar_frame.wrap(&bar, viewport.cols); } if self.margin_left == 0 && self.margin_right == 0 { return bar; } // Every line is inset, not just the first: the box is three of them, // and a border that ran the full width under a margined bar would look // like a mistake. bar.split('\n') .map(|line| { format!( "{}{}{}", " ".repeat(self.margin_left), line, " ".repeat(self.margin_right) ) }) .collect::>() .join("\n") } fn render_bar_content( &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) )); } /// A widget that renders a fixed label and records where it was clicked, /// so a test can ask "which column of the SCREEN reaches column 0 of this /// widget". #[derive(Default)] struct SpyWidget { label: String, clicks: std::sync::Mutex>, } impl Widget for SpyWidget { fn process(&self, _name: &str, _state: &ZellijState) -> String { self.label.clone() } fn process_click(&self, _n: &str, _s: &ZellijState, pos: usize, _d: bool) { self.clicks.lock().unwrap().push(pos); } } fn bar_with_margins(left: usize, right: usize) -> (ModuleConfig, Arc) { let mut config = BTreeMap::new(); config.insert("format_left".to_owned(), "{spy}".to_owned()); config.insert("margin_left".to_owned(), left.to_string()); config.insert("margin_right".to_owned(), right.to_string()); let module_config = ModuleConfig::new(&config).unwrap(); let spy = Arc::new(SpyWidget { label: "[button]".to_owned(), clicks: Default::default(), }); (module_config, spy) } fn spy_map(spy: Arc) -> BTreeMap> { let mut widget_map = BTreeMap::>::new(); widget_map.insert("spy".to_owned(), spy); widget_map } fn state_with_cols(cols: usize) -> ZellijState { ZellijState { cols, ..Default::default() } } /// Column (in CHARACTERS, not bytes) where `needle` is drawn. /// /// The box glyphs are three bytes each, so a byte offset here reads as a /// click two columns to the right of the thing it found. fn drawn_column(haystack: &str, needle: &str) -> usize { let byte = haystack .find(needle) .unwrap_or_else(|| panic!("{needle:?} not drawn in {haystack:?}")); haystack[..byte].chars().count() } fn state_of(rows: usize, cols: usize) -> ZellijState { ZellijState { rows, cols, ..Default::default() } } fn framed_bar() -> (ModuleConfig, Arc) { let mut config = BTreeMap::new(); config.insert("format_left".to_owned(), "{spy}".to_owned()); config.insert("margin_left".to_owned(), "2".to_owned()); config.insert("margin_right".to_owned(), "2".to_owned()); config.insert("bar_frame_enabled".to_owned(), "true".to_owned()); let module_config = ModuleConfig::new(&config).unwrap(); let spy = Arc::new(SpyWidget { label: "[button]".to_owned(), clicks: Default::default(), }); (module_config, spy) } #[test] fn three_rows_get_a_box_and_one_row_stays_a_line() { let (config, _) = framed_bar(); let framed = config.viewport(3, 40); assert!(framed.framed); assert_eq!(framed.line, 1, "content sits between the borders"); assert_eq!(framed.col, 3, "margin plus the left border"); assert_eq!(framed.cols, 34, "40 less two margins and two borders"); // the collapsed layout gives the bar a single row: no box, and the // content gets the columns the borders would have taken let mobile = config.viewport(1, 40); assert!(!mobile.framed); assert_eq!(mobile.line, 0); assert_eq!(mobile.col, 2); assert_eq!(mobile.cols, 36); } #[test] fn the_box_fills_its_pane_exactly() { let (mut config, spy) = framed_bar(); let rendered = config.render_bar(state_of(3, 40), spy_map(spy)); let lines: Vec<&str> = rendered.split('\n').collect(); assert_eq!(lines.len(), 3, "a framed bar is three lines: {rendered:?}"); for line in &lines { assert_eq!( console::measure_text_width(line), 40, "line {line:?} does not fill the pane" ); } assert!(lines[0].starts_with(" ╭"), "{:?}", lines[0]); assert!(lines[0].ends_with("╮ "), "{:?}", lines[0]); assert!(lines[2].starts_with(" ╰"), "{:?}", lines[2]); assert!(lines[2].ends_with("╯ "), "{:?}", lines[2]); } #[test] fn a_framed_widget_is_clicked_where_it_is_drawn() { let (mut config, spy) = framed_bar(); let widget_map = spy_map(spy.clone()); let rendered = config.render_bar(state_of(3, 40), widget_map.clone()); let content_line = rendered.split('\n').nth(1).expect("no content line"); let drawn_at = drawn_column(content_line, "[button]"); config.handle_mouse_action(state_of(3, 40), Mouse::LeftClick(1, drawn_at), widget_map); assert_eq!( *spy.clicks.lock().unwrap(), vec![0], "the button's first drawn column should be its column 0" ); } #[test] fn clicks_on_the_frame_hit_nothing() { let (mut config, spy) = framed_bar(); let widget_map = spy_map(spy.clone()); let rendered = config.render_bar(state_of(3, 40), widget_map.clone()); let content_line = rendered.split('\n').nth(1).expect("no content line"); let drawn_at = drawn_column(content_line, "[button]"); // the border rows, at the column the button occupies on the row between for line in [0, 2] { config.handle_mouse_action( state_of(3, 40), Mouse::LeftClick(line, drawn_at), widget_map.clone(), ); } // and the vertical borders themselves for column in [2, 3 + 34] { config.handle_mouse_action( state_of(3, 40), Mouse::LeftClick(1, column), widget_map.clone(), ); } assert!( spy.clicks.lock().unwrap().is_empty(), "the frame is not part of any widget: {:?}", spy.clicks.lock().unwrap() ); } #[test] fn a_one_row_bar_ignores_the_frame_setting() { // the responsive layout hands the bar a single row: it must not try to // draw a box into it let (mut config, spy) = framed_bar(); let widget_map = spy_map(spy.clone()); let rendered = config.render_bar(state_of(1, 40), widget_map.clone()); assert!(!rendered.contains('\n'), "{rendered:?}"); assert!(rendered.starts_with(" [button]"), "{rendered:?}"); config.handle_mouse_action(state_of(1, 40), Mouse::LeftClick(0, 2), widget_map); assert_eq!(*spy.clicks.lock().unwrap(), vec![0]); } #[test] fn margins_inset_the_bar_at_both_ends() { let (mut config, spy) = bar_with_margins(2, 2); let rendered = config.render_bar(state_with_cols(40), spy_map(spy)); assert!( rendered.starts_with(" [button]"), "left margin missing: {rendered:?}" ); assert!( rendered.ends_with(" "), "right margin missing: {rendered:?}" ); assert_eq!( console::measure_text_width(&rendered), 40, "the bar must still fill its pane: {rendered:?}" ); } #[test] fn a_margined_widget_is_clicked_where_it_is_drawn() { // the whole point of routing both through inset_state: the button moves // right by the margin, so the columns that activate it must move too let (mut config, spy) = bar_with_margins(2, 2); let widget_map = spy_map(spy.clone()); let rendered = config.render_bar(state_with_cols(40), widget_map.clone()); let drawn_at = rendered.find("[button]").expect("button not drawn"); config.handle_mouse_action( state_with_cols(40), Mouse::LeftClick(0, drawn_at), widget_map, ); assert_eq!( *spy.clicks.lock().unwrap(), vec![0], "clicking the button's first drawn column should hit its column 0" ); } #[test] fn clicks_in_the_margin_hit_nothing() { let (mut config, spy) = bar_with_margins(2, 2); let widget_map = spy_map(spy.clone()); for column in [0, 1] { config.handle_mouse_action( state_with_cols(40), Mouse::LeftClick(0, column), widget_map.clone(), ); } assert!( spy.clicks.lock().unwrap().is_empty(), "the margin is not part of any widget: {:?}", spy.clicks.lock().unwrap() ); } #[test] fn without_margins_nothing_changes() { // upstream behaviour has to be untouched when the option is unset let (mut config, spy) = bar_with_margins(0, 0); let widget_map = spy_map(spy.clone()); let rendered = config.render_bar(state_with_cols(40), widget_map.clone()); assert!(rendered.starts_with("[button]"), "{rendered:?}"); config.handle_mouse_action(state_with_cols(40), Mouse::LeftClick(0, 0), widget_map); assert_eq!(*spy.clicks.lock().unwrap(), vec![0]); } #[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" ); } }