use ratatui::layout::Rect;
use super::layout::{PanelLayout, PanelRect, PanelZone, panel_zone_at};
use super::scrollbar::{OverflowCounts, OverflowHit, overflow_hit_test};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MouseAction {
FocusPanel(usize),
FocusTree,
ToggleExpansion {
panel: usize,
line: usize,
},
SelectSession {
item: usize,
},
TogglePin(usize),
ScrollPanel {
panel: usize,
delta: isize,
},
ScrollTree(isize),
StartBorderDrag {
x: u16,
},
ContinueBorderDrag {
x: u16,
},
EndBorderDrag,
StartDragSelect {
panel: usize,
line: usize,
},
ContinueDragSelect {
panel: usize,
line: usize,
},
StartScrollbarDrag {
panel: usize,
y: u16,
},
ContinueScrollbarDrag {
panel: usize,
y: u16,
},
JumpOverflow {
panel: usize,
top: bool,
},
None,
}
#[derive(Debug, Clone)]
pub enum DragState {
Idle,
BorderResize {
initial_x: u16,
},
LineSelect {
panel: usize,
anchor: usize,
},
Scrollbar {
panel: usize,
},
}
#[must_use]
pub fn resolve_click(
x: u16,
y: u16,
tree_area: Rect,
grid_layout: &PanelLayout,
sessions_events_border_x: u16,
tree_scroll_offset: usize,
overflow_counts: &[OverflowCounts],
) -> MouseAction {
if x >= tree_area.x
&& x < tree_area.x + tree_area.width
&& y >= tree_area.y
&& y < tree_area.y + tree_area.height
{
let content_y = tree_area.y + 1;
if y >= content_y {
let item = (y - content_y) as usize + tree_scroll_offset;
return MouseAction::SelectSession { item };
}
return MouseAction::FocusTree;
}
if x >= sessions_events_border_x.saturating_sub(1)
&& x <= sessions_events_border_x.saturating_add(1)
{
return MouseAction::StartBorderDrag {
x: sessions_events_border_x,
};
}
if let Some((panel_idx, zone)) = panel_zone_at(grid_layout, x, y) {
let panel_rect = &grid_layout.panels[panel_idx];
return match zone {
PanelZone::TitleBar => MouseAction::TogglePin(panel_idx),
PanelZone::Scrollbar => MouseAction::StartScrollbarDrag {
panel: panel_idx,
y,
},
PanelZone::Content => {
let content_area = content_area_of(panel_rect);
if let Some(counts) = overflow_counts.get(panel_idx)
&& let Some(hit) = overflow_hit_test(x, y, content_area, counts)
{
return MouseAction::JumpOverflow {
panel: panel_idx,
top: hit == OverflowHit::Top,
};
}
let line = compute_line_from_click(y, panel_rect, 0);
MouseAction::ToggleExpansion {
panel: panel_idx,
line,
}
}
};
}
MouseAction::None
}
#[must_use]
pub fn resolve_scroll(
x: u16,
y: u16,
delta: isize,
tree_area: Rect,
grid_layout: &PanelLayout,
) -> MouseAction {
if x >= tree_area.x
&& x < tree_area.x + tree_area.width
&& y >= tree_area.y
&& y < tree_area.y + tree_area.height
{
return MouseAction::ScrollTree(delta);
}
for panel_rect in &grid_layout.panels {
let r = &panel_rect.rect;
if x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height {
return MouseAction::ScrollPanel {
panel: panel_rect.index,
delta,
};
}
}
MouseAction::None
}
#[must_use]
pub fn resolve_drag(
x: u16,
y: u16,
drag_state: &DragState,
grid_layout: &PanelLayout,
) -> MouseAction {
match drag_state {
DragState::BorderResize { .. } => MouseAction::ContinueBorderDrag { x },
DragState::LineSelect { panel, .. } => {
grid_layout
.panels
.get(*panel)
.map_or(MouseAction::None, |panel_rect| {
let line = compute_line_from_click(y, panel_rect, 0);
MouseAction::ContinueDragSelect {
panel: *panel,
line,
}
})
}
DragState::Scrollbar { panel } => MouseAction::ContinueScrollbarDrag { panel: *panel, y },
DragState::Idle => MouseAction::None,
}
}
#[must_use]
pub const fn resolve_release(drag_state: &DragState) -> MouseAction {
match drag_state {
DragState::BorderResize { .. } => MouseAction::EndBorderDrag,
_ => MouseAction::None,
}
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
reason = "terminal coordinates are always small"
)]
pub const fn compute_line_from_click(
y: u16,
panel_rect: &PanelRect,
scroll_offset: usize,
) -> usize {
let content_top = panel_rect.rect.y + 1;
let row_in_viewport = y.saturating_sub(content_top) as usize;
scroll_offset + row_in_viewport
}
#[must_use]
pub fn compute_sessions_width_from_drag(
x: u16,
terminal_width: u16,
min_width: u16,
) -> Option<u16> {
if x < min_width {
return None; }
let max_width = terminal_width.saturating_sub(min_width);
Some(x.clamp(min_width, max_width))
}
const fn content_area_of(panel_rect: &PanelRect) -> Rect {
let r = &panel_rect.rect;
Rect::new(
r.x,
r.y + 1,
r.width.saturating_sub(1),
r.height.saturating_sub(1),
)
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use std::collections::HashSet;
use super::*;
use crate::tui::layout::{Composition, compute_layout};
fn single_panel_layout(area: Rect) -> PanelLayout {
let comp = Composition(vec![1]);
compute_layout(area, &comp, &HashSet::new(), 1.0)
}
fn two_panel_layout(area: Rect) -> PanelLayout {
let comp = Composition(vec![2]);
compute_layout(area, &comp, &HashSet::new(), 1.0)
}
fn no_overflow() -> Vec<OverflowCounts> {
vec![OverflowCounts { above: 0, below: 0 }]
}
fn no_overflow_n(n: usize) -> Vec<OverflowCounts> {
vec![OverflowCounts { above: 0, below: 0 }; n]
}
#[test]
fn test_click_in_tree_area() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(31, 0, 49, 20);
let layout = single_panel_layout(grid_area);
let action = resolve_click(5, 3, tree_area, &layout, 30, 0, &no_overflow());
assert_eq!(action, MouseAction::SelectSession { item: 2 });
}
#[test]
fn test_click_in_panel_content() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let panel_r = &layout.panels[0].rect;
let cx = panel_r.x + 5;
let cy = panel_r.y + 3;
let action = resolve_click(cx, cy, tree_area, &layout, 31, 0, &no_overflow());
assert_eq!(action, MouseAction::ToggleExpansion { panel: 0, line: 2 });
}
#[test]
fn test_click_on_panel_title() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = two_panel_layout(grid_area);
let panel1_r = &layout.panels[1].rect;
let cx = panel1_r.x + 3;
let cy = panel1_r.y;
let action = resolve_click(cx, cy, tree_area, &layout, 31, 0, &no_overflow_n(2));
assert_eq!(action, MouseAction::TogglePin(1));
}
#[test]
fn test_click_on_border() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let border_x = 30u16;
let action = resolve_click(30, 5, tree_area, &layout, border_x, 0, &no_overflow());
assert_eq!(action, MouseAction::StartBorderDrag { x: 30 });
}
#[test]
fn test_click_outside_all() {
let tree_area = Rect::new(0, 0, 10, 10);
let grid_area = Rect::new(15, 0, 20, 10);
let layout = single_panel_layout(grid_area);
let action = resolve_click(80, 80, tree_area, &layout, 11, 0, &no_overflow());
assert_eq!(action, MouseAction::None);
}
#[test]
fn test_scroll_in_panel() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let panel_r = &layout.panels[0].rect;
let cx = panel_r.x + 5;
let cy = panel_r.y + 5;
let action = resolve_scroll(cx, cy, 3, tree_area, &layout);
assert_eq!(action, MouseAction::ScrollPanel { panel: 0, delta: 3 });
}
#[test]
fn test_scroll_in_tree() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let action = resolve_scroll(10, 5, -2, tree_area, &layout);
assert_eq!(action, MouseAction::ScrollTree(-2));
}
#[test]
fn test_scroll_does_not_focus() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let panel_r = &layout.panels[0].rect;
let action = resolve_scroll(panel_r.x + 5, panel_r.y + 5, 1, tree_area, &layout);
assert!(
!matches!(action, MouseAction::FocusPanel(_)),
"scroll should never return FocusPanel"
);
let action = resolve_scroll(5, 5, 1, tree_area, &layout);
assert!(
!matches!(action, MouseAction::FocusPanel(_)),
"scroll should never return FocusPanel"
);
}
#[test]
fn test_drag_border() {
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let drag = DragState::BorderResize { initial_x: 30 };
let action = resolve_drag(20, 5, &drag, &layout);
assert_eq!(action, MouseAction::ContinueBorderDrag { x: 20 });
}
#[test]
fn test_compute_line_from_click() {
let panel_rect = PanelRect {
rect: Rect::new(0, 5, 40, 20),
row: 0,
col: 0,
index: 0,
};
let line = compute_line_from_click(10, &panel_rect, 5);
assert_eq!(line, 9);
}
#[test]
fn test_compute_sessions_width_collapse() {
let result = compute_sessions_width_from_drag(5, 100, 10);
assert_eq!(result, None, "below min_width should trigger collapse");
}
#[test]
fn test_compute_sessions_width_normal() {
let result = compute_sessions_width_from_drag(40, 100, 10);
assert_eq!(result, Some(40));
}
#[test]
fn test_click_overflow_top() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let content = content_area_of(&layout.panels[0]);
let counts = vec![OverflowCounts {
above: 15,
below: 0,
}];
let right = content.x + content.width;
let label_x = right - 3; let cy = content.y;
let action = resolve_click(label_x, cy, tree_area, &layout, 31, 0, &counts);
assert_eq!(
action,
MouseAction::JumpOverflow {
panel: 0,
top: true
}
);
}
#[test]
fn test_click_overflow_bottom() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let content = content_area_of(&layout.panels[0]);
let counts = vec![OverflowCounts {
above: 0,
below: 10,
}];
let right = content.x + content.width;
let label_x = right - 3; let cy = content.y + content.height - 1;
let action = resolve_click(label_x, cy, tree_area, &layout, 31, 0, &counts);
assert_eq!(
action,
MouseAction::JumpOverflow {
panel: 0,
top: false
}
);
}
#[test]
fn test_click_overflow_padding_miss() {
let tree_area = Rect::new(0, 0, 30, 20);
let grid_area = Rect::new(32, 0, 48, 20);
let layout = single_panel_layout(grid_area);
let content = content_area_of(&layout.panels[0]);
let counts = vec![OverflowCounts {
above: 15,
below: 0,
}];
let right = content.x + content.width;
let space_x = right - 4; let cy = content.y;
let action = resolve_click(space_x, cy, tree_area, &layout, 31, 0, &counts);
assert!(
!matches!(action, MouseAction::JumpOverflow { .. }),
"click on padding space should not trigger JumpOverflow, got {action:?}"
);
}
}