Skip to main content

fresh/view/controls/toggle/
render.rs

1//! Toggle rendering functions
2
3use ratatui::layout::Rect;
4use ratatui::style::Style;
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Paragraph;
7use ratatui::Frame;
8
9use crate::primitives::display_width::str_width;
10
11use super::{FocusState, ToggleColors, ToggleLayout, ToggleState};
12
13fn pad_to_display_width(label: &str, width: u16) -> String {
14    let width = width as usize;
15    let padding = width.saturating_sub(str_width(label));
16    let mut padded = String::with_capacity(label.len() + padding);
17    padded.push_str(label);
18    padded.extend(std::iter::repeat_n(' ', padding));
19    padded
20}
21
22/// Render a toggle control
23///
24/// # Arguments
25/// * `frame` - The ratatui frame to render to
26/// * `area` - Rectangle where the toggle should be rendered
27/// * `state` - The toggle state
28/// * `colors` - Colors for rendering
29///
30/// # Returns
31/// Layout information for hit testing
32pub fn render_toggle(
33    frame: &mut Frame,
34    area: Rect,
35    state: &ToggleState,
36    colors: &ToggleColors,
37) -> ToggleLayout {
38    render_toggle_aligned(frame, area, state, colors, None)
39}
40
41/// Render a toggle control with optional label width alignment
42///
43/// # Arguments
44/// * `frame` - The ratatui frame to render to
45/// * `area` - Rectangle where the toggle should be rendered
46/// * `state` - The toggle state
47/// * `colors` - Colors for rendering
48/// * `label_width` - Optional minimum label width for alignment
49///
50/// # Returns
51/// Layout information for hit testing
52pub fn render_toggle_aligned(
53    frame: &mut Frame,
54    area: Rect,
55    state: &ToggleState,
56    colors: &ToggleColors,
57    label_width: Option<u16>,
58) -> ToggleLayout {
59    if area.height == 0 || area.width < 4 {
60        return ToggleLayout {
61            checkbox_area: Rect::default(),
62            full_area: area,
63        };
64    }
65
66    // When focused/hovered the chip sits on top of the row's highlight bg
67    // (settings_selected_bg / menu_hover_bg). Use `focused_fg` for the
68    // checkmark too — themes guarantee `focused_fg` contrasts with
69    // `focused` (their bg), whereas `checkmark` is green-ish in most
70    // themes and collides with green-tinted highlights (e.g. Nostalgia).
71    let (bracket_color, check_color, label_color) = match state.focus {
72        FocusState::Normal => (colors.bracket, colors.checkmark, colors.label),
73        FocusState::Focused => (colors.focused_fg, colors.focused_fg, colors.focused_fg),
74        FocusState::Hovered => (colors.focused_fg, colors.focused_fg, colors.focused_fg),
75        FocusState::Disabled => (colors.disabled, colors.disabled, colors.disabled),
76    };
77
78    // Format: "Label: [v]" / "Label: [ ]" with optional padding.
79    let label_display_width = str_width(&state.label) as u16;
80    let actual_label_width = label_width
81        .unwrap_or(label_display_width)
82        .max(label_display_width);
83    let padded_label = pad_to_display_width(&state.label, actual_label_width);
84
85    // Compact checkbox glyph — matches the widget framework's
86    // `[v]` / `[ ]` convention so an empty checkbox is not visually
87    // confusable with an empty text input.
88    //   checked:   [v]
89    //   unchecked: [ ]
90    //   inherited: [-]   (value is unset and falls back to a lower layer)
91    const CHIP_WIDTH: u16 = 3;
92
93    let line = if state.inherited {
94        // Neutral chip: the value is inherited/unset, so we deliberately avoid
95        // a definite checked/unchecked glyph that could be read as the user
96        // having set it off (issue #2345).
97        Line::from(vec![
98            Span::styled(padded_label, Style::default().fg(label_color)),
99            Span::styled(": ", Style::default().fg(label_color)),
100            Span::styled("[", Style::default().fg(bracket_color)),
101            Span::styled("-", Style::default().fg(bracket_color)),
102            Span::styled("]", Style::default().fg(bracket_color)),
103        ])
104    } else if state.checked {
105        Line::from(vec![
106            Span::styled(padded_label, Style::default().fg(label_color)),
107            Span::styled(": ", Style::default().fg(label_color)),
108            Span::styled("[", Style::default().fg(bracket_color)),
109            Span::styled(
110                "v",
111                Style::default()
112                    .fg(check_color)
113                    .add_modifier(ratatui::style::Modifier::BOLD),
114            ),
115            Span::styled("]", Style::default().fg(bracket_color)),
116        ])
117    } else {
118        Line::from(vec![
119            Span::styled(padded_label, Style::default().fg(label_color)),
120            Span::styled(": ", Style::default().fg(label_color)),
121            Span::styled("[", Style::default().fg(bracket_color)),
122            Span::styled(" ", Style::default().fg(bracket_color)),
123            Span::styled("]", Style::default().fg(bracket_color)),
124        ])
125    };
126
127    let paragraph = Paragraph::new(line);
128    frame.render_widget(paragraph, area);
129
130    // Chip position after label (label + ": ").
131    let label_overhead = actual_label_width.saturating_add(2);
132    let checkbox_start = area.x.saturating_add(label_overhead);
133    let chip_avail = area.width.saturating_sub(label_overhead.min(area.width));
134    let checkbox_area = Rect::new(checkbox_start, area.y, CHIP_WIDTH.min(chip_avail), 1);
135
136    // Full area is label + ": " + chip
137    let full_width = (actual_label_width + 2 + CHIP_WIDTH).min(area.width);
138    let full_area = Rect::new(area.x, area.y, full_width, 1);
139
140    ToggleLayout {
141        checkbox_area,
142        full_area,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn pad_to_display_width_uses_terminal_columns() {
152        let padded = pad_to_display_width("你好", 6);
153
154        assert_eq!(str_width(&padded), 6);
155        assert_eq!(padded, "你好  ");
156    }
157}