1use crate::ui::style::{StyleOverrides, StyleToken, apply_style_with_theme_overrides};
15use crate::ui::theme::ThemeDefinition;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum SectionFrameStyle {
20 None,
22 #[default]
24 Top,
25 Bottom,
27 TopBottom,
29 Square,
31 Round,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum RuledSectionPolicy {
38 #[default]
40 PerSection,
41 Shared,
43}
44
45impl SectionFrameStyle {
46 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 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#[derive(Debug, Clone, Copy)]
83pub struct SectionStyleTokens {
84 pub border: StyleToken,
86 pub title: StyleToken,
88}
89
90impl SectionStyleTokens {
91 pub const fn same(token: StyleToken) -> Self {
105 Self {
106 border: token,
107 title: token,
108 }
109 }
110}
111
112#[derive(Clone, Copy)]
114pub struct SectionRenderContext<'a> {
115 pub color: bool,
117 pub theme: &'a ThemeDefinition,
119 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)]
134pub 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
156pub 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
169pub 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
241pub 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}