Skip to main content

datui_lib/widgets/
controls.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Direction, Layout, Rect},
4    style::{Color, Style},
5    widgets::{Block, Paragraph, Widget},
6};
7
8pub struct Controls {
9    pub row_count: Option<usize>,
10    pub dimmed: bool,
11    pub query_active: bool,
12    pub custom_controls: Option<Vec<(&'static str, &'static str)>>,
13    pub bg_color: Color,
14    pub key_color: Color,   // Color for keybind hints (keys in toolbar)
15    pub label_color: Color, // Color for action labels
16    pub throbber_color: Color,
17    pub use_unicode_throbber: bool, // When true, use 8-dot braille spinner (4 rows tall); else |/-\
18    pub busy: bool,                 // When true, show throbber at far right
19    pub throbber_frame: u8,         // Spinner frame (0..3 or 0..7 for unicode)
20}
21
22impl Default for Controls {
23    fn default() -> Self {
24        Self {
25            row_count: None,
26            dimmed: false,
27            query_active: false,
28            custom_controls: None,
29            bg_color: Color::Indexed(236), // Default for backward compatibility
30            key_color: Color::Cyan,        // Keys in cyan
31            label_color: Color::White,     // Labels in white
32            throbber_color: Color::Cyan,
33            use_unicode_throbber: false,
34            busy: false,
35            throbber_frame: 0,
36        }
37    }
38}
39
40impl Controls {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    pub fn with_row_count(row_count: usize) -> Self {
46        Self {
47            row_count: Some(row_count),
48            dimmed: false,
49            query_active: false,
50            custom_controls: None,
51            bg_color: Color::Indexed(236), // Default
52            key_color: Color::Cyan,        // Keys in cyan
53            label_color: Color::White,     // Labels in white
54            throbber_color: Color::Cyan,
55            use_unicode_throbber: false,
56            busy: false,
57            throbber_frame: 0,
58        }
59    }
60
61    pub fn with_busy(mut self, busy: bool, throbber_frame: u8) -> Self {
62        self.busy = busy;
63        self.throbber_frame = throbber_frame;
64        self
65    }
66
67    pub fn with_dimmed(mut self, dimmed: bool) -> Self {
68        self.dimmed = dimmed;
69        self
70    }
71
72    pub fn with_query_active(mut self, query_active: bool) -> Self {
73        self.query_active = query_active;
74        self
75    }
76
77    pub fn with_custom_controls(mut self, controls: Vec<(&'static str, &'static str)>) -> Self {
78        self.custom_controls = Some(controls);
79        self
80    }
81
82    pub fn with_colors(
83        mut self,
84        bg_color: Color,
85        key_color: Color,
86        label_color: Color,
87        throbber_color: Color,
88    ) -> Self {
89        self.bg_color = bg_color;
90        self.key_color = key_color;
91        self.label_color = label_color;
92        self.throbber_color = throbber_color;
93        self
94    }
95
96    pub fn with_unicode_throbber(mut self, use_unicode: bool) -> Self {
97        self.use_unicode_throbber = use_unicode;
98        self
99    }
100
101    pub fn with_row_count_and_colors(
102        row_count: usize,
103        bg_color: Color,
104        key_color: Color,
105        label_color: Color,
106        throbber_color: Color,
107    ) -> Self {
108        Self {
109            row_count: Some(row_count),
110            dimmed: false,
111            query_active: false,
112            custom_controls: None,
113            bg_color,
114            key_color,
115            label_color,
116            throbber_color,
117            use_unicode_throbber: false,
118            busy: false,
119            throbber_frame: 0,
120        }
121    }
122}
123
124impl Widget for &Controls {
125    fn render(self, area: Rect, buf: &mut Buffer) {
126        let no_bg = self.bg_color == Color::Reset;
127        if !no_bg {
128            Block::default()
129                .style(Style::default().bg(self.bg_color))
130                .render(area, buf);
131        }
132
133        const DEFAULT_CONTROLS: [(&str, &str); 9] = [
134            ("/", "Query"),
135            ("i", "Info"),
136            ("a", "Analysis"),
137            ("c", "Chart"),
138            ("s", "Sort & Filter"),
139            ("p", "Pivot & Melt"),
140            ("e", "Export"),
141            ("?", "Help"),
142            ("q", "Quit"),
143        ];
144
145        let controls: Vec<(&str, &str)> = if let Some(ref custom) = self.custom_controls {
146            custom.to_vec()
147        } else {
148            DEFAULT_CONTROLS.to_vec()
149        };
150
151        // Width of one key-label pair (fixed; pairs are never shrunk).
152        // Key: key.len() + 1 (one trailing space). Label: action.len() + 1 (one trailing = gap before next key).
153        let pair_width = |(key, action): &(&str, &str)| -> u16 {
154            (key.chars().count() as u16 + 1) + (action.chars().count() as u16 + 1)
155        };
156
157        // Reserve space for fill, row count, and throbber (fixed width so layout never shifts).
158        const THROBBER_WIDTH: u16 = 3;
159        let right_reserved = (if self.row_count.is_some() { 21 } else { 1 }) + THROBBER_WIDTH;
160        let mut available = area.width.saturating_sub(right_reserved);
161
162        let mut n_show = 0;
163        for pair in controls.iter() {
164            let need = pair_width(pair);
165            if available >= need {
166                available -= need;
167                n_show += 1;
168            } else {
169                break;
170            }
171        }
172
173        // Key: +1 trailing (avoids cut-off when terminals render bold/colored wider). Left-aligned so no leading gap.
174        // Label: +1 trailing (single space between label and next key).
175        let mut constraints: Vec<Constraint> = controls
176            .iter()
177            .take(n_show)
178            .flat_map(|(key, action)| {
179                [
180                    Constraint::Length(key.chars().count() as u16 + 1),
181                    Constraint::Length(action.chars().count() as u16 + 1),
182                ]
183            })
184            .collect();
185
186        constraints.push(Constraint::Fill(1));
187        if self.row_count.is_some() {
188            constraints.push(Constraint::Length(20));
189        }
190        constraints.push(Constraint::Length(THROBBER_WIDTH));
191
192        let layout = Layout::new(Direction::Horizontal, constraints).split(area);
193
194        let (key_style, label_style, fill_style) = if no_bg {
195            (
196                Style::default().fg(self.key_color),
197                Style::default().fg(self.label_color),
198                Style::default(),
199            )
200        } else {
201            let base = Style::default().bg(self.bg_color);
202            (base.fg(self.key_color), base.fg(self.label_color), base)
203        };
204
205        for (i, (key, action)) in controls.iter().take(n_show).enumerate() {
206            let j = i * 2;
207            Paragraph::new(*key).style(key_style).render(layout[j], buf);
208            Paragraph::new(*action)
209                .style(label_style)
210                .render(layout[j + 1], buf);
211        }
212
213        let fill_idx = n_show * 2;
214        if let Some(count) = self.row_count {
215            let row_count_text = format!("Rows: {}", format_number_with_commas(count));
216            Paragraph::new(row_count_text)
217                .style(label_style)
218                .right_aligned()
219                .render(layout[fill_idx + 1], buf);
220        }
221
222        Paragraph::new("")
223            .style(fill_style)
224            .render(layout[fill_idx], buf);
225
226        // Throbber slot is always present (fixed width); animate only when busy.
227        // ASCII: |/-\ (4 frames). Unicode: 8-dot braille (8 frames, 4 rows tall) when LANG has UTF-8.
228        // Same as throbber-widgets-tui BRAILLE_EIGHT: https://ratatui.rs/showcase/third-party-widgets/
229        const THROBBER_ASCII: [char; 4] = ['|', '/', '-', '\\'];
230        const THROBBER_BRAILLE_EIGHT: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
231        let throbber_idx = fill_idx + if self.row_count.is_some() { 2 } else { 1 };
232        let throbber_ch = if self.busy {
233            if self.use_unicode_throbber {
234                THROBBER_BRAILLE_EIGHT[self.throbber_frame as usize % 8].to_string()
235            } else {
236                THROBBER_ASCII[self.throbber_frame as usize % 4].to_string()
237            }
238        } else {
239            " ".to_string()
240        };
241        let throbber_style = if no_bg {
242            Style::default().fg(self.throbber_color)
243        } else {
244            Style::default().bg(self.bg_color).fg(self.throbber_color)
245        };
246        Paragraph::new(throbber_ch)
247            .style(throbber_style)
248            .centered()
249            .render(layout[throbber_idx], buf);
250    }
251}
252
253fn format_number_with_commas(n: usize) -> String {
254    let s = n.to_string();
255    let mut result = String::new();
256    let chars: Vec<char> = s.chars().rev().collect();
257
258    for (i, ch) in chars.iter().enumerate() {
259        if i > 0 && i % 3 == 0 {
260            result.push(',');
261        }
262        result.push(*ch);
263    }
264
265    result.chars().rev().collect()
266}