1#![forbid(unsafe_code)]
2
3use crate::{Widget, apply_style, draw_text_span};
21use ftui_core::geometry::Rect;
22use ftui_render::cell::Cell;
23use ftui_render::frame::Frame;
24use ftui_style::Style;
25use ftui_text::display_width;
26
27#[derive(Debug, Clone)]
29pub enum StatusItem<'a> {
30 Text(&'a str),
32 Spinner(usize),
34 Progress {
36 current: u64,
38 total: u64,
40 },
41 KeyHint {
43 key: &'a str,
45 action: &'a str,
47 },
48 Spacer,
50}
51
52impl<'a> StatusItem<'a> {
53 pub const fn text(s: &'a str) -> Self {
55 Self::Text(s)
56 }
57
58 pub const fn key_hint(key: &'a str, action: &'a str) -> Self {
60 Self::KeyHint { key, action }
61 }
62
63 pub const fn progress(current: u64, total: u64) -> Self {
65 Self::Progress { current, total }
66 }
67
68 pub const fn spacer() -> Self {
70 Self::Spacer
71 }
72
73 fn width(&self) -> usize {
75 match self {
76 Self::Text(s) => display_width(s),
77 Self::Spinner(_) => 1, Self::Progress { current, total } => {
79 let pct = current.saturating_mul(100).checked_div(*total).unwrap_or(0);
81 format!("{pct}%").len()
82 }
83 Self::KeyHint { key, action } => {
84 display_width(key) + 1 + display_width(action)
86 }
87 Self::Spacer => 0, }
89 }
90
91 fn render_to_string(&self) -> String {
93 match self {
94 Self::Text(s) => (*s).to_string(),
95 Self::Spinner(idx) => {
96 const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
98 FRAMES[*idx % FRAMES.len()].to_string()
99 }
100 Self::Progress { current, total } => {
101 let pct = current.saturating_mul(100).checked_div(*total).unwrap_or(0);
102 format!("{pct}%")
103 }
104 Self::KeyHint { key, action } => {
105 format!("{key} {action}")
106 }
107 Self::Spacer => String::new(),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Default)]
114pub struct StatusLine<'a> {
115 left: Vec<StatusItem<'a>>,
116 center: Vec<StatusItem<'a>>,
117 right: Vec<StatusItem<'a>>,
118 style: Style,
119 separator: &'a str,
120}
121
122impl<'a> StatusLine<'a> {
123 pub fn new() -> Self {
125 Self {
126 left: Vec::new(),
127 center: Vec::new(),
128 right: Vec::new(),
129 style: Style::default(),
130 separator: " ",
131 }
132 }
133
134 #[must_use]
136 pub fn left(mut self, item: StatusItem<'a>) -> Self {
137 self.left.push(item);
138 self
139 }
140
141 #[must_use]
143 pub fn center(mut self, item: StatusItem<'a>) -> Self {
144 self.center.push(item);
145 self
146 }
147
148 #[must_use]
150 pub fn right(mut self, item: StatusItem<'a>) -> Self {
151 self.right.push(item);
152 self
153 }
154
155 #[must_use]
157 pub fn style(mut self, style: Style) -> Self {
158 self.style = style;
159 self
160 }
161
162 #[must_use]
164 pub fn separator(mut self, separator: &'a str) -> Self {
165 self.separator = separator;
166 self
167 }
168
169 fn items_fixed_width(&self, items: &[StatusItem]) -> usize {
171 let sep_width = display_width(self.separator);
172 let mut width = 0usize;
173 let mut prev_item = false;
174
175 for item in items {
176 if matches!(item, StatusItem::Spacer) {
177 prev_item = false;
178 continue;
179 }
180
181 if prev_item {
182 width += sep_width;
183 }
184 width += item.width();
185 prev_item = true;
186 }
187
188 width
189 }
190
191 fn spacer_count(&self, items: &[StatusItem]) -> usize {
193 items
194 .iter()
195 .filter(|item| matches!(item, StatusItem::Spacer))
196 .count()
197 }
198
199 fn render_items(
201 &self,
202 frame: &mut Frame,
203 items: &[StatusItem],
204 mut x: u16,
205 y: u16,
206 max_x: u16,
207 style: Style,
208 ) -> u16 {
209 let available = max_x.saturating_sub(x) as usize;
210 let fixed_width = self.items_fixed_width(items);
211 let spacers = self.spacer_count(items);
212 let extra = available.saturating_sub(fixed_width);
213 let per_spacer = extra.checked_div(spacers).unwrap_or(0);
214 let mut remainder = extra.checked_rem(spacers).unwrap_or(0);
215 let mut prev_item = false;
216
217 for item in items {
218 if x >= max_x {
219 break;
220 }
221
222 if matches!(item, StatusItem::Spacer) {
223 let mut space = per_spacer;
224 if remainder > 0 {
225 space += 1;
226 remainder -= 1;
227 }
228 let advance = (space as u16).min(max_x.saturating_sub(x));
229 x = x.saturating_add(advance);
230 prev_item = false;
231 continue;
232 }
233
234 if prev_item && !self.separator.is_empty() {
236 x = draw_text_span(frame, x, y, self.separator, style, max_x);
237 if x >= max_x {
238 break;
239 }
240 }
241
242 let text = item.render_to_string();
243 x = draw_text_span(frame, x, y, &text, style, max_x);
244 prev_item = true;
245 }
246
247 x
248 }
249}
250
251impl Widget for StatusLine<'_> {
252 fn render(&self, area: Rect, frame: &mut Frame) {
253 #[cfg(feature = "tracing")]
254 let _span = tracing::debug_span!(
255 "widget_render",
256 widget = "StatusLine",
257 x = area.x,
258 y = area.y,
259 w = area.width,
260 h = area.height
261 )
262 .entered();
263
264 if area.is_empty() || area.height < 1 {
265 return;
266 }
267
268 let deg = frame.buffer.degradation;
269
270 let style = if deg.apply_styling() {
271 self.style
272 } else {
273 Style::default()
274 };
275
276 for x in area.x..area.right() {
278 let mut cell = Cell::from_char(' ');
279 apply_style(&mut cell, style);
280 frame.buffer.set_fast(x, area.y, cell);
281 }
282
283 let width = area.width as usize;
284 let left_width = self.items_fixed_width(&self.left);
285 let center_width = self.items_fixed_width(&self.center);
286 let right_width = self.items_fixed_width(&self.right);
287 let center_spacers = self.spacer_count(&self.center);
288
289 let left_x = area.x;
291 let right_x = area.right().saturating_sub(right_width as u16).max(area.x);
292 let available_center = width.saturating_sub(left_width).saturating_sub(right_width);
293 let center_target_width = if center_width > 0 && center_spacers > 0 {
294 available_center
295 } else {
296 center_width
297 };
298 let center_x = if center_width > 0 || center_spacers > 0 {
299 let center_start =
301 left_width + available_center.saturating_sub(center_target_width) / 2;
302 area.x.saturating_add(center_start as u16)
303 } else {
304 area.x
305 };
306
307 let center_can_render = (center_width > 0 || center_spacers > 0)
308 && center_x + center_target_width as u16 <= right_x;
309 let left_max_x = if center_can_render { center_x } else { right_x };
310
311 if !self.left.is_empty() {
313 self.render_items(frame, &self.left, left_x, area.y, left_max_x, style);
314 }
315
316 if center_can_render {
318 self.render_items(frame, &self.center, center_x, area.y, right_x, style);
319 }
320
321 if !self.right.is_empty() {
323 self.render_items(frame, &self.right, right_x, area.y, area.right(), style);
324 }
325 }
326
327 fn is_essential(&self) -> bool {
328 true }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use ftui_render::budget::DegradationLevel;
336 use ftui_render::buffer::Buffer;
337 use ftui_render::cell::PackedRgba;
338 use ftui_render::grapheme_pool::GraphemePool;
339
340 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
341 (0..width)
342 .map(|x| {
343 buf.get(x, y)
344 .and_then(|c| c.content.as_char())
345 .unwrap_or(' ')
346 })
347 .collect::<String>()
348 .trim_end()
349 .to_string()
350 }
351
352 fn row_full(buf: &Buffer, y: u16, width: u16) -> String {
353 (0..width)
354 .map(|x| {
355 buf.get(x, y)
356 .and_then(|c| c.content.as_char())
357 .unwrap_or(' ')
358 })
359 .collect()
360 }
361
362 #[test]
363 fn empty_status_line() {
364 let status = StatusLine::new();
365 let area = Rect::new(0, 0, 20, 1);
366 let mut pool = GraphemePool::new();
367 let mut frame = Frame::new(20, 1, &mut pool);
368 status.render(area, &mut frame);
369
370 let s = row_string(&frame.buffer, 0, 20);
372 assert!(s.is_empty() || s.chars().all(|c| c == ' '));
373 }
374
375 #[test]
376 fn left_only() {
377 let status = StatusLine::new().left(StatusItem::text("[INSERT]"));
378 let area = Rect::new(0, 0, 20, 1);
379 let mut pool = GraphemePool::new();
380 let mut frame = Frame::new(20, 1, &mut pool);
381 status.render(area, &mut frame);
382
383 let s = row_string(&frame.buffer, 0, 20);
384 assert!(s.starts_with("[INSERT]"), "Got: '{s}'");
385 }
386
387 #[test]
388 fn right_only() {
389 let status = StatusLine::new().right(StatusItem::text("Ln 42"));
390 let area = Rect::new(0, 0, 20, 1);
391 let mut pool = GraphemePool::new();
392 let mut frame = Frame::new(20, 1, &mut pool);
393 status.render(area, &mut frame);
394
395 let s = row_string(&frame.buffer, 0, 20);
396 assert!(s.ends_with("Ln 42"), "Got: '{s}'");
397 }
398
399 #[test]
400 fn center_only() {
401 let status = StatusLine::new().center(StatusItem::text("file.rs"));
402 let area = Rect::new(0, 0, 20, 1);
403 let mut pool = GraphemePool::new();
404 let mut frame = Frame::new(20, 1, &mut pool);
405 status.render(area, &mut frame);
406
407 let s = row_string(&frame.buffer, 0, 20);
408 assert!(s.contains("file.rs"), "Got: '{s}'");
409 let pos = s.find("file.rs").unwrap();
411 assert!(pos > 2 && pos < 15, "Not centered, pos={pos}, got: '{s}'");
412 }
413
414 #[test]
415 fn all_three_regions() {
416 let status = StatusLine::new()
417 .left(StatusItem::text("L"))
418 .center(StatusItem::text("C"))
419 .right(StatusItem::text("R"));
420 let area = Rect::new(0, 0, 20, 1);
421 let mut pool = GraphemePool::new();
422 let mut frame = Frame::new(20, 1, &mut pool);
423 status.render(area, &mut frame);
424
425 let s = row_string(&frame.buffer, 0, 20);
426 assert!(s.starts_with("L"), "Got: '{s}'");
427 assert!(s.ends_with("R"), "Got: '{s}'");
428 assert!(s.contains("C"), "Got: '{s}'");
429 }
430
431 #[test]
432 fn key_hint() {
433 let status = StatusLine::new().left(StatusItem::key_hint("^C", "Quit"));
434 let area = Rect::new(0, 0, 20, 1);
435 let mut pool = GraphemePool::new();
436 let mut frame = Frame::new(20, 1, &mut pool);
437 status.render(area, &mut frame);
438
439 let s = row_string(&frame.buffer, 0, 20);
440 assert!(s.contains("^C Quit"), "Got: '{s}'");
441 }
442
443 #[test]
444 fn progress() {
445 let status = StatusLine::new().left(StatusItem::progress(50, 100));
446 let area = Rect::new(0, 0, 20, 1);
447 let mut pool = GraphemePool::new();
448 let mut frame = Frame::new(20, 1, &mut pool);
449 status.render(area, &mut frame);
450
451 let s = row_string(&frame.buffer, 0, 20);
452 assert!(s.contains("50%"), "Got: '{s}'");
453 }
454
455 #[test]
456 fn multiple_items_left() {
457 let status = StatusLine::new()
458 .left(StatusItem::text("A"))
459 .left(StatusItem::text("B"))
460 .left(StatusItem::text("C"));
461 let area = Rect::new(0, 0, 20, 1);
462 let mut pool = GraphemePool::new();
463 let mut frame = Frame::new(20, 1, &mut pool);
464 status.render(area, &mut frame);
465
466 let s = row_string(&frame.buffer, 0, 20);
467 assert!(s.starts_with("A B C"), "Got: '{s}'");
468 }
469
470 #[test]
471 fn custom_separator() {
472 let status = StatusLine::new()
473 .separator(" | ")
474 .left(StatusItem::text("A"))
475 .left(StatusItem::text("B"));
476 let area = Rect::new(0, 0, 20, 1);
477 let mut pool = GraphemePool::new();
478 let mut frame = Frame::new(20, 1, &mut pool);
479 status.render(area, &mut frame);
480
481 let s = row_string(&frame.buffer, 0, 20);
482 assert!(s.contains("A | B"), "Got: '{s}'");
483 }
484
485 #[test]
486 fn spacer_expands_and_skips_separators() {
487 let status = StatusLine::new()
488 .separator(" | ")
489 .left(StatusItem::text("L"))
490 .left(StatusItem::spacer())
491 .left(StatusItem::text("R"));
492 let area = Rect::new(0, 0, 10, 1);
493 let mut pool = GraphemePool::new();
494 let mut frame = Frame::new(10, 1, &mut pool);
495 status.render(area, &mut frame);
496
497 let row = row_full(&frame.buffer, 0, 10);
498 let chars: Vec<char> = row.chars().collect();
499 assert_eq!(chars[0], 'L');
500 assert_eq!(chars[9], 'R');
501 assert!(
502 !row.contains('|'),
503 "Spacer should skip separators, got: '{row}'"
504 );
505 }
506
507 #[test]
508 fn style_applied() {
509 let fg = PackedRgba::rgb(255, 0, 0);
510 let status = StatusLine::new()
511 .style(Style::new().fg(fg))
512 .left(StatusItem::text("X"));
513 let area = Rect::new(0, 0, 10, 1);
514 let mut pool = GraphemePool::new();
515 let mut frame = Frame::new(10, 1, &mut pool);
516 status.render(area, &mut frame);
517
518 assert_eq!(frame.buffer.get(0, 0).unwrap().fg, fg);
519 }
520
521 #[test]
522 fn is_essential() {
523 let status = StatusLine::new();
524 assert!(status.is_essential());
525 }
526
527 #[test]
528 fn zero_area_no_panic() {
529 let status = StatusLine::new().left(StatusItem::text("Test"));
530 let area = Rect::new(0, 0, 0, 0);
531 let mut pool = GraphemePool::new();
532 let mut frame = Frame::new(1, 1, &mut pool);
533 status.render(area, &mut frame);
534 }
536
537 #[test]
538 fn spinner_renders_braille_char() {
539 let status = StatusLine::new().left(StatusItem::Spinner(0));
540 let area = Rect::new(0, 0, 10, 1);
541 let mut pool = GraphemePool::new();
542 let mut frame = Frame::new(10, 1, &mut pool);
543 status.render(area, &mut frame);
544
545 let c = frame
546 .buffer
547 .get(0, 0)
548 .and_then(|c| c.content.as_char())
549 .unwrap();
550 assert_eq!(c, '⠋');
551 }
552
553 #[test]
554 fn spinner_cycles_through_frames() {
555 let item0 = StatusItem::Spinner(0);
557 let item10 = StatusItem::Spinner(10);
558 assert_eq!(item0.render_to_string(), item10.render_to_string());
559
560 let item1 = StatusItem::Spinner(1);
561 assert_ne!(item0.render_to_string(), item1.render_to_string());
562 }
563
564 #[test]
565 fn spinner_width_is_one() {
566 let item = StatusItem::Spinner(5);
567 assert_eq!(item.width(), 1);
568 }
569
570 #[test]
571 fn progress_zero_total_shows_zero_percent() {
572 let item = StatusItem::progress(50, 0);
573 assert_eq!(item.render_to_string(), "0%");
574 }
575
576 #[test]
577 fn spacer_width_is_zero() {
578 assert_eq!(StatusItem::spacer().width(), 0);
579 }
580
581 #[test]
582 fn spacer_render_to_string_is_empty() {
583 assert_eq!(StatusItem::spacer().render_to_string(), "");
584 }
585
586 #[test]
587 fn status_line_default_is_empty() {
588 let status = StatusLine::default();
589 assert!(status.left.is_empty());
590 assert!(status.center.is_empty());
591 assert!(status.right.is_empty());
592 assert_eq!(status.separator, "");
593 }
594
595 #[test]
596 fn multiple_items_right() {
597 let status = StatusLine::new()
598 .right(StatusItem::text("X"))
599 .right(StatusItem::text("Y"));
600 let area = Rect::new(0, 0, 20, 1);
601 let mut pool = GraphemePool::new();
602 let mut frame = Frame::new(20, 1, &mut pool);
603 status.render(area, &mut frame);
604
605 let s = row_string(&frame.buffer, 0, 20);
606 assert!(s.contains("X Y"), "Got: '{s}'");
607 }
608
609 #[test]
610 fn key_hint_width() {
611 let item = StatusItem::key_hint("^C", "Quit");
612 assert_eq!(item.width(), 7);
614 }
615
616 #[test]
617 fn progress_full_hundred_percent() {
618 let item = StatusItem::progress(100, 100);
619 assert_eq!(item.render_to_string(), "100%");
620 }
621
622 #[test]
623 fn truncation_when_too_narrow() {
624 let status = StatusLine::new()
625 .left(StatusItem::text("VERYLONGTEXT"))
626 .right(StatusItem::text("R"));
627 let area = Rect::new(0, 0, 10, 1);
628 let mut pool = GraphemePool::new();
629 let mut frame = Frame::new(10, 1, &mut pool);
630 status.render(area, &mut frame);
631
632 let s = row_string(&frame.buffer, 0, 10);
634 assert!(!s.is_empty(), "Got empty string");
635 }
636
637 #[test]
638 fn status_line_renders_under_skeleton_as_essential_text() {
639 let status = StatusLine::new()
640 .left(StatusItem::text("READY"))
641 .right(StatusItem::text("Ln 1"));
642 let area = Rect::new(0, 0, 16, 1);
643 let mut pool = GraphemePool::new();
644 let mut frame = Frame::new(16, 1, &mut pool);
645 frame.buffer.degradation = DegradationLevel::Skeleton;
646
647 status.render(area, &mut frame);
648
649 let row = row_full(&frame.buffer, 0, 16);
650 assert!(row.contains("READY"), "Got: '{row}'");
651 assert!(row.contains("Ln 1"), "Got: '{row}'");
652 }
653
654 #[test]
655 fn skeleton_empty_status_line_clears_stale_row() {
656 let populated = StatusLine::new().left(StatusItem::text("BUSY"));
657 let empty = StatusLine::new();
658 let area = Rect::new(0, 0, 12, 1);
659 let mut pool = GraphemePool::new();
660 let mut frame = Frame::new(12, 1, &mut pool);
661
662 populated.render(area, &mut frame);
663 frame.buffer.degradation = DegradationLevel::Skeleton;
664 empty.render(area, &mut frame);
665
666 assert_eq!(row_full(&frame.buffer, 0, 12), " ".repeat(12));
667 }
668}