diff --git a/src/app.rs b/src/app.rs index 3bd4eeb..9625b81 100644 --- a/src/app.rs +++ b/src/app.rs @@ -88,6 +88,11 @@ 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 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, @@ -146,6 +151,11 @@ 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), + 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 a67f5f0..9c6c1c2 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -108,48 +108,160 @@ 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; + 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(); + } + } + } + // 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; + // 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; - 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(); + // 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 } - } else { - app.editor_mut().clear_selection(); } + } + // 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 +269,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 +277,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 +286,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 +301,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 +790,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 +1075,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 +1115,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..2758ccf 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}, @@ -83,6 +84,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 +102,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, 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..c5a2e3e 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,76 @@ 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 }) + // 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) .style(Style::default().fg(app.theme.top_bar.text)) - .highlight_style(if selected_tab == 999 { - Style::default().fg(app.theme.top_bar.text) - } else { + .highlight_style( Style::default() - .fg(app.theme.top_bar.background) + .fg(ratatui::style::Color::Black) .bg(app.theme.top_bar.text) - .add_modifier(Modifier::BOLD) - }) + .add_modifier(Modifier::BOLD), + ) .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 +185,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 +213,8 @@ fn render_dropdown( height, }; + app.dropdown_area.set(Some(dropdown_area)); + f.render_widget(Clear, dropdown_area); let block = Block::default()