npm_run_scripts/tui/widgets/
header.rs

1//! Header widget for the TUI.
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    text::{Line, Span},
7    widgets::{Paragraph, Widget},
8};
9
10use crate::config::AppearanceConfig;
11use crate::package::Runner;
12use crate::tui::theme::Theme;
13
14/// Header widget showing project name and runner info.
15pub struct Header<'a> {
16    project_name: &'a str,
17    runner: Runner,
18    theme: &'a Theme,
19    show_icons: bool,
20}
21
22impl<'a> Header<'a> {
23    /// Create a new header widget.
24    pub fn new(
25        project_name: &'a str,
26        runner: Runner,
27        theme: &'a Theme,
28        config: &AppearanceConfig,
29    ) -> Self {
30        Self {
31            project_name,
32            runner,
33            theme,
34            show_icons: config.icons,
35        }
36    }
37
38    /// Build the header line.
39    fn build_line(&self, width: u16) -> Line<'a> {
40        let icon = if self.show_icons {
41            self.runner.icon()
42        } else {
43            ""
44        };
45
46        let help_hint = "[?]";
47
48        // Calculate available space for project name
49        let icon_len = if self.show_icons { 2 } else { 0 }; // icon + space
50        let runner_part = format!(" {} ", self.runner.display_name());
51        let help_len = help_hint.len() + 2; // help + spaces
52        let fixed_parts = icon_len + runner_part.len() + help_len + 4; // padding/separators
53
54        let max_project_len = (width as usize).saturating_sub(fixed_parts);
55        let project_display = truncate_with_ellipsis(self.project_name, max_project_len);
56
57        // Build spans
58        let mut spans = Vec::new();
59
60        // Left side: icon + project name
61        spans.push(Span::raw(" "));
62        if self.show_icons && !icon.is_empty() {
63            spans.push(Span::styled(
64                format!("{} ", icon),
65                self.theme.header_project(),
66            ));
67        }
68        spans.push(Span::styled(project_display, self.theme.header_project()));
69
70        // Calculate padding to right-align runner info
71        let left_len = spans.iter().map(|s| s.content.len()).sum::<usize>();
72        let right_content = format!("{} {} ", runner_part, help_hint);
73        let padding_len = (width as usize).saturating_sub(left_len + right_content.len());
74
75        if padding_len > 0 {
76            spans.push(Span::styled(" ".repeat(padding_len), self.theme.header()));
77        }
78
79        // Right side: runner + help
80        spans.push(Span::styled(runner_part, self.theme.header_runner()));
81        spans.push(Span::styled(help_hint, self.theme.header()));
82        spans.push(Span::raw(" "));
83
84        Line::from(spans)
85    }
86}
87
88impl Widget for Header<'_> {
89    fn render(self, area: Rect, buf: &mut Buffer) {
90        if area.height == 0 {
91            return;
92        }
93
94        let line = self.build_line(area.width);
95        let paragraph = Paragraph::new(line).style(self.theme.header());
96        paragraph.render(area, buf);
97    }
98}
99
100/// Truncate a string with ellipsis if it exceeds max length.
101///
102/// Handles Unicode characters properly by counting characters, not bytes.
103/// Uses the Unicode ellipsis character (…) which is more compact.
104pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
105    let char_count = s.chars().count();
106
107    if char_count <= max_len {
108        s.to_string()
109    } else if max_len == 0 {
110        String::new()
111    } else if max_len <= 3 {
112        // For very short lengths, just truncate without ellipsis
113        s.chars().take(max_len).collect()
114    } else {
115        // Leave room for ellipsis (1 character)
116        let truncated: String = s.chars().take(max_len - 1).collect();
117        format!("{}…", truncated)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_truncate_with_ellipsis() {
127        assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
128        assert_eq!(truncate_with_ellipsis("hello world", 8), "hello w…");
129        assert_eq!(truncate_with_ellipsis("hi", 2), "hi");
130        assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
131        assert_eq!(truncate_with_ellipsis("hello", 4), "hel…");
132    }
133
134    #[test]
135    fn test_truncate_very_short() {
136        // When max_len <= 3, just truncate without ellipsis
137        assert_eq!(truncate_with_ellipsis("hello", 3), "hel");
138        assert_eq!(truncate_with_ellipsis("hello", 2), "he");
139        assert_eq!(truncate_with_ellipsis("hello", 1), "h");
140    }
141
142    #[test]
143    fn test_truncate_zero_length() {
144        assert_eq!(truncate_with_ellipsis("hello", 0), "");
145    }
146
147    #[test]
148    fn test_truncate_unicode() {
149        // Japanese text - each character is one code point (5 chars)
150        assert_eq!(truncate_with_ellipsis("こんにちは", 6), "こんにちは"); // 5 chars fits in 6
151        assert_eq!(truncate_with_ellipsis("こんにちは", 5), "こんにちは"); // 5 chars fits exactly
152        assert_eq!(truncate_with_ellipsis("こんにちは", 4), "こんに…"); // 5 > 4, truncate with ellipsis
153
154        // Emoji - each emoji is one code point
155        assert_eq!(truncate_with_ellipsis("🚀🎉🔥", 4), "🚀🎉🔥"); // 3 chars fits in 4
156        assert_eq!(truncate_with_ellipsis("🚀🎉🔥", 3), "🚀🎉🔥"); // 3 chars fits exactly
157        assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯", 4), "🚀🎉🔥🎯"); // 4 chars fits exactly
158                                                                       // For max_len <= 3, we truncate without ellipsis (no room for ellipsis)
159        assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯", 3), "🚀🎉🔥"); // 4 > 3, but max_len <= 3
160        assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯🌟", 4), "🚀🎉🔥…"); // 5 > 4, truncate with ellipsis
161
162        // Mixed ASCII and Unicode (7 chars total)
163        assert_eq!(truncate_with_ellipsis("hello世界", 8), "hello世界"); // 7 chars fits in 8
164        assert_eq!(truncate_with_ellipsis("hello世界", 7), "hello世界"); // 7 chars fits exactly
165        assert_eq!(truncate_with_ellipsis("hello世界", 6), "hello…"); // 7 > 6, truncate with ellipsis
166    }
167
168    #[test]
169    fn test_header_build_line() {
170        let theme = Theme::default();
171        let config = AppearanceConfig::default();
172        let header = Header::new("my-project", Runner::Npm, &theme, &config);
173
174        let line = header.build_line(80);
175        let content: String = line.spans.iter().map(|s| s.content.to_string()).collect();
176
177        assert!(content.contains("my-project"));
178        assert!(content.contains("npm"));
179        assert!(content.contains("[?]"));
180    }
181
182    #[test]
183    fn test_header_unicode_project_name() {
184        let theme = Theme::default();
185        let config = AppearanceConfig::default();
186        let header = Header::new("日本語プロジェクト", Runner::Npm, &theme, &config);
187
188        let line = header.build_line(80);
189        let content: String = line.spans.iter().map(|s| s.content.to_string()).collect();
190
191        assert!(content.contains("日本語"));
192        assert!(content.contains("npm"));
193    }
194}