diff --git a/src/tui.rs b/src/tui.rs index 739550d..01a0079 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,7 +5,10 @@ use std::{ }; use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -34,8 +37,9 @@ pub(crate) fn tui_loop( ) -> io::Result<()> { enable_raw_mode()?; let mut out = io::stdout(); - execute!(out, EnterAlternateScreen)?; + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; let mut terminal = Terminal::new(CrosstermBackend::new(out))?; + let mut list_hits = ListHits::default(); let result = loop { if let Some(result) = update_check.as_ref().map(Receiver::try_recv) { match result { @@ -47,7 +51,7 @@ pub(crate) fn tui_loop( Err(TryRecvError::Empty) => {} } } - terminal.draw(|f| draw(f, app))?; + terminal.draw(|f| list_hits = draw(f, app))?; let animate = has_working_entry(app); if (animate || update_check.is_some()) && !event::poll(Duration::from_millis(125))? { if animate { @@ -55,53 +59,63 @@ pub(crate) fn tui_loop( } continue; } - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => match handle_key(app, key) { - Action::Continue => {} - Action::Quit => break Ok(()), - action @ (Action::Open | Action::OpenTemplate) => { - // leave the TUI while the action runs: herdr CLI output goes to - // the normal screen instead of corrupting the alternate screen - cleanup_terminal(&mut terminal)?; - let outcome = app.open_selected(matches!(action, Action::OpenTemplate)); - if let Err(e) = outcome { - eprintln!("{e}"); - wait_for_key(); - } - if !persist { - return Ok(()); - } - app.refresh(); - enable_raw_mode()?; - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - terminal.clear()?; + let action = match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => handle_key(app, key), + Event::Mouse(mouse) => handle_mouse(app, mouse, &list_hits), + _ => Action::Continue, + }; + match action { + Action::Continue => {} + Action::Quit => break Ok(()), + action @ (Action::Open | Action::OpenTemplate) => { + // leave the TUI while the action runs: herdr CLI output goes to + // the normal screen instead of corrupting the alternate screen + cleanup_terminal(&mut terminal)?; + let outcome = app.open_selected(matches!(action, Action::OpenTemplate)); + if let Err(e) = outcome { + eprintln!("{e}"); + wait_for_key(); + } + if !persist { + return Ok(()); } - Action::Update => { - cleanup_terminal(&mut terminal)?; - if let Some(version) = app.update_available.clone() { - if confirm_update(&version)? { - match crate::update::install(&version) { - Ok(()) => eprintln!("Updated Herdr Navigator to v{version}."), - Err(error) => eprintln!("Update failed: {error}"), - } - wait_for_key(); - return Ok(()); + app.refresh(); + enable_raw_mode()?; + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + )?; + terminal.clear()?; + } + Action::Update => { + cleanup_terminal(&mut terminal)?; + if let Some(version) = app.update_available.clone() { + if confirm_update(&version)? { + match crate::update::install(&version) { + Ok(()) => eprintln!("Updated Herdr Navigator to v{version}."), + Err(error) => eprintln!("Update failed: {error}"), } + wait_for_key(); + return Ok(()); } - enable_raw_mode()?; - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - terminal.clear()?; } - Action::CloseWorkspace => { - if let Err(e) = app.close_selected_workspace() { - crate::herdr::notify_error( - &format!("Close failed: {e}"), - &app.config.notifications, - ); - } + enable_raw_mode()?; + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + )?; + terminal.clear()?; + } + Action::CloseWorkspace => { + if let Err(e) = app.close_selected_workspace() { + crate::herdr::notify_error( + &format!("Close failed: {e}"), + &app.config.notifications, + ); } - }, - _ => {} + } } }; cleanup_terminal(&mut terminal)?; @@ -110,7 +124,11 @@ pub(crate) fn tui_loop( fn cleanup_terminal(terminal: &mut Terminal>) -> io::Result<()> { disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + )?; terminal.show_cursor()?; Ok(()) } @@ -140,6 +158,38 @@ enum Action { CloseWorkspace, } +#[derive(Default)] +struct ListHits { + area: Rect, + rows: Vec<(std::ops::Range, usize)>, +} + +fn handle_mouse(app: &mut App, mouse: MouseEvent, hits: &ListHits) -> Action { + if app.input_mode == InputMode::Help || !hits.area.contains((mouse.column, mouse.row).into()) { + return Action::Continue; + } + + match mouse.kind { + MouseEventKind::ScrollUp => app.prev(), + MouseEventKind::ScrollDown => app.next(), + MouseEventKind::Down(MouseButton::Left) => { + let Some((_, row)) = hits + .rows + .iter() + .find(|(range, _)| range.contains(&mouse.row)) + else { + return Action::Continue; + }; + if app.selected == *row { + return Action::Open; + } + app.selected = *row; + } + _ => {} + } + Action::Continue +} + fn handle_key(app: &mut App, key: KeyEvent) -> Action { let command = keybindings(app) .into_iter() @@ -258,7 +308,7 @@ fn execute_command(app: &mut App, command: Command, key: KeyEvent) -> Action { } } -fn draw(f: &mut Frame, app: &App) { +fn draw(f: &mut Frame, app: &App) -> ListHits { let area = f.area(); f.render_widget(Clear, area); let mut outer = Block::default() @@ -327,7 +377,7 @@ fn draw(f: &mut Frame, app: &App) { .split(rows[1]) }; - draw_list(f, app, body[0]); + let list_hits = draw_list(f, app, body[0]); if app.preview { draw_preview(f, app, body[1]); } @@ -336,6 +386,7 @@ fn draw(f: &mut Frame, app: &App) { if app.input_mode == InputMode::Help { draw_keybindings_help(f, app, area); } + list_hits } fn draw_key_hints(f: &mut Frame, app: &App, area: Rect) { @@ -595,10 +646,11 @@ fn entry_branch(app: &App, entry: &Entry, group_end: bool) -> (&'static str, Col } } -fn draw_list(f: &mut Frame, app: &App, area: Rect) { +fn draw_list(f: &mut Frame, app: &App, area: Rect) -> ListHits { let show_scores = !app.query.trim().is_empty(); let row_width = area.width.saturating_sub(3) as usize; let mut items = Vec::new(); + let mut item_entries = Vec::new(); let mut selected_row = None; for (row, idx) in app.filtered.iter().enumerate() { let e = &app.entries[*idx]; @@ -612,6 +664,7 @@ fn draw_list(f: &mut Frame, app: &App, area: Rect) { format!(" ▾ {} ", e.source_name()), Style::default().fg(color).add_modifier(Modifier::BOLD), )))); + item_entries.push(None); } if row == app.selected { @@ -759,7 +812,9 @@ fn draw_list(f: &mut Frame, app: &App, area: Rect) { } items.push(ListItem::new(Line::from(spans))); } + item_entries.push(Some(row)); } + let item_heights: Vec = items.iter().map(|item| item.height() as u16).collect(); let mut state = ListState::default(); state.select(selected_row); let list = List::new(items) @@ -771,6 +826,23 @@ fn draw_list(f: &mut Frame, app: &App, area: Rect) { ) .highlight_symbol("→ "); f.render_stateful_widget(list, area, &mut state); + + let mut rows = Vec::new(); + let mut y = area.y; + for (entry, height) in item_entries + .into_iter() + .zip(item_heights) + .skip(state.offset()) + { + if y.saturating_add(height) > area.bottom() { + break; + } + if let Some(entry) = entry { + rows.push((y..y + height, entry)); + } + y += height; + } + ListHits { area, rows } } fn draw_preview(f: &mut Frame, app: &App, area: Rect) { @@ -973,7 +1045,11 @@ mod tests { let backend = TestBackend::new(70, 8); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); assert!(buffer_text(&terminal).contains("↑ v0.3.2 available · F5 update")); assert!(matches!( @@ -1012,7 +1088,11 @@ mod tests { let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw_list(f, &app, f.area())).unwrap(); + terminal + .draw(|f| { + draw_list(f, &app, f.area()); + }) + .unwrap(); let text = buffer_text(&terminal); let buffer = terminal.backend().buffer(); @@ -1102,7 +1182,11 @@ mod tests { let backend = TestBackend::new(90, 10); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw_list(f, &app, f.area())).unwrap(); + terminal + .draw(|f| { + draw_list(f, &app, f.area()); + }) + .unwrap(); let text = buffer_text(&terminal); let workspace_line = text.lines().find(|line| line.contains("● demo")).unwrap(); let agent_line = text @@ -1130,7 +1214,11 @@ mod tests { let backend = TestBackend::new(40, 12); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw_list(f, &app, f.area())).unwrap(); + terminal + .draw(|f| { + draw_list(f, &app, f.area()); + }) + .unwrap(); let text = buffer_text(&terminal); assert!(text.contains(" ▾ agent ")); @@ -1261,7 +1349,11 @@ mod tests { let backend = TestBackend::new(80, 30); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let text = buffer_text(&terminal); assert!(text.contains(" Keybindings ")); assert!(text.contains("toggle preview")); @@ -1311,7 +1403,11 @@ mod tests { app.config.picker.vim_mode = true; let backend = TestBackend::new(110, 20); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| draw(f, &app)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); let text = buffer_text(&terminal); assert!(text.contains("j/k up/down")); @@ -1320,6 +1416,139 @@ mod tests { assert!(!text.contains("k move up")); } + #[test] + fn rendered_mouse_hits_follow_grouped_detailed_rows_after_scroll() { + let mut app = App::new(Config::default(), Theme::load(false)); + app.config.picker.detailed_rows = true; + app.entries = vec![ + entry(Source::Zoxide, "/one"), + entry(Source::Zoxide, "/two"), + entry(Source::Root, "/three"), + ]; + app.filtered = vec![0, 1, 2]; + app.selected = 2; + + let backend = TestBackend::new(50, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let mut hits = ListHits::default(); + terminal + .draw(|f| hits = draw_list(f, &app, f.area())) + .unwrap(); + let lines: Vec<_> = buffer_text(&terminal).lines().map(str::to_owned).collect(); + let detail_row = lines + .iter() + .position(|line| line.contains("/two")) + .expect("second detailed row should remain visible after scrolling") + as u16; + + assert!(!lines.iter().any(|line| line.contains("/one"))); + assert!(matches!( + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: detail_row, + modifiers: KeyModifiers::NONE, + }, + &hits, + ), + Action::Continue + )); + assert_eq!(app.selected, 1); + } + + #[test] + fn mouse_scroll_moves_selection_inside_results() { + let mut app = App::new(Config::default(), Theme::load(false)); + app.entries = vec![ + entry(Source::Workspace, "one"), + entry(Source::Workspace, "two"), + ]; + app.filtered = vec![0, 1]; + let hits = ListHits { + area: Rect::new(0, 3, 40, 10), + rows: vec![(4..5, 0), (5..6, 1)], + }; + + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 1, + row: 4, + modifiers: KeyModifiers::NONE, + }, + &hits, + ); + assert_eq!(app.selected, 1); + + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 1, + row: 4, + modifiers: KeyModifiers::NONE, + }, + &hits, + ); + assert_eq!(app.selected, 0); + } + + #[test] + fn mouse_click_selects_then_opens_result() { + let mut app = App::new(Config::default(), Theme::load(false)); + app.entries = vec![ + entry(Source::Workspace, "one"), + entry(Source::Workspace, "two"), + ]; + app.filtered = vec![0, 1]; + let hits = ListHits { + area: Rect::new(0, 3, 40, 10), + rows: vec![(4..5, 0), (5..6, 1)], + }; + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 5, + modifiers: KeyModifiers::NONE, + }; + + assert!(matches!( + handle_mouse(&mut app, click, &hits), + Action::Continue + )); + assert_eq!(app.selected, 1); + assert!(matches!(handle_mouse(&mut app, click, &hits), Action::Open)); + } + + #[test] + fn mouse_ignores_input_outside_results() { + let mut app = App::new(Config::default(), Theme::load(false)); + app.entries = vec![ + entry(Source::Workspace, "one"), + entry(Source::Workspace, "two"), + ]; + app.filtered = vec![0, 1]; + let hits = ListHits { + area: Rect::new(0, 3, 40, 10), + rows: vec![(4..5, 0), (5..6, 1)], + }; + + handle_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 50, + row: 4, + modifiers: KeyModifiers::NONE, + }, + &hits, + ); + assert_eq!(app.selected, 0); + } + #[test] fn input_modes_transition_exclusively() { let mut app = App::new(Config::default(), Theme::load(false));