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;
23
24const ROWS_PER_TAB: u16 = 3;
25const SEPARATOR: &str = "\u{2502}";
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28enum TabBarItemState {
29 Active,
30 Inactive,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34struct SidebarTab {
35 state: TabBarItemState,
36 tracked_process_state: TrackedProcessState,
37 cmd_label: Option<String>,
38 path_label: String,
39}
40
41pub fn queue(
46 stdout: &mut impl Write,
47 config: TabBarConfig,
48 layout: &LayoutSnapshot,
49 rows: u16,
50) -> rootcause::Result<()> {
51 queue_cmd(stdout, Hide)?;
52 queue_cmd(stdout, SavePosition)?;
53
54 let tabs = self::sidebar_tabs(layout);
55 let mut row = 0;
56 for tab in &tabs {
57 if row >= rows {
58 break;
59 }
60 self::queue_sidebar_row(
61 stdout,
62 config,
63 row,
64 tab.state,
65 TrackedProcessState::None,
66 &tab.path_label,
67 )?;
68 row = row.saturating_add(1);
69
70 if row >= rows {
71 break;
72 }
73 self::queue_sidebar_row(
74 stdout,
75 config,
76 row,
77 tab.state,
78 tab.tracked_process_state,
79 tab.cmd_label.as_deref().unwrap_or(""),
80 )?;
81 row = row.saturating_add(1);
82
83 if row >= rows {
84 break;
85 }
86 self::queue_sidebar_row(stdout, config, row, tab.state, TrackedProcessState::None, "")?;
88 row = row.saturating_add(1);
89 }
90
91 while row < rows {
92 self::queue_sidebar_row(
93 stdout,
94 config,
95 row,
96 TabBarItemState::Inactive,
97 TrackedProcessState::None,
98 "",
99 )?;
100 row = row.saturating_add(1);
101 }
102
103 queue_cmd(stdout, ResetColor)?;
104 queue_cmd(stdout, SetAttribute(Attribute::Reset))?;
105 queue_cmd(stdout, RestorePosition)?;
106 Ok(())
107}
108
109#[must_use]
110pub fn tab_id_at_row(layout: &LayoutSnapshot, row: u16) -> Option<TabId> {
111 let index = usize::from(row / ROWS_PER_TAB);
112 layout.tabs().get(index).map(|tab| *tab.id())
113}
114
115fn queue_sidebar_row(
116 stdout: &mut impl Write,
117 config: TabBarConfig,
118 row: u16,
119 state: TabBarItemState,
120 tracked_process_state: TrackedProcessState,
121 text: &str,
122) -> rootcause::Result<()> {
123 let content_width = usize::from(config.width.saturating_sub(2));
124 queue_cmd(stdout, MoveTo(0, row))?;
125 queue_cmd(
126 stdout,
127 SetBackgroundColor(crate::frame_buffer::crossterm_color(config.bg)),
128 )?;
129 queue_cmd(
130 stdout,
131 SetForegroundColor(if state == TabBarItemState::Active {
132 crate::frame_buffer::crossterm_color(config.rail.active_fg)
133 } else {
134 crate::frame_buffer::crossterm_color(config.rail.inactive_fg)
135 }),
136 )?;
137 queue_cmd(stdout, Print("\u{258e}"))?;
138 self::queue_sidebar_text_style(stdout, config, state)?;
139 let marker_width = if self::tracked_process_state_dot_color(config, tracked_process_state).is_some() {
141 2
142 } else {
143 0
144 };
145 let label = text
146 .chars()
147 .take(content_width.saturating_sub(marker_width))
148 .collect::<String>();
149 let used_width = label.chars().count().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 = String::new();
350 for ch in text.chars().take(width) {
351 out.push(ch);
352 }
353 for _ in out.chars().count()..width {
354 out.push(' ');
355 }
356 out
357}
358
359fn queue_cmd<W, C>(stdout: &mut W, cmd: C) -> rootcause::Result<()>
360where
361 W: Write,
362 C: Command,
363{
364 Ok(stdout.queue(cmd).map(|_| ()).context("failed to write muxr tab bar")?)
365}
366
367#[cfg(test)]
368mod tests {
369 use muxr_config::MuxrConfig;
370 use muxr_core::PaneId;
371 use rstest::rstest;
372 use test_that::prelude::*;
373
374 use super::*;
375
376 #[test]
377 fn test_sidebar_tabs_when_second_tab_is_active_uses_active_pane_cwd() -> rootcause::Result<()> {
378 let layout = self::layout_snapshot(
379 2,
380 vec![
381 self::tab_snapshot(
382 1,
383 "default",
384 1,
385 vec![self::pane_snapshot(
386 1,
387 "/Users/me/work/default",
388 None,
389 TrackedProcessState::None,
390 )?],
391 )?,
392 self::tab_snapshot(
393 2,
394 "tab 2",
395 2,
396 vec![self::pane_snapshot(
397 2,
398 "/Users/me/src/muxr",
399 Some("nvim"),
400 TrackedProcessState::None,
401 )?],
402 )?,
403 ],
404 )?;
405
406 assert_that!(
407 sidebar_tabs_with_home(&layout, Some("/Users/me")),
408 eq(vec![
409 SidebarTab {
410 state: TabBarItemState::Inactive,
411 tracked_process_state: TrackedProcessState::None,
412 cmd_label: None,
413 path_label: "~/w/default".to_owned(),
414 },
415 SidebarTab {
416 state: TabBarItemState::Active,
417 tracked_process_state: TrackedProcessState::None,
418 cmd_label: Some("nvim".to_owned()),
419 path_label: "~/s/muxr".to_owned(),
420 },
421 ])
422 );
423 Ok(())
424 }
425
426 #[test]
427 fn test_sidebar_tabs_when_inactive_tab_has_unseen_tracked_process_pane_uses_unseen_pane() -> rootcause::Result<()> {
428 let layout = self::layout_snapshot(
429 1,
430 vec![
431 self::tab_snapshot(
432 1,
433 "active",
434 1,
435 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
436 )?,
437 self::tab_snapshot(
438 2,
439 "inactive",
440 2,
441 vec![
442 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
443 self::pane_snapshot(4, "/tmp/cargo", Some("cargo test"), TrackedProcessState::Busy)?,
444 self::pane_snapshot(3, "/tmp/codex", Some("codex"), TrackedProcessState::Unseen)?,
445 ],
446 )?,
447 ],
448 )?;
449
450 assert_that!(
451 sidebar_tabs_with_home(&layout, None),
452 eq(vec![
453 SidebarTab {
454 state: TabBarItemState::Active,
455 tracked_process_state: TrackedProcessState::None,
456 cmd_label: None,
457 path_label: "/t/active".to_owned(),
458 },
459 SidebarTab {
460 state: TabBarItemState::Inactive,
461 tracked_process_state: TrackedProcessState::Unseen,
462 cmd_label: Some("codex".to_owned()),
463 path_label: "/t/codex".to_owned(),
464 },
465 ])
466 );
467 Ok(())
468 }
469
470 #[rstest]
471 #[case::unseen(
472 TrackedProcessState::Unseen,
473 "/tmp/unseen-old",
474 "codex-old",
475 "/tmp/unseen-recent",
476 "codex-recent",
477 "/t/unseen-recent",
478 "codex-recent"
479 )]
480 #[case::busy(
481 TrackedProcessState::Busy,
482 "/tmp/busy-old",
483 "claude-old",
484 "/tmp/busy-recent",
485 "claude-recent",
486 "/t/busy-recent",
487 "claude-recent"
488 )]
489 #[case::seen(
490 TrackedProcessState::Seen,
491 "/tmp/seen-old",
492 "cursor-old",
493 "/tmp/seen-recent",
494 "cursor-recent",
495 "/t/seen-recent",
496 "cursor-recent"
497 )]
498 fn test_sidebar_tabs_when_inactive_tab_has_multiple_tracked_processes_in_same_state_uses_last_focused(
499 #[case] tracked_process_state: TrackedProcessState,
500 #[case] first_cwd: &str,
501 #[case] first_cmd_label: &str,
502 #[case] second_cwd: &str,
503 #[case] second_cmd_label: &str,
504 #[case] expected_path_label: &str,
505 #[case] expected_cmd_label: &str,
506 ) -> rootcause::Result<()> {
507 let mut older_tracked_process_pane =
508 self::pane_snapshot(4, first_cwd, Some(first_cmd_label), tracked_process_state)?;
509 older_tracked_process_pane.focus_seq = 10;
510 let mut recent_tracked_process_pane =
511 self::pane_snapshot(3, second_cwd, Some(second_cmd_label), tracked_process_state)?;
512 recent_tracked_process_pane.focus_seq = 20;
513
514 let layout = self::layout_snapshot(
515 1,
516 vec![
517 self::tab_snapshot(
518 1,
519 "active",
520 1,
521 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
522 )?,
523 self::tab_snapshot(
524 2,
525 "inactive",
526 2,
527 vec![
528 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
529 older_tracked_process_pane,
530 recent_tracked_process_pane,
531 ],
532 )?,
533 ],
534 )?;
535
536 let tabs = sidebar_tabs_with_home(&layout, None);
537
538 assert_that!(
539 tabs[1],
540 eq(SidebarTab {
541 state: TabBarItemState::Inactive,
542 tracked_process_state,
543 cmd_label: Some(expected_cmd_label.to_owned()),
544 path_label: expected_path_label.to_owned(),
545 })
546 );
547 Ok(())
548 }
549
550 #[test]
551 fn test_sidebar_tabs_when_inactive_tab_has_seen_tracked_process_uses_tracked_process_pane_without_dot_state()
552 -> rootcause::Result<()> {
553 let layout = self::layout_snapshot(
554 1,
555 vec![
556 self::tab_snapshot(
557 1,
558 "active",
559 1,
560 vec![self::pane_snapshot(1, "/tmp/active", None, TrackedProcessState::None)?],
561 )?,
562 self::tab_snapshot(
563 2,
564 "inactive",
565 2,
566 vec![
567 self::pane_snapshot(2, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
568 self::pane_snapshot(3, "/tmp/codex", Some("codex"), TrackedProcessState::Seen)?,
569 ],
570 )?,
571 ],
572 )?;
573
574 let tabs = sidebar_tabs_with_home(&layout, None);
575
576 assert_that!(
577 tabs[1],
578 eq(SidebarTab {
579 state: TabBarItemState::Inactive,
580 tracked_process_state: TrackedProcessState::Seen,
581 cmd_label: Some("codex".to_owned()),
582 path_label: "/t/codex".to_owned(),
583 })
584 );
585 Ok(())
586 }
587
588 #[test]
589 fn test_sidebar_tabs_when_active_tab_has_unfocused_unseen_tracked_process_uses_tracked_process_pane()
590 -> rootcause::Result<()> {
591 let layout = self::layout_snapshot(
592 1,
593 vec![self::tab_snapshot(
594 1,
595 "default",
596 1,
597 vec![
598 self::pane_snapshot(1, "/tmp/shell", Some("zsh"), TrackedProcessState::None)?,
599 self::pane_snapshot(2, "/tmp/codex", Some("codex"), TrackedProcessState::Unseen)?,
600 ],
601 )?],
602 )?;
603
604 assert_that!(
605 sidebar_tabs_with_home(&layout, None),
606 eq(vec![SidebarTab {
607 state: TabBarItemState::Active,
608 tracked_process_state: TrackedProcessState::Unseen,
609 cmd_label: Some("codex".to_owned()),
610 path_label: "/t/codex".to_owned(),
611 }])
612 );
613 Ok(())
614 }
615
616 #[rstest]
617 #[case::home_project("/Users/me/project", Some("/Users/me"), "~/project")]
618 #[case::home_nested("/Users/me/src/pkg/project", Some("/Users/me"), "~/s/p/project")]
619 #[case::root_nested("/usr/local/bin", Some("/Users/me"), "/u/l/bin")]
620 #[case::relative("target/debug", Some("/Users/me"), "t/debug")]
621 fn test_short_cwd_with_home_returns_compact_path(
622 #[case] cwd: &str,
623 #[case] home: Option<&str>,
624 #[case] expected: &str,
625 ) {
626 assert_that!(short_cwd_with_home(cwd, home), eq(expected));
627 }
628
629 #[test]
630 fn test_queue_when_layout_is_rendered_writes_sidebar_without_flushing() -> rootcause::Result<()> {
631 let layout = self::layout_snapshot(
632 1,
633 vec![self::tab_snapshot(
634 1,
635 "default",
636 1,
637 vec![self::pane_snapshot(
638 1,
639 "project",
640 Some("codex"),
641 TrackedProcessState::Busy,
642 )?],
643 )?],
644 )?;
645 let mut output = CountingWriter::default();
646
647 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 3)?;
648
649 let rendered = output.rendered_string()?;
650 assert_that!(rendered, contains_substring("project"));
651 assert_that!(rendered, contains_substring("codex"));
652 assert_that!(rendered, contains_substring("\u{2022}"));
653 assert_that!(rendered, contains_substring(SEPARATOR));
654 assert_that!(output.flushes, eq(0));
655 Ok(())
656 }
657
658 #[test]
659 fn test_queue_when_seen_tracked_process_is_rendered_shows_label_without_marker() -> rootcause::Result<()> {
660 let layout = self::layout_snapshot(
661 1,
662 vec![self::tab_snapshot(
663 1,
664 "default",
665 1,
666 vec![self::pane_snapshot(
667 1,
668 "project",
669 Some("cx"),
670 TrackedProcessState::Seen,
671 )?],
672 )?],
673 )?;
674 let mut output = CountingWriter::default();
675
676 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 2)?;
677
678 let visible = self::strip_ansi(&output.rendered_string()?);
679 assert_that!(visible, contains_substring("\u{258e}cx"));
680 assert_that!(visible, not(contains_substring("\u{2022} cx")));
681 Ok(())
682 }
683
684 #[test]
685 fn test_queue_when_tracked_process_marker_is_rendered_keeps_labels_flush_and_spaces_marker() -> rootcause::Result<()>
686 {
687 let layout = self::layout_snapshot(
688 1,
689 vec![self::tab_snapshot(
690 1,
691 "default",
692 1,
693 vec![self::pane_snapshot(
694 1,
695 "project",
696 Some("cx"),
697 TrackedProcessState::Busy,
698 )?],
699 )?],
700 )?;
701 let mut output = CountingWriter::default();
702
703 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 2)?;
704
705 let visible = self::strip_ansi(&output.rendered_string()?);
706 assert_that!(visible, contains_substring("\u{258e}project"));
707 assert_that!(visible, contains_substring("\u{258e}\u{2022} cx"));
708 assert_that!(visible, not(contains_substring("\u{258e} project")));
709 assert_that!(visible, not(contains_substring("\u{258e} cx")));
710 assert_that!(visible, not(contains_substring("\u{258e}cx \u{2022}")));
711 Ok(())
712 }
713
714 #[test]
715 fn test_queue_when_tab_is_rendered_adds_spacer_row_after_cmd_row() -> rootcause::Result<()> {
716 let layout = self::layout_snapshot(
717 1,
718 vec![self::tab_snapshot(
719 1,
720 "default",
721 1,
722 vec![self::pane_snapshot(
723 1,
724 "project",
725 Some("cx"),
726 TrackedProcessState::Busy,
727 )?],
728 )?],
729 )?;
730 let mut output = CountingWriter::default();
731
732 queue(&mut output, MuxrConfig::default().tab_bar, &layout, 3)?;
733
734 let visible = self::strip_ansi(&output.rendered_string()?);
735 let rows = visible.split(SEPARATOR).collect::<Vec<_>>();
736 assert_that!(rows.len(), eq(4));
737 assert_that!(rows[0], starts_with("\u{258e}project"));
738 assert_that!(rows[1], starts_with("\u{258e}\u{2022} cx"));
739 assert_that!(rows[2].trim(), eq("\u{258e}"));
740 Ok(())
741 }
742
743 #[rstest]
744 #[case::first_path_row(0, Some(1))]
745 #[case::first_cmd_row(1, Some(1))]
746 #[case::first_spacer_row(2, Some(1))]
747 #[case::second_path_row(3, Some(2))]
748 #[case::second_cmd_row(4, Some(2))]
749 #[case::second_spacer_row(5, Some(2))]
750 #[case::below_tabs(6, None)]
751 fn test_tab_id_at_row_when_row_varies_returns_clicked_tab(
752 #[case] row: u16,
753 #[case] expected: Option<u32>,
754 ) -> rootcause::Result<()> {
755 let layout = self::layout_snapshot(
756 1,
757 vec![
758 self::tab_snapshot(
759 1,
760 "default",
761 1,
762 vec![self::pane_snapshot(1, "default", None, TrackedProcessState::None)?],
763 )?,
764 self::tab_snapshot(
765 2,
766 "tab 2",
767 2,
768 vec![self::pane_snapshot(2, "tab-2", None, TrackedProcessState::None)?],
769 )?,
770 ],
771 )?;
772
773 assert_that!(tab_id_at_row(&layout, row).map(TabId::get), eq(expected));
774 Ok(())
775 }
776
777 fn layout_snapshot(active_tab: u32, tabs: Vec<TabSnapshot>) -> rootcause::Result<LayoutSnapshot> {
778 LayoutSnapshot::new(TabId::new(active_tab)?, tabs)
779 }
780
781 fn tab_snapshot(
782 id: u32,
783 title: &str,
784 active_pane: u32,
785 panes: Vec<PaneSnapshot>,
786 ) -> rootcause::Result<TabSnapshot> {
787 TabSnapshot::new(TabId::new(id)?, title, PaneId::new(active_pane)?, panes)
788 }
789
790 fn pane_snapshot(
791 id: u32,
792 cwd: &str,
793 cmd_label: Option<&str>,
794 tracked_process_state: TrackedProcessState,
795 ) -> rootcause::Result<PaneSnapshot> {
796 Ok(PaneSnapshot {
797 tracked_process_state,
798 cwd: cwd.to_owned(),
799 cmd_label: cmd_label.map(str::to_owned),
800 focus_seq: u64::from(id),
801 id: PaneId::new(id)?,
802 title: "shell".to_owned(),
803 })
804 }
805
806 #[derive(Default)]
807 struct CountingWriter {
808 bytes: Vec<u8>,
809 flushes: usize,
810 }
811
812 impl CountingWriter {
813 fn rendered_string(&self) -> rootcause::Result<String> {
814 Ok(String::from_utf8(self.bytes.clone()).context("muxr tab bar test output was not utf8")?)
815 }
816 }
817
818 impl std::io::Write for CountingWriter {
819 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
820 self.bytes.extend_from_slice(buf);
821 Ok(buf.len())
822 }
823
824 fn flush(&mut self) -> std::io::Result<()> {
825 self.flushes = self.flushes.saturating_add(1);
826 Ok(())
827 }
828 }
829
830 fn strip_ansi(s: &str) -> String {
831 let mut out = String::new();
832 let mut chars = s.chars();
833 while let Some(ch) = chars.next() {
834 if ch != '\x1b' {
835 out.push(ch);
836 continue;
837 }
838 for escaped in chars.by_ref() {
839 if escaped.is_ascii_alphabetic() {
840 break;
841 }
842 }
843 }
844 out
845 }
846}