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 if !deg.render_content() {
272 return;
273 }
274
275 let style = if deg.apply_styling() {
276 self.style
277 } else {
278 Style::default()
279 };
280
281 for x in area.x..area.right() {
283 let mut cell = Cell::from_char(' ');
284 apply_style(&mut cell, style);
285 frame.buffer.set_fast(x, area.y, cell);
286 }
287
288 let width = area.width as usize;
289 let left_width = self.items_fixed_width(&self.left);
290 let center_width = self.items_fixed_width(&self.center);
291 let right_width = self.items_fixed_width(&self.right);
292 let center_spacers = self.spacer_count(&self.center);
293
294 let left_x = area.x;
296 let right_x = area.right().saturating_sub(right_width as u16);
297 let available_center = width.saturating_sub(left_width).saturating_sub(right_width);
298 let center_target_width = if center_width > 0 && center_spacers > 0 {
299 available_center
300 } else {
301 center_width
302 };
303 let center_x = if center_width > 0 || center_spacers > 0 {
304 let center_start =
306 left_width + available_center.saturating_sub(center_target_width) / 2;
307 area.x.saturating_add(center_start as u16)
308 } else {
309 area.x
310 };
311
312 let center_can_render = (center_width > 0 || center_spacers > 0)
313 && center_x + center_target_width as u16 <= right_x;
314 let left_max_x = if center_can_render { center_x } else { right_x };
315
316 if !self.left.is_empty() {
318 self.render_items(frame, &self.left, left_x, area.y, left_max_x, style);
319 }
320
321 if center_can_render {
323 self.render_items(frame, &self.center, center_x, area.y, right_x, style);
324 }
325
326 if !self.right.is_empty() && right_x >= area.x {
328 self.render_items(frame, &self.right, right_x, area.y, area.right(), style);
329 }
330 }
331
332 fn is_essential(&self) -> bool {
333 true }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use ftui_render::buffer::Buffer;
341 use ftui_render::cell::PackedRgba;
342 use ftui_render::grapheme_pool::GraphemePool;
343
344 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
345 (0..width)
346 .map(|x| {
347 buf.get(x, y)
348 .and_then(|c| c.content.as_char())
349 .unwrap_or(' ')
350 })
351 .collect::<String>()
352 .trim_end()
353 .to_string()
354 }
355
356 fn row_full(buf: &Buffer, y: u16, width: u16) -> String {
357 (0..width)
358 .map(|x| {
359 buf.get(x, y)
360 .and_then(|c| c.content.as_char())
361 .unwrap_or(' ')
362 })
363 .collect()
364 }
365
366 #[test]
367 fn empty_status_line() {
368 let status = StatusLine::new();
369 let area = Rect::new(0, 0, 20, 1);
370 let mut pool = GraphemePool::new();
371 let mut frame = Frame::new(20, 1, &mut pool);
372 status.render(area, &mut frame);
373
374 let s = row_string(&frame.buffer, 0, 20);
376 assert!(s.is_empty() || s.chars().all(|c| c == ' '));
377 }
378
379 #[test]
380 fn left_only() {
381 let status = StatusLine::new().left(StatusItem::text("[INSERT]"));
382 let area = Rect::new(0, 0, 20, 1);
383 let mut pool = GraphemePool::new();
384 let mut frame = Frame::new(20, 1, &mut pool);
385 status.render(area, &mut frame);
386
387 let s = row_string(&frame.buffer, 0, 20);
388 assert!(s.starts_with("[INSERT]"), "Got: '{s}'");
389 }
390
391 #[test]
392 fn right_only() {
393 let status = StatusLine::new().right(StatusItem::text("Ln 42"));
394 let area = Rect::new(0, 0, 20, 1);
395 let mut pool = GraphemePool::new();
396 let mut frame = Frame::new(20, 1, &mut pool);
397 status.render(area, &mut frame);
398
399 let s = row_string(&frame.buffer, 0, 20);
400 assert!(s.ends_with("Ln 42"), "Got: '{s}'");
401 }
402
403 #[test]
404 fn center_only() {
405 let status = StatusLine::new().center(StatusItem::text("file.rs"));
406 let area = Rect::new(0, 0, 20, 1);
407 let mut pool = GraphemePool::new();
408 let mut frame = Frame::new(20, 1, &mut pool);
409 status.render(area, &mut frame);
410
411 let s = row_string(&frame.buffer, 0, 20);
412 assert!(s.contains("file.rs"), "Got: '{s}'");
413 let pos = s.find("file.rs").unwrap();
415 assert!(pos > 2 && pos < 15, "Not centered, pos={pos}, got: '{s}'");
416 }
417
418 #[test]
419 fn all_three_regions() {
420 let status = StatusLine::new()
421 .left(StatusItem::text("L"))
422 .center(StatusItem::text("C"))
423 .right(StatusItem::text("R"));
424 let area = Rect::new(0, 0, 20, 1);
425 let mut pool = GraphemePool::new();
426 let mut frame = Frame::new(20, 1, &mut pool);
427 status.render(area, &mut frame);
428
429 let s = row_string(&frame.buffer, 0, 20);
430 assert!(s.starts_with("L"), "Got: '{s}'");
431 assert!(s.ends_with("R"), "Got: '{s}'");
432 assert!(s.contains("C"), "Got: '{s}'");
433 }
434
435 #[test]
436 fn key_hint() {
437 let status = StatusLine::new().left(StatusItem::key_hint("^C", "Quit"));
438 let area = Rect::new(0, 0, 20, 1);
439 let mut pool = GraphemePool::new();
440 let mut frame = Frame::new(20, 1, &mut pool);
441 status.render(area, &mut frame);
442
443 let s = row_string(&frame.buffer, 0, 20);
444 assert!(s.contains("^C Quit"), "Got: '{s}'");
445 }
446
447 #[test]
448 fn progress() {
449 let status = StatusLine::new().left(StatusItem::progress(50, 100));
450 let area = Rect::new(0, 0, 20, 1);
451 let mut pool = GraphemePool::new();
452 let mut frame = Frame::new(20, 1, &mut pool);
453 status.render(area, &mut frame);
454
455 let s = row_string(&frame.buffer, 0, 20);
456 assert!(s.contains("50%"), "Got: '{s}'");
457 }
458
459 #[test]
460 fn multiple_items_left() {
461 let status = StatusLine::new()
462 .left(StatusItem::text("A"))
463 .left(StatusItem::text("B"))
464 .left(StatusItem::text("C"));
465 let area = Rect::new(0, 0, 20, 1);
466 let mut pool = GraphemePool::new();
467 let mut frame = Frame::new(20, 1, &mut pool);
468 status.render(area, &mut frame);
469
470 let s = row_string(&frame.buffer, 0, 20);
471 assert!(s.starts_with("A B C"), "Got: '{s}'");
472 }
473
474 #[test]
475 fn custom_separator() {
476 let status = StatusLine::new()
477 .separator(" | ")
478 .left(StatusItem::text("A"))
479 .left(StatusItem::text("B"));
480 let area = Rect::new(0, 0, 20, 1);
481 let mut pool = GraphemePool::new();
482 let mut frame = Frame::new(20, 1, &mut pool);
483 status.render(area, &mut frame);
484
485 let s = row_string(&frame.buffer, 0, 20);
486 assert!(s.contains("A | B"), "Got: '{s}'");
487 }
488
489 #[test]
490 fn spacer_expands_and_skips_separators() {
491 let status = StatusLine::new()
492 .separator(" | ")
493 .left(StatusItem::text("L"))
494 .left(StatusItem::spacer())
495 .left(StatusItem::text("R"));
496 let area = Rect::new(0, 0, 10, 1);
497 let mut pool = GraphemePool::new();
498 let mut frame = Frame::new(10, 1, &mut pool);
499 status.render(area, &mut frame);
500
501 let row = row_full(&frame.buffer, 0, 10);
502 let chars: Vec<char> = row.chars().collect();
503 assert_eq!(chars[0], 'L');
504 assert_eq!(chars[9], 'R');
505 assert!(
506 !row.contains('|'),
507 "Spacer should skip separators, got: '{row}'"
508 );
509 }
510
511 #[test]
512 fn style_applied() {
513 let fg = PackedRgba::rgb(255, 0, 0);
514 let status = StatusLine::new()
515 .style(Style::new().fg(fg))
516 .left(StatusItem::text("X"));
517 let area = Rect::new(0, 0, 10, 1);
518 let mut pool = GraphemePool::new();
519 let mut frame = Frame::new(10, 1, &mut pool);
520 status.render(area, &mut frame);
521
522 assert_eq!(frame.buffer.get(0, 0).unwrap().fg, fg);
523 }
524
525 #[test]
526 fn is_essential() {
527 let status = StatusLine::new();
528 assert!(status.is_essential());
529 }
530
531 #[test]
532 fn zero_area_no_panic() {
533 let status = StatusLine::new().left(StatusItem::text("Test"));
534 let area = Rect::new(0, 0, 0, 0);
535 let mut pool = GraphemePool::new();
536 let mut frame = Frame::new(1, 1, &mut pool);
537 status.render(area, &mut frame);
538 }
540
541 #[test]
542 fn spinner_renders_braille_char() {
543 let status = StatusLine::new().left(StatusItem::Spinner(0));
544 let area = Rect::new(0, 0, 10, 1);
545 let mut pool = GraphemePool::new();
546 let mut frame = Frame::new(10, 1, &mut pool);
547 status.render(area, &mut frame);
548
549 let c = frame
550 .buffer
551 .get(0, 0)
552 .and_then(|c| c.content.as_char())
553 .unwrap();
554 assert_eq!(c, '⠋');
555 }
556
557 #[test]
558 fn spinner_cycles_through_frames() {
559 let item0 = StatusItem::Spinner(0);
561 let item10 = StatusItem::Spinner(10);
562 assert_eq!(item0.render_to_string(), item10.render_to_string());
563
564 let item1 = StatusItem::Spinner(1);
565 assert_ne!(item0.render_to_string(), item1.render_to_string());
566 }
567
568 #[test]
569 fn spinner_width_is_one() {
570 let item = StatusItem::Spinner(5);
571 assert_eq!(item.width(), 1);
572 }
573
574 #[test]
575 fn progress_zero_total_shows_zero_percent() {
576 let item = StatusItem::progress(50, 0);
577 assert_eq!(item.render_to_string(), "0%");
578 }
579
580 #[test]
581 fn spacer_width_is_zero() {
582 assert_eq!(StatusItem::spacer().width(), 0);
583 }
584
585 #[test]
586 fn spacer_render_to_string_is_empty() {
587 assert_eq!(StatusItem::spacer().render_to_string(), "");
588 }
589
590 #[test]
591 fn status_line_default_is_empty() {
592 let status = StatusLine::default();
593 assert!(status.left.is_empty());
594 assert!(status.center.is_empty());
595 assert!(status.right.is_empty());
596 assert_eq!(status.separator, "");
597 }
598
599 #[test]
600 fn multiple_items_right() {
601 let status = StatusLine::new()
602 .right(StatusItem::text("X"))
603 .right(StatusItem::text("Y"));
604 let area = Rect::new(0, 0, 20, 1);
605 let mut pool = GraphemePool::new();
606 let mut frame = Frame::new(20, 1, &mut pool);
607 status.render(area, &mut frame);
608
609 let s = row_string(&frame.buffer, 0, 20);
610 assert!(s.contains("X Y"), "Got: '{s}'");
611 }
612
613 #[test]
614 fn key_hint_width() {
615 let item = StatusItem::key_hint("^C", "Quit");
616 assert_eq!(item.width(), 7);
618 }
619
620 #[test]
621 fn progress_full_hundred_percent() {
622 let item = StatusItem::progress(100, 100);
623 assert_eq!(item.render_to_string(), "100%");
624 }
625
626 #[test]
627 fn truncation_when_too_narrow() {
628 let status = StatusLine::new()
629 .left(StatusItem::text("VERYLONGTEXT"))
630 .right(StatusItem::text("R"));
631 let area = Rect::new(0, 0, 10, 1);
632 let mut pool = GraphemePool::new();
633 let mut frame = Frame::new(10, 1, &mut pool);
634 status.render(area, &mut frame);
635
636 let s = row_string(&frame.buffer, 0, 10);
638 assert!(!s.is_empty(), "Got empty string");
639 }
640}