Skip to main content

osp_cli/ui/
chrome.rs

1use crate::ui::style::{StyleOverrides, StyleToken, apply_style_with_theme_overrides};
2use crate::ui::theme::ThemeDefinition;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum SectionFrameStyle {
6    None,
7    #[default]
8    Top,
9    Bottom,
10    TopBottom,
11    Square,
12    Round,
13}
14
15impl SectionFrameStyle {
16    pub fn parse(value: &str) -> Option<Self> {
17        match value.trim().to_ascii_lowercase().as_str() {
18            "none" | "plain" => Some(Self::None),
19            "top" | "rule-top" => Some(Self::Top),
20            "bottom" | "rule-bottom" => Some(Self::Bottom),
21            "top-bottom" | "both" | "rules" => Some(Self::TopBottom),
22            "square" | "box" | "boxed" => Some(Self::Square),
23            "round" | "rounded" => Some(Self::Round),
24            _ => None,
25        }
26    }
27}
28
29#[derive(Debug, Clone, Copy)]
30pub struct SectionStyleTokens {
31    pub border: StyleToken,
32    pub title: StyleToken,
33}
34
35impl SectionStyleTokens {
36    pub const fn same(token: StyleToken) -> Self {
37        Self {
38            border: token,
39            title: token,
40        }
41    }
42}
43
44#[derive(Clone, Copy)]
45pub struct SectionRenderContext<'a> {
46    pub color: bool,
47    pub theme: &'a ThemeDefinition,
48    pub style_overrides: &'a StyleOverrides,
49}
50
51impl SectionRenderContext<'_> {
52    fn style(self, text: &str, token: StyleToken) -> String {
53        if self.color {
54            apply_style_with_theme_overrides(text, token, true, self.theme, self.style_overrides)
55        } else {
56            text.to_string()
57        }
58    }
59}
60
61pub fn render_section_divider(
62    title: &str,
63    unicode: bool,
64    width: Option<usize>,
65    color: bool,
66    theme: &ThemeDefinition,
67    token: StyleToken,
68) -> String {
69    render_section_divider_with_overrides(
70        title,
71        unicode,
72        width,
73        SectionRenderContext {
74            color,
75            theme,
76            style_overrides: &StyleOverrides::default(),
77        },
78        SectionStyleTokens::same(token),
79    )
80}
81
82pub fn render_section_divider_with_overrides(
83    title: &str,
84    unicode: bool,
85    width: Option<usize>,
86    render: SectionRenderContext<'_>,
87    tokens: SectionStyleTokens,
88) -> String {
89    let border_token = tokens.border;
90    let title_token = tokens.title;
91    let fill_char = if unicode { '─' } else { '-' };
92    let target_width = width.unwrap_or(12).max(12);
93    let title = title.trim();
94
95    let raw = if title.is_empty() {
96        fill_char.to_string().repeat(target_width)
97    } else {
98        let prefix = if unicode {
99            format!("─ {title} ")
100        } else {
101            format!("- {title} ")
102        };
103        let prefix_width = prefix.chars().count();
104        if prefix_width >= target_width {
105            prefix
106        } else {
107            format!(
108                "{prefix}{}",
109                fill_char.to_string().repeat(target_width - prefix_width)
110            )
111        }
112    };
113
114    if !render.color {
115        return raw;
116    }
117
118    if title.is_empty() || title_token == border_token {
119        return render.style(&raw, border_token);
120    }
121
122    let prefix = if unicode { "─ " } else { "- " };
123    let title_text = title;
124    let prefix_width = prefix.chars().count();
125    let title_width = title_text.chars().count();
126    let base_width = prefix_width + title_width + 1;
127    let fill_len = target_width.saturating_sub(base_width);
128    let suffix = if fill_len == 0 {
129        " ".to_string()
130    } else {
131        format!(" {}", fill_char.to_string().repeat(fill_len))
132    };
133
134    let styled_prefix = render.style(prefix, border_token);
135    let styled_title = render.style(title_text, title_token);
136    let styled_suffix = render.style(&suffix, border_token);
137    format!("{styled_prefix}{styled_title}{styled_suffix}")
138}
139
140pub fn render_section_block_with_overrides(
141    title: &str,
142    body: &str,
143    frame_style: SectionFrameStyle,
144    unicode: bool,
145    width: Option<usize>,
146    render: SectionRenderContext<'_>,
147    tokens: SectionStyleTokens,
148) -> String {
149    match frame_style {
150        SectionFrameStyle::None => render_plain_section(title, body, render, tokens.title),
151        SectionFrameStyle::Top => {
152            render_ruled_section(title, body, true, false, unicode, width, render, tokens)
153        }
154        SectionFrameStyle::Bottom => {
155            render_ruled_section(title, body, false, true, unicode, width, render, tokens)
156        }
157        SectionFrameStyle::TopBottom => {
158            render_ruled_section(title, body, true, true, unicode, width, render, tokens)
159        }
160        SectionFrameStyle::Square => render_boxed_section(
161            title,
162            body,
163            unicode,
164            render,
165            tokens,
166            BoxFrameChars::square(unicode),
167        ),
168        SectionFrameStyle::Round => render_boxed_section(
169            title,
170            body,
171            unicode,
172            render,
173            tokens,
174            BoxFrameChars::round(unicode),
175        ),
176    }
177}
178
179fn render_plain_section(
180    title: &str,
181    body: &str,
182    render: SectionRenderContext<'_>,
183    title_token: StyleToken,
184) -> String {
185    let mut out = String::new();
186    let title = title.trim();
187    let body = body.trim_end_matches('\n');
188
189    if !title.is_empty() {
190        let raw_title = format!("{title}:");
191        out.push_str(&style_segment(&raw_title, render, title_token));
192        if !body.is_empty() {
193            out.push('\n');
194        }
195    }
196    if !body.is_empty() {
197        out.push_str(body);
198    }
199    out
200}
201
202#[allow(clippy::too_many_arguments)]
203fn render_ruled_section(
204    title: &str,
205    body: &str,
206    top_rule: bool,
207    bottom_rule: bool,
208    unicode: bool,
209    width: Option<usize>,
210    render: SectionRenderContext<'_>,
211    tokens: SectionStyleTokens,
212) -> String {
213    let mut out = String::new();
214    let body = body.trim_end_matches('\n');
215    let title = title.trim();
216
217    if top_rule {
218        out.push_str(&render_section_divider_with_overrides(
219            title, unicode, width, render, tokens,
220        ));
221    } else if !title.is_empty() {
222        let raw_title = format!("{title}:");
223        out.push_str(&style_segment(&raw_title, render, tokens.title));
224    }
225
226    if !body.is_empty() {
227        if !out.is_empty() {
228            out.push('\n');
229        }
230        out.push_str(body);
231    }
232
233    if bottom_rule {
234        if !out.is_empty() {
235            out.push('\n');
236        }
237        out.push_str(&render_section_divider_with_overrides(
238            "",
239            unicode,
240            width,
241            render,
242            SectionStyleTokens::same(tokens.border),
243        ));
244    }
245
246    out
247}
248
249#[derive(Debug, Clone, Copy)]
250struct BoxFrameChars {
251    top_left: char,
252    top_right: char,
253    bottom_left: char,
254    bottom_right: char,
255    horizontal: char,
256    vertical: char,
257}
258
259impl BoxFrameChars {
260    fn square(unicode: bool) -> Self {
261        if unicode {
262            Self {
263                top_left: '┌',
264                top_right: '┐',
265                bottom_left: '└',
266                bottom_right: '┘',
267                horizontal: '─',
268                vertical: '│',
269            }
270        } else {
271            Self {
272                top_left: '+',
273                top_right: '+',
274                bottom_left: '+',
275                bottom_right: '+',
276                horizontal: '-',
277                vertical: '|',
278            }
279        }
280    }
281
282    fn round(unicode: bool) -> Self {
283        if unicode {
284            Self {
285                top_left: '╭',
286                top_right: '╮',
287                bottom_left: '╰',
288                bottom_right: '╯',
289                horizontal: '─',
290                vertical: '│',
291            }
292        } else {
293            Self::square(false)
294        }
295    }
296}
297
298#[allow(clippy::too_many_arguments)]
299fn render_boxed_section(
300    title: &str,
301    body: &str,
302    _unicode: bool,
303    render: SectionRenderContext<'_>,
304    tokens: SectionStyleTokens,
305    chars: BoxFrameChars,
306) -> String {
307    let lines = section_body_lines(body);
308    let title = title.trim();
309    let body_width = lines
310        .iter()
311        .map(|line| visible_width(line))
312        .max()
313        .unwrap_or(0);
314    let title_width = if title.is_empty() {
315        0
316    } else {
317        title.chars().count() + 2
318    };
319    let inner_width = body_width.max(title_width).max(8);
320
321    let mut out = String::new();
322    out.push_str(&render_box_top(title, inner_width, chars, render, tokens));
323
324    if !lines.is_empty() {
325        out.push('\n');
326    }
327
328    for (index, line) in lines.iter().enumerate() {
329        if index > 0 {
330            out.push('\n');
331        }
332        out.push_str(&render_box_body_line(
333            line,
334            inner_width,
335            chars,
336            render,
337            tokens.border,
338        ));
339    }
340
341    if !out.is_empty() {
342        out.push('\n');
343    }
344    out.push_str(&style_segment(
345        &format!(
346            "{}{}{}",
347            chars.bottom_left,
348            chars.horizontal.to_string().repeat(inner_width + 2),
349            chars.bottom_right
350        ),
351        render,
352        tokens.border,
353    ));
354    out
355}
356
357fn render_box_top(
358    title: &str,
359    inner_width: usize,
360    chars: BoxFrameChars,
361    render: SectionRenderContext<'_>,
362    tokens: SectionStyleTokens,
363) -> String {
364    if title.is_empty() {
365        return style_segment(
366            &format!(
367                "{}{}{}",
368                chars.top_left,
369                chars.horizontal.to_string().repeat(inner_width + 2),
370                chars.top_right
371            ),
372            render,
373            tokens.border,
374        );
375    }
376
377    let title_width = title.chars().count();
378    let remaining = inner_width.saturating_sub(title_width);
379    let left = format!("{} ", chars.top_left);
380    let right = format!(
381        " {}{}",
382        chars.horizontal.to_string().repeat(remaining),
383        chars.top_right
384    );
385
386    format!(
387        "{}{}{}",
388        style_segment(&left, render, tokens.border,),
389        style_segment(title, render, tokens.title),
390        style_segment(&right, render, tokens.border,),
391    )
392}
393
394fn render_box_body_line(
395    line: &str,
396    inner_width: usize,
397    chars: BoxFrameChars,
398    render: SectionRenderContext<'_>,
399    border_token: StyleToken,
400) -> String {
401    let padding = inner_width.saturating_sub(visible_width(line));
402    let left = format!("{} ", chars.vertical);
403    let right = format!("{} {}", " ".repeat(padding), chars.vertical);
404    format!(
405        "{}{}{}",
406        style_segment(&left, render, border_token,),
407        line,
408        style_segment(&right, render, border_token,),
409    )
410}
411
412fn style_segment(text: &str, render: SectionRenderContext<'_>, token: StyleToken) -> String {
413    render.style(text, token)
414}
415
416fn section_body_lines(body: &str) -> Vec<&str> {
417    body.trim_end_matches('\n')
418        .lines()
419        .map(str::trim_end)
420        .collect()
421}
422
423fn visible_width(text: &str) -> usize {
424    let mut width = 0usize;
425    let mut chars = text.chars().peekable();
426
427    while let Some(ch) = chars.next() {
428        if ch == '\x1b' && matches!(chars.peek(), Some('[')) {
429            chars.next();
430            for next in chars.by_ref() {
431                if ('@'..='~').contains(&next) {
432                    break;
433                }
434            }
435            continue;
436        }
437        width += 1;
438    }
439
440    width
441}
442
443#[cfg(test)]
444mod tests {
445    use super::{
446        SectionFrameStyle, SectionRenderContext, SectionStyleTokens,
447        render_section_block_with_overrides, render_section_divider,
448        render_section_divider_with_overrides,
449    };
450    use std::sync::{Mutex, OnceLock};
451
452    fn env_lock() -> &'static Mutex<()> {
453        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
454        LOCK.get_or_init(|| Mutex::new(()))
455    }
456
457    #[test]
458    fn section_divider_ignores_columns_env_without_explicit_width() {
459        let _guard = env_lock().lock().expect("lock should not be poisoned");
460        let original = std::env::var("COLUMNS").ok();
461        unsafe {
462            std::env::set_var("COLUMNS", "99");
463        }
464
465        let divider = render_section_divider(
466            "",
467            false,
468            None,
469            false,
470            &crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME),
471            crate::ui::style::StyleToken::PanelBorder,
472        );
473
474        match original {
475            Some(value) => unsafe { std::env::set_var("COLUMNS", value) },
476            None => unsafe { std::env::remove_var("COLUMNS") },
477        }
478
479        assert_eq!(divider.len(), 12);
480    }
481
482    #[test]
483    fn section_divider_can_style_border_and_title_separately() {
484        let theme = crate::ui::theme::resolve_theme("dracula");
485        let overrides = crate::ui::style::StyleOverrides {
486            panel_border: Some("#112233".to_string()),
487            panel_title: Some("#445566".to_string()),
488            ..Default::default()
489        };
490        let divider = render_section_divider_with_overrides(
491            "Info",
492            true,
493            Some(20),
494            SectionRenderContext {
495                color: true,
496                theme: &theme,
497                style_overrides: &overrides,
498            },
499            SectionStyleTokens {
500                border: crate::ui::style::StyleToken::PanelBorder,
501                title: crate::ui::style::StyleToken::PanelTitle,
502            },
503        );
504
505        assert!(divider.starts_with("\x1b[38;2;17;34;51m"));
506        assert!(divider.contains("\x1b[38;2;68;85;102mInfo\x1b[0m"));
507        assert!(divider.ends_with("\x1b[0m"));
508    }
509
510    #[test]
511    fn section_frame_style_parses_expected_names_unit() {
512        assert_eq!(
513            SectionFrameStyle::parse("top"),
514            Some(SectionFrameStyle::Top)
515        );
516        assert_eq!(
517            SectionFrameStyle::parse("top-bottom"),
518            Some(SectionFrameStyle::TopBottom)
519        );
520        assert_eq!(
521            SectionFrameStyle::parse("round"),
522            Some(SectionFrameStyle::Round)
523        );
524        assert_eq!(
525            SectionFrameStyle::parse("square"),
526            Some(SectionFrameStyle::Square)
527        );
528        assert_eq!(
529            SectionFrameStyle::parse("none"),
530            Some(SectionFrameStyle::None)
531        );
532    }
533
534    #[test]
535    fn top_bottom_section_frame_wraps_body_with_rules_unit() {
536        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
537        let render = SectionRenderContext {
538            color: false,
539            theme: &theme,
540            style_overrides: &crate::ui::style::StyleOverrides::default(),
541        };
542        let tokens = SectionStyleTokens {
543            border: crate::ui::style::StyleToken::PanelBorder,
544            title: crate::ui::style::StyleToken::PanelTitle,
545        };
546        let rendered = render_section_block_with_overrides(
547            "Commands",
548            "  show\n  delete",
549            SectionFrameStyle::TopBottom,
550            true,
551            Some(18),
552            render,
553            tokens,
554        );
555
556        assert!(rendered.contains("Commands"));
557        assert!(rendered.contains("show"));
558        assert!(
559            rendered
560                .lines()
561                .last()
562                .is_some_and(|line| line.contains('─'))
563        );
564    }
565
566    #[test]
567    fn square_section_frame_boxes_body_unit() {
568        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
569        let render = SectionRenderContext {
570            color: false,
571            theme: &theme,
572            style_overrides: &crate::ui::style::StyleOverrides::default(),
573        };
574        let tokens = SectionStyleTokens {
575            border: crate::ui::style::StyleToken::PanelBorder,
576            title: crate::ui::style::StyleToken::PanelTitle,
577        };
578        let rendered = render_section_block_with_overrides(
579            "Usage",
580            "osp config show",
581            SectionFrameStyle::Square,
582            true,
583            None,
584            render,
585            tokens,
586        );
587
588        assert!(rendered.contains("┌"));
589        assert!(rendered.contains("│ osp config show"));
590        assert!(rendered.contains("┘"));
591    }
592
593    #[test]
594    fn section_frame_styles_cover_none_bottom_and_round_unit() {
595        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
596        let render = SectionRenderContext {
597            color: false,
598            theme: &theme,
599            style_overrides: &crate::ui::style::StyleOverrides::default(),
600        };
601        let tokens = SectionStyleTokens {
602            border: crate::ui::style::StyleToken::PanelBorder,
603            title: crate::ui::style::StyleToken::PanelTitle,
604        };
605        let plain = render_section_block_with_overrides(
606            "Note",
607            "body",
608            SectionFrameStyle::None,
609            false,
610            Some(16),
611            render,
612            tokens,
613        );
614        let bottom = render_section_block_with_overrides(
615            "Note",
616            "body",
617            SectionFrameStyle::Bottom,
618            false,
619            Some(16),
620            render,
621            tokens,
622        );
623        let round = render_section_block_with_overrides(
624            "Note",
625            "body",
626            SectionFrameStyle::Round,
627            true,
628            Some(16),
629            render,
630            tokens,
631        );
632
633        assert!(plain.contains("Note:"));
634        assert!(bottom.lines().last().is_some_and(|line| line.contains('-')));
635        assert!(round.contains("╭"));
636        assert!(round.contains("╰"));
637    }
638}