use super::{Action, DetailArea, Monitor, Pane};
use crate::review::Control;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Position, Rect};
use std::time::{Duration, Instant};
pub(crate) const SCROLL_PAGE: u16 = 10;
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
const WHEEL_ROWS: i16 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Hit {
Scope(usize),
Run(usize),
Artifact(Vec<super::model::NodeId>),
}
pub(super) struct Click {
hit: Hit,
at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Button {
Review(Control),
Close,
SessionTop,
SessionBottom,
SessionDetails,
}
#[derive(Default)]
pub(super) struct Hits {
pub panes: Vec<(Pane, Rect)>,
pub tree: Rect,
pub detail: Rect,
pub areas: [Rect; 2],
pub review: crate::review::Hits,
pub buttons: Vec<(Rect, Button)>,
pub session_groups: Vec<(Rect, crate::agent::session::transcript::BlockId)>,
}
impl Hits {
pub fn pane(&self, pane: Pane) -> Option<Rect> {
self.panes
.iter()
.find(|(visible, _)| *visible == pane)
.map(|(_, area)| *area)
}
}
pub(crate) fn protocols(mouse: bool, paste: bool) -> Result<(), String> {
let mut out = std::io::stdout();
if mouse {
crossterm::execute!(out, EnableMouseCapture)
} else {
crossterm::execute!(out, DisableMouseCapture)
}
.map_err(|error| error.to_string())?;
if paste {
crossterm::execute!(out, EnableBracketedPaste)
} else {
crossterm::execute!(out, DisableBracketedPaste)
}
.map_err(|error| error.to_string())
}
fn contains(rect: Rect, point: Position) -> bool {
rect.contains(point)
}
impl Monitor {
pub fn mouse(&mut self, event: MouseEvent) -> Action {
self.mouse_at(event, Instant::now())
}
pub(crate) fn mouse_at(&mut self, event: MouseEvent, at: Instant) -> Action {
if !self.mouse_capture {
return Action::None;
}
let point = Position::new(event.column, event.row);
if self.help {
if event.kind == MouseEventKind::Down(MouseButton::Left) {
self.help = false;
}
return Action::None;
}
let pane = self
.hits
.panes
.iter()
.find(|(_, rect)| contains(*rect, point))
.map(|(pane, _)| *pane);
if self.focus == Pane::Detail && self.detail.is_some() {
if contains(self.hits.detail, point) {
return self.detail_mouse(event, point);
}
if self.locked() && matches!(event.kind, MouseEventKind::Down(_)) {
return Action::None;
}
}
let Some(pane) = pane else {
return Action::None;
};
match event.kind {
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
self.wheel(pane, event.kind == MouseEventKind::ScrollDown)
}
MouseEventKind::Down(MouseButton::Left) => {
let Some(area) = self.hits.pane(pane) else {
return Action::None;
};
if pane == Pane::Detail {
return if self.focus == Pane::Artifacts {
Action::OpenDetail
} else {
Action::None
};
}
if self.focus == Pane::Detail {
self.leave_detail();
}
self.focus = pane;
let area = if pane == Pane::Artifacts {
self.hits.tree
} else {
area
};
let hit = match pane {
Pane::Repositories | Pane::Runs
if point.y <= area.y || point.y >= area.bottom().saturating_sub(1) =>
{
return Action::None;
}
Pane::Repositories => {
Hit::Scope(self.repositories.offset() + usize::from(point.y - area.y - 1))
}
Pane::Runs => Hit::Run(self.list.offset() + usize::from(point.y - area.y - 1)),
Pane::Artifacts => match self.tree.rendered_at(point) {
Some(path) => Hit::Artifact(path.to_vec()),
None => return Action::None,
},
Pane::Detail => return Action::None,
};
let double = self.last_click.as_ref().is_some_and(|click| {
click.hit == hit && at.saturating_duration_since(click.at) <= DOUBLE_CLICK
});
self.last_click = Some(Click {
hit: hit.clone(),
at,
});
match hit {
Hit::Scope(index) => self.select_scope(index),
Hit::Run(index) => self.select_run(index),
Hit::Artifact(path) => {
self.tree.select(path);
if double {
self.last_click = None;
Action::OpenDetail
} else {
Action::None
}
}
}
}
_ => Action::None,
}
}
fn wheel(&mut self, pane: Pane, down: bool) -> Action {
let steps = WHEEL_ROWS as usize;
match pane {
Pane::Repositories => {
let index = self.repositories.selected().unwrap_or(0);
let next = if down {
index
.saturating_add(steps)
.min(self.catalog.rows.len().saturating_sub(1))
} else {
index.saturating_sub(steps)
};
self.select_scope(next)
}
Pane::Runs => {
let index = self.list.selected().unwrap_or(0);
let next = if down {
index
.saturating_add(steps)
.min(self.runs.len().saturating_sub(1))
} else {
index.saturating_sub(steps)
};
if down && next == index && self.runs.len() as u32 == self.limit {
self.limit = self.limit.saturating_add(super::PAGE);
Action::Refresh
} else {
self.select_run(next)
}
}
Pane::Artifacts => {
if down {
self.tree.scroll_down(steps);
} else {
self.tree.scroll_up(steps);
}
Action::None
}
Pane::Detail => Action::None,
}
}
fn detail_mouse(&mut self, event: MouseEvent, point: Position) -> Action {
let Some(pane) = &mut self.detail else {
return Action::None;
};
if event.kind == MouseEventKind::Down(MouseButton::Left) {
if let Some((_, button)) = self
.hits
.buttons
.iter()
.find(|(rect, _)| contains(*rect, point))
{
return match button {
Button::Close => {
if let Some(review) = &mut pane.review
&& review.busy()
{
return Action::Review(review.control(Control::Cancel));
}
self.leave_detail();
Action::None
}
Button::SessionDetails => {
pane.show_details = !pane.show_details;
pane.focus = if pane.show_details {
DetailArea::Summary
} else {
DetailArea::Evidence
};
Action::None
}
Button::SessionTop | Button::SessionBottom => {
if let Some(live) = &mut pane.live {
pane.focus = DetailArea::Evidence;
live.movement(if *button == Button::SessionTop {
crate::agent::session::document::Move::Top
} else {
crate::agent::session::document::Move::Bottom
});
}
Action::None
}
Button::Review(control) => {
pane.review.as_mut().map_or(Action::None, |review| {
Action::Review(review.control(*control))
})
}
};
}
if let Some((_, id)) = self
.hits
.session_groups
.iter()
.find(|(rect, _)| contains(*rect, point))
&& let Some(live) = &mut pane.live
{
pane.focus = DetailArea::Evidence;
live.toggle(*id);
return Action::None;
}
if let Some(review) = &mut pane.review {
return Action::Review(review.click(&self.hits.review, point));
} else if contains(self.hits.areas[0], point) {
pane.focus = DetailArea::Summary;
} else if contains(self.hits.areas[1], point) {
pane.focus = DetailArea::Evidence;
}
}
let delta = match event.kind {
MouseEventKind::ScrollDown => WHEEL_ROWS,
MouseEventKind::ScrollUp => -WHEEL_ROWS,
_ => return Action::None,
};
if contains(self.hits.review.instruction, point)
&& let Some(review) = &mut pane.review
{
review.scroll_instruction(delta);
return Action::None;
}
let index = if contains(self.hits.areas[0], point) {
0
} else if contains(self.hits.areas[1], point) {
1
} else {
return Action::None;
};
if let Some(review) = &mut pane.review {
review.scroll_single(delta, index == 1);
} else if index == 1
&& let Some(live) = &mut pane.live
{
pane.focus = DetailArea::Evidence;
live.movement(if delta > 0 {
crate::agent::session::document::Move::Down(delta as usize)
} else {
crate::agent::session::document::Move::Up(delta.unsigned_abs() as usize)
});
} else {
pane.scroll[index] = pane.scroll[index].saturating_add_signed(delta);
}
Action::None
}
}