diff --git a/config/joshuto.toml b/config/joshuto.toml index 9219ca0c9..455210d68 100644 --- a/config/joshuto.toml +++ b/config/joshuto.toml @@ -18,7 +18,6 @@ scroll_offset = 6 show_borders = true show_hidden = false show_icons = true -tilde_in_titlebar = true # none, absolute, relative line_number_style = "none" diff --git a/config/theme.toml b/config/theme.toml index c26502fd9..fce3633d7 100644 --- a/config/theme.toml +++ b/config/theme.toml @@ -2,12 +2,30 @@ ## Tabs ########################################## -# Inactive tabs -[tabs.inactive] +[tabs] +[tabs.styles] -# Active tabs -[tabs.active] -invert=true +# Style of active tab & current directory +[tabs.styles.active] +bg = "light_blue" +fg = "black" +bold = true + +# Style of inactive tabs +[tabs.styles.inactive] + +# Style of the left/front tab scroll tag (when tabs overflow) +[tabs.styles.scroll_front] +fg = "yellow" +bold = true + +# Style of the right/back tab scroll tag (when tabs overflow) +[tabs.styles.scroll_back] +fg = "yellow" +bold = true + +# There are more style options and strings to configure +# the tab-bar theme. ########################################## ## File List - Selections diff --git a/docs/configuration/joshuto.toml.md b/docs/configuration/joshuto.toml.md index 6a77f53fa..ee88c33de 100644 --- a/docs/configuration/joshuto.toml.md +++ b/docs/configuration/joshuto.toml.md @@ -119,14 +119,6 @@ fzf_case_sensitivity = "insensitive" # ... [tab] - -# Options include -# - num -# - dir -# - all -# also can be changed with the 'tab_bar_mode' command -display_mode = "all" - # inherit, home, root home_page = "home" ``` diff --git a/docs/configuration/tabbar/README.md b/docs/configuration/tabbar/README.md new file mode 100644 index 000000000..9cdd5b4e4 --- /dev/null +++ b/docs/configuration/tabbar/README.md @@ -0,0 +1,264 @@ +# Theming the Tab-Bar +The tab bar in the title row can be configured in various aspects. +This page explains the tab-related configuration options and gives +configuration examples at the end. + +## Elements of the Tab-Bar +Of course, the tab-bar is composed from one or more tabs. +Tabs are separated by a _divider_, and if there are more tabs than fit into the window, +there will be _scroll tags_ at the beginning and at the end of the tab bar. +The scroll tags indicate that there are more tabs and each end's scroll tag shows the number +of tabs that did not fit into the tab-bar on that particular side. + +The right scroll tag is always right-aligned. +The space between the right scroll tag and the right-most tab is filled by a "padding" segment. + +Furthermore, each tab, the padding segment, and the scroll tags, all have a _prefix_ and a _postfix_. +Prefixes and postfixes are additional sub-elements that +help to better visualize the tabs and +allow for some more styling options. + + +The following list shows an example sequence with four tabs +that includes all the possible segments of a tab-bar. + +*
+  [pre]  +  [scroll-f]  +  [post]   (left scroll tag) +
+*
+  [pre]  +  [tab 1]  +  [post]   (inactive tab) +
+*
+  [ii]   (divider between two inactive tabs) +
+*
+  [pre]  +  [tab 2]  +  [post]   (inactive tab) +
+*
+  [ia]   (divider before active tab) +
+*
+  [pre]  +  [tab 3]  +  [post]   (active tab) +
+*
+  [ai]   (divider after active tab) +
+*
+  [pre]  + tab 4 +  [post]   (inactive tab) +
+*
+  [pre]  +  [pad]  +  [post]   (padding) +
+*
+  [pre]  +  [scroll-b]  +  [post]   (right scroll tag) + + + +## Configuring the Characters +Characters for prefixes, postfixes and some other elements can be configured under a `[tabs.chars]` +section. +This is a complete example for the configuration of the default values: +```toml +[tabs.chars] +active_prefix = " " +active_postfix = " " +inactive_prefix = "[" +inactive_postfix = "]" +divider = " " +scroll_front_prefix = "" +scroll_front_postfix = "" +scroll_front_prestring = "«" +scroll_front_poststring = " " +scroll_back_prefix = "" +scroll_back_postfix = "" +scroll_back_prestring = " " +scroll_back_poststring = "»" +padding_prefix = " " +padding_postfix = " " +padding_fill = " " +``` +Be aware that all elements for the padding segment are chars and can't be of zero length +or `None`, while the other pre- and postfixes are strings and can also be an empty string. + +Further note that the scroll tags also have a `prestring` and `poststring`, +which are strings shown before and after the number in the scroll tag but are still part of the +tag's "body". While the pre- and postfix can have a separate font style (e.g. color), +the pre- and post-string are styled just as the number. + +## Configuring Styles (Colors and Font Styles) +Each element of the tab-bar can be configured with a separate style. +The divider can even be configured with three different styles, +one for dividers between two inactive tabs (`divider_ii`), +one for the divider before the active tab (`divider_ia`), +and one for the divider after the active tab (`divider_ai`). + +All styles are sub-elements of `[tabs.styles]`. + +To ease configuration, most styles are derived from another style and one only +has to specify the deviation. + +The following image visualizes all styles and how they are derived from others. + +```mermaid +classDiagram + direction LR + + active <|-- prefix_active + prefix_active <|-- postfix_active + + inactive <|-- prefix_inactive + prefix_inactive <|-- postfix_inactive + + divider_ii <|-- divider_ia + divider_ia <|-- divider_ai + + scroll_front <|-- scroll_front_prefix + scroll_front_prefix <|-- scroll_front_postfix + + scroll_back <|-- scroll_back_prefix + scroll_back_prefix <|-- scroll_back_postfix + + padding_fill <|-- padding_prefix + padding_prefix <|-- padding_postfix +``` +The left-most styles in the diagram are basic styles which are not +derived from another style. +The default configuration uses these: + +```toml +[tabs.styles] + +[tabs.styles.active] +bg = "light_blue" +fg = "black" +bold = true + +[tabs.styles.scroll_front] +fg = "yellow" +bold = true + +[tabs.styles.scroll_back] +fg = "yellow" +bold = true +``` + +The remaining base styles use the terminal default background and foreground. +The derived styles are all empty by default and therewith equal to their "root-style". + +Hint: If one changes even only one attribute of one of the base styles, the default +is not used anymore. For example, if only `[tabs.styles.active.bg]` is set in +`theme.toml`, the two other defaults (`fg` and `bold`) are not used anymore +from the default configuration and need to be specified explicitly if wanted. +If they are specified, they will fall back to the terminal default style. + +## Examples + +### Rounded Tabs with Arrow-like Scroll Tags +This styling requires a **nerdfont** being used by the terminal. + +![Fancy Tab-Bar with Nerdfont](nerdfont_bar_1.png) + +This is the configuration that can be copied to `theme.toml`: +```toml +[tabs] + +[tabs.chars] +divider = " " +active_prefix = "" +active_postfix = "" +inactive_prefix = "" +inactive_postfix = "" +scroll_front_prefix = "" +scroll_front_postfix = "" +scroll_front_prestring = "" +scroll_front_poststring = " " +scroll_back_prefix = "" +scroll_back_postfix = "" +scroll_back_prestring = " " +scroll_back_poststring = "" + +[tabs.styles] + +[tabs.styles.active] +fg = "black" +bg = "light_blue" +bold = true + +[tabs.styles.active_prefix] +fg = "light_blue" +bg = "reset" + +[tabs.styles.inactive] +fg = "black" +bg = "gray" + +[tabs.styles.inactive_prefix] +fg = "gray" +bg = "reset" + +[tabs.styles.scroll_front] +fg = "black" +bg = "yellow" +bold = true + +[tabs.styles.scroll_front_prefix] +fg = "yellow" +bg = "reset" + +[tabs.styles.scroll_front_postfix] +invert = true + +[tabs.styles.scroll_back] +fg = "black" +bg = "yellow" +bold = true + +[tabs.styles.scroll_back_prefix] +fg = "yellow" +bg = "reset" +invert = true + +[tabs.styles.scroll_back_postfix] +invert = false +``` diff --git a/docs/configuration/tabbar/nerdfont_bar_1.png b/docs/configuration/tabbar/nerdfont_bar_1.png new file mode 100644 index 000000000..8c7fb77c2 Binary files /dev/null and b/docs/configuration/tabbar/nerdfont_bar_1.png differ diff --git a/docs/configuration/theme.toml.md b/docs/configuration/theme.toml.md index e946be6fd..f42803425 100644 --- a/docs/configuration/theme.toml.md +++ b/docs/configuration/theme.toml.md @@ -61,3 +61,5 @@ a specific style that overrides the former file-type-styles. Last but not least, there are styles for _selected_ files which override all the former styles. +## Theming the Tab-Bar +Theming of the tab-bar is described [here](tabbar/README.md). diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fb45e7966..8b56f5a59 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -40,7 +40,6 @@ pub mod show_tasks; pub mod sort; pub mod sub_process; pub mod subdir_fzf; -pub mod tab_bar_mode; pub mod tab_ops; pub mod touch_file; pub mod uimodes; diff --git a/src/commands/tab_bar_mode.rs b/src/commands/tab_bar_mode.rs deleted file mode 100644 index e292aaca5..000000000 --- a/src/commands/tab_bar_mode.rs +++ /dev/null @@ -1,11 +0,0 @@ -use crate::config::clean::app::tab::TabBarDisplayMode; -use crate::context::AppContext; -use crate::error::AppResult; - -pub fn set_tab_bar_display_mode( - context: &mut AppContext, - mode: &TabBarDisplayMode, -) -> AppResult<()> { - context.tab_context_mut().display.mode = *mode; - Ok(()) -} diff --git a/src/config/clean/app/display/config.rs b/src/config/clean/app/display/config.rs index 4bf64de9d..1f8e51fb6 100644 --- a/src/config/clean/app/display/config.rs +++ b/src/config/clean/app/display/config.rs @@ -26,7 +26,6 @@ pub struct DisplayOption { pub _show_borders: bool, pub _show_hidden: bool, pub _show_icons: bool, - pub _tilde_in_titlebar: bool, pub _line_nums: LineNumberStyle, pub column_ratio: (usize, usize, usize), pub default_layout: [Constraint; 3], @@ -71,7 +70,6 @@ impl From for DisplayOption { _show_borders: raw.show_borders, _show_hidden: raw.show_hidden, _show_icons: raw.show_icons, - _tilde_in_titlebar: raw.tilde_in_titlebar, _line_nums, column_ratio, @@ -120,10 +118,6 @@ impl DisplayOption { self._show_hidden = show_hidden; } - pub fn tilde_in_titlebar(&self) -> bool { - self._tilde_in_titlebar - } - pub fn line_nums(&self) -> LineNumberStyle { self._line_nums } @@ -164,7 +158,6 @@ impl std::default::Default for DisplayOption { _show_borders: true, _show_hidden: false, _show_icons: false, - _tilde_in_titlebar: true, _line_nums: LineNumberStyle::None, default_layout, no_preview_layout, diff --git a/src/config/clean/app/tab/config.rs b/src/config/clean/app/tab/config.rs index 86e156f9e..d724e9af6 100644 --- a/src/config/clean/app/tab/config.rs +++ b/src/config/clean/app/tab/config.rs @@ -1,28 +1,13 @@ -use std::str::FromStr; - -use serde::Deserialize; - -use crate::{ - config::raw::app::display::tab::TabOptionRaw, - error::{AppError, AppErrorKind}, - tab::TabHomePage, -}; +use crate::{config::raw::app::display::tab::TabOptionRaw, tab::TabHomePage}; #[derive(Clone, Debug)] pub struct TabOption { pub _home_page: TabHomePage, - pub display: TabBarDisplayOption, } impl TabOption { - pub fn new(_home_page: TabHomePage, display_mode: TabBarDisplayMode, max_len: usize) -> Self { - Self { - _home_page, - display: TabBarDisplayOption { - mode: display_mode, - max_len, - }, - } + pub fn new(_home_page: TabHomePage) -> Self { + Self { _home_page } } pub fn home_page(&self) -> TabHomePage { self._home_page @@ -33,7 +18,6 @@ impl std::default::Default for TabOption { fn default() -> Self { Self { _home_page: TabHomePage::Home, - display: TabBarDisplayOption::default(), } } } @@ -42,48 +26,6 @@ impl From for TabOption { fn from(raw: TabOptionRaw) -> Self { let home_page = TabHomePage::from_str(raw.home_page.as_str()).unwrap_or(TabHomePage::Home); - Self::new(home_page, raw.display_mode, raw.max_len) - } -} - -#[derive(Clone, Copy, Debug)] -pub struct TabBarDisplayOption { - pub mode: TabBarDisplayMode, - pub max_len: usize, -} - -impl Default for TabBarDisplayOption { - fn default() -> Self { - Self { - mode: Default::default(), - max_len: 16, - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, Default)] -pub enum TabBarDisplayMode { - #[serde(rename = "num")] - Number, - #[default] - #[serde(rename = "dir")] - Directory, - #[serde(rename = "all")] - All, -} - -impl FromStr for TabBarDisplayMode { - type Err = AppError; - - fn from_str(s: &str) -> Result { - match s { - "num" => Ok(Self::Number), - "dir" => Ok(Self::Directory), - "all" => Ok(Self::All), - s => Err(AppError::new( - AppErrorKind::UnrecognizedArgument, - format!("tab_bar_mode: `{}` unknown argument.", s), - )), - } + Self::new(home_page) } } diff --git a/src/config/clean/theme/tab.rs b/src/config/clean/theme/tab.rs index 6a4a9018e..f8cf256de 100644 --- a/src/config/clean/theme/tab.rs +++ b/src/config/clean/theme/tab.rs @@ -1,17 +1,198 @@ -use crate::config::raw::theme::tab::TabThemeRaw; - -use super::style::AppStyle; +use crate::config::raw::theme::tab::{TabThemeCharsRaw, TabThemeColorRaw, TabThemeRaw}; +use crate::util::style::PathStyleIfSome; +use ratatui::style::{Color, Modifier, Style}; +use unicode_width::UnicodeWidthStr; #[derive(Clone, Debug)] pub struct TabTheme { - pub inactive: AppStyle, - pub active: AppStyle, + pub styles: TabThemeColors, + pub chars: TabThemeChars, + pub inference: TabThemeCharsInference, } impl From for TabTheme { fn from(crude: TabThemeRaw) -> Self { - let inactive = crude.inactive.to_style_theme(); - let active = crude.active.to_style_theme(); - Self { inactive, active } + let chars = TabThemeChars::from(crude.chars); + Self { + styles: TabThemeColors::from(crude.styles), + inference: TabThemeCharsInference::from_chars(&chars), + chars, + } + } +} + +#[derive(Clone, Debug)] +pub struct TabThemeChars { + pub divider: String, + pub prefix_i: String, + pub postfix_i: String, + pub prefix_a: String, + pub postfix_a: String, + pub scroll_front_prefix: String, + pub scroll_front_postfix: String, + pub scroll_front_prestring: String, + pub scroll_front_poststring: String, + pub scroll_back_prefix: String, + pub scroll_back_postfix: String, + pub scroll_back_prestring: String, + pub scroll_back_poststring: String, + pub padding_prefix: char, + pub padding_postfix: char, + pub padding_fill: char, +} + +impl From for TabThemeChars { + fn from(crude: TabThemeCharsRaw) -> Self { + Self { + divider: crude.divider.unwrap_or(" ".to_string()), + prefix_i: crude.inactive_prefix.unwrap_or("[".to_string()), + postfix_i: crude.inactive_postfix.unwrap_or("]".to_string()), + prefix_a: crude.active_prefix.unwrap_or(" ".to_string()), + postfix_a: crude.active_postfix.unwrap_or(" ".to_string()), + scroll_front_prefix: crude.scroll_front_prefix.unwrap_or("".to_string()), + scroll_front_postfix: crude.scroll_front_postfix.unwrap_or("".to_string()), + scroll_front_prestring: crude.scroll_front_prestring.unwrap_or("«".to_string()), + scroll_front_poststring: crude.scroll_front_poststring.unwrap_or(" ".to_string()), + scroll_back_prefix: crude.scroll_back_prefix.unwrap_or("".to_string()), + scroll_back_postfix: crude.scroll_back_postfix.unwrap_or("".to_string()), + scroll_back_prestring: crude.scroll_back_prestring.unwrap_or(" ".to_string()), + scroll_back_poststring: crude.scroll_back_poststring.unwrap_or("»".to_string()), + padding_prefix: crude.padding_prefix.unwrap_or(' '), + padding_postfix: crude.padding_postfix.unwrap_or(' '), + padding_fill: crude.padding_fill.unwrap_or(' '), + } + } +} + +#[derive(Clone, Debug)] +pub struct TabThemeCharsInference { + pub tab_divider_length: usize, + pub tab_prefix_i_length: usize, + pub tab_postfix_i_length: usize, + pub tab_prefix_a_length: usize, + pub tab_postfix_a_length: usize, + pub scroll_front_static_length: usize, + pub scroll_back_static_length: usize, + pub active_tab_extra_width: usize, + pub inactive_tab_extra_width: usize, +} + +impl TabThemeCharsInference { + fn from_chars(chars: &TabThemeChars) -> Self { + Self { + tab_divider_length: chars.divider.width(), + tab_prefix_i_length: chars.prefix_i.width(), + tab_prefix_a_length: chars.prefix_a.width(), + tab_postfix_i_length: chars.postfix_i.width(), + tab_postfix_a_length: chars.postfix_a.width(), + scroll_front_static_length: chars.scroll_front_prefix.width() + + chars.scroll_front_postfix.width() + + chars.scroll_front_prestring.width() + + chars.scroll_front_poststring.width(), + scroll_back_static_length: chars.scroll_back_prefix.width() + + chars.scroll_back_postfix.width() + + chars.scroll_back_prestring.width() + + chars.scroll_back_poststring.width(), + active_tab_extra_width: chars.prefix_a.width() + chars.postfix_a.width(), + inactive_tab_extra_width: chars.prefix_i.width() + chars.postfix_i.width(), + } + } + + pub fn calc_scroll_tags_width(&self, num_tabs: usize) -> usize { + let max_num_width = num_tabs.checked_ilog10().unwrap_or(0) as usize + 1; + 2 * max_num_width + self.scroll_front_static_length + self.scroll_back_static_length + } +} + +#[derive(Clone, Debug)] +pub struct TabThemeColors { + pub prefix_a: Style, + pub postfix_a: Style, + pub tab_a: Style, + pub prefix_i: Style, + pub postfix_i: Style, + pub tab_i: Style, + pub divider_ii: Style, + pub divider_ia: Style, + pub divider_ai: Style, + pub scroll_front_prefix: Style, + pub scroll_front_postfix: Style, + pub scroll_front: Style, + pub scroll_back_prefix: Style, + pub scroll_back_postfix: Style, + pub scroll_back: Style, + pub padding_prefix: Style, + pub padding_postfix: Style, + pub padding_fill: Style, +} + +impl From for TabThemeColors { + fn from(crude: TabThemeColorRaw) -> Self { + let tab_a = crude.active.map(|s| s.as_style()).unwrap_or( + Style::new() + .bg(Color::LightBlue) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ); + let prefix_a = tab_a.patch_optionally(crude.active_prefix.map(|s| s.as_style())); + let postfix_a = prefix_a.patch_optionally(crude.active_postfix.map(|s| s.as_style())); + + let tab_i = crude.inactive.map(|s| s.as_style()).unwrap_or(Style::new()); + let prefix_i = tab_i.patch_optionally(crude.inactive_prefix.map(|s| s.as_style())); + let postfix_i = prefix_i.patch_optionally(crude.inactive_postfix.map(|s| s.as_style())); + + let divider_ii = crude + .divider_ii + .map(|s| s.as_style()) + .unwrap_or(Style::new()); + let divider_ia = divider_ii.patch_optionally(crude.divider_ia.map(|s| s.as_style())); + let divider_ai = divider_ia.patch_optionally(crude.divider_ai.map(|s| s.as_style())); + + let scroll_front = crude + .scroll_front + .map(|s| s.as_style()) + .unwrap_or(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + let scroll_front_prefix = + scroll_front.patch_optionally(crude.scroll_front_prefix.map(|s| s.as_style())); + let scroll_front_postfix = + scroll_front_prefix.patch_optionally(crude.scroll_front_postfix.map(|s| s.as_style())); + + let scroll_back = crude + .scroll_back + .map(|s| s.as_style()) + .unwrap_or(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + let scroll_back_prefix = + scroll_back.patch_optionally(crude.scroll_back_prefix.map(|s| s.as_style())); + let scroll_back_postfix = + scroll_back_prefix.patch_optionally(crude.scroll_back_postfix.map(|s| s.as_style())); + + let padding_fill = crude + .padding_fill + .map(|s| s.as_style()) + .unwrap_or(Style::new()); + let padding_prefix = + padding_fill.patch_optionally(crude.padding_prefix.map(|s| s.as_style())); + let padding_postfix = + padding_prefix.patch_optionally(crude.padding_postfix.map(|s| s.as_style())); + Self { + prefix_a, + postfix_a, + tab_a, + prefix_i, + postfix_i, + tab_i, + divider_ii, + divider_ia, + divider_ai, + scroll_front_prefix, + scroll_front_postfix, + scroll_front, + scroll_back_prefix, + scroll_back_postfix, + scroll_back, + padding_prefix, + padding_postfix, + padding_fill, + } } } diff --git a/src/config/raw/app/display/tab.rs b/src/config/raw/app/display/tab.rs index 7a3c853a6..f3ee50b5e 100644 --- a/src/config/raw/app/display/tab.rs +++ b/src/config/raw/app/display/tab.rs @@ -1,31 +1,19 @@ use serde::Deserialize; -use crate::config::clean::app::tab::TabBarDisplayMode; - fn default_home_page() -> String { "home".to_string() } -const fn default_max_len() -> usize { - 16 -} - #[derive(Clone, Debug, Deserialize)] pub struct TabOptionRaw { #[serde(default = "default_home_page")] pub home_page: String, - #[serde(default)] - pub display_mode: TabBarDisplayMode, - #[serde(default = "default_max_len")] - pub max_len: usize, } impl std::default::Default for TabOptionRaw { fn default() -> Self { Self { home_page: default_home_page(), - display_mode: TabBarDisplayMode::default(), - max_len: 16, } } } diff --git a/src/config/raw/theme/style.rs b/src/config/raw/theme/style.rs index a588bd590..fd037a75a 100644 --- a/src/config/raw/theme/style.rs +++ b/src/config/raw/theme/style.rs @@ -1,11 +1,96 @@ use colors_transform::{Color, Rgb}; - +use ratatui::style::{self, Style}; use serde::Deserialize; -use ratatui::style; - use crate::config::clean::theme::style::AppStyle; +fn str_to_color(s: &str) -> style::Color { + match s { + "black" => style::Color::Black, + "red" => style::Color::Red, + "green" => style::Color::Green, + "yellow" => style::Color::Yellow, + "blue" => style::Color::Blue, + "magenta" => style::Color::Magenta, + "cyan" => style::Color::Cyan, + "gray" => style::Color::Gray, + "dark_gray" => style::Color::DarkGray, + "light_red" => style::Color::LightRed, + "light_green" => style::Color::LightGreen, + "light_yellow" => style::Color::LightYellow, + "light_blue" => style::Color::LightBlue, + "light_magenta" => style::Color::LightMagenta, + "light_cyan" => style::Color::LightCyan, + "white" => style::Color::White, + "reset" => style::Color::Reset, + s if s.starts_with('#') => { + let rgb = match Rgb::from_hex_str(s) { + Ok(s) => s, + _ => return style::Color::Reset, + }; + let r = rgb.get_red() as u8; + let g = rgb.get_green() as u8; + let b = rgb.get_blue() as u8; + style::Color::Rgb(r, g, b) + } + s if s.is_empty() => style::Color::Reset, + s => match s.parse::() { + Ok(rgb) => { + let r = rgb.get_red() as u8; + let g = rgb.get_green() as u8; + let b = rgb.get_blue() as u8; + style::Color::Rgb(r, g, b) + } + Err(_) => style::Color::Reset, + }, + } +} + +#[derive(Clone, Debug, Deserialize)] + +pub struct AppStyleOptionsRaw { + pub fg: Option, + pub bg: Option, + pub bold: Option, + pub underline: Option, + pub invert: Option, +} + +impl AppStyleOptionsRaw { + pub fn as_style(&self) -> Style { + let mut add_modifier = style::Modifier::empty(); + let mut sub_modifier = style::Modifier::empty(); + if let Some(bold) = self.bold { + if bold { + add_modifier.insert(style::Modifier::BOLD); + } else { + sub_modifier.insert(style::Modifier::BOLD); + } + } + if let Some(underline) = self.underline { + if underline { + add_modifier.insert(style::Modifier::UNDERLINED); + } else { + sub_modifier.insert(style::Modifier::UNDERLINED); + } + } + if let Some(invert) = self.invert { + if invert { + add_modifier.insert(style::Modifier::REVERSED); + } else { + sub_modifier.insert(style::Modifier::REVERSED); + } + } + Style { + fg: self.fg.clone().map(|s| str_to_color(s.as_ref())), + bg: self.bg.clone().map(|s| str_to_color(s.as_ref())), + add_modifier, + sub_modifier, + //underline_color: None, # only when ratatui with crossterm + } + } +} + #[derive(Clone, Debug, Deserialize)] pub struct AppStyleRaw { #[serde(default)] @@ -40,45 +125,7 @@ impl AppStyleRaw { } pub fn str_to_color(s: &str) -> style::Color { - match s { - "black" => style::Color::Black, - "red" => style::Color::Red, - "green" => style::Color::Green, - "yellow" => style::Color::Yellow, - "blue" => style::Color::Blue, - "magenta" => style::Color::Magenta, - "cyan" => style::Color::Cyan, - "gray" => style::Color::Gray, - "dark_gray" => style::Color::DarkGray, - "light_red" => style::Color::LightRed, - "light_green" => style::Color::LightGreen, - "light_yellow" => style::Color::LightYellow, - "light_blue" => style::Color::LightBlue, - "light_magenta" => style::Color::LightMagenta, - "light_cyan" => style::Color::LightCyan, - "white" => style::Color::White, - "reset" => style::Color::Reset, - s if s.starts_with('#') => { - let rgb = match Rgb::from_hex_str(s) { - Ok(s) => s, - _ => return style::Color::Reset, - }; - let r = rgb.get_red() as u8; - let g = rgb.get_green() as u8; - let b = rgb.get_blue() as u8; - style::Color::Rgb(r, g, b) - } - s if s.is_empty() => style::Color::Reset, - s => match s.parse::() { - Ok(rgb) => { - let r = rgb.get_red() as u8; - let g = rgb.get_green() as u8; - let b = rgb.get_blue() as u8; - style::Color::Rgb(r, g, b) - } - Err(_) => style::Color::Reset, - }, - } + str_to_color(s) } } diff --git a/src/config/raw/theme/tab.rs b/src/config/raw/theme/tab.rs index 1e1fef692..be711136f 100644 --- a/src/config/raw/theme/tab.rs +++ b/src/config/raw/theme/tab.rs @@ -1,11 +1,53 @@ use serde::Deserialize; -use super::style::AppStyleRaw; +use super::style::AppStyleOptionsRaw; #[derive(Clone, Debug, Deserialize, Default)] pub struct TabThemeRaw { #[serde(default)] - pub inactive: AppStyleRaw, + pub styles: TabThemeColorRaw, #[serde(default)] - pub active: AppStyleRaw, + pub chars: TabThemeCharsRaw, +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct TabThemeColorRaw { + pub active_prefix: Option, + pub active_postfix: Option, + pub active: Option, + pub inactive_prefix: Option, + pub inactive_postfix: Option, + pub inactive: Option, + pub divider_ii: Option, + pub divider_ia: Option, + pub divider_ai: Option, + pub scroll_front_prefix: Option, + pub scroll_front_postfix: Option, + pub scroll_front: Option, + pub scroll_back_prefix: Option, + pub scroll_back_postfix: Option, + pub scroll_back: Option, + pub padding_prefix: Option, + pub padding_postfix: Option, + pub padding_fill: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct TabThemeCharsRaw { + pub active_prefix: Option, + pub active_postfix: Option, + pub inactive_prefix: Option, + pub inactive_postfix: Option, + pub divider: Option, + pub scroll_front_prefix: Option, + pub scroll_front_postfix: Option, + pub scroll_front_prestring: Option, + pub scroll_front_poststring: Option, + pub scroll_back_prefix: Option, + pub scroll_back_postfix: Option, + pub scroll_back_prestring: Option, + pub scroll_back_poststring: Option, + pub padding_prefix: Option, + pub padding_postfix: Option, + pub padding_fill: Option, } diff --git a/src/context/app_context.rs b/src/context/app_context.rs index 16401ca38..35af55330 100644 --- a/src/context/app_context.rs +++ b/src/context/app_context.rs @@ -70,7 +70,7 @@ impl AppContext { quit: QuitAction::DoNot, events, args, - tab_context: TabContext::new(config.tab_options_ref().display), + tab_context: TabContext::new(), local_state: None, search_context: None, message_queue: MessageQueue::new(), diff --git a/src/context/tab_context.rs b/src/context/tab_context.rs index fa81c4a8d..1df729577 100644 --- a/src/context/tab_context.rs +++ b/src/context/tab_context.rs @@ -3,21 +3,18 @@ use std::collections::HashMap; use uuid::Uuid; -use crate::config::clean::app::tab::{TabBarDisplayMode, TabBarDisplayOption}; use crate::tab::JoshutoTab; #[derive(Default)] pub struct TabContext { pub index: usize, pub tab_order: Vec, - pub display: TabBarDisplayOption, tabs: HashMap, } impl TabContext { - pub fn new(display: TabBarDisplayOption) -> Self { + pub fn new() -> Self { Self { - display, ..Default::default() } } @@ -28,6 +25,17 @@ impl TabContext { pub fn tab_ref(&self, id: &Uuid) -> Option<&JoshutoTab> { self.tabs.get(id) } + + pub fn tab_refs_in_order(&self) -> Vec<&JoshutoTab> { + let mut tab_refs: Vec<&JoshutoTab> = vec![]; + for tab_id in self.tab_order.iter() { + if let Some(tab_ref) = self.tab_ref(tab_id) { + tab_refs.push(tab_ref); + } + } + tab_refs + } + pub fn tab_mut(&mut self, id: &Uuid) -> Option<&mut JoshutoTab> { self.tabs.get_mut(id) } @@ -61,32 +69,4 @@ impl TabContext { pub fn iter_mut(&mut self) -> IterMut { self.tabs.iter_mut() } - - pub fn tab_title_width(&self) -> usize { - self.tabs - .values() - .map(|tab| { - let title_len = tab.tab_title().len(); - (title_len > self.display.max_len) - .then(|| self.display.max_len) - .unwrap_or(title_len) - }) - .sum() - } - - pub fn tab_area_width(&self) -> usize { - let width_without_divider = match self.display.mode { - TabBarDisplayMode::Number => (1..=self.len()).map(|n| n.to_string().len() + 2).sum(), // each number has a horizontal padding(1 char width) - TabBarDisplayMode::Directory => self.tab_title_width(), - TabBarDisplayMode::All => { - // [number][: ](width = 2)[title] - self.tab_title_width() - + (1..=self.len()) - .map(|n| n.to_string().len() + 2) - .sum::() - } - }; - - width_without_divider + 3 * (self.len() - 1) - } } diff --git a/src/key_command/command.rs b/src/key_command/command.rs index ae2ad6604..d21863222 100644 --- a/src/key_command/command.rs +++ b/src/key_command/command.rs @@ -8,7 +8,6 @@ use crate::config::clean::app::display::line_number::LineNumberStyle; use crate::config::clean::app::display::new_tab::NewTabMode; use crate::config::clean::app::display::sort_type::SortType; use crate::config::clean::app::search::CaseSensitivity; -use crate::config::clean::app::tab::TabBarDisplayMode; use crate::io::FileOperationOptions; #[derive(Clone, Debug)] @@ -165,7 +164,6 @@ pub enum Command { pattern: String, }, - SetTabBarDisplayMode(TabBarDisplayMode), NewTab { mode: NewTabMode, }, diff --git a/src/key_command/constants.rs b/src/key_command/constants.rs index 765d94bf3..1edfb428c 100644 --- a/src/key_command/constants.rs +++ b/src/key_command/constants.rs @@ -72,7 +72,6 @@ cmd_constants![ (CMD_SUBPROCESS_FOREGROUND, "shell"), (CMD_SUBPROCESS_BACKGROUND, "spawn"), (CMD_SHOW_TASKS, "show_tasks"), - (CMD_SET_TAB_BAR_MODE, "tab_bar_mode"), (CMD_TAB_SWITCH, "tab_switch"), (CMD_TAB_SWITCH_INDEX, "tab_switch_index"), (CMD_TOGGLE_HIDDEN, "toggle_hidden"), diff --git a/src/key_command/impl_appcommand.rs b/src/key_command/impl_appcommand.rs index 4822ca871..4b8660838 100644 --- a/src/key_command/impl_appcommand.rs +++ b/src/key_command/impl_appcommand.rs @@ -90,7 +90,6 @@ impl AppCommand for Command { Self::SwitchLineNums(_) => CMD_SWITCH_LINE_NUMBERS, Self::SetLineMode(_) => CMD_SET_LINEMODE, - Self::SetTabBarDisplayMode(_) => CMD_SET_TAB_BAR_MODE, Self::TabSwitch { .. } => CMD_TAB_SWITCH, Self::TabSwitchIndex { .. } => CMD_TAB_SWITCH_INDEX, Self::ToggleHiddenFiles => CMD_TOGGLE_HIDDEN, diff --git a/src/key_command/impl_appexecute.rs b/src/key_command/impl_appexecute.rs index 86200dab9..b4033c281 100644 --- a/src/key_command/impl_appexecute.rs +++ b/src/key_command/impl_appexecute.rs @@ -155,9 +155,6 @@ impl AppExecute for Command { Self::ToggleHiddenFiles => show_hidden::toggle_hidden(context), - Self::SetTabBarDisplayMode(mode) => { - tab_bar_mode::set_tab_bar_display_mode(context, mode) - } Self::TabSwitch { offset } => { tab_ops::tab_switch(context, *offset).map_err(|e| e.into()) } diff --git a/src/key_command/impl_comment.rs b/src/key_command/impl_comment.rs index 39fe88653..3922b323e 100644 --- a/src/key_command/impl_comment.rs +++ b/src/key_command/impl_comment.rs @@ -1,8 +1,5 @@ use crate::{ - config::clean::app::{ - display::{line_mode::LineMode, sort_type::SortType}, - tab::TabBarDisplayMode, - }, + config::clean::app::display::{line_mode::LineMode, sort_type::SortType}, io::FileOperationOptions, }; @@ -131,15 +128,6 @@ impl CommandComment for Command { Self::FilterRegex { .. } => "Filter directory list with regex", Self::FilterString { .. } => "Filter directory list", - Self::SetTabBarDisplayMode(mode) => match mode { - TabBarDisplayMode::Number => "TabBar only display with number ( 1 | 2 | 3 )", - TabBarDisplayMode::Directory => { - "TabBar only display with directory ( dir1 | dir2 | dir3 )" - } - TabBarDisplayMode::All => { - "TabBar display with numbar and directory ( 1: dir1 | 2: dir2 )" - } - }, Self::TabSwitch { .. } => "Switch to the next tab", Self::TabSwitchIndex { .. } => "Switch to a given tab", Self::Help => "Open this help page", diff --git a/src/key_command/impl_from_str.rs b/src/key_command/impl_from_str.rs index b3b34505f..33fdfefd2 100644 --- a/src/key_command/impl_from_str.rs +++ b/src/key_command/impl_from_str.rs @@ -8,7 +8,6 @@ use crate::config::clean::app::display::line_number::LineNumberStyle; use crate::config::clean::app::display::new_tab::NewTabMode; use crate::config::clean::app::display::sort_type::SortType; use crate::config::clean::app::search::CaseSensitivity; -use crate::config::clean::app::tab::TabBarDisplayMode; use crate::error::{AppError, AppErrorKind}; use crate::io::FileOperationOptions; use crate::util::unix; @@ -489,10 +488,6 @@ impl std::str::FromStr for Command { } } else if command == CMD_SET_LINEMODE { Ok(Self::SetLineMode(LineMode::from_string(arg)?)) - } else if command == CMD_SET_TAB_BAR_MODE { - Ok(Self::SetTabBarDisplayMode(TabBarDisplayMode::from_str( - arg, - )?)) } else if command == CMD_TAB_SWITCH { match arg.parse::() { Ok(s) => Ok(Self::TabSwitch { offset: s }), diff --git a/src/tab/tab_struct.rs b/src/tab/tab_struct.rs index 9b64b09fe..09e999900 100644 --- a/src/tab/tab_struct.rs +++ b/src/tab/tab_struct.rs @@ -1,6 +1,4 @@ -use std::borrow::Cow; use std::collections::HashMap; -use std::ffi::OsStr; use std::path; use crate::config::clean::app::display::tab::TabDisplayOption; @@ -111,11 +109,4 @@ impl JoshutoTab { self.history.get_mut(child_path.as_path()) } - - pub fn tab_title(&self) -> Cow<'_, str> { - self.cwd() - .file_name() - .unwrap_or_else(|| OsStr::new("/")) - .to_string_lossy() - } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 709e9a45c..a76b29aa7 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ mod backend; mod preview_area; mod rect; +mod tab_list_builder; pub mod views; pub mod widgets; @@ -8,3 +9,4 @@ pub mod widgets; pub use backend::*; pub use preview_area::*; pub use rect::*; +pub use tab_list_builder::*; diff --git a/src/ui/tab_list_builder.rs b/src/ui/tab_list_builder.rs new file mode 100644 index 000000000..2fda9268c --- /dev/null +++ b/src/ui/tab_list_builder.rs @@ -0,0 +1,1040 @@ +use ratatui::text::Span; +use std::isize; +use std::path::Path; + +use crate::config::clean::theme::tab::TabTheme; +use crate::util::string::UnicodeTruncate; +use crate::HOME_DIR; +use unicode_width::UnicodeWidthStr; + +// This file provides stuff to factor a tab-line from a list of paths. +// +// The tab-line logic uses these basic cases: +// +// * Case 0: Not enough space for anything useful. ⇒ Empty tab line +// * Case 1: All [1..n] tabs fit in long form +// * If only one tab exists, then... +// * Case 2: The only tab did _not_ fit in long form (would have been Case 1)... +// * Case 2a: Single tab fits in short form +// * Case 2b: Single tab fits only in short form, without pre- and postfix +// * Case 2c: Single tab fits only without pre- and postfix, and further +// shortened with an ellipsis +// * If more than one tab exist, then... +// * Case 3: The active tab fits in long form together with the others in short form +// * Case 4: Not all tabs can be shown; Scrolling needed... +// * Case 4a: The active tab fits between the scroll tags in long form +// * Case 4b: The active tab fits between the scroll tags only in short form +// * Case 4c: The active tab fits only without scroll tags and pre- and postfix +// * Case 4d: The active tab fits only without scroll tags and pre- and postfix +// and further shortened with ellipsis + +pub struct TabLabel { + long: String, + short: String, +} + +impl TabLabel { + fn from_path(path: &Path) -> TabLabel { + let mut full_path_str = path.as_os_str().to_str().unwrap().to_string(); + if let Some(home_dir) = HOME_DIR.as_ref() { + let home_dir_str = home_dir.to_string_lossy().into_owned(); + full_path_str = full_path_str.replace(&home_dir_str, "~"); + } + let last = Path::new(&full_path_str) + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + TabLabel { + long: full_path_str, + short: last, + } + } +} + +#[derive(PartialEq, Debug)] +enum TabBarElement { + // Note: The Tab-Elements also store the index, eventhough it's not used + // for the tab-bar rendering at all. The reason is that this would allow + // an easier implementation for other features later on, like changing + // tabs by mouse clicks. + // It's a valuable information that comes for free here. + DividerII, + DividerIA, + DividerAI, + PrefixI, + PostfixI, + PrefixA, + PostfixA, + TabI(usize, String), // index, label-string + TabA(usize, String), // index, label-string + ScrollFrontPrefix, + ScrollFrontPostfix, + ScrollFront(usize), // number of hidden tabs + ScrollBackPrefix, + ScrollBackPostfix, + ScrollBack(usize), // number of hidden tab + PaddingPrefix, + PaddingPostfix, + PaddingFill(usize), // width of padding +} + +fn check_fit_and_build_sequence( + labels: &[String], + available_width: usize, + extra_width: usize, + current_index: usize, +) -> Option> { + if labels.iter().map(|l| l.width()).sum::() + extra_width <= available_width { + let r = labels + .iter() + .enumerate() + .flat_map(|(ix, l)| { + let mut section = Vec::with_capacity(4); + if ix > 0 { + if ix == current_index { + section.push(TabBarElement::DividerIA); + } else if ix - 1 == current_index { + section.push(TabBarElement::DividerAI); + } else { + section.push(TabBarElement::DividerII); + } + }; + if ix == current_index { + section.push(TabBarElement::PrefixA); + section.push(TabBarElement::TabA(ix, l.to_string())); + section.push(TabBarElement::PostfixA); + } else { + section.push(TabBarElement::PrefixI); + section.push(TabBarElement::TabI(ix, l.to_string())); + section.push(TabBarElement::PostfixI); + } + section + }) + .collect(); + Some(r) + } else { + None + } +} + +fn factor_tab_bar_sequence( + available_width: usize, + tab_records: &Vec<&TabLabel>, + current_index: usize, + config: &TabTheme, +) -> Vec { + //################################################################## + //## Case 0: less available width than 2 units -> just show nothing + //################################################################## + if available_width < 2 { + return vec![]; + } + //## Not case 0. Let's continue... + + let tab_num = tab_records.len(); + let labels_are_indexed = tab_num > 1; + let extra_width = config.inference.tab_divider_length * (tab_num - 1) + + config.inference.active_tab_extra_width + + config.inference.inactive_tab_extra_width * (tab_num - 1); + + let all_labels_as_long: Vec<_> = tab_records + .iter() + .enumerate() + .map(|(ix, &r)| { + if labels_are_indexed { + format!("{}: {}", ix + 1, &r.long) + } else { + String::from(&r.long) + } + }) + .collect(); + + //############################################################## + //## Case 1: all tabs fit with long form label + //############################################################## + if let Some(result) = check_fit_and_build_sequence( + &all_labels_as_long, + available_width, + extra_width, + current_index, + ) { + return result; + } + //## Not case 1. Let's continue... + + //############################################################## + //## Case 2: single tab that does _not_ fit in long form + //############################################################## + if tab_num == 1 { + let label = String::from(&tab_records[0].short); + if label.width() as isize + <= available_width as isize - config.inference.active_tab_extra_width as isize + { + return vec![ + TabBarElement::PrefixA, + TabBarElement::TabA(0, label), + TabBarElement::PostfixA, + ]; + } + if label.width() <= available_width { + return vec![TabBarElement::TabA(0, label)]; + } + return vec![TabBarElement::TabA( + 0, + format!("{}…", label.trunc(available_width - 1)), + )]; + } + //## Not case 2. Let's continue... + + let all_labels_as_short: Vec<_> = tab_records + .iter() + .enumerate() + .map(|(ix, &r)| { + if labels_are_indexed { + format!("{}: {}", ix + 1, &r.short) + } else { + String::from(&r.short) + } + }) + .collect(); + + let all_labels_as_short_except_current: Vec = all_labels_as_short + .iter() + .zip(all_labels_as_long.iter()) + .enumerate() + .map(|(ix, (short, long))| { + if ix == current_index { + long.to_string() + } else { + short.to_string() + } + }) + .collect(); + + //#################################################################### + //## Case 3: active tab fits in long form and all others in short form + //##################################################################### + if let Some(result) = check_fit_and_build_sequence( + &all_labels_as_short_except_current, + available_width, + extra_width, + current_index, + ) { + return result; + } + //## Not case 3. Let's continue... + + //#################################################################### + //## Case 4: more than one tab and the tabs don't fit at once + //## (=> we need scrolling) + //#################################################################### + let scroll_tags_width = config.inference.calc_scroll_tags_width(tab_num); + let scrollable_width = if scroll_tags_width > available_width { + 0 + } else { + available_width - scroll_tags_width + }; + + let carousel_labels = if all_labels_as_long[current_index].width() + + config.inference.active_tab_extra_width + <= scrollable_width + { + //## Case 4a: active tab fits in long form between scroll tags + &all_labels_as_short_except_current + } else if all_labels_as_short[current_index].width() + config.inference.active_tab_extra_width + <= scrollable_width + { + //## Case 4b: active tab fits only in short form between scroll tags + &all_labels_as_short + } else if all_labels_as_short[current_index].width() <= available_width { + //## Case 4c: active tab fits only without scroll tags and pre- and postfix + return vec![TabBarElement::TabA( + current_index, + String::from(&all_labels_as_short[current_index]), + )]; + } else { + //## Case 4d: active only fits when shortened with ellipsis + return vec![TabBarElement::TabA( + current_index, + format!( + "{}…", + &all_labels_as_short[current_index].trunc(available_width - 1) + ), + )]; + }; + + //#################################################################### + //## Case 4a/b: Sub-set of tabs shown with scroll tags + //#################################################################### + // Preparing mutable variables for the following looping alogorithm. + // If we end up here, we know that at least the scroll tags and the label of the + // current tab fit into the available space. + let mut remaining_width = scrollable_width as isize; + let mut tab_bar_elements: Vec = Vec::new(); + let mut ix_front = if current_index >= 1 { + Some(current_index - 1) + } else { + None + }; + let mut ix_back = Some(current_index); + let mut try_to_take_next_from_front = false; + let mut remaining_left: Option = None; + let mut remaining_right: Option = None; + + // Loop until the visible space of the tab-bar is full or until we run out of tabs + loop { + // Pick the next tab alternating from before and after the current tab as long as + // another tab is available from the respective side. + let (taking_from_front, ix_to_take) = match (ix_front, ix_back) { + (Some(ixf), Some(ixb)) => ( + try_to_take_next_from_front, + if try_to_take_next_from_front { + ixf + } else { + ixb + }, + ), + (Some(ixf), None) => (true, ixf), //only tabs before current to be added + (None, Some(ixb)) => (false, ixb), //only tabs after current to be added + (None, None) => break, //for both sides hold, either there are no more + //tabs or the next tab does not fit anymore + }; + try_to_take_next_from_front = !try_to_take_next_from_front; + let is_active_tab = ix_to_take == current_index; + + // Pick this loop's tab that shall be added to the tab-bar if it still fits + if let Some(label_to_add) = carousel_labels.get(ix_to_take) { + let needed_width = (if is_active_tab { + config.inference.active_tab_extra_width + } else { + config.inference.inactive_tab_extra_width + config.inference.tab_divider_length + } + label_to_add.width()) as isize; + if needed_width <= remaining_width { + // This next tab still fits into the available space; let's add it... + remaining_width -= needed_width; + if taking_from_front { + // Next tab is added to the front. + // This cannot be the active tab because we start adding from the back side. + assert!(!is_active_tab); + // Add the tab-bar elements... + tab_bar_elements.insert( + 0, + if ix_to_take + 1 == current_index { + TabBarElement::DividerIA + } else { + TabBarElement::DividerII + }, + ); + tab_bar_elements.insert(0, TabBarElement::PostfixI); + tab_bar_elements + .insert(0, TabBarElement::TabI(ix_to_take, label_to_add.to_string())); + tab_bar_elements.insert(0, TabBarElement::PrefixI); + // Prepare the next loop... + if ix_to_take > 0 { + ix_front = Some(ix_to_take - 1); + } else { + ix_front = None; + } + } else { + // Next tab is added to the back. + // Add the tab-bar elements... + if !is_active_tab { + tab_bar_elements.push( + if ix_to_take > 0 && ix_to_take - 1 == current_index { + TabBarElement::DividerAI + } else { + TabBarElement::DividerII + }, + ); + } + if is_active_tab { + tab_bar_elements.push(TabBarElement::PrefixA); + tab_bar_elements + .push(TabBarElement::TabA(ix_to_take, label_to_add.to_string())); + tab_bar_elements.push(TabBarElement::PostfixA); + } else { + tab_bar_elements.push(TabBarElement::PrefixI); + tab_bar_elements + .push(TabBarElement::TabI(ix_to_take, label_to_add.to_string())); + tab_bar_elements.push(TabBarElement::PostfixI); + } + // Prepare the next loop... + if ix_to_take < tab_num - 1 { + ix_back = Some(ix_to_take + 1); + } else { + ix_back = None; + } + } + } else { + // This next tab does NOT fit anymore. + // This must not be the active tab though, as this case was handeled before. + assert!(!is_active_tab); + // Break the processing of the current side, + // and store the number of remaining tabs for the respective scroll tag. + if taking_from_front { + remaining_left = Some(ix_to_take + 1); + ix_front = None; + } else { + remaining_right = Some(tab_num - ix_to_take); + ix_back = None; + } + } + } else { + // The index for the label to pick must always exists. + // Otherwise, there would be a logical bug in this algorithm. + unreachable!(); + } + } // End of loop building up the scrollable tab-bar. + // The visible elements for the scrollable bar are composed. + // Only the scroll-tags on each end of the bar and the padding are missing. + // Let's add them... + // left scroll tag... + tab_bar_elements.insert(0, TabBarElement::ScrollFrontPostfix); + tab_bar_elements.insert( + 0, + TabBarElement::ScrollFront(if let Some(remains) = remaining_left { + remains + } else { + 0 + }), + ); + tab_bar_elements.insert(0, TabBarElement::ScrollFrontPrefix); + // padding... + if remaining_width > 0 { + tab_bar_elements.push(TabBarElement::PaddingPrefix); + if remaining_width > 2 { + tab_bar_elements.push(TabBarElement::PaddingFill(remaining_width as usize - 2)); + } + if remaining_width > 1 { + tab_bar_elements.push(TabBarElement::PaddingPostfix); + } + } + // right scroll tag... + tab_bar_elements.push(TabBarElement::ScrollBackPrefix); + tab_bar_elements.push(TabBarElement::ScrollBack( + if let Some(remains) = remaining_right { + remains + } else { + 0 + }, + )); + tab_bar_elements.push(TabBarElement::ScrollBackPostfix); + + // That was it... + tab_bar_elements +} + +fn factor_tab_bar_spans_from_sequence<'a>( + tab_bar_elements: Vec, + config: &TabTheme, +) -> Vec> { + tab_bar_elements + .into_iter() + .map(|e| match e { + TabBarElement::PrefixA => { + Span::styled(String::from(&config.chars.prefix_a), config.styles.prefix_a) + } + TabBarElement::PostfixA => Span::styled( + String::from(&config.chars.postfix_a), + config.styles.postfix_a, + ), + TabBarElement::TabA(_ix, s) => Span::styled(s, config.styles.tab_a), + TabBarElement::PrefixI => { + Span::styled(String::from(&config.chars.prefix_i), config.styles.prefix_i) + } + TabBarElement::PostfixI => Span::styled( + String::from(&config.chars.postfix_i), + config.styles.postfix_i, + ), + TabBarElement::TabI(_ix, s) => Span::styled(s, config.styles.tab_i), + TabBarElement::DividerII => Span::styled( + String::from(&config.chars.divider), + config.styles.divider_ii, + ), + TabBarElement::DividerAI => Span::styled( + String::from(&config.chars.divider), + config.styles.divider_ai, + ), + TabBarElement::DividerIA => Span::styled( + String::from(&config.chars.divider), + config.styles.divider_ia, + ), + TabBarElement::ScrollFront(s) => Span::styled( + format!( + "{}{}{}", + &config.chars.scroll_front_prestring, s, &config.chars.scroll_front_poststring, + ), + config.styles.scroll_front, + ), + TabBarElement::ScrollFrontPrefix => Span::styled( + String::from(&config.chars.scroll_front_prefix), + config.styles.scroll_front_prefix, + ), + TabBarElement::ScrollFrontPostfix => Span::styled( + String::from(&config.chars.scroll_front_postfix), + config.styles.scroll_front_postfix, + ), + TabBarElement::ScrollBack(s) => Span::styled( + format!( + "{}{}{}", + &config.chars.scroll_back_prestring, s, &config.chars.scroll_back_poststring, + ), + config.styles.scroll_back, + ), + TabBarElement::ScrollBackPrefix => Span::styled( + String::from(&config.chars.scroll_back_prefix), + config.styles.scroll_back_prefix, + ), + TabBarElement::ScrollBackPostfix => Span::styled( + String::from(&config.chars.scroll_back_postfix), + config.styles.scroll_back_postfix, + ), + TabBarElement::PaddingPrefix => Span::styled( + String::from(config.chars.padding_prefix), + config.styles.padding_prefix, + ), + TabBarElement::PaddingPostfix => Span::styled( + String::from(config.chars.padding_postfix), + config.styles.padding_postfix, + ), + TabBarElement::PaddingFill(n) => Span::styled( + String::from(config.chars.padding_fill).repeat(n), + config.styles.padding_fill, + ), + }) + .collect() +} + +pub fn factor_tab_bar_spans<'a>( + available_width: usize, + tab_paths: &[&'a Path], + current_index: usize, + config: &TabTheme, +) -> Vec> { + let reps: Vec = tab_paths.iter().map(|p| TabLabel::from_path(p)).collect(); + let rep_refs: Vec<&TabLabel> = reps.iter().collect(); + let tab_bar_elements = + factor_tab_bar_sequence(available_width, &rep_refs, current_index, config); + let tab_bar = factor_tab_bar_spans_from_sequence(tab_bar_elements, config); + tab_bar +} + +#[cfg(test)] +mod tests_facator_tab_bar_sequence { + use crate::config::clean::theme::tab::TabTheme; + use crate::config::raw::theme::tab::TabThemeRaw; + + use super::{factor_tab_bar_sequence, TabBarElement, TabLabel}; + + fn test_config() -> TabTheme { + let raw = TabThemeRaw { + ..Default::default() + }; + TabTheme::from(raw) + } + + #[test] + /// If there is less available width than 2, + /// an empty tab line is returned. + fn too_little_available_width_for_anything() { + // Given + let tabs = vec![TabLabel { + long: "/foo/a".to_string(), + short: "a".to_string(), + }]; + // When + let elements = factor_tab_bar_sequence(1, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!(Vec::::new(), elements) + } + + #[test] + /// All tabs - even a single one - has a prefix and a postfix. + /// In this test case, a single tab just fits exactly to the last character + /// (`[/foo/a]` is exactly a width of 8). + fn one_tab_that_fits() { + // Given + let tabs = vec![TabLabel { + long: "/foo/a".to_string(), + short: "a".to_string(), + }]; + // When + let elements = factor_tab_bar_sequence(8, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![ + TabBarElement::PrefixA, + TabBarElement::TabA(0, "/foo/a".to_string()), + TabBarElement::PostfixA, + ], + elements + ) + } + + #[test] + /// If there's only a single tab, and the long label does not fit, the short version + /// is used instead. + /// In this test case, the available width is too short by one unit, so the short + /// version of the label will be shown + /// (`[a]`). + fn one_tab_that_fits_only_in_short_form() { + // Given + let tabs = vec![TabLabel { + long: "/foo/a".to_string(), + short: "a".to_string(), + }]; + // When + let elements = factor_tab_bar_sequence(7, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![ + TabBarElement::PrefixA, + TabBarElement::TabA(0, "a".to_string()), + TabBarElement::PostfixA, + ], + elements + ) + } + + #[test] + /// If there's only a single tab, and the long label does not fit, + /// and the short version does not fit with the prefix and postfix, + /// the prefix and postfix are omitted. + /// In this test case, the available width is too short by one unit + /// to use the short form with pre- and post-fix. + /// (`aaaaaaa`). + fn one_tab_that_fits_only_in_short_form_without_prepostfixes() { + // Given + let tabs = vec![TabLabel { + long: "/foo/a".to_string(), + short: "aaaaaaa".to_string(), + }]; + // When + let elements = factor_tab_bar_sequence(7, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![TabBarElement::TabA(0, "aaaaaaa".to_string()),], + elements + ) + } + + #[test] + /// If there's only a single tab, and the long label does not fit, + /// and the short version does not fit even without the prefix and postfix, + /// the prefix and postfix are omitted and the label is shortened with an ellipsis. + /// In this test case, the available width is too short by one unit + /// to use the short form without pre- and post-fix. + /// (`aaaaa…`). + fn case_2c_one_tab_that_does_not_fit_unless_further_shortened() { + // Given + let tabs = vec![TabLabel { + long: "/foo/a".to_string(), + short: "aaaaaaa".to_string(), + }]; + // When + let elements = factor_tab_bar_sequence(6, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![TabBarElement::TabA(0, "aaaaa…".to_string()),], + elements + ) + } + + #[test] + /// If there are multiple tabs, a delimiter is put between each two + /// adjacent tabs and the label of each tab is prefixed with the tab's index, + /// starting counting at 1. + /// In this test case, the tab line just fits exactly into the available width + /// (`[1: /foo/a]| 2: /foo/b ` has exactly a width of 23 ) + fn case_1_two_tabs_that_fit() { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/a".to_string(), + short: "a".to_string(), + }, + TabLabel { + long: "/foo/b".to_string(), + short: "b".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence(23, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![ + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: /foo/b".to_string()), + TabBarElement::PostfixI, + ], + elements + ) + } + + #[test] + /// If there are multiple tabs and not all fit with their long-form labels, + /// the inactive tabs are shown with their short-form labels. + /// In this test case, the tab line's available width is just one unit to short for the long + /// form, so the inactive tab will be shown with its shortened label + /// (`[1: /foo/a]| 2: b `). + fn case_3_two_tabs_fit_shortened() { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/a".to_string(), + short: "a".to_string(), + }, + TabLabel { + long: "/foo/b".to_string(), + short: "b".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence(22, &tabs.iter().collect(), 0, &test_config()); + // Then + assert_eq!( + vec![ + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: b".to_string()), + TabBarElement::PostfixI, + ], + elements + ) + } + + #[test] + /// If there are multiple tabs and scrolling is needed, + /// when the long form of the active tab does not fit in between the scroll tags, + /// the short form is used also for the current tab. + /// + /// This test would result in this tab-bar (dots are padding): + /// `«0 [1: long_name_a]···· 1»` + fn multiple_tabs_but_active_one_does_only_fit_in_short_form_with_scroll_tags() { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/long_name_a".to_string(), + short: "long_name_a".to_string(), + }, + TabLabel { + long: "/foo/long_name_b".to_string(), + short: "long_name_b".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence( + 16 + 3 + 2 + 6 - 1, //label + tab-index + pre/postfix + scroll tags - 1 to make it not fit + &tabs.iter().collect(), + 0, + &test_config(), + ); + // Then + assert_eq!( + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: long_name_a".to_string()), + TabBarElement::PostfixA, + TabBarElement::PaddingPrefix, + TabBarElement::PaddingFill(2), + TabBarElement::PaddingPostfix, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + elements + ) + } + + #[test] + /// If there are multiple tabs and scrolling is needed, + /// when even the short form of the active tab does not fit in between the scroll tags, + /// the scroll tags and the tab's pre- and postfix are omitted. + /// + /// This test would result in this tab-bar (dots are padding): + /// `1: long_name_a` + fn multiple_tabs_but_active_one_does_not_fit_in_short_form_with_scroll_tags() { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/long_name_a".to_string(), + short: "long_name_a".to_string(), + }, + TabLabel { + long: "/foo/long_name_b".to_string(), + short: "long_name_b".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence( + 21, //label + tab-index + pre/postfix + scroll tags - 1 to make it not fit + &tabs.iter().collect(), + 0, + &test_config(), + ); + // Then + assert_eq!( + vec![TabBarElement::TabA(0, "1: long_name_a".to_string()),], + elements + ) + } + + #[test] + /// If there are multiple tabs and scrolling is needed, + /// when even the short form alone does not fit in the available width, + /// only the fitting part of the short form with an ellipsis is shown. + /// + /// This test would result in this tab-bar (dots are padding): + /// `1: long…` + fn multiple_tabs_but_active_one_does_not_fit_in_short_even_without_scroll_tags() { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/long_name_a".to_string(), + short: "long_name_a".to_string(), + }, + TabLabel { + long: "/foo/long_name_b".to_string(), + short: "long_name_b".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence( + 8, //label + tab-index + pre/postfix + scroll tags - 1 to make it not fit + &tabs.iter().collect(), + 0, + &test_config(), + ); + // Then + assert_eq!( + vec![TabBarElement::TabA(0, "1: long…".to_string()),], + elements + ) + } + + /// Scenario, used for the next four test cases. + fn _scenario_three_tabs( + available_width: usize, + current_index: usize, + expected: Vec, + ) { + // Given + let tabs = vec![ + TabLabel { + long: "/foo/long_name_a".to_string(), + short: "long_name_a".to_string(), + }, + TabLabel { + long: "/foo/long_name_b".to_string(), + short: "long_name_b".to_string(), + }, + TabLabel { + long: "/foo/long_name_c".to_string(), + short: "long_name_c".to_string(), + }, + ]; + // When + let elements = factor_tab_bar_sequence( + available_width, + &tabs.iter().collect(), + current_index, + &test_config(), + ); + // Then + assert_eq!(expected, elements) + } + + #[test] + /// When one or more tabs don't fit anymore into the bar, “scroll tags” are added to both ends, + /// showing the number of hidden tabs for each side. + /// + /// The bar starts with the current tab, and then adds more tabs one by one to each + /// side alternating. The first inacive tab added is added to the front. + /// + /// In this test case, there are three tabs with the first on active which would produce a + /// tab-line like this if there is enough space: + /// `[1: /foo/long_name_a]| 2: long_name_b | 3: long_name_c ` (width 55) + /// + /// But we choose a available width which is to short. + /// It's just long enough to fit the two tabs and the scroll tags. + /// So, this example results in a tab-bar like this: + /// `«0·[1: /foo/long_name_a]|·2: long_name_b··1»` (width 44) + fn three_tabs_with_overflow_on_the_right_w_no_padding() { + _scenario_three_tabs( + 44, + 0, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/long_name_a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: long_name_b".to_string()), + TabBarElement::PostfixI, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + ) + } + + // When scroll tags are shown, the right scroll tag stays right-aligned. + // The gap between the rightmost tab and the right scroll tab is filled with a padding section. + // + // In this test case, we use the same tab-situation as before, but the available + // width is only one unit to short for all three tabs to be shown (54 units). + // So, there will be a gap of 10 units to be filled with padding. + // Like the other elements, also the padding has a prefix and postfix. + // The middle (“fill”) character is repeated to fill the gap. + #[test] + fn three_tabs_with_overflow_on_the_right_w_long_padding() { + _scenario_three_tabs( + 54, + 0, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/long_name_a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: long_name_b".to_string()), + TabBarElement::PostfixI, + TabBarElement::PaddingPrefix, + TabBarElement::PaddingFill(8), + TabBarElement::PaddingPostfix, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + ) + } + + // The padding section has a prefix, a postfix, and a fill-sequence in the middle. + // However, this is only possible when the padding has at least a width of 3 chars. + // If the padding is only one char wide, only the prefix is shown. + // If the padding is only two chars wide, only the prefix and postfix are shown. + // + // In the next two test cases, we use the same tab-situation as before, but the available + // width has a value so that the padding is only one or two characters long. + #[test] + fn three_tabs_with_overflow_on_the_right_w_one_char_padding() { + _scenario_three_tabs( + 45, + 0, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/long_name_a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: long_name_b".to_string()), + TabBarElement::PostfixI, + TabBarElement::PaddingPrefix, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + ) + } + + #[test] + fn three_tabs_with_overflow_on_the_right_w_two_char_padding() { + _scenario_three_tabs( + 46, + 0, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixA, + TabBarElement::TabA(0, "1: /foo/long_name_a".to_string()), + TabBarElement::PostfixA, + TabBarElement::DividerAI, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: long_name_b".to_string()), + TabBarElement::PostfixI, + TabBarElement::PaddingPrefix, + TabBarElement::PaddingPostfix, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + ) + } + + #[test] + /// When scrolling is needed, tabs are added alternating to the left and to the right of the + /// current tab. The algorithm starts on the left. So, if there are three tabs, and the middle + /// one is the current one, and there is only enough space for two tabs, the first and the + /// second (current) tab will be shown. The tab right of the current tab will be the one that + /// is hidden. + fn adding_tabs_to_front_has_precedence_over_adding_to_right() { + _scenario_three_tabs( + 44, + 1, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(0), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixI, + TabBarElement::TabI(0, "1: long_name_a".to_string()), + TabBarElement::PostfixI, + TabBarElement::DividerIA, + TabBarElement::PrefixA, + TabBarElement::TabA(1, "2: /foo/long_name_b".to_string()), + TabBarElement::PostfixA, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(1), + TabBarElement::ScrollBackPostfix, + ], + ) + } + + #[test] + /// This tests uses the same three-tab screnario as the ones before, but uses the last tab as + /// the current one. The available space is again just enough for two tabs. + /// As a result, the first tab will now be hidden and the front scroll tag will indicate that + /// hidden tab. + fn overflowing_on_the_left_side() { + _scenario_three_tabs( + 44, + 2, + vec![ + TabBarElement::ScrollFrontPrefix, + TabBarElement::ScrollFront(1), + TabBarElement::ScrollFrontPostfix, + TabBarElement::PrefixI, + TabBarElement::TabI(1, "2: long_name_b".to_string()), + TabBarElement::PostfixI, + TabBarElement::DividerIA, + TabBarElement::PrefixA, + TabBarElement::TabA(2, "3: /foo/long_name_c".to_string()), + TabBarElement::PostfixA, + TabBarElement::ScrollBackPrefix, + TabBarElement::ScrollBack(0), + TabBarElement::ScrollBackPostfix, + ], + ) + } +} diff --git a/src/ui/views/tui_folder_view.rs b/src/ui/views/tui_folder_view.rs index c9ffebac1..68b94ad02 100644 --- a/src/ui/views/tui_folder_view.rs +++ b/src/ui/views/tui_folder_view.rs @@ -11,7 +11,7 @@ use crate::preview::preview_file::PreviewFileState; use crate::ui; use crate::ui::widgets::{ TuiDirList, TuiDirListDetailed, TuiDirListLoading, TuiFilePreview, TuiFooter, TuiMessage, - TuiTabBar, TuiTopBar, + TuiTopBar, }; use crate::ui::PreviewArea; @@ -109,24 +109,6 @@ impl<'a> TuiFolderView<'a> { height: 1, } } - - pub fn tab_area(&self, area: &Rect, tabs_width: usize) -> Rect { - // render tabs - let tabs_width = tabs_width as u16; - let tab_width = if tabs_width > area.width { - area.width - } else { - tabs_width - }; - let topbar_x = area.width.saturating_sub(tab_width); - - Rect { - x: topbar_x, - y: area.top(), - width: tab_width, - height: 1, - } - } } impl<'a> Widget for TuiFolderView<'a> { @@ -236,11 +218,7 @@ impl<'a> Widget for TuiFolderView<'a> { } let topbar_area = Self::header_area(&area); - TuiTopBar::new(self.context, curr_tab_cwd).render(topbar_area, buf); - - // render tabs - let tab_area = self.tab_area(&area, self.context.tab_context_ref().tab_area_width()); - TuiTabBar::new(self.context.tab_context_ref()).render(tab_area, buf); + TuiTopBar::new(self.context).render(topbar_area, buf); } } diff --git a/src/ui/views/tui_hsplit_view.rs b/src/ui/views/tui_hsplit_view.rs index 57614efc3..6244e0912 100644 --- a/src/ui/views/tui_hsplit_view.rs +++ b/src/ui/views/tui_hsplit_view.rs @@ -5,9 +5,7 @@ use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap}; use crate::context::AppContext; -use crate::ui::widgets::{TuiDirListDetailed, TuiFooter, TuiTabBar, TuiTopBar}; - -const TAB_VIEW_WIDTH: u16 = 15; +use crate::ui::widgets::{TuiDirListDetailed, TuiFooter, TuiTopBar}; pub struct TuiHSplitView<'a> { pub context: &'a AppContext, @@ -113,20 +111,7 @@ impl<'a> Widget for TuiHSplitView<'a> { width: topbar_width, height: 1, }; - TuiTopBar::new(self.context, curr_tab.cwd()).render(rect, buf); - - // render tabs - if self.context.tab_context_ref().len() > 1 { - let topbar_width = area.width.saturating_sub(TAB_VIEW_WIDTH); - - let rect = Rect { - x: topbar_width, - y: 0, - width: TAB_VIEW_WIDTH, - height: 1, - }; - TuiTabBar::new(self.context.tab_context_ref()).render(rect, buf); - } + TuiTopBar::new(self.context).render(rect, buf); } let other_tab_index = if tab_index % 2 == 0 { diff --git a/src/ui/views/tui_worker_view.rs b/src/ui/views/tui_worker_view.rs index 3140d89b4..8ed324961 100644 --- a/src/ui/views/tui_worker_view.rs +++ b/src/ui/views/tui_worker_view.rs @@ -22,8 +22,7 @@ impl<'a> Widget for TuiWorkerView<'a> { } let rect = Rect { height: 1, ..area }; - let curr_tab = self.context.tab_context_ref().curr_tab_ref(); - TuiTopBar::new(self.context, curr_tab.cwd()).render(rect, buf); + TuiTopBar::new(self.context).render(rect, buf); let rect = Rect { x: 0, diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 1871da6df..5c86b1dde 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -7,7 +7,6 @@ mod tui_help; mod tui_menu; mod tui_message; mod tui_prompt; -mod tui_tab; mod tui_text; mod tui_topbar; mod tui_worker; @@ -21,7 +20,6 @@ pub use self::tui_help::{get_keymap_table, TuiHelp}; pub use self::tui_menu::TuiMenu; pub use self::tui_message::TuiMessage; pub use self::tui_prompt::TuiPrompt; -pub use self::tui_tab::TuiTabBar; pub use self::tui_text::TuiMultilineText; pub use self::tui_topbar::TuiTopBar; pub use self::tui_worker::TuiWorker; diff --git a/src/ui/widgets/tui_tab.rs b/src/ui/widgets/tui_tab.rs deleted file mode 100644 index f3315fe88..000000000 --- a/src/ui/widgets/tui_tab.rs +++ /dev/null @@ -1,62 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Widget, Wrap}; - -use crate::config::clean::app::tab::TabBarDisplayMode; -use crate::context::TabContext; -use crate::util::format::format_tab_bar_title_string; -use crate::THEME_T; - -pub struct TuiTabBar<'a> { - context: &'a TabContext, -} - -impl<'a> TuiTabBar<'a> { - pub fn new(context: &'a TabContext) -> Self { - Self { context } - } -} - -impl<'a> Widget for TuiTabBar<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let regular_style = THEME_T.tabs.inactive.as_style(); - let selected_style = THEME_T.tabs.active.as_style(); - - let index = self.context.index; - let tab_order = self.context.tab_order.as_slice(); - - let mut spans_vec = vec![]; - for (i, tab_id) in tab_order.iter().enumerate() { - let curr_style = if i == index { - selected_style - } else { - regular_style - }; - if let Some(curr_tab) = self.context.tab_ref(tab_id) { - let preview_text = match self.context.display.mode { - TabBarDisplayMode::Number => format!(" {} ", i + 1), - TabBarDisplayMode::Directory => format_tab_bar_title_string( - self.context.display.max_len, - None, - curr_tab.tab_title(), - ), - TabBarDisplayMode::All => format_tab_bar_title_string( - self.context.display.max_len, - Some(i), - curr_tab.tab_title(), - ), - }; - - spans_vec.push(Span::styled(preview_text, curr_style)); - spans_vec.push(Span::styled(" | ", regular_style)); - } - } - - spans_vec.pop(); - - Paragraph::new(Line::from(spans_vec)) - .wrap(Wrap { trim: true }) - .render(area, buf); - } -} diff --git a/src/ui/widgets/tui_topbar.rs b/src/ui/widgets/tui_topbar.rs index 10ed13157..bcf11da8f 100644 --- a/src/ui/widgets/tui_topbar.rs +++ b/src/ui/widgets/tui_topbar.rs @@ -1,75 +1,30 @@ -use std::path::Component; -use std::path::Path; - use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; -use unicode_width::UnicodeWidthStr; +use tab_list_builder::factor_tab_bar_spans; use crate::context::AppContext; -use crate::{HOME_DIR, HOSTNAME, USERNAME}; +use crate::ui::tab_list_builder; +use crate::THEME_T; +use crate::{HOSTNAME, USERNAME}; pub struct TuiTopBar<'a> { pub context: &'a AppContext, - path: &'a Path, } impl<'a> TuiTopBar<'a> { - pub fn new(context: &'a AppContext, path: &'a Path) -> Self { - Self { context, path } + pub fn new(context: &'a AppContext) -> Self { + Self { context } } } impl<'a> Widget for TuiTopBar<'a> { fn render(self, area: Rect, buf: &mut Buffer) { - let path_style = Style::default() - .fg(Color::LightBlue) - .add_modifier(Modifier::BOLD); - - let mut ellipses = None; - let mut curr_path_str = self.path.to_string_lossy().into_owned(); - - let tab_width = self.context.tab_context_ref().tab_area_width(); let name_width = USERNAME.as_str().len() + HOSTNAME.as_str().len() + 2; - if tab_width + name_width > area.width as usize { - curr_path_str = "".to_owned(); - } else if curr_path_str.width() > area.width as usize - tab_width - name_width { - if let Some(s) = self.path.file_name() { - let mut short_path = String::new(); - let mut components: Vec = self.path.components().collect(); - components.pop(); - - for component in components { - match component { - Component::RootDir => short_path.push('/'), - Component::Normal(s) => { - let ch = s.to_string_lossy().chars().next().unwrap(); - short_path.push(ch); - short_path.push('/'); - } - _ => {} - } - } - ellipses = Some(Span::styled(short_path, path_style)); - curr_path_str = s.to_string_lossy().into_owned(); - } - } - if self - .context - .config_ref() - .display_options_ref() - .tilde_in_titlebar() - { - if let Some(home_dir) = HOME_DIR.as_ref() { - let home_dir_str = home_dir.to_string_lossy().into_owned(); - curr_path_str = curr_path_str.replace(&home_dir_str, "~"); - } - } - let username_style = if USERNAME.as_str() == "root" { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { @@ -78,19 +33,26 @@ impl<'a> Widget for TuiTopBar<'a> { .add_modifier(Modifier::BOLD) }; - let mut text = vec![ + let mut top_bar_spans = vec![ Span::styled(USERNAME.as_str(), username_style), Span::styled("@", username_style), Span::styled(HOSTNAME.as_str(), username_style), Span::styled(" ", username_style), ]; - if let Some(s) = ellipses { - text.push(s); + let available_tab_width = area.width as usize - name_width; + let mut paths = Vec::new(); + let tabs = self.context.tab_context_ref().tab_refs_in_order(); + for tab in tabs { + paths.push(tab.cwd()); } - - text.extend([Span::styled(curr_path_str, path_style)]); - - Paragraph::new(Line::from(text)).render(area, buf); + let tab_bar_spans = factor_tab_bar_spans( + available_tab_width, + &paths, + self.context.tab_context_ref().index, + &THEME_T.tabs, + ); + top_bar_spans.extend(tab_bar_spans); + Paragraph::new(Line::from(top_bar_spans)).render(area, buf); } } diff --git a/src/util/format.rs b/src/util/format.rs index 3a9bc541f..3079840f8 100644 --- a/src/util/format.rs +++ b/src/util/format.rs @@ -26,27 +26,3 @@ pub fn mtime_to_string(mtime: time::SystemTime) -> String { let datetime: chrono::DateTime = mtime.into(); datetime.format(MTIME_FORMATTING).to_string() } - -pub fn format_tab_bar_title_string( - max_len: usize, - number: Option, - title: impl Into, -) -> String { - let title: String = title.into(); - - if let Some(number) = number { - if title.len() > max_len { - format!( - "{}: {}…", - number + 1, - title.chars().take(max_len - 1).collect::() - ) - } else { - format!("{}: {}", number + 1, title) - } - } else if title.len() > max_len { - format!("{}…", title.chars().take(max_len - 1).collect::()) - } else { - title.to_string() - } -} diff --git a/src/util/style.rs b/src/util/style.rs index 2f87a9bb9..96d111c71 100644 --- a/src/util/style.rs +++ b/src/util/style.rs @@ -5,6 +5,23 @@ use crate::util::unix; use crate::THEME_T; +/// Allows patching a ratatui style if there is `Some` style, otherwise returns a clone. +pub trait PathStyleIfSome { + fn patch_optionally(&self, other: Option