use crate::config::TuiConfig;
use crate::frame_snapshot::FrameSnapshot;
use crate::input::AppState;
use crate::runtime::{AppMouseButton, AppMouseEvent, AppMouseEventKind};
use crate::update::Action;
pub(crate) fn handle_mouse_event(
event: AppMouseEvent,
state: &AppState,
frame_snapshot: &FrameSnapshot,
_config: &TuiConfig,
) -> Option<Action> {
if let AppMouseEventKind::Moved = event.kind {
return state.ui.mouse_select.as_ref().map_or(
Some(Action::UpdateHover {
x: event.column,
y: event.row,
}),
|_| Some(Action::UpdateMouseSelection(event)),
);
}
if let AppMouseEventKind::Drag(AppMouseButton::Left) = event.kind
&& state.ui.mouse_select.is_some()
{
return Some(Action::UpdateMouseSelection(event));
}
if let AppMouseEventKind::Up(AppMouseButton::Left) = event.kind {
return Some(Action::ClearMouseSelection);
}
if let AppMouseEventKind::Down(AppMouseButton::Left) = event.kind {
let dropdown_open =
state.ui.dropdown_open.is_some() && frame_snapshot.dropdown_visible_rows().is_some();
if dropdown_open && frame_snapshot.dropdown_contains(event.column, event.row) {
return state
.ui
.dropdown_open
.clone()
.map(|arg_id| Action::ClickDropdownChoice {
arg_id,
row: event.row,
});
}
if let Some(target) = frame_snapshot.footer_target_at(event.column, event.row) {
return Some(Action::ClickFooter(target));
}
if frame_snapshot.preview_contains(event.column, event.row) {
return Some(Action::CopyPreview);
}
if frame_snapshot.search_contains(event.column, event.row) {
return Some(Action::FocusSearch);
}
if state.ui.help_open && frame_snapshot.form_contains(event.column, event.row) {
return Some(Action::ToggleHelp);
}
if frame_snapshot.sidebar_contains(event.column, event.row) {
return Some(Action::ClickSidebar {
x: event.column,
y: event.row,
});
}
if let Some(tab) = frame_snapshot.tab_at(event.column, event.row) {
return Some(Action::SwitchTab(tab));
}
if frame_snapshot.form_contains(event.column, event.row) {
return Some(Action::ClickForm(event));
}
if dropdown_open {
return Some(Action::CloseDropdown);
}
}
if frame_snapshot.dropdown_contains(event.column, event.row) && state.ui.dropdown_open.is_some()
{
match event.kind {
AppMouseEventKind::ScrollDown => return Some(Action::ScrollDropdown(1)),
AppMouseEventKind::ScrollUp => return Some(Action::ScrollDropdown(-1)),
_ => {}
}
}
if state.ui.help_open && frame_snapshot.form_contains(event.column, event.row) {
match event.kind {
AppMouseEventKind::ScrollDown => return Some(Action::ScrollForm(2)),
AppMouseEventKind::ScrollUp => return Some(Action::ScrollForm(-2)),
_ => {}
}
}
if state.ui.help_open {
return None;
}
if frame_snapshot.sidebar_contains(event.column, event.row) {
match event.kind {
AppMouseEventKind::ScrollDown => return Some(Action::ScrollSidebar(2)),
AppMouseEventKind::ScrollUp => return Some(Action::ScrollSidebar(-2)),
_ => {}
}
}
if let AppMouseEventKind::ScrollDown = event.kind {
return Some(Action::ScrollForm(2));
}
if let AppMouseEventKind::ScrollUp = event.kind {
return Some(Action::ScrollForm(-2));
}
None
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use ratatui::layout::Rect;
use super::handle_mouse_event;
use crate::config::TuiConfig;
use crate::frame_snapshot::{
FooterButtonLayout, FrameSnapshot, SidebarItemLayout, TabButtonLayout,
};
use crate::input::{ActiveTab, AppState, Focus, HoverTarget};
use crate::runtime::{AppKeyModifiers, AppMouseButton, AppMouseEvent, AppMouseEventKind};
use crate::spec::{ArgKind, ArgSpec, CommandSpec, ValueCardinality};
use crate::update::{Action, Effect, apply_action};
fn os_vec(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
fn arg(id: &str, name: &str, kind: ArgKind) -> ArgSpec {
ArgSpec {
id: id.to_string(),
display_name: name.to_string(),
help: None,
required: false,
kind,
default_values: Vec::new(),
choices: Vec::new(),
position: None,
value_cardinality: ValueCardinality::One,
value_hint: None,
..ArgSpec::default()
}
}
fn command(args: Vec<ArgSpec>) -> CommandSpec {
CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args,
subcommands: Vec::new(),
..CommandSpec::default()
}
}
fn click(column: u16, row: u16) -> AppMouseEvent {
AppMouseEvent {
kind: AppMouseEventKind::Down(AppMouseButton::Left),
column,
row,
modifiers: AppKeyModifiers::default(),
}
}
fn scroll(kind: AppMouseEventKind, column: u16, row: u16) -> AppMouseEvent {
AppMouseEvent {
kind,
column,
row,
modifiers: AppKeyModifiers::default(),
}
}
#[test]
fn flag_description_click_selects_without_toggling() {
let mut verbose = arg("verbose", "--verbose", ArgKind::Flag);
verbose.help = Some("Enable verbose output".to_string());
let mut state = AppState::new(command(vec![verbose]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.active_tab = ActiveTab::Inputs;
frame_snapshot.layout.form = Some(Rect::new(0, 0, 30, 10));
frame_snapshot.layout.form_view = Some(Rect::new(0, 0, 30, 10));
frame_snapshot
.layout
.form_fields
.push(crate::layout::form::FormFieldLayout {
arg_id: "verbose".to_string(),
heading: None,
label: Some(Rect::new(0, 0, 12, 1)),
input: Rect::new(13, 0, 12, 3),
input_clip_top: 0,
full_input_height: 3,
description: Some(Rect::new(13, 3, 12, 1)),
});
let action =
handle_mouse_event(click(1, 0), &state, &frame_snapshot, &TuiConfig::default())
.expect("click action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(state.ui.selected_arg_index, 0);
assert!(matches!(state.ui.focus, Focus::Form));
assert!(!state.domain.is_touched("verbose"));
assert_eq!(
state.domain.current_form().and_then(|inputs| {
state
.domain
.current_command()
.args
.iter()
.find(|arg| arg.id == "verbose")
.and_then(|arg| inputs.compatibility_value(arg))
}),
Some(crate::input::ArgValue::Bool(false))
);
let action =
handle_mouse_event(click(14, 1), &state, &frame_snapshot, &TuiConfig::default())
.expect("toggle action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert!(state.domain.is_touched("verbose"));
}
#[test]
fn footer_click_runs_or_focuses_search_based_on_button_hit() {
let mut state = AppState::new(command(Vec::new()));
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.footer = Some(Rect::new(0, 20, 40, 1));
frame_snapshot.layout.footer_buttons = vec![
FooterButtonLayout {
target: HoverTarget::Run,
rect: Rect::new(0, 20, 8, 1),
},
FooterButtonLayout {
target: HoverTarget::Search,
rect: Rect::new(10, 20, 8, 1),
},
];
let run_action =
handle_mouse_event(click(1, 20), &state, &frame_snapshot, &TuiConfig::default())
.expect("run action");
let run_effect = apply_action(&run_action, &mut state, &frame_snapshot);
assert_eq!(run_effect, Effect::Run(os_vec(&["tool"])));
let search_action = handle_mouse_event(
click(11, 20),
&state,
&frame_snapshot,
&TuiConfig::default(),
)
.expect("search action");
let search_effect = apply_action(&search_action, &mut state, &frame_snapshot);
assert_eq!(search_effect, Effect::None);
assert!(matches!(state.ui.focus, Focus::Search));
}
#[test]
fn footer_run_click_closes_dropdown_before_running() {
let mut state = AppState::new(command(Vec::new()));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
frame_snapshot.layout.footer = Some(Rect::new(0, 20, 20, 1));
frame_snapshot.layout.footer_buttons = vec![FooterButtonLayout {
target: HoverTarget::Run,
rect: Rect::new(0, 20, 8, 1),
}];
let action =
handle_mouse_event(click(1, 20), &state, &frame_snapshot, &TuiConfig::default())
.expect("run action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::Run(os_vec(&["tool"])));
assert!(state.ui.dropdown_open.is_none());
}
#[test]
fn preview_click_copies_full_width_dock() {
let state = AppState::new(command(Vec::new()));
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.preview = Some(Rect::new(0, 18, 80, 4));
let action = handle_mouse_event(
click(72, 20),
&state,
&frame_snapshot,
&TuiConfig::default(),
);
assert_eq!(action, Some(Action::CopyPreview));
}
#[test]
fn outside_click_retargets_search_while_closing_dropdown() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = vec!["red".to_string(), "green".to_string()];
let mut state = AppState::new(command(vec![color]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
frame_snapshot.layout.dropdown = Some(Rect::new(20, 5, 20, 5));
frame_snapshot.layout.search = Some(Rect::new(0, 0, 20, 1));
let action =
handle_mouse_event(click(1, 0), &state, &frame_snapshot, &TuiConfig::default())
.expect("search action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert!(state.ui.dropdown_open.is_none());
assert!(matches!(state.ui.focus, Focus::Search));
}
#[test]
fn outside_click_retargets_form_while_closing_dropdown() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = vec!["red".to_string(), "green".to_string()];
let path = arg("path", "--path", ArgKind::Option);
let mut state = AppState::new(command(vec![color, path]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
frame_snapshot.layout.dropdown = Some(Rect::new(20, 5, 20, 5));
frame_snapshot.layout.form = Some(Rect::new(0, 2, 30, 8));
frame_snapshot.layout.form_view = Some(Rect::new(0, 2, 30, 8));
let action =
handle_mouse_event(click(1, 6), &state, &frame_snapshot, &TuiConfig::default())
.expect("form action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert!(state.ui.dropdown_open.is_none());
assert!(matches!(state.ui.focus, Focus::Form));
}
#[test]
fn tab_click_switches_active_tab() {
let mut state = AppState::new(command(Vec::new()));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.active_tab = ActiveTab::Inputs;
frame_snapshot.layout.form_tabs = vec![TabButtonLayout {
tab: ActiveTab::Inputs,
rect: Rect::new(0, 0, 8, 1),
}];
let action =
handle_mouse_event(click(1, 0), &state, &frame_snapshot, &TuiConfig::default())
.expect("tab action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(state.ui.active_tab, ActiveTab::Inputs);
}
#[test]
fn dropdown_click_selects_choice_and_scroll_hits_dropdown_only() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = vec!["red".to_string(), "green".to_string(), "blue".to_string()];
let mut state = AppState::new(command(vec![color]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
state.ui.dropdown_scroll = 1;
frame_snapshot.layout.dropdown = Some(Rect::new(0, 5, 20, 5));
let action =
handle_mouse_event(click(1, 7), &state, &frame_snapshot, &TuiConfig::default())
.expect("dropdown action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(
state.domain.current_form().and_then(|inputs| {
state
.domain
.current_command()
.args
.iter()
.find(|arg| arg.id == "color")
.and_then(|arg| inputs.compatibility_value(arg))
}),
Some(crate::input::ArgValue::Choice("green".to_string()))
);
assert!(state.domain.is_touched("color"));
assert!(state.ui.dropdown_open.is_none());
}
#[test]
fn dropdown_scroll_events_adjust_dropdown_scroll_without_touching_form_scroll() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = (0..8).map(|index| format!("choice-{index}")).collect();
let mut state = AppState::new(command(vec![color]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
frame_snapshot.layout.dropdown = Some(Rect::new(0, 5, 20, 5));
state.ui.form_scroll = 4;
let action = handle_mouse_event(
scroll(AppMouseEventKind::ScrollDown, 1, 6),
&state,
&frame_snapshot,
&TuiConfig::default(),
)
.expect("scroll action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(state.ui.dropdown_scroll, 1);
assert_eq!(state.ui.form_scroll, 4);
}
#[test]
fn sidebar_scroll_events_adjust_sidebar_scroll_without_touching_form_scroll() {
let mut state = AppState::new(CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: (0..6)
.map(|index| CommandSpec {
name: format!("cmd-{index}"),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
})
.collect(),
..CommandSpec::default()
});
state
.select_command_path(&["cmd-2".to_string()])
.expect("valid path");
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
frame_snapshot.layout.sidebar_list = Some(Rect::new(1, 2, 18, 3));
state.ui.form_scroll = 4;
let action = handle_mouse_event(
scroll(AppMouseEventKind::ScrollDown, 2, 3),
&state,
&frame_snapshot,
&TuiConfig::default(),
)
.expect("scroll action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(state.ui.sidebar_scroll, 2);
assert_eq!(state.ui.form_scroll, 4);
}
#[test]
fn sidebar_scroll_events_over_search_or_chrome_still_scroll_sidebar() {
let mut state = AppState::new(CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: (0..6)
.map(|index| CommandSpec {
name: format!("cmd-{index}"),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
})
.collect(),
..CommandSpec::default()
});
state
.select_command_path(&["cmd-2".to_string()])
.expect("valid path");
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
frame_snapshot.layout.sidebar_list = Some(Rect::new(1, 2, 18, 3));
frame_snapshot.layout.search = Some(Rect::new(1, 1, 18, 1));
state.ui.form_scroll = 4;
let action = handle_mouse_event(
scroll(AppMouseEventKind::ScrollDown, 2, 1),
&state,
&frame_snapshot,
&TuiConfig::default(),
)
.expect("scroll action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(state.ui.sidebar_scroll, 2);
assert_eq!(state.ui.form_scroll, 4);
}
#[test]
fn dropdown_click_uses_clamped_scroll_position() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = (0..8).map(|index| format!("choice-{index}")).collect();
let mut state = AppState::new(command(vec![color]));
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
state.ui.dropdown_scroll = 20;
frame_snapshot.layout.dropdown = Some(Rect::new(0, 5, 20, 5));
let action =
handle_mouse_event(click(1, 7), &state, &frame_snapshot, &TuiConfig::default())
.expect("dropdown action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(
state.domain.current_form().and_then(|inputs| {
state
.domain
.current_command()
.args
.iter()
.find(|arg| arg.id == "color")
.and_then(|arg| inputs.compatibility_value(arg))
}),
Some(crate::input::ArgValue::Choice("choice-6".to_string()))
);
}
#[test]
fn outside_click_retargets_sidebar_while_closing_dropdown() {
let mut color = arg("color", "--color", ArgKind::Enum);
color.choices = vec!["red".to_string(), "green".to_string()];
let mut state = AppState::new(CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: vec![color],
subcommands: vec![CommandSpec {
name: "build".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
}],
..CommandSpec::default()
});
let mut frame_snapshot = FrameSnapshot::default();
state.ui.dropdown_open = Some("color".to_string());
frame_snapshot.layout.dropdown = Some(Rect::new(20, 5, 20, 5));
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
frame_snapshot.layout.sidebar_items = vec![SidebarItemLayout {
path: vec!["build".to_string()].into(),
row: Rect::new(0, 1, 20, 1),
has_children: false,
}];
let action =
handle_mouse_event(click(1, 1), &state, &frame_snapshot, &TuiConfig::default())
.expect("sidebar action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert!(state.ui.dropdown_open.is_none());
assert_eq!(
state.domain.selected_path().as_slice(),
&["build".to_string()]
);
assert!(matches!(state.ui.focus, Focus::Sidebar));
}
#[test]
fn sidebar_caret_click_selects_command_and_expands_item() {
let mut state = AppState::new(CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: vec![CommandSpec {
name: "build".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: vec![CommandSpec {
name: "release".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
}],
..CommandSpec::default()
}],
..CommandSpec::default()
});
state
.select_command_path(&["build".to_string()])
.expect("valid path");
state.domain.expanded.remove("tool::build");
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
frame_snapshot.layout.sidebar_items = vec![SidebarItemLayout {
path: vec!["build".to_string()].into(),
row: Rect::new(0, 1, 20, 1),
has_children: true,
}];
let action =
handle_mouse_event(click(2, 1), &state, &frame_snapshot, &TuiConfig::default())
.expect("sidebar action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(
state.domain.selected_path().as_slice(),
&["build".to_string()]
);
assert!(state.domain.expanded.contains("tool::build"));
assert!(matches!(state.ui.focus, Focus::Sidebar));
}
#[test]
fn sidebar_row_click_selects_command_and_expands_item() {
let mut state = AppState::new(CommandSpec {
name: "tool".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: vec![CommandSpec {
name: "build".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: vec![CommandSpec {
name: "release".to_string(),
version: None,
about: None,
help: String::new(),
args: Vec::new(),
subcommands: Vec::new(),
..CommandSpec::default()
}],
..CommandSpec::default()
}],
..CommandSpec::default()
});
state.domain.expanded.remove("tool::build");
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
frame_snapshot.layout.sidebar_items = vec![SidebarItemLayout {
path: vec!["build".to_string()].into(),
row: Rect::new(0, 1, 20, 1),
has_children: true,
}];
let action =
handle_mouse_event(click(8, 1), &state, &frame_snapshot, &TuiConfig::default())
.expect("sidebar action");
let effect = apply_action(&action, &mut state, &frame_snapshot);
assert_eq!(effect, Effect::None);
assert_eq!(
state.domain.selected_path().as_slice(),
&["build".to_string()]
);
assert!(state.domain.expanded.contains("tool::build"));
assert!(matches!(state.ui.focus, Focus::Sidebar));
}
#[test]
fn help_overlay_ignores_scroll_events_outside_form_area() {
let mut state = AppState::new(command(Vec::new()));
state.ui.help_open = true;
let mut frame_snapshot = FrameSnapshot::default();
frame_snapshot.layout.form = Some(Rect::new(20, 0, 40, 10));
frame_snapshot.layout.form_view = Some(Rect::new(20, 0, 40, 10));
frame_snapshot.layout.sidebar = Some(Rect::new(0, 0, 20, 10));
let action = handle_mouse_event(
scroll(AppMouseEventKind::ScrollDown, 5, 5),
&state,
&frame_snapshot,
&TuiConfig::default(),
);
assert!(action.is_none());
}
}