npm_run_scripts/tui/widgets/
header.rs1use 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
14pub 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 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 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 let icon_len = if self.show_icons { 2 } else { 0 }; let runner_part = format!(" {} ", self.runner.display_name());
51 let help_len = help_hint.len() + 2; let fixed_parts = icon_len + runner_part.len() + help_len + 4; 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 let mut spans = Vec::new();
59
60 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 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 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
100pub 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 s.chars().take(max_len).collect()
114 } else {
115 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 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 assert_eq!(truncate_with_ellipsis("こんにちは", 6), "こんにちは"); assert_eq!(truncate_with_ellipsis("こんにちは", 5), "こんにちは"); assert_eq!(truncate_with_ellipsis("こんにちは", 4), "こんに…"); assert_eq!(truncate_with_ellipsis("🚀🎉🔥", 4), "🚀🎉🔥"); assert_eq!(truncate_with_ellipsis("🚀🎉🔥", 3), "🚀🎉🔥"); assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯", 4), "🚀🎉🔥🎯"); assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯", 3), "🚀🎉🔥"); assert_eq!(truncate_with_ellipsis("🚀🎉🔥🎯🌟", 4), "🚀🎉🔥…"); assert_eq!(truncate_with_ellipsis("hello世界", 8), "hello世界"); assert_eq!(truncate_with_ellipsis("hello世界", 7), "hello世界"); assert_eq!(truncate_with_ellipsis("hello世界", 6), "hello…"); }
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}