use super::clipboard;
use crate::tui::{input, render, state};
use std::{ops::Not, time::Instant};
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct MouseHandlingResult {
pub(crate) changed: bool,
pub(crate) copy: Option<String>,
}
impl MouseHandlingResult {
fn changed(changed: bool) -> Self {
Self {
changed,
copy: None,
}
}
fn copy(text: String) -> Self {
Self {
changed: true,
copy: Some(text),
}
}
}
impl Not for MouseHandlingResult {
type Output = bool;
fn not(self) -> Self::Output {
!self.changed && self.copy.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MouseScrollBatch {
pub(crate) target: MouseScrollTarget,
pub(crate) kind: crossterm::event::MouseEventKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MouseScrollTarget {
SessionList,
SessionPreview,
SystemPrompt,
RewindModal,
Transcript,
ActivityTree,
Detail,
Prompt,
Help,
}
impl MouseScrollBatch {
pub(crate) fn coalesces_with(self, other: Self) -> bool {
self.target == other.target && self.kind == other.kind
}
}
pub(crate) fn mouse_scroll_batch(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &state::MissionControlState,
) -> Option<MouseScrollBatch> {
use crossterm::event::MouseEventKind;
if !matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
return None;
}
if state.session_picker_visible() {
if render::sessions_modal_list_area(terminal_area)
.is_some_and(|area| point_in_rect(mouse.column, mouse.row, area))
{
return Some(MouseScrollBatch {
target: MouseScrollTarget::SessionList,
kind: mouse.kind,
});
}
if render::sessions_modal_preview_area(terminal_area)
.is_some_and(|area| point_in_rect(mouse.column, mouse.row, area))
{
return Some(MouseScrollBatch {
target: MouseScrollTarget::SessionPreview,
kind: mouse.kind,
});
}
return None;
}
if state.system_prompt_modal_visible() {
return render::system_prompt_text_area(terminal_area)
.filter(|area| point_in_rect(mouse.column, mouse.row, *area))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::SystemPrompt,
kind: mouse.kind,
});
}
if state.rewind_modal_visible() {
return render::rewind_modal_text_area(terminal_area)
.filter(|area| point_in_rect(mouse.column, mouse.row, *area))
.map(|_| MouseScrollBatch {
target: MouseScrollTarget::RewindModal,
kind: mouse.kind,
});
}
let layout = render::layout_for(terminal_area, state);
let target = match layout.pane_at(mouse.column, mouse.row)? {
render::TuiPane::Transcript => MouseScrollTarget::Transcript,
render::TuiPane::ActivityTree => MouseScrollTarget::ActivityTree,
render::TuiPane::Detail => MouseScrollTarget::Detail,
render::TuiPane::Prompt => MouseScrollTarget::Prompt,
render::TuiPane::Help => MouseScrollTarget::Help,
render::TuiPane::Status => return None,
};
Some(MouseScrollBatch {
target,
kind: mouse.kind,
})
}
pub(crate) fn handle_mouse_event(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &mut state::MissionControlState,
) -> MouseHandlingResult {
handle_mouse_event_with_scroll_rows(mouse, terminal_area, state, state::MOUSE_WHEEL_SCROLL_ROWS)
}
pub(crate) fn handle_mouse_event_with_scroll_rows(
mouse: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &mut state::MissionControlState,
scroll_rows: u16,
) -> MouseHandlingResult {
use crossterm::event::{MouseButton, MouseEventKind};
if state.session_picker_visible() {
let changed = match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
if render::sessions_modal_list_area(terminal_area).is_some_and(|area| {
point_in_rect(mouse.column, mouse.row, area)
&& match mouse.kind {
MouseEventKind::ScrollUp => {
state.session_picker_up(area.height as usize)
}
MouseEventKind::ScrollDown => {
state.session_picker_down(area.height as usize)
}
_ => false,
}
}) {
true
} else {
render::sessions_modal_preview_area(terminal_area).is_some_and(|area| {
point_in_rect(mouse.column, mouse.row, area)
&& match mouse.kind {
MouseEventKind::ScrollUp => state.session_picker_preview_up_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
MouseEventKind::ScrollDown => state.session_picker_preview_down_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
_ => false,
}
})
}
}
MouseEventKind::Down(MouseButton::Left) => {
render::sessions_modal_list_area(terminal_area).is_some_and(|area| {
point_in_rect(mouse.column, mouse.row, area)
&& state.session_picker_select_visible_row(
(mouse.row - area.y) as usize,
area.height.max(1) as usize,
)
})
}
_ => false,
};
return MouseHandlingResult::changed(changed);
}
if state.skills_modal_visible() {
return MouseHandlingResult::changed(false);
}
if state.mcp_modal_visible() {
return MouseHandlingResult::changed(false);
}
if state.tools_modal_visible() {
return MouseHandlingResult::changed(false);
}
if state.subagents_modal_visible() {
return MouseHandlingResult::changed(false);
}
if state.system_prompt_modal_visible()
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
let changed = render::system_prompt_text_area(terminal_area).is_some_and(|area| {
point_in_rect(mouse.column, mouse.row, area)
&& match mouse.kind {
MouseEventKind::ScrollUp => state.scroll_system_prompt_up_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
MouseEventKind::ScrollDown => state.scroll_system_prompt_down_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
_ => false,
}
});
return MouseHandlingResult::changed(changed);
}
if state.rewind_modal_visible()
&& matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
)
{
let changed = render::rewind_modal_text_area(terminal_area).is_some_and(|area| {
point_in_rect(mouse.column, mouse.row, area)
&& match mouse.kind {
MouseEventKind::ScrollUp => state.scroll_rewind_up_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
MouseEventKind::ScrollDown => state.scroll_rewind_down_by(
scroll_rows,
area.height.max(1),
render::scroll_view_content_width(area).max(1),
),
_ => false,
}
});
return MouseHandlingResult::changed(changed);
}
let layout = render::layout_for(terminal_area, state);
let pane = layout.pane_at(mouse.column, mouse.row).filter(|pane| {
layout.pane_area(*pane).is_none_or(|area| {
let text_area = render::content_text_area_for_pane(*pane, area);
mouse.column
< text_area
.x
.saturating_add(render::scroll_view_content_width(text_area))
})
});
match (pane, mouse.kind) {
(Some(render::TuiPane::Transcript), MouseEventKind::ScrollUp) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Transcript).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Transcript, area);
state.scroll_transcript_up_by(
scroll_rows,
text_area.height,
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::Transcript), MouseEventKind::ScrollDown) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Transcript).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Transcript, area);
state.scroll_transcript_down_by(
scroll_rows,
text_area.height,
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::ActivityTree), MouseEventKind::ScrollUp) => {
MouseHandlingResult::changed(
layout
.pane_area(render::TuiPane::ActivityTree)
.is_some_and(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::ActivityTree, area);
state.scroll_activity_tree_up_by(scroll_rows, text_area.height)
}),
)
}
(Some(render::TuiPane::ActivityTree), MouseEventKind::ScrollDown) => {
MouseHandlingResult::changed(
layout
.pane_area(render::TuiPane::ActivityTree)
.is_some_and(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::ActivityTree, area);
state.scroll_activity_tree_down_by(scroll_rows, text_area.height)
}),
)
}
(Some(render::TuiPane::ActivityTree), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::ActivityTree)
.and_then(|area| {
text_cell(mouse.column, mouse.row, render::TuiPane::ActivityTree, area)
})
.map(|(row, _, text_area)| {
let selection_changed = state.clear_selection();
let focus_changed = !state.is_activity_tree_focused();
state.focus_activity_tree();
let activity_changed = state.click_activity_tree_row(row, text_area.height.max(1));
MouseHandlingResult::changed(selection_changed || focus_changed || activity_changed)
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection())),
(Some(render::TuiPane::Detail), MouseEventKind::ScrollUp) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Detail).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Detail, area);
let visible_rows = state.detail_body_viewport_rows(
text_area.height,
render::scroll_view_content_width(text_area).max(1),
);
state.scroll_detail_up_by(
scroll_rows,
visible_rows,
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::Detail), MouseEventKind::ScrollDown) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Detail).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Detail, area);
let visible_rows = state.detail_body_viewport_rows(
text_area.height,
render::scroll_view_content_width(text_area).max(1),
);
state.scroll_detail_down_by(
scroll_rows,
visible_rows,
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::Prompt), MouseEventKind::ScrollUp) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Prompt).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Prompt, area);
state.scroll_prompt_up_by(
scroll_rows,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::Prompt), MouseEventKind::ScrollDown) => {
MouseHandlingResult::changed(layout.pane_area(render::TuiPane::Prompt).is_some_and(
|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Prompt, area);
state.scroll_prompt_down_by(
scroll_rows,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
)
},
))
}
(Some(render::TuiPane::Help), MouseEventKind::ScrollUp) => MouseHandlingResult::changed(
layout.pane_area(render::TuiPane::Help).is_some_and(|area| {
let text_area = render::content_text_area_for_pane(render::TuiPane::Help, area);
let help_text = input::help_text();
state.scroll_help_up_by(
scroll_rows,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
help_text,
)
}),
),
(Some(render::TuiPane::Help), MouseEventKind::ScrollDown) => MouseHandlingResult::changed(
layout.pane_area(render::TuiPane::Help).is_some_and(|area| {
let text_area = render::content_text_area_for_pane(render::TuiPane::Help, area);
let help_text = input::help_text();
state.scroll_help_down_by(
scroll_rows,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
help_text,
)
}),
),
(Some(render::TuiPane::Prompt), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Prompt)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Prompt, area))
.map(|(row, col, text_area)| {
let pos = state.prompt_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
);
state.start_selection(render::TuiPane::Prompt, pos);
MouseHandlingResult::changed(true)
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection())),
(Some(render::TuiPane::Transcript), MouseEventKind::Down(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Transcript)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Transcript, area))
.map(|(row, col, text_area)| {
if let Some(activity_id) = state.transcript_activity_button_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
) {
let selection_changed = state.clear_selection();
let focus_changed = !state.is_activity_tree_focused();
state.focus_activity_tree();
let activity_changed = state.select_activity_id(&activity_id);
if let Some(activity_rows) =
layout.pane_area(render::TuiPane::ActivityTree).map(|area| {
render::content_text_area_for_pane(render::TuiPane::ActivityTree, area)
.height
.max(1)
})
{
state.ensure_activity_selection_visible(activity_rows);
}
return MouseHandlingResult::changed(
selection_changed || focus_changed || activity_changed,
);
}
state
.transcript_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
)
.map(|pos| {
state.start_selection(render::TuiPane::Transcript, pos);
MouseHandlingResult::changed(true)
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection()))
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection())),
(Some(render::TuiPane::Prompt), MouseEventKind::Drag(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Prompt)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Prompt, area))
.map(|(row, col, text_area)| {
let pos = state.prompt_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
);
MouseHandlingResult::changed(state.update_selection(render::TuiPane::Prompt, pos))
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection())),
(Some(render::TuiPane::Transcript), MouseEventKind::Drag(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Transcript)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Transcript, area))
.and_then(|(row, col, text_area)| {
state.transcript_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
)
})
.map(|pos| {
MouseHandlingResult::changed(
state.update_selection(render::TuiPane::Transcript, pos),
)
})
.unwrap_or_else(|| MouseHandlingResult::changed(state.clear_selection())),
(Some(render::TuiPane::Prompt), MouseEventKind::Up(MouseButton::Left)) => {
let copy = state.finish_selection(render::TuiPane::Prompt, 0);
if let Some(text) = copy {
MouseHandlingResult::copy(text)
} else if let Some(area) = layout
.pane_area(render::TuiPane::Prompt)
.and_then(|area| text_cell(mouse.column, mouse.row, render::TuiPane::Prompt, area))
{
let (row, col, text_area) = area;
let pos = state.prompt_text_position_for_cell(
row,
col,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
);
state.move_prompt_cursor_to(
pos.byte,
text_area.height.max(1),
render::scroll_view_content_width(text_area).max(1),
);
MouseHandlingResult::changed(true)
} else {
MouseHandlingResult::changed(false)
}
}
(Some(render::TuiPane::Transcript), MouseEventKind::Up(MouseButton::Left)) => layout
.pane_area(render::TuiPane::Transcript)
.and_then(|area| {
let text_area =
render::content_text_area_for_pane(render::TuiPane::Transcript, area);
state.finish_selection(
render::TuiPane::Transcript,
render::scroll_view_content_width(text_area).max(1),
)
})
.map(MouseHandlingResult::copy)
.unwrap_or_else(|| MouseHandlingResult::changed(false)),
(_, MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Drag(MouseButton::Left)) => {
MouseHandlingResult::changed(state.clear_selection())
}
_ => MouseHandlingResult::changed(false),
}
}
pub(crate) fn apply_copy_request(
text: &str,
state: &mut state::MissionControlState,
clipboard: &mut impl clipboard::ClipboardWriter,
now: Instant,
) -> bool {
if text.is_empty() {
return false;
}
if clipboard.copy(text).is_ok() {
state.show_copy_success_toast(now);
} else {
state.show_copy_failure_toast(now);
}
true
}
fn point_in_rect(column: u16, row: u16, area: ratatui::layout::Rect) -> bool {
column >= area.x
&& row >= area.y
&& column
< area
.x
.saturating_add(render::scroll_view_content_width(area))
&& row < area.y.saturating_add(area.height)
}
fn text_cell(
column: u16,
row: u16,
pane: render::TuiPane,
pane_area: ratatui::layout::Rect,
) -> Option<(u16, u16, ratatui::layout::Rect)> {
let text_area = render::content_text_area_for_pane(pane, pane_area);
if column < text_area.x
|| row < text_area.y
|| column
>= text_area
.x
.saturating_add(render::scroll_view_content_width(text_area))
|| row >= text_area.y.saturating_add(text_area.height)
{
return None;
}
Some((row - text_area.y, column - text_area.x, text_area))
}
#[cfg(test)]
#[path = "mouse_tests.rs"]
mod tests;