1use 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#[derive(Debug, Clone)]
16pub struct StatusBarStyle {
17 pub base: BaseStyle,
19 pub text: Style,
21 pub hint_key: Style,
23 pub hint_label: Style,
25 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 pub fn borderless() -> Self {
50 let mut style = Self::default();
51 style.base.border = None;
52 style
53 }
54
55 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#[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 pub fn new(key: &'a str, label: &'a str) -> Self {
80 Self { key, label }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub enum StatusBarItem<'a> {
87 Text(&'a str),
89 Span(Span<'a>),
91}
92
93impl<'a> StatusBarItem<'a> {
94 pub fn text(text: &'a str) -> Self {
96 Self::Text(text)
97 }
98
99 pub fn span(span: Span<'a>) -> Self {
101 Self::Span(span)
102 }
103}
104
105#[derive(Debug, Clone)]
107pub enum StatusBarContent<'a> {
108 Empty,
110 Items(&'a [StatusBarItem<'a>]),
112 Hints(&'a [StatusBarHint<'a>]),
114}
115
116#[derive(Debug, Clone)]
118pub struct StatusBarSection<'a> {
119 pub content: StatusBarContent<'a>,
121 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 pub fn empty() -> Self {
134 Self {
135 content: StatusBarContent::Empty,
136 separator: " ",
137 }
138 }
139
140 pub fn items(items: &'a [StatusBarItem<'a>]) -> Self {
142 Self {
143 content: StatusBarContent::Items(items),
144 separator: " ",
145 }
146 }
147
148 pub fn hints(hints: &'a [StatusBarHint<'a>]) -> Self {
150 Self {
151 content: StatusBarContent::Hints(hints),
152 separator: "",
153 }
154 }
155
156 pub fn with_separator(mut self, separator: &'a str) -> Self {
158 self.separator = separator;
159 self
160 }
161}
162
163pub struct StatusBarProps<'a> {
165 pub left: StatusBarSection<'a>,
167 pub center: StatusBarSection<'a>,
169 pub right: StatusBarSection<'a>,
171 pub style: StatusBarStyle,
173 pub is_focused: bool,
175}
176
177impl<'a> StatusBarProps<'a> {
178 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#[derive(Default)]
196pub struct StatusBar;
197
198impl StatusBar {
199 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(¢er_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}