diff --git a/CLAUDE.md b/CLAUDE.md index fe1acde2..d90e2b2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,13 +41,13 @@ All non-trivial work should be tracked via GitHub Issues. **For all other changes:** -1. **Always work on a local branch off `develop`.** Never commit code directly to `develop`. Branch naming: `issue-{number}-{short-description}` or `{kind}-{short-description}`. -2. Do the work on that branch, committing as you go. -3. **Do NOT push the branch yet.** Keep it local until the user has run smoke tests or explicitly agreed testing is not needed. +1. **Claim the issue before starting work.** Multiple agents may be active concurrently — claim publicly so nobody picks up the same issue. Run `gh issue edit --add-assignee @me`, create the feature branch from `develop` (`issue-{number}-{short-description}`), and push it empty so it appears on the remote as the claim signal. Pushing an empty branch is NOT opening a PR. +2. **Work on that branch**, committing as you go. Never commit code directly to `develop`. For non-issue work, use `{kind}-{short-description}` naming and you may skip the claim step. +3. **Do NOT open a PR yet.** Keep the branch in "commits pushed, no PR" state until the user has run smoke tests or explicitly agreed testing is not needed. Subsequent pushes to the claim branch are fine. 4. **Once approved, ask the user which landing path:** - **Path A — merge locally + push.** For small/trivial changes: `git merge --ff-only `, push `develop`, delete the branch. - - **Path B — push branch + open PR.** For normal feature/bugfix work: push the branch, open a PR to `develop`. Reference "Closes #{number}" if it closes an issue. -5. **When the user confirms a merge that closes an issue**, immediately `gh issue close `. + - **Path B — open PR.** For normal feature/bugfix work: open a PR to `develop` against the already-pushed branch. Reference "Closes #{number}" if it closes an issue. +5. **When the user confirms a merge that closes an issue**, immediately `gh issue close ` and unassign yourself. **Creating issues:** Include full design context in the body — file paths, API details, expected behavior. Issues should be self-contained so a new session can pick one up. diff --git a/SUMMARIES/engine_mod.md b/SUMMARIES/engine_mod.md index c63ab4d9..9301938d 100644 --- a/SUMMARIES/engine_mod.md +++ b/SUMMARIES/engine_mod.md @@ -1,4 +1,4 @@ -# src/core/engine/mod.rs — 3,943 lines +# src/core/engine/mod.rs — 4,756 lines Core engine definition. Contains the `Engine` struct (all editor state), enums, types, `new()` constructor, free functions, and `mod` declarations for all submodules. @@ -20,6 +20,7 @@ Core engine definition. Contains the `Engine` struct (all editor state), enums, - `ContextMenuState` / `ContextMenuItem` / `ContextMenuTarget` — context menus (Tab, ExplorerFile, ExplorerDir, Editor, EditorActionMenu, ExtPanel) - `PanelHoverPopup` / `EditorHoverPopup` — hover popup state - `EditorGroup` — tab group with own tab list + `tab_scroll_offset` for overflow scrolling +- `BottomPanelGeometry` / `BottomPanelZone` — cached panel top_y/height/row_h + zone enum (TabBar/Toolbar/Content) for click dispatch via `resolve_bottom_panel_zone()` (#418) - `UserKeymap` — user-defined key remapping - `DiffPeekState` — inline diff peek popup state - `SwapRecovery` — crash recovery swap file state diff --git a/SUMMARIES/gtk_draw.md b/SUMMARIES/gtk_draw.md index 4560aebe..5013b72d 100644 --- a/SUMMARIES/gtk_draw.md +++ b/SUMMARIES/gtk_draw.md @@ -1,4 +1,4 @@ -# src/gtk/draw.rs — 5,122 lines +# src/gtk/draw.rs — 3,882 lines All Cairo/Pango drawing functions for the GTK backend. Each `draw_*` function renders one UI component onto a Cairo context using data from `ScreenLayout`. diff --git a/SUMMARIES/gtk_mod.md b/SUMMARIES/gtk_mod.md index 291d4013..77b3038d 100644 --- a/SUMMARIES/gtk_mod.md +++ b/SUMMARIES/gtk_mod.md @@ -1,4 +1,4 @@ -# src/gtk/mod.rs — 10,973 lines +# src/gtk/mod.rs — 10,888 lines GTK4/Relm4 application shell. Defines the `App` struct, `Msg` enum, and `SimpleComponent` impl (init/view/update). Contains the main event loop, window setup, input handling, and all GTK widget wiring. diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index 1f9d1959..58be99f6 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -1390,6 +1390,41 @@ pub enum TerminalToolbarHits { TabStrip(quadraui::TabBarHits), } +/// Cached vertical geometry of the bottom panel (tab bar + toolbar + content), +/// written at paint time so click handlers don't have to recompute the snapped +/// panel top from chrome/status/wildmenu heights. Units match the backend's +/// click coordinates: pixels for GTK, character rows for TUI. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BottomPanelGeometry { + /// Top edge of the tab-bar row (= panel top). + pub top_y: f64, + /// Total panel height in the same unit as `top_y`. + pub height: f64, + /// Y offset of the toolbar row relative to `top_y`. + /// GTK: `(line_height * 1.6).ceil()` (tab bar is taller than a normal row). + /// TUI: `1.0`. + pub toolbar_y: f64, + /// Y offset of the content area relative to `top_y`. + /// GTK: `toolbar_y + line_height`. TUI: `2.0`. + pub content_y: f64, + /// Height of one content row (line_height for GTK, 1.0 for TUI). + /// Used to compute `row_offset` within the content zone. + pub content_row_h: f64, +} + +/// Click zone within the bottom panel as resolved by +/// [`Engine::resolve_bottom_panel_zone`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BottomPanelZone { + /// Row 0 — the shared "TERMINAL / DEBUG CONSOLE" tab strip. + TabBar, + /// Row 1 — the per-terminal toolbar (tab strip or find bar). + Toolbar, + /// Row 2+ — the panel content. `row_offset` is the row index relative + /// to the top of the content area (0 = first content row). + Content { row_offset: u16 }, +} + /// Action resolved from a terminal toolbar click. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TerminalToolbarAction { @@ -2947,6 +2982,10 @@ pub struct Engine { /// Cached hit data from the last paint of the terminal toolbar (find bar /// or tab strip). Written at paint time; read by `resolve_terminal_toolbar_click`. pub terminal_toolbar_hits: std::cell::RefCell>, + /// Cached vertical geometry of the bottom panel from the last paint. + /// Written at paint time by both backends; read by `resolve_bottom_panel_zone` + /// so click handlers don't recompute the snapped panel top (#418). + pub bottom_panel_geometry: std::cell::RefCell>, /// Cached layout from the last paint of the command center (nav arrows + search box). /// Written at paint time; read by click handlers. pub command_center_layout: std::cell::RefCell>, @@ -3684,6 +3723,7 @@ impl Engine { bottom_panel_kind: BottomPanelKind::Terminal, bottom_tab_bar_hits: std::cell::RefCell::new(None), terminal_toolbar_hits: std::cell::RefCell::new(None), + bottom_panel_geometry: std::cell::RefCell::new(None), command_center_layout: std::cell::RefCell::new(None), dap_pending_launch: None, bottom_panel_open: false, diff --git a/src/core/engine/terminal_ops.rs b/src/core/engine/terminal_ops.rs index 898faa50..817d8106 100644 --- a/src/core/engine/terminal_ops.rs +++ b/src/core/engine/terminal_ops.rs @@ -183,6 +183,31 @@ impl Engine { self.terminal_has_focus = false; } + /// Resolve which zone of the bottom panel contains the click y-coordinate + /// using the geometry cached at paint time. Returns `None` if the panel + /// isn't currently painted or `y` is above the panel top. `y` is in the + /// caller's unit (pixels for GTK, character rows for TUI) — must match + /// what the backend wrote into [`BottomPanelGeometry`] at paint time. + pub fn resolve_bottom_panel_zone(&self, y: f64) -> Option { + let g = (*self.bottom_panel_geometry.borrow())?; + if y < g.top_y || y >= g.top_y + g.height { + return None; + } + let rel = y - g.top_y; + let zone = if rel < g.toolbar_y { + BottomPanelZone::TabBar + } else if rel < g.content_y { + BottomPanelZone::Toolbar + } else if g.content_row_h > 0.0 { + BottomPanelZone::Content { + row_offset: ((rel - g.content_y) / g.content_row_h) as u16, + } + } else { + BottomPanelZone::Content { row_offset: 0 } + }; + Some(zone) + } + /// Dispatch a click on the bottom panel tab bar using the cached /// `TabBarHits` from the last paint. Returns `true` if the click /// was consumed (tab switch or panel close). diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index 7e3bae14..e6be7e04 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -372,14 +372,8 @@ pub(super) fn draw_editor( // 5b. Draw completion popup (on top of everything else). Cache // the layout so the click handler can hit-test items. - *completion_layout_out.borrow_mut() = draw_completion_popup( - cr, - &layout, - &screen, - &theme, - line_height, - char_width, - ); + *completion_layout_out.borrow_mut() = + draw_completion_popup(cr, &layout, &screen, &theme, line_height, char_width); // 5c. Draw hover popup (on top of everything else) draw_hover_popup( @@ -513,6 +507,15 @@ pub(super) fn draw_editor( let y = height as f64 - status_bar_height - debug_toolbar_px - term_px; (y, term_px) }; + engine + .bottom_panel_geometry + .replace(Some(crate::core::engine::BottomPanelGeometry { + top_y: term_y, + height: term_px, + toolbar_y: line_height, + content_y: 2.0 * line_height, + content_row_h: line_height, + })); // Tab bar row (1 line high) at the top of the bottom panel area. let hits = draw_bottom_panel_tabs( backend, @@ -696,6 +699,8 @@ pub(super) fn draw_editor( }); } } + } else { + engine.bottom_panel_geometry.replace(None); } // 5h. Draw debug toolbar strip if visible (above status bar) diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index a1a06c13..cdf1302f 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -6752,64 +6752,17 @@ impl App { self.engine.borrow_mut().clear_sidebar_focus(); // Check if click lands in the terminal panel before general handling. // Layout (bottom to top): status | toolbar | terminal | quickfix | DAP | editor - let in_terminal = if self.cached_line_height > 0.0 { - let engine = self.engine.borrow(); - if engine.terminal_open || engine.bottom_panel_open { - // Pixel-exact terminal Y must match the draw function's - // special-case snapping for maximize (#386). The draw - // function caches hit regions on engine but this handler - // predates that — kept as-is until migrated to cached hits. - let lh = self.cached_line_height; - let tab_row_h = (lh * 1.6).ceil(); - let per_window = engine.settings.window_status_line; - let bp_open = engine.terminal_open || engine.bottom_panel_open; - let has_sep = - per_window && !engine.settings.status_line_above_terminal && bp_open; - let sep_px = if has_sep { lh } else { 0.0 }; - let wild_px = if engine.wildmenu_items.is_empty() { - 0.0 - } else { - lh - }; - let status_h = lh * if per_window { 1.0 } else { 2.0 } + wild_px; - let dbg_px = if engine.debug_toolbar_visible { - lh - } else { - 0.0 - }; - let term_y = if engine.terminal_maximized { - tab_row_h - } else { - let el = render::compute_editor_layout(&engine, height, lh, false); - height - status_h - dbg_px - sep_px - el.terminal_h - }; - if y >= term_y { - // 0 = tab bar, 1 = toolbar, 2 = content - let zone = if y >= term_y + 2.0 * self.cached_line_height { - 2 - } else if y >= term_y + self.cached_line_height { - 1 - } else { - 0 - }; - Some((term_y, zone)) - } else { - None - } - } else { - None - } - } else { - None - }; - if let Some((term_y, zone)) = in_terminal { - if zone == 0 { + // Geometry is cached at paint time on engine.bottom_panel_geometry (#418). + let zone = self.engine.borrow().resolve_bottom_panel_zone(y); + if let Some(zone) = zone { + use crate::core::engine::BottomPanelZone; + if matches!(zone, BottomPanelZone::TabBar) { self.engine.borrow_mut().handle_bottom_tab_bar_click(x); sender.input(Msg::Resize); return; } self.engine.borrow_mut().terminal_has_focus = true; - if zone == 2 { + if let BottomPanelZone::Content { row_offset } = zone { const SB_W: f64 = 6.0; // In split mode: detect a click on the divider (start drag) // or set keyboard focus to the appropriate pane. @@ -6838,8 +6791,7 @@ impl App { }; if !on_divider { self.terminal_resize_dragging = false; - let row = ((y - term_y - 2.0 * self.cached_line_height) - / self.cached_line_height) as u16; + let row = row_offset; let col = (x / self.cached_char_width.max(1.0)) as u16; self.engine.borrow_mut().terminal_scroll_reset(); if let Some(term) = self.engine.borrow_mut().active_terminal_mut() { @@ -7447,52 +7399,15 @@ impl App { self.draw_needed.set(true); } } else { - // Check if drag is in the terminal content area (text selection). - let in_terminal = if self.cached_line_height > 0.0 { - let engine = self.engine.borrow(); - if engine.terminal_open || engine.bottom_panel_open { - // Pixel-exact terminal Y must match the draw function's - // special-case snapping for maximize (#386). The draw - // function caches hit regions on engine but this handler - // predates that — kept as-is until migrated to cached hits. - let lh = self.cached_line_height; - let tab_row_h = (lh * 1.6).ceil(); - let per_window = engine.settings.window_status_line; - let bp_open = engine.terminal_open || engine.bottom_panel_open; - let has_sep = - per_window && !engine.settings.status_line_above_terminal && bp_open; - let sep_px = if has_sep { lh } else { 0.0 }; - let wild_px = if engine.wildmenu_items.is_empty() { - 0.0 - } else { - lh - }; - let status_h = lh * if per_window { 1.0 } else { 2.0 } + wild_px; - let dbg_px = if engine.debug_toolbar_visible { - lh - } else { - 0.0 - }; - let term_y = if engine.terminal_maximized { - tab_row_h - } else { - let el = render::compute_editor_layout(&engine, height, lh, false); - height - status_h - dbg_px - sep_px - el.terminal_h - }; - if y >= term_y + 2.0 * self.cached_line_height { - Some(term_y) - } else { - None - } - } else { - None + // Drag in the terminal content area (text selection). Geometry is + // cached at paint time on engine.bottom_panel_geometry (#418). + let content_row = match self.engine.borrow().resolve_bottom_panel_zone(y) { + Some(crate::core::engine::BottomPanelZone::Content { row_offset }) => { + Some(row_offset) } - } else { - None + _ => None, }; - if let Some(term_y) = in_terminal { - let row = - ((y - term_y - 2.0 * self.cached_line_height) / self.cached_line_height) as u16; + if let Some(row) = content_row { let col = (x / self.cached_char_width.max(1.0)) as u16; if let Some(term) = self.engine.borrow_mut().active_terminal_mut() { if let Some(ref mut sel) = term.selection { @@ -8207,7 +8122,8 @@ impl App { self.draw_needed.set(true); } Msg::TerminalCopySelection => { - if let Some(text) = self.engine.borrow_mut().terminal_copy_selection() { + let text = self.engine.borrow_mut().terminal_copy_selection(); + if let Some(text) = text { let engine = self.engine.borrow(); if let Some(ref cb) = engine.clipboard_write { let _ = cb(text.as_str()); diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index cfd5e45f..818b97f0 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -1875,29 +1875,15 @@ pub(super) fn handle_mouse( } // ── Bottom panel tab bar click (shared row above Terminal / Debug Output) ── + // Geometry is cached at paint time on engine.bottom_panel_geometry (#418). + if col >= editor_left + && matches!( + engine.resolve_bottom_panel_zone(row as f64), + Some(crate::core::engine::BottomPanelZone::TabBar) + ) { - let bottom_panel_visible = engine.terminal_open || engine.bottom_panel_open; - if bottom_panel_visible && col >= editor_left { - let dt_rows: u16 = if engine.debug_toolbar_visible { 1 } else { 0 }; - let wildmenu_rows: u16 = if !engine.wildmenu_items.is_empty() { - 1 - } else { - 0 - }; - let global_status_rows: u16 = if engine.settings.window_status_line { - 0 - } else { - 1 - }; - let panel_height = super::effective_terminal_panel_rows_tui(engine, term_height) + 2; - // Bottom panel y = term_height - cmd(1) - status - wildmenu - debug_toolbar - panel - let tab_bar_row = term_height - .saturating_sub(1 + global_status_rows + wildmenu_rows + dt_rows + panel_height); - if row == tab_bar_row { - engine.handle_bottom_tab_bar_click(col as f64); - return sidebar_width; - } - } + engine.handle_bottom_tab_bar_click(col as f64); + return sidebar_width; } // ── Scroll-surface click dispatch (scrollbar thumb-drag + track-page). ── @@ -2006,21 +1992,16 @@ pub(super) fn handle_mouse( } } // ── Terminal panel click ─────────────────────────────────────────────────── - { - let qf_rows: u16 = if engine.quickfix_open { 6 } else { 0 }; - let strip_rows: u16 = if engine.terminal_open { - super::effective_terminal_panel_rows_tui(engine, term_height) + 1 - } else { - 0 - }; - let term_strip_top = term_height.saturating_sub(bottom_chrome + qf_rows + strip_rows); - if engine.terminal_open - && strip_rows > 0 - && col >= editor_left - && row >= term_strip_top - && row < term_strip_top + strip_rows - { - if row == term_strip_top { + // Zone resolved from cached geometry written at paint time (#418). Toolbar + // and content rows live inside the bottom panel area; their absolute y + // (e.g. for the scrollbar track) is recovered from the cached top_y. + if engine.terminal_open && col >= editor_left { + let zone = engine.resolve_bottom_panel_zone(row as f64); + let geom = *engine.bottom_panel_geometry.borrow(); + if let (Some(zone), Some(geom)) = (zone, geom) { + use crate::core::engine::BottomPanelZone; + // Tab bar was already dispatched above; only Toolbar / Content land here. + if matches!(zone, BottomPanelZone::Toolbar) { // Header row — dispatch through cached toolbar hit regions. engine.terminal_has_focus = true; let action = engine.resolve_terminal_toolbar_click(col as f64); @@ -2037,7 +2018,7 @@ pub(super) fn handle_mouse( { *dragging_terminal_resize = true; } - } else { + } else if let BottomPanelZone::Content { row_offset } = zone { // Content row — focus split pane or start divider drag. if engine.terminal_split && engine.terminal_panes.len() >= 2 { // Mirror render.rs: use drag-override if set, else actual PTY cols. @@ -2061,10 +2042,10 @@ pub(super) fn handle_mouse( engine.terminal_has_focus = true; if col == sb_col { // Scrollbar column — start drag through shared state. - let track_start = term_strip_top + 1; - let track_len = strip_rows.saturating_sub(1); // content rows - // Cap total to one screenful (vt100 API limit) so the drag range - // [0, total] exactly matches what set_scroll_offset can deliver. + let track_start = (geom.top_y + geom.content_y) as u16; + let track_len = (geom.height - geom.content_y).max(0.0) as u16; + // Cap total to one screenful (vt100 API limit) so the drag range + // [0, total] exactly matches what set_scroll_offset can deliver. let total = engine .active_terminal() .map(|t| t.history.len()) @@ -2096,7 +2077,7 @@ pub(super) fn handle_mouse( ); } else { // Content area — start a selection. - let term_row = row - term_strip_top - 1; + let term_row = row_offset; let term_col = col.saturating_sub(editor_left); engine.terminal_scroll_reset(); if let Some(term) = engine.active_terminal_mut() { diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 8cea5292..6fcfe505 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -630,6 +630,15 @@ pub(super) fn draw_frame( // ── Bottom panel (tab bar + terminal or debug output) ──────────────────── if bottom_panel_area.height > 0 { + engine + .bottom_panel_geometry + .replace(Some(crate::core::engine::BottomPanelGeometry { + top_y: bottom_panel_area.y as f64, + height: bottom_panel_area.height as f64, + toolbar_y: 1.0, + content_y: 2.0, + content_row_h: 1.0, + })); // Tab bar (first row) let tab_bar_area = Rect { x: bottom_panel_area.x, @@ -744,6 +753,8 @@ pub(super) fn draw_frame( }); } } + } else { + engine.bottom_panel_geometry.replace(None); } // ── Debug toolbar strip (if visible) ────────────────────────────────────