1use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
32use ratatui::buffer::Buffer;
33use ratatui::layout::Rect;
34use ratatui::style::{Modifier, Style};
35use ratatui::text::{Line, Span};
36
37use crate::text;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct WidgetStyle {
55 pub content: Style,
57 pub secondary: Style,
59 pub muted: Style,
61 pub info: Style,
63 pub success: Style,
65 pub warning: Style,
67 pub danger: Style,
69 pub page: Style,
71 pub section: Style,
73 pub subsection: Style,
75 pub action: Style,
77 pub filled: Style,
80 pub sunken: Style,
84 pub focus: Modifier,
90 pub meter_cells: u16,
92 pub meter_full: char,
94 pub meter_empty: char,
96 pub required_marker: &'static str,
101}
102
103impl Default for WidgetStyle {
104 fn default() -> Self {
107 Self {
108 content: Style::new(),
109 secondary: Style::new(),
110 muted: Style::new().add_modifier(Modifier::DIM),
111 info: Style::new(),
112 success: Style::new(),
113 warning: Style::new(),
114 danger: Style::new().add_modifier(Modifier::BOLD),
115 page: Style::new().add_modifier(Modifier::BOLD),
116 section: Style::new().add_modifier(Modifier::BOLD),
117 subsection: Style::new(),
118 action: Style::new().add_modifier(Modifier::UNDERLINED),
119 filled: Style::new().add_modifier(Modifier::REVERSED),
120 sunken: Style::new().add_modifier(Modifier::DIM),
121 focus: Modifier::REVERSED,
122 meter_cells: 10,
123 meter_full: '#',
124 meter_empty: '-',
125 required_marker: "*",
126 }
127 }
128}
129
130impl WidgetStyle {
131 #[cfg(feature = "theme")]
138 #[must_use]
139 pub fn from_theme(theme: &crate::Theme) -> Self {
140 Self {
141 content: Style::new().fg(theme.content_primary),
142 secondary: Style::new().fg(theme.content_secondary),
143 muted: Style::new().fg(theme.content_muted),
144 info: Style::new().fg(theme.status_info),
145 success: Style::new().fg(theme.status_success),
146 warning: Style::new().fg(theme.status_warning),
147 danger: Style::new().fg(theme.status_danger),
148 page: Style::new()
155 .fg(theme.action_primary)
156 .add_modifier(Modifier::BOLD),
157 section: Style::new()
158 .fg(theme.content_primary)
159 .add_modifier(Modifier::BOLD),
160 subsection: Style::new().fg(theme.content_secondary),
161 action: Style::new().fg(theme.action_primary),
162 filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
163 sunken: Style::new().bg(theme.surface_sunken),
164 focus: Modifier::REVERSED,
165 meter_cells: 10,
166 meter_full: '#',
167 meter_empty: '-',
168 required_marker: "*",
169 }
170 }
171
172 #[must_use]
177 pub const fn tone(&self, tone: Tone) -> Style {
178 match tone {
179 Tone::Neutral => self.content,
180 Tone::Info => self.info,
181 Tone::Success => self.success,
182 Tone::Warning => self.warning,
183 Tone::Danger => self.danger,
184 }
185 }
186
187 #[must_use]
189 pub const fn heading(&self, level: Heading) -> Style {
190 match level {
191 Heading::Page => self.page,
192 Heading::Section => self.section,
193 Heading::Subsection => self.subsection,
194 }
195 }
196
197 #[must_use]
202 pub fn focused(&self, focused: bool, style: Style) -> Style {
203 if focused {
204 style.add_modifier(self.focus)
205 } else {
206 style
207 }
208 }
209}
210
211#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
222pub enum Held<'a> {
223 #[default]
225 Absent,
226 Text(&'a str),
230 On(bool),
232}
233
234impl<'a> Held<'a> {
235 #[must_use]
237 pub const fn text(self) -> &'a str {
238 match self {
239 Self::Text(text) => text,
240 Self::Absent | Self::On(_) => "",
241 }
242 }
243
244 #[must_use]
246 pub const fn on(self) -> bool {
247 matches!(self, Self::On(true))
248 }
249}
250
251#[must_use]
257pub fn meter(style: &WidgetStyle, meter: &Meter<'_>) -> Line<'static> {
258 let cells = u32::from(style.meter_cells);
259 let filled = meter
260 .done
261 .checked_mul(cells)
262 .and_then(|reached| reached.checked_div(meter.total))
263 .unwrap_or(0)
264 .min(cells);
265 let bar = format!(
266 "{}{}",
267 style.meter_full.to_string().repeat(filled as usize),
268 style
269 .meter_empty
270 .to_string()
271 .repeat((cells - filled) as usize)
272 );
273 let reading = match meter.label {
274 Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
275 None => format!(" {}/{}", meter.done, meter.total),
276 };
277 Line::from(vec![
278 Span::styled(bar, style.tone(meter.tone)),
279 Span::styled(reading, style.muted),
280 ])
281}
282
283#[must_use]
300pub fn token(
301 style: &WidgetStyle,
302 label: &str,
303 kind: Token,
304 tone: Tone,
305 latched: bool,
306 focused: bool,
307) -> Span<'static> {
308 let painted = style.tone(tone);
309 let painted = if latched {
310 painted.add_modifier(style.focus)
311 } else {
312 style.focused(focused, painted)
313 };
314 match kind {
315 Token::Badge => Span::styled(format!("({label})"), painted),
316 Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
317 }
318}
319
320#[must_use]
331pub fn act(style: &WidgetStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
332 let painted = if act.disabled() {
333 style.muted
334 } else {
335 style.focused(focused, style.tone(act.tone))
336 };
337 let label = match act.key {
338 Some(key) => format!("< {} > ({key})", act.label),
339 None => format!("< {} >", act.label),
340 };
341 Line::from(Span::styled(label, painted))
342}
343
344#[must_use]
350pub fn filled_act(style: &WidgetStyle, label: &str, focused: bool) -> Line<'static> {
351 Line::from(Span::styled(
352 format!("[ {label} ]"),
353 style.focused(focused, style.filled),
354 ))
355}
356
357#[must_use]
359pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
360 text::height(figure.value, width) + text::height(figure.caption, width)
361}
362
363pub fn figure(style: &WidgetStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
369 let value = match figure.change {
370 Some(change) => format!("{} {change}", figure.value),
371 None => figure.value.to_owned(),
372 };
373 let used = text::draw(
374 &value,
375 style.tone(figure.tone).add_modifier(Modifier::BOLD),
376 area,
377 buf,
378 );
379 used + text::draw(figure.caption, style.muted, below(area, used), buf)
380}
381
382#[must_use]
388pub fn field_height(style: &WidgetStyle, field: &Field<'_>, width: u16) -> u16 {
389 if !field.kind.visible() {
390 return 0;
391 }
392 let label = text::height(&label_of(style, field), width);
393 let body = match field.kind {
394 FieldKind::Textarea => 3,
395 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
396 _ => 1,
397 };
398 let note = note_of(field).map_or(0, |note| text::height(note, width));
399 label + body + note
400}
401
402pub fn field(
410 style: &WidgetStyle,
411 field: &Field<'_>,
412 held: Held<'_>,
413 focused: bool,
414 area: Rect,
415 buf: &mut Buffer,
416) -> u16 {
417 if !field.kind.visible() || area.width == 0 || area.height == 0 {
420 return 0;
421 }
422
423 let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
424
425 let well = style.focused(focused, style.content);
426 let placeholder = field.placeholder.unwrap_or_default();
427
428 used += match field.kind {
429 FieldKind::Checkbox => text::draw(
430 if held.on() { "[x]" } else { "[ ]" },
431 well,
432 below(area, used),
433 buf,
434 ),
435 kind if kind.offers_options() => {
436 let mut rows = 0;
437 for choice in field.options {
438 let chosen = held.text() == choice.value;
439 let mark = if chosen { "(*)" } else { "( )" };
440 rows += text::draw(
441 &format!("{mark} {}", choice.label),
442 if chosen { well } else { style.muted },
443 below(area, used + rows),
444 buf,
445 );
446 }
447 rows
448 }
449 FieldKind::Secret if !held.text().is_empty() => {
454 let dots = "*".repeat(held.text().chars().count());
455 text::draw(&dots, well, below(area, used), buf).max(1)
456 }
457 _ if held.text().is_empty() => {
461 empty_well(style, placeholder, well, focused, below(area, used), buf)
462 }
463 _ => text::draw(held.text(), well, below(area, used), buf),
464 };
465
466 match note_of(field) {
470 Some(note) => {
471 let painted = if field.error.is_some() {
472 style.danger
473 } else {
474 style.muted
475 };
476 used + text::draw(note, painted, below(area, used), buf)
477 }
478 None => used,
479 }
480}
481
482fn label_of(style: &WidgetStyle, field: &Field<'_>) -> String {
484 if field.required {
485 format!("{} {}", field.label, style.required_marker)
486 } else {
487 field.label.to_owned()
488 }
489}
490
491fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
493 field.error.or(field.hint)
494}
495
496fn empty_well(
504 style: &WidgetStyle,
505 placeholder: &str,
506 well: Style,
507 focused: bool,
508 area: Rect,
509 buf: &mut Buffer,
510) -> u16 {
511 let used = text::draw(placeholder, style.muted, area, buf).max(1);
512 if focused
513 && area.height > 0
514 && area.width > 0
515 && let Some(cell) = buf.cell_mut((area.x, area.y))
516 {
517 cell.set_style(well);
518 }
519 used
520}
521
522fn below(area: Rect, used: u16) -> Rect {
524 let used = used.min(area.height);
525 Rect {
526 x: area.x,
527 y: area.y + used,
528 width: area.width,
529 height: area.height - used,
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use makeover_layout::{Choice, State};
537
538 fn style() -> WidgetStyle {
541 WidgetStyle {
542 content: Style::new().add_modifier(Modifier::BOLD),
543 muted: Style::new().add_modifier(Modifier::DIM),
544 danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
545 ..WidgetStyle::default()
546 }
547 }
548
549 fn buffer(width: u16, height: u16) -> Buffer {
550 Buffer::empty(Rect::new(0, 0, width, height))
551 }
552
553 fn rows(buf: &Buffer) -> Vec<String> {
555 (0..buf.area.height)
556 .map(|y| {
557 (0..buf.area.width)
558 .map(|x| {
559 buf.cell((x, y))
560 .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
561 })
562 .collect::<String>()
563 .trim_end()
564 .to_owned()
565 })
566 .collect()
567 }
568
569 #[test]
570 fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
571 let style = style();
572 let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
573 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
574 assert_eq!(drawn, "###------- 3/10 subtasks");
575 let bare = meter(&style, &Meter::new(3, 10));
578 let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
579 assert_eq!(drawn, "###------- 3/10");
580 }
581
582 #[test]
583 fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
584 let line = meter(&style(), &Meter::new(0, 0));
587 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
588 assert_eq!(drawn, "---------- 0/0");
589 }
590
591 #[test]
592 fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
593 let line = meter(&style(), &Meter::new(14, 10));
596 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
597 assert_eq!(drawn, "########## 14/10");
598 }
599
600 #[test]
601 fn a_badge_is_round_and_a_chip_is_square() {
602 let style = style();
605 let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
606 assert_eq!(badge.content.as_ref(), "(draft)");
607 let chip = token(
608 &style,
609 "rust",
610 Token::Chip { removable: false },
611 Tone::Neutral,
612 false,
613 false,
614 );
615 assert_eq!(chip.content.as_ref(), "[rust]");
616 }
617
618 #[test]
619 fn a_latched_chip_reads_the_same_as_a_focused_one() {
620 let style = style();
624 let kind = Token::Chip { removable: false };
625 let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
626 let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
627 assert_eq!(latched.style, focused.style);
628 assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
629 }
630
631 #[test]
632 fn a_control_draws_its_key_only_where_one_was_named() {
633 let style = style();
634 let line = act(&style, &Act::new("Delete"), false);
635 assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
636 let line = act(&style, &Act::new("Quit").key("q"), false);
637 assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
638 }
639
640 #[test]
641 fn a_disabled_control_is_never_marked_focused() {
642 let style = style();
645 let disabled = Act::new("Save").state(State::Disabled);
646 let line = act(&style, &disabled, true);
647 assert!(
648 !line.spans[0]
649 .style
650 .add_modifier
651 .contains(Modifier::REVERSED)
652 );
653 assert_eq!(line.spans[0].style, style.muted);
654 let focused_state = Act::new("Save").state(State::Focus);
656 let line = act(&style, &focused_state, true);
657 assert!(
658 line.spans[0]
659 .style
660 .add_modifier
661 .contains(Modifier::REVERSED)
662 );
663 }
664
665 #[test]
666 fn a_danger_control_keeps_its_tone_under_focus() {
667 let style = style();
670 let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
671 assert_eq!(
672 line.spans[0].style.add_modifier,
673 style.danger.add_modifier | Modifier::REVERSED
674 );
675 }
676
677 #[test]
678 fn a_figure_puts_the_number_over_what_it_counts() {
679 let style = style();
680 let figure_ = Figure::new("42", "open tasks");
681 let mut buf = buffer(20, 4);
682 let used = figure(&style, &figure_, buf.area, &mut buf);
683 assert_eq!(used, 2);
684 assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
685 assert_eq!(figure_height(&figure_, 20), 2);
686 }
687
688 #[test]
689 fn a_figures_change_rides_on_the_value_row() {
690 let style = style();
693 let figure_ = Figure::new("42", "open tasks")
694 .change("+3")
695 .tone(Tone::Success);
696 let mut buf = buffer(20, 4);
697 figure(&style, &figure_, buf.area, &mut buf);
698 assert_eq!(rows(&buf)[0], "42 +3");
699 }
700
701 #[test]
702 fn a_compulsory_field_says_so_in_its_label() {
703 let style = style();
704 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
705 field_.required = true;
706 let mut buf = buffer(20, 4);
707 field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
708 assert_eq!(rows(&buf)[0], "Email *");
709 }
710
711 #[test]
712 fn a_hidden_field_costs_no_rows_at_all() {
713 let style = style();
715 let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
716 let mut buf = buffer(20, 4);
717 assert_eq!(
718 field(
719 &style,
720 &field_,
721 Held::Text("abc"),
722 false,
723 buf.area,
724 &mut buf
725 ),
726 0
727 );
728 assert_eq!(field_height(&style, &field_, 20), 0);
729 assert_eq!(rows(&buf)[0], "");
730 }
731
732 #[test]
733 fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
734 let style = style();
737 let field_ = Field::new(FieldKind::Secret, "password", "Password");
738 let mut buf = buffer(20, 4);
739 field(
740 &style,
741 &field_,
742 Held::Text("hunter2"),
743 false,
744 buf.area,
745 &mut buf,
746 );
747 assert_eq!(rows(&buf)[1], "*******");
748 }
749
750 #[test]
751 fn an_error_takes_the_row_the_hint_would_have_had() {
752 let style = style();
755 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
756 field_.hint = Some("work address");
757 field_.error = Some("not an address");
758 let mut buf = buffer(20, 5);
759 field(
760 &style,
761 &field_,
762 Held::Text("nope"),
763 false,
764 buf.area,
765 &mut buf,
766 );
767 assert_eq!(rows(&buf)[2], "not an address");
768 assert_eq!(field_height(&style, &field_, 20), 3);
769 }
770
771 #[test]
772 fn a_focused_empty_box_shows_where_the_typing_will_land() {
773 let style = style();
776 let field_ = Field::new(FieldKind::Text, "email", "Email");
777 let mut buf = buffer(20, 4);
778 field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
779 let caret = buf.cell((0, 1)).expect("the well's first cell").style();
780 assert!(caret.add_modifier.contains(Modifier::REVERSED));
781 }
782
783 #[test]
784 fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
785 let style = style();
786 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
787 let options = [Choice::plain("small"), Choice::plain("large")];
788 field_.options = &options;
789 let mut buf = buffer(20, 5);
790 field(
791 &style,
792 &field_,
793 Held::Text("large"),
794 false,
795 buf.area,
796 &mut buf,
797 );
798 assert_eq!(rows(&buf)[1], "( ) small");
799 assert_eq!(rows(&buf)[2], "(*) large");
800 assert_eq!(field_height(&style, &field_, 20), 3);
801 }
802
803 #[test]
804 fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
805 let style = style();
808 let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
809 let mut buf = buffer(20, 4);
810 field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
811 assert_eq!(rows(&buf)[1], "[x]");
812 let mut buf = buffer(20, 4);
813 field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
814 assert_eq!(rows(&buf)[1], "[ ]");
815 }
816
817 #[test]
818 fn a_tone_and_a_heading_map_without_a_fallback_arm() {
819 let style = style();
822 assert_eq!(style.tone(Tone::Neutral), style.content);
823 assert_eq!(style.tone(Tone::Danger), style.danger);
824 assert_eq!(style.heading(Heading::Page), style.page);
825 assert_eq!(style.heading(Heading::Subsection), style.subsection);
826 }
827
828 #[test]
829 fn the_default_style_carries_no_colour_at_all() {
830 let style = WidgetStyle::default();
833 for painted in [style.content, style.danger, style.page, style.action] {
834 assert_eq!(painted.fg, None);
835 assert_eq!(painted.bg, None);
836 }
837 }
838}