use super::adapters::{AttachClock, FixedAttachClock};
use super::input::{TerminalGeometry, TerminalMouseEvent};
use super::prompt_ui::PromptKeyDisposition;
use super::runtime::{
attach_key_event_actions, attach_tab_drop_marker_col, build_attach_help_lines,
handle_attach_ui_action_at, handle_help_overlay_key_event,
reduce_attach_mouse_floating_drag_event, reduce_attach_mouse_resize_event,
reduce_attach_status_tab_mouse_event, status_row_for_position,
};
use super::state::{AttachTabDropPlacement, AttachUiEffect, AttachViewState, PaneRenderBuffer};
use crate::input::{InputProcessor, RuntimeAction};
use crate::status::{AttachStatusLine, AttachTab, build_attach_status_line};
use anyhow::{Result, bail};
use bmux_appearance::RuntimeAppearance;
use bmux_attach_layout_protocol::{
AttachFocusTarget, AttachLayer, AttachRect, AttachScene, AttachSurface, AttachSurfaceKind,
PaneLayoutNode, PaneState, PaneSummary,
};
use bmux_client::{AttachLayoutState, AttachOpenInfo};
use bmux_config::{BmuxConfig, StatusBarConfig, StatusPosition, StatusTabOrder};
use bmux_keyboard::{KeyCode as BmuxKeyCode, KeyStroke};
use crossterm::event::{
Event as CrosstermEvent, KeyCode as CrosstermKeyCode, KeyEvent, KeyEventKind, KeyEventState,
KeyModifiers,
};
use std::time::Instant;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachSimWindow {
pub id: Uuid,
pub name: String,
pub active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachSimLocatedText {
pub start_col: u16,
pub end_col: u16,
pub center_col: u16,
pub row: u16,
}
pub struct AttachSimHarness {
geometry: TerminalGeometry,
status_config: StatusBarConfig,
appearance: RuntimeAppearance,
windows: Vec<AttachSimWindow>,
view_state: AttachViewState,
input_processor: InputProcessor,
effects: Vec<AttachUiEffect>,
forwarded_bytes: Vec<Vec<u8>>,
clock: FixedAttachClock,
}
impl AttachSimHarness {
pub fn new(cols: u16, rows: u16) -> Self {
let session_id = Uuid::from_u128(1);
let status_config = StatusBarConfig {
tab_order: StatusTabOrder::Stable,
..StatusBarConfig::default()
};
let mut view_state = AttachViewState::new(AttachOpenInfo {
context_id: None,
session_id,
can_write: true,
});
view_state.mouse.tab_drag_enabled = true;
Self {
geometry: TerminalGeometry { cols, rows },
status_config,
appearance: RuntimeAppearance::default(),
windows: Vec::new(),
view_state,
input_processor: InputProcessor::new(crate::input::Keymap::default_runtime(), false),
effects: Vec::new(),
forwarded_bytes: Vec::new(),
clock: FixedAttachClock::new(Instant::now()),
}
}
pub fn seed_window_list(&mut self, names: &[&str], active: &str) {
self.windows = names
.iter()
.enumerate()
.map(|(index, name)| AttachSimWindow {
id: Uuid::from_u128(u128::try_from(index).unwrap_or(0) + 1),
name: (*name).to_string(),
active: *name == active,
})
.collect();
self.view_state.attached_context_id = self
.windows
.iter()
.find(|window| window.active)
.map(|window| window.id);
self.render();
}
pub fn set_tab_order(&mut self, order: StatusTabOrder) {
self.status_config.tab_order = order;
self.view_state.mouse.tab_drag_enabled = !matches!(order, StatusTabOrder::Mru);
self.render();
}
pub fn set_status_position(&mut self, position: StatusPosition) {
self.view_state.status_position = position;
self.render();
}
#[cfg(test)]
pub fn set_mru_tab_order(&mut self) {
self.set_tab_order(StatusTabOrder::Mru);
}
pub fn render(&mut self) -> &AttachStatusLine {
let tabs = self
.windows
.iter()
.map(|window| AttachTab {
label: window.name.clone(),
active: window.active,
context_id: Some(window.id),
})
.collect::<Vec<_>>();
let mode_label = if self.view_state.help_overlay_open {
"HELP"
} else if self.view_state.prompt.is_active() {
"PROMPT"
} else {
"NORMAL"
};
let hint = if self.view_state.help_overlay_open {
"Help overlay open | ? toggles | Esc/Enter close"
} else {
self.view_state.prompt.active_hint().unwrap_or("")
};
let mut status_line = build_attach_status_line(
self.geometry.cols,
&self.status_config,
&self.appearance,
"sim",
1,
"sim",
&tabs,
None,
mode_label,
"write",
None,
hint,
);
status_line.drag_marker_col = self
.view_state
.mouse
.tab_drag
.and_then(|drag| drag.drop_target)
.and_then(|target| {
attach_tab_drop_marker_col(&status_line, target, self.geometry.cols)
});
self.view_state.cached_status_line = Some(status_line);
self.view_state
.cached_status_line
.as_ref()
.expect("simulation render should cache status line")
}
#[cfg(test)]
pub const fn set_clock(&mut self, now: Instant) {
self.clock.set_now(now);
}
#[cfg(test)]
pub fn advance_clock(&mut self, duration: std::time::Duration) {
self.clock.advance(duration);
}
pub fn send_mouse(&mut self, event: TerminalMouseEvent) {
let mut reduction =
reduce_attach_status_tab_mouse_event(&mut self.view_state, event, self.geometry);
if !reduction.consumed {
reduction =
reduce_attach_mouse_resize_event(&mut self.view_state, event, self.clock.now());
}
if !reduction.consumed {
reduction = reduce_attach_mouse_floating_drag_event(&mut self.view_state, event);
}
if !reduction.consumed {
return;
}
for effect in reduction.effects {
self.apply_effect(effect);
}
self.render();
}
pub fn seed_vertical_split_panes(&mut self) {
let left_pane = Uuid::from_u128(21);
let right_pane = Uuid::from_u128(22);
let height = self.geometry.rows.saturating_sub(1).max(4);
self.view_state.cached_layout_state = Some(AttachLayoutState {
context_id: self.view_state.attached_context_id,
session_id: self.view_state.attached_id,
focused_pane_id: left_pane,
panes: vec![
PaneSummary {
id: left_pane,
index: 1,
name: Some("left".to_string()),
focused: true,
state: PaneState::Running,
state_reason: None,
},
PaneSummary {
id: right_pane,
index: 2,
name: Some("right".to_string()),
focused: false,
state: PaneState::Running,
state_reason: None,
},
],
layout_root: PaneLayoutNode::Split {
direction: bmux_attach_layout_protocol::PaneSplitDirection::Vertical,
ratio_percent: 50,
first: Box::new(PaneLayoutNode::Leaf { pane_id: left_pane }),
second: Box::new(PaneLayoutNode::Leaf {
pane_id: right_pane,
}),
},
scene: AttachScene {
session_id: self.view_state.attached_id,
focus: AttachFocusTarget::Pane { pane_id: left_pane },
surfaces: vec![
AttachSurface {
id: Uuid::from_u128(23),
kind: AttachSurfaceKind::Pane,
layer: AttachLayer::Pane,
z: 0,
pane_id: Some(left_pane),
rect: AttachRect {
x: 0,
y: 0,
w: 10,
h: height,
},
content_rect: AttachRect {
x: 1,
y: 1,
w: 8,
h: height.saturating_sub(2).max(1),
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
},
AttachSurface {
id: Uuid::from_u128(24),
kind: AttachSurfaceKind::Pane,
layer: AttachLayer::Pane,
z: 0,
pane_id: Some(right_pane),
rect: AttachRect {
x: 10,
y: 0,
w: 10,
h: height,
},
content_rect: AttachRect {
x: 11,
y: 1,
w: 8,
h: height.saturating_sub(2).max(1),
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
},
],
},
zoomed: false,
});
}
pub fn seed_floating_pane_layout(&mut self) {
let tiled_pane = Uuid::from_u128(31);
let floating_pane = Uuid::from_u128(32);
let height = self.geometry.rows.saturating_sub(1).max(8);
self.view_state.cached_layout_state = Some(AttachLayoutState {
context_id: self.view_state.attached_context_id,
session_id: self.view_state.attached_id,
focused_pane_id: tiled_pane,
panes: vec![
PaneSummary {
id: tiled_pane,
index: 1,
name: Some("tiled".to_string()),
focused: true,
state: PaneState::Running,
state_reason: None,
},
PaneSummary {
id: floating_pane,
index: 2,
name: Some("float".to_string()),
focused: false,
state: PaneState::Running,
state_reason: None,
},
],
layout_root: PaneLayoutNode::Leaf {
pane_id: tiled_pane,
},
scene: AttachScene {
session_id: self.view_state.attached_id,
focus: AttachFocusTarget::Pane {
pane_id: tiled_pane,
},
surfaces: vec![
AttachSurface {
id: Uuid::from_u128(33),
kind: AttachSurfaceKind::Pane,
layer: AttachLayer::Pane,
z: 0,
pane_id: Some(tiled_pane),
rect: AttachRect {
x: 0,
y: 0,
w: 40,
h: height,
},
content_rect: AttachRect {
x: 1,
y: 1,
w: 38,
h: height.saturating_sub(2).max(1),
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
},
AttachSurface {
id: Uuid::from_u128(34),
kind: AttachSurfaceKind::FloatingPane,
layer: AttachLayer::FloatingPane,
z: 10,
pane_id: Some(floating_pane),
rect: AttachRect {
x: 2,
y: 2,
w: 10,
h: 6,
},
content_rect: AttachRect {
x: 3,
y: 3,
w: 8,
h: 4,
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: false,
},
],
},
zoomed: false,
});
}
pub fn seed_pane_lines(&mut self, lines: &[&str], cursor_row: u16, cursor_col: u16) {
let pane_id = Uuid::from_u128(10);
let content_width = lines
.iter()
.map(|line| u16::try_from(line.chars().count()).unwrap_or(u16::MAX))
.max()
.unwrap_or(1)
.max(1);
let content_height = u16::try_from(lines.len()).unwrap_or(u16::MAX).max(1);
let outer_width = content_width
.saturating_add(2)
.min(self.geometry.cols.max(2));
let outer_height = content_height
.saturating_add(2)
.min(self.geometry.rows.max(2));
self.view_state.cached_layout_state = Some(AttachLayoutState {
context_id: self.view_state.attached_context_id,
session_id: self.view_state.attached_id,
focused_pane_id: pane_id,
panes: vec![PaneSummary {
id: pane_id,
index: 1,
name: None,
focused: true,
state: PaneState::Running,
state_reason: None,
}],
layout_root: PaneLayoutNode::Leaf { pane_id },
scene: AttachScene {
session_id: self.view_state.attached_id,
focus: AttachFocusTarget::Pane { pane_id },
surfaces: vec![AttachSurface {
id: Uuid::from_u128(11),
kind: AttachSurfaceKind::Pane,
layer: AttachLayer::Pane,
z: 0,
pane_id: Some(pane_id),
rect: AttachRect {
x: 0,
y: 0,
w: outer_width,
h: outer_height,
},
content_rect: AttachRect {
x: 1,
y: 1,
w: outer_width.saturating_sub(2).max(1),
h: outer_height.saturating_sub(2).max(1),
},
interactive_regions: Vec::new(),
opaque: true,
visible: true,
accepts_input: true,
cursor_owner: true,
}],
},
zoomed: false,
});
let buffer = self
.view_state
.pane_buffers
.entry(pane_id)
.or_insert_with(|| PaneRenderBuffer {
terminal_grid: bmux_terminal_grid::TerminalGridStream::new(
content_width,
content_height,
bmux_terminal_grid::GridLimits::default(),
)
.expect("attach-sim pane grid dimensions are valid"),
..PaneRenderBuffer::default()
});
buffer.terminal_grid = bmux_terminal_grid::TerminalGridStream::new(
content_width,
content_height,
bmux_terminal_grid::GridLimits::default(),
)
.expect("attach-sim pane grid dimensions are valid");
buffer.visual_row_fingerprints.clear();
let mut bytes = lines.join("\r\n").into_bytes();
bytes.extend_from_slice(format!("\x1b[{cursor_row};{cursor_col}H").as_bytes());
append_sim_pane_output(buffer, &bytes);
self.view_state.exit_scrollback();
}
pub fn send_attach_chord(&mut self, chord: &str) -> Result<Vec<String>> {
self.input_processor
.set_scroll_mode(self.view_state.scrollback_active);
let strokes = crate::input::parse_key_chord(chord)
.map_err(|error| anyhow::anyhow!("invalid attach key chord '{chord}': {error}"))?;
let mut emitted = Vec::new();
for stroke in strokes {
let event = crossterm_event_from_stroke(stroke);
let CrosstermEvent::Key(key) = event else {
continue;
};
if self.view_state.prompt.is_active() {
match self.view_state.prompt.handle_key_event(&key) {
PromptKeyDisposition::Completed(_) => {
emitted.push("prompt:completed".to_string());
}
PromptKeyDisposition::Consumed => emitted.push("prompt:consumed".to_string()),
PromptKeyDisposition::NotActive => {}
}
continue;
}
if self.view_state.help_overlay_open {
let help_lines = build_attach_help_lines(&BmuxConfig::default());
if handle_help_overlay_key_event(
&key,
&help_lines,
&mut self.view_state,
self.geometry,
) {
emitted.push("help:handled".to_string());
continue;
}
}
for action in
attach_key_event_actions(&key, &mut self.input_processor, self.view_state.ui_mode)?
{
self.apply_attach_event_action(action, &mut emitted)?;
}
}
let trailing = self.input_processor.process_stream_bytes(&[]);
for ui_action in trailing {
emitted.push(format!("ui:{ui_action:?}"));
self.apply_ui_action(&ui_action);
}
self.render();
Ok(emitted)
}
fn apply_attach_event_action(
&mut self,
action: super::state::AttachEventAction,
emitted: &mut Vec<String>,
) -> Result<()> {
match action {
super::state::AttachEventAction::Ui(ui_action) => {
emitted.push(format!("ui:{ui_action:?}"));
self.apply_ui_action(&ui_action);
}
super::state::AttachEventAction::Send(bytes) => {
emitted.push("send".to_string());
self.forwarded_bytes.push(bytes);
}
super::state::AttachEventAction::Ignore => {
emitted.push("ignore".to_string());
}
super::state::AttachEventAction::Detach => {
bail!("attach-sim send-attach emitted detach");
}
super::state::AttachEventAction::PluginCommand {
plugin_id,
command_name,
..
} => {
bail!(
"attach-sim send-attach emitted unsupported plugin command {plugin_id}:{command_name}"
);
}
super::state::AttachEventAction::Mouse(_) | super::state::AttachEventAction::Redraw => {
}
}
Ok(())
}
fn apply_ui_action(&mut self, ui_action: &RuntimeAction) {
if matches!(ui_action, RuntimeAction::ShowHelp) {
self.view_state.help_overlay_open = !self.view_state.help_overlay_open;
if !self.view_state.help_overlay_open {
self.view_state.help_overlay_scroll = 0;
}
} else {
handle_attach_ui_action_at(ui_action, &mut self.view_state, self.clock.now());
}
}
pub fn rendered(&self) -> &str {
self.view_state
.cached_status_line
.as_ref()
.map_or("", |status_line| status_line.rendered.as_str())
}
pub fn effects(&self) -> &[AttachUiEffect] {
&self.effects
}
pub fn window_names(&self) -> Vec<String> {
self.windows
.iter()
.map(|window| window.name.clone())
.collect()
}
pub fn active_window_name(&self) -> Option<&str> {
self.windows
.iter()
.find(|window| window.active)
.map(|window| window.name.as_str())
}
pub const fn scrollback_active(&self) -> bool {
self.view_state.scrollback_active
}
pub const fn selection_active(&self) -> bool {
self.view_state.selection_active()
}
pub const fn help_overlay_open(&self) -> bool {
self.view_state.help_overlay_open
}
pub const fn help_overlay_scroll(&self) -> usize {
self.view_state.help_overlay_scroll
}
pub const fn prompt_active(&self) -> bool {
self.view_state.prompt.is_active()
}
pub fn selected_text(&mut self) -> Option<String> {
super::runtime::selected_attach_text(&mut self.view_state)
}
pub fn scrollback_cursor(&self) -> Option<(usize, usize)> {
self.view_state
.scrollback_cursor
.map(|cursor| (cursor.row, cursor.col))
}
pub fn locate_text(&self, text: &str) -> Option<AttachSimLocatedText> {
let status_line = self.view_state.cached_status_line.as_ref()?;
for (index, window) in self.windows.iter().enumerate() {
let indexed_label = format!("{}:{}", index + 1, window.name);
if text == indexed_label {
let hitbox = status_line
.tab_hitboxes
.iter()
.find(|hitbox| hitbox.context_id == window.id)?;
return Some(AttachSimLocatedText {
start_col: hitbox.start_col,
end_col: hitbox.end_col,
center_col: hitbox
.start_col
.saturating_add(hitbox.end_col.saturating_sub(hitbox.start_col) / 2),
row: status_row_for_position(
self.view_state.status_position,
self.geometry.rows,
)?,
});
}
}
let rendered = self.rendered();
let start = rendered.find(text)?;
let end = start.checked_add(text.len())?.checked_sub(1)?;
let start_col = u16::try_from(start).ok()?;
let end_col = u16::try_from(end).ok()?;
Some(AttachSimLocatedText {
start_col,
end_col,
center_col: start_col.saturating_add(end_col.saturating_sub(start_col) / 2),
row: status_row_for_position(self.view_state.status_position, self.geometry.rows)?,
})
}
fn apply_effect(&mut self, effect: AttachUiEffect) {
match effect.clone() {
AttachUiEffect::SwitchWindow { target_context_id } => {
for window in &mut self.windows {
window.active = window.id == target_context_id;
}
self.view_state.attached_context_id = Some(target_context_id);
}
AttachUiEffect::MoveWindow {
source_context_id,
target_context_id,
placement,
} => {
reorder_windows(
&mut self.windows,
source_context_id,
target_context_id,
placement,
);
}
AttachUiEffect::ResizePane { .. } | AttachUiEffect::ShowTransientStatus { .. } => {}
AttachUiEffect::FocusPane { pane_id } => {
if let Some(layout_state) = &mut self.view_state.cached_layout_state {
layout_state.focused_pane_id = pane_id;
layout_state.scene.focus = AttachFocusTarget::Pane { pane_id };
for pane in &mut layout_state.panes {
pane.focused = pane.id == pane_id;
}
}
self.view_state.mouse.last_focused_pane_id = Some(pane_id);
}
AttachUiEffect::MoveFloatingPane { pane_id, x, y } => {
if let Some(layout_state) = &mut self.view_state.cached_layout_state {
for surface in &mut layout_state.scene.surfaces {
if surface.pane_id == Some(pane_id)
&& surface.kind == AttachSurfaceKind::FloatingPane
{
let inner_x_offset =
surface.content_rect.x.saturating_sub(surface.rect.x);
let inner_y_offset =
surface.content_rect.y.saturating_sub(surface.rect.y);
surface.rect.x = x;
surface.rect.y = y;
surface.content_rect.x = x.saturating_add(inner_x_offset);
surface.content_rect.y = y.saturating_add(inner_y_offset);
}
}
}
}
}
self.effects.push(effect);
}
}
fn append_sim_pane_output(buffer: &mut PaneRenderBuffer, bytes: &[u8]) {
let was_alternate = buffer.protocol_tracker.alternate_screen();
let previous_content_revision = buffer.terminal_grid.grid().content_revision();
let _ = buffer.protocol_tracker.process(bytes);
buffer.terminal_grid.process(bytes);
if buffer.terminal_grid.grid().content_revision() != previous_content_revision {
buffer.visual_row_fingerprints.clear();
}
if was_alternate != buffer.protocol_tracker.alternate_screen() {
buffer.prev_rows.clear();
}
}
fn crossterm_event_from_stroke(stroke: KeyStroke) -> CrosstermEvent {
let code = match stroke.key {
BmuxKeyCode::Char(value) => CrosstermKeyCode::Char(value),
BmuxKeyCode::Enter => CrosstermKeyCode::Enter,
BmuxKeyCode::Tab => CrosstermKeyCode::Tab,
BmuxKeyCode::Backspace => CrosstermKeyCode::Backspace,
BmuxKeyCode::Delete => CrosstermKeyCode::Delete,
BmuxKeyCode::Escape => CrosstermKeyCode::Esc,
BmuxKeyCode::Space => CrosstermKeyCode::Char(' '),
BmuxKeyCode::Up => CrosstermKeyCode::Up,
BmuxKeyCode::Down => CrosstermKeyCode::Down,
BmuxKeyCode::Left => CrosstermKeyCode::Left,
BmuxKeyCode::Right => CrosstermKeyCode::Right,
BmuxKeyCode::Home => CrosstermKeyCode::Home,
BmuxKeyCode::End => CrosstermKeyCode::End,
BmuxKeyCode::PageUp => CrosstermKeyCode::PageUp,
BmuxKeyCode::PageDown => CrosstermKeyCode::PageDown,
BmuxKeyCode::Insert => CrosstermKeyCode::Insert,
BmuxKeyCode::F(value) => CrosstermKeyCode::F(value),
};
let mut modifiers = KeyModifiers::NONE;
if stroke.modifiers.ctrl {
modifiers |= KeyModifiers::CONTROL;
}
if stroke.modifiers.alt {
modifiers |= KeyModifiers::ALT;
}
if stroke.modifiers.shift {
modifiers |= KeyModifiers::SHIFT;
}
if stroke.modifiers.super_key {
modifiers |= KeyModifiers::SUPER;
}
if stroke.modifiers.hyper {
modifiers |= KeyModifiers::HYPER;
}
if stroke.modifiers.meta {
modifiers |= KeyModifiers::META;
}
CrosstermEvent::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn reorder_windows(
windows: &mut Vec<AttachSimWindow>,
source_context_id: Uuid,
target_context_id: Uuid,
placement: AttachTabDropPlacement,
) {
if source_context_id == target_context_id {
return;
}
let Some(source_index) = windows
.iter()
.position(|window| window.id == source_context_id)
else {
return;
};
let source = windows.remove(source_index);
let Some(mut target_index) = windows
.iter()
.position(|window| window.id == target_context_id)
else {
windows.insert(source_index.min(windows.len()), source);
return;
};
if matches!(placement, AttachTabDropPlacement::After) {
target_index = target_index.saturating_add(1);
}
windows.insert(target_index.min(windows.len()), source);
}
#[cfg(test)]
mod tests {
use super::AttachSimHarness;
use crate::runtime::attach::input::{
TerminalModifiers, TerminalMouseButton, TerminalMouseEvent, TerminalMousePhase,
};
use crate::runtime::attach::state::{AttachTabDropPlacement, AttachUiEffect};
use bmux_config::StatusPosition;
use std::time::{Duration, Instant};
use uuid::Uuid;
const fn left_mouse(phase: TerminalMousePhase, col: u16, row: u16) -> TerminalMouseEvent {
TerminalMouseEvent {
phase,
button: Some(TerminalMouseButton::Left),
col,
row,
modifiers: TerminalModifiers {
shift: false,
control: false,
alt: false,
super_key: false,
hyper: false,
meta: false,
},
}
}
#[test]
fn attach_sim_uses_fixed_clock_adapter() {
let start = Instant::now();
let mut sim = AttachSimHarness::new(100, 24);
sim.set_clock(start);
sim.advance_clock(Duration::from_millis(10));
sim.seed_vertical_split_panes();
sim.send_mouse(left_mouse(TerminalMousePhase::Down, 10, 5));
assert!(sim.effects().is_empty());
}
#[test]
fn attach_sim_reorders_tabs_through_shared_reducer() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_window_list(&["one", "two", "three"], "one");
assert!(sim.rendered().contains("1:one"));
let one = sim.locate_text("1:one").expect("one tab");
let three = sim.locate_text("3:three").expect("three tab");
sim.send_mouse(left_mouse(
TerminalMousePhase::Down,
one.center_col,
one.row,
));
sim.send_mouse(left_mouse(
TerminalMousePhase::Move,
three.end_col,
three.row,
));
sim.send_mouse(left_mouse(TerminalMousePhase::Up, three.end_col, three.row));
assert_eq!(
sim.effects(),
&[AttachUiEffect::MoveWindow {
source_context_id: Uuid::from_u128(1),
target_context_id: Uuid::from_u128(3),
placement: AttachTabDropPlacement::After,
}]
);
assert_eq!(sim.window_names(), ["two", "three", "one"]);
assert!(sim.rendered().contains("1:two"));
}
#[test]
fn attach_sim_status_position_changes_located_mouse_row() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_window_list(&["one", "two"], "one");
let bottom = sim.locate_text("1:one").expect("bottom tab");
assert_eq!(bottom.row, 23);
sim.set_status_position(StatusPosition::Top);
let top = sim.locate_text("1:one").expect("top tab");
assert_eq!(top.row, 0);
}
#[test]
fn attach_sim_mru_order_does_not_move() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_window_list(&["one", "two", "three"], "one");
sim.set_mru_tab_order();
let one = sim.locate_text("1:one").expect("one tab");
let three = sim.locate_text("3:three").expect("three tab");
sim.send_mouse(left_mouse(
TerminalMousePhase::Down,
one.center_col,
one.row,
));
sim.send_mouse(left_mouse(
TerminalMousePhase::Move,
three.end_col,
three.row,
));
sim.send_mouse(left_mouse(TerminalMousePhase::Up, three.end_col, three.row));
assert_eq!(sim.window_names(), ["one", "two", "three"]);
assert!(
!sim.effects()
.iter()
.any(|effect| matches!(effect, AttachUiEffect::MoveWindow { .. }))
);
}
#[test]
fn attach_sim_send_attach_drives_scrollback_selection() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_pane_lines(&["one", " four", " five", " six"], 4, 3);
sim.send_attach_chord("ctrl+a [").expect("enter scrollback");
assert!(sim.scrollback_active());
assert_eq!(sim.scrollback_cursor(), Some((3, 2)));
sim.send_attach_chord("v").expect("begin selection");
sim.send_attach_chord("k").expect("move cursor");
assert!(sim.selection_active());
assert_eq!(sim.scrollback_cursor(), Some((2, 2)));
assert_eq!(sim.selected_text(), Some("e\n f".to_string()));
}
#[test]
fn attach_sim_mouse_resize_emits_resize_pane_effect() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_vertical_split_panes();
sim.send_mouse(left_mouse(TerminalMousePhase::Down, 9, 3));
sim.send_mouse(left_mouse(TerminalMousePhase::Drag, 12, 3));
sim.send_mouse(left_mouse(TerminalMousePhase::Up, 12, 3));
assert!(sim.effects().iter().any(|effect| {
matches!(
effect,
AttachUiEffect::ResizePane {
direction:
bmux_windows_plugin_api::windows_commands::PaneResizeDirection::Right,
cells: 3,
..
}
)
}));
}
#[test]
fn attach_sim_mouse_floating_drag_emits_move_effect() {
let mut sim = AttachSimHarness::new(100, 24);
sim.seed_floating_pane_layout();
sim.send_mouse(left_mouse(TerminalMousePhase::Down, 2, 2));
sim.send_mouse(left_mouse(TerminalMousePhase::Drag, 6, 4));
sim.send_mouse(left_mouse(TerminalMousePhase::Up, 6, 4));
assert!(sim.effects().iter().any(|effect| {
matches!(
effect,
AttachUiEffect::MoveFloatingPane {
pane_id,
x: 6,
y: 4,
} if *pane_id == Uuid::from_u128(32)
)
}));
}
}