Skip to main content

tui_dispatch_components/
status_bar.rs

1//! Status bar component with left/center/right sections
2
3use ratatui::{
4    layout::Rect,
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, Paragraph},
8    Frame,
9};
10use tui_dispatch_core::Component;
11
12use crate::style::{BaseStyle, ComponentStyle, Padding};
13
14/// Unified styling for StatusBar
15#[derive(Debug, Clone)]
16pub struct StatusBarStyle {
17    /// Shared base style
18    pub base: BaseStyle,
19    /// Default text style
20    pub text: Style,
21    /// Style for key hints
22    pub hint_key: Style,
23    /// Style for hint labels
24    pub hint_label: Style,
25    /// Style for separators
26    pub separator: Style,
27}
28
29impl Default for StatusBarStyle {
30    fn default() -> Self {
31        Self {
32            base: BaseStyle {
33                border: None,
34                fg: None,
35                ..Default::default()
36            },
37            text: Style::default(),
38            hint_key: Style::default()
39                .fg(Color::Cyan)
40                .add_modifier(Modifier::BOLD),
41            hint_label: Style::default(),
42            separator: Style::default().fg(Color::DarkGray),
43        }
44    }
45}
46
47impl StatusBarStyle {
48    /// Create a style with no border
49    pub fn borderless() -> Self {
50        let mut style = Self::default();
51        style.base.border = None;
52        style
53    }
54
55    /// Create a minimal style (no border, no padding)
56    pub fn minimal() -> Self {
57        let mut style = Self::default();
58        style.base.border = None;
59        style.base.padding = Padding::default();
60        style
61    }
62}
63
64impl ComponentStyle for StatusBarStyle {
65    fn base(&self) -> &BaseStyle {
66        &self.base
67    }
68}
69
70/// A single hint entry (key + label)
71#[derive(Debug, Clone, Copy)]
72pub struct StatusBarHint<'a> {
73    pub key: &'a str,
74    pub label: &'a str,
75}
76
77impl<'a> StatusBarHint<'a> {
78    /// Create a new hint entry
79    pub fn new(key: &'a str, label: &'a str) -> Self {
80        Self { key, label }
81    }
82}
83
84/// An item within a status bar section
85#[derive(Debug, Clone)]
86pub enum StatusBarItem<'a> {
87    /// Plain text rendered using the default text style
88    Text(&'a str),
89    /// Styled span (uses its own style)
90    Span(Span<'a>),
91}
92
93impl<'a> StatusBarItem<'a> {
94    /// Create a new text item
95    pub fn text(text: &'a str) -> Self {
96        Self::Text(text)
97    }
98
99    /// Create a new styled span item
100    pub fn span(span: Span<'a>) -> Self {
101        Self::Span(span)
102    }
103}
104
105/// Content for a status bar section
106#[derive(Debug, Clone)]
107pub enum StatusBarContent<'a> {
108    /// No content
109    Empty,
110    /// Render a list of items
111    Items(&'a [StatusBarItem<'a>]),
112    /// Render a list of hints
113    Hints(&'a [StatusBarHint<'a>]),
114}
115
116/// Section configuration for the status bar
117#[derive(Debug, Clone)]
118pub struct StatusBarSection<'a> {
119    /// Section content
120    pub content: StatusBarContent<'a>,
121    /// Separator inserted between items
122    pub separator: &'a str,
123}
124
125impl<'a> Default for StatusBarSection<'a> {
126    fn default() -> Self {
127        Self::empty()
128    }
129}
130
131impl<'a> StatusBarSection<'a> {
132    /// Create an empty section
133    pub fn empty() -> Self {
134        Self {
135            content: StatusBarContent::Empty,
136            separator: " ",
137        }
138    }
139
140    /// Create a section from items
141    pub fn items(items: &'a [StatusBarItem<'a>]) -> Self {
142        Self {
143            content: StatusBarContent::Items(items),
144            separator: " ",
145        }
146    }
147
148    /// Create a section from hint items
149    pub fn hints(hints: &'a [StatusBarHint<'a>]) -> Self {
150        Self {
151            content: StatusBarContent::Hints(hints),
152            separator: "",
153        }
154    }
155
156    /// Override the separator between items
157    pub fn with_separator(mut self, separator: &'a str) -> Self {
158        self.separator = separator;
159        self
160    }
161}
162
163/// Props for StatusBar component
164pub struct StatusBarProps<'a> {
165    /// Left-aligned section
166    pub left: StatusBarSection<'a>,
167    /// Center-aligned section
168    pub center: StatusBarSection<'a>,
169    /// Right-aligned section
170    pub right: StatusBarSection<'a>,
171    /// Unified styling
172    pub style: StatusBarStyle,
173    /// Whether the component is focused (for border styling)
174    pub is_focused: bool,
175}
176
177impl<'a> StatusBarProps<'a> {
178    /// Create props with default style
179    pub fn new(
180        left: StatusBarSection<'a>,
181        center: StatusBarSection<'a>,
182        right: StatusBarSection<'a>,
183    ) -> Self {
184        Self {
185            left,
186            center,
187            right,
188            style: StatusBarStyle::default(),
189            is_focused: false,
190        }
191    }
192}
193
194/// A status bar with left/center/right sections
195#[derive(Default)]
196pub struct StatusBar;
197
198impl StatusBar {
199    /// Create a new StatusBar
200    pub fn new() -> Self {
201        Self
202    }
203}
204
205impl<A> Component<A> for StatusBar {
206    type Props<'a> = StatusBarProps<'a>;
207
208    fn handle_event(
209        &mut self,
210        _event: &tui_dispatch_core::EventKind,
211        _props: Self::Props<'_>,
212    ) -> impl IntoIterator<Item = A> {
213        None
214    }
215
216    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
217        let style = &props.style;
218
219        let mut background_style = Style::default();
220        if let Some(bg) = style.base.bg {
221            background_style = background_style.bg(bg);
222        }
223
224        for y in area.y..area.y.saturating_add(area.height) {
225            for x in area.x..area.x.saturating_add(area.width) {
226                if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
227                    cell.set_symbol(" ");
228                    cell.set_style(background_style);
229                }
230            }
231        }
232
233        let content_area = Rect {
234            x: area.x + style.base.padding.left,
235            y: area.y + style.base.padding.top,
236            width: area.width.saturating_sub(style.base.padding.horizontal()),
237            height: area.height.saturating_sub(style.base.padding.vertical()),
238        };
239
240        let mut inner_area = content_area;
241        if let Some(border) = &style.base.border {
242            let block = Block::default()
243                .borders(border.borders)
244                .border_style(border.style_for_focus(props.is_focused));
245            inner_area = block.inner(content_area);
246            frame.render_widget(block, content_area);
247        }
248
249        if inner_area.width == 0 || inner_area.height == 0 {
250            return;
251        }
252
253        let row_area = Rect {
254            y: inner_area.y,
255            height: 1,
256            ..inner_area
257        };
258
259        let left_line = section_line(&props.left, style);
260        let center_line = section_line(&props.center, style);
261        let right_line = section_line(&props.right, style);
262
263        let content_width = row_area.width as usize;
264        let right_width = right_line.width().min(content_width);
265        let left_width = left_line
266            .width()
267            .min(content_width.saturating_sub(right_width));
268        let gap_width = content_width.saturating_sub(left_width + right_width);
269        let center_width = center_line.width().min(gap_width);
270
271        if left_width > 0 {
272            let left_area = Rect {
273                width: left_width as u16,
274                ..row_area
275            };
276            frame.render_widget(Paragraph::new(left_line).style(style.text), left_area);
277        }
278
279        if center_width > 0 {
280            let center_x = row_area.x
281                + left_width as u16
282                + ((gap_width.saturating_sub(center_width)) / 2) as u16;
283            let center_area = Rect {
284                x: center_x,
285                width: center_width as u16,
286                ..row_area
287            };
288            frame.render_widget(Paragraph::new(center_line).style(style.text), center_area);
289        }
290
291        if right_width > 0 {
292            let right_area = Rect {
293                x: row_area.x + row_area.width.saturating_sub(right_width as u16),
294                width: right_width as u16,
295                ..row_area
296            };
297            frame.render_widget(Paragraph::new(right_line).style(style.text), right_area);
298        }
299    }
300}
301
302fn section_line<'a>(section: &StatusBarSection<'a>, style: &StatusBarStyle) -> Line<'a> {
303    match section.content {
304        StatusBarContent::Empty => Line::raw(""),
305        StatusBarContent::Items(items) => items_line(items, section.separator, style),
306        StatusBarContent::Hints(hints) => hints_line(hints, section.separator, style),
307    }
308}
309
310fn items_line<'a>(
311    items: &'a [StatusBarItem<'a>],
312    separator: &'a str,
313    style: &StatusBarStyle,
314) -> Line<'a> {
315    let mut spans = Vec::new();
316
317    for (idx, item) in items.iter().enumerate() {
318        if idx > 0 && !separator.is_empty() {
319            spans.push(Span::styled(separator, style.separator));
320        }
321
322        match item {
323            StatusBarItem::Text(text) => spans.push(Span::styled(*text, style.text)),
324            StatusBarItem::Span(span) => spans.push(span.clone()),
325        }
326    }
327
328    Line::from(spans)
329}
330
331fn hints_line<'a>(
332    hints: &'a [StatusBarHint<'a>],
333    separator: &'a str,
334    style: &StatusBarStyle,
335) -> Line<'a> {
336    let mut spans = Vec::new();
337
338    for (idx, hint) in hints.iter().enumerate() {
339        if idx > 0 && !separator.is_empty() {
340            spans.push(Span::styled(separator, style.separator));
341        }
342
343        spans.push(Span::styled(format!(" {} ", hint.key), style.hint_key));
344        spans.push(Span::styled(format!(" {} ", hint.label), style.hint_label));
345    }
346
347    Line::from(spans)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use tui_dispatch_core::testing::RenderHarness;
354
355    #[test]
356    fn test_status_bar_renders_sections() {
357        let mut harness = RenderHarness::new(60, 1);
358        let mut status_bar = StatusBar::new();
359
360        let left_items = [StatusBarItem::text("Left")];
361        let center_items = [StatusBarItem::text("Center")];
362        let right_hints = [StatusBarHint::new("F1", "Help")];
363
364        let output = harness.render_to_string_plain(|frame| {
365            <StatusBar as Component<()>>::render(
366                &mut status_bar,
367                frame,
368                frame.area(),
369                StatusBarProps {
370                    left: StatusBarSection::items(&left_items),
371                    center: StatusBarSection::items(&center_items),
372                    right: StatusBarSection::hints(&right_hints),
373                    style: StatusBarStyle::default(),
374                    is_focused: false,
375                },
376            );
377        });
378
379        assert!(output.contains("Left"));
380        assert!(output.contains("Center"));
381        assert!(output.contains("Help"));
382    }
383}