Skip to main content

par_term/
badge.rs

1//! Badge system for displaying session information overlays.
2//!
3//! Badges are semi-transparent text labels displayed in the terminal corner,
4//! showing dynamic information about the session (hostname, username, path, etc.).
5//! This implementation follows the iTerm2 badge system design.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::config::Config;
11use crate::profile::Profile;
12
13/// Session variables available for badge interpolation
14#[derive(Debug, Clone, Default)]
15pub struct SessionVariables {
16    /// Remote hostname (from SSH or local)
17    pub hostname: String,
18    /// Current username
19    pub username: String,
20    /// Current working directory
21    pub path: String,
22    /// Current foreground job name
23    pub job: Option<String>,
24    /// Last executed command
25    pub last_command: Option<String>,
26    /// Current profile name
27    pub profile_name: String,
28    /// TTY device name
29    pub tty: String,
30    /// Terminal columns
31    pub columns: usize,
32    /// Terminal rows
33    pub rows: usize,
34    /// Number of bells received
35    pub bell_count: usize,
36    /// Currently selected text
37    pub selection: Option<String>,
38    /// tmux pane title (when in tmux mode)
39    pub tmux_pane_title: Option<String>,
40    /// Last command exit code (from shell integration)
41    pub exit_code: Option<i32>,
42    /// Currently running command name (from shell integration)
43    pub current_command: Option<String>,
44    /// Custom variables set via escape sequences
45    pub custom: HashMap<String, String>,
46}
47
48impl SessionVariables {
49    /// Create new session variables with system defaults
50    pub fn new() -> Self {
51        let hostname = hostname::get()
52            .map(|h| h.to_string_lossy().to_string())
53            .unwrap_or_else(|_| "unknown".to_string());
54
55        let username = std::env::var("USER")
56            .or_else(|_| std::env::var("USERNAME"))
57            .unwrap_or_else(|_| "unknown".to_string());
58
59        let path = std::env::current_dir()
60            .map(|p| p.to_string_lossy().to_string())
61            .unwrap_or_else(|_| "~".to_string());
62
63        Self {
64            hostname,
65            username,
66            path,
67            profile_name: "Default".to_string(),
68            tty: std::env::var("TTY").unwrap_or_default(),
69            columns: 80,
70            rows: 24,
71            ..Default::default()
72        }
73    }
74
75    /// Get variable value by name
76    pub fn get(&self, name: &str) -> Option<String> {
77        match name {
78            "session.hostname" => Some(self.hostname.clone()),
79            "session.username" => Some(self.username.clone()),
80            "session.path" => Some(self.path.clone()),
81            "session.job" => self.job.clone(),
82            "session.last_command" => self.last_command.clone(),
83            "session.profile_name" => Some(self.profile_name.clone()),
84            "session.tty" => Some(self.tty.clone()),
85            "session.columns" => Some(self.columns.to_string()),
86            "session.rows" => Some(self.rows.to_string()),
87            "session.bell_count" => Some(self.bell_count.to_string()),
88            "session.selection" => self.selection.clone(),
89            "session.tmux_pane_title" => self.tmux_pane_title.clone(),
90            "session.exit_code" => self.exit_code.map(|c| c.to_string()),
91            "session.current_command" => self.current_command.clone(),
92            _ => {
93                // Check custom variables
94                if let Some(custom_name) = name.strip_prefix("session.") {
95                    self.custom.get(custom_name).cloned()
96                } else {
97                    None
98                }
99            }
100        }
101    }
102
103    /// Update the working directory
104    pub fn set_path(&mut self, path: String) {
105        self.path = path;
106    }
107
108    /// Update terminal dimensions
109    pub fn set_dimensions(&mut self, cols: usize, rows: usize) {
110        self.columns = cols;
111        self.rows = rows;
112    }
113
114    /// Increment bell count
115    pub fn increment_bell(&mut self) {
116        self.bell_count += 1;
117    }
118
119    /// Set a custom variable
120    pub fn set_custom(&mut self, name: &str, value: String) {
121        self.custom.insert(name.to_string(), value);
122    }
123
124    /// Set the last command exit code
125    pub fn set_exit_code(&mut self, code: Option<i32>) {
126        self.exit_code = code;
127    }
128
129    /// Set the currently running command name
130    pub fn set_current_command(&mut self, command: Option<String>) {
131        self.current_command = command;
132    }
133}
134
135/// Badge state and configuration
136#[derive(Clone)]
137pub struct BadgeState {
138    /// Whether badge is enabled
139    pub enabled: bool,
140    /// Badge format string (with variable placeholders)
141    pub format: String,
142    /// Rendered badge text after variable interpolation
143    pub rendered_text: String,
144    /// Badge text color [R, G, B]
145    pub color: [u8; 3],
146    /// Badge opacity (0.0-1.0)
147    pub alpha: f32,
148    /// Font family for badge
149    pub font: String,
150    /// Use bold font
151    pub font_bold: bool,
152    /// Top margin in pixels
153    pub top_margin: f32,
154    /// Right margin in pixels
155    pub right_margin: f32,
156    /// Maximum width as fraction of terminal width (0.0-1.0)
157    pub max_width: f32,
158    /// Maximum height as fraction of terminal height (0.0-1.0)
159    pub max_height: f32,
160    /// Session variables for interpolation
161    pub variables: Arc<parking_lot::RwLock<SessionVariables>>,
162    /// Whether the badge needs re-rendering
163    dirty: bool,
164}
165
166impl BadgeState {
167    /// Create a new badge state from config
168    pub fn new(config: &Config) -> Self {
169        Self {
170            enabled: config.badge_enabled,
171            format: config.badge_format.clone(),
172            rendered_text: String::new(),
173            color: config.badge_color,
174            alpha: config.badge_color_alpha,
175            font: config.badge_font.clone(),
176            font_bold: config.badge_font_bold,
177            top_margin: config.badge_top_margin,
178            right_margin: config.badge_right_margin,
179            max_width: config.badge_max_width,
180            max_height: config.badge_max_height,
181            variables: Arc::new(parking_lot::RwLock::new(SessionVariables::new())),
182            dirty: true,
183        }
184    }
185
186    /// Update badge configuration
187    pub fn update_config(&mut self, config: &Config) {
188        let format_changed = self.format != config.badge_format;
189
190        self.enabled = config.badge_enabled;
191        self.format = config.badge_format.clone();
192        self.color = config.badge_color;
193        self.alpha = config.badge_color_alpha;
194        self.font = config.badge_font.clone();
195        self.font_bold = config.badge_font_bold;
196        self.top_margin = config.badge_top_margin;
197        self.right_margin = config.badge_right_margin;
198        self.max_width = config.badge_max_width;
199        self.max_height = config.badge_max_height;
200
201        if format_changed {
202            self.dirty = true;
203        }
204    }
205
206    /// Set badge format directly (e.g., from OSC 1337)
207    pub fn set_format(&mut self, format: String) {
208        if self.format != format {
209            self.format = format;
210            self.dirty = true;
211        }
212    }
213
214    /// Mark badge as needing re-render
215    pub fn mark_dirty(&mut self) {
216        self.dirty = true;
217    }
218
219    /// Check if badge needs re-rendering
220    pub fn is_dirty(&self) -> bool {
221        self.dirty
222    }
223
224    /// Clear dirty flag
225    pub fn clear_dirty(&mut self) {
226        self.dirty = false;
227    }
228
229    /// Interpolate variables in the format string
230    pub fn interpolate(&mut self) {
231        let variables = self.variables.read();
232        self.rendered_text = interpolate_badge_format(&self.format, &variables);
233        self.dirty = false;
234    }
235
236    /// Get the rendered badge text
237    pub fn text(&self) -> &str {
238        &self.rendered_text
239    }
240
241    /// Access session variables for mutation
242    pub fn variables_mut(&self) -> parking_lot::RwLockWriteGuard<'_, SessionVariables> {
243        self.variables.write()
244    }
245
246    /// Apply badge settings from a profile (overrides global config where set)
247    ///
248    /// This is called when a profile is activated to apply its custom badge settings.
249    /// Only non-None profile settings override the current values.
250    pub fn apply_profile_settings(&mut self, profile: &Profile) {
251        let mut changed = false;
252
253        // Badge format/text
254        if let Some(ref text) = profile.badge_text
255            && self.format != *text
256        {
257            self.format = text.clone();
258            changed = true;
259        }
260
261        // Badge color
262        if let Some(color) = profile.badge_color {
263            self.color = color;
264        }
265
266        // Badge alpha
267        if let Some(alpha) = profile.badge_color_alpha {
268            self.alpha = alpha;
269        }
270
271        // Badge font
272        if let Some(ref font) = profile.badge_font {
273            self.font = font.clone();
274        }
275
276        // Badge font bold
277        if let Some(bold) = profile.badge_font_bold {
278            self.font_bold = bold;
279        }
280
281        // Badge top margin
282        if let Some(margin) = profile.badge_top_margin {
283            self.top_margin = margin;
284        }
285
286        // Badge right margin
287        if let Some(margin) = profile.badge_right_margin {
288            self.right_margin = margin;
289        }
290
291        // Badge max width
292        if let Some(width) = profile.badge_max_width {
293            self.max_width = width;
294        }
295
296        // Badge max height
297        if let Some(height) = profile.badge_max_height {
298            self.max_height = height;
299        }
300
301        if changed {
302            self.dirty = true;
303        }
304    }
305}
306
307/// Interpolate badge format string with session variables
308///
309/// Replaces `\(session.*)` placeholders with actual values.
310/// Supports:
311/// - `\(session.hostname)` - Remote/local hostname
312/// - `\(session.username)` - Current user
313/// - `\(session.path)` - Working directory
314/// - `\(session.job)` - Foreground job
315/// - `\(session.last_command)` - Last command
316/// - `\(session.profile_name)` - Profile name
317/// - `\(session.tty)` - TTY device
318/// - `\(session.columns)` - Terminal columns
319/// - `\(session.rows)` - Terminal rows
320/// - `\(session.bell_count)` - Bell count
321/// - `\(session.selection)` - Selected text
322/// - `\(session.tmux_pane_title)` - tmux pane title
323/// - `\(session.exit_code)` - Last command exit code
324/// - `\(session.current_command)` - Currently running command name
325pub fn interpolate_badge_format(format: &str, variables: &SessionVariables) -> String {
326    let mut result = String::with_capacity(format.len());
327    let mut chars = format.chars().peekable();
328
329    while let Some(ch) = chars.next() {
330        if ch == '\\' && chars.peek() == Some(&'(') {
331            // Skip the '('
332            chars.next();
333
334            // Collect variable name until ')'
335            let mut var_name = String::new();
336            for c in chars.by_ref() {
337                if c == ')' {
338                    break;
339                }
340                var_name.push(c);
341            }
342
343            // Look up variable value
344            if let Some(value) = variables.get(&var_name) {
345                result.push_str(&value);
346            }
347            // If variable not found, output nothing (empty string)
348        } else {
349            result.push(ch);
350        }
351    }
352
353    result
354}
355
356/// Decode base64-encoded badge format (for OSC 1337 SetBadgeFormat)
357///
358/// Returns None if decoding fails or the format contains security risks.
359pub fn decode_badge_format(base64_format: &str) -> Option<String> {
360    use base64::Engine;
361    let engine = base64::engine::general_purpose::STANDARD;
362
363    let decoded = engine.decode(base64_format).ok()?;
364    let format = String::from_utf8(decoded).ok()?;
365
366    // Security check: reject formats that look like function calls
367    // or contain suspicious patterns
368    if format.contains("$(")
369        || format.contains("`")
370        || format.contains("eval")
371        || format.contains("exec")
372    {
373        log::warn!(
374            "Rejecting badge format with suspicious content: {:?}",
375            format
376        );
377        return None;
378    }
379
380    Some(format)
381}
382
383/// Render badge using egui
384///
385/// This function renders the badge as a semi-transparent overlay in the top-right
386/// corner of the terminal window.
387pub fn render_badge(
388    ctx: &egui::Context,
389    badge: &BadgeState,
390    window_width: f32,
391    _window_height: f32,
392) {
393    if !badge.enabled || badge.rendered_text.is_empty() {
394        return;
395    }
396
397    // Set up badge styling
398    let color = egui::Color32::from_rgba_unmultiplied(
399        badge.color[0],
400        badge.color[1],
401        badge.color[2],
402        (badge.alpha * 255.0) as u8,
403    );
404
405    // Use a large font for badge
406    let font_id = egui::FontId::new(24.0, egui::FontFamily::Proportional);
407
408    // Create an area for the badge in the top-right corner
409    egui::Area::new(egui::Id::new("badge_overlay"))
410        .fixed_pos(egui::pos2(0.0, badge.top_margin))
411        .order(egui::Order::Foreground)
412        .interactable(false)
413        .show(ctx, |ui| {
414            // Calculate position: measure text first to know width
415            let text = &badge.rendered_text;
416
417            // Get approximate text width using the painter
418            let text_rect = ui.painter().text(
419                egui::pos2(0.0, 0.0),
420                egui::Align2::LEFT_TOP,
421                text,
422                font_id.clone(),
423                egui::Color32::TRANSPARENT, // Invisible measurement
424            );
425
426            // Calculate actual position (right-aligned with margin)
427            let x = window_width - text_rect.width() - badge.right_margin;
428            let y = badge.top_margin;
429
430            // Draw the actual badge text
431            ui.painter().text(
432                egui::pos2(x, y),
433                egui::Align2::LEFT_TOP,
434                text,
435                font_id,
436                color,
437            );
438        });
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_interpolate_basic() {
447        let vars = SessionVariables {
448            hostname: "myhost".to_string(),
449            username: "testuser".to_string(),
450            ..Default::default()
451        };
452
453        let result = interpolate_badge_format("\\(session.username)@\\(session.hostname)", &vars);
454        assert_eq!(result, "testuser@myhost");
455    }
456
457    #[test]
458    fn test_interpolate_missing_variable() {
459        let vars = SessionVariables::default();
460        let result = interpolate_badge_format("Hello \\(session.nonexistent) World", &vars);
461        assert_eq!(result, "Hello  World");
462    }
463
464    #[test]
465    fn test_interpolate_no_variables() {
466        let vars = SessionVariables::default();
467        let result = interpolate_badge_format("Plain text", &vars);
468        assert_eq!(result, "Plain text");
469    }
470
471    #[test]
472    fn test_interpolate_escaped_backslash() {
473        let vars = SessionVariables::default();
474        // Just a backslash not followed by ( should pass through
475        let result = interpolate_badge_format("Path: C:\\Users", &vars);
476        assert_eq!(result, "Path: C:\\Users");
477    }
478
479    #[test]
480    fn test_decode_badge_format_valid() {
481        use base64::Engine;
482        let engine = base64::engine::general_purpose::STANDARD;
483        let encoded = engine.encode("Hello World");
484        let decoded = decode_badge_format(&encoded);
485        assert_eq!(decoded, Some("Hello World".to_string()));
486    }
487
488    #[test]
489    fn test_decode_badge_format_security_check() {
490        use base64::Engine;
491        let engine = base64::engine::general_purpose::STANDARD;
492
493        // Test command substitution rejection
494        let encoded = engine.encode("$(whoami)");
495        assert!(decode_badge_format(&encoded).is_none());
496
497        // Test backtick rejection
498        let encoded = engine.encode("`whoami`");
499        assert!(decode_badge_format(&encoded).is_none());
500
501        // Test eval rejection
502        let encoded = engine.encode("eval bad");
503        assert!(decode_badge_format(&encoded).is_none());
504    }
505
506    #[test]
507    fn test_session_variables_get() {
508        let vars = SessionVariables {
509            hostname: "test".to_string(),
510            columns: 120,
511            rows: 40,
512            ..Default::default()
513        };
514
515        assert_eq!(vars.get("session.hostname"), Some("test".to_string()));
516        assert_eq!(vars.get("session.columns"), Some("120".to_string()));
517        assert_eq!(vars.get("session.rows"), Some("40".to_string()));
518        assert_eq!(vars.get("session.nonexistent"), None);
519    }
520
521    #[test]
522    fn test_session_variables_custom() {
523        let mut vars = SessionVariables::default();
524        vars.set_custom("myvar", "myvalue".to_string());
525
526        assert_eq!(vars.get("session.myvar"), Some("myvalue".to_string()));
527    }
528
529    #[test]
530    fn test_interpolate_exit_code() {
531        let vars = SessionVariables {
532            exit_code: Some(1),
533            ..Default::default()
534        };
535
536        let result = interpolate_badge_format("Exit: \\(session.exit_code)", &vars);
537        assert_eq!(result, "Exit: 1");
538    }
539
540    #[test]
541    fn test_interpolate_current_command() {
542        let vars = SessionVariables {
543            current_command: Some("vim".to_string()),
544            ..Default::default()
545        };
546
547        let result = interpolate_badge_format("Running: \\(session.current_command)", &vars);
548        assert_eq!(result, "Running: vim");
549    }
550
551    #[test]
552    fn test_interpolate_exit_code_none() {
553        let vars = SessionVariables {
554            exit_code: None,
555            ..Default::default()
556        };
557
558        let result = interpolate_badge_format("Exit: \\(session.exit_code)", &vars);
559        assert_eq!(result, "Exit: ");
560    }
561}