From cfab8e82d6006eca3255052d986c164ec522aded Mon Sep 17 00:00:00 2001 From: muk Date: Wed, 18 Feb 2026 19:49:30 +0000 Subject: [PATCH] feat: add mouse support for editor and UI navigation Add mouse click, scroll wheel, and focus switching via mouse. Layout rects are stored during each draw call and used to map mouse coordinates to UI elements. - Left click in editor positions cursor at click location - Left click in sidebar selects item - Left click in results selects row and switches focus - Scroll wheel works in editor, results, and sidebar - Mouse is ignored during connection dialog and help overlay - Keyboard navigation continues to work exactly as before Closes #20 Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 40 ++++++++------- src/ui/app.rs | 117 +++++++++++++++++++++++++++++++++++++++++++ src/ui/components.rs | 16 +++++- 3 files changed, 155 insertions(+), 18 deletions(-) diff --git a/src/main.rs b/src/main.rs index 16bc4db..fb2cdca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,25 +108,31 @@ async fn run_app( terminal.draw(|f| ui::draw(f, app))?; if event::poll(std::time::Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - // Only handle key press events (ignore release/repeat) - if key.kind != KeyEventKind::Press { - continue; + match event::read()? { + Event::Key(key) => { + // Only handle key press events (ignore release/repeat) + if key.kind != KeyEventKind::Press { + continue; + } + + // Global quit: Ctrl+Q or Ctrl+D + if (key.code == KeyCode::Char('q') || key.code == KeyCode::Char('d')) + && key.modifiers.contains(KeyModifiers::CONTROL) + { + return Ok(()); + } + + // Handle input based on current focus + app.handle_input(key).await?; + + if app.should_quit { + return Ok(()); + } } - - // Global quit: Ctrl+Q or Ctrl+D - if (key.code == KeyCode::Char('q') || key.code == KeyCode::Char('d')) - && key.modifiers.contains(KeyModifiers::CONTROL) - { - return Ok(()); - } - - // Handle input based on current focus - app.handle_input(key).await?; - - if app.should_quit { - return Ok(()); + Event::Mouse(mouse) => { + app.handle_mouse(mouse.kind, mouse.column, mouse.row); } + _ => {} } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 2ccc163..d810060 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,8 +11,18 @@ use crate::db::{ use crate::editor::{HistoryEntry, QueryHistory, TextBuffer}; use crate::ui::Theme; +use ratatui::layout::Rect; + pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +#[derive(Debug, Clone, Copy, Default)] +pub struct LayoutRects { + pub sidebar: Rect, + pub editor: Rect, + pub results: Rect, + pub status_bar: Rect, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Focus { Sidebar, @@ -83,6 +93,9 @@ pub struct App { // Help pub show_help: bool, + // Layout rects (updated each draw for mouse support) + pub layout: LayoutRects, + // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, } @@ -240,6 +253,7 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + layout: LayoutRects::default(), pending_connection: None, } } @@ -1130,6 +1144,109 @@ impl App { Ok(()) } + + pub fn handle_mouse(&mut self, kind: crossterm::event::MouseEventKind, column: u16, row: u16) { + use crossterm::event::MouseEventKind; + + // Don't handle mouse when in dialog/help overlays + if self.connection_dialog.active || self.show_help { + return; + } + + match kind { + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + self.handle_mouse_click(column, row); + } + MouseEventKind::ScrollUp => { + self.handle_mouse_scroll(column, row, -3); + } + MouseEventKind::ScrollDown => { + self.handle_mouse_scroll(column, row, 3); + } + _ => {} + } + } + + fn handle_mouse_click(&mut self, column: u16, row: u16) { + let layout = self.layout; + + if rect_contains(layout.sidebar, column, row) { + self.focus = Focus::Sidebar; + // Map click to sidebar item (subtract border + tab area) + let local_y = row.saturating_sub(layout.sidebar.y + 3) as usize; + let idx = local_y + self.sidebar_scroll; + let max = match self.sidebar_tab { + SidebarTab::Databases => self.databases.len(), + SidebarTab::Tables => self.tables.len() + self.schemas.len(), + SidebarTab::History => self.query_history.entries().len(), + }; + if idx < max { + self.sidebar_selected = idx; + } + } else if rect_contains(layout.editor, column, row) { + self.focus = Focus::Editor; + // Map click to editor position (inside border) + let inner_x = column.saturating_sub(layout.editor.x + 1) as usize; + let inner_y = row.saturating_sub(layout.editor.y + 1) as usize; + let target_y = inner_y + self.editor.scroll_offset; + if target_y < self.editor.lines.len() { + self.editor.cursor_y = target_y; + self.editor.cursor_x = inner_x.min(self.editor.lines[target_y].len()); + self.editor.clear_selection(); + } + } else if rect_contains(layout.results, column, row) { + self.focus = Focus::Results; + // Map click to result row (inside border + header) + let inner_y = row.saturating_sub(layout.results.y + 2) as usize; + let target_row = inner_y + self.result_scroll_y; + if let Some(result) = self.results.get(self.current_result) { + if target_row < result.rows.len() { + self.result_selected_row = target_row; + } + } + } + } + + fn handle_mouse_scroll(&mut self, column: u16, row: u16, delta: i32) { + let layout = self.layout; + + if rect_contains(layout.editor, column, row) { + if delta < 0 { + self.editor.scroll_offset = + self.editor.scroll_offset.saturating_sub((-delta) as usize); + } else { + let max_scroll = self.editor.lines.len().saturating_sub(1); + self.editor.scroll_offset = + (self.editor.scroll_offset + delta as usize).min(max_scroll); + } + } else if rect_contains(layout.results, column, row) { + if let Some(result) = self.results.get(self.current_result) { + if delta < 0 { + self.result_selected_row = + self.result_selected_row.saturating_sub((-delta) as usize); + } else { + self.result_selected_row = (self.result_selected_row + delta as usize) + .min(result.rows.len().saturating_sub(1)); + } + } + } else if rect_contains(layout.sidebar, column, row) { + let max = match self.sidebar_tab { + SidebarTab::Databases => self.databases.len(), + SidebarTab::Tables => self.tables.len() + self.schemas.len(), + SidebarTab::History => self.query_history.entries().len(), + }; + if delta < 0 { + self.sidebar_selected = self.sidebar_selected.saturating_sub((-delta) as usize); + } else { + self.sidebar_selected = + (self.sidebar_selected + delta as usize).min(max.saturating_sub(1)); + } + } + } +} + +fn rect_contains(rect: Rect, x: u16, y: u16) -> bool { + x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height } fn dialog_field_len(config: &ConnectionConfig, field_index: usize) -> usize { diff --git a/src/ui/components.rs b/src/ui/components.rs index da943dd..2bffea0 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -11,7 +11,7 @@ use crate::ui::{ is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, SPINNER_FRAMES, }; -pub fn draw(frame: &mut Frame, app: &App) { +pub fn draw(frame: &mut Frame, app: &mut App) { // Create main layout let chunks = Layout::default() .direction(Direction::Vertical) @@ -31,6 +31,20 @@ pub fn draw(frame: &mut Frame, app: &App) { .constraints([Constraint::Length(app.sidebar_width), Constraint::Min(0)]) .split(chunks[1]); + // Store layout rects for mouse support + let panel_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), // Editor + Constraint::Min(0), // Results + ]) + .split(main_chunks[1]); + + app.layout.sidebar = main_chunks[0]; + app.layout.editor = panel_chunks[0]; + app.layout.results = panel_chunks[1]; + app.layout.status_bar = chunks[2]; + draw_sidebar(frame, app, main_chunks[0]); draw_main_panel(frame, app, main_chunks[1]);