use std::{cmp, collections::BTreeMap}; use zellij_tile::{ prelude::{InputMode, ModeInfo, PaneInfo, PaneManifest, TabInfo}, shim::switch_tab_to, }; use crate::{config::ZellijState, render::FormattedPart}; use super::widget::Widget; pub struct TabsWidget { active_tab_format: Vec, active_tab_fullscreen_format: Vec, active_tab_sync_format: Vec, normal_tab_format: Vec, normal_tab_fullscreen_format: Vec, normal_tab_sync_format: Vec, normal_tab_bell_format: Option>, normal_tab_flashing_bell_format: Option>, rename_tab_format: Vec, separator: Option, fullscreen_indicator: Option, floating_indicator: Option, sync_indicator: Option, bell_indicator: Option, flashing_bell_indicator: Option, tab_display_count: Option, tab_truncate_start_format: Vec, tab_truncate_end_format: Vec, tab_zero_based_index: bool, } impl TabsWidget { pub fn new(config: &BTreeMap) -> Self { let mut normal_tab_format: Vec = Vec::new(); if let Some(form) = config.get("tab_normal") { normal_tab_format = FormattedPart::multiple_from_format_string(form, config); } let normal_tab_fullscreen_format = match config.get("tab_normal_fullscreen") { Some(form) => FormattedPart::multiple_from_format_string(form, config), None => normal_tab_format.clone(), }; let normal_tab_sync_format = match config.get("tab_normal_sync") { Some(form) => FormattedPart::multiple_from_format_string(form, config), None => normal_tab_format.clone(), }; let normal_tab_bell_format = config .get("tab_normal_bell") .map(|form| FormattedPart::multiple_from_format_string(form, config)); let normal_tab_flashing_bell_format = config .get("tab_normal_flashing_bell") .map(|form| FormattedPart::multiple_from_format_string(form, config)); let mut active_tab_format = normal_tab_format.clone(); if let Some(form) = config.get("tab_active") { active_tab_format = FormattedPart::multiple_from_format_string(form, config); } let active_tab_fullscreen_format = match config.get("tab_active_fullscreen") { Some(form) => FormattedPart::multiple_from_format_string(form, config), None => active_tab_format.clone(), }; let active_tab_sync_format = match config.get("tab_active_sync") { Some(form) => FormattedPart::multiple_from_format_string(form, config), None => active_tab_format.clone(), }; let rename_tab_format = match config.get("tab_rename") { Some(form) => FormattedPart::multiple_from_format_string(form, config), None => active_tab_format.clone(), }; let tab_display_count = match config.get("tab_display_count") { Some(count) => count.parse::().ok(), None => None, }; let tab_truncate_start_format = config .get("tab_truncate_start_format") .map(|form| FormattedPart::multiple_from_format_string(form, config)) .unwrap_or_default(); let tab_truncate_end_format = config .get("tab_truncate_end_format") .map(|form| FormattedPart::multiple_from_format_string(form, config)) .unwrap_or_default(); let tab_zero_based_index = match config.get("tab_zero_based_index") { Some(e) => matches!(e.as_str(), "true"), None => false, }; let separator = config .get("tab_separator") .map(|s| FormattedPart::from_format_string(s, config)); let bell_indicator = config.get("tab_bell_indicator").cloned(); let flashing_bell_indicator = config .get("tab_flashing_bell_indicator") .cloned() .or_else(|| bell_indicator.clone()); Self { normal_tab_format, normal_tab_fullscreen_format, normal_tab_sync_format, normal_tab_bell_format, normal_tab_flashing_bell_format, active_tab_format, active_tab_fullscreen_format, active_tab_sync_format, rename_tab_format, separator, floating_indicator: config.get("tab_floating_indicator").cloned(), sync_indicator: config.get("tab_sync_indicator").cloned(), fullscreen_indicator: config.get("tab_fullscreen_indicator").cloned(), bell_indicator, flashing_bell_indicator, tab_display_count, tab_truncate_start_format, tab_truncate_end_format, tab_zero_based_index, } } } impl Widget for TabsWidget { fn process(&self, _name: &str, state: &ZellijState) -> String { let mut output = "".to_owned(); let mut counter = 0; let (truncated_start, truncated_end, tabs) = get_tab_window(&state.tabs, self.tab_display_count); if truncated_start > 0 { for f in &self.tab_truncate_start_format { let mut content = f.content.clone(); if content.contains("{count}") { content = content.replace("{count}", (truncated_start).to_string().as_str()); } output = format!("{output}{}", f.format_string(&content)); } } for tab in &tabs { let content = self.render_tab(tab, &state.panes, &state.mode); counter += 1; output = format!("{}{}", output, content); if counter < tabs.len() && let Some(sep) = &self.separator { output = format!("{}{}", output, sep.format_string(&sep.content)); } } if truncated_end > 0 { for f in &self.tab_truncate_end_format { let mut content = f.content.clone(); if content.contains("{count}") { content = content.replace("{count}", (truncated_end).to_string().as_str()); } output = format!("{output}{}", f.format_string(&content)); } } output } fn process_click(&self, _name: &str, state: &ZellijState, pos: usize) { let mut offset = 0; let mut counter = 0; let (truncated_start, truncated_end, tabs) = get_tab_window(&state.tabs, self.tab_display_count); let active_pos = &state .tabs .iter() .find(|t| t.active) .expect("no active tab") .position + 1; if truncated_start > 0 { for f in &self.tab_truncate_start_format { let mut content = f.content.clone(); if content.contains("{count}") { content = content.replace("{count}", (truncated_end).to_string().as_str()); } offset += console::measure_text_width(&f.format_string(&content)); if pos <= offset { switch_tab_to(active_pos.saturating_sub(1) as u32); } } } for tab in &tabs { counter += 1; let mut rendered_content = self.render_tab(tab, &state.panes, &state.mode); if counter < tabs.len() && let Some(sep) = &self.separator { rendered_content = format!("{}{}", rendered_content, sep.format_string(&sep.content)); } let content_len = console::measure_text_width(&rendered_content); if pos > offset && pos < offset + content_len { switch_tab_to(tab.position as u32 + 1); break; } offset += content_len; } if truncated_end > 0 { for f in &self.tab_truncate_end_format { let mut content = f.content.clone(); if content.contains("{count}") { content = content.replace("{count}", (truncated_end).to_string().as_str()); } offset += console::measure_text_width(&f.format_string(&content)); if pos <= offset { switch_tab_to(cmp::min(active_pos + 1, state.tabs.len()) as u32); } } } } } impl TabsWidget { fn select_format(&self, info: &TabInfo, mode: &ModeInfo) -> &Vec { if info.active && mode.mode == InputMode::RenameTab { return &self.rename_tab_format; } if !info.active && info.is_flashing_bell { let fmt = self .normal_tab_flashing_bell_format .as_ref() .or(self.normal_tab_bell_format.as_ref()); if let Some(fmt) = fmt { return fmt; } } if !info.active && info.has_bell_notification && let Some(fmt) = self.normal_tab_bell_format.as_ref() { return fmt; } if info.active && info.is_fullscreen_active { return &self.active_tab_fullscreen_format; } if info.active && info.is_sync_panes_active { return &self.active_tab_sync_format; } if info.active { return &self.active_tab_format; } if info.is_fullscreen_active { return &self.normal_tab_fullscreen_format; } if info.is_sync_panes_active { return &self.normal_tab_sync_format; } &self.normal_tab_format } fn render_tab(&self, tab: &TabInfo, panes: &PaneManifest, mode: &ModeInfo) -> String { let formatters = self.select_format(tab, mode); let mut output = "".to_owned(); for f in formatters.iter() { let mut content = f.content.clone(); let tab_name = match mode.mode { InputMode::RenameTab => match tab.name.is_empty() { true => "Enter name...", false => tab.name.as_str(), }, _name => tab.name.as_str(), }; if content.contains("{name}") { content = content.replace("{name}", tab_name); } if content.contains("{index}") { let index = match self.tab_zero_based_index { true => tab.position, false => tab.position + 1, }; content = content.replace("{index}", index.to_string().as_str()); } if content.contains("{floating_total_count}") { let panes_for_tab: Vec = panes.panes.get(&tab.position).cloned().unwrap_or_default(); content = content.replace( "{floating_total_count}", &format!("{}", panes_for_tab.iter().filter(|p| p.is_floating).count()), ); } if content.contains("{focused_pane_title}") { let panes_for_tab: Vec = panes.panes.get(&tab.position).cloned().unwrap_or_default(); let focused_pane_title = panes_for_tab .iter() .find(|pane| pane.is_focused) .map(|pane| pane.title.clone()) .unwrap_or_default(); content = content.replace("{focused_pane_title}", &focused_pane_title); } content = self.replace_indicators(content, tab, panes); output = format!("{}{}", output, f.format_string(&content)); } output.to_owned() } fn replace_indicators(&self, content: String, tab: &TabInfo, panes: &PaneManifest) -> String { let mut content = content; if content.contains("{fullscreen_indicator}") && let Some(fullscreen_indicator) = self.fullscreen_indicator.clone() { content = content.replace( "{fullscreen_indicator}", if tab.is_fullscreen_active { fullscreen_indicator.as_ref() } else { "" }, ); } if content.contains("{sync_indicator}") && let Some(sync_indicator) = self.sync_indicator.clone() { content = content.replace( "{sync_indicator}", if tab.is_sync_panes_active { sync_indicator.as_ref() } else { "" }, ); } if content.contains("{floating_indicator}") && let Some(floating_indicator) = self.floating_indicator.clone() { let panes_for_tab: Vec = panes.panes.get(&tab.position).cloned().unwrap_or_default(); let is_floating = panes_for_tab.iter().any(|p| p.is_floating); content = content.replace( "{floating_indicator}", if is_floating { floating_indicator.as_ref() } else { "" }, ); } if content.contains("{bell_indicator}") && (self.bell_indicator.is_some() || self.flashing_bell_indicator.is_some()) { let indicator = if tab.is_flashing_bell { self.flashing_bell_indicator.as_deref().unwrap_or("") } else if tab.has_bell_notification { self.bell_indicator.as_deref().unwrap_or("") } else { "" }; content = content.replace("{bell_indicator}", indicator); } content } } pub fn get_tab_window( tabs: &Vec, max_count: Option, ) -> (usize, usize, Vec) { let max_count = match max_count { Some(count) => count, None => return (0, 0, tabs.to_vec()), }; if tabs.len() <= max_count { return (0, 0, tabs.to_vec()); } let active_index = tabs.iter().position(|t| t.active).expect("no active tab"); // active tab is in the last #max_count tabs, so return the last #max_count if active_index > tabs.len().saturating_sub(max_count) { return ( tabs.len().saturating_sub(max_count), 0, tabs.iter() .cloned() .rev() .take(max_count) .rev() .collect::>(), ); } // tabs must be truncated let first_index = active_index.saturating_sub(1); let last_index = cmp::min(first_index + max_count, tabs.len()); ( first_index, tabs.len().saturating_sub(last_index), tabs.as_slice()[first_index..last_index].to_vec(), ) } #[cfg(test)] mod test { use zellij_tile::prelude::TabInfo; use super::get_tab_window; use rstest::rstest; #[rstest] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ], Some(3), (1, 1, vec![ TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: true, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ], Some(3), (0, 2, vec![ TabInfo { active: true, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ], Some(3), (0, 2, vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "5".to_owned(), ..TabInfo::default() }, ], Some(3), (2, 0, vec![ TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "5".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ], Some(3), (2, 0, vec![ TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ], None, (0, 0, vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "3".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "4".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "5".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, ], Some(3), (0, 0, vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, ] ) )] #[case( vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, ], Some(3), (0, 0, vec![ TabInfo { active: false, name: "1".to_owned(), ..TabInfo::default() }, TabInfo { active: true, name: "2".to_owned(), ..TabInfo::default() }, TabInfo { active: false, name: "3".to_owned(), ..TabInfo::default() }, ] ) )] pub fn test_get_tab_window( #[case] tabs: Vec, #[case] max_count: Option, #[case] expected: (usize, usize, Vec), ) { let res = get_tab_window(&tabs, max_count); assert_eq!(res, expected); } }