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 pub fn left(mut self, item: StatusItem<'a>) -> Self {
136 self.left.push(item);
137 self
138 }
139
140 pub fn center(mut self, item: StatusItem<'a>) -> Self {
142 self.center.push(item);
143 self
144 }
145
146 pub fn right(mut self, item: StatusItem<'a>) -> Self {
148 self.right.push(item);
149 self
150 }
151
152 pub fn style(mut self, style: Style) -> Self {
154 self.style = style;
155 self
156 }
157
158 pub fn separator(mut self, separator: &'a str) -> Self {
160 self.separator = separator;
161 self
162 }
163
164 fn items_fixed_width(&self, items: &[StatusItem]) -> usize {
166 let sep_width = display_width(self.separator);
167 let mut width = 0usize;
168 let mut prev_item = false;
169
170 for item in items {
171 if matches!(item, StatusItem::Spacer) {
172 prev_item = false;
173 continue;
174 }
175
176 if prev_item {
177 width += sep_width;
178 }
179 width += item.width();
180 prev_item = true;
181 }
182
183 width
184 }
185
186 fn spacer_count(&self, items: &[StatusItem]) -> usize {
188 items
189 .iter()
190 .filter(|item| matches!(item, StatusItem::Spacer))
191 .count()
192 }
193
194 fn render_items(
196 &self,
197 frame: &mut Frame,
198 items: &[StatusItem],
199 mut x: u16,
200 y: u16,
201 max_x: u16,
202 style: Style,
203 ) -> u16 {
204 let available = max_x.saturating_sub(x) as usize;
205 let fixed_width = self.items_fixed_width(items);
206 let spacers = self.spacer_count(items);
207 let extra = available.saturating_sub(fixed_width);
208 let per_spacer = extra.checked_div(spacers).unwrap_or(0);
209 let mut remainder = extra.checked_rem(spacers).unwrap_or(0);
210 let mut prev_item = false;
211
212 for item in items {
213 if x >= max_x {
214 break;
215 }
216
217 if matches!(item, StatusItem::Spacer) {
218 let mut space = per_spacer;
219 if remainder > 0 {
220 space += 1;
221 remainder -= 1;
222 }
223 let advance = (space as u16).min(max_x.saturating_sub(x));
224 x = x.saturating_add(advance);
225 prev_item = false;
226 continue;
227 }
228
229 if prev_item && !self.separator.is_empty() {
231 x = draw_text_span(frame, x, y, self.separator, style, max_x);
232 if x >= max_x {
233 break;
234 }
235 }
236
237 let text = item.render_to_string();
238 x = draw_text_span(frame, x, y, &text, style, max_x);
239 prev_item = true;
240 }
241
242 x
243 }
244}
245
246impl Widget for StatusLine<'_> {
247 fn render(&self, area: Rect, frame: &mut Frame) {
248 #[cfg(feature = "tracing")]
249 let _span = tracing::debug_span!(
250 "widget_render",
251 widget = "StatusLine",
252 x = area.x,
253 y = area.y,
254 w = area.width,
255 h = area.height
256 )
257 .entered();
258
259 if area.is_empty() || area.height < 1 {
260 return;
261 }
262
263 let deg = frame.buffer.degradation;
264
265 if !deg.render_content() {
267 return;
268 }
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(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);
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() && right_x >= area.x {
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::buffer::Buffer;
336 use ftui_render::cell::PackedRgba;
337 use ftui_render::grapheme_pool::GraphemePool;
338
339 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
340 (0..width)
341 .map(|x| {
342 buf.get(x, y)
343 .and_then(|c| c.content.as_char())
344 .unwrap_or(' ')
345 })
346 .collect::<String>()
347 .trim_end()
348 .to_string()
349 }
350
351 fn row_full(buf: &Buffer, y: u16, width: u16) -> String {
352 (0..width)
353 .map(|x| {
354 buf.get(x, y)
355 .and_then(|c| c.content.as_char())
356 .unwrap_or(' ')
357 })
358 .collect()
359 }
360
361 #[test]
362 fn empty_status_line() {
363 let status = StatusLine::new();
364 let area = Rect::new(0, 0, 20, 1);
365 let mut pool = GraphemePool::new();
366 let mut frame = Frame::new(20, 1, &mut pool);
367 status.render(area, &mut frame);
368
369 let s = row_string(&frame.buffer, 0, 20);
371 assert!(s.is_empty() || s.chars().all(|c| c == ' '));
372 }
373
374 #[test]
375 fn left_only() {
376 let status = StatusLine::new().left(StatusItem::text("[INSERT]"));
377 let area = Rect::new(0, 0, 20, 1);
378 let mut pool = GraphemePool::new();
379 let mut frame = Frame::new(20, 1, &mut pool);
380 status.render(area, &mut frame);
381
382 let s = row_string(&frame.buffer, 0, 20);
383 assert!(s.starts_with("[INSERT]"), "Got: '{s}'");
384 }
385
386 #[test]
387 fn right_only() {
388 let status = StatusLine::new().right(StatusItem::text("Ln 42"));
389 let area = Rect::new(0, 0, 20, 1);
390 let mut pool = GraphemePool::new();
391 let mut frame = Frame::new(20, 1, &mut pool);
392 status.render(area, &mut frame);
393
394 let s = row_string(&frame.buffer, 0, 20);
395 assert!(s.ends_with("Ln 42"), "Got: '{s}'");
396 }
397
398 #[test]
399 fn center_only() {
400 let status = StatusLine::new().center(StatusItem::text("file.rs"));
401 let area = Rect::new(0, 0, 20, 1);
402 let mut pool = GraphemePool::new();
403 let mut frame = Frame::new(20, 1, &mut pool);
404 status.render(area, &mut frame);
405
406 let s = row_string(&frame.buffer, 0, 20);
407 assert!(s.contains("file.rs"), "Got: '{s}'");
408 let pos = s.find("file.rs").unwrap();
410 assert!(pos > 2 && pos < 15, "Not centered, pos={pos}, got: '{s}'");
411 }
412
413 #[test]
414 fn all_three_regions() {
415 let status = StatusLine::new()
416 .left(StatusItem::text("L"))
417 .center(StatusItem::text("C"))
418 .right(StatusItem::text("R"));
419 let area = Rect::new(0, 0, 20, 1);
420 let mut pool = GraphemePool::new();
421 let mut frame = Frame::new(20, 1, &mut pool);
422 status.render(area, &mut frame);
423
424 let s = row_string(&frame.buffer, 0, 20);
425 assert!(s.starts_with("L"), "Got: '{s}'");
426 assert!(s.ends_with("R"), "Got: '{s}'");
427 assert!(s.contains("C"), "Got: '{s}'");
428 }
429
430 #[test]
431 fn key_hint() {
432 let status = StatusLine::new().left(StatusItem::key_hint("^C", "Quit"));
433 let area = Rect::new(0, 0, 20, 1);
434 let mut pool = GraphemePool::new();
435 let mut frame = Frame::new(20, 1, &mut pool);
436 status.render(area, &mut frame);
437
438 let s = row_string(&frame.buffer, 0, 20);
439 assert!(s.contains("^C Quit"), "Got: '{s}'");
440 }
441
442 #[test]
443 fn progress() {
444 let status = StatusLine::new().left(StatusItem::progress(50, 100));
445 let area = Rect::new(0, 0, 20, 1);
446 let mut pool = GraphemePool::new();
447 let mut frame = Frame::new(20, 1, &mut pool);
448 status.render(area, &mut frame);
449
450 let s = row_string(&frame.buffer, 0, 20);
451 assert!(s.contains("50%"), "Got: '{s}'");
452 }
453
454 #[test]
455 fn multiple_items_left() {
456 let status = StatusLine::new()
457 .left(StatusItem::text("A"))
458 .left(StatusItem::text("B"))
459 .left(StatusItem::text("C"));
460 let area = Rect::new(0, 0, 20, 1);
461 let mut pool = GraphemePool::new();
462 let mut frame = Frame::new(20, 1, &mut pool);
463 status.render(area, &mut frame);
464
465 let s = row_string(&frame.buffer, 0, 20);
466 assert!(s.starts_with("A B C"), "Got: '{s}'");
467 }
468
469 #[test]
470 fn custom_separator() {
471 let status = StatusLine::new()
472 .separator(" | ")
473 .left(StatusItem::text("A"))
474 .left(StatusItem::text("B"));
475 let area = Rect::new(0, 0, 20, 1);
476 let mut pool = GraphemePool::new();
477 let mut frame = Frame::new(20, 1, &mut pool);
478 status.render(area, &mut frame);
479
480 let s = row_string(&frame.buffer, 0, 20);
481 assert!(s.contains("A | B"), "Got: '{s}'");
482 }
483
484 #[test]
485 fn spacer_expands_and_skips_separators() {
486 let status = StatusLine::new()
487 .separator(" | ")
488 .left(StatusItem::text("L"))
489 .left(StatusItem::spacer())
490 .left(StatusItem::text("R"));
491 let area = Rect::new(0, 0, 10, 1);
492 let mut pool = GraphemePool::new();
493 let mut frame = Frame::new(10, 1, &mut pool);
494 status.render(area, &mut frame);
495
496 let row = row_full(&frame.buffer, 0, 10);
497 let chars: Vec<char> = row.chars().collect();
498 assert_eq!(chars[0], 'L');
499 assert_eq!(chars[9], 'R');
500 assert!(
501 !row.contains('|'),
502 "Spacer should skip separators, got: '{row}'"
503 );
504 }
505
506 #[test]
507 fn style_applied() {
508 let fg = PackedRgba::rgb(255, 0, 0);
509 let status = StatusLine::new()
510 .style(Style::new().fg(fg))
511 .left(StatusItem::text("X"));
512 let area = Rect::new(0, 0, 10, 1);
513 let mut pool = GraphemePool::new();
514 let mut frame = Frame::new(10, 1, &mut pool);
515 status.render(area, &mut frame);
516
517 assert_eq!(frame.buffer.get(0, 0).unwrap().fg, fg);
518 }
519
520 #[test]
521 fn is_essential() {
522 let status = StatusLine::new();
523 assert!(status.is_essential());
524 }
525
526 #[test]
527 fn zero_area_no_panic() {
528 let status = StatusLine::new().left(StatusItem::text("Test"));
529 let area = Rect::new(0, 0, 0, 0);
530 let mut pool = GraphemePool::new();
531 let mut frame = Frame::new(1, 1, &mut pool);
532 status.render(area, &mut frame);
533 }
535
536 #[test]
537 fn truncation_when_too_narrow() {
538 let status = StatusLine::new()
539 .left(StatusItem::text("VERYLONGTEXT"))
540 .right(StatusItem::text("R"));
541 let area = Rect::new(0, 0, 10, 1);
542 let mut pool = GraphemePool::new();
543 let mut frame = Frame::new(10, 1, &mut pool);
544 status.render(area, &mut frame);
545
546 let s = row_string(&frame.buffer, 0, 10);
548 assert!(!s.is_empty(), "Got empty string");
549 }
550}