From dd421410dbaff0e77aca8c5a4219ff90a2d5555e Mon Sep 17 00:00:00 2001 From: Levi Zim Date: Tue, 31 Mar 2026 21:02:14 +0700 Subject: [PATCH 1/2] tui: initial support for mouse --- config.toml | 6 + crates/tracexec-core/src/cli/args.rs | 13 + crates/tracexec-core/src/cli/config.rs | 2 + crates/tracexec-core/src/cli/keys.rs | 5 + crates/tracexec-core/src/cli/tui_theme.rs | 4 + crates/tracexec-core/src/event.rs | 6 +- crates/tracexec-tui/src/action.rs | 2 + crates/tracexec-tui/src/app.rs | 241 +++++++++++++++ crates/tracexec-tui/src/app/ui.rs | 229 ++++++++++---- crates/tracexec-tui/src/breakpoint_manager.rs | 116 ++++--- crates/tracexec-tui/src/copy_popup.rs | 10 +- crates/tracexec-tui/src/details_popup.rs | 91 +++++- crates/tracexec-tui/src/event_list/scroll.rs | 39 +++ crates/tracexec-tui/src/event_list/ui.rs | 96 +++--- crates/tracexec-tui/src/help.rs | 89 +++++- crates/tracexec-tui/src/hit_manager.rs | 128 +++++--- crates/tracexec-tui/src/lib.rs | 30 +- crates/tracexec-tui/src/mouse.rs | 134 ++++++++ crates/tracexec-tui/src/pseudo_term.rs | 290 ++++++++++++++++++ crates/tracexec-tui/src/query.rs | 65 ++-- ..._tui__app__tests__snapshot_app_render.snap | 20 +- ...snapshot_app_render_with_custom_theme.snap | 20 +- ...tui__help__tests__snapshot_help_popup.snap | 30 +- ...t_hit_manager_editing_default_command.snap | 4 +- ...apshot_hit_manager_no_default_command.snap | 4 +- ...manager_with_hits_and_default_command.snap | 5 +- crates/tracexec-tui/src/theme.rs | 12 +- src/bpf.rs | 2 +- src/main.rs | 2 +- themes/amber.toml | 2 + themes/high-contrast.toml | 2 + themes/nord.toml | 2 + 32 files changed, 1410 insertions(+), 291 deletions(-) create mode 100644 crates/tracexec-tui/src/mouse.rs diff --git a/config.toml b/config.toml index cefd0a30..9bfed97e 100644 --- a/config.toml +++ b/config.toml @@ -78,6 +78,12 @@ # Number of scrollback lines to keep in the pseudo terminal # scrollback_lines = 1000 +# Whether to switch active pane on mouse hover (default: false) +# focus_on_hover = false + +# Enable or disable mouse support in the TUI (default: true) +# mouse = true + # Load a TUI theme from a file. # Absolute paths are used as-is. # Relative paths are resolved relative to the theme directories, in the following order: diff --git a/crates/tracexec-core/src/cli/args.rs b/crates/tracexec-core/src/cli/args.rs index 005f9307..454d257d 100644 --- a/crates/tracexec-core/src/cli/args.rs +++ b/crates/tracexec-core/src/cli/args.rs @@ -513,6 +513,17 @@ pub struct TuiModeArgs { requires = "tty" )] pub scrollback_lines: Option, + #[clap( + long, + help = "Focus pane when the mouse hovers over it instead of requiring a click", + requires = "tty" + )] + pub focus_on_hover: Option, + #[clap( + long, + help = "Enable or disable mouse support in the TUI (default: true)" + )] + pub mouse: Option, #[clap( long = "theme", help = "Path to a theme file to use for the TUI.", @@ -576,6 +587,8 @@ impl TuiModeArgs { self.frame_rate = self.frame_rate.or(config.frame_rate); self.max_events = self.max_events.or(config.max_events); self.scrollback_lines = self.scrollback_lines.or(config.scrollback_lines); + self.focus_on_hover = self.focus_on_hover.or(config.focus_on_hover); + self.mouse = self.mouse.or(config.mouse); if self.theme_file.is_none() && let Some(path) = config.theme_file { diff --git a/crates/tracexec-core/src/cli/config.rs b/crates/tracexec-core/src/cli/config.rs index 3123db39..1318e486 100644 --- a/crates/tracexec-core/src/cli/config.rs +++ b/crates/tracexec-core/src/cli/config.rs @@ -159,6 +159,8 @@ pub struct TuiModeConfig { pub frame_rate: Option, pub max_events: Option, pub scrollback_lines: Option, + pub focus_on_hover: Option, + pub mouse: Option, #[serde(rename = "theme-file")] pub theme_file: Option, #[serde(default)] diff --git a/crates/tracexec-core/src/cli/keys.rs b/crates/tracexec-core/src/cli/keys.rs index 80671b51..eac8dce2 100644 --- a/crates/tracexec-core/src/cli/keys.rs +++ b/crates/tracexec-core/src/cli/keys.rs @@ -120,6 +120,11 @@ impl KeyList { pub fn first(&self) -> Option<&KeyBinding> { self.0.first() } + + /// Returns the first key binding as a KeyEvent, for use in mouse click simulation. + pub fn first_key_event(&self) -> Option { + self.first().map(|b| KeyEvent::new(b.code, b.modifiers)) + } } impl From> for KeyList { diff --git a/crates/tracexec-core/src/cli/tui_theme.rs b/crates/tracexec-core/src/cli/tui_theme.rs index a83c9128..1f29e0ec 100644 --- a/crates/tracexec-core/src/cli/tui_theme.rs +++ b/crates/tracexec-core/src/cli/tui_theme.rs @@ -22,7 +22,9 @@ pub struct ThemeSpec { pub inline_timestamp: Option, pub cli_flag: Option, pub help_key: Option, + pub help_key_hover: Option, pub help_desc: Option, + pub help_desc_hover: Option, pub fancy_help_desc: Option, pub pid_success: Option, pub pid_failure: Option, @@ -152,7 +154,9 @@ impl ThemeSpec { inline_timestamp, cli_flag, help_key, + help_key_hover, help_desc, + help_desc_hover, fancy_help_desc, pid_success, pid_failure, diff --git a/crates/tracexec-core/src/event.rs b/crates/tracexec-core/src/event.rs index 4a4bc1c9..85d57afc 100644 --- a/crates/tracexec-core/src/event.rs +++ b/crates/tracexec-core/src/event.rs @@ -13,7 +13,10 @@ use chrono::{ Local, }; use clap::ValueEnum; -use crossterm::event::KeyEvent; +use crossterm::event::{ + KeyEvent, + MouseEvent, +}; use enumflags2::BitFlags; use filterable_enum::FilterableEnum; use nix::{ @@ -55,6 +58,7 @@ pub use parent::*; pub enum Event { ShouldQuit, Key(KeyEvent), + Mouse(MouseEvent), Tracer(TracerMessage), Render, Resize { width: u16, height: u16 }, diff --git a/crates/tracexec-tui/src/action.rs b/crates/tracexec-tui/src/action.rs index f1eced05..debc88f0 100644 --- a/crates/tracexec-tui/src/action.rs +++ b/crates/tracexec-tui/src/action.rs @@ -69,6 +69,8 @@ pub enum Action { PrevMatch, // Terminal HandleTerminalKeyPress(KeyEvent), + // Help bar mouse click + HandleHelpBarClick(KeyEvent), // Breakpoint ShowBreakpointManager, CloseBreakpointManager, diff --git a/crates/tracexec-tui/src/app.rs b/crates/tracexec-tui/src/app.rs index c1e9b7b3..9b86a4e9 100644 --- a/crates/tracexec-tui/src/app.rs +++ b/crates/tracexec-tui/src/app.rs @@ -25,6 +25,10 @@ use std::{ }; use arboard::Clipboard; +use crossterm::event::{ + MouseButton, + MouseEventKind, +}; use nix::{ errno::Errno, sys::signal::Signal, @@ -92,6 +96,12 @@ use crate::{ }, error_popup::InfoPopupState, event::TracerEventDetailsTuiExt, + mouse::{ + ClickTracker, + HelpBarEntry, + HoverState, + LayoutAreas, + }, query::QueryKind, }; @@ -118,6 +128,20 @@ pub struct App { breakpoint_manager: Option, hit_manager_state: Option, exit_handling: ExitHandling, + /// Layout areas computed during rendering, used for mouse hit testing. + pub layout_areas: LayoutAreas, + /// Clickable help bar entries computed during rendering. + pub help_bar_entries: Vec, + /// Tracks click state for double-click detection. + pub click_tracker: ClickTracker, + /// Tracks the current mouse position for hover effects. + pub hover_state: HoverState, + /// Whether hovering over a pane should focus it. + pub focus_on_hover: bool, + /// Whether the user is currently dragging the pane divider. + pub dragging_divider: bool, + /// Whether mouse support is enabled. + pub mouse_enabled: bool, } pub struct PTracer { @@ -210,6 +234,13 @@ impl App { ExitHandling::Wait } }, + layout_areas: LayoutAreas::default(), + help_bar_entries: Vec::new(), + click_tracker: ClickTracker::default(), + hover_state: HoverState::default(), + focus_on_hover: tui_args.focus_on_hover.unwrap_or(false), + dragging_divider: false, + mouse_enabled: tui_args.mouse.unwrap_or(true), }) } @@ -504,6 +535,9 @@ impl App { Event::Render => { action_tx.send(Action::Render); } + Event::Mouse(me) => { + self.handle_mouse_event(me, &action_tx).await; + } Event::Resize { width, height } => { action_tx.send(Action::Resize(Size { width, height })); // action_tx.send(Action::Render)?; @@ -581,6 +615,10 @@ impl App { term.handle_key_event(&ke, &self.key_bindings).await; } } + Action::HandleHelpBarClick(ke) => { + // Re-inject as a regular key event through the event channel + tui.event_tx.send(Event::Key(ke)).unwrap(); + } Action::Resize(_size) => { self.should_handle_internal_resize = true; } @@ -738,6 +776,209 @@ impl App { } } + async fn handle_mouse_event( + &mut self, + me: crossterm::event::MouseEvent, + action_tx: &local_chan::LocalUnboundedSender, + ) { + if !self.mouse_enabled { + return; + } + use crate::mouse::position_in_rect; + + let col = me.column; + let row = me.row; + + // Update hover state for hover effects + if matches!(me.kind, MouseEventKind::Moved) { + self.hover_state.col = col; + self.hover_state.row = row; + } + + // Handle divider dragging between event list and terminal pane + if self.term.is_some() { + if self.dragging_divider { + match me.kind { + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => { + let rest = &self.layout_areas.rest_area; + let new_pct = if self.layout == AppLayout::Horizontal { + if rest.width > 0 { + ((col.saturating_sub(rest.x)) as u32 * 100 / rest.width as u32) as u16 + } else { + self.split_percentage + } + } else if rest.height > 0 { + ((row.saturating_sub(rest.y)) as u32 * 100 / rest.height as u32) as u16 + } else { + self.split_percentage + }; + self.split_percentage = new_pct.clamp(10, 90); + self.should_handle_internal_resize = true; + return; + } + MouseEventKind::Up(MouseButton::Left) => { + self.dragging_divider = false; + return; + } + _ => { + self.dragging_divider = false; + } + } + } + + // Detect drag start on the divider border + if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) { + let on_divider = if self.layout == AppLayout::Horizontal { + if let Some(term_outer) = self.layout_areas.terminal_outer { + // The divider is the left border of terminal pane (= right border of event list) + col == term_outer.x + || col == self.layout_areas.event_list_outer.right().saturating_sub(1) + } else { + false + } + } else if let Some(term_outer) = self.layout_areas.terminal_outer { + // The divider is the top border of terminal pane (= bottom border of event list) + row == term_outer.y + || row + == self + .layout_areas + .event_list_outer + .bottom() + .saturating_sub(1) + } else { + false + }; + if on_divider { + self.dragging_divider = true; + return; + } + } + } + + // Check help bar (footer) clicks + if position_in_rect(col, row, &self.layout_areas.footer) { + if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) { + for entry in &self.help_bar_entries { + if position_in_rect(col, row, &entry.area) { + action_tx.send(Action::HandleHelpBarClick(entry.key_event)); + return; + } + } + } + return; + } + + // Check title bar help entry clicks (e.g. breakpoint/hit manager top-right items) + if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) { + for entry in &self.layout_areas.title_bar_entries { + if position_in_rect(col, row, &entry.area) { + action_tx.send(Action::HandleHelpBarClick(entry.key_event)); + return; + } + } + } + + // When overlays are active (popups, breakpoint manager, hit manager), + // block mouse events from reaching the event list and terminal pane. + // But still route relevant events to the active popup. + let has_overlay = !self.popup.is_empty() + || self.breakpoint_manager.is_some() + || self.hit_manager_state.as_ref().is_some_and(|h| h.visible); + if has_overlay { + // Route mouse events to the topmost popup if applicable + if let Some(popup) = self.popup.last_mut() + && let ActivePopup::ViewDetails(state) = popup + { + state.handle_mouse_event(&me); + } + return; + } + + // Check terminal pane clicks + if let Some(term_inner) = self.layout_areas.terminal_inner + && let Some(term_outer) = self.layout_areas.terminal_outer + && position_in_rect(col, row, &term_outer) + { + // Switch to terminal pane if not already active + if self.active_pane != ActivePane::Terminal { + let should_switch = matches!(me.kind, MouseEventKind::Down(_)) + || (self.focus_on_hover && matches!(me.kind, MouseEventKind::Moved)); + if should_switch { + action_tx.send(Action::SwitchActivePane); + } + } + // Pass mouse event through to the PTY + if position_in_rect(col, row, &term_inner) + && let Some(term) = self.term.as_ref() + { + // In scrollback mode, intercept scroll events for scrollback navigation + if term.is_scrollback_mode() { + match me.kind { + MouseEventKind::ScrollUp => { + term.scroll_up(); + return; + } + MouseEventKind::ScrollDown => { + term.scroll_down(); + return; + } + _ => {} + } + } + let rel_col = col - term_inner.x; + let rel_row = row - term_inner.y; + term.handle_mouse_event(&me, rel_col, rel_row).await; + } + return; + } + + // Check event list clicks + if position_in_rect(col, row, &self.layout_areas.event_list_outer) { + // Switch to event pane if not already active + if self.active_pane != ActivePane::Events && self.term.is_some() { + let should_switch = matches!(me.kind, MouseEventKind::Down(_)) + || (self.focus_on_hover && matches!(me.kind, MouseEventKind::Moved)); + if should_switch { + action_tx.send(Action::SwitchActivePane); + } + } + + if position_in_rect(col, row, &self.layout_areas.event_list_inner) { + let rel_row = (row - self.layout_areas.event_list_inner.y) as usize; + + match me.kind { + MouseEventKind::Down(MouseButton::Left) => { + let is_double_click = self.click_tracker.record_click(col, row); + let list = self.active_event_list(); + list.stop_follow(); + if list.select_row(rel_row) && is_double_click { + // Double-click: open details popup + if let Some(event) = list.selection() { + action_tx.send(Action::SetActivePopup(ActivePopup::ViewDetails( + crate::details_popup::DetailsPopupState::new(&event.borrow(), list), + ))); + } + } + } + MouseEventKind::ScrollUp => { + action_tx.send(Action::StopFollow); + action_tx.send(Action::PrevItem); + } + MouseEventKind::ScrollDown => { + action_tx.send(Action::NextItem); + } + MouseEventKind::ScrollLeft => { + action_tx.send(Action::ScrollLeft); + } + MouseEventKind::ScrollRight => { + action_tx.send(Action::ScrollRight); + } + _ => {} + } + } + } + } + pub fn exit(&self) -> color_eyre::Result<()> { // Close pty master self.term.as_ref().inspect(|t| t.exit()); diff --git a/crates/tracexec-tui/src/app/ui.rs b/crates/tracexec-tui/src/app/ui.rs index 255d1512..964e0424 100644 --- a/crates/tracexec-tui/src/app/ui.rs +++ b/crates/tracexec-tui/src/app/ui.rs @@ -16,7 +16,6 @@ use ratatui::{ StatefulWidget, StatefulWidgetRef, Widget, - Wrap, }, }; use tracexec_core::{ @@ -32,12 +31,17 @@ use super::{ details_popup::DetailsPopup, error_popup::InfoPopup, help::{ + HelpItem, fancy_help_desc, help, help_item, help_key, }, hit_manager::HitManager, + mouse::{ + HelpBarEntry, + position_in_rect, + }, ui::render_title, }, App, @@ -130,6 +134,13 @@ impl Widget for &mut App { let inner = block.inner(event_area); block.render(event_area, buf); self.event_list.render(inner, buf); + + // Store layout areas for mouse hit testing + self.layout_areas.event_list_inner = inner; + self.layout_areas.event_list_outer = event_area; + self.layout_areas.footer = footer_area; + self.layout_areas.rest_area = rest_area; + if let Some(term) = self.term.as_mut() { let block = Block::default() .title("Terminal") @@ -139,18 +150,62 @@ impl Widget for &mut App { } else { self.theme.inactive_border }); - term.render(block.inner(term_area), buf); + let term_inner = block.inner(term_area); + term.render(term_inner, buf); block.render(term_area, buf); + self.layout_areas.terminal_inner = Some(term_inner); + self.layout_areas.terminal_outer = Some(term_area); + } else { + self.layout_areas.terminal_inner = None; + self.layout_areas.terminal_outer = None; } + // Clear title bar entries before overlay rendering + self.layout_areas.title_bar_entries.clear(); + let mut title_bar_items: Vec<(Rect, HelpItem<'static>)> = Vec::new(); + if let Some(breakpoint_mgr_state) = self.breakpoint_manager.as_mut() { BreakPointManager.render_ref(rest_area, buf, breakpoint_mgr_state); + title_bar_items.append(&mut breakpoint_mgr_state.title_bar_items); } if let Some(h) = self.hit_manager_state.as_mut() && h.visible { HitManager.render(rest_area, buf, h); + title_bar_items.append(&mut h.title_bar_items); + } + + // Render title bar items with hover support + for (item_area, item) in &title_bar_items { + let is_hovered = item.key_event.is_some() + && position_in_rect(self.hover_state.col, self.hover_state.row, item_area); + let key_w = item.key_span.width() as u16; + if is_hovered { + let key_span = Span::styled(item.key_span.content.clone(), self.theme.help_key_hover); + let desc_span = Span::styled(item.desc_span.content.clone(), self.theme.help_desc_hover); + buf.set_span(item_area.x, item_area.y, &key_span, key_w); + buf.set_span( + item_area.x + key_w, + item_area.y, + &desc_span, + item_area.width.saturating_sub(key_w), + ); + } else { + buf.set_span(item_area.x, item_area.y, &item.key_span, key_w); + buf.set_span( + item_area.x + key_w, + item_area.y, + &item.desc_span, + item_area.width.saturating_sub(key_w), + ); + } + if let Some(ke) = item.key_event { + self.layout_areas.title_bar_entries.push(HelpBarEntry { + area: *item_area, + key_event: ke, + }); + } } // popups @@ -180,7 +235,7 @@ impl Widget for &mut App { } impl App { - fn render_help(&self, area: Rect, buf: &mut Buffer) { + fn render_help(&mut self, area: Rect, buf: &mut Buffer) { /// Compat arrow key pairs into a single item for more concise help display. fn compact_pair(a: String, b: String) -> String { let is_simple = |s: &str| s.chars().count() == 1; @@ -192,34 +247,36 @@ impl App { } } - let mut items = Vec::from_iter( - Some(help_item!( + let mut items: Vec> = Vec::new(); + + if self.term.is_some() { + items.push(help_item!( self.key_bindings.switch_pane.display(), "Switch\u{00a0}Pane", - self.theme - )) - .filter(|_| self.term.is_some()) - .into_iter() - .flatten(), - ); + self.theme, + &self.key_bindings.switch_pane + )); + } if let Some(popup) = &self.popup.last() { - items.extend(help_item!( + items.push(help_item!( self.key_bindings.close_popup.display(), "Close\u{00a0}Popup", - self.theme + self.theme, + &self.key_bindings.close_popup )); match popup { ActivePopup::ViewDetails(state) => { state.update_help(&self.key_bindings, &mut items); } ActivePopup::CopyTargetSelection(state) => { - items.extend(help_item!( + items.push(help_item!( self.key_bindings.copy_choose.display(), "Choose", - self.theme + self.theme, + &self.key_bindings.copy_choose )); - items.extend(state.help_items()) + items.extend(state.help_items()); } ActivePopup::Backtrace(state) => { state.list.update_help(&self.key_bindings, &mut items); @@ -239,14 +296,15 @@ impl App { } else if let Some(query_builder) = self.query_builder.as_ref().filter(|q| q.editing()) { items.extend(query_builder.help(&self.key_bindings, self.theme)); } else if self.active_pane == ActivePane::Events { - items.extend(help_item!( + items.push(help_item!( self.key_bindings.help.display(), "Help", - self.theme + self.theme, + &self.key_bindings.help )); self.event_list.update_help(&self.key_bindings, &mut items); if self.term.is_some() { - items.extend(help_item!( + items.push(help_item!( format!( "{}/{}", self.key_bindings.event_grow_pane.display(), @@ -255,54 +313,61 @@ impl App { "Grow/Shrink\u{00a0}Pane", self.theme )); - items.extend(help_item!( + items.push(help_item!( self.key_bindings.switch_layout.display(), "Layout", - self.theme + self.theme, + &self.key_bindings.switch_layout )); } if let Some(h) = self.hit_manager_state.as_ref() { - items.extend(help_item!( + items.push(help_item!( self.key_bindings.event_breakpoints.display(), "Breakpoints", - self.theme + self.theme, + &self.key_bindings.event_breakpoints )); if h.count() > 0 { - items.extend([ - help_key(self.key_bindings.event_hits.display(), self.theme), - fancy_help_desc(format!("Hits({})", h.count()), self.theme), - "\u{200b}".into(), - ]) + items.push(HelpItem { + key_span: help_key(self.key_bindings.event_hits.display(), self.theme), + desc_span: fancy_help_desc(format!("Hits({})", h.count()), self.theme), + key_event: self.key_bindings.event_hits.first_key_event(), + }); } else { - items.extend(help_item!( + items.push(help_item!( self.key_bindings.event_hits.display(), "Hits", - self.theme + self.theme, + &self.key_bindings.event_hits )); } } if let Some(query_builder) = self.query_builder.as_ref() { items.extend(query_builder.help(&self.key_bindings, self.theme)); } - items.extend(help_item!( + items.push(help_item!( self.key_bindings.quit.display(), "Quit", - self.theme + self.theme, + &self.key_bindings.quit )); } else { // Terminal if let Some(term) = self.term.as_ref() { if term.is_scrollback_mode() { // In scrollback mode - show navigation keys highlighted - items.extend([ - help_key( + items.push(HelpItem { + key_span: help_key( self.key_bindings.terminal_toggle_scrollback.display(), self.theme, ), - fancy_help_desc("Exit\u{00a0}Scroll", self.theme), - "\u{200b}".into(), - ]); - items.extend(help_item!( + desc_span: fancy_help_desc("Exit\u{00a0}Scroll", self.theme), + key_event: self + .key_bindings + .terminal_toggle_scrollback + .first_key_event(), + }); + items.push(help_item!( compact_pair( self.key_bindings.terminal_scroll_up.display(), self.key_bindings.terminal_scroll_down.display() @@ -310,7 +375,7 @@ impl App { "Scroll", self.theme )); - items.extend(help_item!( + items.push(help_item!( format!( "{}/{}", self.key_bindings.terminal_page_up.display(), @@ -319,7 +384,7 @@ impl App { "Page", self.theme )); - items.extend(help_item!( + items.push(help_item!( format!( "{}/{}", self.key_bindings.terminal_scroll_top.display(), @@ -330,18 +395,19 @@ impl App { )); } else { // Normal mode - show how to enter scrollback - items.extend(help_item!( + items.push(help_item!( self.key_bindings.terminal_toggle_scrollback.display(), "Scroll", - self.theme + self.theme, + &self.key_bindings.terminal_toggle_scrollback )); } } if let Some(h) = self.hit_manager_state.as_ref() && h.count() > 0 { - items.extend([ - help_key( + items.push(HelpItem { + key_span: help_key( format!( "{},\u{00a0}{}", self.key_bindings.switch_pane.display(), @@ -349,16 +415,75 @@ impl App { ), self.theme, ), - fancy_help_desc(format!("Hits({})", h.count()), self.theme), - "\u{200b}".into(), - ]); + desc_span: fancy_help_desc(format!("Hits({})", h.count()), self.theme), + key_event: None, // compound action, not directly clickable + }); } }; - let line = Line::default().spans(items); - Paragraph::new(line) - .wrap(Wrap { trim: false }) - .centered() - .render(area, buf); + // Render help items with position tracking for mouse click support. + // We simulate wrapping and centering manually instead of using Paragraph + // so we can record the screen position of each clickable item. + let mut help_entries = Vec::new(); + + // First, compute wrapped lines: each line holds (HelpItem, width) + let mut lines: Vec> = vec![vec![]]; // (index, width) + let mut current_line_width: u16 = 0; + + for (idx, item) in items.iter().enumerate() { + let w = item.width(); + if current_line_width > 0 && current_line_width + w > area.width { + lines.push(vec![]); + current_line_width = 0; + } + lines.last_mut().unwrap().push((idx, w)); + current_line_width += w; + } + + // Render each line, centered + for (line_idx, line) in lines.iter().enumerate() { + let y = area.y + line_idx as u16; + if y >= area.y + area.height { + break; + } + let line_width: u16 = line.iter().map(|(_, w)| *w).sum(); + let offset = area.width.saturating_sub(line_width) / 2; + let mut x = area.x + offset; + + for &(idx, width) in line { + let item = &items[idx]; + let item_rect = Rect { + x, + y, + width, + height: 1, + }; + let is_clickable = item.key_event.is_some(); + let is_hovered = + is_clickable && position_in_rect(self.hover_state.col, self.hover_state.row, &item_rect); + // Record clickable region + if let Some(ke) = item.key_event { + help_entries.push(HelpBarEntry { + area: item_rect, + key_event: ke, + }); + } + // Render spans with hover styling when applicable + let key_w = item.key_span.width() as u16; + if is_hovered { + // Apply hover style directly without re-wrapping (content already has padding) + let key_span = Span::styled(item.key_span.content.clone(), self.theme.help_key_hover); + let desc_span = Span::styled(item.desc_span.content.clone(), self.theme.help_desc_hover); + buf.set_span(x, y, &key_span, key_w); + buf.set_span(x + key_w, y, &desc_span, width - key_w); + } else { + buf.set_span(x, y, &item.key_span, key_w); + buf.set_span(x + key_w, y, &item.desc_span, width - key_w); + } + x += width; + } + } + + self.help_bar_entries = help_entries; } } diff --git a/crates/tracexec-tui/src/breakpoint_manager.rs b/crates/tracexec-tui/src/breakpoint_manager.rs index 99e36c8a..81ca2961 100644 --- a/crates/tracexec-tui/src/breakpoint_manager.rs +++ b/crates/tracexec-tui/src/breakpoint_manager.rs @@ -4,10 +4,7 @@ use std::{ }; use crossterm::event::KeyEvent; -use itertools::{ - Itertools, - chain, -}; +use itertools::Itertools; use ratatui::{ layout::{ Alignment, @@ -57,6 +54,7 @@ use tui_widget_list::{ use super::{ error_popup::InfoPopupState, help::{ + HelpItem, help_item, help_key, }, @@ -150,6 +148,9 @@ pub struct BreakPointManagerState { active: bool, editing: Option, theme: &'static Theme, + /// Title-bar help items populated during rendering (area + HelpItem). + /// Rendered by App with hover support. + pub title_bar_items: Vec<(Rect, HelpItem<'static>)>, } impl BreakPointManager { @@ -620,6 +621,7 @@ impl BreakPointManagerState { active: true, editing: None, theme, + title_bar_items: Vec::new(), } } } @@ -732,34 +734,43 @@ impl BreakPointManagerState { None } - pub fn help(&self, keys: &TuiKeyBindings, theme: &Theme) -> impl Iterator> { - chain!( - [ - help_item!(keys.go_back.display(), "Close Mgr", theme), - help_item!(keys.breakpoint_delete.display(), "Delete", theme), - help_item!(keys.breakpoint_edit.display(), "Edit", theme), - help_item!( - keys.breakpoint_toggle_active.display(), - "Enable/Disable", - theme - ), - help_item!( - keys.breakpoint_new.display(), - "New\u{00a0}Breakpoint", - theme - ), - ], - if self.editor.is_some() { - Some(help_item!( - keys.breakpoint_editor_cancel.display(), - "Cancel", - theme - )) - } else { - None - } - ) - .flatten() + pub fn help(&self, keys: &TuiKeyBindings, theme: &Theme) -> Vec> { + let mut items = vec![ + help_item!(keys.go_back.display(), "Close Mgr", theme, &keys.go_back), + help_item!( + keys.breakpoint_delete.display(), + "Delete", + theme, + &keys.breakpoint_delete + ), + help_item!( + keys.breakpoint_edit.display(), + "Edit", + theme, + &keys.breakpoint_edit + ), + help_item!( + keys.breakpoint_toggle_active.display(), + "Enable/Disable", + theme, + &keys.breakpoint_toggle_active + ), + help_item!( + keys.breakpoint_new.display(), + "New\u{00a0}Breakpoint", + theme, + &keys.breakpoint_new + ), + ]; + if self.editor.is_some() { + items.push(help_item!( + keys.breakpoint_editor_cancel.display(), + "Cancel", + theme, + &keys.breakpoint_editor_cancel + )); + } + items } } @@ -767,6 +778,7 @@ impl StatefulWidgetRef for BreakPointManager { type State = BreakPointManagerState; fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + state.title_bar_items.clear(); let editor_area = Rect { x: 0, y: 1, @@ -785,26 +797,30 @@ impl StatefulWidgetRef for BreakPointManager { let [stop_toggle_area, active_toggle_area] = Layout::horizontal([Constraint::Length(22), Constraint::Length(17)]).areas(toggles_area); TextPrompt::new("πŸ›".into()).render(editor_area, buf, editing); - Line::default() - .spans(help_item!( + state.title_bar_items.push(( + stop_toggle_area, + help_item!( state.key_bindings.breakpoint_editor_toggle_stop.display(), match state.stop { - BreakPointStop::SyscallEnter => "Syscall Enter", // 13 + 2 = 15 + BreakPointStop::SyscallEnter => "Syscall Enter", BreakPointStop::SyscallExit => "Syscall Exit", }, - state.theme - )) - .render(stop_toggle_area, buf); - Line::default() - .spans(help_item!( + state.theme, + &state.key_bindings.breakpoint_editor_toggle_stop + ), + )); + state.title_bar_items.push(( + active_toggle_area, + help_item!( state.key_bindings.breakpoint_editor_toggle_active.display(), match state.active { - true => " Active ", // 8 + 2 = 10 + true => " Active ", false => "Inactive", }, - state.theme - )) - .render(active_toggle_area, buf); + state.theme, + &state.key_bindings.breakpoint_editor_toggle_active + ), + )); } else { let help_area = Rect { x: buf.area.width.saturating_sub(10), @@ -813,13 +829,15 @@ impl StatefulWidgetRef for BreakPointManager { height: 1, }; Clear.render(help_area, buf); - Line::default() - .spans(help_item!( + state.title_bar_items.push(( + help_area, + help_item!( state.key_bindings.help.display(), "Help", - state.theme - )) - .render(help_area, buf); + state.theme, + &state.key_bindings.help + ), + )); } Clear.render(area, buf); let block = Block::new() diff --git a/crates/tracexec-tui/src/copy_popup.rs b/crates/tracexec-tui/src/copy_popup.rs index 2ebbda50..b11b0696 100644 --- a/crates/tracexec-tui/src/copy_popup.rs +++ b/crates/tracexec-tui/src/copy_popup.rs @@ -15,7 +15,6 @@ use ratatui::{ Modifier, Style, }, - text::Span, widgets::{ Block, Borders, @@ -33,7 +32,10 @@ use tracexec_core::{ event::TracerEventDetails, }; -use super::help::help_item; +use super::help::{ + HelpItem, + help_item, +}; use crate::{ action::{ Action, @@ -175,8 +177,8 @@ impl CopyPopupState { None } - pub fn help_items(&self) -> impl Iterator> { - self.available_targets.iter().flat_map(|&target| { + pub fn help_items(&self) -> impl Iterator> { + self.available_targets.iter().map(|&target| { let config = copy_target_config(target); let key_label = copy_target_binding(&self.key_bindings, target).display(); help_item!(key_label, config.help_label, self.theme) diff --git a/crates/tracexec-tui/src/details_popup.rs b/crates/tracexec-tui/src/details_popup.rs index b8e9b16a..e998fd0b 100644 --- a/crates/tracexec-tui/src/details_popup.rs +++ b/crates/tracexec-tui/src/details_popup.rs @@ -4,7 +4,12 @@ use std::ops::{ }; use arboard::Clipboard; -use crossterm::event::KeyEvent; +use crossterm::event::{ + KeyEvent, + MouseButton, + MouseEvent, + MouseEventKind, +}; use hashbrown::HashMap; use itertools::{ Itertools, @@ -23,10 +28,7 @@ use ratatui::{ Size, }, style::Styled, - text::{ - Line, - Span, - }, + text::Line, widgets::{ Block, Borders, @@ -69,10 +71,12 @@ use super::{ EventList, }, help::{ + HelpItem, help_desc, help_item, help_key, }, + mouse::position_in_rect, theme::Theme, }; use crate::{ @@ -105,6 +109,10 @@ pub struct DetailsPopupState { available_tabs: Vec<&'static str>, tab_index: usize, parent_id: Option, + /// The area where the popup was last rendered (for mouse hit testing). + rendered_area: Rect, + /// The position of each tab label in screen coordinates, set during render. + tab_areas: Vec, } impl DetailsPopupState { @@ -624,6 +632,8 @@ impl DetailsPopupState { available_tabs, tab_index: 0, parent_id, + rendered_area: Rect::default(), + tab_areas: Vec::new(), } } @@ -667,9 +677,9 @@ impl DetailsPopupState { self.available_tabs[self.tab_index] } - pub fn update_help(&self, keys: &TuiKeyBindings, items: &mut Vec>) { + pub fn update_help<'a>(&self, keys: &TuiKeyBindings, items: &mut Vec>) { if self.active_tab() == "Info" { - items.extend(help_item!( + items.push(help_item!( format!( "{}/{}", keys.details_prev_field.display(), @@ -679,7 +689,7 @@ impl DetailsPopupState { self.theme )); } - items.extend(help_item!( + items.push(help_item!( format!( "{}/{}/{}", keys.details_prev_tab.display(), @@ -690,14 +700,61 @@ impl DetailsPopupState { self.theme )); if self.env.is_some() { - items.extend(help_item!( + items.push(help_item!( keys.details_view_parent.display(), "View\u{00a0}Parent\u{00a0}Details", - self.theme + self.theme, + &keys.details_view_parent )); } } + /// Handle a mouse event within the details popup. + /// Returns `true` if the event was consumed. + pub fn handle_mouse_event(&mut self, event: &MouseEvent) -> bool { + let col = event.column; + let row = event.row; + + // Check tab clicks + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + for (i, tab_area) in self.tab_areas.iter().enumerate() { + if position_in_rect(col, row, tab_area) { + let old = self.tab_index; + self.tab_index = i; + if old != self.tab_index { + self.scroll.scroll_to_top(); + } + return true; + } + } + } + + // Check scroll events within the popup area + if position_in_rect(col, row, &self.rendered_area) { + match event.kind { + MouseEventKind::ScrollUp => { + self.scroll_up(); + return true; + } + MouseEventKind::ScrollDown => { + self.scroll_down(); + return true; + } + MouseEventKind::ScrollLeft => { + self.scroll_left(); + return true; + } + MouseEventKind::ScrollRight => { + self.scroll_right(); + return true; + } + _ => {} + } + } + + false + } + pub fn handle_key_event( &mut self, ke: KeyEvent, @@ -783,6 +840,7 @@ impl DerefMut for DetailsPopupState { impl StatefulWidgetRef for DetailsPopup { fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut DetailsPopupState) { Clear.render(area, buf); + state.rendered_area = area; let theme = state.theme; let block = Block::new() .title(" Details ") @@ -807,6 +865,19 @@ impl StatefulWidgetRef for DetailsPopup { let start = screen.right().saturating_sub(tabs_width); tabs.render(Rect::new(start, 0, tabs_width, 1), buf); + // Compute per-tab click areas for mouse support + // Each tab occupies: " label " (1 space + label + 1 space), separated by "β”‚" + state.tab_areas.clear(); + let mut tab_x = start; + for (i, tab_label) in state.available_tabs.iter().enumerate() { + let tab_w = tab_label.len() as u16 + 2; // space + label + space + state.tab_areas.push(Rect::new(tab_x, 0, tab_w, 1)); + tab_x += tab_w; + if i + 1 < state.available_tabs.len() { + tab_x += 1; // separator "β”‚" + } + } + // Tab Info let paragraph = match state.tab_index { 0 => self.info_paragraph(state), diff --git a/crates/tracexec-tui/src/event_list/scroll.rs b/crates/tracexec-tui/src/event_list/scroll.rs index 240b5f1f..e6796a29 100644 --- a/crates/tracexec-tui/src/event_list/scroll.rs +++ b/crates/tracexec-tui/src/event_list/scroll.rs @@ -250,6 +250,23 @@ impl EventList { } } +/// Mouse support +impl EventList { + /// Select the item at the given row offset from the top of the visible area. + /// Returns true if an item was selected, false if the row is out of range. + pub fn select_row(&mut self, row: usize) -> bool { + if self.events.is_empty() { + return false; + } + let abs_index = self.window.0 + row; + if abs_index >= self.events.len() || abs_index >= self.window.1 { + return false; + } + self.state.select(Some(row)); + true + } +} + #[cfg(test)] mod tests { use std::{ @@ -361,4 +378,26 @@ mod tests { list.scroll_to_start(); assert_eq!(list.horizontal_offset, 0); } + + #[test] + fn select_row_within_window() { + let mut list = make_list(5, 3); + assert!(list.select_row(0)); + assert_eq!(list.state.selected(), Some(0)); + assert!(list.select_row(2)); + assert_eq!(list.state.selected(), Some(2)); + } + + #[test] + fn select_row_out_of_window() { + let mut list = make_list(5, 3); + assert!(!list.select_row(3)); + assert!(!list.select_row(5)); + } + + #[test] + fn select_row_empty_list() { + let mut list = make_list(0, 3); + assert!(!list.select_row(0)); + } } diff --git a/crates/tracexec-tui/src/event_list/ui.rs b/crates/tracexec-tui/src/event_list/ui.rs index d4096490..bf9cf770 100644 --- a/crates/tracexec-tui/src/event_list/ui.rs +++ b/crates/tracexec-tui/src/event_list/ui.rs @@ -1,4 +1,3 @@ -use itertools::chain; use nix::sys::signal; use ratatui::{ buffer::Buffer, @@ -47,7 +46,10 @@ use super::{ use crate::{ event::TracerEventDetailsTuiExt, event_line::EventLine, - help::help_item, + help::{ + HelpItem, + help_item, + }, partial_line::PartialLine, theme::Theme, }; @@ -196,56 +198,72 @@ impl EventList { .alignment(Alignment::Right) } - pub fn update_help(&self, keys: &TuiKeyBindings, items: &mut Vec>) { + pub fn update_help<'a>(&self, keys: &TuiKeyBindings, items: &mut Vec>) { if self.is_primary { - items.extend(chain!( - help_item!( - keys.event_toggle_follow.display(), - if self.is_following() { - "Unfollow" - } else { - "Follow" - }, - self.theme - ), - help_item!(keys.event_search.display(), "Search", self.theme), - )) - } - items.extend(chain!( - help_item!( - keys.event_toggle_env.display(), - if self.is_env_in_cmdline() { - "Hide\u{00a0}Env" + items.push(help_item!( + keys.event_toggle_follow.display(), + if self.is_following() { + "Unfollow" } else { - "Show\u{00a0}Env" + "Follow" }, - self.theme - ), - help_item!( - keys.event_toggle_cwd.display(), - if self.is_cwd_in_cmdline() { - "Hide\u{00a0}CWD" - } else { - "Show\u{00a0}CWD" - }, - self.theme - ), - help_item!(keys.event_view_details.display(), "View", self.theme), + self.theme, + &keys.event_toggle_follow + )); + items.push(help_item!( + keys.event_search.display(), + "Search", + self.theme, + &keys.event_search + )); + } + items.push(help_item!( + keys.event_toggle_env.display(), + if self.is_env_in_cmdline() { + "Hide\u{00a0}Env" + } else { + "Show\u{00a0}Env" + }, + self.theme, + &keys.event_toggle_env + )); + items.push(help_item!( + keys.event_toggle_cwd.display(), + if self.is_cwd_in_cmdline() { + "Hide\u{00a0}CWD" + } else { + "Show\u{00a0}CWD" + }, + self.theme, + &keys.event_toggle_cwd + )); + items.push(help_item!( + keys.event_view_details.display(), + "View", + self.theme, + &keys.event_view_details )); if self.is_primary && self.selection_index().is_some() { - items.extend(help_item!( + items.push(help_item!( keys.event_go_to_parent.display(), "GoTo Parent", - self.theme + self.theme, + &keys.event_go_to_parent )); - items.extend(help_item!( + items.push(help_item!( keys.event_backtrace.display(), "Backtrace", - self.theme + self.theme, + &keys.event_backtrace )); } if self.has_clipboard { - items.extend(help_item!(keys.event_copy.display(), "Copy", self.theme)); + items.push(help_item!( + keys.event_copy.display(), + "Copy", + self.theme, + &keys.event_copy + )); } } } diff --git a/crates/tracexec-tui/src/help.rs b/crates/tracexec-tui/src/help.rs index d2204f86..2975845e 100644 --- a/crates/tracexec-tui/src/help.rs +++ b/crates/tracexec-tui/src/help.rs @@ -48,6 +48,17 @@ where key_string.push('\u{00a0}'); key_string.set_style(theme.help_key) } + +pub fn help_key_hover<'a, T>(k: T, theme: &Theme) -> Span<'a> +where + T: Into> + Styled>, +{ + let mut key_string = String::from("\u{00a0}"); + key_string.push_str(&k.into()); + key_string.push('\u{00a0}'); + key_string.set_style(theme.help_key_hover) +} + pub fn help_desc<'a, T>(d: T, theme: &Theme) -> Span<'a> where T: Into> + Styled>, @@ -58,6 +69,16 @@ where desc_string.set_style(theme.help_desc) } +pub fn help_desc_hover<'a, T>(d: T, theme: &Theme) -> Span<'a> +where + T: Into> + Styled>, +{ + let mut desc_string = String::from("\u{00a0}"); + desc_string.push_str(&d.into()); + desc_string.push('\u{00a0}'); + desc_string.set_style(theme.help_desc_hover) +} + pub fn fancy_help_desc<'a, T>(d: T, theme: &Theme) -> Span<'a> where T: Into> + Styled>, @@ -68,13 +89,42 @@ where desc_string.set_style(theme.fancy_help_desc) } +/// A help bar item consisting of a key label, description, and an optional +/// key event for mouse click simulation. +pub struct HelpItem<'a> { + pub key_span: Span<'a>, + pub desc_span: Span<'a>, + /// The key event to simulate when this help item is clicked. + /// `None` means this item is not directly clickable (e.g., compound keys). + pub key_event: Option, +} + +impl<'a> HelpItem<'a> { + /// Total visual width (key + desc, not counting the zero-width separator). + pub fn width(&self) -> u16 { + (self.key_span.width() + self.desc_span.width()) as u16 + } + + /// Convert to a 3-element span array for legacy rendering. + pub fn into_spans(self) -> [Span<'a>; 3] { + [self.key_span, self.desc_span, "\u{200b}".into()] + } +} + macro_rules! help_item { - ($key: expr, $desc: expr, $theme:expr) => {{ - [ - crate::help::help_key($key, $theme), - crate::help::help_desc($desc, $theme), - "\u{200b}".into(), - ] + ($key:expr, $desc:expr, $theme:expr) => {{ + $crate::help::HelpItem { + key_span: $crate::help::help_key($key, $theme), + desc_span: $crate::help::help_desc($desc, $theme), + key_event: None, + } + }}; + ($key:expr, $desc:expr, $theme:expr, $binding:expr) => {{ + $crate::help::HelpItem { + key_span: $crate::help::help_key($key, $theme), + desc_span: $crate::help::help_desc($desc, $theme), + key_event: $binding.first_key_event(), + } }}; } @@ -377,4 +427,31 @@ mod tests { let rendered = test_render_widget_area(help(area, &keys, current_theme()), area); assert_snapshot!(rendered); } + + #[test] + fn help_item_macro_without_binding() { + let theme = current_theme(); + let item = help_item!("Esc", "Quit", theme); + assert!(item.key_event.is_none()); + assert!(item.key_span.content.contains("Esc")); + assert!(item.desc_span.content.contains("Quit")); + } + + #[test] + fn help_item_macro_with_binding() { + let theme = current_theme(); + let keys = TuiKeyBindings::default(); + let item = help_item!("Esc", "Quit", theme, &keys.quit); + assert!(item.key_event.is_some()); + assert!(item.key_span.content.contains("Esc")); + assert!(item.desc_span.content.contains("Quit")); + } + + #[test] + fn help_item_into_spans_length() { + let theme = current_theme(); + let item = help_item!("Esc", "Quit", theme); + let spans = item.into_spans(); + assert_eq!(spans.len(), 3); + } } diff --git a/crates/tracexec-tui/src/hit_manager.rs b/crates/tracexec-tui/src/hit_manager.rs index 02963faf..5673790a 100644 --- a/crates/tracexec-tui/src/hit_manager.rs +++ b/crates/tracexec-tui/src/hit_manager.rs @@ -16,11 +16,7 @@ use crossterm::event::{ KeyEvent, KeyModifiers, }; -use either::Either; -use itertools::{ - Itertools, - chain, -}; +use itertools::Itertools; use nix::{ sys::signal::Signal, unistd::Pid, @@ -79,6 +75,7 @@ use tui_widget_list::{ use super::{ error_popup::InfoPopupState, help::{ + HelpItem, cli_flag, help_item, help_key, @@ -162,6 +159,9 @@ pub struct HitManagerState { editor_state: TextState<'static>, key_bindings: Arc, theme: &'static Theme, + /// Title-bar help items populated during rendering (area + HelpItem). + /// Rendered by App with hover support. + pub title_bar_items: Vec<(Rect, HelpItem<'static>)>, } impl HitManagerState { @@ -183,6 +183,7 @@ impl HitManagerState { editor_state: TextState::new(), key_bindings, theme, + title_bar_items: Vec::new(), }) } @@ -195,51 +196,71 @@ impl HitManagerState { self.editing = None; } - pub fn help(&self, keys: &TuiKeyBindings) -> impl Iterator> { + pub fn help(&self, keys: &TuiKeyBindings) -> Vec> { if self.editing.is_none() { - Either::Left(chain!( - [ - help_item!(keys.hit_close.display(), "Back", self.theme), - help_item!( - keys.hit_resume.display(), - "Resume\u{00a0}Process", - self.theme - ), - help_item!( - keys.hit_detach.display(), - "Detach\u{00a0}Process", - self.theme - ), - help_item!( - keys.hit_edit_default_command.display(), - "Edit\u{00a0}Default\u{00a0}Command", - self.theme - ) - ], - if self.default_external_command.is_some() { - Some(help_item!( - keys.hit_run_default_command.display(), - "Detach,\u{00a0}Stop\u{00a0}and\u{00a0}Run\u{00a0}Default\u{00a0}Command", - self.theme - )) - } else { - None - }, - [help_item!( - keys.hit_run_custom_command.display(), - "Detach,\u{00a0}Stop\u{00a0}and\u{00a0}Run\u{00a0}Command", - self.theme - ),] - )) + let mut items = vec![ + help_item!( + keys.hit_close.display(), + "Back", + self.theme, + &keys.hit_close + ), + help_item!( + keys.hit_resume.display(), + "Resume\u{00a0}Process", + self.theme, + &keys.hit_resume + ), + help_item!( + keys.hit_detach.display(), + "Detach\u{00a0}Process", + self.theme, + &keys.hit_detach + ), + help_item!( + keys.hit_edit_default_command.display(), + "Edit\u{00a0}Default\u{00a0}Command", + self.theme, + &keys.hit_edit_default_command + ), + ]; + if self.default_external_command.is_some() { + items.push(help_item!( + keys.hit_run_default_command.display(), + "Detach,\u{00a0}Stop\u{00a0}and\u{00a0}Run\u{00a0}Default\u{00a0}Command", + self.theme, + &keys.hit_run_default_command + )); + } + items.push(help_item!( + keys.hit_run_custom_command.display(), + "Detach,\u{00a0}Stop\u{00a0}and\u{00a0}Run\u{00a0}Command", + self.theme, + &keys.hit_run_custom_command + )); + items } else { - Either::Right(chain!([ - help_item!(keys.hit_editor_save.display(), "Save", self.theme), - help_item!(keys.hit_editor_clear.display(), "Clear", self.theme), - help_item!(keys.hit_editor_cancel.display(), "Cancel", self.theme), - ],)) + vec![ + help_item!( + keys.hit_editor_save.display(), + "Save", + self.theme, + &keys.hit_editor_save + ), + help_item!( + keys.hit_editor_clear.display(), + "Clear", + self.theme, + &keys.hit_editor_clear + ), + help_item!( + keys.hit_editor_cancel.display(), + "Cancel", + self.theme, + &keys.hit_editor_cancel + ), + ] } - .into_iter() - .flatten() } fn close_when_empty(&self) -> Option { @@ -852,6 +873,7 @@ impl StatefulWidget for HitManager { type State = HitManagerState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + state.title_bar_items.clear(); let theme = state.theme; let help_area = Rect { x: buf.area.width.saturating_sub(10), @@ -860,9 +882,15 @@ impl StatefulWidget for HitManager { height: 1, }; Clear.render(help_area, buf); - Line::default() - .spans(help_item!(state.key_bindings.help.display(), "Help", theme)) - .render(help_area, buf); + state.title_bar_items.push(( + help_area, + help_item!( + state.key_bindings.help.display(), + "Help", + theme, + &state.key_bindings.help + ), + )); let editor_area = Rect { x: 0, y: 1, diff --git a/crates/tracexec-tui/src/lib.rs b/crates/tracexec-tui/src/lib.rs index aae1a6eb..b0ce1e87 100644 --- a/crates/tracexec-tui/src/lib.rs +++ b/crates/tracexec-tui/src/lib.rs @@ -30,6 +30,8 @@ use color_eyre::eyre::Result; use crossterm::{ cursor, event::{ + DisableMouseCapture, + EnableMouseCapture, Event as CrosstermEvent, KeyEventKind, }, @@ -76,6 +78,7 @@ pub mod event_line; mod event_list; pub mod help; mod hit_manager; +pub mod mouse; mod output; mod partial_line; mod pseudo_term; @@ -104,14 +107,28 @@ pub struct Tui { pub frame_rate: f64, } -pub fn init_tui() -> Result<()> { +pub fn init_tui(mouse: bool) -> Result<()> { crossterm::terminal::enable_raw_mode()?; - crossterm::execute!(std::io::stdout(), EnterAlternateScreen, cursor::Hide)?; + if mouse { + crossterm::execute!( + std::io::stdout(), + EnterAlternateScreen, + cursor::Hide, + EnableMouseCapture + )?; + } else { + crossterm::execute!(std::io::stdout(), EnterAlternateScreen, cursor::Hide)?; + } Ok(()) } pub fn restore_tui() -> Result<()> { - crossterm::execute!(std::io::stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::execute!( + std::io::stdout(), + LeaveAlternateScreen, + cursor::Show, + DisableMouseCapture + )?; crossterm::terminal::disable_raw_mode()?; Ok(()) } @@ -186,6 +203,9 @@ impl Tui { height: rows, }).unwrap(); }, + CrosstermEvent::Mouse(mouse) => { + event_tx.send(Event::Mouse(mouse)).unwrap(); + }, _ => {}, } } @@ -224,8 +244,8 @@ impl Tui { Ok(()) } - pub fn enter(&mut self, tracer_rx: UnboundedReceiver) -> Result<()> { - init_tui()?; + pub fn enter(&mut self, tracer_rx: UnboundedReceiver, mouse: bool) -> Result<()> { + init_tui(mouse)?; self.start(tracer_rx); Ok(()) } diff --git a/crates/tracexec-tui/src/mouse.rs b/crates/tracexec-tui/src/mouse.rs new file mode 100644 index 00000000..b60cb292 --- /dev/null +++ b/crates/tracexec-tui/src/mouse.rs @@ -0,0 +1,134 @@ +use std::time::Instant; + +use crossterm::event::KeyEvent; +use ratatui::layout::Rect; + +/// Stores the areas of UI components as computed during rendering. +/// Used for mouse hit-testing to determine which component was clicked. +#[derive(Debug, Default, Clone)] +pub struct LayoutAreas { + /// Inner area of the event list (content area without the border) + pub event_list_inner: Rect, + /// Outer area of the event list (including border) + pub event_list_outer: Rect, + /// Inner area of the terminal pane (without border) + pub terminal_inner: Option, + /// Outer area of the terminal pane (including border) + pub terminal_outer: Option, + /// Footer (help bar) area + pub footer: Rect, + /// Title-bar help entries (e.g. in breakpoint manager, hit manager) + pub title_bar_entries: Vec, + /// The rest area containing both event list and terminal pane, used for divider dragging + pub rest_area: Rect, +} + +/// A clickable region in the help bar. +#[derive(Debug, Clone)] +pub struct HelpBarEntry { + /// The screen area of this clickable region. + pub area: Rect, + /// The key event to simulate when this region is clicked. + pub key_event: KeyEvent, +} + +/// Tracks click state for double-click detection. +#[derive(Debug, Default)] +pub struct ClickTracker { + last_click: Option<(u16, u16, Instant)>, +} + +impl ClickTracker { + const DOUBLE_CLICK_THRESHOLD_MS: u128 = 500; + + /// Record a click and return whether it constitutes a double-click. + pub fn record_click(&mut self, col: u16, row: u16) -> bool { + let now = Instant::now(); + let is_double = self + .last_click + .as_ref() + .map(|(lc, lr, lt)| { + *lc == col + && *lr == row + && now.duration_since(*lt).as_millis() < Self::DOUBLE_CLICK_THRESHOLD_MS + }) + .unwrap_or(false); + + if is_double { + self.last_click = None; + } else { + self.last_click = Some((col, row, now)); + } + + is_double + } +} + +/// Check if a screen position is within a given rectangle. +pub fn position_in_rect(col: u16, row: u16, rect: &Rect) -> bool { + col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height +} + +/// Tracks the current mouse cursor position for hover effects. +#[derive(Debug, Default, Clone, Copy)] +pub struct HoverState { + pub col: u16, + pub row: u16, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn click_tracker_single_click() { + let mut tracker = ClickTracker::default(); + assert!(!tracker.record_click(5, 10)); + } + + #[test] + fn click_tracker_double_click() { + let mut tracker = ClickTracker::default(); + assert!(!tracker.record_click(5, 10)); + assert!(tracker.record_click(5, 10)); + } + + #[test] + fn click_tracker_different_position_not_double() { + let mut tracker = ClickTracker::default(); + assert!(!tracker.record_click(5, 10)); + assert!(!tracker.record_click(6, 10)); + } + + #[test] + fn click_tracker_resets_after_double_click() { + let mut tracker = ClickTracker::default(); + assert!(!tracker.record_click(5, 10)); + assert!(tracker.record_click(5, 10)); + // After double-click, next click should be single + assert!(!tracker.record_click(5, 10)); + } + + #[test] + fn position_in_rect_inside() { + let rect = Rect::new(10, 20, 30, 5); + assert!(position_in_rect(10, 20, &rect)); + assert!(position_in_rect(39, 24, &rect)); + assert!(position_in_rect(25, 22, &rect)); + } + + #[test] + fn position_in_rect_outside() { + let rect = Rect::new(10, 20, 30, 5); + assert!(!position_in_rect(9, 20, &rect)); + assert!(!position_in_rect(40, 20, &rect)); + assert!(!position_in_rect(10, 19, &rect)); + assert!(!position_in_rect(10, 25, &rect)); + } + + #[test] + fn position_in_rect_empty() { + let rect = Rect::new(0, 0, 0, 0); + assert!(!position_in_rect(0, 0, &rect)); + } +} diff --git a/crates/tracexec-tui/src/pseudo_term.rs b/crates/tracexec-tui/src/pseudo_term.rs index 9180b949..904339b0 100644 --- a/crates/tracexec-tui/src/pseudo_term.rs +++ b/crates/tracexec-tui/src/pseudo_term.rs @@ -39,6 +39,9 @@ use crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, + MouseButton, + MouseEvent, + MouseEventKind, }; use ratatui::{ prelude::{ @@ -342,6 +345,202 @@ impl PseudoTerminalPane { pub fn exit(&self) { self.master_cancellation_token.cancel() } + + /// Handle a mouse event by converting it to xterm mouse escape sequences + /// and sending them to the PTY. The `col` and `row` are relative to the + /// Scroll up by one line in scrollback mode. + /// Returns true if scrollback mode is active and the scroll was handled. + pub fn scroll_up(&self) -> bool { + if !self.scrollback_mode.get() { + return false; + } + let mut parser = self.parser.write().unwrap(); + let screen = parser.screen_mut(); + let current = screen.scrollback(); + if current < self.scrollback_lines { + screen.set_scrollback(current + 1); + } + true + } + + /// Scroll down by one line in scrollback mode. + /// Returns true if scrollback mode is active and the scroll was handled. + pub fn scroll_down(&self) -> bool { + if !self.scrollback_mode.get() { + return false; + } + let mut parser = self.parser.write().unwrap(); + let screen = parser.screen_mut(); + let current = screen.scrollback(); + if current > 0 { + screen.set_scrollback(current - 1); + } + true + } + + /// terminal pane's inner area (0-based). + /// Only sends escape sequences when the terminal has enabled mouse capture, + /// respecting both the protocol mode and encoding. + pub async fn handle_mouse_event(&self, event: &MouseEvent, col: u16, row: u16) { + let (mode, encoding) = { + let parser = self.parser.read().unwrap(); + let screen = parser.screen(); + ( + screen.mouse_protocol_mode(), + screen.mouse_protocol_encoding(), + ) + }; + + // Only forward mouse events if the program running in the terminal + // has enabled mouse reporting (e.g. via DECSET 1000/1002/1003). + if mode == vt100::MouseProtocolMode::None { + return; + } + + // Filter events based on the protocol mode + let dominated = match mode { + vt100::MouseProtocolMode::None => unreachable!(), + // X10: only button press + vt100::MouseProtocolMode::Press => matches!( + event.kind, + MouseEventKind::Down(_) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight + ), + // VT200: button press and release + vt100::MouseProtocolMode::PressRelease => matches!( + event.kind, + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight + ), + // Button press, release, and drag (motion with button held) + vt100::MouseProtocolMode::ButtonMotion => matches!( + event.kind, + MouseEventKind::Down(_) + | MouseEventKind::Up(_) + | MouseEventKind::Drag(_) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight + ), + // Everything including plain motion + vt100::MouseProtocolMode::AnyMotion => true, + }; + if !dominated { + return; + } + + let seq = match encoding { + vt100::MouseProtocolEncoding::Sgr => encode_sgr_mouse(event, col, row), + // Default and UTF-8 both use the traditional encoding + vt100::MouseProtocolEncoding::Default | vt100::MouseProtocolEncoding::Utf8 => { + encode_default_mouse(event, col, row) + } + }; + if let Some(seq) = seq { + self + .master_tx + .send(Bytes::from(seq.into_bytes())) + .await + .ok(); + } + } +} + +/// Encode a mouse event as an SGR (1006) escape sequence. +fn encode_sgr_mouse(event: &MouseEvent, col: u16, row: u16) -> Option { + // SGR (1006) mouse encoding: ESC [ < button ; col ; row M/m + // button: 0 = left, 1 = middle, 2 = right, 64 = scroll up, 65 = scroll down + // +32 for motion events + // M = press/motion, m = release + let (button, is_release) = match event.kind { + MouseEventKind::Down(MouseButton::Left) => (0u8, false), + MouseEventKind::Down(MouseButton::Middle) => (1, false), + MouseEventKind::Down(MouseButton::Right) => (2, false), + MouseEventKind::Up(MouseButton::Left) => (0, true), + MouseEventKind::Up(MouseButton::Middle) => (1, true), + MouseEventKind::Up(MouseButton::Right) => (2, true), + MouseEventKind::Drag(MouseButton::Left) => (32, false), + MouseEventKind::Drag(MouseButton::Middle) => (33, false), + MouseEventKind::Drag(MouseButton::Right) => (34, false), + MouseEventKind::ScrollUp => (64, false), + MouseEventKind::ScrollDown => (65, false), + MouseEventKind::ScrollLeft => (66, false), + MouseEventKind::ScrollRight => (67, false), + MouseEventKind::Moved => (35, false), + }; + + // Add modifier flags + let mut button = button; + if event.modifiers.contains(KeyModifiers::SHIFT) { + button += 4; + } + if event.modifiers.contains(KeyModifiers::ALT) { + button += 8; + } + if event.modifiers.contains(KeyModifiers::CONTROL) { + button += 16; + } + + // SGR format: ESC [ < button ; col+1 ; row+1 M/m + let suffix = if is_release { 'm' } else { 'M' }; + Some(format!( + "\x1b[<{};{};{}{}", + button, + col + 1, + row + 1, + suffix + )) +} + +/// Encode a mouse event in the traditional X10/default format. +/// Returns `None` for events that cannot be represented (release events in +/// default encoding use button=3, coordinates > 222 are unrepresentable). +fn encode_default_mouse(event: &MouseEvent, col: u16, row: u16) -> Option { + // Traditional encoding: ESC [ M Cb Cx Cy + // Cb = button + 32, Cx = col + 33, Cy = row + 33 + // Coordinates are limited to 222 (255 - 33) + if col > 222 || row > 222 { + return None; + } + + let button: u8 = match event.kind { + MouseEventKind::Down(MouseButton::Left) => 0, + MouseEventKind::Down(MouseButton::Middle) => 1, + MouseEventKind::Down(MouseButton::Right) => 2, + MouseEventKind::Up(_) => 3, // Release is encoded as button 3 + MouseEventKind::Drag(MouseButton::Left) => 32, + MouseEventKind::Drag(MouseButton::Middle) => 33, + MouseEventKind::Drag(MouseButton::Right) => 34, + MouseEventKind::ScrollUp => 64, + MouseEventKind::ScrollDown => 65, + MouseEventKind::ScrollLeft => 66, + MouseEventKind::ScrollRight => 67, + MouseEventKind::Moved => 35, + }; + + let mut button = button; + if event.modifiers.contains(KeyModifiers::SHIFT) { + button += 4; + } + if event.modifiers.contains(KeyModifiers::ALT) { + button += 8; + } + if event.modifiers.contains(KeyModifiers::CONTROL) { + button += 16; + } + + let cb = (button + 32) as char; + let cx = (col as u8 + 33) as char; + let cy = (row as u8 + 33) as char; + Some(format!("\x1b[M{cb}{cx}{cy}")) } impl Widget for &PseudoTerminalPane { @@ -873,4 +1072,95 @@ mod tests { Ok(()) } + + #[test] + fn handle_mouse_event_sgr_encoding() -> color_eyre::Result<()> { + use super::encode_sgr_mouse; + + // Left button down at col=5, row=3 + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(encode_sgr_mouse(&mouse, 5, 3), Some("\x1b[<0;6;4M".into())); + + // Scroll up at col=2, row=1 + let mouse = MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(encode_sgr_mouse(&mouse, 2, 1), Some("\x1b[<64;3;2M".into())); + + // Release with shift modifier + let mouse = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::SHIFT, + }; + assert_eq!(encode_sgr_mouse(&mouse, 5, 3), Some("\x1b[<4;6;4m".into())); + + // Right button drag with control + let mouse = MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Right), + column: 0, + row: 0, + modifiers: KeyModifiers::CONTROL, + }; + assert_eq!( + encode_sgr_mouse(&mouse, 10, 20), + Some("\x1b[<50;11;21M".into()) + ); + + // Moved event returns button=35 (motion without button) + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(encode_sgr_mouse(&mouse, 0, 0), Some("\x1b[<35;1;1M".into())); + + Ok(()) + } + + #[test] + fn handle_mouse_event_default_encoding() -> color_eyre::Result<()> { + use super::encode_default_mouse; + + // Left button down at col=0, row=0 + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + }; + // button=0 β†’ Cb=32=' ', col=0+33=33='!', row=0+33=33='!' + assert_eq!(encode_default_mouse(&mouse, 0, 0), Some("\x1b[M !!".into())); + + // Release β†’ button=3 β†’ Cb=35='#' + let mouse = MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(encode_default_mouse(&mouse, 5, 3), Some("\x1b[M#&$".into())); + + // Coordinates > 222 are not representable + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 10, + row: 5, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(encode_default_mouse(&mouse, 223, 0), None); + assert_eq!(encode_default_mouse(&mouse, 0, 223), None); + + Ok(()) + } } diff --git a/crates/tracexec-tui/src/query.rs b/crates/tracexec-tui/src/query.rs index 62aa18e0..493cd89f 100644 --- a/crates/tracexec-tui/src/query.rs +++ b/crates/tracexec-tui/src/query.rs @@ -8,10 +8,7 @@ use crossterm::event::{ use itertools::Itertools; use ratatui::{ style::Styled, - text::{ - Line, - Span, - }, + text::Line, widgets::{ StatefulWidget, Widget, @@ -34,7 +31,10 @@ use tui_prompts::{ use super::{ event_line::EventLine, - help::help_item, + help::{ + HelpItem, + help_item, + }, theme::Theme, }; use crate::action::Action; @@ -251,11 +251,21 @@ impl QueryBuilder { } impl QueryBuilder { - pub fn help(&self, keys: &TuiKeyBindings, theme: &Theme) -> Vec> { + pub fn help<'a>(&self, keys: &TuiKeyBindings, theme: &'a Theme) -> Vec> { if self.editing { - [ - help_item!(keys.query_cancel.display(), "Cancel\u{00a0}Search", theme), - help_item!(keys.query_execute.display(), "Execute\u{00a0}Search", theme), + vec![ + help_item!( + keys.query_cancel.display(), + "Cancel\u{00a0}Search", + theme, + &keys.query_cancel + ), + help_item!( + keys.query_execute.display(), + "Execute\u{00a0}Search", + theme, + &keys.query_execute + ), help_item!( keys.query_toggle_case.display(), if self.case_sensitive { @@ -263,7 +273,8 @@ impl QueryBuilder { } else { "Case\u{00a0}Insensitive" }, - theme + theme, + &keys.query_toggle_case ), help_item!( keys.query_toggle_regex.display(), @@ -272,25 +283,31 @@ impl QueryBuilder { } else { "Text\u{00a0}Mode" }, - theme + theme, + &keys.query_toggle_regex + ), + help_item!( + keys.query_clear.display(), + "Clear", + theme, + &keys.query_clear ), - help_item!(keys.query_clear.display(), "Clear", theme), ] - .into_iter() - .flatten() - .collect() } else { - [ - help_item!(keys.query_next_match.display(), "Next\u{00a0}Match", theme), + vec![ + help_item!( + keys.query_next_match.display(), + "Next\u{00a0}Match", + theme, + &keys.query_next_match + ), help_item!( keys.query_prev_match.display(), "Previous\u{00a0}Match", - theme + theme, + &keys.query_prev_match ), ] - .into_iter() - .flatten() - .collect() } } } @@ -476,8 +493,10 @@ mod tests { let qb = QueryBuilder::new(QueryKind::Search); let keys = TuiKeyBindings::default(); let help = qb.help(&keys, current_theme()); - assert!(help.iter().any(|span| span.content.contains("Esc"))); - assert!(help.iter().any(|span| span.content.contains("Enter"))); + assert!(help.iter().any(|item| item.key_span.content.contains("Esc") || item.desc_span.content.contains("Esc"))); + assert!(help.iter().any( + |item| item.key_span.content.contains("Enter") || item.desc_span.content.contains("Enter") + )); } #[test] diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render.snap index d48192b1..36747a1c 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render.snap @@ -28,7 +28,7 @@ Buffer { "β”‚ β”‚", "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", " Β F1Β Β HelpΒ Β FΒ Β FollowΒ Β Ctrl+FΒ Β SearchΒ Β EΒ Β HideΒ EnvΒ Β WΒ Β HideΒ CWDΒ Β VΒ Β ViewΒ  ", - " Β QΒ Β QuitΒ  ", + " Β QΒ Β QuitΒ  ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, @@ -77,20 +77,20 @@ Buffer { x: 79, y: 20, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 4, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 8, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 8, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 14, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 17, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 17, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 25, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 33, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 33, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 41, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 44, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 44, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 54, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 57, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 57, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 67, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 70, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 70, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 76, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 36, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 39, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, - x: 45, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, + x: 38, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, + x: 44, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render_with_custom_theme.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render_with_custom_theme.snap index 6578a88a..bddbe553 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render_with_custom_theme.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__app__tests__snapshot_app_render_with_custom_theme.snap @@ -28,7 +28,7 @@ Buffer { "β”‚ β”‚", "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", " Β F1Β Β HelpΒ Β FΒ Β FollowΒ Β Ctrl+FΒ Β SearchΒ Β EΒ Β HideΒ EnvΒ Β WΒ Β HideΒ CWDΒ Β VΒ Β ViewΒ  ", - " Β QΒ Β QuitΒ  ", + " Β QΒ Β QuitΒ  ", ], styles: [ x: 0, y: 0, fg: Rgb(255, 176, 0), bg: Reset, underline: Reset, modifier: BOLD, @@ -77,20 +77,20 @@ Buffer { x: 79, y: 20, fg: Rgb(255, 176, 0), bg: Reset, underline: Reset, modifier: BOLD, x: 0, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 4, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 8, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 8, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 14, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 17, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 17, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 25, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 33, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 33, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 41, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 44, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 44, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 54, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 57, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 57, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 67, y: 22, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 70, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 70, y: 22, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, x: 76, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 36, y: 23, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, - x: 39, y: 23, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, - x: 45, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 35, y: 23, fg: Black, bg: Yellow, underline: Reset, modifier: BOLD, + x: 38, y: 23, fg: Rgb(255, 211, 107), bg: DarkGray, underline: Reset, modifier: BOLD, + x: 44, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__help__tests__snapshot_help_popup.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__help__tests__snapshot_help_popup.snap index df411c52..995131cd 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__help__tests__snapshot_help_popup.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__help__tests__snapshot_help_popup.snap @@ -90,77 +90,77 @@ Buffer { x: 64, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 66, y: 22, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 67, y: 22, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 68, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 68, y: 22, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 9, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 11, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 13, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 14, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 15, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 15, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 28, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 30, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 32, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 33, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 34, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 34, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 43, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 45, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 47, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 49, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 49, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 57, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 59, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 61, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 62, y: 23, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 63, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 63, y: 23, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 73, y: 23, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 2, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 3, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 4, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 4, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 21, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 23, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 25, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 26, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 27, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 27, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 46, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 50, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 51, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 52, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 52, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 64, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 66, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 68, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 69, y: 24, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 70, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 70, y: 24, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 78, y: 24, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 2, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 3, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 4, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 4, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 14, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 16, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 18, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 19, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 20, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 20, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 33, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 35, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 37, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 38, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 39, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 39, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 60, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 62, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 64, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 65, y: 25, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 66, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 66, y: 25, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 75, y: 25, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 26, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 2, y: 26, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 3, y: 26, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 4, y: 26, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 4, y: 26, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 14, y: 26, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 16, y: 26, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 18, y: 26, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 19, y: 26, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 20, y: 26, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, + x: 20, y: 26, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD, x: 48, y: 26, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 20, y: 27, fg: Black, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 27, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_editing_default_command.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_editing_default_command.snap index b74bc44c..d906e746 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_editing_default_command.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_editing_default_command.snap @@ -5,7 +5,7 @@ expression: rendered Buffer { area: Rect { x: 0, y: 0, width: 70, height: 12 }, content: [ - " Β F1Β Β HelpΒ ", + " ", "? default command β€Ί ", "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Hit Manager ────────────────────────────┐", "β”‚4321 hit breakpoint #0(in-filename:/bin/echo) at SyscallExit β”‚", @@ -20,8 +20,6 @@ Buffer { ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 60, y: 0, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 64, y: 0, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, x: 0, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_no_default_command.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_no_default_command.snap index d90642fc..34f44d3b 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_no_default_command.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_no_default_command.snap @@ -5,7 +5,7 @@ expression: rendered Buffer { area: Rect { x: 0, y: 0, width: 70, height: 12 }, content: [ - " Β F1Β Β HelpΒ ", + " ", "default command not set. Press Β EΒ  to set ", "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Hit Manager ────────────────────────────┐", "β”‚4321 hit breakpoint #0(in-filename:/bin/echo) at SyscallExit β”‚", @@ -20,8 +20,6 @@ Buffer { ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 60, y: 0, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 64, y: 0, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, x: 0, y: 1, fg: LightYellow, bg: Reset, underline: Reset, modifier: BOLD, x: 31, y: 1, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, x: 34, y: 1, fg: LightYellow, bg: Reset, underline: Reset, modifier: BOLD, diff --git a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_with_hits_and_default_command.snap b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_with_hits_and_default_command.snap index d176ae84..aa14c720 100644 --- a/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_with_hits_and_default_command.snap +++ b/crates/tracexec-tui/src/snapshots/tracexec_tui__hit_manager__tests__snapshot_hit_manager_with_hits_and_default_command.snap @@ -5,7 +5,7 @@ expression: rendered Buffer { area: Rect { x: 0, y: 0, width: 70, height: 12 }, content: [ - " Β F1Β Β HelpΒ ", + " ", "πŸš€ default command: echo {{PID}} ", // hidden by multi-width symbols: [(1, " ")] "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Hit Manager ────────────────────────────┐", "β”‚4321 hit breakpoint #0(in-filename:/bin/echo) at SyscallExit β”‚", @@ -20,9 +20,6 @@ Buffer { ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 60, y: 0, fg: Black, bg: Cyan, underline: Reset, modifier: BOLD, - x: 64, y: 0, fg: LightGreen, bg: DarkGray, underline: Reset, modifier: BOLD | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 20, y: 1, fg: LightCyan, bg: Reset, underline: Reset, modifier: BOLD, x: 32, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: LightMagenta, bg: Reset, underline: Reset, modifier: REVERSED, diff --git a/crates/tracexec-tui/src/theme.rs b/crates/tracexec-tui/src/theme.rs index d794afe0..cb9c63dc 100644 --- a/crates/tracexec-tui/src/theme.rs +++ b/crates/tracexec-tui/src/theme.rs @@ -45,7 +45,9 @@ pub struct Theme { pub inline_timestamp: Style, pub cli_flag: Style, pub help_key: Style, + pub help_key_hover: Style, pub help_desc: Style, + pub help_desc_hover: Style, pub fancy_help_desc: Style, pub pid_success: Style, pub pid_failure: Style, @@ -149,11 +151,9 @@ impl Default for Theme { inline_timestamp: Style::default().light_cyan(), cli_flag: Style::default().yellow().on_dark_gray().bold(), help_key: Style::default().black().on_cyan().bold(), - help_desc: Style::default() - .light_green() - .on_dark_gray() - .italic() - .bold(), + help_key_hover: Style::default().black().on_light_cyan().bold(), + help_desc: Style::default().light_green().on_dark_gray().bold(), + help_desc_hover: Style::default().white().on_dark_gray().bold(), fancy_help_desc: Style::default().red().on_light_yellow().bold().slow_blink(), pid_success: Style::default().light_green(), pid_failure: Style::default().light_red(), @@ -286,7 +286,9 @@ fn apply_theme_spec(spec: ThemeSpec, mut theme: Theme) -> color_eyre::Result Date: Sat, 27 Jun 2026 09:38:03 +0700 Subject: [PATCH 2/2] tui: add mouse support for copy popup --- crates/tracexec-tui/src/app.rs | 52 ++++++--- crates/tracexec-tui/src/copy_popup.rs | 155 +++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/crates/tracexec-tui/src/app.rs b/crates/tracexec-tui/src/app.rs index 9b86a4e9..cccd088d 100644 --- a/crates/tracexec-tui/src/app.rs +++ b/crates/tracexec-tui/src/app.rs @@ -795,6 +795,42 @@ impl App { self.hover_state.row = row; } + // When overlays are active (popups, breakpoint manager, hit manager), + // block mouse events from reaching the event list and terminal pane. + // Route events to the active popup and keep footer help entries clickable. + let has_overlay = !self.popup.is_empty() + || self.breakpoint_manager.is_some() + || self.hit_manager_state.as_ref().is_some_and(|h| h.visible); + if has_overlay { + if let Some(popup) = self.popup.last_mut() { + match popup { + ActivePopup::ViewDetails(state) => { + state.handle_mouse_event(&me); + } + ActivePopup::CopyTargetSelection(state) => { + if let Some(action) = state.handle_mouse_event(&me) { + action_tx.send(action); + return; + } + } + _ => {} + } + } + + if position_in_rect(col, row, &self.layout_areas.footer) + && matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) + { + for entry in &self.help_bar_entries { + if position_in_rect(col, row, &entry.area) { + action_tx.send(Action::HandleHelpBarClick(entry.key_event)); + return; + } + } + } + + return; + } + // Handle divider dragging between event list and terminal pane if self.term.is_some() { if self.dragging_divider { @@ -878,22 +914,6 @@ impl App { } } - // When overlays are active (popups, breakpoint manager, hit manager), - // block mouse events from reaching the event list and terminal pane. - // But still route relevant events to the active popup. - let has_overlay = !self.popup.is_empty() - || self.breakpoint_manager.is_some() - || self.hit_manager_state.as_ref().is_some_and(|h| h.visible); - if has_overlay { - // Route mouse events to the topmost popup if applicable - if let Some(popup) = self.popup.last_mut() - && let ActivePopup::ViewDetails(state) = popup - { - state.handle_mouse_event(&me); - } - return; - } - // Check terminal pane clicks if let Some(term_inner) = self.layout_areas.terminal_inner && let Some(term_outer) = self.layout_areas.terminal_outer diff --git a/crates/tracexec-tui/src/copy_popup.rs b/crates/tracexec-tui/src/copy_popup.rs index b11b0696..ae4b391a 100644 --- a/crates/tracexec-tui/src/copy_popup.rs +++ b/crates/tracexec-tui/src/copy_popup.rs @@ -3,7 +3,12 @@ use std::{ sync::Arc, }; -use crossterm::event::KeyEvent; +use crossterm::event::{ + KeyEvent, + MouseButton, + MouseEvent, + MouseEventKind, +}; use ratatui::{ buffer::Buffer, layout::{ @@ -42,6 +47,7 @@ use crate::{ CopyTarget, SupportedShell::Bash, }, + mouse::position_in_rect, theme::Theme, }; @@ -53,6 +59,8 @@ pub struct CopyPopupState { pub event: Arc, pub state: ListState, pub available_targets: Vec, + rendered_area: Rect, + list_area: Rect, key_bindings: Arc, theme: &'static Theme, } @@ -145,6 +153,8 @@ impl CopyPopupState { event, state, available_targets, + rendered_area: Rect::default(), + list_area: Rect::default(), key_bindings, theme, } @@ -226,6 +236,41 @@ impl CopyPopupState { } Ok(None) } + + pub fn handle_mouse_event(&mut self, event: &MouseEvent) -> Option { + let col = event.column; + let row = event.row; + + if !position_in_rect(col, row, &self.rendered_area) { + return None; + } + + let hovered_target = position_in_rect(col, row, &self.list_area) + .then_some((row - self.list_area.y) as usize) + .filter(|idx| *idx < self.available_targets.len()); + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(idx) = hovered_target { + self.state.select(Some(idx)); + return Some(Action::CopyToClipboard { + event: self.event.clone(), + target: self.selected(), + }); + } + } + MouseEventKind::Moved => { + if let Some(idx) = hovered_target { + self.state.select(Some(idx)); + } + } + MouseEventKind::ScrollUp => self.prev(), + MouseEventKind::ScrollDown => self.next(), + _ => {} + } + + None + } } fn copy_target_config(target: CopyTarget) -> CopyTargetConfig { @@ -277,6 +322,13 @@ impl StatefulWidgetRef for CopyPopup { .highlight_symbol(">") .highlight_spacing(HighlightSpacing::Always); let popup_area = centered_popup_rect(38, list.len() as u16, area); + state.rendered_area = popup_area; + state.list_area = Rect { + x: popup_area.x.saturating_add(1), + y: popup_area.y.saturating_add(1), + width: popup_area.width.saturating_sub(2), + height: popup_area.height.saturating_sub(2), + }; Clear.render(popup_area, buf); StatefulWidget::render(&list, popup_area, buf, &mut state.state); } @@ -313,8 +365,8 @@ fn centered_popup_rect(width: u16, height: u16, area: Rect) -> Rect { let height = height.saturating_add(2).min(area.height); let width = width.saturating_add(2).min(area.width); Rect { - x: area.width.saturating_sub(width) / 2, - y: area.height.saturating_sub(height) / 2, + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, width: min(width, area.width), height: min(height, area.height), } @@ -322,15 +374,35 @@ fn centered_popup_rect(width: u16, height: u16, area: Rect) -> Rect { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{ + collections::BTreeMap, + sync::Arc, + }; + use crossterm::event::{ + KeyModifiers, + MouseButton, + MouseEvent, + MouseEventKind, + }; use insta::assert_snapshot; + use nix::unistd::Pid; use tracexec_core::{ + cache::ArcStr, cli::keys::TuiKeyBindings, event::{ + ExecEvent, + ExecSyscall, + OutputMsg, TracerEventDetails, TracerEventMessage, }, + proc::{ + CgroupInfo, + FileDescriptorInfoCollection, + diff_env, + }, + timestamp::ts_from_boot_ns, }; use super::{ @@ -338,6 +410,10 @@ mod tests { CopyPopupState, }; use crate::{ + action::{ + Action, + CopyTarget, + }, test_utils::{ test_area_full, test_render_stateful_widget_area, @@ -345,6 +421,30 @@ mod tests { theme::current_theme, }; + fn exec_details() -> TracerEventDetails { + TracerEventDetails::Exec(Box::new(ExecEvent { + syscall: ExecSyscall::Execve, + exec_pid: Pid::from_raw(100), + pid: Pid::from_raw(100), + cwd: OutputMsg::Ok(ArcStr::from("/tmp")), + comm: ArcStr::from("cmd"), + filename: OutputMsg::Ok(ArcStr::from("/bin/true")), + argv: Arc::new(Ok(vec![OutputMsg::Ok(ArcStr::from("true"))])), + envp: Arc::new(Ok(BTreeMap::new())), + has_dash_env: false, + cred: Ok(Default::default()), + interpreter: None, + env_diff: Ok(diff_env(&BTreeMap::new(), &BTreeMap::new())), + fdinfo: Arc::new(FileDescriptorInfoCollection::default()), + result: 0, + timestamp: ts_from_boot_ns(1), + parent: None, + cgroup: CgroupInfo::V2 { + path: "/".to_string(), + }, + })) + } + #[test] fn snapshot_copy_popup_info_event() { let event = Arc::new(TracerEventDetails::Info(TracerEventMessage { @@ -358,4 +458,51 @@ mod tests { let rendered = test_render_stateful_widget_area(CopyPopup, area, &mut state); assert_snapshot!(rendered); } + + #[test] + fn mouse_click_on_target_selects_and_copies() { + let event = Arc::new(TracerEventDetails::Info(TracerEventMessage { + pid: None, + timestamp: None, + msg: "hello".to_string(), + })); + let mut state = + CopyPopupState::new(event, Arc::new(TuiKeyBindings::default()), current_theme()); + let area = test_area_full(40, 40); + let _ = test_render_stateful_widget_area(CopyPopup, area, &mut state); + + let action = state.handle_mouse_event(&MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: state.list_area.x, + row: state.list_area.y, + modifiers: KeyModifiers::NONE, + }); + + assert!(matches!( + action, + Some(Action::CopyToClipboard { + target: CopyTarget::Line, + .. + }) + )); + } + + #[test] + fn mouse_move_over_target_updates_highlight() { + let event = Arc::new(exec_details()); + let mut state = + CopyPopupState::new(event, Arc::new(TuiKeyBindings::default()), current_theme()); + let area = test_area_full(80, 40); + let _ = test_render_stateful_widget_area(CopyPopup, area, &mut state); + + let action = state.handle_mouse_event(&MouseEvent { + kind: MouseEventKind::Moved, + column: state.list_area.x, + row: state.list_area.y + 2, + modifiers: KeyModifiers::NONE, + }); + + assert!(action.is_none()); + assert_eq!(state.state.selected(), Some(2)); + } }