diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index 616d37bd..7d7a5eda 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -259,8 +259,15 @@ pub(super) fn handle_mouse( drag_state: &mut quadraui::DragState, modal_stack: &mut quadraui::ModalStack, last_layout: Option<&render::ScreenLayout>, - last_click_time: &mut Instant, - last_click_pos: &mut (u16, u16), + // #817: the backend's own `quadraui::DoubleClickDetector` (`TuiBackend`, + // `quadraui/src/tui/backend.rs`) already folded timing+position into a + // `UiEvent::DoubleClick` before `TuiShellApp::handle_mouse_event` ever + // reaches this function — this flag is that verdict, carried across the + // `UiEvent` -> crossterm `MouseEvent` round-trip (crossterm itself has no + // double-click concept). It replaces a second, independent 400ms/position + // detector that used to live here with its own `last_click_time`/ + // `last_click_pos` state, so the two could disagree. + is_double_click: bool, folder_picker: &mut Option, should_quit: &mut bool, explorer_drag_src: &mut Option, @@ -563,15 +570,10 @@ pub(super) fn handle_mouse( match hit { render::FindReplaceRoute::Target { target, is_input } => { // Double-click inside an input field selects the word - // under the cursor. TUI-only: it needs the click-time - // history this backend keeps and GTK routes its own - // double-clicks through `UiEvent::DoubleClick`. - let now = Instant::now(); - let is_double = now.duration_since(*last_click_time) - < Duration::from_millis(400) - && *last_click_pos == (col, row); - *last_click_time = now; - *last_click_pos = (col, row); + // under the cursor (#817: `is_double_click` comes from + // the backend's own `DoubleClickDetector`, same as GTK + // routes its double-clicks through `UiEvent::DoubleClick`). + let is_double = is_double_click; use crate::core::engine::FindReplaceClickTarget::*; if is_double { @@ -1936,13 +1938,9 @@ pub(super) fn handle_mouse( let flat_idx = engine.ext_panel_scroll_top + (sidebar_row - content_start) as usize; if flat_idx < flat_len { engine.ext_panel_selected = flat_idx; - // Check for double-click - let now = Instant::now(); - let is_double = now.duration_since(*last_click_time) - < Duration::from_millis(400) - && *last_click_pos == (col, row); - *last_click_time = now; - *last_click_pos = (col, row); + // #817: double-click verdict from the backend's + // `DoubleClickDetector`, not a re-derived local timer. + let is_double = is_double_click; if is_double { engine.handle_ext_panel_double_click(); } else { @@ -2047,19 +2045,14 @@ pub(super) fn handle_mouse( modifiers: quadraui::Modifiers::default(), }; let outcome = render::route_sc_sidebar_click(engine, &click_ev, pos, &bands, true); - // Double-click detection stays TUI-only plumbing — crossterm has - // no double-click concept, so it must be synthesized here from - // click timing before being fed back through the same shared - // dispatch, matching how GTK's toolkit-native `DoubleClick` - // reaches `route_sc_sidebar_click` directly. Only a genuine - // content-row click gets one — a double-click on the header, - // commit box, or a toolbar button was never synthesized here. + // #817: reuse the backend's own `DoubleClickDetector` verdict + // instead of re-deriving one from click timing here — matching + // how GTK's toolkit-native `DoubleClick` reaches + // `route_sc_sidebar_click` directly. Only a genuine content-row + // click gets one — a double-click on the header, commit box, or + // a toolbar button was never synthesized here. if outcome == render::ScSidebarClickOutcome::Content { - let now = Instant::now(); - let is_double = now.duration_since(*last_click_time) < Duration::from_millis(400) - && *last_click_pos == (col, row); - *last_click_time = now; - *last_click_pos = (col, row); + let is_double = is_double_click; if is_double { let double_ev = quadraui::UiEvent::DoubleClick { widget: None, @@ -2136,13 +2129,9 @@ pub(super) fn handle_mouse( let fi = engine.settings_scroll_top + content_row; if fi < flat_total { engine.settings_selected = fi; - // Double-click toggles bools / expands categories - let now = Instant::now(); - let is_double = now.duration_since(*last_click_time) - < Duration::from_millis(400) - && *last_click_pos == (col, row); - *last_click_time = now; - *last_click_pos = (col, row); + // #817: double-click toggles bools / expands categories — + // verdict from the backend's `DoubleClickDetector`. + let is_double = is_double_click; if is_double { engine.handle_settings_key("Return", false, None); } @@ -2662,12 +2651,9 @@ pub(super) fn handle_mouse( let (editor, editor_layout) = crate::render::editor_text_layout(rw, 1.0, 1.0); let col_in_text = editor_layout.col_at_x(&editor, view_row, col as f32); - // Double-click detection - let now = Instant::now(); - let is_double = now.duration_since(*last_click_time) < Duration::from_millis(400) - && *last_click_pos == (col, row); - *last_click_time = now; - *last_click_pos = (col, row); + // #817: double-click verdict from the backend's + // `DoubleClickDetector`, not a re-derived local timer. + let is_double = is_double_click; if ev.modifiers.contains(KeyModifiers::CONTROL) || (ev.modifiers.contains(KeyModifiers::ALT) && engine.is_vscode_mode()) @@ -2943,8 +2929,6 @@ mod tests { let mut sidebar = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( @@ -2963,8 +2947,7 @@ mod tests { &mut drag_state, &mut modal_stack, None, - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, @@ -3175,8 +3158,6 @@ mod tests { let mut sidebar = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( @@ -3195,8 +3176,7 @@ mod tests { &mut drag_state, &mut modal_stack, last_layout, - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, @@ -3504,8 +3484,6 @@ mod tests { let mut sidebar_state = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( move_ev, @@ -3523,8 +3501,7 @@ mod tests { &mut drag_state, &mut modal_stack, Some(&screen), - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, @@ -3683,8 +3660,6 @@ mod tests { let mut sidebar_state = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( move_ev, @@ -3702,8 +3677,7 @@ mod tests { &mut drag_state, &mut modal_stack, Some(&screen), - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, @@ -3794,8 +3768,6 @@ mod tests { let mut sidebar_state = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( @@ -3814,8 +3786,7 @@ mod tests { &mut drag_state, &mut modal_stack, Some(&screen), - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 3cb61e9e..258d5a2b 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -1783,8 +1783,6 @@ mod tests { let mut sidebar = TuiSidebar::new(); let mut drag_state = quadraui::DragState::default(); let mut modal_stack = quadraui::ModalStack::new(); - let mut last_click_time = Instant::now(); - let mut last_click_pos: (u16, u16) = (0, 0); let mut should_quit = false; handle_mouse( ev, @@ -1802,8 +1800,7 @@ mod tests { &mut drag_state, &mut modal_stack, Some(&screen), - &mut last_click_time, - &mut last_click_pos, + false, &mut None, &mut should_quit, &mut None, diff --git a/src/tui_main/shell_app.rs b/src/tui_main/shell_app.rs index 0b76d991..2be5380b 100644 --- a/src/tui_main/shell_app.rs +++ b/src/tui_main/shell_app.rs @@ -422,8 +422,6 @@ pub struct TuiShellApp { /// `driver_with_shell` wraps the app in a shell adapter, so `driver.app()` /// cannot reach `TuiShellApp`'s own fields (#765). debug_toolbar_rect: std::rc::Rc>, - last_click_time: Cell, - last_click_pos: Cell<(u16, u16)>, explorer_sb_dragging: bool, explorer_drag_src: Option, explorer_drag_active: Option<(usize, Option)>, @@ -997,8 +995,6 @@ impl TuiShellApp { last_layout: RefCell::new(None), tab_visible_counts: RefCell::new(Vec::new()), debug_toolbar_rect: std::rc::Rc::new(Cell::new(quadraui::Rect::default())), - last_click_time: Cell::new(now.checked_sub(Duration::from_secs(1)).unwrap_or(now)), - last_click_pos: Cell::new((0, 0)), explorer_sb_dragging: false, explorer_drag_src: None, explorer_drag_active: None, @@ -1202,9 +1198,15 @@ impl TuiShellApp { /// Mirrors `event_loop`'s own bridge verbatim (see its /// `events::uievent_to_crossterm` call ahead of its `Event::Mouse` arm, /// `mod.rs`): fold `DoubleClick` back to `MouseDown` first (crossterm has - /// no double-click concept; `handle_mouse` re-derives it from its own - /// `last_click_time`/`last_click_pos` pair), then round-trip through - /// [`super::events::uievent_to_crossterm`]. The one piece `event_loop` + /// no double-click concept to round-trip through + /// [`super::events::uievent_to_crossterm`]), but capture the fact that it + /// *was* a `DoubleClick` in `is_double_click` first and pass that through + /// to `handle_mouse` as a plain `bool` (#817). `handle_mouse` used to + /// re-derive the same verdict itself from a hand-rolled + /// `last_click_time`/`last_click_pos` pair — a second, independent + /// 400ms/position detector racing the backend's own + /// `quadraui::DoubleClickDetector` that already classified this event as + /// `DoubleClick` in the first place. The one piece `event_loop` /// couldn't do — obtain `&mut DragState` *and* `&mut ModalStack` from a /// `&mut dyn Backend` — is exactly what /// `Backend::{drag_state_handle, modal_stack_handle}` (quadraui#704, @@ -1508,6 +1510,11 @@ impl TuiShellApp { } } + // #817: capture the backend's own double-click verdict before + // folding it away for the crossterm round-trip below — this is the + // single source of truth `handle_mouse` now reads instead of running + // a second, independent timer. + let is_double_click = matches!(event, UiEvent::DoubleClick { .. }); let event = match event { UiEvent::DoubleClick { position, .. } => UiEvent::MouseDown { button: quadraui::MouseButton::Left, @@ -1531,8 +1538,6 @@ impl TuiShellApp { viewport.height.round() as u16, )); - let mut last_click_time = self.last_click_time.get(); - let mut last_click_pos = self.last_click_pos.get(); let mut should_quit = false; let last_layout = self.last_layout.borrow(); let hover_link_rects = self.hover_link_rects.borrow(); @@ -1563,8 +1568,7 @@ impl TuiShellApp { &mut drag_state, &mut modal_stack, last_layout.as_ref(), - &mut last_click_time, - &mut last_click_pos, + is_double_click, &mut self.folder_picker, &mut should_quit, &mut self.explorer_drag_src, @@ -1598,8 +1602,6 @@ impl TuiShellApp { drop(dialog_layout); self.sidebar_width = new_sidebar_width; - self.last_click_time.set(last_click_time); - self.last_click_pos.set(last_click_pos); if should_quit { return Reaction::Exit; @@ -5458,16 +5460,20 @@ mod tests { /// column measured off frame 1 is stale from frame 2 onwards. The /// `Escape` below settles it before anything is measured. /// 3. **Wall-clock double-click detection.** `TuiDriver::click` is a bare - /// `MouseDown` (no release), and `mouse.rs`'s editor arm promotes a - /// second `MouseDown` to `engine.mouse_double_click` when it lands on - /// the *same cell* within `Duration::from_millis(400)` of the first. - /// Parking the cursor and then pressing on that same cell therefore - /// raced real time: fast machine → word-select-then-extend, loaded - /// machine → two plain clicks. The park click below is deliberately one - /// cell left of the drag press so `last_click_pos` differs and - /// `is_double` is `false` regardless of how long the two dispatches - /// take (the same "don't race the 400ms detector" lesson quadraui#592 - /// baked into `TuiDriver::double_click`). + /// `MouseDown` (no release), and `TuiDriver::dispatch` routes every + /// injected event through `TuiBackend::translate_injected`, which folds + /// a second close `MouseDown` into `UiEvent::DoubleClick` inside its + /// `400ms`/1.5-cell `DoubleClickDetector` (`quadraui::dispatch`). + /// `mouse.rs`'s editor arm (#817) just reads that fold's verdict — + /// it no longer runs a second, independent timer of its own — but the + /// detector itself is still real wall-clock state: parking the cursor + /// and then pressing on that same cell would still race real time, + /// fast machine → word-select-then-extend, loaded machine → two plain + /// clicks. The park click below is deliberately one cell left of the + /// drag press so the detector's position check misses and no + /// `DoubleClick` fold happens regardless of how long the two + /// dispatches take (the same "don't race the 400ms detector" lesson + /// quadraui#592 baked into `TuiDriver::double_click`). /// /// The assertion sweeps the whole dragged span rather than one hardcoded /// probe column, so it states the property under test ("some cell the drag @@ -5536,6 +5542,404 @@ mod tests { ); } + /// #817 regression coverage: a genuine double-click must still select + /// the *whole clicked word* once `handle_mouse_event`/`mouse::handle_mouse` + /// stop running their own hand-rolled 400ms/position detector and instead + /// read the verdict `TuiBackend`'s `quadraui::DoubleClickDetector` + /// already folded into `UiEvent::DoubleClick`. Uses + /// `TuiDriver::double_click` (quadraui#592) rather than two `click()`s, + /// so the assertion is deterministic instead of racing real time. + /// + /// Probes two columns *inside the same word* (`probe_a` near its start, + /// `probe_b` near its end) rather than one: a lone repainted cursor glyph + /// — what a regression back to "fold every `DoubleClick` into a plain + /// `MouseDown` and call `engine.mouse_click` instead of + /// `mouse_double_click`" would still produce, since a plain click also + /// repaints the one cell the cursor lands on — only ever touches + /// `probe_a` (where the click landed). A real word *selection* paints + /// every cell of the word, including `probe_b`, with the same highlight. + /// Asserting `after_a == after_b` (and that both differ from their + /// unselected "before" style) is what actually distinguishes "selected + /// the word" from "moved the cursor to where the click landed" — an + /// earlier draft of this test asserted only the single-probe version and + /// stayed green with `is_double` hardcoded to `false` (verified by + /// temporarily reintroducing that regression), i.e. it didn't fail + /// against the bug it claims to cover. The two-probe design below does + /// fail against that regression (see `#817`'s PR for the before/after run). + /// + /// The second word — never clicked — pins down that the change is + /// scoped to the double-clicked word rather than some unrelated + /// full-line repaint; the cursor is parked off in blank space first + /// (rather than on the second word) so its own cursor-glyph styling + /// doesn't leak into that "before" snapshot. + #[test] + fn tui_editor_double_click_selects_the_word_via_shared_dispatch() { + let mut app = TuiShellApp::new_for_test(); + app.engine + .buffer_mut() + .insert(0, "wordZQXW817alpha tailZQXW817beta\n"); + assert_eq!( + app.engine.windows.len(), + 1, + "setup sanity: this test measures editor-pane geometry, so it needs \ + exactly one unsplit window — `new_for_test` must not have restored \ + an ambient session's splits" + ); + let mut driver = driver_with_shell(app, config(), 100, 24); + + // Settle the sidebar width before measuring anything (see the drag + // test above for why this matters). + driver.press_named(quadraui::NamedKey::Escape); + + let first = driver + .find_bounds("wordZQXW817alpha") + .expect("the first word should be painted"); + let second = driver + .find_bounds("tailZQXW817beta") + .expect("the second word should be painted"); + let row = first.y as u16; + assert_eq!(row, second.y as u16, "setup sanity: both words on one row"); + let row_y = first.y + first.height / 2.0; + // Two probes inside the *first* word — near its start (where the + // double-click itself lands) and near its end — plus one inside the + // untouched second word. + let probe_a = first.x as u16 + 2; + let probe_b = first.x as u16 + first.width as u16 - 2; + assert!( + probe_b > probe_a + 1, + "setup sanity: the word must be wide enough for two distinct probes" + ); + let second_probe = second.x as u16 + 2; + // Well past both words, same row — blank space the cursor can park + // on without its own cursor-glyph styling landing on any probe + // column (moving the plain cursor itself repaints whatever cell it + // was on, independent of any selection, so parking *on* a probe + // column would make that column's "before" snapshot include a + // cursor glyph the double-click's cursor move alone would clear — + // a false positive for "the untouched word's paint changed"). + let park_x = second.x + second.width + 3.0; + + // Park the cursor off in blank space first (single click — must + // never be promoted to a double-click here) so the "before" + // snapshot is the words' plain, unselected paint, and `mouse_up` + // releases it. `TuiDriver::click` is a bare `MouseDown` (no + // release), and mouse.rs's own scroll-surface rung + // (`quadraui::dispatch_click` over `engine.scroll_surfaces`) still + // has a registered-but-invisible `"explorer:sb"` surface at a small + // column even with the sidebar hidden; if the park click's + // `TextSelection` drag is left active, a later click landing on that + // column gets misrouted as a scrollbar-drag continuation and + // swallowed before it ever reaches the editor's double-click arm. + // Not this test's bug to fix, just a trap it must not fall into. + driver.click(park_x, row_y); + driver.mouse_up(park_x, row_y); + let before_a = driver.style_at(probe_a, row); + let before_b = driver.style_at(probe_b, row); + let before_second = driver.style_at(second_probe, row); + + driver.double_click(probe_a as f32, row_y); + + let after_a = driver.style_at(probe_a, row); + let after_b = driver.style_at(probe_b, row); + let after_second = driver.style_at(second_probe, row); + + assert_ne!( + before_a, + after_a, + "double-clicking the first word must repaint it with a selection \ + style, but column {probe_a} of row {row} paints identically \ + before and after ({after_a:?}); screen:\n{}", + driver.screen() + ); + assert_eq!( + after_a, + after_b, + "a double-click must select the *whole* word, not just move the \ + cursor to the clicked cell — column {probe_a} (click site) and \ + column {probe_b} (same word, far end) must paint with the same \ + selection style, but got {after_a:?} vs {after_b:?}; screen:\n{}", + driver.screen() + ); + assert_ne!( + before_b, + after_b, + "the far end of the double-clicked word must also pick up the \ + selection style, but column {probe_b} of row {row} paints \ + identically before and after ({after_b:?}); screen:\n{}", + driver.screen() + ); + assert_eq!( + before_second, + after_second, + "double-clicking the first word must not touch the second word's \ + paint, but column {second_probe} of row {row} changed \ + ({before_second:?} -> {after_second:?}); screen:\n{}", + driver.screen() + ); + } + + /// #817 regression coverage, settings-sidebar site (`mouse.rs`'s + /// `SidebarOwner::Settings` arm, one of the four hand-rolled + /// 400ms/position detectors this issue deleted): a genuine double-click + /// on a boolean settings row must toggle it via + /// `engine.handle_settings_key("Return", ...)`, reading the same + /// `is_double_click` verdict the editor-word-selection test above + /// exercises. "Cursor Line" (`cursorline`) is a `SettingType::Bool` that + /// defaults to `true` (`default_cursorline`), so it paints `"[x]"` — + /// flipping to `"[ ]"` (quadraui's TUI `Form` renderer's literal + /// checkbox glyphs) is the rendered-output assertion. + /// + /// The click's row is derived from `engine.settings_flat_list()` — the + /// same list `mouse.rs`'s `SidebarOwner::Settings` arm indexes into via + /// `fi = settings_scroll_top + content_row` — rather than from + /// `find_bounds("Cursor Line")`'s painted position: the two disagree by + /// one row in this fixture (a pre-existing mismatch between where + /// `render_settings_panel` paints row N and where the click router's + /// `content_row = sidebar_row - 2` resolves row N, unrelated to #817 — + /// tracked separately). Computing the target row from the same list the + /// click router consults keeps this test about the double-click verdict, + /// not that separate off-by-one. + #[test] + fn tui_settings_double_click_toggles_a_boolean_row_via_shared_dispatch() { + let mut app = TuiShellApp::new_for_test(); + app.engine + .app_shell + .show_panel(&quadraui::WidgetId::new(PANEL_SETTINGS)); + assert!( + app.engine.settings.cursorline, + "setup sanity: `cursorline` must default to true so the test can \ + observe a true -> false double-click toggle" + ); + let flat = app.engine.settings_flat_list(); + let flat_idx = flat + .iter() + .position(|row| { + matches!( + row, + crate::core::engine::SettingsRow::CoreSetting(idx) + if crate::core::settings::SETTING_DEFS[*idx].key == "cursorline" + ) + }) + .expect("the flat settings list must contain the `cursorline` row"); + // Mirrors `mouse.rs`'s `SidebarOwner::Settings` arm: `sidebar_row = + // row - menu_rows` (menu bar hidden here, so `menu_rows == 0`), then + // `content_row = sidebar_row - 2` (header + search rows), then + // `fi = settings_scroll_top + content_row` (scrolled to the top). + let row = flat_idx as u16 + 2; + let col = ACTIVITY_BAR_WIDTH + 2; + + let mut driver = driver_with_shell(app, config(), 100, 24); + + let before = driver.screen(); + let before_line = before + .lines() + .find(|l| l.contains("Cursor Line")) + .expect("the Cursor Line settings row should be painted"); + assert!( + before_line.contains("[x]"), + "Cursor Line must paint checked (\"[x]\") before any click, since \ + it defaults to true; line: {before_line:?}" + ); + + driver.double_click(col as f32, row as f32 + 0.5); + + let after = driver.screen(); + let after_line = after + .lines() + .find(|l| l.contains("Cursor Line")) + .expect("the Cursor Line settings row should still be painted"); + assert!( + after_line.contains("[ ]"), + "double-clicking the Cursor Line row must toggle it to unchecked \ + (\"[ ]\") via `engine.handle_settings_key(\"Return\", ..)`, but \ + the checkbox glyph didn't flip; line: {after_line:?}; screen:\n{after}" + ); + } + + /// #817 regression coverage, extension-panel site (`mouse.rs`'s + /// `SidebarOwner::ExtPanel` arm, one of the four hand-rolled + /// 400ms/position detectors this issue deleted — formerly around + /// `:1958`). `engine.handle_ext_panel_double_click()` is a no-op for a + /// section-*header* row (`ext_panel_flat_to_section` returns + /// `item_idx == usize::MAX`, and the function bails on that), while the + /// *single*-click branch (`handle_ext_panel_key("Return", ..)`) toggles + /// the section's expand/collapse state. So a genuine double-click on a + /// header must leave the section exactly as it was; a real double-click + /// that gets mis-read as a plain click collapses it, hiding its item. + /// + /// Uses `TuiDriver::double_click` (quadraui#592) to deliver a single + /// synthetic `UiEvent::DoubleClick` with no preceding click — this is + /// what proves the fold reads the backend's own `DoubleClickDetector` + /// verdict instead of re-deriving one from a local click-timing/position + /// history: the *hand-rolled* detector `#817` deleted had no prior + /// `last_click_time` to compare against on a lone injected event, so it + /// would have classified this as a first, ordinary single click and + /// toggled the section closed — this test is RED against that behavior + /// (verified by temporarily hardcoding the `ext_panel` arm's `is_double` + /// to `false`, which reproduces exactly that: the item disappears) and + /// green against the fix, which reads the already-correct verdict + /// `TuiBackend`'s detector attached to the injected event. + #[test] + fn tui_ext_panel_double_click_on_a_section_header_does_not_toggle_it() { + let mut app = TuiShellApp::new_for_test(); + app.engine.settings.use_nerd_fonts = false; + crate::icons::set_nerd_fonts(false); + app.engine.ext_panels.clear(); + app.engine.ext_panels.insert( + "git-insights".to_string(), + crate::core::plugin::PanelRegistration { + name: "git-insights".to_string(), + title: "Git Insights".to_string(), + icon: '\u{f113}', + fallback_icon: Some(EXT_ICON), + sections: vec!["AlphaZQXW817".to_string()], + }, + ); + app.engine.ext_panel_items.insert( + ("git-insights".to_string(), "AlphaZQXW817".to_string()), + vec![crate::core::plugin::ExtPanelItem { + text: "AlphaItemZQXW817".to_string(), + id: "item1".to_string(), + ..Default::default() + }], + ); + // Start on a panel *other* than Explorer, so frame 1 never paints + // the explorer tree: `TuiShellApp::handle_mouse_event`'s Explorer + // `TreeController` intercept only re-checks `active_panel_is( + // PANEL_EXPLORER)`, not whether `explorer_tree_rect` is stale, so a + // rect the Explorer's own render left behind keeps claiming every + // later click landing in that same screen region — including ones + // meant for a plugin panel that has since taken over the sidebar + // body. Not this test's bug to fix, just a trap it must not fall + // into (mirrors the "not this test's bug to fix" scroll-surface trap + // `tui_editor_double_click_selects_the_word_via_shared_dispatch` + // documents above). + app.engine + .app_shell + .show_panel(&quadraui::WidgetId::new(PANEL_SETTINGS)); + + let cfg = TuiShellApp::live_shell_config(&app.engine); + let mut driver = driver_with_shell(app, cfg, 80, 24); + // Same reveal-then-click sequence as + // `driver_click_on_extension_icon_opens_the_plugin_panel`: one benign + // event lets `take_requested_panel` steer the runner onto the shadow's + // real panel before the icon click, and row 7 is this fixture's only + // extension icon (hamburger@0 .. ai@6, ext@7). + driver.dispatch(quadraui::UiEvent::WindowFocused(true)); + driver.render(); + driver.click(1.0, 7.0); + + assert!( + driver.screen_contains("AlphaItemZQXW817"), + "precondition: the section defaults to expanded, so its item \ + must already be painted; screen:\n{}", + driver.screen() + ); + + let header = driver + .find_bounds("AlphaZQXW817") + .expect("the section header should be painted"); + let header_col = header.x as u16 + 1; + // The row to *click*, mirroring `mouse.rs`'s `SidebarOwner::ExtPanel` + // arm's own formula (`sidebar_row = content_start + flat_idx`, here + // `flat_idx = 0` for the section header) rather than the row + // `find_bounds` reports the header *painted* at — the two disagree by + // one row in this fixture (menu bar hidden, so `menu_rows == 0`; no + // search input active, so `content_start == 1`), the same kind of + // paint/click-router mismatch + // `tui_settings_double_click_toggles_a_boolean_row_via_shared_dispatch` + // documents and works around for the Settings sidebar, unrelated to + // #817. + let header_click_row = 1u16; + + driver.double_click(header_col as f32, header_click_row as f32 + 0.5); + + assert!( + driver.screen_contains("AlphaItemZQXW817"), + "a genuine double-click on an ext-panel section header must not \ + toggle its expand/collapse state — `handle_ext_panel_double_click` \ + no-ops on header rows and the single-click `Return` toggle must \ + be suppressed by `is_double_click` — but the item under the \ + section disappeared, meaning the double-click was mis-read as a \ + plain click; screen:\n{}", + driver.screen() + ); + } + + /// #817 regression coverage, git/source-control sidebar site + /// (`mouse.rs`'s `SidebarOwner::Git` arm, one of the four hand-rolled + /// 400ms/position detectors this issue deleted — formerly around + /// `:2076`): a genuine double-click on a changed file's row must open it + /// via `engine.handle_sc_sidebar_ui_event(DoubleClick)` -> + /// `sc_activate_row`, exactly like double-clicking a file in the + /// Explorer. The file here is untracked (`unstaged: Untracked`), so + /// `sc_activate_row`'s `is_new` branch takes the plain-open path + /// (`open_file_with_mode`) rather than the diff-split path — no real + /// `git show`/`diff` subprocess needed, just the `git rev-parse + /// --show-toplevel` `find_repo_root` always runs (a real, fast, git repo + /// this test creates with `git init`). + /// + /// A single `TuiDriver::double_click` (quadraui#592) delivers one + /// synthetic `UiEvent::DoubleClick` with no preceding click — `mouse.rs`'s + /// `Git` arm folds that one event into both an ordinary `MouseDown` (which + /// selects the row via `route_sc_sidebar_click`) and, because + /// `is_double_click` is true, a second `UiEvent::DoubleClick` fed straight + /// to `handle_sc_sidebar_ui_event` — so one call is enough to select *and* + /// activate the row. Verified RED (by temporarily hardcoding the `Git` + /// arm's `is_double` to `false`) against the deleted hand-rolled + /// detector's behavior: a lone injected event has no `last_click_time` to + /// compare against, so the old 400ms/position timer would have read it as + /// a first, ordinary click and never opened the file. + #[test] + fn tui_sc_sidebar_double_click_on_a_changed_file_opens_it() { + let dir = std::env::temp_dir().join("vimcode_sc817_double_click"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(&dir) + .output() + .ok(); + let file_path = dir.join("sc817file.txt"); + std::fs::write(&file_path, "sc817ZQXWBODYMARKER\n").unwrap(); + + let mut app = TuiShellApp::new_for_test(); + app.engine.cwd = dir.clone(); + app.engine.sc_file_statuses = vec![crate::core::git::FileStatus { + path: "sc817file.txt".to_string(), + staged: None, + unstaged: Some(crate::core::git::StatusKind::Untracked), + }]; + app.engine + .app_shell + .show_panel(&quadraui::WidgetId::new(PANEL_GIT)); + + let mut driver = driver_with_shell(app, config(), 100, 30); + assert!( + !driver.screen_contains("sc817ZQXWBODYMARKER"), + "precondition: no file should be open yet, so its body text must \ + not be painted anywhere; screen:\n{}", + driver.screen() + ); + + let row = driver + .find_bounds("sc817file.txt") + .expect("the unstaged file row should be painted"); + + driver.double_click(row.x + 1.0, row.y + row.height / 2.0); + + assert!( + driver.screen_contains("sc817ZQXWBODYMARKER"), + "double-clicking a changed file's row in the source-control \ + sidebar must open it via `sc_activate_row` (reading `is_double`, \ + this issue's shared verdict), but the file's body text never \ + appeared in the editor pane; screen:\n{}", + driver.screen() + ); + + let _ = std::fs::remove_dir_all(&dir); + } + /// #609: `render_content` must also paint the tab-hover tooltip — /// `render_tab_hover_tooltip`, ported from `draw_frame`'s raw-`Buffer` /// write to `Backend::draw_status_bar` (see that function's doc