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}