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;
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
42/// Queue the left tab sidebar.
43///
44/// # Errors
45/// - The sidebar cmds cannot be written.
46pub 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        // Keep each tab entry a stable three-row block with a spacer row.
88        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    // Keep normal labels flush after the rail; marker rows prefix the dot and one space.
141    let marker_width = if self::tracked_process_state_dot_color(config, tracked_process_state).is_some() {
142        2
143    } else {
144        0
145    };
146    // Alacritty advances its grid per codepoint using `unicode-width`, so a Rust character
147    // count is not a terminal width. Use the same cell contract for truncation and padding.
148    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    // 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 = 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    // Do not leave a zero-width suffix dangling when truncation dropped its following base.
381    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}