Align TUI mouse interactions

This commit is contained in:
Georg Bauer
2026-08-03 22:13:10 +02:00
parent c67d274a95
commit 95526f8956
2 changed files with 95 additions and 26 deletions

View File

@@ -165,6 +165,8 @@ pub struct App {
pub last_input: Instant, pub last_input: Instant,
pub last_reload: Instant, pub last_reload: Instant,
pub list_area: ratatui::layout::Rect, pub list_area: ratatui::layout::Rect,
pub list_offset: usize,
pub detail_area: ratatui::layout::Rect,
pub tab_areas: Vec<ratatui::layout::Rect>, pub tab_areas: Vec<ratatui::layout::Rect>,
activity_filter: ActivityFilter, activity_filter: ActivityFilter,
issue_state: String, issue_state: String,
@@ -219,6 +221,8 @@ impl App {
last_input: Instant::now(), last_input: Instant::now(),
last_reload: Instant::now(), last_reload: Instant::now(),
list_area: ratatui::layout::Rect::default(), list_area: ratatui::layout::Rect::default(),
list_offset: 0,
detail_area: ratatui::layout::Rect::default(),
tab_areas: Vec::new(), tab_areas: Vec::new(),
activity_filter: ActivityFilter::All, activity_filter: ActivityFilter::All,
issue_state: "open".into(), issue_state: "open".into(),
@@ -335,15 +339,13 @@ impl App {
self.switch_tab(Tab::ALL[index]).await; self.switch_tab(Tab::ALL[index]).await;
return; return;
} }
if self.list_area.contains((mouse.column, mouse.row).into()) { if let Some(index) = list_item_at(
let visible = (self.list_area.height.saturating_sub(2) / 2).max(1) as usize; self.list_area,
let offset = self self.list_offset,
.screen mouse.column,
.selected mouse.row,
.saturating_add(1) self.screen.items.len(),
.saturating_sub(visible); ) {
let index = offset + mouse.row.saturating_sub(self.list_area.y + 1) as usize / 2;
if index < self.screen.items.len() {
let now = Instant::now(); let now = Instant::now();
let opens = self.last_click.is_some_and(|(last, time)| { let opens = self.last_click.is_some_and(|(last, time)| {
last == index && time.elapsed() < Duration::from_millis(500) last == index && time.elapsed() < Duration::from_millis(500)
@@ -355,22 +357,35 @@ impl App {
} }
} }
} }
}
match mouse.kind { match mouse.kind {
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown
if self.list_area.contains((mouse.column, mouse.row).into()) =>
{
let was_last = self.screen.selected + 1 == self.screen.items.len(); let was_last = self.screen.selected + 1 == self.screen.items.len();
self.move_selection(3); self.move_selection(1);
if was_last && self.screen.has_more { if was_last && self.screen.has_more {
self.change_page(1).await; self.change_page(1).await;
} }
} }
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp
if self.list_area.contains((mouse.column, mouse.row).into()) =>
{
let was_first = self.screen.selected == 0; let was_first = self.screen.selected == 0;
self.move_selection(-3); self.move_selection(-1);
if was_first && self.screen.page > 1 { if was_first && self.screen.page > 1 {
self.change_page(-1).await; self.change_page(-1).await;
} }
} }
MouseEventKind::ScrollDown
if self.detail_area.contains((mouse.column, mouse.row).into()) =>
{
self.screen.detail_scroll = self.screen.detail_scroll.saturating_add(1);
}
MouseEventKind::ScrollUp
if self.detail_area.contains((mouse.column, mouse.row).into()) =>
{
self.screen.detail_scroll = self.screen.detail_scroll.saturating_sub(1);
}
_ => {} _ => {}
} }
} }
@@ -1618,6 +1633,23 @@ impl App {
} }
} }
fn list_item_at(
area: ratatui::layout::Rect,
offset: usize,
column: u16,
row: u16,
item_count: usize,
) -> Option<usize> {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
inner
.contains((column, row).into())
.then(|| offset + row.saturating_sub(inner.y) as usize / 2)
.filter(|index| *index < item_count)
}
fn initial_selection(config: &Config, requested: Option<&str>) -> Result<Selection, String> { fn initial_selection(config: &Config, requested: Option<&str>) -> Result<Selection, String> {
if let Some(name) = requested { if let Some(name) = requested {
return config.select(Some(name), None); return config.select(Some(name), None);
@@ -1845,6 +1877,16 @@ mod tests {
); );
} }
#[test]
fn list_clicks_follow_the_rendered_offset_and_rows() {
let area = ratatui::layout::Rect::new(10, 4, 30, 10);
assert_eq!(list_item_at(area, 3, 11, 5, 10), Some(3));
assert_eq!(list_item_at(area, 3, 11, 6, 10), Some(3));
assert_eq!(list_item_at(area, 3, 11, 7, 10), Some(4));
assert_eq!(list_item_at(area, 3, 10, 5, 10), None);
assert_eq!(list_item_at(area, 9, 11, 7, 10), None);
}
#[tokio::test] #[tokio::test]
async fn auto_reload_waits_for_idle_overview_without_an_editor() { async fn auto_reload_waits_for_idle_overview_without_an_editor() {
let mut app = App::new(Config::default(), None).await.unwrap(); let mut app = App::new(Config::default(), None).await.unwrap();

View File

@@ -51,9 +51,7 @@ fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
horizontal: 1, horizontal: 1,
vertical: 1, vertical: 1,
}); });
app.tab_areas = Layout::horizontal([Constraint::Ratio(1, 5); 5]) app.tab_areas = tab_areas(inner);
.split(inner)
.to_vec();
} }
fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) { fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
@@ -67,10 +65,28 @@ fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
}) })
.split(area); .split(area);
app.list_area = panes[0]; app.list_area = panes[0];
app.detail_area = panes[1];
draw_list(frame, app, panes[0]); draw_list(frame, app, panes[0]);
draw_detail(frame, app, panes[1]); draw_detail(frame, app, panes[1]);
} }
fn tab_areas(area: Rect) -> Vec<Rect> {
let mut x = area.x;
Tab::ALL
.iter()
.filter_map(|tab| {
let remaining = area.right().saturating_sub(x);
if remaining == 0 {
return None;
}
let width = (tab.title().len() as u16 + 2).min(remaining);
let tab_area = Rect::new(x, area.y, width, area.height.min(1));
x = x.saturating_add(width).saturating_add(1);
Some(tab_area)
})
.collect()
}
fn content_direction(width: u16) -> Direction { fn content_direction(width: u16) -> Direction {
if width >= 90 { if width >= 90 {
Direction::Horizontal Direction::Horizontal
@@ -79,7 +95,7 @@ fn content_direction(width: u16) -> Direction {
} }
} }
fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) { fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let items = if app.screen.items.is_empty() { let items = if app.screen.items.is_empty() {
vec![ListItem::new(Line::styled( vec![ListItem::new(Line::styled(
"No items", "No items",
@@ -114,6 +130,7 @@ fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
let mut state = ListState::default() let mut state = ListState::default()
.with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected)); .with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected));
frame.render_stateful_widget(list, area, &mut state); frame.render_stateful_widget(list, area, &mut state);
app.list_offset = state.offset();
} }
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
@@ -268,4 +285,14 @@ mod tests {
assert_eq!(content_direction(80), Direction::Vertical); assert_eq!(content_direction(80), Direction::Vertical);
assert_eq!(content_direction(120), Direction::Horizontal); assert_eq!(content_direction(120), Direction::Horizontal);
} }
#[test]
fn tab_hit_areas_follow_the_rendered_titles() {
let areas = tab_areas(Rect::new(1, 1, 100, 1));
assert_eq!(areas[0], Rect::new(1, 1, 8, 1));
assert_eq!(areas[1], Rect::new(10, 1, 10, 1));
assert_eq!(areas[4], Rect::new(39, 1, 14, 1));
assert!(!areas[0].contains((9, 1).into()));
assert!(!areas[4].contains((80, 1).into()));
}
} }