Skip to main content

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