use super::mux::MuxAttachPending;
use super::mux::tests::{manners_state, socket_path, spawn_daemon};
use super::mux_test_seams::{
DAEMON_DEADLINE, SettledSend, attach_until_installed, quiesce, wait_until,
};
use crate::app::window_state::WindowState;
use crate::pane::NavigationDirection;
use par_term_emu_core_rust::mux::MuxClient;
use par_term_emu_core_rust::tmux_control::TmuxNotification;
use std::time::Instant;
fn send(ws: &WindowState, cmd: &str) -> Vec<String> {
ws.tmux_state
.transport
.as_ref()
.expect("transport")
.send_settled(cmd)
.unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn drain_until(ws: &mut WindowState, what: &str, done: impl Fn(&WindowState) -> bool) {
wait_until(what, || {
done(ws) || {
ws.check_mux_notifications();
done(ws)
}
});
quiesce(ws);
}
fn attached_split(tag: &str) -> (WindowState, MuxClient, std::path::PathBuf) {
let path = socket_path(tag);
spawn_daemon(&path);
let mut ws = manners_state();
attach_until_installed(
&mut ws,
|| {
let core_client = MuxClient::connect(&path).expect("connect");
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(core_client)).unwrap();
drop(tx);
MuxAttachPending {
name: tag.to_string(),
rx,
}
},
&path,
);
drain_until(&mut ws, "%0 mapped", |ws| {
ws.tmux_state.tmux_pane_owners.contains_key(&0)
});
assert_eq!(ws.tab_manager.tab_count(), 1, "one tab for @0");
assert!(ws.split_pane_via_mux(true), "daemon-side split");
assert!(
!ws.tmux_state
.mux_last_error
.as_deref()
.is_some_and(|e| e.contains("split failed")),
"the split reached the daemon: {:?}",
ws.tmux_state.mux_last_error
);
drain_until(&mut ws, "split mapped", |ws| {
ws.tmux_state.tmux_pane_owners.contains_key(&1)
&& ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.is_some_and(|pm| pm.pane_count() == 2)
});
if let Some(pm) = ws
.tab_manager
.active_tab_mut()
.and_then(|t| t.pane_manager_mut())
{
pm.set_bounds(crate::pane::PaneBounds::new(0.0, 0.0, 800.0, 480.0));
}
let mut second = MuxClient::connect(&path).expect("second client");
second
.send("list-windows")
.expect("second client registers");
while second.notifications().try_recv().is_ok() {}
(ws, second, path)
}
fn second_sees(second: &MuxClient, what: &str, want: impl Fn(&TmuxNotification) -> bool) {
let deadline = Instant::now() + DAEMON_DEADLINE;
loop {
let left = deadline.saturating_duration_since(Instant::now());
assert!(!left.is_zero(), "the second client never saw {what}");
match second.notifications().recv_timeout(left) {
Ok(n) if want(&n) => return,
Ok(_) => {}
Err(e) => panic!("the second client never saw {what}: {e}"),
}
}
}
fn focused_daemon_pane(ws: &WindowState) -> Option<u64> {
ws.focused_mux_pane_from_native()
}
fn zoomed_native(ws: &WindowState) -> Option<crate::pane::PaneId> {
ws.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.zoomed_pane_id())
}
#[test]
fn attached_zoom_toggles_shows_its_badge_and_a_focus_move_or_split_unzooms() {
let (mut ws, second, path) = attached_split("p3-zoom");
let focused = focused_daemon_pane(&ws).expect("a mux pane is focused");
ws.toggle_pane_zoom();
drain_until(&mut ws, "zoom mirrored", |ws| zoomed_native(ws).is_some());
second_sees(&second, "the zoomed layout", |n| {
matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
if window_raw_flags.contains('Z'))
});
let tab = ws.tab_manager.active_tab().expect("tab");
assert_eq!(
tab.pane_mode_badge(),
Some(crate::tab::pane_badges::ZOOM_BADGE),
"the tab shows the zoom badge"
);
let pm = tab.pane_manager().expect("pm");
assert_eq!(pm.visible_panes().len(), 1, "only the zoomed pane is drawn");
assert_eq!(
ws.tmux_state
.tmux_pane_owner(focused)
.map(|(_, native)| native),
zoomed_native(&ws),
"the focused pane is the zoomed one"
);
let info = send(&ws, &format!("pane-info -t %{focused}")).join(" ");
let zoomed_mirror = pm
.get_pane(zoomed_native(&ws).unwrap())
.and_then(|p| p.terminal.try_read().ok())
.map(|t| t.dimensions())
.expect("mirror dims");
assert!(
info.contains(&format!("{}x{}", zoomed_mirror.0, zoomed_mirror.1)),
"the zoomed mirror has the daemon PTY's full-window size {zoomed_mirror:?}: {info}"
);
let zoomed_cols = daemon_size(&ws, focused).0;
ws.navigate_pane(NavigationDirection::Left);
ws.navigate_pane(NavigationDirection::Right);
second_sees(&second, "the unzoomed layout", |n| {
matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
if !window_raw_flags.contains('Z'))
});
assert!(
daemon_size(&ws, focused).0 < zoomed_cols,
"the daemon unzoomed: the pane is back to its tree width"
);
drain_until(&mut ws, "unzoom on focus move", |ws| {
zoomed_native(ws).is_none()
});
assert_eq!(ws.tab_manager.active_tab().unwrap().pane_mode_badge(), None);
ws.toggle_pane_zoom();
drain_until(&mut ws, "zoom again", |ws| zoomed_native(ws).is_some());
assert!(ws.split_pane_via_mux(false), "split while zoomed");
drain_until(&mut ws, "unzoom on split", |ws| {
zoomed_native(ws).is_none()
&& ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.is_some_and(|pm| pm.pane_count() == 3)
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn keyboard_focus_moves_are_visible_to_a_second_client() {
let (mut ws, second, path) = attached_split("p3-focus");
let start = focused_daemon_pane(&ws).expect("focused");
ws.focus_pane_cycle(true);
let after_next = focused_daemon_pane(&ws).expect("focused");
assert_ne!(after_next, start, "next pane moved focus");
second_sees(&second, "select-pane after next_pane", |n| {
matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
if *pane_id == format!("%{after_next}"))
});
ws.focus_last_pane();
assert_eq!(
focused_daemon_pane(&ws),
Some(start),
"last pane toggles back"
);
second_sees(&second, "select-pane after last_pane", |n| {
matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
if *pane_id == format!("%{start}"))
});
let dir = if ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Left))
.is_some()
{
NavigationDirection::Left
} else {
NavigationDirection::Right
};
ws.navigate_pane(dir);
let after_nav = focused_daemon_pane(&ws).expect("focused");
assert_ne!(after_nav, start);
second_sees(&second, "select-pane after directional focus", |n| {
matches!(n, TmuxNotification::WindowPaneChanged { pane_id, .. }
if *pane_id == format!("%{after_nav}"))
});
let _ = std::fs::remove_file(&path);
}
fn daemon_size(ws: &WindowState, pane: u64) -> (u16, u16) {
let info = send(ws, &format!("pane-info -t %{pane}")).join(" ");
let size = info.split_whitespace().nth(2).expect("size field");
let (c, r) = size.split_once('x').expect("COLSxROWS");
(c.parse().expect("cols"), r.parse().expect("rows"))
}
#[test]
fn keyboard_resize_is_visible_to_a_second_client() {
let (mut ws, second, path) = attached_split("p3-resize");
let focused = focused_daemon_pane(&ws).expect("focused");
let left_is_focused = ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Right))
.is_some();
send(&ws, "refresh-client -t %0 -C 80x24");
drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
ws.tmux_state
.tmux_pane_owner(focused)
.is_some_and(|(tab, native)| {
ws.tab_manager
.get_tab(tab)
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.get_pane(native))
.and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
== Some(daemon_size(ws, focused).0 as usize)
})
});
while second.notifications().try_recv().is_ok() {}
let (cols_before, rows_before) = daemon_size(&ws, focused);
ws.resize_pane(NavigationDirection::Right);
second_sees(&second, "the resize layout", |n| {
matches!(n, TmuxNotification::LayoutChange { .. })
});
let (cols_after, rows_after) = daemon_size(&ws, focused);
assert_eq!(rows_after, rows_before, "a Right resize keeps the height");
if left_is_focused {
assert!(cols_after > cols_before, "{cols_before} -> {cols_after}");
} else {
assert!(cols_after < cols_before, "{cols_before} -> {cols_after}");
}
drain_until(&mut ws, "mirror resized", |ws| {
ws.tmux_state
.tmux_pane_owner(focused)
.and_then(|(tab, native)| {
ws.tab_manager
.get_tab(tab)?
.pane_manager()?
.get_pane(native)?
.terminal
.try_read()
.ok()
.map(|t| t.dimensions().0 as u16 == cols_after)
})
.unwrap_or(false)
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn keyboard_resize_while_zoomed_keeps_the_zoom() {
let (mut ws, second, path) = attached_split("p3-zoom-resize");
let focused = focused_daemon_pane(&ws).expect("focused");
let left_is_focused = ws
.tab_manager
.active_tab()
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.neighbor_in_direction(pm.focused_pane_id()?, NavigationDirection::Right))
.is_some();
send(&ws, "refresh-client -t %0 -C 80x24");
drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
ws.tmux_state
.tmux_pane_owner(focused)
.is_some_and(|(tab, native)| {
ws.tab_manager
.get_tab(tab)
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.get_pane(native))
.and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
== Some(daemon_size(ws, focused).0 as usize)
})
});
let (cols_unzoomed, rows_unzoomed) = daemon_size(&ws, focused);
ws.toggle_pane_zoom();
drain_until(&mut ws, "zoom mirrored", |ws| zoomed_native(ws).is_some());
second_sees(&second, "the zoomed layout", |n| {
matches!(n, TmuxNotification::LayoutChange { window_raw_flags, .. }
if window_raw_flags.contains('Z'))
});
while second.notifications().try_recv().is_ok() {}
ws.resize_pane(NavigationDirection::Right);
let deadline = Instant::now() + DAEMON_DEADLINE;
let mut saw_unzoomed = false;
loop {
let left = deadline.saturating_duration_since(Instant::now());
assert!(
!left.is_zero(),
"the daemon never echoed the resize and re-zoomed"
);
match second.notifications().recv_timeout(left) {
Ok(TmuxNotification::LayoutChange {
window_raw_flags, ..
}) => {
if window_raw_flags.contains('Z') {
assert!(saw_unzoomed, "the re-zoom arrived before the resize echo");
break;
}
saw_unzoomed = true;
}
Ok(_) => {}
Err(e) => panic!("the daemon never re-zoomed after the resize: {e}"),
}
}
drain_until(&mut ws, "the mirror keeps the zoom", |ws| {
zoomed_native(ws).is_some()
});
assert_eq!(
ws.tab_manager.active_tab().unwrap().pane_mode_badge(),
Some(crate::tab::pane_badges::ZOOM_BADGE),
"the zoom survives the resize"
);
let nav = if left_is_focused {
NavigationDirection::Right
} else {
NavigationDirection::Left
};
ws.navigate_pane(nav);
drain_until(&mut ws, "unzoomed after focus move", |ws| {
zoomed_native(ws).is_none()
});
let (cols_after, rows_after) = daemon_size(&ws, focused);
assert_eq!(
rows_after, rows_unzoomed,
"a horizontal resize keeps the height"
);
if left_is_focused {
assert!(
cols_after > cols_unzoomed,
"the zoomed resize grew the pane: {cols_unzoomed} -> {cols_after}"
);
} else {
assert!(
cols_after < cols_unzoomed,
"the zoomed resize shrank the pane: {cols_unzoomed} -> {cols_after}"
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn keyboard_tab_switch_is_visible_to_a_second_client() {
let (mut ws, second, path) = attached_split("p3-tabs");
send(&ws, "new-window");
drain_until(&mut ws, "second window mapped", |ws| {
ws.tab_manager
.tabs()
.iter()
.filter(|t| ws.mux_window_for_tab(t.id).is_some())
.count()
== 2
});
while second.notifications().try_recv().is_ok() {}
for step in 0..2 {
ws.next_tab();
let window = ws
.tab_manager
.active_tab_id()
.and_then(|id| ws.mux_window_for_tab(id))
.expect("the active tab mirrors a daemon window");
second_sees(&second, &format!("select-window step {step}"), |n| {
matches!(n, TmuxNotification::WindowPaneChanged { window_id, .. }
if *window_id == format!("@{window}"))
});
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn attached_split_left_places_the_new_pane_before_the_focused_one() {
let (mut ws, _second, path) = attached_split("p3-split-left");
let focused = focused_daemon_pane(&ws).expect("focused");
let before: std::collections::HashSet<u64> =
ws.tmux_state.tmux_pane_owners.keys().copied().collect();
ws.split_pane_before(crate::pane::SplitDirection::Vertical);
drain_until(&mut ws, "split-left mapped", |ws| {
ws.tmux_state.tmux_pane_owners.len() == before.len() + 1
});
let new = *ws
.tmux_state
.tmux_pane_owners
.keys()
.find(|p| !before.contains(p))
.expect("new daemon pane");
if let Some(pm) = ws
.tab_manager
.active_tab_mut()
.and_then(|t| t.pane_manager_mut())
{
pm.set_bounds(crate::pane::PaneBounds::new(0.0, 0.0, 800.0, 480.0));
}
let x_of = |ws: &WindowState, daemon: u64| {
let (tab, native) = ws.tmux_state.tmux_pane_owner(daemon).expect("mapped");
ws.tab_manager
.get_tab(tab)
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.get_pane(native))
.map(|p| p.bounds.x)
.expect("mirror pane")
};
assert!(
x_of(&ws, new) < x_of(&ws, focused),
"the new pane sits left of the pane that was focused"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn attached_equalize_evens_the_daemon_panes() {
let (mut ws, second, path) = attached_split("p3b-equalize");
send(&ws, "refresh-client -t %0 -C 80x24");
drain_until(&mut ws, "mirror adopts the daemon grid", |ws| {
ws.tmux_state
.tmux_pane_owner(0)
.is_some_and(|(tab, native)| {
ws.tab_manager
.get_tab(tab)
.and_then(|t| t.pane_manager())
.and_then(|pm| pm.get_pane(native))
.and_then(|p| p.terminal.try_read().ok().map(|t| t.dimensions().0))
== Some(daemon_size(ws, 0).0 as usize)
})
});
send(&ws, "resize-pane -t %0 -x 60");
drain_until(&mut ws, "skewed layout mirrored", |ws| {
daemon_size(ws, 0).0 >= 55
});
while second.notifications().try_recv().is_ok() {}
ws.equalize_panes();
second_sees(&second, "the equalize layout", |n| {
matches!(n, TmuxNotification::LayoutChange { .. })
});
wait_until("the daemon panes even out", || {
ws.check_mux_notifications();
daemon_size(&ws, 0).0.abs_diff(daemon_size(&ws, 1).0) <= 1
});
let _ = std::fs::remove_file(&path);
}
#[test]
fn attached_layout_presets_are_refused_and_leave_the_daemon_layout() {
let (mut ws, _second, path) = attached_split("p3b-preset");
let before = (daemon_size(&ws, 0), daemon_size(&ws, 1));
for preset in crate::pane::LayoutPreset::ALL {
ws.overlay_state.toasts.clear();
ws.apply_layout_preset(preset);
let toast = ws.last_toast_text().map(str::to_string).unwrap_or_default();
assert!(
toast.contains("not available in par-mux tabs"),
"{}: {toast:?}",
preset.name()
);
}
quiesce(&mut ws);
assert_eq!((daemon_size(&ws, 0), daemon_size(&ws, 1)), before);
let _ = std::fs::remove_file(&path);
}