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