Skip to main content

muxr_client/
tab_bar.rs

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
41/// Queue the left tab sidebar.
42///
43/// # Errors
44/// - The sidebar cmds cannot be written.
45pub 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        // Keep each tab entry a stable three-row block with a spacer row.
87        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    // Keep normal labels flush after the rail; marker rows prefix the dot and one space.
140    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    // Inactive tabs need one representative pane: attention/running state wins, while
265    // idle tracked processes still keep their label and no dot. Ties use focus recency.
266    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}