1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Mouse event routing: scroll, click, and drag handlers.
use crate::ui::pr_detail::DetailSection;
use super::state::App;
use super::types::Focus;
impl App {
/// Route a raw mouse event to the focused panel.
///
/// In Detail focus:
/// - Wheel over sidebar → scroll the sidebar files list.
/// - Wheel over right pane → scroll the active section.
/// - Left click on a sidebar section row → select that section.
/// - Left click on a sidebar file row → select the Files section and set
/// `pr_detail_files_cursor` (Phase 2 will use this to open the diff view).
/// - Left click on the right pane → enter copy mode.
/// - Drag on the right pane → extend the copy selection.
pub(super) fn handle_mouse(&mut self, m: crossterm::event::MouseEvent) {
use crossterm::event::{MouseButton, MouseEventKind};
match self.focus {
Focus::Detail => {
let (sections_rect, files_rect) = self.pr_detail_sidebar_rects.get();
let right_rect = self.pr_detail_right_viewport.get();
// Determine where the event landed.
let in_sidebar = m.column < right_rect.x;
match m.kind {
MouseEventKind::ScrollUp => {
if in_sidebar {
self.pr_detail_sidebar_scroll =
self.pr_detail_sidebar_scroll.saturating_sub(3);
} else {
let next = self.right_pane_scroll().saturating_sub(3);
*self.right_pane_scroll_mut() = next;
}
}
MouseEventKind::ScrollDown => {
if in_sidebar {
self.pr_detail_sidebar_scroll =
self.pr_detail_sidebar_scroll.saturating_add(3);
} else {
let next = self.right_pane_scroll().saturating_add(3);
*self.right_pane_scroll_mut() = next;
}
}
MouseEventKind::Down(MouseButton::Left) => {
if in_sidebar {
self.handle_sidebar_click(m.column, m.row, sections_rect, files_rect);
} else if let Some((row, col)) = self.mouse_to_content_pos(m.column, m.row)
{
// A fresh left-click on the right pane enters copy mode.
self.copy_mode.enter(row, col);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if !self.copy_mode.active || in_sidebar {
return;
}
if self.copy_mode.anchor.is_none() {
self.copy_mode.anchor = Some(self.copy_mode.cursor);
}
if let Some((row, col)) = self.mouse_to_content_pos(m.column, m.row) {
let lines = self.current_detail_lines();
let last_row = lines.len().saturating_sub(1);
self.copy_mode.cursor =
crate::ui::copy_mode::Pos { row: row.min(last_row), col };
self.ensure_cursor_visible(&lines);
}
}
_ => {}
}
}
Focus::Dashboard => match m.kind {
crossterm::event::MouseEventKind::ScrollUp => {
self.move_dashboard_selection(-1);
}
crossterm::event::MouseEventKind::ScrollDown => {
self.move_dashboard_selection(1);
}
_ => {}
},
_ => {}
}
}
/// Handle a left-click in the sidebar area.
///
/// Clicks in the sections panel select that section row; clicks in the
/// files panel jump to the Files section and set `pr_detail_files_cursor`.
pub fn handle_sidebar_click(
&mut self,
_col: u16,
row: u16,
sections_rect: ratatui::layout::Rect,
files_rect: ratatui::layout::Rect,
) {
// Sections panel: row 0 is the "SECTIONS" header; rows 1–5 are sections.
if row >= sections_rect.y && row < sections_rect.y.saturating_add(sections_rect.height) {
let relative = (row - sections_rect.y) as usize;
// relative == 0 is the header row — ignore it.
if relative >= 1 {
let sec_idx = relative - 1;
if let Some(&sec) = DetailSection::ALL.get(sec_idx) {
self.pr_detail_selected_section = sec;
self.copy_mode.h_scroll = 0;
}
}
return;
}
// Files panel: row 0 is the "FILES CHANGED" header; rows 1+ are files.
if row >= files_rect.y && row < files_rect.y.saturating_add(files_rect.height) {
let relative = (row - files_rect.y) as usize;
// Subtract sidebar scroll offset and 1 for the header row.
let header_offset = 1;
let scroll_offset = usize::from(self.pr_detail_sidebar_scroll);
if relative >= header_offset {
let file_idx = relative - header_offset + scroll_offset;
self.pr_detail_selected_section = DetailSection::Files;
self.pr_detail_files_cursor = file_idx;
// A sidebar file click is a drill-in gesture — open diff mode.
self.pr_detail_files_show_diff = true;
self.copy_mode.h_scroll = 0;
}
}
}
/// Map a (column, row) mouse position to a logical (row, col) position
/// within the currently rendered detail lines. Returns `None` when the
/// event is outside the right-pane viewport (including sidebar, status bar,
/// or tab bar).
///
/// The column mapping uses display cells, not characters: wide characters
/// (CJK / emoji) will round to the nearest cell boundary.
pub(super) fn mouse_to_content_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
let area = self.pr_detail_right_viewport.get();
if area.width == 0 || area.height == 0 {
return None;
}
if row < area.y || row >= area.y.saturating_add(area.height) {
return None;
}
if col < area.x || col >= area.x.saturating_add(area.width) {
return None;
}
let scroll = self.scroll_for(self.pr_detail_selected_section);
let content_row = scroll.saturating_add(row.saturating_sub(area.y));
let content_col = self.copy_mode.h_scroll.saturating_add(col.saturating_sub(area.x));
Some((usize::from(content_row), usize::from(content_col)))
}
/// Move the dashboard selection by `delta` rows, clamped to the current
/// list length. Used by the mouse wheel handler.
pub(super) fn move_dashboard_selection(&mut self, delta: i32) {
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
return;
};
let len = self.active_list_len();
if len == 0 {
return;
}
let max_idx = len.saturating_sub(1);
let sel = self.selection.entry(repo).or_insert(0);
let new = i64::try_from(*sel).unwrap_or(0).saturating_add(i64::from(delta));
let clamped = new.clamp(0, i64::try_from(max_idx).unwrap_or(i64::MAX));
*sel = usize::try_from(clamped).unwrap_or(0);
}
}