Skip to main content

osp_cli/ui/
chrome.rs

1//! Reusable section chrome helpers for messages, help, and guide rendering.
2//!
3//! This module exists so the rest of the UI can ask for titled sections and
4//! framed blocks without duplicating border logic in every renderer. It keeps
5//! section-frame policy, title styling, and ASCII/Unicode fallback behavior in
6//! one place.
7//!
8//! Contract:
9//!
10//! - chrome helpers may depend on theme/style resolution
11//! - they should not decide *when* sections are shown, only how a requested
12//!   section frame is rendered
13
14use crate::ui::style::{StyleOverrides, StyleToken, apply_style_with_theme_overrides};
15use crate::ui::theme::ThemeDefinition;
16
17/// Frame style used when rendering section chrome.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum SectionFrameStyle {
20    /// Render no surrounding frame.
21    None,
22    /// Render a top rule only.
23    #[default]
24    Top,
25    /// Render a bottom rule only.
26    Bottom,
27    /// Render both top and bottom rules.
28    TopBottom,
29    /// Render a square boxed frame.
30    Square,
31    /// Render a rounded boxed frame.
32    Round,
33}
34
35/// Placement policy for ruled section separators across sibling sections.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum RuledSectionPolicy {
38    /// Render each section independently according to its frame style.
39    #[default]
40    PerSection,
41    /// Share titled top rules between sibling sections and close the list once.
42    Shared,
43}
44
45impl SectionFrameStyle {
46    /// Parses the section-frame spellings accepted by configuration.
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use osp_cli::ui::SectionFrameStyle;
52    ///
53    /// assert_eq!(SectionFrameStyle::parse("rules"), Some(SectionFrameStyle::TopBottom));
54    /// assert_eq!(SectionFrameStyle::parse("boxed"), Some(SectionFrameStyle::Square));
55    /// assert_eq!(SectionFrameStyle::parse("wat"), None);
56    /// ```
57    pub fn parse(value: &str) -> Option<Self> {
58        match value.trim().to_ascii_lowercase().as_str() {
59            "none" | "plain" => Some(Self::None),
60            "top" | "rule-top" => Some(Self::Top),
61            "bottom" | "rule-bottom" => Some(Self::Bottom),
62            "top-bottom" | "both" | "rules" => Some(Self::TopBottom),
63            "square" | "box" | "boxed" => Some(Self::Square),
64            "round" | "rounded" => Some(Self::Round),
65            _ => None,
66        }
67    }
68}
69
70impl RuledSectionPolicy {
71    /// Parses the ruled-section policy spellings accepted by configuration.
72    pub fn parse(value: &str) -> Option<Self> {
73        match value.trim().to_ascii_lowercase().as_str() {
74            "per-section" | "independent" | "separate" => Some(Self::PerSection),
75            "shared" | "stacked" | "list" => Some(Self::Shared),
76            _ => None,
77        }
78    }
79}
80
81/// Style tokens applied to section borders and titles.
82#[derive(Debug, Clone, Copy)]
83pub struct SectionStyleTokens {
84    /// Style token used for borders and rules.
85    pub border: StyleToken,
86    /// Style token used for titles.
87    pub title: StyleToken,
88}
89
90impl SectionStyleTokens {
91    /// Uses the same style token for both the border and the title.
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use osp_cli::ui::{SectionStyleTokens, StyleToken};
97    ///
98    /// let tokens = SectionStyleTokens::same(StyleToken::PanelTitle);
99    ///
100    /// assert_eq!(tokens.border, StyleToken::PanelTitle);
101    /// assert_eq!(tokens.title, StyleToken::PanelTitle);
102    /// ```
103    pub const fn same(token: StyleToken) -> Self {
104        Self {
105            border: token,
106            title: token,
107        }
108    }
109}
110
111/// Context required to render section chrome with semantic styling.
112#[derive(Clone, Copy)]
113pub struct SectionRenderContext<'a> {
114    /// Whether ANSI color output is enabled.
115    pub color: bool,
116    /// Active theme used for style resolution.
117    pub theme: &'a ThemeDefinition,
118    /// Explicit style overrides layered over the theme.
119    pub style_overrides: &'a StyleOverrides,
120}
121
122impl SectionRenderContext<'_> {
123    fn style(self, text: &str, token: StyleToken) -> String {
124        if self.color {
125            apply_style_with_theme_overrides(text, token, true, self.theme, self.style_overrides)
126        } else {
127            text.to_string()
128        }
129    }
130}
131
132#[cfg(test)]
133/// Renders a single section divider line for the given title and width hint.
134pub fn render_section_divider(
135    title: &str,
136    unicode: bool,
137    width: Option<usize>,
138    color: bool,
139    theme: &ThemeDefinition,
140    token: StyleToken,
141) -> String {
142    render_section_divider_with_overrides(
143        title,
144        unicode,
145        width,
146        SectionRenderContext {
147            color,
148            theme,
149            style_overrides: &StyleOverrides::default(),
150        },
151        SectionStyleTokens::same(token),
152    )
153}
154
155/// Renders a section divider line using explicit style overrides.
156///
157/// Returns an unstyled divider when `render.color` is `false`.
158pub fn render_section_divider_with_overrides(
159    title: &str,
160    unicode: bool,
161    width: Option<usize>,
162    render: SectionRenderContext<'_>,
163    tokens: SectionStyleTokens,
164) -> String {
165    render_section_divider_with_columns(title, unicode, width, 2, render, tokens)
166}
167
168/// Renders a section divider while controlling the title start column.
169///
170/// `title_columns` describes how many columns the divider should consume before
171/// the title text starts. This lets shared ruled sections keep header lines
172/// flush-left while still aligning the title with indented body content.
173pub fn render_section_divider_with_columns(
174    title: &str,
175    unicode: bool,
176    width: Option<usize>,
177    title_columns: usize,
178    render: SectionRenderContext<'_>,
179    tokens: SectionStyleTokens,
180) -> String {
181    let border_token = tokens.border;
182    let title_token = tokens.title;
183    let fill_char = if unicode { '─' } else { '-' };
184    let target_width = width.unwrap_or(12).max(12);
185    let title = title.trim();
186
187    let raw = if title.is_empty() {
188        fill_char.to_string().repeat(target_width)
189    } else {
190        let title_columns = title_columns.max(2);
191        let prefix = format!(
192            "{} {title} ",
193            fill_char
194                .to_string()
195                .repeat(title_columns.saturating_sub(1))
196        );
197        let prefix_width = prefix.chars().count();
198        if prefix_width >= target_width {
199            prefix
200        } else {
201            format!(
202                "{prefix}{}",
203                fill_char.to_string().repeat(target_width - prefix_width)
204            )
205        }
206    };
207
208    if !render.color {
209        return raw;
210    }
211
212    if title.is_empty() || title_token == border_token {
213        return render.style(&raw, border_token);
214    }
215
216    let title_columns = title_columns.max(2);
217    let prefix = format!(
218        "{} ",
219        fill_char
220            .to_string()
221            .repeat(title_columns.saturating_sub(1))
222    );
223    let title_text = title;
224    let prefix_width = prefix.chars().count();
225    let title_width = title_text.chars().count();
226    let base_width = prefix_width + title_width + 1;
227    let fill_len = target_width.saturating_sub(base_width);
228    let suffix = if fill_len == 0 {
229        " ".to_string()
230    } else {
231        format!(" {}", fill_char.to_string().repeat(fill_len))
232    };
233
234    let styled_prefix = render.style(&prefix, border_token);
235    let styled_title = render.style(title_text, title_token);
236    let styled_suffix = render.style(&suffix, border_token);
237    format!("{styled_prefix}{styled_title}{styled_suffix}")
238}
239
240/// Renders one titled section body with the requested frame and style tokens.
241///
242/// The returned text is newline-free at the end so callers can compose several
243/// sections without trimming renderer output.
244///
245/// # Examples
246///
247/// ```
248/// use osp_cli::ui::{
249///     SectionFrameStyle, SectionRenderContext, SectionStyleTokens, StyleOverrides,
250///     StyleToken, render_section_block_with_overrides,
251/// };
252///
253/// let theme = osp_cli::ui::resolve_theme("plain");
254/// let rendered = render_section_block_with_overrides(
255///     "Errors",
256///     "- bad",
257///     SectionFrameStyle::TopBottom,
258///     false,
259///     Some(18),
260///     SectionRenderContext {
261///         color: false,
262///         theme: &theme,
263///         style_overrides: &StyleOverrides::default(),
264///     },
265///     SectionStyleTokens::same(StyleToken::MessageError),
266/// );
267///
268/// assert!(rendered.contains("Errors"));
269/// assert!(rendered.contains("- bad"));
270/// ```
271pub fn render_section_block_with_overrides(
272    title: &str,
273    body: &str,
274    frame_style: SectionFrameStyle,
275    unicode: bool,
276    width: Option<usize>,
277    render: SectionRenderContext<'_>,
278    tokens: SectionStyleTokens,
279) -> String {
280    match frame_style {
281        SectionFrameStyle::None => render_plain_section(title, body, render, tokens.title),
282        SectionFrameStyle::Top => {
283            render_ruled_section(title, body, true, false, unicode, width, render, tokens)
284        }
285        SectionFrameStyle::Bottom => {
286            render_ruled_section(title, body, false, true, unicode, width, render, tokens)
287        }
288        SectionFrameStyle::TopBottom => {
289            render_ruled_section(title, body, true, true, unicode, width, render, tokens)
290        }
291        SectionFrameStyle::Square => render_boxed_section(
292            title,
293            body,
294            unicode,
295            render,
296            tokens,
297            BoxFrameChars::square(unicode),
298        ),
299        SectionFrameStyle::Round => render_boxed_section(
300            title,
301            body,
302            unicode,
303            render,
304            tokens,
305            BoxFrameChars::round(unicode),
306        ),
307    }
308}
309
310fn render_plain_section(
311    title: &str,
312    body: &str,
313    render: SectionRenderContext<'_>,
314    title_token: StyleToken,
315) -> String {
316    let mut out = String::new();
317    let title = title.trim();
318    let body = body.trim_end_matches('\n');
319
320    if !title.is_empty() {
321        let raw_title = format!("{title}:");
322        out.push_str(&style_segment(&raw_title, render, title_token));
323        if !body.is_empty() {
324            out.push('\n');
325        }
326    }
327    if !body.is_empty() {
328        out.push_str(body);
329    }
330    out
331}
332
333#[allow(clippy::too_many_arguments)]
334fn render_ruled_section(
335    title: &str,
336    body: &str,
337    top_rule: bool,
338    bottom_rule: bool,
339    unicode: bool,
340    width: Option<usize>,
341    render: SectionRenderContext<'_>,
342    tokens: SectionStyleTokens,
343) -> String {
344    let mut out = String::new();
345    let body = body.trim_end_matches('\n');
346    let title = title.trim();
347
348    if top_rule {
349        out.push_str(&render_section_divider_with_overrides(
350            title, unicode, width, render, tokens,
351        ));
352    } else if !title.is_empty() {
353        let raw_title = format!("{title}:");
354        out.push_str(&style_segment(&raw_title, render, tokens.title));
355    }
356
357    if !body.is_empty() {
358        if !out.is_empty() {
359            out.push('\n');
360        }
361        out.push_str(body);
362    }
363
364    if bottom_rule {
365        if !out.is_empty() {
366            out.push('\n');
367        }
368        out.push_str(&render_section_divider_with_overrides(
369            "",
370            unicode,
371            width,
372            render,
373            SectionStyleTokens::same(tokens.border),
374        ));
375    }
376
377    out
378}
379
380#[derive(Debug, Clone, Copy)]
381struct BoxFrameChars {
382    top_left: char,
383    top_right: char,
384    bottom_left: char,
385    bottom_right: char,
386    horizontal: char,
387    vertical: char,
388}
389
390impl BoxFrameChars {
391    fn square(unicode: bool) -> Self {
392        if unicode {
393            Self {
394                top_left: '┌',
395                top_right: '┐',
396                bottom_left: '└',
397                bottom_right: '┘',
398                horizontal: '─',
399                vertical: '│',
400            }
401        } else {
402            Self {
403                top_left: '+',
404                top_right: '+',
405                bottom_left: '+',
406                bottom_right: '+',
407                horizontal: '-',
408                vertical: '|',
409            }
410        }
411    }
412
413    fn round(unicode: bool) -> Self {
414        if unicode {
415            Self {
416                top_left: '╭',
417                top_right: '╮',
418                bottom_left: '╰',
419                bottom_right: '╯',
420                horizontal: '─',
421                vertical: '│',
422            }
423        } else {
424            Self::square(false)
425        }
426    }
427}
428
429#[allow(clippy::too_many_arguments)]
430fn render_boxed_section(
431    title: &str,
432    body: &str,
433    _unicode: bool,
434    render: SectionRenderContext<'_>,
435    tokens: SectionStyleTokens,
436    chars: BoxFrameChars,
437) -> String {
438    let lines = section_body_lines(body);
439    let title = title.trim();
440    let body_width = lines
441        .iter()
442        .map(|line| visible_width(line))
443        .max()
444        .unwrap_or(0);
445    let title_width = if title.is_empty() {
446        0
447    } else {
448        title.chars().count() + 2
449    };
450    let inner_width = body_width.max(title_width).max(8);
451
452    let mut out = String::new();
453    out.push_str(&render_box_top(title, inner_width, chars, render, tokens));
454
455    if !lines.is_empty() {
456        out.push('\n');
457    }
458
459    for (index, line) in lines.iter().enumerate() {
460        if index > 0 {
461            out.push('\n');
462        }
463        out.push_str(&render_box_body_line(
464            line,
465            inner_width,
466            chars,
467            render,
468            tokens.border,
469        ));
470    }
471
472    if !out.is_empty() {
473        out.push('\n');
474    }
475    out.push_str(&style_segment(
476        &format!(
477            "{}{}{}",
478            chars.bottom_left,
479            chars.horizontal.to_string().repeat(inner_width + 2),
480            chars.bottom_right
481        ),
482        render,
483        tokens.border,
484    ));
485    out
486}
487
488fn render_box_top(
489    title: &str,
490    inner_width: usize,
491    chars: BoxFrameChars,
492    render: SectionRenderContext<'_>,
493    tokens: SectionStyleTokens,
494) -> String {
495    if title.is_empty() {
496        return style_segment(
497            &format!(
498                "{}{}{}",
499                chars.top_left,
500                chars.horizontal.to_string().repeat(inner_width + 2),
501                chars.top_right
502            ),
503            render,
504            tokens.border,
505        );
506    }
507
508    let title_width = title.chars().count();
509    let remaining = inner_width.saturating_sub(title_width);
510    let left = format!("{} ", chars.top_left);
511    let right = format!(
512        " {}{}",
513        chars.horizontal.to_string().repeat(remaining),
514        chars.top_right
515    );
516
517    format!(
518        "{}{}{}",
519        style_segment(&left, render, tokens.border,),
520        style_segment(title, render, tokens.title),
521        style_segment(&right, render, tokens.border,),
522    )
523}
524
525fn render_box_body_line(
526    line: &str,
527    inner_width: usize,
528    chars: BoxFrameChars,
529    render: SectionRenderContext<'_>,
530    border_token: StyleToken,
531) -> String {
532    let padding = inner_width.saturating_sub(visible_width(line));
533    let left = format!("{} ", chars.vertical);
534    let right = format!("{} {}", " ".repeat(padding), chars.vertical);
535    format!(
536        "{}{}{}",
537        style_segment(&left, render, border_token,),
538        line,
539        style_segment(&right, render, border_token,),
540    )
541}
542
543fn style_segment(text: &str, render: SectionRenderContext<'_>, token: StyleToken) -> String {
544    render.style(text, token)
545}
546
547fn section_body_lines(body: &str) -> Vec<&str> {
548    body.trim_end_matches('\n')
549        .lines()
550        .map(str::trim_end)
551        .collect()
552}
553
554fn visible_width(text: &str) -> usize {
555    let mut width = 0usize;
556    let mut chars = text.chars().peekable();
557
558    while let Some(ch) = chars.next() {
559        if ch == '\x1b' && matches!(chars.peek(), Some('[')) {
560            chars.next();
561            for next in chars.by_ref() {
562                if ('@'..='~').contains(&next) {
563                    break;
564                }
565            }
566            continue;
567        }
568        width += 1;
569    }
570
571    width
572}
573
574#[cfg(test)]
575mod tests {
576    use super::{
577        SectionFrameStyle, SectionRenderContext, SectionStyleTokens,
578        render_section_block_with_overrides, render_section_divider,
579        render_section_divider_with_overrides,
580    };
581    use std::sync::Mutex;
582
583    fn env_lock() -> &'static Mutex<()> {
584        crate::tests::env_lock()
585    }
586
587    #[test]
588    fn section_divider_ignores_columns_env_without_explicit_width() {
589        let _guard = env_lock().lock().expect("lock should not be poisoned");
590        let original = std::env::var("COLUMNS").ok();
591        unsafe {
592            std::env::set_var("COLUMNS", "99");
593        }
594
595        let divider = render_section_divider(
596            "",
597            false,
598            None,
599            false,
600            &crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME),
601            crate::ui::style::StyleToken::PanelBorder,
602        );
603
604        match original {
605            Some(value) => unsafe { std::env::set_var("COLUMNS", value) },
606            None => unsafe { std::env::remove_var("COLUMNS") },
607        }
608
609        assert_eq!(divider.len(), 12);
610    }
611
612    #[test]
613    fn section_divider_can_style_border_and_title_separately() {
614        let theme = crate::ui::theme::resolve_theme("dracula");
615        let overrides = crate::ui::style::StyleOverrides {
616            panel_border: Some("#112233".to_string()),
617            panel_title: Some("#445566".to_string()),
618            ..Default::default()
619        };
620        let divider = render_section_divider_with_overrides(
621            "Info",
622            true,
623            Some(20),
624            SectionRenderContext {
625                color: true,
626                theme: &theme,
627                style_overrides: &overrides,
628            },
629            SectionStyleTokens {
630                border: crate::ui::style::StyleToken::PanelBorder,
631                title: crate::ui::style::StyleToken::PanelTitle,
632            },
633        );
634
635        assert!(divider.starts_with("\x1b[38;2;17;34;51m"));
636        assert!(divider.contains("\x1b[38;2;68;85;102mInfo\x1b[0m"));
637        assert!(divider.ends_with("\x1b[0m"));
638    }
639
640    #[test]
641    fn section_frame_style_parses_expected_names_unit() {
642        assert_eq!(
643            SectionFrameStyle::parse("top"),
644            Some(SectionFrameStyle::Top)
645        );
646        assert_eq!(
647            SectionFrameStyle::parse("top-bottom"),
648            Some(SectionFrameStyle::TopBottom)
649        );
650        assert_eq!(
651            SectionFrameStyle::parse("round"),
652            Some(SectionFrameStyle::Round)
653        );
654        assert_eq!(
655            SectionFrameStyle::parse("square"),
656            Some(SectionFrameStyle::Square)
657        );
658        assert_eq!(
659            SectionFrameStyle::parse("none"),
660            Some(SectionFrameStyle::None)
661        );
662    }
663
664    #[test]
665    fn ruled_section_policy_parses_expected_names_unit() {
666        assert_eq!(
667            super::RuledSectionPolicy::parse("per-section"),
668            Some(super::RuledSectionPolicy::PerSection)
669        );
670        assert_eq!(
671            super::RuledSectionPolicy::parse("stacked"),
672            Some(super::RuledSectionPolicy::Shared)
673        );
674        assert_eq!(
675            super::RuledSectionPolicy::parse("list"),
676            Some(super::RuledSectionPolicy::Shared)
677        );
678        assert_eq!(super::RuledSectionPolicy::parse("wat"), None);
679    }
680
681    #[test]
682    fn top_bottom_section_frame_wraps_body_with_rules_unit() {
683        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
684        let render = SectionRenderContext {
685            color: false,
686            theme: &theme,
687            style_overrides: &crate::ui::style::StyleOverrides::default(),
688        };
689        let tokens = SectionStyleTokens {
690            border: crate::ui::style::StyleToken::PanelBorder,
691            title: crate::ui::style::StyleToken::PanelTitle,
692        };
693        let rendered = render_section_block_with_overrides(
694            "Commands",
695            "  show\n  delete",
696            SectionFrameStyle::TopBottom,
697            true,
698            Some(18),
699            render,
700            tokens,
701        );
702
703        assert!(rendered.contains("Commands"));
704        assert!(rendered.contains("show"));
705        assert!(
706            rendered
707                .lines()
708                .last()
709                .is_some_and(|line| line.contains('─'))
710        );
711    }
712
713    #[test]
714    fn square_section_frame_boxes_body_unit() {
715        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
716        let render = SectionRenderContext {
717            color: false,
718            theme: &theme,
719            style_overrides: &crate::ui::style::StyleOverrides::default(),
720        };
721        let tokens = SectionStyleTokens {
722            border: crate::ui::style::StyleToken::PanelBorder,
723            title: crate::ui::style::StyleToken::PanelTitle,
724        };
725        let rendered = render_section_block_with_overrides(
726            "Usage",
727            "osp config show",
728            SectionFrameStyle::Square,
729            true,
730            None,
731            render,
732            tokens,
733        );
734
735        assert!(rendered.contains("┌"));
736        assert!(rendered.contains("│ osp config show"));
737        assert!(rendered.contains("┘"));
738    }
739
740    #[test]
741    fn section_frame_styles_cover_none_bottom_and_round_unit() {
742        let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
743        let render = SectionRenderContext {
744            color: false,
745            theme: &theme,
746            style_overrides: &crate::ui::style::StyleOverrides::default(),
747        };
748        let tokens = SectionStyleTokens {
749            border: crate::ui::style::StyleToken::PanelBorder,
750            title: crate::ui::style::StyleToken::PanelTitle,
751        };
752        let plain = render_section_block_with_overrides(
753            "Note",
754            "body",
755            SectionFrameStyle::None,
756            false,
757            Some(16),
758            render,
759            tokens,
760        );
761        let bottom = render_section_block_with_overrides(
762            "Note",
763            "body",
764            SectionFrameStyle::Bottom,
765            false,
766            Some(16),
767            render,
768            tokens,
769        );
770        let round = render_section_block_with_overrides(
771            "Note",
772            "body",
773            SectionFrameStyle::Round,
774            true,
775            Some(16),
776            render,
777            tokens,
778        );
779
780        assert!(plain.contains("Note:"));
781        assert!(bottom.lines().last().is_some_and(|line| line.contains('-')));
782        assert!(round.contains("╭"));
783        assert!(round.contains("╰"));
784    }
785}