Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 191 additions & 12 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ use crate::config::{
load_for_scan,
};
use crate::{Finding, Severity};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
Expand Down Expand Up @@ -46,7 +49,16 @@ pub fn run_scan_tui(args: &TuiArgs) -> Result<i32, String> {
.map_err(|e| e.to_string())?;

if event::poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
let Event::Key(key) = event::read().map_err(|e| e.to_string())? else {
let ev = event::read().map_err(|e| e.to_string())?;

if let Event::Mouse(mouse) = ev {
if app.can_handle_finding_mouse() {
app.handle_mouse(mouse);
}
continue;
}

let Event::Key(key) = ev else {
continue;
};

Expand Down Expand Up @@ -129,6 +141,9 @@ struct TuiApp {
/// legacy severity-desc ordering; cycled via `Shift+C` (feature B).
sort_mode: SortMode,
selected: usize,
list_state: ListState,
list_area: Rect,
hover_index: Option<usize>,
Comment on lines 143 to +146
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Invalidate hover_index when the visible list changes.

hover_index is cached as an absolute filtered-list index, so after wheel scrolling or any filter/sort change the row under the pointer can change without a MouseEventKind::Moved. The hover background can then stick to the wrong visible row until the mouse moves again.

Also applies to: 431-439, 1376-1387

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tui.rs` around lines 143 - 146, hover_index is stored as an absolute
index into the filtered list and must be cleared whenever the visible list or
ListState changes; update code paths that mutate scrolling, filtering, sorting
or the list state (e.g., the functions that handle wheel scrolling, filter/sort
updates, and any code that updates list_state or list_area) to set
self.hover_index = None after mutating the list or ListState (and after resizing
the list_area) so the hover background won’t stick to the wrong visible row;
locate usages of hover_index, ListState, selected and list_area and add
hover_index invalidation immediately after those mutations.

show_notices: bool,
show_help: bool,
/// When on, a CNSA 2.0 migration-readiness strip is drawn at the bottom
Expand Down Expand Up @@ -170,6 +185,9 @@ impl TuiApp {
session_min_confidence: 0.0,
sort_mode: SortMode::default(),
selected: 0,
list_state: ListState::default(),
list_area: Rect::default(),
hover_index: None,
show_notices: true,
show_help: false,
show_compliance_panel: false,
Expand All @@ -193,6 +211,8 @@ impl TuiApp {
self.error = None;
self.result = None;
self.selected = 0;
self.list_state = ListState::default();
self.hover_index = None;
self.scanning = true;
self.show_launch = false;
self.show_help = false;
Expand Down Expand Up @@ -378,6 +398,49 @@ impl TuiApp {
}
}

fn can_handle_finding_mouse(&self) -> bool {
!self.show_launch
&& !self.show_help
&& self.severity_picker.is_none()
&& self.action_menu.is_none()
&& self.export_menu.is_none()
&& !self.search_mode
}

fn handle_mouse(&mut self, mouse: MouseEvent) {
match mouse.kind {
kind @ (MouseEventKind::ScrollUp | MouseEventKind::ScrollDown) => {
let last_kind = drain_queued_scroll_events(kind);
match last_kind {
MouseEventKind::ScrollUp => self.move_selection(-1),
MouseEventKind::ScrollDown => self.move_selection(1),
_ => {}
}
Comment on lines +412 to +418
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't drain and collapse the event queue here.

drain_queued_scroll_events() consumes the first queued non-scroll event and drops it on _ => break, and it also compresses an arbitrary wheel burst into a single row move. That means rapid scrolling is capped to one step, and a click/key queued behind the wheel can disappear.

🛠️ Safe fallback
-            kind @ (MouseEventKind::ScrollUp | MouseEventKind::ScrollDown) => {
-                let last_kind = drain_queued_scroll_events(kind);
-                match last_kind {
-                    MouseEventKind::ScrollUp => self.move_selection(-1),
-                    MouseEventKind::ScrollDown => self.move_selection(1),
-                    _ => {}
-                }
-            }
+            MouseEventKind::ScrollUp => self.move_selection(-1),
+            MouseEventKind::ScrollDown => self.move_selection(1),

If you still want coalescing, buffer the first non-scroll event and replay it in the main loop instead of reading past it.

Also applies to: 2922-2934

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tui.rs` around lines 412 - 418, The handler is calling
drain_queued_scroll_events(kind) which consumes and discards the first
non-scroll event and collapses a burst of wheel events into a single move,
causing rapid scrolls to be limited and queued clicks/keys to be lost; change
the logic to not consume past the first non-scroll event—either process every
MouseEventKind::ScrollUp/ScrollDown individually (calling move_selection per
scroll event) or implement safe coalescing by counting consecutive scroll
events, buffering the first non-scroll event returned by
drain_queued_scroll_events (do not drop it) and replaying it into the main event
loop after applying the appropriate number of move_selection(delta) calls;
update the code paths around drain_queued_scroll_events and the match handling
in the main loop to ensure non-scroll events are preserved and replayed rather
than discarded.

}
MouseEventKind::Down(event::MouseButton::Left) => {
if let Some(index) = finding_list_index_at_position(
self.list_area,
self.list_state.offset(),
self.filtered_indices().len(),
mouse.column,
mouse.row,
) {
self.select_filtered_index(index);
}
}
MouseEventKind::Moved => {
self.hover_index = finding_list_index_at_position(
self.list_area,
self.list_state.offset(),
self.filtered_indices().len(),
mouse.column,
mouse.row,
);
}
_ => {}
}
}

