Skip to main content

par_term/status_bar/
widgets.rs

1//! Widget text generation and layout helpers for the status bar.
2//!
3//! Each [`WidgetId`] variant maps to a function that produces a display string
4//! from the current [`WidgetContext`].  An optional format-override string
5//! supports `\(variable)` interpolation.
6
7use crate::badge::SessionVariables;
8use crate::config::{StatusBarSection, StatusBarWidgetConfig, WidgetId};
9use crate::status_bar::system_monitor::{SystemMonitorData, format_bytes_per_sec, format_memory};
10
11/// Runtime context passed to widget text generators.
12#[derive(Debug, Clone)]
13pub struct WidgetContext {
14    /// Session variables (hostname, username, path, bell count, etc.)
15    pub session_vars: SessionVariables,
16    /// Latest system monitor snapshot
17    pub system_data: SystemMonitorData,
18    /// Current git branch (if known)
19    pub git_branch: Option<String>,
20    /// Commits ahead of upstream
21    pub git_ahead: u32,
22    /// Commits behind upstream
23    pub git_behind: u32,
24    /// Whether the working tree has uncommitted changes
25    pub git_dirty: bool,
26    /// Whether to show ahead/behind/dirty in the git widget
27    pub git_show_status: bool,
28    /// Time format string (chrono strftime syntax)
29    pub time_format: String,
30    /// Available update version string (e.g., "0.20.0"), None if up-to-date
31    pub update_available_version: Option<String>,
32}
33
34/// Generate display text for a single widget.
35///
36/// If `format_override` is `Some`, the format string is interpolated instead
37/// of the built-in formatting.
38pub fn widget_text(id: &WidgetId, ctx: &WidgetContext, format_override: Option<&str>) -> String {
39    if let Some(fmt) = format_override {
40        return interpolate_format(fmt, ctx);
41    }
42
43    match id {
44        WidgetId::Clock => chrono::Local::now().format(&ctx.time_format).to_string(),
45        WidgetId::UsernameHostname => {
46            format!(
47                "{}@{}",
48                ctx.session_vars.username, ctx.session_vars.hostname
49            )
50        }
51        WidgetId::CurrentDirectory => ctx.session_vars.path.clone(),
52        WidgetId::GitBranch => {
53            if let Some(ref branch) = ctx.git_branch {
54                let mut text = format!("\u{e0a0} {}", branch);
55                if ctx.git_show_status {
56                    if ctx.git_ahead > 0 {
57                        text.push_str(&format!(" \u{2191}{}", ctx.git_ahead));
58                    }
59                    if ctx.git_behind > 0 {
60                        text.push_str(&format!(" \u{2193}{}", ctx.git_behind));
61                    }
62                    if ctx.git_dirty {
63                        text.push_str(" \u{25cf}");
64                    }
65                }
66                text
67            } else {
68                String::new()
69            }
70        }
71        WidgetId::CpuUsage => format!("CPU {:>5.1}%", ctx.system_data.cpu_usage),
72        WidgetId::MemoryUsage => {
73            format!(
74                "MEM {}",
75                format_memory(ctx.system_data.memory_used, ctx.system_data.memory_total)
76            )
77        }
78        WidgetId::NetworkStatus => {
79            format!(
80                "\u{2193} {} \u{2191} {}",
81                format_bytes_per_sec(ctx.system_data.network_rx_rate),
82                format_bytes_per_sec(ctx.system_data.network_tx_rate)
83            )
84        }
85        WidgetId::BellIndicator => {
86            if ctx.session_vars.bell_count > 0 {
87                format!("\u{1f514} {}", ctx.session_vars.bell_count)
88            } else {
89                String::new()
90            }
91        }
92        WidgetId::CurrentCommand => ctx.session_vars.current_command.clone().unwrap_or_default(),
93        WidgetId::UpdateAvailable => {
94            if let Some(ref version) = ctx.update_available_version {
95                format!("\u{2b06} v{}", version)
96            } else {
97                String::new()
98            }
99        }
100        WidgetId::Custom(_) => String::new(),
101    }
102}
103
104/// Interpolate `\(variable)` placeholders in a format string.
105///
106/// Supported variables:
107/// - `\(session.hostname)`, `\(session.username)`, `\(session.path)`, etc.
108/// - `\(git.branch)`
109/// - `\(system.cpu)`, `\(system.memory)`
110pub fn interpolate_format(fmt: &str, ctx: &WidgetContext) -> String {
111    let mut result = String::with_capacity(fmt.len());
112    let mut chars = fmt.chars().peekable();
113
114    while let Some(ch) = chars.next() {
115        if ch == '\\' && chars.peek() == Some(&'(') {
116            // Consume '('
117            chars.next();
118            // Collect variable name until ')'
119            let mut var_name = String::new();
120            let mut found_close = false;
121            for c in chars.by_ref() {
122                if c == ')' {
123                    found_close = true;
124                    break;
125                }
126                var_name.push(c);
127            }
128            if found_close {
129                // Resolve variable
130                let value = resolve_variable(&var_name, ctx);
131                result.push_str(&value);
132            } else {
133                // Unterminated \( — output raw text
134                result.push_str("\\(");
135                result.push_str(&var_name);
136            }
137        } else {
138            result.push(ch);
139        }
140    }
141
142    result
143}
144
145/// Resolve a single variable name to its string value.
146fn resolve_variable(name: &str, ctx: &WidgetContext) -> String {
147    match name {
148        // Session variables delegate to SessionVariables::get
149        n if n.starts_with("session.") => ctx.session_vars.get(n).unwrap_or_default(),
150        "git.branch" => ctx.git_branch.clone().unwrap_or_default(),
151        "git.ahead" => ctx.git_ahead.to_string(),
152        "git.behind" => ctx.git_behind.to_string(),
153        "git.dirty" => if ctx.git_dirty { "\u{25cf}" } else { "" }.to_string(),
154        "system.cpu" => format!("{:.1}%", ctx.system_data.cpu_usage),
155        "system.memory" => format_memory(ctx.system_data.memory_used, ctx.system_data.memory_total),
156        _ => String::new(),
157    }
158}
159
160/// Return widgets for a given section, filtered by enabled, sorted by order.
161pub fn sorted_widgets_for_section(
162    widgets: &[StatusBarWidgetConfig],
163    section: StatusBarSection,
164) -> Vec<&StatusBarWidgetConfig> {
165    let mut result: Vec<&StatusBarWidgetConfig> = widgets
166        .iter()
167        .filter(|w| w.enabled && w.section == section)
168        .collect();
169    result.sort_by_key(|w| w.order);
170    result
171}
172
173// ============================================================================
174// Tests
175// ============================================================================
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::config::StatusBarSection;
181
182    fn make_ctx() -> WidgetContext {
183        let sv = SessionVariables {
184            username: "alice".to_string(),
185            hostname: "dev-box".to_string(),
186            path: "/home/alice/project".to_string(),
187            bell_count: 3,
188            current_command: Some("cargo build".to_string()),
189            ..Default::default()
190        };
191
192        WidgetContext {
193            session_vars: sv,
194            system_data: SystemMonitorData {
195                cpu_usage: 42.5,
196                memory_used: 4_294_967_296,   // 4 GB
197                memory_total: 17_179_869_184, // 16 GB
198                network_rx_rate: 1024,
199                network_tx_rate: 2048,
200                last_update: None,
201            },
202            git_branch: Some("main".to_string()),
203            git_ahead: 2,
204            git_behind: 1,
205            git_dirty: true,
206            git_show_status: true,
207            time_format: "%H:%M:%S".to_string(),
208            update_available_version: None,
209        }
210    }
211
212    #[test]
213    fn test_widget_text_clock() {
214        let ctx = make_ctx();
215        let text = widget_text(&WidgetId::Clock, &ctx, None);
216        // Should be HH:MM:SS format
217        assert_eq!(text.len(), 8);
218        assert_eq!(text.as_bytes()[2], b':');
219        assert_eq!(text.as_bytes()[5], b':');
220
221        // Custom time format
222        let mut ctx2 = make_ctx();
223        ctx2.time_format = "%H:%M".to_string();
224        let text = widget_text(&WidgetId::Clock, &ctx2, None);
225        // Should be HH:MM format
226        assert_eq!(text.len(), 5);
227        assert_eq!(text.as_bytes()[2], b':');
228
229        // 12-hour format
230        let mut ctx3 = make_ctx();
231        ctx3.time_format = "%I:%M %p".to_string();
232        let text = widget_text(&WidgetId::Clock, &ctx3, None);
233        assert!(text.contains(':'));
234        assert!(text.contains("AM") || text.contains("PM"));
235    }
236
237    #[test]
238    fn test_widget_text_username_hostname() {
239        let ctx = make_ctx();
240        let text = widget_text(&WidgetId::UsernameHostname, &ctx, None);
241        assert_eq!(text, "alice@dev-box");
242    }
243
244    #[test]
245    fn test_widget_text_current_directory() {
246        let ctx = make_ctx();
247        let text = widget_text(&WidgetId::CurrentDirectory, &ctx, None);
248        assert_eq!(text, "/home/alice/project");
249    }
250
251    #[test]
252    fn test_widget_text_git_branch() {
253        let ctx = make_ctx();
254        let text = widget_text(&WidgetId::GitBranch, &ctx, None);
255        // ahead=2, behind=1, dirty=true
256        assert_eq!(text, "\u{e0a0} main \u{2191}2 \u{2193}1 \u{25cf}");
257
258        // With status disabled
259        let mut ctx_no_status = make_ctx();
260        ctx_no_status.git_show_status = false;
261        let text = widget_text(&WidgetId::GitBranch, &ctx_no_status, None);
262        assert_eq!(text, "\u{e0a0} main");
263
264        // No branch
265        let mut ctx2 = make_ctx();
266        ctx2.git_branch = None;
267        let text = widget_text(&WidgetId::GitBranch, &ctx2, None);
268        assert!(text.is_empty());
269
270        // Clean repo (no ahead/behind/dirty)
271        let mut ctx_clean = make_ctx();
272        ctx_clean.git_ahead = 0;
273        ctx_clean.git_behind = 0;
274        ctx_clean.git_dirty = false;
275        let text = widget_text(&WidgetId::GitBranch, &ctx_clean, None);
276        assert_eq!(text, "\u{e0a0} main");
277    }
278
279    #[test]
280    fn test_widget_text_cpu_usage() {
281        let ctx = make_ctx();
282        let text = widget_text(&WidgetId::CpuUsage, &ctx, None);
283        assert_eq!(text, "CPU  42.5%");
284
285        // Single-digit CPU should be padded
286        let mut ctx2 = make_ctx();
287        ctx2.system_data.cpu_usage = 5.0;
288        let text2 = widget_text(&WidgetId::CpuUsage, &ctx2, None);
289        assert_eq!(text2, "CPU   5.0%");
290        // Same width regardless of value
291        assert_eq!(text.len(), text2.len());
292    }
293
294    #[test]
295    fn test_widget_text_memory_usage() {
296        let ctx = make_ctx();
297        let text = widget_text(&WidgetId::MemoryUsage, &ctx, None);
298        assert_eq!(text, "MEM   4.0 GB /  16.0 GB");
299    }
300
301    #[test]
302    fn test_widget_text_network_status() {
303        let ctx = make_ctx();
304        let text = widget_text(&WidgetId::NetworkStatus, &ctx, None);
305        assert_eq!(text, "\u{2193}   1.0 KB/s \u{2191}   2.0 KB/s");
306
307        // Verify consistent width across different magnitudes
308        let mut ctx2 = make_ctx();
309        ctx2.system_data.network_rx_rate = 500; // B/s
310        ctx2.system_data.network_tx_rate = 1_048_576; // MB/s
311        let text2 = widget_text(&WidgetId::NetworkStatus, &ctx2, None);
312        assert_eq!(text.len(), text2.len());
313    }
314
315    #[test]
316    fn test_widget_text_bell_indicator() {
317        let ctx = make_ctx();
318        let text = widget_text(&WidgetId::BellIndicator, &ctx, None);
319        assert_eq!(text, "\u{1f514} 3");
320
321        let mut ctx2 = make_ctx();
322        ctx2.session_vars.bell_count = 0;
323        let text = widget_text(&WidgetId::BellIndicator, &ctx2, None);
324        assert!(text.is_empty());
325    }
326
327    #[test]
328    fn test_widget_text_current_command() {
329        let ctx = make_ctx();
330        let text = widget_text(&WidgetId::CurrentCommand, &ctx, None);
331        assert_eq!(text, "cargo build");
332    }
333
334    #[test]
335    fn test_widget_text_format_override() {
336        let ctx = make_ctx();
337        let text = widget_text(
338            &WidgetId::UsernameHostname,
339            &ctx,
340            Some("Host: \\(session.hostname) CPU: \\(system.cpu)"),
341        );
342        assert_eq!(text, "Host: dev-box CPU: 42.5%");
343    }
344
345    #[test]
346    fn test_interpolate_format() {
347        let ctx = make_ctx();
348        let result = interpolate_format(
349            "\\(session.username)@\\(session.hostname) [\\(git.branch)]",
350            &ctx,
351        );
352        assert_eq!(result, "alice@dev-box [main]");
353    }
354
355    #[test]
356    fn test_sorted_widgets_for_section() {
357        let widgets = vec![
358            StatusBarWidgetConfig {
359                id: WidgetId::Clock,
360                enabled: true,
361                section: StatusBarSection::Right,
362                order: 2,
363                format: None,
364            },
365            StatusBarWidgetConfig {
366                id: WidgetId::CpuUsage,
367                enabled: false,
368                section: StatusBarSection::Right,
369                order: 0,
370                format: None,
371            },
372            StatusBarWidgetConfig {
373                id: WidgetId::BellIndicator,
374                enabled: true,
375                section: StatusBarSection::Right,
376                order: 1,
377                format: None,
378            },
379            StatusBarWidgetConfig {
380                id: WidgetId::UsernameHostname,
381                enabled: true,
382                section: StatusBarSection::Left,
383                order: 0,
384                format: None,
385            },
386        ];
387
388        let right = sorted_widgets_for_section(&widgets, StatusBarSection::Right);
389        assert_eq!(right.len(), 2); // CpuUsage is disabled
390        assert_eq!(right[0].id, WidgetId::BellIndicator); // order 1
391        assert_eq!(right[1].id, WidgetId::Clock); // order 2
392
393        let left = sorted_widgets_for_section(&widgets, StatusBarSection::Left);
394        assert_eq!(left.len(), 1);
395        assert_eq!(left[0].id, WidgetId::UsernameHostname);
396
397        let center = sorted_widgets_for_section(&widgets, StatusBarSection::Center);
398        assert!(center.is_empty());
399    }
400}