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 {
104 Self {
105 border: token,
106 title: token,
107 }
108 }
109}
110
111#[derive(Clone, Copy)]
113pub struct SectionRenderContext<'a> {
114 pub color: bool,
116 pub theme: &'a ThemeDefinition,
118 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)]
133pub 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
155pub 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
168pub 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
240pub 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}