diff --git a/CLAUDE.md b/CLAUDE.md index e8807b7c..3582a77f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,9 @@ All non-trivial work should be tracked via GitHub Issues. Issues are the source **Starting work on an issue:** 1. Create a feature branch from `develop`: `git checkout -b issue-{number}-{short-description} develop` 2. Do the work on that branch, committing as you go -3. When done, create a PR to `develop` using `gh pr create` — reference the issue with "Closes #{number}" in the PR body -4. The user reviews and merges the PR. When the user confirms the merge, immediately close the issue with `gh issue close -c "Implemented in PR #N"` — do not rely on GitHub auto-close +3. **Do NOT push or create a PR until the user has run smoke tests and confirmed the changes work.** Commit locally, offer smoke tests, wait for approval before pushing. +4. When the user approves, push and create a PR to `develop` using `gh pr create` — reference the issue with "Closes #{number}" in the PR body +5. The user reviews and merges the PR. When the user confirms the merge, immediately close the issue with `gh issue close -c "Implemented in PR #N"` — do not rely on GitHub auto-close **Creating issues:** - At session end, create issues for any planned but unstarted work discussed during the session diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index bdd795ba..30254096 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -1227,7 +1227,7 @@ pub fn resolve_context_menu_click( } // Inside popup — find which item - let inner_row = click_row - py - 1; // -1 for top border + let inner_row = click_row.saturating_sub(py).saturating_sub(1); // -1 for top border let mut visual_row: u16 = 0; for (i, item) in items.iter().enumerate() { if visual_row == inner_row && item.enabled { diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index bca99017..d93101da 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -617,6 +617,18 @@ pub(super) fn draw_editor( line_height, ); *dialog_btn_rects_out.borrow_mut() = btn_rects; + + draw_context_menu_popup( + cr, + &layout, + &screen, + &theme, + width as f64, + height as f64, + char_width, + line_height, + mouse_pos, + ); } /// Draw thin Cairo horizontal scrollbars that overlay the bottom of each editor @@ -3430,6 +3442,147 @@ pub(super) fn draw_dialog_popup( rects } +/// Draw an engine-driven context menu popup on the DrawingArea. +/// Uses the same data as TUI/Win-GUI for visual consistency. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_context_menu_popup( + cr: &Context, + _layout: &pango::Layout, + screen: &render::ScreenLayout, + theme: &Theme, + editor_width: f64, + editor_height: f64, + char_width: f64, + line_height: f64, + mouse_pos: (f64, f64), +) { + let Some(cm) = &screen.context_menu else { + return; + }; + if cm.items.is_empty() { + return; + } + + let pango_ctx = pangocairo::create_context(cr); + let ui_font_desc = FontDescription::from_string(UI_FONT); + let ui_layout = pango::Layout::new(&pango_ctx); + ui_layout.set_font_description(Some(&ui_font_desc)); + + // Calculate popup dimensions. + let sep_count = cm.items.iter().filter(|i| i.separator_after).count(); + let max_label = cm.items.iter().map(|i| i.label.len()).max().unwrap_or(4); + let max_sc = cm.items.iter().map(|i| i.shortcut.len()).max().unwrap_or(0); + let content_cols = (max_label + max_sc + 6).clamp(20, 50); + let popup_w = content_cols as f64 * char_width; + let popup_h = (cm.items.len() + sep_count + 2) as f64 * line_height; + + // Position: use char-cell coordinates from engine, scaled to pixels. + let raw_x = cm.screen_col as f64 * char_width; + let raw_y = cm.screen_row as f64 * line_height; + let px = raw_x.min(editor_width - popup_w); + let py = raw_y.min(editor_height - popup_h); + + // Background. + let (r, g, b) = theme.fuzzy_bg.to_cairo(); + cr.set_source_rgb(r, g, b); + cr.rectangle(px, py, popup_w, popup_h); + cr.fill().ok(); + + // Border. + let (r, g, b) = theme.fuzzy_border.to_cairo(); + cr.set_source_rgb(r, g, b); + cr.set_line_width(1.0); + cr.rectangle(px, py, popup_w, popup_h); + cr.stroke().ok(); + + // Compute hovered item from mouse position (avoids engine borrow in motion callback). + let hover_idx: Option = if mouse_pos.0 >= 0.0 { + let mcol = (mouse_pos.0 / char_width) as u16; + let mrow = (mouse_pos.1 / line_height) as u16; + let tw = (editor_width / char_width) as u16; + let th = (editor_height / line_height) as u16; + match crate::core::engine::resolve_context_menu_click( + &cm.items + .iter() + .map(|i| crate::core::engine::ContextMenuItem { + label: i.label.clone(), + action: String::new(), + shortcut: i.shortcut.clone(), + separator_after: i.separator_after, + enabled: i.enabled, + }) + .collect::>(), + cm.screen_col, + cm.screen_row, + tw, + th, + mcol, + mrow, + ) { + crate::core::engine::ContextMenuClickResult::Item(idx) => Some(idx), + _ => None, + } + } else { + None + }; + // Use hover index if mouse is over an item; otherwise keep engine selection + // (preserves last-hovered or keyboard-navigated item when mouse leaves). + let selected = hover_idx.unwrap_or(cm.selected_idx); + + // Items. + let mut visual_row: usize = 0; + let item_x = px + char_width; + for (i, item) in cm.items.iter().enumerate() { + let item_y = py + (visual_row + 1) as f64 * line_height; + + // Selection highlight. + if i == selected && item.enabled { + let (r, g, b) = theme.fuzzy_selected_bg.to_cairo(); + cr.set_source_rgb(r, g, b); + cr.rectangle(px + 1.0, item_y, popup_w - 2.0, line_height); + cr.fill().ok(); + } + + // Label — disabled items heavily darkened for obvious visual distinction. + let fg = if item.enabled { + theme.fuzzy_fg + } else { + theme.fuzzy_fg.darken(0.5) + }; + let (r, g, b) = fg.to_cairo(); + cr.set_source_rgb(r, g, b); + ui_layout.set_text(&item.label); + ui_layout.set_attributes(None); + cr.move_to(item_x, item_y); + pangocairo::show_layout(cr, &ui_layout); + + // Shortcut (right-aligned). + if !item.shortcut.is_empty() { + ui_layout.set_text(&item.shortcut); + let (sw, _) = ui_layout.pixel_size(); + let sc_x = px + popup_w - sw as f64 - char_width; + let (r, g, b) = theme.line_number_fg.to_cairo(); + cr.set_source_rgb(r, g, b); + cr.move_to(sc_x, item_y); + pangocairo::show_layout(cr, &ui_layout); + } + + visual_row += 1; + + // Separator line. + if item.separator_after { + let sep_y = py + (visual_row + 1) as f64 * line_height + line_height / 2.0; + let (r, g, b) = theme.fuzzy_border.to_cairo(); + cr.set_source_rgb(r, g, b); + cr.set_line_width(0.5); + cr.move_to(px + 4.0, sep_y); + cr.line_to(px + popup_w - 4.0, sep_y); + cr.stroke().ok(); + visual_row += 1; + } + } +} + /// Draw the tab bar for the bottom panel (Terminal / Debug Output). /// One row high at `(x, y)`, full width `w`. #[allow(clippy::too_many_arguments)] diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index e3622cd4..e7280c65 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -3896,9 +3896,45 @@ impl SimpleComponent for App { { let pos_cell = mouse_pos_cell.clone(); let pos_cell_leave = mouse_pos_cell.clone(); + let engine_motion = engine.clone(); + let lh_motion = line_height_cell.clone(); + let cw_motion = char_width_cell.clone(); + let da_motion = widgets.drawing_area.clone(); let mc = gtk4::EventControllerMotion::new(); mc.connect_motion(move |_, x, y| { pos_cell.set((x, y)); + // Update context menu hover: persist selected index so it + // sticks when the mouse leaves. try_borrow_mut fails during + // draw (engine immutably borrowed) — that's fine, the draw + // function computes hover from mouse_pos directly. + if let Ok(mut eng) = engine_motion.try_borrow_mut() { + if eng.context_menu.is_some() { + let lh = lh_motion.get(); + let cw = cw_motion.get(); + if lh >= 1.0 && cw >= 1.0 { + let col = (x / cw) as u16; + let row = (y / lh) as u16; + let tw = (da_motion.width() as f64 / cw) as u16; + let th = (da_motion.height() as f64 / lh) as u16; + let cm = eng.context_menu.as_ref().unwrap(); + if let crate::core::engine::ContextMenuClickResult::Item(idx) = + crate::core::engine::resolve_context_menu_click( + &cm.items, + cm.screen_x, + cm.screen_y, + tw, + th, + col, + row, + ) + { + eng.context_menu.as_mut().unwrap().selected = idx; + } + } + drop(eng); + da_motion.queue_draw(); + } + } }); mc.connect_leave(move |_| { pos_cell_leave.set((-1.0, -1.0)); @@ -4063,14 +4099,26 @@ impl SimpleComponent for App { x, y, } => { - self.handle_tab_right_click(group_id, tab_idx, x, y, &sender); + let cw = self.cached_char_width.max(1.0); + let lh = self.cached_line_height.max(1.0); + let cx = (x / cw) as u16; + let cy = (y / lh) as u16; + self.engine + .borrow_mut() + .open_tab_context_menu(group_id, tab_idx, cx, cy); + self.draw_needed.set(true); } Msg::TabSwitcherRelease => { // Handled directly by the root EventControllerKey release handler. // Kept as a no-op for exhaustive match. } Msg::EditorRightClick { x, y } => { - self.handle_editor_right_click(x, y); + let cw = self.cached_char_width.max(1.0); + let lh = self.cached_line_height.max(1.0); + let cx = (x / cw) as u16; + let cy = (y / lh) as u16; + self.engine.borrow_mut().open_editor_context_menu(cx, cy); + self.draw_needed.set(true); } Msg::Resize => { // Propagate window resize to open terminal panes. @@ -5085,6 +5133,64 @@ impl App { return; } + // Dismiss context menu on any key press (Escape, or j/k for nav, Enter to confirm). + if self.engine.borrow().context_menu.is_some() { + let mut engine = self.engine.borrow_mut(); + match key_name.as_str() { + "Escape" => { + engine.close_context_menu(); + drop(engine); + self.draw_needed.set(true); + return; + } + "Return" => { + let _act = engine.context_menu_confirm(); + let needs_refresh = engine.explorer_needs_refresh; + if needs_refresh { + engine.explorer_needs_refresh = false; + } + drop(engine); + if needs_refresh { + sender.input(Msg::RefreshFileTree); + } + self.draw_needed.set(true); + return; + } + "j" | "Down" => { + if let Some(ref mut cm) = engine.context_menu { + let len = cm.items.len(); + if len > 0 { + cm.selected = (cm.selected + 1) % len; + } + } + drop(engine); + self.draw_needed.set(true); + return; + } + "k" | "Up" => { + if let Some(ref mut cm) = engine.context_menu { + let len = cm.items.len(); + if len > 0 { + cm.selected = if cm.selected > 0 { + cm.selected - 1 + } else { + len - 1 + }; + } + } + drop(engine); + self.draw_needed.set(true); + return; + } + _ => { + engine.close_context_menu(); + drop(engine); + self.draw_needed.set(true); + // Fall through to normal key handling + } + } + } + // Dismiss any panel hover popup on key press. self.engine.borrow_mut().dismiss_panel_hover_now(); if let Some(ref da) = *self.panel_hover_da.borrow() { @@ -5750,6 +5856,57 @@ impl App { alt: bool, sender: &ComponentSender, ) { + // ── Context menu click handling (engine-drawn) ── + if self.engine.borrow().context_menu.is_some() { + let cw = self.cached_char_width.max(1.0); + let lh = self.cached_line_height.max(1.0); + let click_col = (x / cw) as u16; + let click_row = (y / lh) as u16; + let term_w = (width / cw) as u16; + let term_h = (height / lh) as u16; + + let result = { + let engine = self.engine.borrow(); + let cm = engine.context_menu.as_ref().unwrap(); + crate::core::engine::resolve_context_menu_click( + &cm.items, + cm.screen_x, + cm.screen_y, + term_w, + term_h, + click_col, + click_row, + ) + }; + + use crate::core::engine::ContextMenuClickResult; + match result { + ContextMenuClickResult::Item(idx) => { + let mut engine = self.engine.borrow_mut(); + engine.context_menu.as_mut().unwrap().selected = idx; + // context_menu_confirm() handles the action internally and + // consumes the menu. + let _act = engine.context_menu_confirm(); + let needs_tree_refresh = engine.explorer_needs_refresh; + if needs_tree_refresh { + engine.explorer_needs_refresh = false; + } + drop(engine); + if needs_tree_refresh { + sender.input(Msg::RefreshFileTree); + } + } + ContextMenuClickResult::InsidePopup => { + // Click inside but not on an item — ignore + } + ContextMenuClickResult::Outside => { + self.engine.borrow_mut().close_context_menu(); + } + } + self.draw_needed.set(true); + return; + } + // ── Find/replace overlay click handling (using shared hit regions) ── if self.engine.borrow().find_replace_open { let cw = self.cached_char_width.max(1.0); @@ -6898,6 +7055,7 @@ impl App { } } + #[allow(dead_code)] fn handle_tab_right_click( &mut self, group_id: core::window::GroupId, @@ -7122,6 +7280,7 @@ impl App { } } + #[allow(dead_code)] fn handle_editor_right_click(&mut self, x: f64, y: f64) { let da = match self.drawing_area.borrow().as_ref() { Some(da) => da.clone(),