diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index b53e1d5..7e9cf41 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -165,6 +165,8 @@ pub struct App { pub last_input: Instant, pub last_reload: Instant, pub list_area: ratatui::layout::Rect, + pub list_offset: usize, + pub detail_area: ratatui::layout::Rect, pub tab_areas: Vec, activity_filter: ActivityFilter, issue_state: String, @@ -219,6 +221,8 @@ impl App { last_input: Instant::now(), last_reload: Instant::now(), list_area: ratatui::layout::Rect::default(), + list_offset: 0, + detail_area: ratatui::layout::Rect::default(), tab_areas: Vec::new(), activity_filter: ActivityFilter::All, issue_state: "open".into(), @@ -335,42 +339,53 @@ impl App { self.switch_tab(Tab::ALL[index]).await; return; } - if self.list_area.contains((mouse.column, mouse.row).into()) { - let visible = (self.list_area.height.saturating_sub(2) / 2).max(1) as usize; - let offset = self - .screen - .selected - .saturating_add(1) - .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 opens = self.last_click.is_some_and(|(last, time)| { - last == index && time.elapsed() < Duration::from_millis(500) - }); - self.screen.select(index); - self.last_click = Some((index, now)); - if opens { - self.open_selected().await; - } + if let Some(index) = list_item_at( + self.list_area, + self.list_offset, + mouse.column, + mouse.row, + self.screen.items.len(), + ) { + let now = Instant::now(); + let opens = self.last_click.is_some_and(|(last, time)| { + last == index && time.elapsed() < Duration::from_millis(500) + }); + self.screen.select(index); + self.last_click = Some((index, now)); + if opens { + self.open_selected().await; } } } 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(); - self.move_selection(3); + self.move_selection(1); if was_last && self.screen.has_more { 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; - self.move_selection(-3); + self.move_selection(-1); if was_first && self.screen.page > 1 { 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 { + 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 { if let Some(name) = requested { 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] async fn auto_reload_waits_for_idle_overview_without_an_editor() { let mut app = App::new(Config::default(), None).await.unwrap(); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 18a32a0..74142a6 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -51,9 +51,7 @@ fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) { horizontal: 1, vertical: 1, }); - app.tab_areas = Layout::horizontal([Constraint::Ratio(1, 5); 5]) - .split(inner) - .to_vec(); + app.tab_areas = tab_areas(inner); } 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); app.list_area = panes[0]; + app.detail_area = panes[1]; draw_list(frame, app, panes[0]); draw_detail(frame, app, panes[1]); } +fn tab_areas(area: Rect) -> Vec { + 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 { if width >= 90 { 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() { vec![ListItem::new(Line::styled( "No items", @@ -114,6 +130,7 @@ fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) { let mut state = ListState::default() .with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected)); frame.render_stateful_widget(list, area, &mut state); + app.list_offset = state.offset(); } 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(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())); + } }