fn handle_search_key(&mut self, key: KeyCode) -> ControlFlow {
match key {
KeyCode::Esc => self.search_mode = false,
Expand Down Expand Up @@ -737,6 +800,21 @@ impl TuiApp {
}
}

fn select_filtered_index(&mut self, index: usize) {
let filtered_len = self.filtered_indices().len();
if index >= filtered_len {
return;
}

let previous = self.selected;
self.selected = index;
if self.selected != previous {
self.detail_scroll = 0;
self.source_context_cache = None;
self.normalize_open_focus();
}
}

fn clamp_selection(&mut self) {
let previous = self.selected;
let filtered_len = self.filtered_indices().len();
Expand Down Expand Up @@ -1295,12 +1373,18 @@ impl TuiApp {
.split(body_layout[0]);

let filtered = self.filtered_indices();
let hover = self.hover_index;
let items = if let Some(result) = self.result.as_ref() {
filtered
.iter()
.map(|index| {
.enumerate()
.map(|(display_index, index)| {
let finding = &result.findings[*index];
list_item(finding, self.review_state_for(finding))
let mut item = list_item(finding, self.review_state_for(finding));
if hover == Some(display_index) && self.selected != display_index {
item = item.style(Style::default().bg(Color::Rgb(40, 40, 50)));
}
item
})
.collect::<Vec<_>>()
} else {
Expand Down Expand Up @@ -1331,13 +1415,16 @@ impl TuiApp {
.bg(DETAIL_BG)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
.highlight_symbol(">> ")
.scroll_padding(0);

let mut state = ListState::default();
if !filtered.is_empty() {
state.select(Some(self.selected));
self.list_state.select(Some(self.selected));
} else {
self.list_state.select(None);
}
frame.render_stateful_widget(list, layout[0], &mut state);
self.list_area = layout[0];
frame.render_stateful_widget(list, layout[0], &mut self.list_state);

let detail = Paragraph::new(self.detail_text())
.block(panel_block(Some("Detail"), DETAIL_BG))
Expand Down Expand Up @@ -1687,6 +1774,9 @@ impl TuiApp {
Line::from("e export findings (CBOM / JSON / SARIF)"),
Line::from("PageUp/Down scroll detail pane"),
Line::from("[/] scroll notices pane"),
Line::from("mouse wheel move between findings"),
Line::from("mouse click select a finding"),
Line::from("Shift-drag terminal-native text selection"),
Line::from("r rescan"),
Line::from("q quit"),
Line::from("? or Esc close this help"),
Expand Down Expand Up @@ -2633,7 +2723,7 @@ impl TerminalSession {
fn enter() -> Result<Self, String> {
enable_raw_mode().map_err(|e| e.to_string())?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(|e| e.to_string())?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture).map_err(|e| e.to_string())?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).map_err(|e| e.to_string())?;
Ok(Self {
Expand All @@ -2648,7 +2738,12 @@ impl TerminalSession {
}

disable_raw_mode().map_err(|e| e.to_string())?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen).map_err(|e| e.to_string())?;
execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.map_err(|e| e.to_string())?;
self.terminal.show_cursor().map_err(|e| e.to_string())?;
self.active = false;
Ok(())
Expand All @@ -2660,7 +2755,12 @@ impl TerminalSession {
}

enable_raw_mode().map_err(|e| e.to_string())?;
execute!(self.terminal.backend_mut(), EnterAlternateScreen).map_err(|e| e.to_string())?;
execute!(
self.terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)
.map_err(|e| e.to_string())?;
self.terminal.clear().map_err(|e| e.to_string())?;
self.active = true;
Ok(())
Expand All @@ -2670,7 +2770,11 @@ impl TerminalSession {
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = self.terminal.show_cursor();
}
}
Expand Down Expand Up @@ -2815,6 +2919,50 @@ fn adjust_scroll(current: u16, delta: i32) -> u16 {
}
}

fn drain_queued_scroll_events(first_kind: MouseEventKind) -> MouseEventKind {
let mut last_kind = first_kind;
while event::poll(Duration::ZERO).unwrap_or(false) {
match event::read() {
Ok(Event::Mouse(MouseEvent {
kind: kind @ (MouseEventKind::ScrollUp | MouseEventKind::ScrollDown),
..
})) => last_kind = kind,
_ => break,
}
}
last_kind
}

fn finding_list_index_at_position(
list_area: Rect,
list_offset: usize,
item_count: usize,
column: u16,
row: u16,
) -> Option<usize> {
let content = finding_list_content_area(list_area);
if column < content.x
|| column >= content.x.saturating_add(content.width)
|| row < content.y
|| row >= content.y.saturating_add(content.height)
{
return None;
}

let row_in_content = row.saturating_sub(content.y);
let index = list_offset + usize::from(row_in_content / FINDING_LIST_ITEM_HEIGHT);
(index < item_count).then_some(index)
}

fn finding_list_content_area(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(2),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
Expand All @@ -2840,6 +2988,35 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
mod tests {
use super::*;

#[test]
fn finding_list_index_at_position_maps_two_line_rows() {
let area = Rect {
x: 10,
y: 5,
width: 40,
height: 12,
};

assert_eq!(finding_list_index_at_position(area, 0, 10, 11, 7), Some(0));
assert_eq!(finding_list_index_at_position(area, 0, 10, 11, 8), Some(0));
assert_eq!(finding_list_index_at_position(area, 0, 10, 11, 9), Some(1));
assert_eq!(finding_list_index_at_position(area, 3, 10, 11, 9), Some(4));
}

#[test]
fn finding_list_index_at_position_rejects_outside_content() {
let area = Rect {
x: 10,
y: 5,
width: 40,
height: 12,
};

assert_eq!(finding_list_index_at_position(area, 0, 10, 10, 7), None);
assert_eq!(finding_list_index_at_position(area, 0, 10, 11, 6), None);
assert_eq!(finding_list_index_at_position(area, 0, 1, 11, 9), None);
}

#[test]
fn resolve_finding_path_joins_relative_file_under_directory_root() {
let resolved = resolve_finding_path("/tmp/project", "src/main.rs");
Expand Down Expand Up @@ -5348,6 +5525,8 @@ const LOADING_SKELETON_WIDTH: usize = 28;
const LOADING_SHIMMER_GAP: usize = 8;
const LOADING_SHIMMER_CYCLE: usize = LOADING_SKELETON_WIDTH + LOADING_SHIMMER_GAP * 2;
const LOADING_SHIMMER_BAND: f32 = 7.0;
// `list_item` renders exactly two lines: title/metadata and file:line.
const FINDING_LIST_ITEM_HEIGHT: u16 = 2;
const APP_BG: Color = Color::Rgb(20, 17, 14);
const HEADER_BG: Color = Color::Rgb(44, 37, 28);
const PANEL_BG: Color = Color::Rgb(27, 23, 18);
Expand Down