Skip to main content

smux/
ui.rs

1use std::env;
2
3use crate::config::{Config, IconColors, IconMode};
4
5const SESSION_ICON: &str = "";
6const DIRECTORY_ICON: &str = "󰉋";
7const TEMPLATE_ICON: &str = "󰙅";
8const PROJECT_ICON: &str = "󰏖";
9const ANSI_RESET: &str = "\x1b[0m";
10const ANSI_BOLD: &str = "\x1b[1m";
11const ANSI_RED: &str = "\x1b[31m";
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub struct DisplayStyle {
15    icons_enabled: bool,
16    icon_mode: IconMode,
17    icon_colors: IconColors,
18}
19
20impl DisplayStyle {
21    pub fn from_config(config: Option<&Config>) -> Self {
22        let (icon_mode, icon_colors) = config.map_or_else(
23            || (IconMode::Auto, IconColors::default()),
24            |config| (config.settings.icons, config.settings.icon_colors),
25        );
26        Self::new(icon_mode, icon_colors)
27    }
28
29    pub fn from_icon_mode(icon_mode: IconMode) -> Self {
30        Self::new(icon_mode, IconColors::default())
31    }
32
33    pub fn new(icon_mode: IconMode, icon_colors: IconColors) -> Self {
34        let icons_enabled = match icon_mode {
35            IconMode::Always => true,
36            IconMode::Never => false,
37            IconMode::Auto => terminal_supports_icons(),
38        };
39
40        Self {
41            icons_enabled,
42            icon_mode,
43            icon_colors,
44        }
45    }
46
47    pub fn icons_enabled(self) -> bool {
48        self.icons_enabled
49    }
50
51    pub fn icon_mode(self) -> IconMode {
52        self.icon_mode
53    }
54
55    pub fn icon_colors(self) -> IconColors {
56        self.icon_colors
57    }
58
59    pub fn session_label(self, value: &str) -> String {
60        self.label(SESSION_ICON, self.icon_colors.session, "session", value)
61    }
62
63    pub fn current_session_label(self, value: &str) -> String {
64        if self.icons_enabled {
65            format!(
66                "{ANSI_BOLD}\x1b[38;5;{color}m{icon}{ANSI_RESET}  {ANSI_BOLD}\x1b[38;5;{color}m{value}{ANSI_RESET}",
67                color = self.icon_colors.session,
68                icon = SESSION_ICON,
69            )
70        } else {
71            format!("current  {value}")
72        }
73    }
74
75    pub fn directory_label(self, value: &str) -> String {
76        self.label(DIRECTORY_ICON, self.icon_colors.directory, "dir", value)
77    }
78
79    pub fn template_label(self, value: &str) -> String {
80        self.label(TEMPLATE_ICON, self.icon_colors.template, "template", value)
81    }
82
83    pub fn project_label(self, value: &str) -> String {
84        self.label(PROJECT_ICON, self.icon_colors.project, "project", value)
85    }
86
87    pub fn invalid_project_label(self, name: &str, error: &str) -> String {
88        let summary = single_line(error);
89        if self.icons_enabled {
90            format!(
91                "{ANSI_RED}{PROJECT_ICON}{ANSI_RESET}  {ANSI_RED}{name}{ANSI_RESET}  {ANSI_RED}[invalid: {summary}]{ANSI_RESET}"
92            )
93        } else {
94            format!("invalid  {name} [{summary}]")
95        }
96    }
97
98    fn label(self, icon: &str, color: u8, text: &str, value: &str) -> String {
99        if self.icons_enabled {
100            format!("\x1b[38;5;{color}m{icon}{ANSI_RESET}  {value}")
101        } else {
102            format!("{text:<8} {value}")
103        }
104    }
105}
106
107pub fn terminal_supports_icons() -> bool {
108    if matches!(env::var("TERM"), Ok(term) if term == "dumb") {
109        return false;
110    }
111
112    match locale_value() {
113        Some(locale) => {
114            let locale = locale.to_string_lossy().to_ascii_lowercase();
115            locale.contains("utf-8") || locale.contains("utf8")
116        }
117        None => true,
118    }
119}
120
121fn locale_value() -> Option<std::ffi::OsString> {
122    ["LC_ALL", "LC_CTYPE", "LANG"]
123        .into_iter()
124        .find_map(env::var_os)
125}
126
127fn single_line(value: &str) -> &str {
128    value.lines().next().unwrap_or(value).trim()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::DisplayStyle;
134    use crate::config::{IconColors, IconMode};
135
136    #[test]
137    fn always_mode_enables_icons() {
138        let style = DisplayStyle::from_icon_mode(IconMode::Always);
139        assert!(style.icons_enabled());
140        assert!(
141            style
142                .session_label("demo")
143                .starts_with("\u{1b}[38;5;75m\u{1b}[0m")
144        );
145    }
146
147    #[test]
148    fn never_mode_uses_text_labels() {
149        let style = DisplayStyle::from_icon_mode(IconMode::Never);
150        assert!(!style.icons_enabled());
151        assert_eq!(style.directory_label("/tmp/demo"), "dir      /tmp/demo");
152        assert_eq!(style.template_label("rust"), "template rust");
153        assert_eq!(style.project_label("demo"), "project  demo");
154        assert_eq!(style.current_session_label("demo"), "current  demo");
155    }
156
157    #[test]
158    fn custom_palette_changes_icon_colors() {
159        let style = DisplayStyle::new(
160            IconMode::Always,
161            IconColors {
162                session: 33,
163                directory: 44,
164                template: 55,
165                project: 66,
166            },
167        );
168
169        assert!(style.session_label("demo").starts_with("\u{1b}[38;5;33m"));
170        assert!(
171            style
172                .directory_label("/tmp/demo")
173                .starts_with("\u{1b}[38;5;44m")
174        );
175        assert!(style.template_label("rust").starts_with("\u{1b}[38;5;55m"));
176        assert!(style.project_label("demo").starts_with("\u{1b}[38;5;66m"));
177    }
178
179    #[test]
180    fn current_session_label_uses_bold_style() {
181        let style = DisplayStyle::from_icon_mode(IconMode::Always);
182        let label = style.current_session_label("demo");
183        assert!(label.starts_with("\u{1b}[1m\u{1b}[38;5;75m\u{1b}[0m"));
184        assert!(label.ends_with("  \u{1b}[1m\u{1b}[38;5;75mdemo\u{1b}[0m"));
185    }
186}