Align TUI mouse interactions
This commit is contained in:
@@ -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<ratatui::layout::Rect>,
|
||||
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<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> {
|
||||
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();
|
||||
|
||||
@@ -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<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 {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user