Skip to main content

datui_lib/widgets/
controls.rs

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