From db7764be612a49fe1753421af43aa1951a6f7dff Mon Sep 17 00:00:00 2001 From: turnipy Date: Wed, 25 Mar 2026 18:37:22 +1100 Subject: [PATCH 1/2] feat: UI improvements, mouse support, selection fixes, and event loop optimization ## Top Bar Menu - Underline first letter of each menu item to indicate Alt+letter shortcut - Selected menu item now shows black text on highlighted background - Fixed: Navigation menu no longer appears selected on startup ## Mouse Support - Click top bar menu labels to open/close dropdown menus - Click dropdown items to execute actions - Click in sidebar, editor, or terminal to switch panel focus - Click in sidebar selects the clicked file entry - Click outside an open menu closes it ## Selection Fixes - Shift+Left/Right now extends selection character by character - Ctrl+Shift+Left/Right extends selection word by word - Ctrl+Shift+Home/End selects from cursor to start/end of document - Ctrl+Home/End fixed (was silently consumed by global Ctrl handler) - Ctrl+Shift+PageUp/PageDown now selects by page - Ctrl+PageUp/PageDown fixed for page navigation in editor ## Event Loop Optimization - Restructured event loop: process all pending events before drawing - Filter key Release events at source to prevent unnecessary wakeups - Single draw per event batch instead of per-event, improving responsiveness - Removed 8ms sleep between frames ## Keyboard Enhancement - Enable crossterm DISAMBIGUATE_ESCAPE_CODES for terminals that support it - Best-effort: gracefully degrades on terminals without support - Improves Ctrl+Shift modifier detection in Ghostty, ptyxis, etc. ## Sidebar Area Tracking - Store sidebar render area for mouse click detection Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 8 ++ src/events/mod.rs | 223 ++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 184 ++++++++++++++++++++++---------------- src/ui/mod.rs | 3 + src/ui/top_bar.rs | 78 ++++++++++++---- 5 files changed, 369 insertions(+), 127 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3bd4eeb..7729279 100644 --- a/src/app.rs +++ b/src/app.rs @@ -88,6 +88,10 @@ pub struct App { pub terminal_scroll: usize, pub terminal_restarting: bool, pub terminal_area: Cell, + pub sidebar_area: Cell, + pub top_bar_area: Cell, + pub top_bar_positions: Cell>, + pub dropdown_area: Cell>, pub terminal_sel: Option<((usize, usize), (usize, usize))>, pub show_quit_confirm: bool, pub show_unsaved_confirm: bool, @@ -146,6 +150,10 @@ impl App { terminal_scroll: 0, terminal_restarting: false, terminal_area: Cell::new(ratatui::layout::Rect::default()), + sidebar_area: Cell::new(ratatui::layout::Rect::default()), + top_bar_area: Cell::new(ratatui::layout::Rect::default()), + top_bar_positions: Cell::new(Vec::new()), + dropdown_area: Cell::new(None), terminal_sel: None, show_quit_confirm: false, show_unsaved_confirm: false, diff --git a/src/events/mod.rs b/src/events/mod.rs index a67f5f0..e6fca6d 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -108,48 +108,139 @@ pub fn handle_event(app: &mut App, event: Event) -> io::Result<()> { Ok(()) } +fn point_in_rect(col: u16, row: u16, r: ratatui::layout::Rect) -> bool { + r.width > 0 && r.height > 0 + && col >= r.x && col < r.x + r.width + && row >= r.y && row < r.y + r.height +} + fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { let area = app.editor_area.get(); - let is_in_editor = mouse.column >= area.x - && mouse.column < area.x + area.width - && mouse.row >= area.y - && mouse.row < area.y + area.height; + let is_in_editor = point_in_rect(mouse.column, mouse.row, area); let term_area = app.terminal_area.get(); - let is_in_terminal = mouse.column >= term_area.x - && mouse.column < term_area.x + term_area.width - && mouse.row >= term_area.y - && mouse.row < term_area.y + term_area.height; + let is_in_terminal = point_in_rect(mouse.column, mouse.row, term_area); + + let sidebar_area = app.sidebar_area.get(); + let is_in_sidebar = point_in_rect(mouse.column, mouse.row, sidebar_area); + + let top_bar_area = app.top_bar_area.get(); + let is_in_top_bar = point_in_rect(mouse.column, mouse.row, top_bar_area); + + let dropdown_area = app.dropdown_area.get(); + let is_in_dropdown = dropdown_area + .map(|r| point_in_rect(mouse.column, mouse.row, r)) + .unwrap_or(false); match mouse.kind { MouseEventKind::ScrollUp => { - if matches!(app.active_panel, Panel::Terminal) { + if is_in_terminal || matches!(app.active_panel, Panel::Terminal) { app.terminal_scroll = app.terminal_scroll.saturating_add(3); } } MouseEventKind::ScrollDown => { - if matches!(app.active_panel, Panel::Terminal) { + if is_in_terminal || matches!(app.active_panel, Panel::Terminal) { app.terminal_scroll = app.terminal_scroll.saturating_sub(3); } } - MouseEventKind::Down(crossterm::event::MouseButton::Left) if is_in_editor => { - app.active_panel = Panel::Editor; - let new_y = (mouse.row - area.y) as usize + app.editor().scroll_y; - let new_x = (mouse.column - area.x) as usize; - - if new_y < app.editor().buffer.len_lines() { - if mouse.modifiers.contains(KeyModifiers::SHIFT) { - if app.editor().selection_start.is_none() { - app.editor_mut().toggle_selection(); + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + // Click on dropdown item + if is_in_dropdown { + if let Some(dd) = dropdown_area { + // Row within dropdown content (subtract border) + let item_row = mouse.row.saturating_sub(dd.y + 1) as usize; + let items_len = if let Some(menu) = app.top_bar.active_menu { + crate::ui::top_bar::get_menu_items(menu, app).len() + } else { + 0 + }; + if item_row < items_len { + app.top_bar.selected_index = item_row; + app.execute_top_bar_action(); } - } else { - app.editor_mut().clear_selection(); } + } + // Click on top bar menu label + else if is_in_top_bar { + let positions = app.top_bar_positions.take(); + let menus = [ + crate::app::TopBarMenu::Navigation, + crate::app::TopBarMenu::Edit, + crate::app::TopBarMenu::Files, + crate::app::TopBarMenu::Panels, + crate::app::TopBarMenu::Sidebar, + crate::app::TopBarMenu::Code, + crate::app::TopBarMenu::Help, + crate::app::TopBarMenu::Theme, + ]; + let mut clicked_menu = None; + for (i, &(start, end)) in positions.iter().enumerate() { + if mouse.column >= start && mouse.column < end { + clicked_menu = Some(menus[i]); + break; + } + } + app.top_bar_positions.set(positions); + if let Some(menu) = clicked_menu { + if app.top_bar.active_menu == Some(menu) { + app.close_menu(); + } else { + app.top_bar.active_menu = Some(menu); + app.top_bar.selected_index = 0; + } + } + } + // Click in sidebar + else if is_in_sidebar { + // Close any open menu + if app.top_bar.active_menu.is_some() { + app.close_menu(); + } + app.active_panel = Panel::Sidebar; + app.terminal_sel = None; + // Calculate which sidebar entry was clicked + let clicked_row = (mouse.row - sidebar_area.y) as usize; + let target_index = app.sidebar.offset + clicked_row; + if target_index < app.sidebar.flat_list.len() { + app.sidebar.selected_index = target_index; + } + } + // Click in editor + else if is_in_editor { + if app.top_bar.active_menu.is_some() { + app.close_menu(); + } + app.active_panel = Panel::Editor; + app.terminal_sel = None; + let new_y = (mouse.row - area.y) as usize + app.editor().scroll_y; + let new_x = (mouse.column - area.x) as usize; - app.editor_mut().cursor_y = - new_y.min(app.editor().buffer.len_lines().saturating_sub(1)); - app.editor_mut().cursor_x = new_x; - app.editor_mut().clamp_cursor_x(); + if new_y < app.editor().buffer.len_lines() { + if mouse.modifiers.contains(KeyModifiers::SHIFT) { + if app.editor().selection_start.is_none() { + app.editor_mut().toggle_selection(); + } + } else { + app.editor_mut().clear_selection(); + } + + app.editor_mut().cursor_y = + new_y.min(app.editor().buffer.len_lines().saturating_sub(1)); + app.editor_mut().cursor_x = new_x; + app.editor_mut().clamp_cursor_x(); + } + } + // Click in terminal + else if is_in_terminal { + if app.top_bar.active_menu.is_some() { + app.close_menu(); + } + app.active_panel = Panel::Terminal; + app.terminal_sel = None; + } + // Click elsewhere closes menu + else if app.top_bar.active_menu.is_some() { + app.close_menu(); } } MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { @@ -157,8 +248,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { let term_y = mouse.row.saturating_sub(term_area.y).saturating_sub(1) as usize; let term_x = mouse.column.saturating_sub(term_area.x).saturating_sub(1) as usize; - // For simplicity, we just use term_y as the absolute Y within the grid - // This means selection highlights will be restricted to the active screen view. let abs_y = term_y; if let Some((sel_start, _)) = app.terminal_sel { @@ -167,7 +256,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { app.terminal_sel = Some(((abs_y, term_x), (abs_y, term_x))); } - // Copy selection immediately on drag like most modern terminals copy_terminal_selection(app); } else if is_in_editor { if app.editor().selection_start.is_none() { @@ -177,13 +265,11 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { let new_x = (mouse.column.saturating_sub(area.x)) as usize; if mouse.row < area.y { - // Dragging above the editor area let scroll_y = app.editor().scroll_y; app.editor_mut().scroll_y = scroll_y.saturating_sub(1); let scroll_y = app.editor().scroll_y; app.editor_mut().cursor_y = scroll_y; } else if mouse.row >= area.y + area.height { - // Dragging below the editor area let scroll_y = app.editor().scroll_y; let buf_len = app.editor().buffer.len_lines(); if scroll_y + (area.height as usize) < buf_len { @@ -194,7 +280,6 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { .saturating_sub(1) .min(buf_len.saturating_sub(1)); } else { - // Within editor area y-bounds let scroll_y = app.editor().scroll_y; let target_y = (mouse.row - area.y) as usize + scroll_y; app.editor_mut().cursor_y = @@ -684,7 +769,13 @@ fn handle_key_event(app: &mut App, key: KeyEvent) -> io::Result<()> { } // Global Control shortcuts - if key.modifiers.contains(KeyModifiers::CONTROL) { + // Let Ctrl(+Shift)+Home/End and Ctrl+Shift+Left/Right fall through to editor handler + let ctrl_nav_to_editor = key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(app.active_panel, Panel::Editor) + && (matches!(key.code, KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown) + || (key.modifiers.contains(KeyModifiers::SHIFT) + && matches!(key.code, KeyCode::Left | KeyCode::Right))); + if key.modifiers.contains(KeyModifiers::CONTROL) && !ctrl_nav_to_editor { // Ctrl+Shift combos if key.modifiers.contains(KeyModifiers::SHIFT) { match key.code { @@ -963,8 +1054,37 @@ fn handle_key_event(app: &mut App, key: KeyEvent) -> io::Result<()> { schedule_document_sync(app); return Ok(()); } - app.editor_mut().clear_selection(); - app.editor_mut().move_cursor_left(); + if is_selecting { + app.editor_mut().toggle_selection(); + } else { + app.editor_mut().clear_selection(); + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Word-left: skip whitespace then non-whitespace + let editor = app.editor_mut(); + let line = editor.buffer.line(editor.cursor_y); + let text: String = line.chars().collect(); + if editor.cursor_x == 0 { + if editor.cursor_y > 0 { + editor.cursor_y -= 1; + editor.cursor_x = editor.get_max_cursor_x(editor.cursor_y); + } + } else { + let mut x = editor.cursor_x; + let chars: Vec = text.chars().collect(); + // Skip whitespace backwards + while x > 0 && chars.get(x - 1).map_or(false, |c| c.is_whitespace()) { + x -= 1; + } + // Skip word chars backwards + while x > 0 && chars.get(x - 1).map_or(false, |c| !c.is_whitespace()) { + x -= 1; + } + editor.cursor_x = x; + } + } else { + app.editor_mut().move_cursor_left(); + } app.lsp_state.hover = None; return Ok(()); } @@ -974,8 +1094,39 @@ fn handle_key_event(app: &mut App, key: KeyEvent) -> io::Result<()> { schedule_document_sync(app); return Ok(()); } - app.editor_mut().clear_selection(); - app.editor_mut().move_cursor_right(); + if is_selecting { + app.editor_mut().toggle_selection(); + } else { + app.editor_mut().clear_selection(); + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Word-right: skip non-whitespace then whitespace + let editor = app.editor_mut(); + let line = editor.buffer.line(editor.cursor_y); + let text: String = line.chars().collect(); + let max_x = editor.get_max_cursor_x(editor.cursor_y); + if editor.cursor_x >= max_x { + let total_lines = editor.buffer.len_lines(); + if editor.cursor_y + 1 < total_lines { + editor.cursor_y += 1; + editor.cursor_x = 0; + } + } else { + let mut x = editor.cursor_x; + let chars: Vec = text.chars().collect(); + // Skip word chars forward + while x < max_x && chars.get(x).map_or(false, |c| !c.is_whitespace()) { + x += 1; + } + // Skip whitespace forward + while x < max_x && chars.get(x).map_or(false, |c| c.is_whitespace()) { + x += 1; + } + editor.cursor_x = x; + } + } else { + app.editor_mut().move_cursor_right(); + } app.lsp_state.hover = None; return Ok(()); } diff --git a/src/main.rs b/src/main.rs index 579dccc..b08c7eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use clap::Parser; use crossterm::{ event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -62,9 +63,16 @@ async fn main() -> Result<()> { }); // Bridge: crossterm terminal events → unified channel (runs on blocking thread) + // Filter out key Release events to avoid unnecessary redraws let term_event_tx = event_tx.clone(); std::thread::spawn(move || { + use crossterm::event::{Event as CEvent, KeyEventKind}; while let Ok(ev) = event::read() { + if let CEvent::Key(ref key) = ev { + if key.kind == KeyEventKind::Release { + continue; + } + } if term_event_tx .send(events::klein_event::KleinEvent::Terminal(ev)) .is_err() @@ -83,6 +91,12 @@ async fn main() -> Result<()> { EnableMouseCapture, EnableBracketedPaste )?; + // Best-effort: enable enhanced keyboard protocol for terminals that support it + let has_keyboard_enhancement = execute!( + stdout, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ) + .is_ok(); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -95,6 +109,9 @@ async fn main() -> Result<()> { // Restore terminal disable_raw_mode()?; + if has_keyboard_enhancement { + let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); + } execute!( terminal.backend_mut(), LeaveAlternateScreen, @@ -110,14 +127,103 @@ async fn main() -> Result<()> { Ok(()) } +async fn process_event( + app: &mut App, + klein_event: events::klein_event::KleinEvent, +) -> io::Result<()> { + match klein_event { + events::klein_event::KleinEvent::Terminal(ev) => { + events::handle_event(app, ev)?; + } + events::klein_event::KleinEvent::Lsp(notification) => { + events::handle_lsp_notification(app, notification); + } + events::klein_event::KleinEvent::Timer(kind) => { + events::handle_timer_event(app, kind); + } + events::klein_event::KleinEvent::InitLsp(path) => { + log::warn!("LSP: received InitLsp for {}", path.display()); + if app + .lsp_manager + .ensure_server_for_file(&path) + .await + .is_some() + { + log::warn!("LSP: server confirmed for {}", path.display()); + app.notify_lsp_did_open_for_path(&path); + } else { + log::error!( + "LSP: server NOT found or failed to start for {}", + path.display() + ); + } + } + events::klein_event::KleinEvent::GotoDefinition => { + app.trigger_goto_definition(); + } + events::klein_event::KleinEvent::FindReferences => { + app.trigger_find_references(); + } + events::klein_event::KleinEvent::FormatDocument => { + app.trigger_format_document(); + } + events::klein_event::KleinEvent::Rename => { + app.execute_rename(); + } + events::klein_event::KleinEvent::CodeAction => { + app.trigger_code_action(); + } + events::klein_event::KleinEvent::CompletionResponse(resp, path, pos) => { + app.handle_completion_response(resp, path, pos); + } + events::klein_event::KleinEvent::HoverResponse(resp, path, pos) => { + app.handle_hover_response(resp, path, pos); + } + events::klein_event::KleinEvent::DefinitionResponse(resp, path) => { + app.handle_definition_response(resp, path); + } + events::klein_event::KleinEvent::ReferencesResponse(resp, path) => { + app.handle_references_response(resp, path); + } + events::klein_event::KleinEvent::FormatResponse(resp, path) => { + app.handle_format_response(resp, path); + } + events::klein_event::KleinEvent::RenameResponse(resp, path, new_name) => { + app.handle_rename_response(resp, path, new_name); + } + events::klein_event::KleinEvent::CodeActionResponse(resp, path, pos) => { + app.handle_code_action_response(resp, path, pos); + } + events::klein_event::KleinEvent::RefreshTheme => { + log::info!("Hot-reloading themes..."); + app.reload_themes(); + } + } + Ok(()) +} + async fn run_app( terminal: &mut Terminal, app: &mut App, mut event_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> io::Result<()> { + // Initial draw + terminal.draw(|f| ui::render(f, app))?; + loop { - terminal.draw(|f| ui::render(f, app))?; + // Block until at least one event arrives + if let Some(first_event) = event_rx.recv().await { + process_event(app, first_event).await?; + } + // Give a tiny window for more events to arrive, then drain them all. + // This batches rapid key events so we only draw once per batch. + tokio::task::yield_now().await; + while let Ok(ev) = event_rx.try_recv() { + process_event(app, ev).await?; + } + + // Check terminal child exit if !app.terminal_restarting { let mut child_exited = false; if let Ok(mut child) = app.terminal.child.lock() { @@ -137,80 +243,8 @@ async fn run_app( app.maximized = klein_ide::app::Maximized::None; } - // Drain all pending events (non-blocking) - while let Ok(klein_event) = event_rx.try_recv() { - match klein_event { - events::klein_event::KleinEvent::Terminal(ev) => { - events::handle_event(app, ev)?; - } - events::klein_event::KleinEvent::Lsp(notification) => { - events::handle_lsp_notification(app, notification); - } - events::klein_event::KleinEvent::Timer(kind) => { - events::handle_timer_event(app, kind); - } - events::klein_event::KleinEvent::InitLsp(path) => { - log::warn!("LSP: received InitLsp for {}", path.display()); - if app - .lsp_manager - .ensure_server_for_file(&path) - .await - .is_some() - { - log::warn!("LSP: server confirmed for {}", path.display()); - app.notify_lsp_did_open_for_path(&path); - } else { - log::error!( - "LSP: server NOT found or failed to start for {}", - path.display() - ); - } - } - events::klein_event::KleinEvent::GotoDefinition => { - app.trigger_goto_definition(); - } - events::klein_event::KleinEvent::FindReferences => { - app.trigger_find_references(); - } - events::klein_event::KleinEvent::FormatDocument => { - app.trigger_format_document(); - } - events::klein_event::KleinEvent::Rename => { - app.execute_rename(); - } - events::klein_event::KleinEvent::CodeAction => { - app.trigger_code_action(); - } - events::klein_event::KleinEvent::CompletionResponse(resp, path, pos) => { - app.handle_completion_response(resp, path, pos); - } - events::klein_event::KleinEvent::HoverResponse(resp, path, pos) => { - app.handle_hover_response(resp, path, pos); - } - events::klein_event::KleinEvent::DefinitionResponse(resp, path) => { - app.handle_definition_response(resp, path); - } - events::klein_event::KleinEvent::ReferencesResponse(resp, path) => { - app.handle_references_response(resp, path); - } - events::klein_event::KleinEvent::FormatResponse(resp, path) => { - app.handle_format_response(resp, path); - } - events::klein_event::KleinEvent::RenameResponse(resp, path, new_name) => { - app.handle_rename_response(resp, path, new_name); - } - events::klein_event::KleinEvent::CodeActionResponse(resp, path, pos) => { - app.handle_code_action_response(resp, path, pos); - } - events::klein_event::KleinEvent::RefreshTheme => { - log::info!("Hot-reloading themes..."); - app.reload_themes(); - } - } - } - - // Yield briefly so the channel can accumulate events - tokio::time::sleep(std::time::Duration::from_millis(8)).await; + // Single draw after all events are processed + terminal.draw(|f| ui::render(f, app))?; if app.should_quit { log::info!("Klein shutting down"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 34e68bc..d4e5407 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -42,7 +42,10 @@ pub fn render(f: &mut Frame, app: &App) { let main_chunks = layout::get_editor_layout(chunks[2], show_sidebar); if show_sidebar { + app.sidebar_area.set(main_chunks[0]); sidebar::render(f, main_chunks[0], app); + } else { + app.sidebar_area.set(ratatui::layout::Rect::default()); } editor::render(f, main_chunks[1], app); diff --git a/src/ui/top_bar.rs b/src/ui/top_bar.rs index c980d7b..7ee744b 100644 --- a/src/ui/top_bar.rs +++ b/src/ui/top_bar.rs @@ -8,7 +8,7 @@ use ratatui::{ }; pub fn render(f: &mut Frame, area: Rect, app: &App) { - let menus = vec![ + let menu_labels = vec![ " Navigation ", " Edit ", " Files ", @@ -31,28 +31,71 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { None => 999, // Nothing selected }; - let tabs = Tabs::new(menus.clone()) - .select(if selected_tab == 999 { 0 } else { selected_tab }) - .style(Style::default().fg(app.theme.top_bar.text)) - .highlight_style(if selected_tab == 999 { - Style::default().fg(app.theme.top_bar.text) - } else { - Style::default() - .fg(app.theme.top_bar.background) - .bg(app.theme.top_bar.text) - .add_modifier(Modifier::BOLD) + // Build styled menu labels with underlined first letter (the Alt shortcut key) + let menus: Vec = menu_labels + .iter() + .enumerate() + .map(|(i, label)| { + let trimmed = label.trim_start(); + let leading_spaces = label.len() - trimmed.len(); + let prefix = &label[..leading_spaces]; + let first_char = &trimmed[..1]; + let rest = &trimmed[1..]; + + let is_selected = i == selected_tab; + let base_style = if is_selected { + Style::default() + .fg(ratatui::style::Color::Black) + .bg(app.theme.top_bar.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.top_bar.text) + }; + + Line::from(vec![ + Span::styled(prefix, base_style), + Span::styled(first_char, base_style.add_modifier(Modifier::UNDERLINED)), + Span::styled(rest, base_style), + ]) }) + .collect(); + + // We style each tab manually in the Line construction above, + // so set highlight_style same as base to avoid Tabs widget double-highlighting. + let tabs = Tabs::new(menus) + .select(selected_tab.min(7)) + .style(Style::default().fg(app.theme.top_bar.text)) + .highlight_style(Style::default().fg(app.theme.top_bar.text)) .divider("│"); f.render_widget(tabs, area); - // If a menu is active, render the dropdown + // Store menu positions for mouse click detection + // Tabs widget layout: padding_left(1) + tab + padding_right(1) + divider(1) + padding_left(1) + tab + ... + { + let mut positions = Vec::new(); + let mut x = area.x; + for (i, label) in menu_labels.iter().enumerate() { + if i == 0 { + x += 1; // initial left padding + } + let w = label.chars().count() as u16; + positions.push((x, x + w)); + x += w + 3; // right_padding(1) + divider(1) + left_padding(1) + } + app.top_bar_positions.set(positions); + app.top_bar_area.set(area); + } + + // If a menu is active, render the dropdown; otherwise clear dropdown area + if app.top_bar.active_menu.is_none() { + app.dropdown_area.set(None); + } if let Some(active_menu) = app.top_bar.active_menu { // Calculate the starting x position of the selected tab approximately - // Each tab name length + divider. Let's do a simple calculation. let mut x_offset = area.x; - for menu in menus.iter().take(selected_tab) { - x_offset += menu.chars().count() as u16 + 1; // +1 for divider + for label in menu_labels.iter().take(selected_tab) { + x_offset += label.chars().count() as u16 + 1; // +1 for divider } render_dropdown( @@ -137,7 +180,8 @@ fn render_dropdown( menu: TopBarMenu, selected_index: usize, app: &App, -) { +) + { let items = get_menu_items(menu, app); let max_shortcut_len = items @@ -164,6 +208,8 @@ fn render_dropdown( height, }; + app.dropdown_area.set(Some(dropdown_area)); + f.render_widget(Clear, dropdown_area); let block = Block::default() From 7d2dc72666ab376367482b7ba4c435c9265a16bb Mon Sep 17 00:00:00 2001 From: turnipy Date: Wed, 25 Mar 2026 19:51:22 +1100 Subject: [PATCH 2/2] fix: revert event loop to upstream, fix menu highlighting, sidebar clicks, and update help text - Revert event loop back to original upstream design (remove Release event filtering and process_event refactor that caused Shift+arrow lag) - Fix menu highlight_style to match selected style (black text on theme bg) so selected menu labels are readable - Fix Theme menu being selected on startup by removing .min(7) clamp - Fix sidebar click offset (account for border +1) - Add double-click support for sidebar (open file / toggle folder) - Update HELP_TEXT with all new keybinds (mouse, menus, tree-sitter, LSP) - Best-effort keyboard enhancement protocol (graceful fallback for terminals that don't support it) Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 2 + src/config.rs | 29 +++++++- src/events/mod.rs | 25 ++++++- src/main.rs | 174 ++++++++++++++++++++-------------------------- src/ui/top_bar.rs | 9 ++- 5 files changed, 134 insertions(+), 105 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7729279..9625b81 100644 --- a/src/app.rs +++ b/src/app.rs @@ -92,6 +92,7 @@ pub struct App { pub top_bar_area: Cell, pub top_bar_positions: Cell>, pub dropdown_area: Cell>, + pub last_click: Option<(std::time::Instant, u16, u16)>, pub terminal_sel: Option<((usize, usize), (usize, usize))>, pub show_quit_confirm: bool, pub show_unsaved_confirm: bool, @@ -154,6 +155,7 @@ impl App { top_bar_area: Cell::new(ratatui::layout::Rect::default()), top_bar_positions: Cell::new(Vec::new()), dropdown_area: Cell::new(None), + last_click: None, terminal_sel: None, show_quit_confirm: false, show_unsaved_confirm: false, diff --git a/src/config.rs b/src/config.rs index f4c6ebe..9e2ca06 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ pub const HELP_TEXT: &str = r#" [Ctrl+U / PgUp] Page Up [Home / End] Jump to Top/Bottom [Enter] Open File / Toggle Folder +[Double-Click] Open File / Toggle Folder --- EDITOR --- [Home / End] Start/End of Line @@ -14,12 +15,21 @@ pub const HELP_TEXT: &str = r#" [PgUp / PgDn] Scroll Page [Delete] Forward Delete / Delete Selection [Shift+Arrows] Extend Selection +[Shift+Home / Shift+End] Select to Line Start/End +[Shift+PgUp / Shift+PgDn] Select by Page +[Ctrl+Shift+Home / End] Select to File Start/End +[Ctrl+Shift+Left / Right] Select Word by Word +[Ctrl+Shift+PgUp / PgDn] Select Page [Ctrl+X] Cut Line / Selection [Ctrl+C / Ctrl+V] Copy / Paste [Ctrl+A] Select All [Ctrl+Z] Undo +[Alt+Up / Alt+Down] Expand / Shrink Selection +[Alt+Left / Alt+Right] Swap Nodes +[Alt+Shift+Up / Down] Move Block --- FILE MANAGEMENT --- +[Ctrl+N] New File [Ctrl+P] Find File (fzf) [Ctrl+G] Project Search (rg) [Ctrl+W] Close Current File @@ -29,12 +39,26 @@ pub const HELP_TEXT: &str = r#" --- FOCUS CONTROL --- [Ctrl+F] Focus Sidebar -[Ctrl+E] Focus Editor -[Ctrl+T] Focus Terminal +[Ctrl+E] Focus / Maximize Editor +[Ctrl+T] Focus / Maximize Terminal [Ctrl+B] Toggle Sidebar Visibility [Ctrl+J] Toggle Terminal Visibility +[Ctrl+Left / Right] Switch Panels +[Ctrl+Up / Down] Switch Panels [Esc] Restore Standard Layout / Close Overlays +--- MOUSE --- +[Click] Focus Panel / Select Entry +[Double-Click] Open File / Toggle Folder (Sidebar) +[Shift+Click] Extend Selection (Editor) +[Drag] Select Text (Editor / Terminal) +[Scroll] Scroll Terminal + +--- MENUS --- +[Alt+N/E/F/P/S/C/H] Open Menu +[Click Menu Label] Open / Close Menu +[Click Menu Item] Execute Action + --- HELP --- [Ctrl+H / Esc] Toggle Help Overlay @@ -45,6 +69,7 @@ pub const HELP_TEXT: &str = r#" [Alt+G, then n] Rename Symbol under Cursor [Alt+F] Format Document [Alt+Enter] Code Actions / Quick Fix +[Alt+K] Trigger Hover Info "#; pub mod colors { diff --git a/src/events/mod.rs b/src/events/mod.rs index e6fca6d..9c6c1c2 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -198,11 +198,32 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> io::Result<()> { } app.active_panel = Panel::Sidebar; app.terminal_sel = None; - // Calculate which sidebar entry was clicked - let clicked_row = (mouse.row - sidebar_area.y) as usize; + // Account for top border (+1) when mapping click to entry + let clicked_row = (mouse.row.saturating_sub(sidebar_area.y + 1)) as usize; let target_index = app.sidebar.offset + clicked_row; if target_index < app.sidebar.flat_list.len() { app.sidebar.selected_index = target_index; + + // Detect double-click (same position within 400ms) + let now = std::time::Instant::now(); + let is_double_click = app + .last_click + .map(|(t, col, row)| { + now.duration_since(t).as_millis() < 400 + && col == mouse.column + && row == mouse.row + }) + .unwrap_or(false); + app.last_click = Some((now, mouse.column, mouse.row)); + + if is_double_click { + // Toggle folder or open file (same as Enter) + if let Ok(Some(path)) = app.sidebar.toggle_selected() { + app.preview = None; + open_tab_from_path(app, path); + } + app.last_click = None; // Reset to prevent triple-click + } } } // Click in editor diff --git a/src/main.rs b/src/main.rs index b08c7eb..2758ccf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,16 +63,9 @@ async fn main() -> Result<()> { }); // Bridge: crossterm terminal events → unified channel (runs on blocking thread) - // Filter out key Release events to avoid unnecessary redraws let term_event_tx = event_tx.clone(); std::thread::spawn(move || { - use crossterm::event::{Event as CEvent, KeyEventKind}; while let Ok(ev) = event::read() { - if let CEvent::Key(ref key) = ev { - if key.kind == KeyEventKind::Release { - continue; - } - } if term_event_tx .send(events::klein_event::KleinEvent::Terminal(ev)) .is_err() @@ -127,103 +120,14 @@ async fn main() -> Result<()> { Ok(()) } -async fn process_event( - app: &mut App, - klein_event: events::klein_event::KleinEvent, -) -> io::Result<()> { - match klein_event { - events::klein_event::KleinEvent::Terminal(ev) => { - events::handle_event(app, ev)?; - } - events::klein_event::KleinEvent::Lsp(notification) => { - events::handle_lsp_notification(app, notification); - } - events::klein_event::KleinEvent::Timer(kind) => { - events::handle_timer_event(app, kind); - } - events::klein_event::KleinEvent::InitLsp(path) => { - log::warn!("LSP: received InitLsp for {}", path.display()); - if app - .lsp_manager - .ensure_server_for_file(&path) - .await - .is_some() - { - log::warn!("LSP: server confirmed for {}", path.display()); - app.notify_lsp_did_open_for_path(&path); - } else { - log::error!( - "LSP: server NOT found or failed to start for {}", - path.display() - ); - } - } - events::klein_event::KleinEvent::GotoDefinition => { - app.trigger_goto_definition(); - } - events::klein_event::KleinEvent::FindReferences => { - app.trigger_find_references(); - } - events::klein_event::KleinEvent::FormatDocument => { - app.trigger_format_document(); - } - events::klein_event::KleinEvent::Rename => { - app.execute_rename(); - } - events::klein_event::KleinEvent::CodeAction => { - app.trigger_code_action(); - } - events::klein_event::KleinEvent::CompletionResponse(resp, path, pos) => { - app.handle_completion_response(resp, path, pos); - } - events::klein_event::KleinEvent::HoverResponse(resp, path, pos) => { - app.handle_hover_response(resp, path, pos); - } - events::klein_event::KleinEvent::DefinitionResponse(resp, path) => { - app.handle_definition_response(resp, path); - } - events::klein_event::KleinEvent::ReferencesResponse(resp, path) => { - app.handle_references_response(resp, path); - } - events::klein_event::KleinEvent::FormatResponse(resp, path) => { - app.handle_format_response(resp, path); - } - events::klein_event::KleinEvent::RenameResponse(resp, path, new_name) => { - app.handle_rename_response(resp, path, new_name); - } - events::klein_event::KleinEvent::CodeActionResponse(resp, path, pos) => { - app.handle_code_action_response(resp, path, pos); - } - events::klein_event::KleinEvent::RefreshTheme => { - log::info!("Hot-reloading themes..."); - app.reload_themes(); - } - } - Ok(()) -} - async fn run_app( terminal: &mut Terminal, app: &mut App, mut event_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> io::Result<()> { - // Initial draw - terminal.draw(|f| ui::render(f, app))?; - loop { - // Block until at least one event arrives - if let Some(first_event) = event_rx.recv().await { - process_event(app, first_event).await?; - } - - // Give a tiny window for more events to arrive, then drain them all. - // This batches rapid key events so we only draw once per batch. - tokio::task::yield_now().await; - while let Ok(ev) = event_rx.try_recv() { - process_event(app, ev).await?; - } + terminal.draw(|f| ui::render(f, app))?; - // Check terminal child exit if !app.terminal_restarting { let mut child_exited = false; if let Ok(mut child) = app.terminal.child.lock() { @@ -243,8 +147,80 @@ async fn run_app( app.maximized = klein_ide::app::Maximized::None; } - // Single draw after all events are processed - terminal.draw(|f| ui::render(f, app))?; + // Drain all pending events (non-blocking) + while let Ok(klein_event) = event_rx.try_recv() { + match klein_event { + events::klein_event::KleinEvent::Terminal(ev) => { + events::handle_event(app, ev)?; + } + events::klein_event::KleinEvent::Lsp(notification) => { + events::handle_lsp_notification(app, notification); + } + events::klein_event::KleinEvent::Timer(kind) => { + events::handle_timer_event(app, kind); + } + events::klein_event::KleinEvent::InitLsp(path) => { + log::warn!("LSP: received InitLsp for {}", path.display()); + if app + .lsp_manager + .ensure_server_for_file(&path) + .await + .is_some() + { + log::warn!("LSP: server confirmed for {}", path.display()); + app.notify_lsp_did_open_for_path(&path); + } else { + log::error!( + "LSP: server NOT found or failed to start for {}", + path.display() + ); + } + } + events::klein_event::KleinEvent::GotoDefinition => { + app.trigger_goto_definition(); + } + events::klein_event::KleinEvent::FindReferences => { + app.trigger_find_references(); + } + events::klein_event::KleinEvent::FormatDocument => { + app.trigger_format_document(); + } + events::klein_event::KleinEvent::Rename => { + app.execute_rename(); + } + events::klein_event::KleinEvent::CodeAction => { + app.trigger_code_action(); + } + events::klein_event::KleinEvent::CompletionResponse(resp, path, pos) => { + app.handle_completion_response(resp, path, pos); + } + events::klein_event::KleinEvent::HoverResponse(resp, path, pos) => { + app.handle_hover_response(resp, path, pos); + } + events::klein_event::KleinEvent::DefinitionResponse(resp, path) => { + app.handle_definition_response(resp, path); + } + events::klein_event::KleinEvent::ReferencesResponse(resp, path) => { + app.handle_references_response(resp, path); + } + events::klein_event::KleinEvent::FormatResponse(resp, path) => { + app.handle_format_response(resp, path); + } + events::klein_event::KleinEvent::RenameResponse(resp, path, new_name) => { + app.handle_rename_response(resp, path, new_name); + } + events::klein_event::KleinEvent::CodeActionResponse(resp, path, pos) => { + app.handle_code_action_response(resp, path, pos); + } + events::klein_event::KleinEvent::RefreshTheme => { + log::info!("Hot-reloading themes..."); + app.reload_themes(); + } + } + } + + // Yield briefly so the channel can accumulate events + tokio::time::sleep(std::time::Duration::from_millis(8)).await; if app.should_quit { log::info!("Klein shutting down"); diff --git a/src/ui/top_bar.rs b/src/ui/top_bar.rs index 7ee744b..c5a2e3e 100644 --- a/src/ui/top_bar.rs +++ b/src/ui/top_bar.rs @@ -63,9 +63,14 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // We style each tab manually in the Line construction above, // so set highlight_style same as base to avoid Tabs widget double-highlighting. let tabs = Tabs::new(menus) - .select(selected_tab.min(7)) + .select(selected_tab) .style(Style::default().fg(app.theme.top_bar.text)) - .highlight_style(Style::default().fg(app.theme.top_bar.text)) + .highlight_style( + Style::default() + .fg(ratatui::style::Color::Black) + .bg(app.theme.top_bar.text) + .add_modifier(Modifier::BOLD), + ) .divider("│"); f.render_widget(tabs, area);