1use std::io::Write;
2
3use crossterm::Command;
4use crossterm::QueueableCommand;
5use crossterm::cursor::Hide;
6use crossterm::cursor::MoveTo;
7use crossterm::cursor::RestorePosition;
8use crossterm::cursor::SavePosition;
9use crossterm::style::Attribute;
10use crossterm::style::Print;
11use crossterm::style::ResetColor;
12use crossterm::style::SetAttribute;
13use crossterm::style::SetBackgroundColor;
14use crossterm::style::SetForegroundColor;
15use muxr_config::TabBarConfig;
16use muxr_core::LayoutSnapshot;
17use muxr_core::PaneSnapshot;
18use muxr_core::RenderColor;
19use muxr_core::TabId;
20use muxr_core::TabSnapshot;
21use muxr_core::TrackedProcessState;
22use rootcause::prelude::ResultExt;
23use unicode_width::UnicodeWidthChar;
24
25const ROWS_PER_TAB: u16 = 3;
26const SEPARATOR: &str = "\u{2502}";
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29enum TabBarItemState {
30 Active,
31 Inactive,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35struct SidebarTab {
36 state: TabBarItemState,
37 tracked_process_state: TrackedProcessState,
38 cmd_label: Option<String>,
39 path_label: String,
40}
41
42pub fn queue(
47 stdout: &mut impl Write,
48 config: TabBarConfig,
49 layout: &LayoutSnapshot,
50 rows: u16,
51) -> rootcause::Result<()> {
52 queue_cmd(stdout, Hide)?;
53 queue_cmd(stdout, SavePosition)?;
54
55 let tabs = self::sidebar_tabs(layout);
56 let mut row = 0;
57 for tab in &tabs {
58 if row >= rows {
59 break;
60 }
61 self::queue_sidebar_row(
62 stdout,
63 config,
64 row,
65 tab.state,
66 TrackedProcessState::None,
67 &tab.path_label,
68 )?;
69 row = row.saturating_add(1);
70
71 if row >= rows {
72 break;
73 }
74 self::queue_sidebar_row(
75 stdout,
76 config,
77 row,
78 tab.state,
79 tab.tracked_process_state,
80 tab.cmd_label.as_deref().unwrap_or(""),
81 )?;
82 row = row.saturating_add(1);
83
84 if row >= rows {
85 break;
86 }
87 self::queue_sidebar_row(stdout, config, row, tab.state, TrackedProcessState::None, "")?;
89 row = row.saturating_add(1);
90 }
91
92 while row < rows {
93 self::queue_sidebar_row(
94 stdout,
95 config,
96 row,
97 TabBarItemState::Inactive,
98 TrackedProcessState::None,
99 "",
100 )?;
101 row = row.saturating_add(1);
102 }
103
104 queue_cmd(stdout, ResetColor)?;
105 queue_cmd(stdout, SetAttribute(Attribute::Reset))?;
106 queue_cmd(stdout, RestorePosition)?;
107 Ok(())
108}
109
110#[must_use]
111pub fn tab_id_at_row(layout: &LayoutSnapshot, row: u16) -> Option<TabId> {
112 let index = usize::from(row / ROWS_PER_TAB);
113 layout.tabs().get(index).map(|tab| *tab.id())
114}
115
116fn queue_sidebar_row(
117 stdout: &mut impl Write,
118 config: TabBarConfig,
119 row: u16,
120 state: TabBarItemState,
121 tracked_process_state: TrackedProcessState,
122 text: &str,
123) -> rootcause::Result<()> {
124 let content_width = usize::from(config.width.saturating_sub(2));
125 queue_cmd(stdout, MoveTo(0, row))?;
126 queue_cmd(
127 stdout,
128 SetBackgroundColor(crate::frame_buffer::crossterm_color(config.bg)),
129 )?;
130 queue_cmd(
131 stdout,
132 SetForegroundColor(if state == TabBarItemState::Active {
133 crate::frame_buffer::crossterm_color(config.rail.active_fg)
134 } else {
135 crate::frame_buffer::crossterm_color(config.rail.inactive_fg)
136 }),
137 )?;
138 queue_cmd(stdout, Print("\u{258e}"))?;
139 self::queue_sidebar_text_style(stdout, config, state)?;
140 let marker_width = if self::tracked_process_state_dot_color(config, tracked_process_state).is_some() {
142 2
143 } else {
144 0
145 };
146 let label = self::take_terminal_cells(text, content_width.saturating_sub(marker_width));
149 let used_width = self::terminal_cell_width(&label).saturating_add(marker_width);
150 self::queue_tracked_process_state_marker(stdout, config, state, tracked_process_state)?;
151 queue_cmd(stdout, Print(&label))?;
152 self::queue_sidebar_text_style(stdout, config, state)?;
153 let trailing_width = content_width.saturating_sub(used_width);
154 if trailing_width > 0 {
155 queue_cmd(stdout, Print(pad("", trailing_width)))?;
156 }
157 queue_cmd(stdout, SetAttribute(Attribute::Reset))?;
158 queue_cmd(
159 stdout,
160 SetBackgroundColor(crate::frame_buffer::crossterm_color(config.bg)),
161 )?;
162 queue_cmd(
163 stdout,
164 SetForegroundColor(crate::frame_buffer::crossterm_color(config.separator_fg)),
165 )?;
166 queue_cmd(stdout, Print(SEPARATOR))?;
167 Ok(())
168}
169
170fn queue_sidebar_text_style(
171 stdout: &mut impl Write,
172 config: TabBarConfig,
173 state: TabBarItemState,
174) -> rootcause::Result<()> {
175 queue_cmd(stdout, SetAttribute(Attribute::Reset))?;
176 queue_cmd(
177 stdout,
178 SetBackgroundColor(crate::frame_buffer::crossterm_color(config.bg)),
179 )?;
180 queue_cmd(
181 stdout,
182 SetForegroundColor(if state == TabBarItemState::Active {
183 crate::frame_buffer::crossterm_color(config.active_fg)
184 } else {
185 crate::frame_buffer::crossterm_color(config.inactive_fg)
186 }),
187 )?;
188 if state == TabBarItemState::Active {
189 queue_cmd(stdout, SetAttribute(Attribute::Bold))?;
190 }
191 Ok(())
192}
193
194fn queue_tracked_process_state_marker(
195 stdout: &mut impl Write,
196 config: TabBarConfig,
197 state: TabBarItemState,
198 tracked_process_state: TrackedProcessState,
199) -> rootcause::Result<()> {
200 let Some(color) = self::tracked_process_state_dot_color(config, tracked_process_state) else {
201 return Ok(());
202 };
203
204 queue_cmd(stdout, SetAttribute(Attribute::Bold))?;
205 queue_cmd(stdout, SetForegroundColor(crate::frame_buffer::crossterm_color(color)))?;
206 queue_cmd(stdout, Print("\u{2022}"))?;
207 self::queue_sidebar_text_style(stdout, config, state)?;
208 queue_cmd(stdout, Print(" "))?;
209 Ok(())
210}
211
212const fn tracked_process_state_dot_color(
213 config: TabBarConfig,
214 tracked_process_state: TrackedProcessState,
215) -> Option<RenderColor> {
216 match tracked_process_state {
217 TrackedProcessState::Busy => Some(config.tracked_process.busy_fg),
218 TrackedProcessState::Unseen => Some(config.tracked_process.unseen_fg),
219 TrackedProcessState::None | TrackedProcessState::Seen => None,
220 }
221}
222
223fn sidebar_tabs(layout: &LayoutSnapshot) -> Vec<SidebarTab> {
224 let home = std::env::var("HOME").ok();
225 self::sidebar_tabs_with_home(layout, home.as_deref())
226}
227
228fn sidebar_tabs_with_home(layout: &LayoutSnapshot, home: Option<&str>) -> Vec<SidebarTab> {
229 layout
230 .tabs()
231 .iter()
232 .map(|tab| {
233 let state = if tab.id() == layout.active_tab() {
234 TabBarItemState::Active
235 } else {
236 TabBarItemState::Inactive
237 };
238 let display_pane = self::display_pane(tab, state);
239 SidebarTab {
240 state,
241 tracked_process_state: display_pane.map(|pane| pane.tracked_process_state).unwrap_or_default(),
242 cmd_label: self::cmd_label(display_pane),
243 path_label: self::path_label(tab, display_pane, home),
244 }
245 })
246 .collect()
247}
248
249fn display_pane(tab: &TabSnapshot, state: TabBarItemState) -> Option<&PaneSnapshot> {
250 if state == TabBarItemState::Active && tab.panes().len() > 1 {
251 return self::unfocused_unseen_tracked_process_pane(tab).or_else(|| self::active_pane(tab));
252 }
253
254 self::inactive_tab_display_pane(tab)
255}
256
257fn unfocused_unseen_tracked_process_pane(tab: &TabSnapshot) -> Option<&PaneSnapshot> {
258 tab.panes()
259 .iter()
260 .find(|pane| &pane.id != tab.active_pane() && pane.tracked_process_state == TrackedProcessState::Unseen)
261}
262
263fn inactive_tab_display_pane(tab: &TabSnapshot) -> Option<&PaneSnapshot> {
264 self::focused_pane_with_tracked_process_state(tab, TrackedProcessState::Unseen)
267 .or_else(|| self::focused_pane_with_tracked_process_state(tab, TrackedProcessState::Busy))
268 .or_else(|| self::focused_pane_with_tracked_process_state(tab, TrackedProcessState::Seen))
269 .or_else(|| self::active_pane(tab))
270}
271
272fn focused_pane_with_tracked_process_state(
273 tab: &TabSnapshot,
274 tracked_process_state: TrackedProcessState,
275) -> Option<&PaneSnapshot> {
276 tab.panes()
277 .iter()
278 .filter(|pane| pane.tracked_process_state == tracked_process_state)
279 .max_by_key(|pane| pane.focus_seq)
280}
281
282fn cmd_label(pane: Option<&PaneSnapshot>) -> Option<String> {
283 pane.and_then(|pane| pane.cmd_label.as_deref())
284 .map(str::trim)
285 .filter(|cmd| !cmd.is_empty())
286 .map(ToOwned::to_owned)
287}
288
289fn path_label(tab: &TabSnapshot, pane: Option<&PaneSnapshot>, home: Option<&str>) -> String {
290 let cwd = pane.map_or("", |pane| pane.cwd.trim());
291 if cwd.is_empty() {
292 tab.title().to_owned()
293 } else {
294 self::short_cwd_with_home(cwd, home)
295 }
296}
297
298fn active_pane(tab: &TabSnapshot) -> Option<&PaneSnapshot> {
299 tab.panes().iter().find(|pane| &pane.id == tab.active_pane())
300}
301
302fn short_cwd_with_home(cwd: &str, home: Option<&str>) -> String {
303 let cwd = cwd.trim();
304 if cwd.is_empty() {
305 return String::new();
306 }
307
308 let (prefix, rest) = self::strip_home_or_root(cwd, home);
309 let components = rest
310 .split('/')
311 .filter(|component| !component.is_empty())
312 .collect::<Vec<_>>();
313 if components.is_empty() {
314 return prefix;
315 }
316
317 let mut out = prefix;
318 for (index, component) in components.iter().enumerate() {
319 let segment = if index.saturating_add(1) == components.len() {
320 (*component).to_owned()
321 } else {
322 component.chars().next().map_or_else(String::new, |ch| ch.to_string())
323 };
324 self::push_path_segment(&mut out, &segment);
325 }
326 out
327}
328
329fn strip_home_or_root<'a>(cwd: &'a str, home: Option<&str>) -> (String, &'a str) {
330 if let Some(home) = home.filter(|home| !home.is_empty())
331 && (cwd == home || cwd.strip_prefix(home).is_some_and(|rest| rest.starts_with('/')))
332 {
333 let rest = cwd.strip_prefix(home).unwrap_or(cwd).trim_start_matches('/');
334 return ("~".to_owned(), rest);
335 }
336
337 cwd.strip_prefix('/')
338 .map_or_else(|| (String::new(), cwd), |rest| ("/".to_owned(), rest))
339}
340
341fn push_path_segment(out: &mut String, segment: &str) {
342 if !out.is_empty() && out != "/" {
343 out.push('/');
344 }
345 out.push_str(segment);
346}
347
348fn pad(text: &str, width: usize) -> String {
349 let mut out = self::take_terminal_cells(text, width);
350 let used_width = self::terminal_cell_width(&out);
351 for _ in used_width..width {
352 out.push(' ');
353 }
354 out
355}
356
357fn take_terminal_cells(text: &str, max_width: usize) -> String {
358 let mut out = String::new();
359 let mut width = 0_usize;
360 let mut pending_zero_width = String::new();
361 for character in text.chars() {
362 let character_width = character.width().unwrap_or(0);
363 if character_width == 0 {
364 pending_zero_width.push(character);
365 continue;
366 }
367 let Some(next_width) = width.checked_add(character_width) else {
368 pending_zero_width.clear();
369 break;
370 };
371 if next_width > max_width {
372 pending_zero_width.clear();
373 break;
374 }
375 out.push_str(&pending_zero_width);
376 pending_zero_width.clear();
377 out.push(character);
378 width = next_width;
379 }
380 if !out.is_empty() {
382 out.push_str(&pending_zero_width);
383 }
384 out
385}
386
387fn terminal_cell_width(text: &str) -> usize {
388 text.chars().map(|character| character.width().unwrap_or(0)).sum()
389}
390
391fn queue_cmd<W, C>(stdout: &mut W, cmd: C) -> rootcause::Result<()>
392where
393 W: Write,
394 C: Command,
395{
396 Ok(stdout.queue(cmd).map(|_| ()).context("failed to write muxr tab bar")?)
397}
398
399#[cfg(test)]
400mod tests {
401 use muxr_config::MuxrConfig;
402 use muxr_core::PaneId;
403 use rootcause::report;
404 use rstest::rstest;
405 use test_that::prelude::*;
406
407 use super::*;
408
409 #[test]
410 fn test_sidebar_tabs_when_second_tab_is_active_uses_active_pane_cwd() -> rootcause::Result<()> {
411 let layout = self::layout_snapshot(
412 2,
413 vec![
414 self::tab_snapshot(
415 1,
416 "default",
417 1,
418 vec![self::pane_snapshot(
419 1,
420 "/Users/me/work/default",
421 None,
422 TrackedProcessState::None,
423 )?],
424 )?,
425 self::tab_snapshot(
426 2,
427 "tab 2",
428 2,
429 vec![self::pane_snapshot(
430 2,
431 "/Users/me/src/muxr",
432 Some("nvim"),
433 TrackedProcessState::None,
434 )?],
435 )?,
436 ],
437 )?;
438
439 assert_that!(
440 sidebar_tabs_with_home(&layout, Some("/Users/me")),
441 eq(vec![
442 SidebarTab {
443 state: TabBarItemState::Inactive,
444 tracked_process_state: TrackedProcessState::None,
445 cmd_label: None,
446 path_label: "~/w/default".to_owned(),
447 },
448 SidebarTab {
449 state: TabBarItemState::Active,
450 tracked_process_state: TrackedProcessState::None,
451 cmd_label: Some("nvim".to_owned()),
452 path_label: "~/s/muxr".to_owned(),
453 },
454 ])
455 );
456 Ok(())
457 }
458
459 #[test]
460 fn test_sidebar_tabs_when_inactive_tab_has_unseen_tracked_process_pane_uses_unseen_pane() -> rootcause::Result<()> {
461 let layout = self::layout_snapshot(
462 1,
463 vec![
464 self::tab_snapshot(
465 1,
466 "active",
467 1,
468 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
469 )?,
470 self::tab_snapshot(
471 2,
472 "inactive",
473 2,
474 vec![
475 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
476 self::pane_snapshot(4, "/tmp/cargo", Some("cargo test"), TrackedProcessState::Busy)?,
477 self::pane_snapshot(3, "/tmp/codex", Some("codex"), TrackedProcessState::Unseen)?,
478 ],
479 )?,
480 ],
481 )?;
482
483 assert_that!(
484 sidebar_tabs_with_home(&layout, None),
485 eq(vec![
486 SidebarTab {
487 state: TabBarItemState::Active,
488 tracked_process_state: TrackedProcessState::None,
489 cmd_label: None,
490 path_label: "/t/active".to_owned(),
491 },
492 SidebarTab {
493 state: TabBarItemState::Inactive,
494 tracked_process_state: TrackedProcessState::Unseen,
495 cmd_label: Some("codex".to_owned()),
496 path_label: "/t/codex".to_owned(),
497 },
498 ])
499 );
500 Ok(())
501 }
502
503 #[rstest]
504 #[case::unseen(
505 TrackedProcessState::Unseen,
506 "/tmp/unseen-old",
507 "codex-old",
508 "/tmp/unseen-recent",
509 "codex-recent",
510 "/t/unseen-recent",
511 "codex-recent"
512 )]
513 #[case::busy(
514 TrackedProcessState::Busy,
515 "/tmp/busy-old",
516 "claude-old",
517 "/tmp/busy-recent",
518 "claude-recent",
519 "/t/busy-recent",
520 "claude-recent"
521 )]
522 #[case::seen(
523 TrackedProcessState::Seen,
524 "/tmp/seen-old",
525 "cursor-old",
526 "/tmp/seen-recent",
527 "cursor-recent",
528 "/t/seen-recent",
529 "cursor-recent"
530 )]
531 fn test_sidebar_tabs_when_inactive_tab_has_multiple_tracked_processes_in_same_state_uses_last_focused(
532 #[case] tracked_process_state: TrackedProcessState,
533 #[case] first_cwd: &str,
534 #[case] first_cmd_label: &str,
535 #[case] second_cwd: &str,
536 #[case] second_cmd_label: &str,
537 #[case] expected_path_label: &str,
538 #[case] expected_cmd_label: &str,
539 ) -> rootcause::Result<()> {
540 let mut older_tracked_process_pane =
541 self::pane_snapshot(4, first_cwd, Some(first_cmd_label), tracked_process_state)?;
542 older_tracked_process_pane.focus_seq = 10;
543 let mut recent_tracked_process_pane =
544 self::pane_snapshot(3, second_cwd, Some(second_cmd_label), tracked_process_state)?;
545 recent_tracked_process_pane.focus_seq = 20;
546
547 let layout = self::layout_snapshot(
548 1,
549 vec![
550 self::tab_snapshot(
551 1,
552 "active",
553 1,
554 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
555 )?,
556 self::tab_snapshot(
557 2,
558 "inactive",
559 2,
560 vec![
561 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
562 older_tracked_process_pane,
563 recent_tracked_process_pane,
564 ],
565 )?,
566 ],
567 )?;
568
569 let tabs = sidebar_tabs_with_home(&layout, None);
570
571 assert_that!(
572 tabs[1],
573 eq(SidebarTab {
574 state: TabBarItemState::Inactive,
575 tracked_process_state,
576 cmd_label: Some(expected_cmd_label.to_owned()),
577 path_label: expected_path_label.to_owned(),
578 })
579 );
580 Ok(())
581 }
582
583 #[test]
584 fn test_sidebar_tabs_when_inactive_tab_has_seen_tracked_process_uses_tracked_process_pane_without_dot_state()
585 -> rootcause::Result<()> {
586 let layout = self::layout_snapshot(
587 1,
588 vec![
589 self::tab_snapshot(
590 1,
591 "active",
592 1,
593 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
594 )?,
595 self::tab_snapshot(
596 2,
597 "inactive",
598 2,
599 vec![
600 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
601 self::pane_snapshot(3, "/tmp/codex", Some("codex"), TrackedProcessState::Seen)?,
602 ],
603 )?,
604 ],
605 )?;
606
607 let tabs = sidebar_tabs_with_home(&layout, None);
608
609 assert_that!(
610 tabs[1],
611 eq(SidebarTab {
612 state: TabBarItemState::Inactive,
613 tracked_process_state: TrackedProcessState::Seen,
614 cmd_label: Some("codex".to_owned()),
615 path_label: "/t/codex".to_owned(),
616 })
617 );
618 Ok(())
619 }
620
621 #[test]
622 fn test_sidebar_tabs_when_active_tab_has_unfocused_unseen_tracked_process_uses_tracked_process_pane()
623 -> rootcause::Result<()> {
624 let layout = self::layout_snapshot(
625 1,
626 vec![self::tab_snapshot(
627 1,
628 "default",
629 1,
630 vec![
631 self::pane_snapshot(1, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
632 self::pane_snapshot(2, "/tmp/codex", Some("codex"), TrackedProcessState::Unseen)?,
633 ],
634 )?],
635 )?;
636
637 assert_that!(
638 sidebar_tabs_with_home(&layout, None),
639 eq(vec![SidebarTab {
640 state: TabBarItemState::Active,
641 tracked_process_state: TrackedProcessState::Unseen,
642 cmd_label: Some("codex".to_owned()),
643 path_label: "/t/codex".to_owned(),
644 }])
645 );
646 Ok(())
647 }
648
649 #[rstest]
650 #[case::home_project("/Users/me/project", Some("/Users/me"), "~/project")]
651 #[case::home_nested("/Users/me/src/pkg/project", Some("/Users/me"), "~/s/p/project")]
652 #[case::root_nested("/usr/local/bin", Some("/Users/me"), "/u/l/bin")]
653 #[case::relative("target/debug", Some("/Users/me"), "t/debug")]
654 fn test_short_cwd_with_home_returns_compact_path(
655 #[case] cwd: &str,
656 #[case] home: Option<&str>,
657 #[case] expected: &str,
658 ) {
659 assert_that!(short_cwd_with_home(cwd, home), eq(expected));
660 }
661
662 #[test]
663 fn test_take_terminal_cells_when_text_contains_zwj_emoji_uses_alacritty_codepoint_width() {
664 let text = "👩🌾X";
665
666 assert_that!(terminal_cell_width(text), eq(5));
667 assert_that!(take_terminal_cells(text, 2), eq("👩"));
668 assert_that!(take_terminal_cells(text, 3), eq("👩"));
669 assert_that!(take_terminal_cells(text, 4), eq("👩🌾"));
670 assert_that!(take_terminal_cells(text, 5), eq(text));
671 }
672
673 #[test]
674 fn test_pad_when_text_contains_wide_codepoints_fills_remaining_terminal_cells() {
675 assert_that!(pad("👩🌾", 6), eq("👩🌾 "));
676 }
677
678 #[test]
679 fn test_queue_sidebar_row_when_path_contains_zwj_emoji_keeps_label_flush_after_rail() -> rootcause::Result<()> {
680 let mut output = CountingWriter::default();
681 let config = MuxrConfig::default().tab_bar;
682
683 queue_sidebar_row(
684 &mut output,
685 config,
686 0,
687 TabBarItemState::Active,
688 TrackedProcessState::None,
689 "/t/👩🌾",
690 )?;
691
692 let visible = self::strip_ansi(&output.rendered_string()?);
693 assert_that!(visible, starts_with("\u{258e}/t/👩🌾"));
694 let Some((before_separator, _)) = visible.split_once(SEPARATOR) else {
695 return Err(report!("expected tab-bar separator"));
696 };
697 assert_that!(
698 self::terminal_cell_width(before_separator),
699 eq(usize::from(config.width.saturating_sub(1)))
700 );
701 assert_that!(self::terminal_cell_width(&visible), eq(usize::from(config.width)));
702 Ok(())
703 }
704
705 #[test]
706 fn test_queue_when_layout_is_rendered_writes_sidebar_without_flushing() -> rootcause::Result<()> {
707 let layout = self::layout_snapshot(
708 1,
709 vec![self::tab_snapshot(
710 1,
711 "default",
712 1,
713 vec![self::pane_snapshot(
714 1,
715 "project",
716 Some("codex"),
717 TrackedProcessState::Busy,
718 )?],
719 )?],
720 )?;
721 let mut output = CountingWriter::default();
722
723 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 3)?;
724
725 let rendered = output.rendered_string()?;
726 assert_that!(rendered, contains_substring("project"));
727 assert_that!(rendered, contains_substring("codex"));
728 assert_that!(rendered, contains_substring("\u{2022}"));
729 assert_that!(rendered, contains_substring(SEPARATOR));
730 assert_that!(output.flushes, eq(0));
731 Ok(())
732 }
733
734 #[test]
735 fn test_queue_when_seen_tracked_process_is_rendered_shows_label_without_marker() -> rootcause::Result<()> {
736 let layout = self::layout_snapshot(
737 1,
738 vec![self::tab_snapshot(
739 1,
740 "default",
741 1,
742 vec![self::pane_snapshot(
743 1,
744 "project",
745 Some("cx"),
746 TrackedProcessState::Seen,
747 )?],
748 )?],
749 )?;
750 let mut output = CountingWriter::default();
751
752 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 2)?;
753
754 let visible = self::strip_ansi(&output.rendered_string()?);
755 assert_that!(visible, contains_substring("\u{258e}cx"));
756 assert_that!(visible, not(contains_substring("\u{2022} cx")));
757 Ok(())
758 }
759
760 #[test]
761 fn test_queue_when_tracked_process_marker_is_rendered_keeps_labels_flush_and_spaces_marker() -> rootcause::Result<()>
762 {
763 let layout = self::layout_snapshot(
764 1,
765 vec![self::tab_snapshot(
766 1,
767 "default",
768 1,
769 vec![self::pane_snapshot(
770 1,
771 "project",
772 Some("cx"),
773 TrackedProcessState::Busy,
774 )?],
775 )?],
776 )?;
777 let mut output = CountingWriter::default();
778
779 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 2)?;
780
781 let visible = self::strip_ansi(&output.rendered_string()?);
782 assert_that!(visible, contains_substring("\u{258e}project"));
783 assert_that!(visible, contains_substring("\u{258e}\u{2022} cx"));
784 assert_that!(visible, not(contains_substring("\u{258e} project")));
785 assert_that!(visible, not(contains_substring("\u{258e} cx")));
786 assert_that!(visible, not(contains_substring("\u{258e}cx \u{2022}")));
787 Ok(())
788 }
789
790 #[test]
791 fn test_queue_when_tab_is_rendered_adds_spacer_row_after_cmd_row() -> rootcause::Result<()> {
792 let layout = self::layout_snapshot(
793 1,
794 vec![self::tab_snapshot(
795 1,
796 "default",
797 1,
798 vec![self::pane_snapshot(
799 1,
800 "project",
801 Some("cx"),
802 TrackedProcessState::Busy,
803 )?],
804 )?],
805 )?;
806 let mut output = CountingWriter::default();
807
808 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 3)?;
809
810 let visible = self::strip_ansi(&output.rendered_string()?);
811 let rows = visible.split(SEPARATOR).collect::<Vec<_>>();
812 assert_that!(rows.len(), eq(4));
813 assert_that!(rows[0], starts_with("\u{258e}project"));
814 assert_that!(rows[1], starts_with("\u{258e}\u{2022} cx"));
815 assert_that!(rows[2].trim(), eq("\u{258e}"));
816 Ok(())
817 }
818
819 #[rstest]
820 #[case::first_path_row(0, Some(1))]
821 #[case::first_cmd_row(1, Some(1))]
822 #[case::first_spacer_row(2, Some(1))]
823 #[case::second_path_row(3, Some(2))]
824 #[case::second_cmd_row(4, Some(2))]
825 #[case::second_spacer_row(5, Some(2))]
826 #[case::below_tabs(6, None)]
827 fn test_tab_id_at_row_when_row_varies_returns_clicked_tab(
828 #[case] row: u16,
829 #[case] expected: Option<u32>,
830 ) -> rootcause::Result<()> {
831 let layout = self::layout_snapshot(
832 1,
833 vec![
834 self::tab_snapshot(
835 1,
836 "default",
837 1,
838 vec![self::pane_snapshot(1, "default", None, TrackedProcessState::None)?],
839 )?,
840 self::tab_snapshot(
841 2,
842 "tab 2",
843 2,
844 vec![self::pane_snapshot(2, "tab-2", None, TrackedProcessState::None)?],
845 )?,
846 ],
847 )?;
848
849 assert_that!(tab_id_at_row(&layout, row).map(TabId::get), eq(expected));
850 Ok(())
851 }
852
853 fn layout_snapshot(active_tab: u32, tabs: Vec<TabSnapshot>) -> rootcause::Result<LayoutSnapshot> {
854 LayoutSnapshot::new(TabId::new(active_tab)?, tabs)
855 }
856
857 fn tab_snapshot(
858 id: u32,
859 title: &str,
860 active_pane: u32,
861 panes: Vec<PaneSnapshot>,
862 ) -> rootcause::Result<TabSnapshot> {
863 TabSnapshot::new(TabId::new(id)?, title, PaneId::new(active_pane)?, panes)
864 }
865
866 fn pane_snapshot(
867 id: u32,
868 cwd: &str,
869 cmd_label: Option<&str>,
870 tracked_process_state: TrackedProcessState,
871 ) -> rootcause::Result<PaneSnapshot> {
872 Ok(PaneSnapshot {
873 tracked_process_state,
874 cwd: cwd.to_owned(),
875 cmd_label: cmd_label.map(str::to_owned),
876 focus_seq: u64::from(id),
877 id: PaneId::new(id)?,
878 title: "shell".to_owned(),
879 })
880 }
881
882 #[derive(Default)]
883 struct CountingWriter {
884 bytes: Vec<u8>,
885 flushes: usize,
886 }
887
888 impl CountingWriter {
889 fn rendered_string(&self) -> rootcause::Result<String> {
890 Ok(String::from_utf8(self.bytes.clone()).context("muxr tab bar test output was not utf8")?)
891 }
892 }
893
894 impl std::io::Write for CountingWriter {
895 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
896 self.bytes.extend_from_slice(buf);
897 Ok(buf.len())
898 }
899
900 fn flush(&mut self) -> std::io::Result<()> {
901 self.flushes = self.flushes.saturating_add(1);
902 Ok(())
903 }
904 }
905
906 fn strip_ansi(s: &str) -> String {
907 let mut out = String::new();
908 let mut chars = s.chars();
909 while let Some(ch) = chars.next() {
910 if ch != '\x1b' {
911 out.push(ch);
912 continue;
913 }
914 for escaped in chars.by_ref() {
915 if escaped.is_ascii_alphabetic() {
916 break;
917 }
918 }
919 }
920 out
921 }
922}