diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index c770c877..1e1fb970 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -90,6 +90,13 @@ const CLOSE_TAB_PAD: f64 = 14.0; const CLOSE_TAB_OUTER_GAP: f64 = 1.0; const CLOSE_HOVER_PAD: f64 = 2.0; +/// What the git sidebar's commit-message `TextInput` border costs vertically in +/// GTK's native unit: 1px on top + 1px on bottom. TUI's whole-cell equivalent +/// is `render::sc_commit_input_box_height`'s `+ 2` rows. Fed to +/// `render::sc_sidebar_bands` by both the painter and the click router so the +/// two agree (#544). +const SC_COMMIT_BORDER_PX: f32 = 2.0; + /// Trim a *padded* close-button hit zone `(start, end)` — as reported by /// `quadraui::Backend::tab_bar_layout` — down to the tight × glyph box the /// rasteriser actually draws (including its 2px hover halo). Leading @@ -634,6 +641,22 @@ struct App { /// by the real global `idx` instead of position fixes that regardless /// of how many editor windows precede the tab bars. cached_tab_bar_zones: Rc>>, + /// A sidebar panel claimed the current press, so the rest of the gesture + /// (`MouseMoved` with the left button held, then `MouseUp`) belongs to it — + /// that is what lets a panel scrollbar thumb or a tree drag keep tracking + /// once the pointer strays outside the sidebar. Cleared on release. An + /// *unclaimed* drag is never intercepted, so an editor text-selection drag + /// that wanders over the sidebar still finalises in the editor (#544). + sidebar_pointer_captured: Cell, + /// Git-sidebar band geometry (header / commit input / toolbar slab) as the + /// last `render_content` pass painted it, from `render::sc_sidebar_bands`. + /// `route_sc_sidebar_event` resolves presses against this so the click and + /// paint derivations cannot drift (#544). + cached_sc_bands: Cell>, + /// Debug-sidebar action-button row rect as last painted. The hit regions in + /// `engine.dap_sidebar_action_hits` are relative to this rect's origin, so + /// the router needs it to translate an absolute press (#544). + cached_dap_action_rect: Cell>, /// Per-group tab-drop geometry (absolute pixel bounds) computed each frame in /// `render_content`. Both the drag overlay (same frame) and the drag hit-test /// in `handle_mouse_drag_msg` (next mouse-move) read this, so the drop-zone @@ -749,6 +772,13 @@ struct App { /// Line height the last frame actually painted with, published by /// `render_content` — see [`App::painted_line_height`] (#555). painted_line_height: Rc>>, + /// The sidebar content area the last frame painted a panel into + /// (`ShellContext::layout.sidebar_content_bounds`), or `None` when the + /// sidebar was hidden. Published purely so the headless harness can aim a + /// click at the panel the renderer actually drew instead of guessing pixel + /// offsets — the same "locate targets, never hardcode coords" rule + /// `screen_layout` / `tab_slots_abs` exist for (#544). + painted_sidebar_bounds: Rc>>, /// Link hit rects populated during editor hover popup draw: (x, y, w, h, url). #[allow(clippy::type_complexity)] editor_hover_link_rects: Rc>>, @@ -1558,6 +1588,9 @@ impl App { status_segment_map: Rc::new(RefCell::new(HashMap::new())), cached_screen_layout: Rc::new(RefCell::new(None)), cached_frame_hit_map: Rc::new(RefCell::new(None)), + sidebar_pointer_captured: Cell::new(false), + cached_sc_bands: Cell::new(None), + cached_dap_action_rect: Cell::new(None), cached_tab_bar_zones: Rc::new(RefCell::new(HashMap::new())), cached_drop_groups: Rc::new(RefCell::new(Vec::new())), cached_drop_tbh: Rc::new(Cell::new(0.0)), @@ -1588,6 +1621,7 @@ impl App { context_menu_layout: Rc::new(RefCell::new(None)), tab_switcher_popup_rect: Rc::new(Cell::new(None)), picker_popup_rect: Rc::new(Cell::new(None)), + painted_sidebar_bounds: Rc::new(Cell::new(None)), painted_line_height: Rc::new(Cell::new(None)), editor_hover_link_rects: Rc::new(RefCell::new(Vec::new())), editor_hover_scrollbar: Rc::new(Cell::new(None)), @@ -7026,11 +7060,29 @@ impl App { /// Forward a pointer event over the sidebar content area to the active panel's /// controller. In ShellApp mode the sidebar has no dedicated per-panel - /// `DrawingArea`, so events the Relm4 build delivered straight to the explorer - /// DA must be routed here instead. Currently wires the file explorer through - /// its shared `quadraui::TreeController` (via `Msg::ExplorerUiEvent`); other - /// panels have their own routing or are keyboard-driven. Returns `true` when - /// the event was consumed. (#540 ShellApp port) + /// `DrawingArea`, so events the Relm4 build delivered straight to each panel's + /// DA must be routed here instead. Returns `true` when the event was + /// consumed. (#540 ShellApp port, #544 non-explorer panels) + /// + /// The panel arms mirror `render_content`'s own `match active_id` — each one + /// feeds the very controller (`TreeController` / `SidebarSystem` / + /// `FormController`) that painted the panel, at the rect it painted into. + /// That is the whole reason most arms are just a line or two of dispatch + /// and carry no GTK-specific hit-test: the geometry already lives in the + /// shared controller, exactly as `tui_main::shell_app`'s equivalent + /// intercepts use it. A few panels (settings, extensions, debug/git via + /// their helper functions below) need a bit more — focus bookkeeping or + /// translating a press into a chrome band's local coordinate space — but + /// none of them re-derive hit geometry the painter doesn't already own. + /// + /// # Drag / release follow-through + /// + /// A press claimed here sets `sidebar_pointer_captured`, and while that is + /// set the subsequent `MouseMoved`(left held) / `MouseUp` are routed to the + /// same panel so scrollbar thumbs and tree drags track the pointer. An + /// *unclaimed* move/release is deliberately left alone, so an editor + /// text-drag that happens to cross into the sidebar still finalizes through + /// the editor's own mouse-up path. fn try_route_sidebar_mouse_event( &mut self, event: &quadraui::UiEvent, @@ -7039,21 +7091,46 @@ impl App { use quadraui::UiEvent; let Some(sb) = ctx.layout.sidebar_content_bounds else { + self.sidebar_pointer_captured.set(false); return false; }; - // Intercept only interaction-starting events (press / double-click) and - // wheel scroll. MouseMoved/MouseUp are deliberately NOT intercepted so an - // editor text-drag that happens to cross into the sidebar still finalizes - // through the editor's own mouse-up path. + let dragging = self.sidebar_pointer_captured.get(); let pos = match event { UiEvent::MouseDown { position, .. } | UiEvent::DoubleClick { position, .. } | UiEvent::Scroll { position, .. } => *position, + // Follow-through only: never *start* an interaction from a move or + // a release (see the doc comment above). + UiEvent::MouseUp { position, .. } if dragging => { + self.sidebar_pointer_captured.set(false); + *position + } + UiEvent::MouseMoved { + position, + buttons: quadraui::ButtonMask { left: true, .. }, + } if dragging => *position, _ => return false, }; - if pos.x < sb.x || pos.x >= sb.x + sb.width || pos.y < sb.y || pos.y >= sb.y + sb.height { + // A captured drag keeps its grab even when the pointer leaves the + // sidebar — otherwise dragging a scrollbar thumb sideways would silently + // hand the rest of the gesture to the editor. + let starts_interaction = + !matches!(event, UiEvent::MouseUp { .. } | UiEvent::MouseMoved { .. }); + if starts_interaction + && (pos.x < sb.x + || pos.x >= sb.x + sb.width + || pos.y < sb.y + || pos.y >= sb.y + sb.height) + { return false; } + // Only a *press* moves keyboard focus into the panel. A wheel notch is + // deliberately excluded: hovering-and-scrolling must not steal focus, + // the same rule the editor's own wheel path follows (#240/#646). + let is_press = matches!( + event, + UiEvent::MouseDown { .. } | UiEvent::DoubleClick { .. } + ); // An open picker / command palette is painted *over* the sidebar and // owns every press while it is up (#555). `render_content` centres the @@ -7097,23 +7174,209 @@ impl App { return true; } - // Only the file explorer panel is wired through here. When no panel id is - // set the explorer is the default (mirrors render_content). - let explorer_active = { + // Which panel owns the sidebar body? Derived exactly as + // `render_content` derives it, so the click router and the painter can + // never disagree about who is on screen. + let active_id: String = { let engine = self.engine.borrow(); - engine.ext_panel_active.is_none() - && engine + if let Some(ref name) = engine.ext_panel_active { + format!("ext:{name}") + } else { + engine .app_shell .active_panel_id() - .map(|id| id.as_str() == PANEL_EXPLORER) - .unwrap_or(true) + .map(|id| id.as_str().to_string()) + .unwrap_or_else(|| PANEL_EXPLORER.to_string()) + } + }; + + let consumed = match active_id.as_str() { + PANEL_EXPLORER => { + self.dispatch(Msg::ExplorerUiEvent(event.clone())); + true + } + PANEL_SEARCH => { + let mut engine = self.engine.borrow_mut(); + if is_press { + engine.search_set_focus(true); + } + engine.handle_search_sidebar_ui_event(event.clone()); + true + } + PANEL_DEBUG => self.route_debug_sidebar_event(event), + PANEL_GIT => self.route_sc_sidebar_event(event), + PANEL_EXTENSIONS => { + let mut engine = self.engine.borrow_mut(); + if is_press { + engine.ext_sidebar_has_focus = true; + } + engine.handle_ext_sidebar_ui_event(event.clone()); + if matches!(event, UiEvent::DoubleClick { .. }) { + engine.ext_open_selected_readme(); + } + true + } + PANEL_SETTINGS => { + let mut engine = self.engine.borrow_mut(); + if is_press { + engine.settings_has_focus = true; + } + // `handle_settings_form_ui_event`'s own `bool` return (whether + // `FormController` recognized a row/field under the point) is + // deliberately ignored here: the position is already confirmed + // to be inside the sidebar's content bounds (checked above), + // so even a click on empty panel padding belongs to this panel, + // not the editor underneath it. Honoring `false` would let that + // click fall through to `handle_mouse_click_msg` at sidebar-local + // coordinates, which is exactly the leak every other arm in this + // match also guards against by returning `true` unconditionally. + render::handle_settings_form_ui_event(&mut engine, event, sb); + true + } + id if is_ext_panel_id(id) => { + // Plugin-provided panel: `render_content` paints it through the + // same `ext_sidebar_system` at the same rect, so it routes the + // same way. + let mut engine = self.engine.borrow_mut(); + if is_press { + engine.ext_sidebar_has_focus = true; + } + engine.handle_ext_sidebar_ui_event(event.clone()); + true + } + // PANEL_AI and unknowns are not painted through `render_content` + // yet (same `_ =>` holdout that arm has), so there is nothing for a + // click to hit — let it fall through rather than swallow it. + _ => false, }; - if !explorer_active { + + if consumed { + if starts_interaction { + self.sidebar_pointer_captured + .set(matches!(event, UiEvent::MouseDown { .. })); + } + self.draw_needed.set(true); + } + consumed + } + + /// Sidebar routing for the Debug panel (#544). + /// + /// `render_content` stacks two chrome rows above the body: a title bar and + /// an action-button bar whose `StatusBarLayout` it stashes in + /// `engine.dap_sidebar_action_hits`. Those hit regions are **bar-relative** + /// (`StatusBar::layout` lays out from `0,0`; `quadraui::gtk::draw_status_bar` + /// returns them verbatim), so the press has to be translated into the action + /// row's own space before hit-testing. Everything below goes to the shared + /// `SidebarSystem` at the body rect it painted into. + fn route_debug_sidebar_event(&mut self, event: &quadraui::UiEvent) -> bool { + let action_rect = self.cached_dap_action_rect.get(); + let body_rect = self.engine.borrow().dap_sidebar_body_rect.get(); + if body_rect.width <= 0.0 { return false; } + let starts_interaction = matches!( + event, + quadraui::UiEvent::MouseDown { .. } | quadraui::UiEvent::DoubleClick { .. } + ); + let pos = match event { + quadraui::UiEvent::MouseDown { position, .. } + | quadraui::UiEvent::DoubleClick { position, .. } + | quadraui::UiEvent::MouseUp { position, .. } + | quadraui::UiEvent::MouseMoved { position, .. } + | quadraui::UiEvent::Scroll { position, .. } => *position, + _ => return false, + }; + let mut engine = self.engine.borrow_mut(); + if starts_interaction { + engine.dap_sidebar_has_focus = true; + } + // Chrome band (title + action row) — above the body rect. + if starts_interaction && pos.y < body_rect.y { + let matched = action_rect.is_some_and(|ar| { + let hits = engine.dap_sidebar_action_hits.borrow(); + hits.as_ref().is_some_and(|l| { + matches!( + l.hit_test(pos.x - ar.x, pos.y - ar.y), + quadraui::StatusBarHit::Segment(_) + ) + }) + }); + if matched { + engine.handle_dap_sidebar_action_click(); + } + // Claimed either way: the press landed on this panel's own chrome, + // so it must not leak through to the editor beneath (#637's rule + // for the TUI twin of this intercept). + return true; + } + render::populate_dap_sidebar_system(&engine); + let backend_rc = self.backend.clone(); + let sidebar_event = engine.dap_sidebar_system.borrow_mut().handle( + event, + &mut *backend_rc.borrow_mut(), + body_rect, + ); + engine.dispatch_dap_sidebar_event(sidebar_event); + true + } - self.dispatch(Msg::ExplorerUiEvent(event.clone())); - self.draw_needed.set(true); + /// Sidebar routing for the git ("source control") panel (#544). + /// + /// The panel is three stacked bands — header, commit-message input, and the + /// toolbar slab + change sections. `render_content` derives them via + /// `render::sc_sidebar_bands` and caches the result here, so this resolves a + /// press against the exact geometry that was painted rather than + /// re-deriving it (the pre-#544 handler assumed `DrawingArea`-local + /// coordinates with the panel top at `y == 0`, which the ShellApp painter + /// never produces). + fn route_sc_sidebar_event(&mut self, event: &quadraui::UiEvent) -> bool { + let Some(bands) = self.cached_sc_bands.get() else { + return false; + }; + let starts_interaction = matches!( + event, + quadraui::UiEvent::MouseDown { .. } | quadraui::UiEvent::DoubleClick { .. } + ); + let pos = match event { + quadraui::UiEvent::MouseDown { position, .. } + | quadraui::UiEvent::DoubleClick { position, .. } + | quadraui::UiEvent::MouseUp { position, .. } + | quadraui::UiEvent::MouseMoved { position, .. } + | quadraui::UiEvent::Scroll { position, .. } => *position, + _ => return false, + }; + let mut engine = self.engine.borrow_mut(); + if starts_interaction { + engine.sc_set_focus(true); + } + if starts_interaction { + let commit_bottom = bands.commit_input.y + bands.commit_input.height; + if pos.y < bands.header.y + bands.header.height { + engine.sc_commit_input_active = false; + return true; + } + if pos.y < commit_bottom { + engine.sc_commit_input_active = true; + engine.sc_commit_cursor = engine.sc_commit_message.len(); + return true; + } + engine.sc_commit_input_active = false; + // Toolbar buttons live in the slab above the section list; the + // cached `SidebarPanelLayout` is in absolute space because + // `render_content` painted it at an absolute `slab_rect`. + let hit = { + let layout = engine.sc_panel_layout.borrow(); + layout.as_ref().map(|l| l.hit_test(pos.x, pos.y)) + }; + if let Some(quadraui::SidebarPanelHit::ToolbarButton(_)) = hit { + if let Some(idx) = engine.sc_button_hit(pos.x, pos.y) { + engine.sc_activate_button(idx); + } + return true; + } + } + engine.handle_sc_sidebar_ui_event(event.clone()); true } @@ -8302,6 +8565,8 @@ impl quadraui::ShellApp for App { // ── Draw sidebar panel content ───────────────────────────────────────── // The quadraui AppShell chrome (activity bar + sidebar header) is rendered // by the runner; we fill only the content area it exposes. + self.painted_sidebar_bounds + .set(layout.sidebar_content_bounds); if let Some(q_sb) = layout.sidebar_content_bounds { // Which panel is active? Extension panels bypass AppShell. let active_id: String = if let Some(ref name) = engine.ext_panel_active { @@ -8344,6 +8609,9 @@ impl quadraui::ShellApp for App { let _ = backend.draw_status_bar(title_rect, &title_bar, None, None); let hits = backend.draw_status_bar(action_rect, &action_bar, None, None); engine.dap_sidebar_action_hits.replace(Some(hits)); + // `hits` are relative to `action_rect`'s origin; the click + // router needs the rect to translate into that space (#544). + self.cached_dap_action_rect.set(Some(action_rect)); engine.dap_sidebar_body_rect.set(body_rect); render::populate_dap_sidebar_system(&engine); engine @@ -8363,30 +8631,30 @@ impl quadraui::ShellApp for App { // real now that quadraui#222 (TextInput) has landed, // through the same `render::sc_*` adapters TUI uses // so the two renderers can't drift. - let header_h = lh as f32; - let header_rect = quadraui::Rect::new(q_sb.x, q_sb.y, q_sb.width, header_h); + // Band geometry (header / commit box / slab) comes from + // the shared `render::sc_sidebar_bands` so the click + // router in `try_route_sidebar_mouse_event` resolves a + // press against the *same* derivation that painted it + // (#544). `SC_COMMIT_BORDER_PX` is the primitive's 1px + // border top+bottom — GTK's native unit is pixels, + // unlike TUI's whole-cell border (see + // `render::sc_commit_input_box_height` doc). + let bands = render::sc_sidebar_bands( + &sc.commit_message, + q_sb, + lh as f32, + SC_COMMIT_BORDER_PX, + ); + self.cached_sc_bands.set(Some(bands)); let header_bar = render::sc_header_status_bar(sc, &theme); - let _ = backend.draw_status_bar(header_rect, &header_bar, None, None); + let _ = backend.draw_status_bar(bands.header, &header_bar, None, None); let ti = render::sc_commit_message_to_text_input(sc); - let commit_rows = render::sc_commit_input_row_count(&sc.commit_message); - // +2px for the primitive's 1px border top+bottom — GTK's - // native unit is pixels, unlike TUI's whole-cell border - // (see `render::sc_commit_input_box_height` doc). - let commit_h = commit_rows as f32 * lh as f32 + 2.0; - let ti_rect = - quadraui::Rect::new(q_sb.x, q_sb.y + header_h, q_sb.width, commit_h); - backend.draw_text_input(ti_rect, &ti); + backend.draw_text_input(bands.commit_input, &ti); // Render the toolbar-slab + section list below the // header + commit input. - let slab_y = q_sb.y + header_h + commit_h; - let slab_rect = quadraui::Rect::new( - q_sb.x, - slab_y, - q_sb.width, - (q_sb.height - header_h - commit_h).max(0.0), - ); + let slab_rect = bands.slab; render::draw_sc_sidebar_panel(backend, &engine, sc, slab_rect); let body_rect = engine .sc_panel_layout @@ -8428,6 +8696,15 @@ impl quadraui::ShellApp for App { render::sc_help_dialog_layout(viewport, cw as f32, lh as f32); backend.draw_dialog(&dialog, &dlayout); } + } else { + // Git panel is the active tab but there's no repo open + // (e.g. the user closed it, or switched to a non-git + // folder, without also switching sidebar tabs) — nothing + // paints this frame. Clear the cached band geometry so a + // stray click doesn't get resolved against stale + // coordinates from the last time a repo *was* open + // (`route_sc_sidebar_event` reads this cache directly). + self.cached_sc_bands.set(None); } } PANEL_EXTENSIONS => { diff --git a/src/gtk/testing.rs b/src/gtk/testing.rs index 615b285a..3b2c6276 100644 --- a/src/gtk/testing.rs +++ b/src/gtk/testing.rs @@ -95,6 +95,11 @@ pub(super) struct Harness { pub picker_popup_rect: Rc>>, /// Line height the last frame actually painted with (#555). pub painted_line_height: Rc>>, + /// The sidebar content rect the last frame painted the active panel into, + /// or `None` if the sidebar was hidden. The sidebar twin of + /// [`Self::screen_layout`]'s window rects — aim panel clicks at this rather + /// than at guessed offsets (#544). + pub painted_sidebar_bounds: Rc>>, } impl Harness { @@ -276,6 +281,7 @@ pub(super) fn harness(engine: Engine, width: i32, height: i32) -> Harness Harness Harness { + let mut engine = Engine::new(); + engine.settings.use_nerd_fonts = false; + engine.app_shell.show_panel(&quadraui::WidgetId::new(panel)); + harness(engine, 1400, 900) + } + + /// Settings: a click on a category row must expand/collapse it. + /// + /// The pre-fix path reached `Msg::SettingsClick`, whose geometry was read + /// off `settings_da_ref` — a `DrawingArea` that is `None` for the whole + /// life of a ShellApp run, so panel width/height came back `0` and every + /// row test failed even when the message was dispatched. Nothing dispatched + /// it either. Now the press goes to the same `FormController` that painted + /// the rows. + #[test] + fn settings_panel_click_toggles_the_clicked_category() { + let mut h = panel_harness(PANEL_SETTINGS); + let sb = h + .painted_sidebar_bounds + .get() + .expect("the settings panel must have painted into a sidebar rect"); + assert!( + matches!( + h.engine.borrow().settings_flat_list().first(), + Some(crate::core::engine::SettingsRow::CoreCategory(0)) + ), + "this test aims at the first row expecting it to be category 0" + ); + let before = h.engine.borrow().settings_collapsed[0]; + + h.driver.click(sb.x + 20.0, sb.y + 4.0); + + assert_eq!( + h.engine.borrow().settings_collapsed[0], + !before, + "clicking the first settings category must toggle it (#544)" + ); + assert_eq!( + h.engine.borrow().settings_selected, + 0, + "the clicked row must also become the selection" + ); + } + + /// Settings: the wheel must scroll the panel, not the editor behind it. + #[test] + fn settings_panel_scrolls_under_the_wheel() { + let mut h = panel_harness(PANEL_SETTINGS); + let sb = h.painted_sidebar_bounds.get().unwrap(); + assert_eq!(h.engine.borrow().settings_scroll_top, 0); + + h.driver.dispatch(UiEvent::Scroll { + widget: None, + // Negative y = wheel down in quadraui's convention. + delta: ScrollDelta::new(0.0, -1.0), + position: Point::new(sb.x + 20.0, sb.y + 100.0), + }); + + assert!( + h.engine.borrow().settings_scroll_top > 0, + "a wheel notch over the settings panel must scroll it (#544)" + ); + } + + /// Search: clicking the query box at the top of the panel must focus it. + /// + /// `search_panel_form_focus` is what the renderer reads to draw the caret + /// and what keystrokes are routed by, so a `None` here is the "typing goes + /// nowhere after clicking the search box" half of the report. + #[test] + fn search_panel_click_focuses_the_query_field() { + let mut h = panel_harness(PANEL_SEARCH); + let sb = h.painted_sidebar_bounds.get().unwrap(); + // Clear the panel's default focus so the assertion below can only pass + // because the click put it back. + h.engine.borrow().search_panel_form_focus.replace(None); + h.engine.borrow_mut().search_set_focus(false); + + h.driver.click(sb.x + 20.0, sb.y + 4.0); + + assert_eq!( + h.engine + .borrow() + .search_panel_form_focus + .borrow() + .as_deref(), + Some("search:query"), + "clicking the search panel's query field must focus it (#544)" + ); + assert!( + h.engine.borrow().search_has_focus, + "and the panel itself must take focus" + ); + } + + /// Git: the commit-message box must activate when clicked, and the header + /// row above it must not. + /// + /// This is the band geometry (`render::sc_sidebar_bands`) the painter and + /// the router now share. The pre-fix handler assumed `DrawingArea`-local + /// coordinates with the panel top at `y == 0`, which the ShellApp painter + /// never produces — under the sidebar's real origin every band test landed + /// in the wrong band even if the event had reached it. + /// + /// Skipped when the checkout isn't a git repo: without a `SourceControl` + /// screen the panel paints nothing at all and there are no bands to hit. + #[test] + fn git_panel_click_activates_the_commit_box_but_not_the_header() { + let mut h = panel_harness(PANEL_GIT); + if h.engine.borrow().sc_panel_layout.borrow().is_none() { + return; + } + let sb = h.painted_sidebar_bounds.get().unwrap(); + let lh = h.painted_line_height.get().unwrap() as f32; + let bands = crate::render::sc_sidebar_bands( + &h.engine.borrow().sc_commit_message.clone(), + sb, + lh, + super::super::SC_COMMIT_BORDER_PX, + ); + + h.driver.click( + bands.commit_input.x + 20.0, + bands.commit_input.y + bands.commit_input.height / 2.0, + ); + assert!( + h.engine.borrow().sc_commit_input_active, + "clicking the commit-message box must put the caret in it (#544)" + ); + + h.driver.click( + bands.header.x + 20.0, + bands.header.y + bands.header.height / 2.0, + ); + assert!( + !h.engine.borrow().sc_commit_input_active, + "clicking the header row above it must take the caret back out" + ); + } + + /// Debug: a press in the panel body must reach the panel at all — before + /// #544 it was swallowed by the editor click path, which left + /// `dap_sidebar_has_focus` false so every subsequent keystroke went to the + /// buffer instead of the debug tree. + #[test] + fn debug_panel_click_gives_the_panel_focus() { + let mut h = panel_harness(PANEL_DEBUG); + let body = h.engine.borrow().dap_sidebar_body_rect.get(); + assert!(body.width > 0.0, "the debug panel must have painted a body"); + assert!(!h.engine.borrow().dap_sidebar_has_focus); + + h.driver.click(body.x + 20.0, body.y + 4.0); + + assert!( + h.engine.borrow().dap_sidebar_has_focus, + "a click in the debug panel body must focus it (#544)" + ); + } + + /// An editor text-selection drag that wanders over the sidebar must still + /// finalise in the editor: only a press that a panel *claimed* captures the + /// rest of the gesture. Guards the `sidebar_pointer_captured` follow-through + /// added for panel scrollbar drags from swallowing unrelated releases. + #[test] + fn an_editor_drag_crossing_the_sidebar_is_not_stolen_by_a_panel() { + let mut engine = Engine::new(); + engine.settings.use_nerd_fonts = false; + engine.buffer_mut().insert( + 0, + "alpha beta gamma +second line here +", + ); + let mut h = harness(engine, 1400, 900); + let win = h.engine.borrow().active_window_id(); + let (wx, wy) = h.window_center(win).expect("editor pane must paint"); + let sb = h.painted_sidebar_bounds.get().unwrap(); + + h.driver.mouse_down(wx, wy); + h.driver.mouse_move(sb.x + 10.0, sb.y + 10.0); + h.driver.mouse_up(sb.x + 10.0, sb.y + 10.0); + + assert!( + !h.engine.borrow().explorer_has_focus, + "the explorer must not claim a drag that started in the editor (#544)" + ); + } + + /// The mirror image of the test above: a drag that *starts* inside the + /// sidebar must keep its grab for the rest of the gesture even once the + /// pointer wanders out over the editor — a panel scrollbar-thumb or tree + /// row drag must not hand off to the editor's own drag handling the + /// instant the cursor crosses the sidebar's right edge. Exercises the + /// `dragging` branch of `try_route_sidebar_mouse_event` (the "captured but + /// out of bounds" path the ordinary not-dragging fallthrough never + /// reaches), unlike the test above where the press starts outside the + /// sidebar and `sidebar_pointer_captured` is never set. + /// + /// Uses the debug panel rather than the explorer: `route_debug_sidebar_event` + /// sets `dap_sidebar_has_focus` on *any* press inside the body rect + /// unconditionally (see `debug_panel_click_gives_the_panel_focus` above), + /// so the assertions here don't depend on a real DAP session or tree rows + /// existing — only on the routing/capture plumbing under test. + #[test] + fn a_sidebar_drag_keeps_its_grab_once_it_crosses_into_the_editor() { + let mut engine = Engine::new(); + engine.settings.use_nerd_fonts = false; + engine.buffer_mut().insert( + 0, + "alpha beta gamma +second line here +", + ); + engine + .app_shell + .show_panel(&quadraui::WidgetId::new(PANEL_DEBUG)); + let mut h = harness(engine, 1400, 900); + let win = h.engine.borrow().active_window_id(); + let (wx, wy) = h.window_center(win).expect("editor pane must paint"); + let body = h.engine.borrow().dap_sidebar_body_rect.get(); + assert!(body.width > 0.0, "the debug panel must have painted a body"); + let cursor_before = *h.engine.borrow().cursor(); + + h.driver.mouse_down(body.x + 20.0, body.y + 4.0); + assert!( + h.engine.borrow().dap_sidebar_has_focus, + "a press inside the sidebar must focus the debug panel" + ); + + // Follow the gesture out into the editor pane while the button is + // still held — this is the same move/up pair the test above uses, + // just starting from inside the sidebar instead of outside it. + h.driver.mouse_move(wx, wy); + h.driver.mouse_up(wx, wy); + + assert_eq!( + *h.engine.borrow().cursor(), + cursor_before, + "a captured sidebar drag must never reach the editor's own click \ + path, so the buffer cursor must not move even though the \ + release lands on top of the editor pane (#544)" + ); + } +} diff --git a/src/render.rs b/src/render.rs index 28ce488c..b87af62e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -7116,6 +7116,53 @@ pub fn sc_commit_input_box_height(commit_message: &str) -> u16 { sc_commit_input_row_count(commit_message) + 2 } +/// The three fixed bands the git ("source control") sidebar stacks inside its +/// content area, top to bottom: the header status bar, the commit-message +/// `TextInput` box, and the toolbar-slab + section list that fills the rest. +/// +/// Returned by [`sc_sidebar_bands`] so a backend's *painter* and its *click +/// router* read one derivation instead of two. Both used to inline this +/// arithmetic separately, which is exactly how the pre-#544 GTK click path +/// ended up hit-testing against DrawingArea-local `y` (`0` at the panel top) +/// while the ShellApp painter drew at absolute window coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ScSidebarBands { + /// Header row (branch name + summary). + pub header: quadraui::Rect, + /// Commit-message input box, including its border. + pub commit_input: quadraui::Rect, + /// Everything below: the toolbar slab and the change sections. + pub slab: quadraui::Rect, +} + +/// Split a git-sidebar content rect into its [`ScSidebarBands`]. +/// +/// `row_height` is one text row in the caller's native unit (pixels on GTK, +/// cells on TUI). `commit_border` is what the `TextInput` primitive's 1-unit +/// border on top *and* bottom costs in that same unit — 2.0 px on GTK, 2.0 +/// rows on TUI (see [`sc_commit_input_box_height`], which is the row-unit +/// spelling of the same constant). +pub fn sc_sidebar_bands( + commit_message: &str, + rect: quadraui::Rect, + row_height: f32, + commit_border: f32, +) -> ScSidebarBands { + let header_h = row_height; + let commit_h = sc_commit_input_row_count(commit_message) as f32 * row_height + commit_border; + let slab_y = rect.y + header_h + commit_h; + ScSidebarBands { + header: quadraui::Rect::new(rect.x, rect.y, rect.width, header_h), + commit_input: quadraui::Rect::new(rect.x, rect.y + header_h, rect.width, commit_h), + slab: quadraui::Rect::new( + rect.x, + slab_y, + rect.width, + (rect.y + rect.height - slab_y).max(0.0), + ), + } +} + /// Adapt the SC commit-message state into a `quadraui::TextInput` (#480, /// migrating the hand-rolled `set_cell` commit-row painter to the shared /// primitive shipped in quadraui#222). @@ -9139,6 +9186,100 @@ pub fn populate_settings_form_controller(engine: &Engine) { fc.set_has_focus(engine.settings_has_focus); } +/// Route a pointer event over the Settings panel through the shared +/// `quadraui::FormController` and apply the result to engine state. +/// +/// `rect` is the panel's content area in the caller's own coordinate space — +/// the *same* rect the last frame passed to +/// `FormController::render_and_cache`, since `handle_cached` re-derives its +/// row layout from it. Returns `true` when the event was consumed. +/// +/// This is the click twin of [`populate_settings_form_controller`], and it +/// exists so neither backend has to re-derive the panel's row geometry by +/// hand: before #544 GTK computed `row_h = line_height * 1.4`, a header/search +/// band and a scrollbar gutter from a `DrawingArea`'s own width/height, none of +/// which survive the ShellApp migration (there is no per-panel DrawingArea any +/// more, so every one of those numbers read back `0`). `FormController` already +/// owns all of it and is the only thing that painted the rows. +/// +/// Activation policy matches the keyboard path and the pre-migration GTK/TUI +/// mouse paths: a `Toggle` field flips on a single click, a category header +/// expands/collapses on a single click, and a value row selects on a single +/// click but only *activates* (opens the inline editor / cycles an enum) on a +/// double click. +pub fn handle_settings_form_ui_event( + engine: &mut Engine, + event: &quadraui::UiEvent, + rect: quadraui::Rect, +) -> bool { + use crate::core::engine::SettingsRow; + + // `FormController` has no `DoubleClick` arm — probe with the equivalent + // press and remember that the caller asked for activation. + let (probe, activate_row) = match event { + quadraui::UiEvent::DoubleClick { widget, position } => ( + quadraui::UiEvent::MouseDown { + widget: widget.clone(), + button: quadraui::MouseButton::Left, + position: *position, + modifiers: quadraui::Modifiers::default(), + }, + true, + ), + other => (other.clone(), false), + }; + + populate_settings_form_controller(engine); + let result = engine + .settings_form_controller + .borrow_mut() + .handle_cached(&probe, rect); + + let sync_scroll = |engine: &mut Engine| { + let offset = engine.settings_form_controller.borrow().scroll_offset(); + engine.settings_scroll_top = offset; + }; + + match result { + quadraui::FormControllerEvent::Ignored => false, + quadraui::FormControllerEvent::ScrollChanged | quadraui::FormControllerEvent::Consumed => { + sync_scroll(engine); + true + } + quadraui::FormControllerEvent::FormAction(action) => { + let (id, activates) = match action { + quadraui::FormEvent::ToggleChanged { id, .. } => (id, true), + quadraui::FormEvent::ButtonClicked { id } => (id, true), + quadraui::FormEvent::FocusChanged { id } => (id, activate_row), + _ => return true, + }; + // The form's fields are built 1:1 from `settings_flat_list()` + // (see `settings_to_form`), so the field's position *is* the flat + // index `settings_selected` indexes — read it off the controller + // rather than re-parsing the id string, so the two can't drift. + let idx = engine + .settings_form_controller + .borrow() + .form() + .and_then(|f| f.fields.iter().position(|field| field.id == id)); + let Some(idx) = idx else { + return true; + }; + engine.settings_has_focus = true; + engine.settings_selected = idx; + sync_scroll(engine); + let is_category = matches!( + engine.settings_flat_list().get(idx), + Some(SettingsRow::CoreCategory(_)) | Some(SettingsRow::ExtCategory(_)) + ); + if activates || is_category { + engine.handle_settings_key("Return", false, None); + } + true + } + } +} + /// Adapt the quickfix panel data into a generic `quadraui::ListView`. /// /// The quickfix panel is a simple flat list of pre-formatted strings