diff --git a/src/app.rs b/src/app.rs index 85b8dd6..9c365f2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,13 +9,14 @@ use crate::input::{apply_to_string, apply_to_textarea, text_edit_action, TextEdi use crate::library::{CategoryFile, CommandEntry, CommandLibrary, CommandVariant}; use crate::render::{self, RenderContext}; use crate::ui::{self, splash::SplashState}; +use crate::ui::layout::{scroll_by, HitRegions, StatusBarAction}; use crate::vim::{Action, KeyParser, Mode}; use anyhow::{Context, Result}; use crossterm::event::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyboardEnhancementFlags, - KeyCode, KeyEvent, KeyEventKind, KeyModifiers, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; use crossterm::terminal::{ @@ -25,6 +26,7 @@ use futures::StreamExt; use nucleo_matcher::{Matcher, Utf32Str, pattern::Pattern}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; use std::collections::{BTreeMap, HashSet}; use std::fs; use std::io::{Stdout, stdout}; @@ -389,6 +391,7 @@ pub struct CveModal { pub results: Vec, pub cursor: usize, pub detail: bool, + pub detail_scroll: usize, pub kev_only: bool, pub syncing: bool, pub db_total: u64, @@ -429,6 +432,8 @@ pub struct EditModal { /// Tab-completion candidates for the current path token. pub path_suggestions: Vec, pub path_pick: usize, + /// Viewport scroll top (row, col) mirrored from tui-textarea each frame. + pub textarea_scroll: (u16, u16), } #[derive(Debug, Clone)] @@ -470,6 +475,8 @@ pub struct App { pub selected_category: usize, pub selected_command: usize, pub selected_job: usize, + pub preview_scroll: usize, + pub preview_visible_lines: usize, pub multi_selected: HashSet<(String, String)>, pub marks: BTreeMap, pub last_action: Option, @@ -495,6 +502,33 @@ pub struct App { pub cve_sync_rx: Option>>, /// Engagement directory root (`--root` or default XDG path). engagements_root: PathBuf, + + /// Last-frame panel bounds for mouse hit-testing. + pub hit: HitRegions, + + /// Tracks the previous click for double-click detection. + last_mouse_click: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MouseClickTarget { + Commands { index: usize }, + Jobs { index: usize }, + Engagement { index: usize }, + Target { index: usize }, + Ap { index: usize }, + Pivot { index: usize }, + Creds { index: usize }, + Variables { index: usize }, + Search { index: usize }, + Cve { index: usize }, + EditSuggestion { index: usize }, +} + +#[derive(Debug, Clone, Copy)] +struct LastMouseClick { + at: Instant, + target: MouseClickTarget, } /// Options from the CLI when launching the TUI (global `-e` / `--root`). @@ -524,6 +558,8 @@ impl App { selected_category: 0, selected_command: 0, selected_job: 0, + preview_scroll: 0, + preview_visible_lines: 1, multi_selected: HashSet::new(), marks: BTreeMap::new(), last_action: None, @@ -541,6 +577,8 @@ impl App { cve_sync_rx: None, needs_full_redraw: false, engagements_root, + hit: HitRegions::default(), + last_mouse_click: None, }; if let Some(name) = boot.engagement { app.try_open_engagement_by_name(&name); @@ -613,6 +651,9 @@ impl App { Event::Resize(_, _) => { self.needs_full_redraw = true; } + Event::Mouse(me) => { + self.handle_mouse(me); + } _ => {} } } @@ -723,6 +764,707 @@ impl App { } } + // === mouse handling ================================================== + + const WHEEL_LINES: i32 = 3; + const WHEEL_ITEMS: i32 = 1; + const DOUBLE_CLICK_MS: u128 = 450; + + fn is_double_click(&mut self, target: MouseClickTarget) -> bool { + let now = Instant::now(); + let is_double = self.last_mouse_click.is_some_and(|prev| { + prev.target == target && now.duration_since(prev.at).as_millis() < Self::DOUBLE_CLICK_MS + }); + self.last_mouse_click = Some(LastMouseClick { at: now, target }); + is_double + } + + fn clear_mouse_click(&mut self) { + self.last_mouse_click = None; + } + + fn handle_mouse(&mut self, me: MouseEvent) { + match me.kind { + MouseEventKind::Down(MouseButton::Left) => self.handle_mouse_click(me), + MouseEventKind::ScrollUp => self.handle_mouse_scroll(me, -1), + MouseEventKind::ScrollDown => self.handle_mouse_scroll(me, 1), + _ => {} + } + } + + fn handle_mouse_click(&mut self, me: MouseEvent) { + if self.splash.is_some() { + self.splash = None; + self.key_parser.reset(); + return; + } + + let shift = me.modifiers.contains(KeyModifiers::SHIFT); + let col = me.column; + let row = me.row; + + if matches!(self.mode, Mode::Normal) && matches!(self.modal, Modal::None) { + if let Some(action) = self.hit.status_bar.chip_at(col, row) { + self.handle_status_bar_action(action); + self.clear_mouse_click(); + return; + } + } + + if let Some(popup) = self.hit.modal_popup { + let pos = Position { x: col, y: row }; + if !popup.contains(pos) { + self.dismiss_modal_backdrop(); + return; + } + if matches!(self.modal, Modal::Edit(_)) { + self.handle_edit_modal_click(col, row, shift); + return; + } + self.handle_modal_panel_click(col, row, shift); + return; + } + + if !matches!(self.mode, Mode::Normal) { + return; + } + + if !matches!(self.modal, Modal::None) { + return; + } + + self.handle_main_panel_click(col, row, shift); + } + + fn handle_mouse_scroll(&mut self, me: MouseEvent, direction: i32) { + if self.splash.is_some() { + return; + } + + let col = me.column; + let row = me.row; + + if let Some(region) = self.hit.job_log_scroll { + if region.contains(col, row) { + self.scroll_job_log(direction * Self::WHEEL_LINES); + return; + } + } + if let Some(region) = self.hit.help_scroll { + if region.contains(col, row) { + self.scroll_help(direction * Self::WHEEL_LINES); + return; + } + } + if let Some(list) = self.hit.search_list { + if list.contains_list(col, row) { + self.scroll_search_cursor(direction * Self::WHEEL_ITEMS); + return; + } + } + if let Some(region) = self.hit.cve_detail_scroll { + if region.contains(col, row) { + self.scroll_cve_detail(direction * Self::WHEEL_LINES); + return; + } + } + if let Some(list) = self.hit.cve_list { + if list.contains_list(col, row) { + self.scroll_cve_cursor(direction * Self::WHEEL_ITEMS); + return; + } + } + if let Some(region) = self.hit.edit.as_ref().map(|e| e.textarea) { + if region.contains(col, row) { + self.scroll_edit_textarea(direction * Self::WHEEL_LINES); + return; + } + } + + if !matches!(self.modal, Modal::None) || !matches!(self.mode, Mode::Normal) { + return; + } + + let hit = &self.hit; + if hit.categories.contains_list(col, row) { + self.focus = Focus::Categories; + self.move_cursor(direction * Self::WHEEL_ITEMS); + return; + } + if hit.commands.contains_list(col, row) { + self.focus = Focus::Commands; + self.move_cursor(direction * Self::WHEEL_ITEMS); + return; + } + if hit.preview.contains(col, row) { + self.focus = Focus::Preview; + self.scroll_preview(direction * Self::WHEEL_LINES); + return; + } + if hit.jobs.contains_list(col, row) { + self.focus = Focus::Jobs; + self.move_cursor(direction * Self::WHEEL_ITEMS); + } + } + + fn scroll_job_log(&mut self, delta: i32) { + let Modal::JobLog(modal) = &mut self.modal else { + return; + }; + modal.follow = false; + let vis = modal.last_visible_lines.max(1); + let max = ui::modals::job_log::max_scroll(modal, vis); + modal.scroll = scroll_by(modal.scroll, delta, max); + ui::modals::job_log::clamp_scroll(modal, vis); + } + + fn scroll_help(&mut self, delta: i32) { + let Modal::Help(modal) = &mut self.modal else { + return; + }; + let vis = modal.last_visible_lines.max(1); + let max = ui::modals::help::max_scroll(modal, vis); + modal.scroll = scroll_by(modal.scroll, delta, max); + ui::modals::help::clamp_scroll(modal, vis); + } + + fn scroll_preview(&mut self, delta: i32) { + let vis = self.preview_visible_lines.max(1); + let max = ui::panels::preview::max_preview_scroll(self, vis); + self.preview_scroll = scroll_by(self.preview_scroll, delta, max); + ui::panels::preview::clamp_preview_scroll(self, vis); + } + + fn scroll_search_cursor(&mut self, delta: i32) { + let Modal::Search { matches, cursor } = &mut self.modal else { + return; + }; + if matches.is_empty() { + return; + } + let max = matches.len().saturating_sub(1) as i32; + *cursor = (*cursor as i32 + delta).clamp(0, max) as usize; + } + + fn scroll_cve_cursor(&mut self, delta: i32) { + let Modal::Cve(m) = &mut self.modal else { + return; + }; + if m.detail || m.syncing || m.results.is_empty() { + return; + } + let max = m.results.len().saturating_sub(1) as i32; + let new = (m.cursor as i32 + delta).clamp(0, max) as usize; + if new != m.cursor { + m.cursor = new; + m.detail_scroll = 0; + } + } + + fn scroll_cve_detail(&mut self, delta: i32) { + let Modal::Cve(m) = &mut self.modal else { + return; + }; + if !m.detail { + return; + } + let vis = self + .hit + .cve_detail_scroll + .map(|r| r.visible_lines) + .unwrap_or(1); + let max = ui::modals::cve::max_detail_scroll(m, vis); + m.detail_scroll = scroll_by(m.detail_scroll, delta, max); + ui::modals::cve::clamp_detail_scroll(m, vis); + } + + fn reset_preview_scroll(&mut self) { + self.preview_scroll = 0; + } + + fn handle_status_bar_action(&mut self, action: StatusBarAction) { + match action { + StatusBarAction::Engagement => self.open_engagement_modal(&[]), + StatusBarAction::Target => self.open_target_modal(&[]), + StatusBarAction::Ap => self.open_ap_modal(&[]), + StatusBarAction::Pivot => self.open_pivot_modal(&[]), + StatusBarAction::Creds => self.open_creds_modal(&[]), + StatusBarAction::Jobs => self.focus = Focus::Jobs, + } + } + + fn handle_edit_modal_click(&mut self, column: u16, row: u16, shift: bool) { + let _ = shift; + let hit = self.hit.edit; + + if let Some(list) = hit.and_then(|e| e.suggestions) { + let len = match &self.modal { + Modal::Edit(m) => m.path_suggestions.len(), + _ => 0, + }; + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Edit(m) = &mut self.modal { + m.path_pick = idx; + } + if self.is_double_click(MouseClickTarget::EditSuggestion { index: idx }) { + self.apply_edit_path_pick(idx); + } + return; + } + } + + let Some(edit) = hit else { + return; + }; + if !edit.textarea.contains(column, row) { + return; + } + + let (scroll, panel) = match &mut self.modal { + Modal::Edit(m) => (m.textarea_scroll, edit.textarea_panel), + _ => return, + }; + if let Modal::Edit(m) = &mut self.modal { + m.path_suggestions.clear(); + crate::ui::textarea_mouse::textarea_cursor_from_click( + &mut m.textarea, + panel, + scroll, + column, + row, + ); + } + self.clear_mouse_click(); + } + + fn apply_edit_path_pick(&mut self, pick: usize) { + let Modal::Edit(em) = &mut self.modal else { + return; + }; + if pick >= em.path_suggestions.len() { + return; + } + em.path_pick = pick; + let choice = em.path_suggestions[pick].clone(); + let (row, col) = em.textarea.cursor(); + let line = em.textarea.lines().get(row).cloned().unwrap_or_default(); + let Some(token) = crate::path_complete::token_at_cursor(&line, col as usize) else { + return; + }; + crate::path_complete::replace_token( + &mut em.textarea, + row, + token.start, + token.end, + &choice, + ); + } + + fn scroll_edit_textarea(&mut self, delta: i32) { + let Modal::Edit(m) = &mut self.modal else { + return; + }; + let rows = delta.clamp(-32, 32) as i16; + if rows != 0 { + m.textarea.scroll((rows, 0)); + } + } + + fn dismiss_modal_backdrop(&mut self) { + match (&self.modal, self.mode) { + (Modal::None, _) => {} + (_, Mode::Search) | (_, Mode::SearchGlobal) => { + self.mode = Mode::Normal; + self.search_buf.clear(); + self.modal = Modal::None; + self.needs_full_redraw = true; + } + (Modal::Edit(_), Mode::Insert) => { + self.modal = Modal::None; + self.mode = Mode::Normal; + self.needs_full_redraw = true; + } + (Modal::Cve(m), _) if m.syncing => { + self.modal = Modal::None; + self.needs_full_redraw = true; + } + (Modal::Cve(m), _) if m.detail => { + if let Modal::Cve(m) = &mut self.modal { + m.detail = false; + } + } + _ => { + self.modal = Modal::None; + self.mode = Mode::Normal; + self.needs_full_redraw = true; + } + } + } + + fn handle_main_panel_click(&mut self, column: u16, row: u16, shift: bool) { + let hit = &self.hit; + + if let Some(idx) = hit + .categories + .list_index_at(column, row, self.library.categories.len()) + { + self.focus = Focus::Categories; + if idx != self.selected_category { + self.selected_category = idx; + self.selected_command = 0; + self.reset_preview_scroll(); + } + self.clear_mouse_click(); + return; + } + + if hit.categories.contains_panel(column, row) { + self.focus = Focus::Categories; + self.clear_mouse_click(); + return; + } + + let cmd_len = self.visible_commands().len(); + if let Some(idx) = hit.commands.list_index_at(column, row, cmd_len) { + self.focus = Focus::Commands; + if idx != self.selected_command { + self.selected_command = idx; + self.reset_preview_scroll(); + } + if shift { + self.toggle_select_at_index(idx); + self.clear_mouse_click(); + return; + } + if self.is_double_click(MouseClickTarget::Commands { index: idx }) { + self.run_current(1); + } + return; + } + + if hit.commands.contains_panel(column, row) { + self.focus = Focus::Commands; + self.clear_mouse_click(); + return; + } + + if hit.preview.contains(column, row) { + self.focus = Focus::Preview; + self.clear_mouse_click(); + return; + } + + let job_count = self.jobs.len().min(50); + if let Some(idx) = hit.jobs.list_index_at(column, row, job_count) { + self.focus = Focus::Jobs; + self.selected_job = idx.min(self.jobs.len().saturating_sub(1)); + if self.is_double_click(MouseClickTarget::Jobs { index: idx }) { + self.open_job_log_modal(); + } + return; + } + + if hit.jobs.contains_panel(column, row) { + self.focus = Focus::Jobs; + self.clear_mouse_click(); + } + } + + fn handle_modal_panel_click(&mut self, column: u16, row: u16, shift: bool) { + let _ = shift; + let hit = &self.hit; + + if let Some(list) = hit.search_list { + let len = match &self.modal { + Modal::Search { matches, .. } => matches.len(), + _ => 0, + }; + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Search { cursor, .. } = &mut self.modal { + *cursor = idx; + } + if self.is_double_click(MouseClickTarget::Search { index: idx }) { + self.commit_search_selection(); + } + return; + } + } + + if let Some(list) = hit.cve_list { + let len = match &self.modal { + Modal::Cve(m) if !m.detail && !m.syncing => m.results.len(), + _ => 0, + }; + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Cve(m) = &mut self.modal { + m.cursor = idx; + m.detail_scroll = 0; + } + if self.is_double_click(MouseClickTarget::Cve { index: idx }) { + if let Modal::Cve(m) = &mut self.modal { + if !m.results.is_empty() { + m.detail = true; + m.detail_scroll = 0; + } + } + } + return; + } + } + + if let Some(list) = hit.engagement_list { + let len = match &self.modal { + Modal::Engagement(m) if m.new_name_prompt.is_none() => m.available.len(), + _ => 0, + }; + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Engagement(m) = &mut self.modal { + m.cursor = idx; + } + if self.is_double_click(MouseClickTarget::Engagement { index: idx }) { + self.activate_engagement_modal(); + } + return; + } + } + + if let Some(list) = hit.target_list { + let len = self + .engagement + .as_ref() + .map(|e| e.targets.targets.len()) + .unwrap_or(0); + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Target(m) = &mut self.modal { + if let TargetModalState::List { cursor } = &mut m.state { + *cursor = idx; + } + } + if self.is_double_click(MouseClickTarget::Target { index: idx }) { + self.activate_target_modal(); + } + return; + } + } + + if let Some(list) = hit.ap_list { + let len = self + .engagement + .as_ref() + .map(|e| e.aps.aps.len()) + .unwrap_or(0); + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Ap(m) = &mut self.modal { + if let ApModalState::List { cursor } = &mut m.state { + *cursor = idx; + } + } + if self.is_double_click(MouseClickTarget::Ap { index: idx }) { + self.activate_ap_modal(); + } + return; + } + } + + if let Some(list) = hit.pivot_list { + let len = self + .engagement + .as_ref() + .map(|e| e.pivots.pivots.len()) + .unwrap_or(0); + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Pivot(m) = &mut self.modal { + if let PivotModalState::List { cursor } = &mut m.state { + *cursor = idx; + } + } + if self.is_double_click(MouseClickTarget::Pivot { index: idx }) { + self.activate_pivot_modal(); + } + return; + } + } + + if let Some(list) = hit.creds_list { + let len = self + .engagement + .as_ref() + .map(|e| e.profiles.profiles.len()) + .unwrap_or(0); + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Creds(m) = &mut self.modal { + if let CredsModalState::List { cursor } = &mut m.state { + *cursor = idx; + } + } + if self.is_double_click(MouseClickTarget::Creds { index: idx }) { + self.activate_creds_modal(); + } + return; + } + } + + if let Some(list) = hit.variables_list { + let unset_only = match &self.modal { + Modal::Variables(m) => matches!( + m.state, + VariablesModalState::List { + unset_only: true, + .. + } + ), + _ => false, + }; + let len = self.variable_rows(unset_only).len(); + if let Some(idx) = list.list_index_at(column, row, len) { + if let Modal::Variables(m) = &mut self.modal { + if let VariablesModalState::List { cursor, .. } = &mut m.state { + *cursor = idx; + } + } + if self.is_double_click(MouseClickTarget::Variables { index: idx }) { + self.activate_variables_modal(); + } + } + } + } + + fn activate_engagement_modal(&mut self) { + let name = match &self.modal { + Modal::Engagement(m) if m.new_name_prompt.is_none() => { + m.available.get(m.cursor).cloned() + } + _ => None, + }; + if let Some(name) = name { + self.modal = Modal::None; + self.switch_engagement(&name); + } + } + + fn activate_target_modal(&mut self) { + let name = match (&self.modal, self.engagement.as_ref()) { + (Modal::Target(m), Some(eng)) => match &m.state { + TargetModalState::List { cursor } => eng + .targets + .targets + .get(*cursor) + .map(|t| t.name.clone()), + _ => None, + }, + _ => None, + }; + if let (Some(name), Some(eng)) = (name, self.engagement.as_mut()) { + eng.targets.set_active(&name); + let _ = eng.save_targets(); + self.flash_ok(format!("target '{}' active", name)); + } + } + + fn activate_ap_modal(&mut self) { + let name = match (&self.modal, self.engagement.as_ref()) { + (Modal::Ap(m), Some(eng)) => match &m.state { + ApModalState::List { cursor } => { + eng.aps.aps.get(*cursor).map(|a| a.name.clone()) + } + _ => None, + }, + _ => None, + }; + if let (Some(name), Some(eng)) = (name, self.engagement.as_mut()) { + eng.aps.set_active(&name); + let _ = eng.save_aps(); + self.flash_ok(format!("AP '{}' active", name)); + } + } + + fn activate_pivot_modal(&mut self) { + let name = match (&self.modal, self.engagement.as_ref()) { + (Modal::Pivot(m), Some(eng)) => match &m.state { + PivotModalState::List { cursor } => eng + .pivots + .pivots + .get(*cursor) + .map(|p| p.name.clone()), + _ => None, + }, + _ => None, + }; + if let (Some(name), Some(eng)) = (name, self.engagement.as_mut()) { + eng.pivots.set_active_tunnel(&name); + eng.pivots.set_active_remote(&name); + let _ = eng.save_pivots(); + self.flash_ok(format!("pivot '{}' active (tunnel + remote)", name)); + } + } + + fn activate_creds_modal(&mut self) { + let name = match (&self.modal, self.engagement.as_ref()) { + (Modal::Creds(m), Some(eng)) => match &m.state { + CredsModalState::List { cursor } => eng + .profiles + .profiles + .get(*cursor) + .map(|p| p.name.clone()), + _ => None, + }, + _ => None, + }; + if let (Some(name), Some(eng)) = (name, self.engagement.as_mut()) { + eng.profiles.set_active(&name); + let _ = eng.save_profiles(); + self.flash_ok(format!("profile '{}' active", name)); + } + } + + fn activate_variables_modal(&mut self) { + let unset_only = match &self.modal { + Modal::Variables(m) => match &m.state { + VariablesModalState::List { unset_only, .. } => *unset_only, + _ => return, + }, + _ => return, + }; + let row = match &self.modal { + Modal::Variables(m) => match &m.state { + VariablesModalState::List { cursor, .. } => { + self.variable_rows(unset_only).get(*cursor).cloned() + } + _ => None, + }, + _ => None, + }; + let Some(row) = row else { + return; + }; + if let Modal::Variables(m) = &mut self.modal { + m.state = VariablesModalState::Edit { + name: row.name, + value: row.value.clone().unwrap_or_default(), + focused: if row.value.as_ref().is_some_and(|v| !v.is_empty()) { + 1 + } else { + 0 + }, + name_editable: false, + }; + } + } + + fn toggle_select_at_index(&mut self, idx: usize) { + let visible = self.visible_commands(); + let Some(cmd) = visible.get(idx) else { + return; + }; + let Some(cat) = self.current_category().cloned() else { + return; + }; + let key = (cat.id, cmd.id.clone()); + if !self.multi_selected.insert(key.clone()) { + self.multi_selected.remove(&key); + } + } + fn handle_normal_mode_key(&mut self, ke: KeyEvent) { if matches!(self.modal, Modal::JobLog(_)) { self.handle_job_log_modal_key(ke); @@ -838,6 +1580,7 @@ impl App { if new != self.selected_category { self.selected_category = new; self.selected_command = 0; + self.reset_preview_scroll(); } } Focus::Commands => { @@ -846,7 +1589,10 @@ impl App { return; } let new = (self.selected_command as i32 + delta).clamp(0, len as i32 - 1) as usize; - self.selected_command = new; + if new != self.selected_command { + self.selected_command = new; + self.reset_preview_scroll(); + } } Focus::Jobs => { let len = self.jobs.len(); @@ -856,7 +1602,7 @@ impl App { let new = (self.selected_job as i32 + delta).clamp(0, len as i32 - 1) as usize; self.selected_job = new; } - Focus::Preview => {} + Focus::Preview => self.scroll_preview(delta), } } @@ -2497,6 +3243,7 @@ impl App { save_as_prompt: None, path_suggestions: Vec::new(), path_pick: 0, + textarea_scroll: (0, 0), }); self.mode = Mode::Insert; } @@ -2873,8 +3620,32 @@ impl App { if let Modal::Cve(m) = &mut self.modal { if m.detail { + let vis = self + .hit + .cve_detail_scroll + .map(|r| r.visible_lines) + .unwrap_or(1); match ke.code { - KeyCode::Esc => m.detail = false, + KeyCode::Esc => { + m.detail = false; + m.detail_scroll = 0; + } + KeyCode::Char('j') | KeyCode::Down => { + let max = ui::modals::cve::max_detail_scroll(m, vis); + m.detail_scroll = (m.detail_scroll + 1).min(max); + } + KeyCode::Char('k') | KeyCode::Up => { + m.detail_scroll = m.detail_scroll.saturating_sub(1); + } + KeyCode::Char('d') if ke.modifiers.contains(KeyModifiers::CONTROL) => { + let max = ui::modals::cve::max_detail_scroll(m, vis); + let page = vis.max(1); + m.detail_scroll = (m.detail_scroll + page).min(max); + } + KeyCode::Char('u') if ke.modifiers.contains(KeyModifiers::CONTROL) => { + let page = vis.max(1); + m.detail_scroll = m.detail_scroll.saturating_sub(page); + } KeyCode::Char('y') => { if let Some(rec) = m.results.get(m.cursor) { let id = rec.id.clone(); @@ -2899,18 +3670,23 @@ impl App { if let Modal::Cve(m) = &mut self.modal { if m.cursor + 1 < m.results.len() { m.cursor += 1; + m.detail_scroll = 0; } } } KeyCode::Char('k') | KeyCode::Up => { if let Modal::Cve(m) = &mut self.modal { - m.cursor = m.cursor.saturating_sub(1); + if m.cursor > 0 { + m.cursor -= 1; + m.detail_scroll = 0; + } } } KeyCode::Enter => { if let Modal::Cve(m) = &mut self.modal { if !m.results.is_empty() { m.detail = true; + m.detail_scroll = 0; } } } diff --git a/src/ui/layout.rs b/src/ui/layout.rs new file mode 100644 index 0000000..e5f84ff --- /dev/null +++ b/src/ui/layout.rs @@ -0,0 +1,188 @@ +use crate::app::Modal; +use ratatui::layout::{Position, Rect}; + +/// Interactive bounds for a list panel, updated each frame during render. +#[derive(Debug, Clone, Copy, Default)] +pub struct ListRegion { + pub panel: Rect, + pub list_inner: Rect, + pub list_offset: usize, +} + +impl ListRegion { + /// Inner area of a `Block` with `Borders::ALL`. + pub fn block_inner(area: Rect) -> Rect { + Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + } + } + + pub fn contains_panel(&self, column: u16, row: u16) -> bool { + self.panel.contains(Position { x: column, y: row }) + } + + pub fn contains_list(&self, column: u16, row: u16) -> bool { + self.list_inner.contains(Position { x: column, y: row }) + } + + /// Map a click to a list index, accounting for scroll offset. + pub fn list_index_at(&self, column: u16, row: u16, item_count: usize) -> Option { + if item_count == 0 { + return None; + } + let inner = self.list_inner; + if column < inner.x || column >= inner.x.saturating_add(inner.width) { + return None; + } + if row < inner.y || row >= inner.y.saturating_add(inner.height) { + return None; + } + let rel = (row - inner.y) as usize; + let index = self.list_offset.saturating_add(rel); + (index < item_count).then_some(index) + } +} + +/// Scrollable text viewport (preview, help body, job log, CVE detail). +#[derive(Debug, Clone, Copy, Default)] +pub struct ScrollRegion { + pub area: Rect, + pub visible_lines: usize, +} + +impl ScrollRegion { + pub fn from_block(area: Rect) -> Self { + let inner = ListRegion::block_inner(area); + Self { + area: inner, + visible_lines: inner.height.max(1) as usize, + } + } + + pub fn contains(&self, column: u16, row: u16) -> bool { + self.area.contains(Position { x: column, y: row }) + } +} + +pub fn scroll_by(current: usize, delta: i32, max: usize) -> usize { + if delta >= 0 { + current.saturating_add(delta as usize).min(max) + } else { + current.saturating_sub((-delta) as usize) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusBarAction { + Engagement, + Target, + Ap, + Pivot, + Creds, + Jobs, +} + +#[derive(Debug, Clone, Copy)] +pub struct StatusChipHit { + pub area: Rect, + pub action: StatusBarAction, +} + +#[derive(Debug, Clone, Default)] +pub struct StatusBarHits { + pub bar: Rect, + pub chips: Vec, +} + +impl StatusBarHits { + pub fn chip_at(&self, column: u16, row: u16) -> Option { + if !self.bar.contains(Position { x: column, y: row }) { + return None; + } + self.chips + .iter() + .find(|c| c.area.contains(Position { x: column, y: row })) + .map(|c| c.action) + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct EditHitRegions { + pub textarea_panel: Rect, + pub textarea: ScrollRegion, + pub suggestions: Option, +} + +/// Last-frame layout snapshot for mouse hit-testing. +#[derive(Debug, Clone, Default)] +pub struct HitRegions { + pub frame: Rect, + pub categories: ListRegion, + pub commands: ListRegion, + pub preview: ScrollRegion, + pub jobs: ListRegion, + pub status_bar: StatusBarHits, + pub edit: Option, + pub modal_popup: Option, + pub help_scroll: Option, + pub job_log_scroll: Option, + pub search_list: Option, + pub cve_list: Option, + pub cve_detail_scroll: Option, + pub engagement_list: Option, + pub target_list: Option, + pub ap_list: Option, + pub pivot_list: Option, + pub creds_list: Option, + pub variables_list: Option, +} + +impl HitRegions { + pub fn modal_popup_rect(frame: Rect, modal: &Modal) -> Option { + let (pct_x, pct_y) = match modal { + Modal::None => return None, + Modal::Help(_) => (70, 80), + Modal::Engagement(_) => (60, 60), + Modal::Target(_) => (70, 70), + Modal::Ap(_) => (75, 75), + Modal::Pivot(_) => (80, 78), + Modal::Creds(_) => (70, 70), + Modal::Variables(_) => (75, 75), + Modal::Tools(_) => (60, 70), + Modal::Search { .. } => (80, 70), + Modal::Edit(_) => (80, 55), + Modal::JobLog(_) => (88, 85), + Modal::Cve(_) => (90, 85), + }; + Some(super::centered_rect(frame, pct_x, pct_y)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_index_respects_offset_and_bounds() { + let region = ListRegion { + panel: Rect::new(0, 0, 10, 10), + list_inner: Rect::new(1, 1, 8, 5), + list_offset: 3, + }; + assert_eq!(region.list_index_at(2, 1, 10), Some(3)); + assert_eq!(region.list_index_at(2, 3, 10), Some(5)); + assert_eq!(region.list_index_at(2, 6, 10), None); + assert_eq!(region.list_index_at(0, 2, 10), None); + assert_eq!(region.list_index_at(2, 3, 5), None); + } + + #[test] + fn scroll_by_clamps() { + assert_eq!(scroll_by(2, 5, 4), 4); + assert_eq!(scroll_by(2, -3, 4), 0); + assert_eq!(scroll_by(2, 1, 4), 3); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5cc4a52..2828938 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,17 +1,25 @@ +pub mod layout; pub mod modals; pub mod panels; pub mod splash; pub mod theme; +pub mod textarea_mouse; use crate::app::{App, Focus, Modal}; +use layout::HitRegions; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; pub fn draw(f: &mut Frame, app: &mut App) { let area = f.area(); + let mut hit = HitRegions { + frame: area, + ..Default::default() + }; // Splash screen takes over the whole frame until the user dismisses it. if let Some(splash) = app.splash.as_ref() { + app.hit = hit; splash::draw(f, area, splash); return; } @@ -33,35 +41,73 @@ pub fn draw(f: &mut Frame, app: &mut App) { ]) .split(body); - panels::categories::render(f, columns[0], app); - panels::commands::render(f, columns[1], app); + panels::categories::render(f, columns[0], app, &mut hit.categories); + panels::commands::render(f, columns[1], app, &mut hit.commands); let right = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(columns[2]); + hit.preview = layout::ScrollRegion::from_block(right[0]); panels::preview::render(f, right[0], app); - panels::jobs::render(f, right[1], app); + panels::jobs::render(f, right[1], app, &mut hit.jobs); - panels::status_bar::render(f, status, app); + panels::status_bar::render(f, status, app, &mut hit.status_bar); + + hit.modal_popup = HitRegions::modal_popup_rect(area, &app.modal); + app.hit = hit; + + let mut help_scroll = None; + let mut job_log_scroll = None; + let mut search_list = None; + let mut cve_list = None; + let mut cve_detail_scroll = None; + let mut engagement_list = None; + let mut target_list = None; + let mut ap_list = None; + let mut pivot_list = None; + let mut creds_list = None; + let mut variables_list = None; + let mut edit_hit = None; match &app.modal { Modal::None => {} - Modal::Help(_) => modals::help::render(f, area, app), - Modal::Engagement(_) => modals::engagement::render(f, area, app), - Modal::Target(_) => modals::target::render(f, area, app), - Modal::Ap(_) => modals::ap::render(f, area, app), - Modal::Pivot(_) => modals::pivot::render(f, area, app), - Modal::Creds(_) => modals::creds::render(f, area, app), - Modal::Variables(_) => modals::variables::render(f, area, app), + Modal::Help(_) => modals::help::render(f, area, app, &mut help_scroll), + Modal::Engagement(_) => modals::engagement::render(f, area, app, &mut engagement_list), + Modal::Target(_) => modals::target::render(f, area, app, &mut target_list), + Modal::Ap(_) => modals::ap::render(f, area, app, &mut ap_list), + Modal::Pivot(_) => modals::pivot::render(f, area, app, &mut pivot_list), + Modal::Creds(_) => modals::creds::render(f, area, app, &mut creds_list), + Modal::Variables(_) => modals::variables::render(f, area, app, &mut variables_list), Modal::Tools(_) => modals::tools::render(f, area, app), - Modal::Search { .. } => modals::search::render(f, area, app), - Modal::Edit(_) => modals::edit::render(f, area, app), - Modal::JobLog(_) => modals::job_log::render(f, area, app), - Modal::Cve(_) => modals::cve::render(f, area, app), + Modal::Search { .. } => modals::search::render(f, area, app, &mut search_list), + Modal::Edit(_) => modals::edit::render(f, area, app, &mut edit_hit), + Modal::JobLog(_) => modals::job_log::render(f, area, app, &mut job_log_scroll), + Modal::Cve(_) => { + modals::cve::render( + f, + area, + app, + &mut cve_list, + &mut cve_detail_scroll, + ); + } } + app.hit.help_scroll = help_scroll; + app.hit.job_log_scroll = job_log_scroll; + app.hit.search_list = search_list; + app.hit.cve_list = cve_list; + app.hit.cve_detail_scroll = cve_detail_scroll; + app.hit.engagement_list = engagement_list; + app.hit.target_list = target_list; + app.hit.ap_list = ap_list; + app.hit.pivot_list = pivot_list; + app.hit.creds_list = creds_list; + app.hit.variables_list = variables_list; + app.hit.edit = edit_hit; + let _ = Focus::Categories; // keep import alive let _ = Rect::default; } diff --git a/src/ui/modals/ap.rs b/src/ui/modals/ap.rs index e07d98f..26b6ccf 100644 --- a/src/ui/modals/ap.rs +++ b/src/ui/modals/ap.rs @@ -1,12 +1,13 @@ use crate::app::{App, ApEditField, ApModalState, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 75, 75); f.render_widget(Clear, r); let block = Block::default() @@ -23,12 +24,21 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; match &modal.state { - ApModalState::List { cursor } => render_list(f, inner, app, *cursor), - ApModalState::Edit { fields, focused, .. } => render_edit(f, inner, fields, *focused), + ApModalState::List { cursor } => render_list(f, inner, app, *cursor, list_hit), + ApModalState::Edit { fields, focused, .. } => { + *list_hit = None; + render_edit(f, inner, fields, *focused); + } } } -fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) { +fn render_list( + f: &mut Frame, + area: Rect, + app: &App, + cursor: usize, + list_hit: &mut Option, +) { let items: Vec = app .engagement .as_ref() @@ -81,6 +91,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) { .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[0], &mut state); + *list_hit = Some(ListRegion { + panel: layout[0], + list_inner: layout[0], + list_offset: state.offset(), + }); let hints = Paragraph::new(Line::from(vec![ Span::styled("a", Theme::magenta()), diff --git a/src/ui/modals/creds.rs b/src/ui/modals/creds.rs index 8353c94..de626f0 100644 --- a/src/ui/modals/creds.rs +++ b/src/ui/modals/creds.rs @@ -1,12 +1,13 @@ use crate::app::{App, CredEditField, CredsModal, CredsModalState, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 70, 70); f.render_widget(Clear, r); let block = Block::default() @@ -23,12 +24,22 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; match &modal.state { - CredsModalState::List { cursor } => render_list(f, inner, app, modal, *cursor), - CredsModalState::Edit { fields, focused, .. } => render_edit(f, inner, fields, *focused), + CredsModalState::List { cursor } => render_list(f, inner, app, modal, *cursor, list_hit), + CredsModalState::Edit { fields, focused, .. } => { + *list_hit = None; + render_edit(f, inner, fields, *focused); + } } } -fn render_list(f: &mut Frame, area: Rect, app: &App, _modal: &CredsModal, cursor: usize) { +fn render_list( + f: &mut Frame, + area: Rect, + app: &App, + _modal: &CredsModal, + cursor: usize, + list_hit: &mut Option, +) { let items: Vec = app .engagement .as_ref() @@ -89,6 +100,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, _modal: &CredsModal, cursor .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[0], &mut state); + *list_hit = Some(ListRegion { + panel: layout[0], + list_inner: layout[0], + list_offset: state.offset(), + }); let hints = Paragraph::new(Line::from(vec![ Span::styled("a", Theme::magenta()), diff --git a/src/ui/modals/cve.rs b/src/ui/modals/cve.rs index b29135c..f49c96c 100644 --- a/src/ui/modals/cve.rs +++ b/src/ui/modals/cve.rs @@ -1,16 +1,104 @@ use crate::app::{App, CveModal, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::{ListRegion, ScrollRegion}; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn max_detail_scroll(modal: &CveModal, visible_lines: usize) -> usize { + detail_line_count(modal).saturating_sub(visible_lines.max(1)) +} + +pub fn clamp_detail_scroll(modal: &mut CveModal, visible_lines: usize) { + modal.detail_scroll = modal + .detail_scroll + .min(max_detail_scroll(modal, visible_lines)); +} + +fn detail_line_count(modal: &CveModal) -> usize { + build_detail_lines(modal).len() +} + +fn build_detail_lines(modal: &CveModal) -> Vec> { + let rec = match modal.results.get(modal.cursor) { + Some(r) => r, + None => return vec![Line::from("No selection")], + }; + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled(rec.id.clone(), Theme::accent_bold()))); + if let Some(s) = &rec.severity { + let cvss = rec + .cvss_v31 + .map(|v| format!(" CVSS {v:.1}")) + .unwrap_or_default(); + lines.push(Line::from(format!("{s}{cvss}"))); + } + if rec.in_kev { + lines.push(Line::from(Span::styled( + format!( + "KEV added {} due {}", + rec.kev_date_added.as_deref().unwrap_or("-"), + rec.kev_due_date.as_deref().unwrap_or("-"), + ), + Theme::warn(), + ))); + } + if let Some(e) = rec.epss_score { + lines.push(Line::from(format!( + "EPSS {:.4} (p{:.1}%)", + e, + rec.epss_percentile.unwrap_or(0.0) * 100.0 + ))); + } + if !rec.products.is_empty() { + let prods: String = rec + .products + .iter() + .take(6) + .map(|p| format!("{}/{}", p.vendor, p.product)) + .collect::>() + .join(", "); + lines.push(Line::from(format!("Products: {prods}"))); + } + if !rec.cwes.is_empty() { + lines.push(Line::from(format!("CWEs: {}", rec.cwes.join(", ")))); + } + lines.push(Line::from("")); + for line in rec.description.lines() { + lines.push(Line::from(line.to_string())); + } + if !rec.references.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("References:", Theme::muted()))); + for r in rec.references.iter().take(8) { + lines.push(Line::from(format!(" {}", r.url))); + } + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Esc back j/k/wheel scroll y yank id", + Theme::muted(), + ))); + lines +} + +pub fn render( + f: &mut Frame, + area: Rect, + app: &App, + list_hit: &mut Option, + detail_scroll_hit: &mut Option, +) { let Modal::Cve(modal) = &app.modal else { return; }; + *list_hit = None; + *detail_scroll_hit = None; + let r = centered_rect(area, 90, 85); f.render_widget(Clear, r); @@ -28,6 +116,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let inner = block.inner(r); if modal.detail { + let visible_lines = inner.height.max(1) as usize; + *detail_scroll_hit = Some(ScrollRegion { + area: inner, + visible_lines, + }); render_detail(f, inner, modal); return; } @@ -67,7 +160,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ); let chips = format!( - "{} {} shown j/k move Enter detail y yank s sync K KEV{} Esc close", + "{} {} shown j/k/wheel move Enter detail y yank s sync K KEV{} Esc close", if modal.kev_only { "[KEV]" } else { "[all]" }, modal.results.len(), if modal.kev_only { "✓" } else { "" }, @@ -104,6 +197,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[3], &mut state); + *list_hit = Some(ListRegion { + panel: layout[3], + list_inner: layout[3], + list_offset: state.offset(), + }); let hint = if modal.results.is_empty() { "No matches — run :cve then press s to sync, or type to search" @@ -114,68 +212,14 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } fn render_detail(f: &mut Frame, area: Rect, modal: &CveModal) { - let rec = modal.results.get(modal.cursor); - let Some(rec) = rec else { - f.render_widget(Paragraph::new("No selection"), area); - return; - }; - - let mut lines: Vec = Vec::new(); - lines.push(Line::from(Span::styled(&rec.id, Theme::accent_bold()))); - if let Some(s) = &rec.severity { - let cvss = rec - .cvss_v31 - .map(|v| format!(" CVSS {v:.1}")) - .unwrap_or_default(); - lines.push(Line::from(format!("{s}{cvss}"))); - } - if rec.in_kev { - lines.push(Line::from(Span::styled( - format!( - "KEV added {} due {}", - rec.kev_date_added.as_deref().unwrap_or("-"), - rec.kev_due_date.as_deref().unwrap_or("-"), - ), - Theme::warn(), - ))); - } - if let Some(e) = rec.epss_score { - lines.push(Line::from(format!( - "EPSS {:.4} (p{:.1}%)", - e, - rec.epss_percentile.unwrap_or(0.0) * 100.0 - ))); - } - if !rec.products.is_empty() { - let prods: String = rec - .products - .iter() - .take(6) - .map(|p| format!("{}/{}", p.vendor, p.product)) - .collect::>() - .join(", "); - lines.push(Line::from(format!("Products: {prods}"))); - } - if !rec.cwes.is_empty() { - lines.push(Line::from(format!("CWEs: {}", rec.cwes.join(", ")))); - } - lines.push(Line::from("")); - for line in rec.description.lines().take(12) { - lines.push(Line::from(line.to_string())); - } - if !rec.references.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled("References:", Theme::muted()))); - for r in rec.references.iter().take(5) { - lines.push(Line::from(format!(" {}", r.url))); - } - } - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Esc back y yank id", - Theme::muted(), - ))); + let visible_lines = area.height.max(1) as usize; + let scroll = modal + .detail_scroll + .min(max_detail_scroll(modal, visible_lines)); - let para = Paragraph::new(lines).wrap(Wrap { trim: true }); + let lines = build_detail_lines(modal); + let para = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .scroll((scroll as u16, 0)); f.render_widget(para, area); } diff --git a/src/ui/modals/edit.rs b/src/ui/modals/edit.rs index 1a6341f..22016da 100644 --- a/src/ui/modals/edit.rs +++ b/src/ui/modals/edit.rs @@ -1,5 +1,7 @@ use crate::app::{App, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::{EditHitRegions, ListRegion, ScrollRegion}; +use crate::ui::textarea_mouse::textarea_scroll_after_render; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; @@ -7,7 +9,9 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &mut App, edit_hit: &mut Option) { + *edit_hit = None; + let r = centered_rect(area, 80, 55); f.render_widget(Clear, r); let block = Block::default() @@ -18,7 +22,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { f.render_widget(block.clone(), r); let inner = block.inner(r); - let modal = match &app.modal { + let modal = match &mut app.modal { Modal::Edit(m) => m, _ => return, }; @@ -43,8 +47,24 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .constraints(constraints) .split(inner); + let textarea_area = layout[0]; + let inner_block = modal.textarea.block().cloned(); + let (inner_w, inner_h) = if let Some(b) = inner_block.as_ref() { + let inner = b.inner(textarea_area); + (inner.width, inner.height) + } else { + (textarea_area.width, textarea_area.height) + }; + modal.textarea_scroll = textarea_scroll_after_render( + &modal.textarea, + modal.textarea_scroll, + inner_w, + inner_h, + ); + let mut idx = 0; f.render_widget(&modal.textarea, layout[idx]); + let mut suggestions = None; idx += 1; if show_suggestions { @@ -72,18 +92,23 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .block(sugg_block) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[idx], &mut state); + suggestions = Some(ListRegion { + panel: layout[idx], + list_inner: ListRegion::block_inner(layout[idx]), + list_offset: state.offset(), + }); idx += 1; } let hints = Paragraph::new(Line::from(vec![ + Span::styled("click", Theme::magenta()), + Span::raw(" place cursor "), + Span::styled("wheel", Theme::magenta()), + Span::raw(" scroll "), Span::styled("Tab", Theme::magenta()), Span::raw(" path "), - Span::styled("Ctrl-H/U/W", Theme::magenta()), - Span::raw(" delete "), Span::styled("Ctrl-S", Theme::magenta()), Span::raw(" run "), - Span::styled("Ctrl-Shift-W", Theme::magenta()), - Span::raw(" save "), Span::styled("Esc", Theme::magenta()), Span::raw(" cancel"), ])) @@ -100,4 +125,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .style(Theme::panel()); f.render_widget(line, layout[idx]); } + + *edit_hit = Some(EditHitRegions { + textarea_panel: textarea_area, + textarea: ScrollRegion::from_block(textarea_area), + suggestions, + }); } diff --git a/src/ui/modals/engagement.rs b/src/ui/modals/engagement.rs index f9a3374..53e639c 100644 --- a/src/ui/modals/engagement.rs +++ b/src/ui/modals/engagement.rs @@ -1,12 +1,13 @@ use crate::app::{App, EngagementModal, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 60, 60); f.render_widget(Clear, r); let block = Block::default() @@ -36,6 +37,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ]) .block(block); f.render_widget(p, r); + *list_hit = None; return; } @@ -71,6 +73,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, r, &mut state); + *list_hit = Some(ListRegion { + panel: r, + list_inner: ListRegion::block_inner(r), + list_offset: state.offset(), + }); let _ = EngagementModal::default; } diff --git a/src/ui/modals/help.rs b/src/ui/modals/help.rs index d0b6023..8bad831 100644 --- a/src/ui/modals/help.rs +++ b/src/ui/modals/help.rs @@ -9,7 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; const HELP: &[(&str, &str)] = &[ ("h / l", "focus left / right panel"), - ("j / k", "move down / up"), + ("j / k", "move down / up (preview: scroll)"), ("gg / G", "top / bottom"), ("Ctrl-d / Ctrl-u", "half-page down / up"), ("Enter / r", "run highlighted command in background tmux window"), @@ -29,6 +29,13 @@ const HELP: &[(&str, &str)] = &[ (":", "command palette"), ("? / :help", "show this help"), ("Esc / Ctrl-c", "clear selection / dismiss modal"), + ("click", "focus panel / select list row"), + ("double-click", "run command / open job log / activate modal row"), + ("Shift+click", "toggle multi-select (commands panel)"), + ("wheel", "scroll preview / lists / modals"), + ("click outside modal", "dismiss modal (same as Esc)"), + ("status bar click", "open engagement/target/ap/pivot/creds; jobs → focus"), + ("edit modal click", "place cursor; wheel scroll; dbl-click path pick"), ("q / :q", "quit"), ("", ""), ("job log (floating)", "j/k scroll Ctrl-d/u page g/G top/bottom f follow o tmux"), @@ -85,7 +92,7 @@ fn help_lines() -> Vec> { lines } -pub fn render(f: &mut Frame, area: Rect, app: &mut App) { +pub fn render(f: &mut Frame, area: Rect, app: &mut App, scroll_hit: &mut Option) { let Modal::Help(modal) = &mut app.modal else { return; }; @@ -108,6 +115,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { let visible_lines = layout[0].height.max(1) as usize; modal.last_visible_lines = visible_lines; clamp_scroll(modal, visible_lines); + *scroll_hit = Some(crate::ui::layout::ScrollRegion { + area: layout[0], + visible_lines, + }); let lines = help_lines(); let p = Paragraph::new(lines) @@ -127,6 +138,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { Span::raw(" top/bottom "), Span::styled("Esc/?", Theme::magenta()), Span::raw(" close "), + Span::styled("wheel", Theme::magenta()), + Span::raw(" scroll "), Span::styled(position, Theme::muted().add_modifier(Modifier::ITALIC)), ]); f.render_widget(Paragraph::new(hints).style(Theme::muted()), layout[1]); diff --git a/src/ui/modals/job_log.rs b/src/ui/modals/job_log.rs index f8013b6..4d56b31 100644 --- a/src/ui/modals/job_log.rs +++ b/src/ui/modals/job_log.rs @@ -175,7 +175,12 @@ fn normalize_display_line(s: &str) -> String { } } -pub fn render(f: &mut Frame, area: Rect, app: &mut App) { +pub fn render( + f: &mut Frame, + area: Rect, + app: &mut App, + scroll_hit: &mut Option, +) { let Modal::JobLog(modal) = &mut app.modal else { return; }; @@ -229,6 +234,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { refresh_modal_from_job(modal, job); } clamp_scroll(modal, visible_lines); + *scroll_hit = Some(crate::ui::layout::ScrollRegion { + area: layout[0], + visible_lines, + }); let width = layout[0].width.max(1) as usize; let body: Vec = modal @@ -267,6 +276,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { Span::raw(" top/bottom "), Span::styled("f", Theme::magenta()), Span::raw(" follow "), + Span::styled("wheel", Theme::magenta()), + Span::raw(" scroll "), Span::styled("o", Theme::magenta()), Span::raw(" tmux "), Span::styled("Esc", Theme::magenta()), diff --git a/src/ui/modals/pivot.rs b/src/ui/modals/pivot.rs index 954fcf6..15a67c6 100644 --- a/src/ui/modals/pivot.rs +++ b/src/ui/modals/pivot.rs @@ -1,12 +1,13 @@ use crate::app::{App, Modal, PivotEditField, PivotModalState}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 80, 78); f.render_widget(Clear, r); let block = Block::default() @@ -23,12 +24,21 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; match &modal.state { - PivotModalState::List { cursor } => render_list(f, inner, app, *cursor), - PivotModalState::Edit { fields, focused, .. } => render_edit(f, inner, fields, *focused), + PivotModalState::List { cursor } => render_list(f, inner, app, *cursor, list_hit), + PivotModalState::Edit { fields, focused, .. } => { + *list_hit = None; + render_edit(f, inner, fields, *focused); + } } } -fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) { +fn render_list( + f: &mut Frame, + area: Rect, + app: &App, + cursor: usize, + list_hit: &mut Option, +) { let eng = app.engagement.as_ref(); let active_tunnel = eng.and_then(|e| e.pivots.active_tunnel.clone()); let active_remote = eng.and_then(|e| e.pivots.active_remote.clone()); @@ -90,6 +100,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) { .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[0], &mut state); + *list_hit = Some(ListRegion { + panel: layout[0], + list_inner: layout[0], + list_offset: state.offset(), + }); let hints = Paragraph::new(vec![ Line::from(vec![ diff --git a/src/ui/modals/search.rs b/src/ui/modals/search.rs index 3b37235..8c74bcb 100644 --- a/src/ui/modals/search.rs +++ b/src/ui/modals/search.rs @@ -1,12 +1,13 @@ use crate::app::{App, Modal}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 80, 70); f.render_widget(Clear, r); let global = matches!(app.mode, crate::vim::Mode::SearchGlobal); @@ -55,4 +56,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[1], &mut state); + *list_hit = Some(ListRegion { + panel: layout[1], + list_inner: layout[1], + list_offset: state.offset(), + }); } diff --git a/src/ui/modals/target.rs b/src/ui/modals/target.rs index c6d6330..8951c7e 100644 --- a/src/ui/modals/target.rs +++ b/src/ui/modals/target.rs @@ -1,12 +1,13 @@ use crate::app::{App, Modal, TargetEditField, TargetModal, TargetModalState}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 70, 70); f.render_widget(Clear, r); let block = Block::default() @@ -23,14 +24,24 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; match &modal.state { - TargetModalState::List { cursor } => render_list(f, inner, app, modal, *cursor), - TargetModalState::Edit { fields, focused, .. } => render_edit(f, inner, fields, *focused), + TargetModalState::List { cursor } => render_list(f, inner, app, modal, *cursor, list_hit), + TargetModalState::Edit { fields, focused, .. } => { + *list_hit = None; + render_edit(f, inner, fields, *focused); + } } let _ = TargetModal::default; } -fn render_list(f: &mut Frame, area: Rect, app: &App, _modal: &TargetModal, cursor: usize) { +fn render_list( + f: &mut Frame, + area: Rect, + app: &App, + _modal: &TargetModal, + cursor: usize, + list_hit: &mut Option, +) { let items: Vec = app .engagement .as_ref() @@ -81,6 +92,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, _modal: &TargetModal, curso .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[0], &mut state); + *list_hit = Some(ListRegion { + panel: layout[0], + list_inner: layout[0], + list_offset: state.offset(), + }); let hints = Paragraph::new(Line::from(vec![ Span::styled("a", Theme::magenta()), diff --git a/src/ui/modals/variables.rs b/src/ui/modals/variables.rs index 2c84431..491e0cf 100644 --- a/src/ui/modals/variables.rs +++ b/src/ui/modals/variables.rs @@ -1,12 +1,13 @@ use crate::app::{App, Modal, VariableEditField, VariablesModalState}; use crate::ui::centered_rect; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option) { let r = centered_rect(area, 75, 75); f.render_widget(Clear, r); let block = Block::default() @@ -26,17 +27,27 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { VariablesModalState::List { cursor, unset_only, - } => render_list(f, inner, app, *cursor, *unset_only), + } => render_list(f, inner, app, *cursor, *unset_only, list_hit), VariablesModalState::Edit { name, value, focused, name_editable, - } => render_edit(f, inner, name, value, *focused, *name_editable), + } => { + *list_hit = None; + render_edit(f, inner, name, value, *focused, *name_editable); + } } } -fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize, unset_only: bool) { +fn render_list( + f: &mut Frame, + area: Rect, + app: &App, + cursor: usize, + unset_only: bool, + list_hit: &mut Option, +) { let rows = app.variable_rows(unset_only); let (set_n, unset_n) = app.variable_counts(); @@ -102,6 +113,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize, unset_only: .highlight_style(Theme::selected()) .highlight_symbol("▶ "); f.render_stateful_widget(list, layout[1], &mut state); + *list_hit = Some(ListRegion { + panel: layout[1], + list_inner: layout[1], + list_offset: state.offset(), + }); let current_need = app.current_command_unresolved_vars(); let need_line = if current_need.is_empty() { diff --git a/src/ui/panels/categories.rs b/src/ui/panels/categories.rs index c450f4d..b98bec3 100644 --- a/src/ui/panels/categories.rs +++ b/src/ui/panels/categories.rs @@ -1,4 +1,5 @@ use crate::app::{App, Focus}; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::Rect; @@ -6,7 +7,9 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, hit: &mut ListRegion) { + hit.panel = area; + hit.list_inner = ListRegion::block_inner(area); let is_focused = app.focus == Focus::Categories; let border_style = if is_focused { Theme::border_active() } else { Theme::border() }; let block = Block::default() @@ -46,4 +49,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol(if is_focused { "▶ " } else { " " }); f.render_stateful_widget(list, area, &mut state); + hit.list_offset = state.offset(); } diff --git a/src/ui/panels/commands.rs b/src/ui/panels/commands.rs index 289df63..888a6a4 100644 --- a/src/ui/panels/commands.rs +++ b/src/ui/panels/commands.rs @@ -1,4 +1,5 @@ use crate::app::{App, Focus}; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::Rect; @@ -6,7 +7,9 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, hit: &mut ListRegion) { + hit.panel = area; + hit.list_inner = ListRegion::block_inner(area); let is_focused = app.focus == Focus::Commands; let border_style = if is_focused { Theme::border_active() } else { Theme::border() }; @@ -75,4 +78,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol(if is_focused { "▶ " } else { " " }); f.render_stateful_widget(list, area, &mut state); + hit.list_offset = state.offset(); } diff --git a/src/ui/panels/jobs.rs b/src/ui/panels/jobs.rs index bfdbc3d..b830dd2 100644 --- a/src/ui/panels/jobs.rs +++ b/src/ui/panels/jobs.rs @@ -1,5 +1,6 @@ use crate::app::{App, Focus}; use crate::engagement::JobStatus; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use chrono::Utc; use ratatui::Frame; @@ -8,7 +9,9 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, hit: &mut ListRegion) { + hit.panel = area; + hit.list_inner = ListRegion::block_inner(area); let is_focused = app.focus == Focus::Jobs; let border_style = if is_focused { Theme::border_active() } else { Theme::border() }; @@ -63,6 +66,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .highlight_style(Theme::selected()) .highlight_symbol(if is_focused { "▶ " } else { " " }); f.render_stateful_widget(list, area, &mut state); + hit.list_offset = state.offset(); } fn format_duration(secs: i64) -> String { diff --git a/src/ui/panels/preview.rs b/src/ui/panels/preview.rs index b018c72..563180a 100644 --- a/src/ui/panels/preview.rs +++ b/src/ui/panels/preview.rs @@ -1,4 +1,5 @@ use crate::app::{App, Focus}; +use crate::ui::layout::ListRegion; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::Rect; @@ -6,15 +7,21 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let is_focused = app.focus == Focus::Preview; - let border_style = if is_focused { Theme::border_active() } else { Theme::border() }; - let block = Block::default() - .borders(Borders::ALL) - .title(Span::styled(" preview ", Theme::accent_bold())) - .border_style(border_style) - .style(Theme::panel()); +pub fn preview_line_count(app: &App) -> usize { + build_preview_lines(app).len() +} + +pub fn max_preview_scroll(app: &App, visible_lines: usize) -> usize { + preview_line_count(app).saturating_sub(visible_lines.max(1)) +} + +pub fn clamp_preview_scroll(app: &mut App, visible_lines: usize) { + app.preview_scroll = app + .preview_scroll + .min(max_preview_scroll(app, visible_lines)); +} +fn build_preview_lines(app: &App) -> Vec> { let mut lines: Vec = Vec::new(); match app.current_command() { @@ -77,8 +84,30 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } + lines +} + +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let is_focused = app.focus == Focus::Preview; + let border_style = if is_focused { + Theme::border_active() + } else { + Theme::border() + }; + let block = Block::default() + .borders(Borders::ALL) + .title(Span::styled(" preview ", Theme::accent_bold())) + .border_style(border_style) + .style(Theme::panel()); + + let visible_lines = ListRegion::block_inner(area).height.max(1) as usize; + app.preview_visible_lines = visible_lines; + clamp_preview_scroll(app, visible_lines); + + let lines = build_preview_lines(app); let p = Paragraph::new(lines) .block(block) - .wrap(Wrap { trim: false }); + .wrap(Wrap { trim: false }) + .scroll((app.preview_scroll as u16, 0)); f.render_widget(p, area); } diff --git a/src/ui/panels/status_bar.rs b/src/ui/panels/status_bar.rs index 3009c50..79057c4 100644 --- a/src/ui/panels/status_bar.rs +++ b/src/ui/panels/status_bar.rs @@ -1,4 +1,5 @@ use crate::app::App; +use crate::ui::layout::{StatusBarAction, StatusBarHits, StatusChipHit}; use crate::ui::theme::Theme; use ratatui::Frame; use ratatui::layout::Rect; @@ -6,13 +7,32 @@ use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -pub fn render(f: &mut Frame, area: Rect, app: &App) { +pub fn render(f: &mut Frame, area: Rect, app: &App, hits: &mut StatusBarHits) { + hits.bar = area; + hits.chips.clear(); + let mut x = area.x; + + let mut advance = |hits: &mut StatusBarHits, x: &mut u16, text: &str, action: Option| { + let w = text.len() as u16; + if w > 0 { + if let Some(action) = action { + hits.chips.push(StatusChipHit { + area: Rect::new(*x, area.y, w, area.height.max(1)), + action, + }); + } + } + *x = x.saturating_add(w); + }; + let mode = app.mode.label(); let mode_style = match app.mode { crate::vim::Mode::Normal => Theme::accent_bold(), crate::vim::Mode::Insert => Theme::success().add_modifier(Modifier::BOLD), crate::vim::Mode::Command => Theme::magenta().add_modifier(Modifier::BOLD), - crate::vim::Mode::Search | crate::vim::Mode::SearchGlobal => Theme::warn().add_modifier(Modifier::BOLD), + crate::vim::Mode::Search | crate::vim::Mode::SearchGlobal => { + Theme::warn().add_modifier(Modifier::BOLD) + } }; let engagement = app @@ -92,6 +112,21 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { _ => String::new(), }; + // Track clickable chip regions (mode label is not clickable). + advance(hits, &mut x, &format!(" {} ", mode), None); + advance(hits, &mut x, "│ ", None); + advance(hits, &mut x, &engagement, Some(StatusBarAction::Engagement)); + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &target, Some(StatusBarAction::Target)); + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &ap, Some(StatusBarAction::Ap)); + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &pivot, Some(StatusBarAction::Pivot)); + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &profile, Some(StatusBarAction::Creds)); + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &jobs, Some(StatusBarAction::Jobs)); + let mut spans = vec![ Span::styled(format!(" {} ", mode), mode_style), Span::raw("│ "), @@ -109,11 +144,15 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ]; if !prefix.is_empty() { + advance(hits, &mut x, " │ ", None); + advance(hits, &mut x, &prefix, None); spans.push(Span::raw(" │ ")); spans.push(Span::styled(prefix, Theme::warn().add_modifier(Modifier::BOLD))); } if let Some(msg) = &app.flash { + let flash_text = format!(" │ {}", msg.text); + advance(hits, &mut x, &flash_text, None); spans.push(Span::raw(" │ ")); let style = if msg.is_error { Theme::error() diff --git a/src/ui/splash.rs b/src/ui/splash.rs index 00824c2..06bf3ac 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash.rs @@ -245,6 +245,8 @@ fn draw_hint(f: &mut Frame, area: Rect) { let hint = Line::from(vec![ Span::styled("press ", Theme::muted()), Span::styled("any key", Theme::accent_bold()), + Span::styled(" or ", Theme::muted()), + Span::styled("click", Theme::accent_bold()), Span::styled(" to enter · ", Theme::muted()), Span::styled("?", Theme::accent_bold()), Span::styled(" for help · ", Theme::muted()), diff --git a/src/ui/textarea_mouse.rs b/src/ui/textarea_mouse.rs new file mode 100644 index 0000000..4def653 --- /dev/null +++ b/src/ui/textarea_mouse.rs @@ -0,0 +1,85 @@ +use ratatui::layout::Rect; +use tui_textarea::{CursorMove, TextArea}; + +fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 { + if cursor < prev_top { + cursor + } else if cursor >= prev_top.saturating_add(len) { + cursor.saturating_sub(len.saturating_sub(1)) + } else { + prev_top + } +} + +fn num_digits_usize(i: usize) -> u16 { + if i == 0 { + 1 + } else { + i.ilog10() as u16 + 1 + } +} + +/// Mirror `tui_textarea` viewport scroll after a frame (see `widget.rs`). +pub fn textarea_scroll_after_render( + ta: &TextArea<'_>, + prev: (u16, u16), + inner_width: u16, + inner_height: u16, +) -> (u16, u16) { + let top_row = next_scroll_top(prev.0, ta.cursor().0 as u16, inner_height); + let mut cursor_col = ta.cursor().1 as u16; + if ta.line_number_style().is_some() { + let lnum = num_digits_usize(ta.lines().len()) + 2; + if cursor_col <= lnum { + cursor_col *= 2; + } else { + cursor_col += lnum; + } + } + let top_col = next_scroll_top(prev.1, cursor_col, inner_width); + (top_row, top_col) +} + +/// Move the textarea cursor to a terminal cell inside `area`. +pub fn textarea_cursor_from_click( + ta: &mut TextArea<'static>, + area: Rect, + scroll: (u16, u16), + column: u16, + row: u16, +) -> bool { + let inner = if ta.block().is_some() { + crate::ui::layout::ListRegion::block_inner(area) + } else { + area + }; + if column < inner.x + || column >= inner.x.saturating_add(inner.width) + || row < inner.y + || row >= inner.y.saturating_add(inner.height) + { + return false; + } + + let lnum = if ta.line_number_style().is_some() { + num_digits_usize(ta.lines().len()) + 2 + } else { + 0 + }; + + let rel_y = row.saturating_sub(inner.y); + let rel_x = column.saturating_sub(inner.x); + let line_count = ta.lines().len(); + if line_count == 0 { + ta.move_cursor(CursorMove::Jump(0, 0)); + return true; + } + + let line = (scroll.0.saturating_add(rel_y) as usize).min(line_count - 1); + let mut text_col = rel_x.saturating_sub(lnum) as usize + scroll.1 as usize; + let line_len = ta.lines().get(line).map(|l| l.chars().count()).unwrap_or(0); + text_col = text_col.min(line_len); + + ta.move_cursor(CursorMove::Jump(line as u16, text_col as u16)); + true +}