diff --git a/src/core/engine/keys.rs b/src/core/engine/keys.rs index 0da8aec2..4788dd4e 100644 --- a/src/core/engine/keys.rs +++ b/src/core/engine/keys.rs @@ -807,8 +807,12 @@ impl Engine { self.lsp_request_definition(); return EngineAction::None; } - "backslash" => { - // Ctrl+\: Split editor group to the right (VSCode style) + "backslash" | "\\" => { + // Ctrl+\: Split editor group to the right (VSCode style). + // TUI maps the raw '\' to "backslash"; the GTK ShellApp key + // path forwards the raw char "\\". Accept both so the split + // fires on every backend (mirrors the "bracketright" | "]" + // dual-match above). (#515) self.open_editor_group(SplitDirection::Vertical); return EngineAction::None; } diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index 927c0701..b378f00e 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -28,8 +28,7 @@ use super::tab::{Tab, TabId}; use super::terminal::{default_shell, InstallContext}; use super::view::{FoldRegion, View}; use super::window::{ - DropZone, GroupDivider, GroupId, GroupLayout, SplitDirection, Window, WindowId, WindowLayout, - WindowRect, + GroupDivider, GroupId, GroupLayout, SplitDirection, Window, WindowId, WindowLayout, WindowRect, }; use quadraui::terminal_engine::TerminalSession; use std::borrow::Cow; @@ -1489,14 +1488,6 @@ pub enum TerminalKeyAction { Ignore, } -/// State of an in-progress tab drag operation. -#[derive(Debug, Clone)] -pub struct TabDragState { - pub source_group: GroupId, - pub source_tab_index: usize, - pub tab_name: String, -} - // ── Context menu data model ────────────────────────────────────────────────── /// What the context menu was opened on. @@ -2356,13 +2347,6 @@ pub struct Engine { next_group_id: usize, next_window_id: usize, next_tab_id: usize, - /// Active tab drag-and-drop operation (set by UI on drag start). - pub tab_drag: Option, - /// Current mouse position during a tab drag (for rendering ghost/overlay). - pub tab_drag_mouse: Option<(f64, f64)>, - /// Computed drop zone for the current tab drag (updated each frame). - pub tab_drop_zone: DropZone, - // --- Preview mode --- /// The buffer currently in preview mode (at most one at a time). pub preview_buffer_id: Option, @@ -3510,9 +3494,6 @@ impl Engine { next_group_id: 1, next_window_id: 2, next_tab_id: 2, - tab_drag: None, - tab_drag_mouse: None, - tab_drop_zone: DropZone::None, preview_buffer_id: None, mode: Mode::Normal, command_buffer: String::new(), diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 9b6061ff..40d2926d 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -14349,6 +14349,23 @@ fn test_yyp_linewise_via_clipboard_intercept() { // ── Editor group tests ──────────────────────────────────────────────────── +#[test] +fn test_ctrl_backslash_splits_editor_right_both_key_names() { + // Regression (#515): the GTK ShellApp key path forwards the raw char "\\" + // while the TUI maps it to "backslash". The engine must split on both so + // Ctrl+\ works on every backend. + for name in ["backslash", "\\"] { + let mut engine = Engine::new(); + assert_eq!(engine.group_layout.leaf_count(), 1); + engine.handle_key(name, Some('\\'), true); + assert_eq!( + engine.group_layout.leaf_count(), + 2, + "Ctrl+\\ via key_name {name:?} should split the editor group to the right" + ); + } +} + #[test] fn test_editor_group_split_commands() { let mut engine = Engine::new(); @@ -15807,7 +15824,6 @@ fn test_execute_command_uri_unknown_command() { #[test] fn test_tab_drag_reorder_same_group() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); @@ -15817,12 +15833,9 @@ fn test_tab_drag_reorder_same_group() { assert_eq!(e.active_group().tabs.len(), 3); assert_eq!(e.active_group().active_tab, 2); - // Drag tab 2 (ccc) to position 0 + // Reorder tab 2 (ccc) to position 0 let gid = e.active_group; - e.tab_drag_begin(gid, 2); - assert!(e.tab_drag.is_some()); - e.tab_drag_drop(DropZone::TabReorder(gid, 0)); - assert!(e.tab_drag.is_none()); + e.reorder_tab_in_group(gid, 2, 0); // Now order should be [ccc, aaa, bbb], active tab is 0 assert_eq!(e.active_group().active_tab, 0); @@ -15837,7 +15850,6 @@ fn test_tab_drag_reorder_same_group() { #[test] fn test_tab_drag_to_other_group_center() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); @@ -15851,9 +15863,8 @@ fn test_tab_drag_to_other_group_center() { assert_ne!(group1, group2); e.buffer_mut().insert(0, "ccc\n"); - // Drag bbb (tab 1 in group1) to group2 center - e.tab_drag_begin(group1, 1); - e.tab_drag_drop(DropZone::Center(group2)); + // Move bbb (tab 1 in group1) to group2 center + e.move_tab_to_target_group(group1, 1, group2); // group1 should have 1 tab (aaa), group2 should have 2 tabs assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); @@ -15864,7 +15875,6 @@ fn test_tab_drag_to_other_group_center() { #[test] fn test_tab_drag_to_new_split() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); @@ -15872,9 +15882,8 @@ fn test_tab_drag_to_new_split() { assert_eq!(e.active_group().tabs.len(), 2); assert!(e.group_layout.is_single_group()); - // Drag tab 0 (aaa) to create a new split - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Vertical, false)); + // Move tab 0 (aaa) to create a new split + e.move_tab_to_new_split(gid, 0, gid, SplitDirection::Vertical, false); // Should now have 2 groups assert!(!e.group_layout.is_single_group()); @@ -15883,25 +15892,19 @@ fn test_tab_drag_to_new_split() { #[test] fn test_tab_drag_cancel() { + // With the new controller-based drag, "cancel" is handled by the + // controller (TabGroupController::cancel_tab_drag). Engine state is + // not mutated during a drag — cancelling is a no-op at the engine level. let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); - let gid = e.active_group; let tabs_before = e.active_group().tabs.len(); - - e.tab_drag_begin(gid, 0); - assert!(e.tab_drag.is_some()); - e.tab_drag_cancel(); - assert!(e.tab_drag.is_none()); - assert_eq!(e.tab_drag_mouse, None); - assert_eq!(e.tab_drop_zone, DropZone::None); - // No state changed + // No drag state in engine any more — just verify tabs are unchanged. assert_eq!(e.active_group().tabs.len(), tabs_before); } #[test] fn test_tab_drag_last_tab_closes_group() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); // Create second group with split e.open_editor_group(SplitDirection::Vertical); @@ -15912,9 +15915,8 @@ fn test_tab_drag_last_tab_closes_group() { let group1 = *e.editor_groups.keys().find(|g| **g != group2).unwrap(); assert_eq!(e.editor_groups.len(), 2); - // Drag the only tab from group1 to group2 - e.tab_drag_begin(group1, 0); - e.tab_drag_drop(DropZone::Center(group2)); + // Move the only tab from group1 to group2 + e.move_tab_to_target_group(group1, 0, group2); // group1 should be closed, only group2 remains assert_eq!(e.editor_groups.len(), 1); @@ -15924,16 +15926,15 @@ fn test_tab_drag_last_tab_closes_group() { #[test] fn test_tab_drag_drop_none_is_noop() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); - let gid = e.active_group; let tabs_before = e.active_group().tabs.len(); let active_before = e.active_group().active_tab; - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::None); + // DropZone::None → no-op (apply_drop_zone branch) + // Call engine underlying fn to verify nothing changes when called with same group. + e.reorder_tab_in_group(e.active_group, active_before, active_before); // Nothing changed assert_eq!(e.active_group().tabs.len(), tabs_before); @@ -15942,7 +15943,6 @@ fn test_tab_drag_drop_none_is_noop() { #[test] fn test_tab_drag_reorder_to_other_group_at_index() { - use crate::core::window::DropZone; let mut e = engine_with_text("aaa\n"); e.new_tab(None); e.buffer_mut().insert(0, "bbb\n"); @@ -15956,9 +15956,8 @@ fn test_tab_drag_reorder_to_other_group_at_index() { e.buffer_mut().insert(0, "ddd\n"); assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); - // Drag aaa (tab 0 in group1) to group2 at index 1 - e.tab_drag_begin(group1, 0); - e.tab_drag_drop(DropZone::TabReorder(group2, 1)); + // Move aaa (tab 0 in group1) to group2 at index 1 + e.move_tab_to_target_group_at(group1, 0, group2, 1); // group1: [bbb], group2: [ccc, aaa, ddd] assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); @@ -15970,6 +15969,95 @@ fn test_tab_drag_reorder_to_other_group_at_index() { assert!(e.buffer().to_string().starts_with("aaa")); } +// ── apply_tab_drop_zone: shared cross-backend drop entry point (#515) ──────── + +#[test] +fn test_apply_drop_zone_center_moves_tab() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let group1 = e.active_group; + e.open_editor_group(SplitDirection::Vertical); + let group2 = e.active_group; + assert_ne!(group1, group2); + + // Drop tab 0 of group1 into the center of group2 → merge. + e.apply_tab_drop_zone(group1, 0, DropZone::Center(group2)); + assert_eq!(e.editor_groups.get(&group1).unwrap().tabs.len(), 1); + assert_eq!(e.editor_groups.get(&group2).unwrap().tabs.len(), 2); + assert_eq!(e.active_group, group2); +} + +#[test] +fn test_apply_drop_zone_center_same_group_is_noop() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let g = e.active_group; + let before = e.editor_groups.get(&g).unwrap().tabs.len(); + // Dropping onto its own group's center must not mutate anything. + e.apply_tab_drop_zone(g, 0, DropZone::Center(g)); + assert_eq!(e.editor_groups.get(&g).unwrap().tabs.len(), before); +} + +#[test] +fn test_apply_drop_zone_split_creates_group() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let g = e.active_group; + assert_eq!(e.editor_groups.len(), 1); + + // Split tab 1 out of the only group into a new vertical split. + e.apply_tab_drop_zone(g, 1, DropZone::Split(g, SplitDirection::Vertical, false)); + assert_eq!(e.editor_groups.len(), 2); + assert!(!e.group_layout.is_single_group()); +} + +#[test] +fn test_apply_drop_zone_reorder_within_group() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let g = e.active_group; + // Group has [aaa, bbb]; reorder tab 0 → index 1. + e.apply_tab_drop_zone(g, 0, DropZone::TabReorder(g, 1)); + assert_eq!(e.active_group().active_tab, 1); +} + +#[test] +fn test_apply_drop_zone_reorder_across_groups() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + let group1 = e.active_group; + e.open_editor_group(SplitDirection::Vertical); + let group2 = e.active_group; + e.buffer_mut().insert(0, "bbb\n"); + // TabReorder targeting a *different* group routes to move_tab_to_target_group_at. + e.apply_tab_drop_zone(group1, 0, DropZone::TabReorder(group2, 0)); + // group1 had its only tab → collapses; group2 gains it. + assert_eq!(e.editor_groups.len(), 1); + assert!(e.editor_groups.contains_key(&group2)); +} + +#[test] +fn test_apply_drop_zone_none_is_noop() { + use crate::core::window::DropZone; + let mut e = engine_with_text("aaa\n"); + e.new_tab(None); + e.buffer_mut().insert(0, "bbb\n"); + let g = e.active_group; + let before = e.editor_groups.get(&g).unwrap().tabs.len(); + let active_before = e.active_group().active_tab; + e.apply_tab_drop_zone(g, 0, DropZone::None); + assert_eq!(e.editor_groups.get(&g).unwrap().tabs.len(), before); + assert_eq!(e.active_group().active_tab, active_before); +} + #[test] fn test_has_code_actions_on_line_empty() { let e = engine_with_text("hello\nworld\n"); diff --git a/src/core/engine/windows.rs b/src/core/engine/windows.rs index 5726c610..fb5133c2 100644 --- a/src/core/engine/windows.rs +++ b/src/core/engine/windows.rs @@ -2041,72 +2041,6 @@ impl Engine { // --- Tab drag-and-drop --- /// Begin dragging a tab from the given group. - pub fn tab_drag_begin(&mut self, group_id: GroupId, tab_index: usize) { - let name = self - .editor_groups - .get(&group_id) - .and_then(|g| g.tabs.get(tab_index)) - .and_then(|t| self.windows.get(&t.active_window)) - .and_then(|w| self.buffer_manager.get(w.buffer_id)) - .map(|s| s.display_name()) - .unwrap_or_default(); - self.tab_drag = Some(TabDragState { - source_group: group_id, - source_tab_index: tab_index, - tab_name: name, - }); - self.tab_drop_zone = DropZone::None; - } - - /// Cancel an in-progress tab drag. - #[allow(dead_code)] - pub fn tab_drag_cancel(&mut self) { - self.tab_drag = None; - self.tab_drag_mouse = None; - self.tab_drop_zone = DropZone::None; - } - - /// Execute the drop for the current tab drag. - pub fn tab_drag_drop(&mut self, zone: DropZone) { - let drag = match self.tab_drag.take() { - Some(d) => d, - None => return, - }; - self.tab_drag_mouse = None; - self.tab_drop_zone = DropZone::None; - - match zone { - DropZone::Center(target) => { - if target != drag.source_group { - self.move_tab_to_target_group(drag.source_group, drag.source_tab_index, target); - } - } - DropZone::Split(target, direction, new_first) => { - self.move_tab_to_new_split( - drag.source_group, - drag.source_tab_index, - target, - direction, - new_first, - ); - } - DropZone::TabReorder(group_id, to_idx) => { - if group_id == drag.source_group { - self.reorder_tab_in_group(group_id, drag.source_tab_index, to_idx); - } else { - // Drag to a specific position in another group - self.move_tab_to_target_group_at( - drag.source_group, - drag.source_tab_index, - group_id, - to_idx, - ); - } - } - DropZone::None => {} - } - } - /// Move a tab from one group to another (appends at end). pub fn move_tab_to_target_group( &mut self, @@ -2118,7 +2052,7 @@ impl Engine { } /// Move a tab from one group to another at a specific insertion index. - pub(crate) fn move_tab_to_target_group_at( + pub fn move_tab_to_target_group_at( &mut self, src_group: GroupId, tab_idx: usize, @@ -2156,7 +2090,7 @@ impl Engine { } /// Move a tab out of its group into a new split adjacent to `target_group`. - pub(crate) fn move_tab_to_new_split( + pub fn move_tab_to_new_split( &mut self, src_group: GroupId, tab_idx: usize, @@ -2192,6 +2126,47 @@ impl Engine { } } + /// Apply a resolved tab-drag [`DropZone`] to the engine. + /// + /// This is the single, backend-agnostic entry point for committing a tab + /// drag-and-drop. Both the GTK and TUI backends resolve the drop zone with + /// `quadraui::compute_drop_zone` (via `render::compute_tab_drop_zone`) and + /// then call this method, so the mutation semantics live in exactly one + /// place. `source_gid` / `source_tab_idx` identify the dragged tab, captured + /// when the drag started. + pub fn apply_tab_drop_zone( + &mut self, + source_gid: GroupId, + source_tab_idx: usize, + zone: crate::core::window::DropZone, + ) { + use crate::core::window::DropZone; + match zone { + DropZone::Center(target) => { + if target != source_gid { + self.move_tab_to_target_group(source_gid, source_tab_idx, target); + } + } + DropZone::Split(target, direction, new_first) => { + self.move_tab_to_new_split( + source_gid, + source_tab_idx, + target, + direction, + new_first, + ); + } + DropZone::TabReorder(group_id, to_idx) => { + if group_id == source_gid { + self.reorder_tab_in_group(group_id, source_tab_idx, to_idx); + } else { + self.move_tab_to_target_group_at(source_gid, source_tab_idx, group_id, to_idx); + } + } + DropZone::None => {} + } + } + /// Reorder a tab within its group. pub fn reorder_tab_in_group(&mut self, group_id: GroupId, from_idx: usize, to_idx: usize) { if let Some(g) = self.editor_groups.get_mut(&group_id) { diff --git a/src/gtk/click.rs b/src/gtk/click.rs index 6eb88cc0..c8648dd5 100644 --- a/src/gtk/click.rs +++ b/src/gtk/click.rs @@ -26,38 +26,46 @@ pub(super) fn pixel_to_click_target( char_width: f64, pango_layout: &pango::Layout, cached_layout: &render::ScreenLayout, - tab_slot_positions: &TabSlotMap, - diff_btn_map: &DiffBtnMap, - split_btn_map: &SplitBtnMap, - action_btn_map: &ActionBtnMap, + // Pixel-accurate per-group tab-bar hit geometry captured from the + // rasteriser during `render_content` (via `Backend::tab_bar_layout`). GTK + // draws tabs with proportional-font Pango widths, so the char-cell + // `hit_regions` on `cached_layout` do NOT match the drawn geometry — clicks + // must resolve against these actual pixel bounds. (#515) + tab_pixel_hits: &TabPixelHitMap, + // Legacy per-backend pixel maps — no longer consulted for tab-bar clicks. + // Kept in the signature so existing call sites compile unchanged; slated for + // removal along with the rest of the pixel-map plumbing. (#515) + _tab_slot_positions: &TabSlotMap, + _diff_btn_map: &DiffBtnMap, + _split_btn_map: &SplitBtnMap, + _action_btn_map: &ActionBtnMap, status_segment_map: &StatusSegmentMap, ) -> ClickTarget { let tab_bar_height = render_mod::tab_bar_height_px(line_height, engine.settings.breadcrumbs); let single_tab_hidden = engine.is_tab_bar_hidden(engine.active_group); - match render_mod::screen_zone_hit_test( + let zone = render_mod::screen_zone_hit_test( cached_layout, x, y, tab_bar_height, single_tab_hidden, engine.active_group, - ) { + ); + match zone { ScreenZone::TabBar { group_id, local_x, - bar_width, + bar_width: _, } => { engine.active_group = group_id; tab_bar_inner_hit_test( engine, group_id, local_x, - bar_width, - tab_slot_positions, - diff_btn_map, - split_btn_map, - action_btn_map, + char_width, + cached_layout, + tab_pixel_hits, ) } ScreenZone::Window { @@ -117,87 +125,120 @@ pub(super) fn pixel_to_click_target( } } -/// Tab bar inner hit-test using cached Pango-measured pixel positions. -#[allow(clippy::too_many_arguments)] +/// Tab bar inner hit-test. +/// +/// `local_x` is pixels relative to the tab bar's left edge. For GTK we resolve +/// against the pixel-accurate geometry the rasteriser actually drew this frame +/// (`tab_pixel_hits`, captured in `render_content` via `Backend::tab_bar_layout`). +/// GTK tabs are laid out with proportional-font Pango widths + fixed pixel +/// padding, so the char-cell `hit_regions` (correct for the monospace TUI) badly +/// mis-measure them — clicks in a tab's middle landed on the close button and +/// clicks near its right edge landed on the next tab (#515 regression). Falls +/// back to the char-cell path only if no pixel geometry was cached (e.g. a click +/// arriving before the first paint populated the map). fn tab_bar_inner_hit_test( engine: &mut Engine, group_id: GroupId, local_x: f64, - bar_width: f64, - tab_slot_positions: &TabSlotMap, - diff_btn_map: &DiffBtnMap, - split_btn_map: &SplitBtnMap, - action_btn_map: &ActionBtnMap, + char_width: f64, + cached_layout: &render::ScreenLayout, + tab_pixel_hits: &TabPixelHitMap, ) -> ClickTarget { - // Diff toolbar buttons (left of split buttons). - if let Some(&(prev_start, prev_end, next_start, next_end, fold_start, fold_end)) = - diff_btn_map.get(&group_id.0) - { - if local_x >= prev_start && local_x < prev_end { - return ClickTarget::DiffToolbarPrev; - } else if local_x >= next_start && local_x < next_end { - return ClickTarget::DiffToolbarNext; - } else if local_x >= fold_start && local_x < fold_end { - return ClickTarget::DiffToolbarToggleFold; - } - } + let target = tab_pixel_hits + .get(&group_id.0) + .and_then(|ph| resolve_pixel_tab_click(ph, local_x)) + .or_else(|| resolve_charcell_tab_click(cached_layout, group_id, local_x, char_width)); - // Split buttons (left of action menu button). - if let Some(&(both_btns_px, btn_right_px)) = split_btn_map.get(&group_id.0) { - let action_offset = action_btn_map - .get(&group_id.0) - .map(|&(start, end)| end - start) - .unwrap_or(0.0); - let btn_down_px = both_btns_px - btn_right_px; - if local_x >= bar_width - btn_down_px - action_offset && local_x < bar_width - action_offset - { - return ClickTarget::SplitButton( - group_id, - crate::core::window::SplitDirection::Horizontal, - ); + dispatch_tab_bar_target(engine, group_id, target) +} + +/// Resolve a tab-bar click against the pixel-accurate drawn geometry. +/// +/// Close buttons are checked before tab bodies (a close zone is a sub-region of +/// its tab), then tab bodies, then the disjoint right-segment buttons. +fn resolve_pixel_tab_click( + ph: &TabBarPixelHits, + local_x: f64, +) -> Option { + use crate::core::engine::TabBarClickTarget as T; + + let in_range = |(a, b): (f64, f64)| a != b && local_x >= a && local_x < b; + + for (idx, cb) in ph.close.iter().enumerate() { + if let Some(&bounds) = cb.as_ref() { + if in_range(bounds) { + return Some(T::CloseTab(idx)); + } } - if local_x >= bar_width - both_btns_px - action_offset - && local_x < bar_width - btn_down_px - action_offset - { - return ClickTarget::SplitButton( - group_id, - crate::core::window::SplitDirection::Vertical, - ); + } + for (idx, &slot) in ph.slots.iter().enumerate() { + if in_range(slot) { + return Some(T::Tab(idx)); } } - - // Action menu button ("…") at far right. - if let Some(&(start_x, end_x)) = action_btn_map.get(&group_id.0) { - if local_x >= start_x && local_x < end_x { - return ClickTarget::ActionMenuButton(group_id); + for &(start, end, target) in &ph.segments { + if in_range((start, end)) { + return Some(target); } } + None +} - // Tab slots (Pango-measured positions from draw_tab_bar). - let hit = tab_slot_positions - .get(&group_id.0) - .and_then(|slots: &Vec<(f64, f64)>| { - for (i, &(slot_start, slot_end)) in slots.iter().enumerate() { - if local_x >= slot_start && local_x < slot_end { - let close_zone = (slot_end - slot_start) * 0.2; - let is_close = local_x >= slot_end - close_zone; - return Some((i, is_close)); - } - } - None - }); - if let Some((tab_idx, is_close)) = hit { - if is_close { +/// Char-cell fallback (matches the TUI monospace layout). Only used before the +/// first paint has populated the pixel-hit cache. +fn resolve_charcell_tab_click( + cached_layout: &render::ScreenLayout, + group_id: GroupId, + local_x: f64, + char_width: f64, +) -> Option { + let col = (local_x / char_width).floor().max(0.0) as u16; + let regions: &[( + crate::core::engine::TabBarHitRegion, + crate::core::engine::TabBarClickTarget, + )] = if let Some(ref split) = cached_layout.editor_group_split { + split + .group_tab_bars + .iter() + .find(|g| g.group_id == group_id) + .map(|g| g.hit_regions.as_slice()) + .unwrap_or(&[]) + } else { + cached_layout.tab_bar_hit_regions.as_slice() + }; + render_mod::resolve_tab_bar_click(regions, col) +} + +/// Apply the engine-side effect for a resolved tab-bar click target and return +/// the `ClickTarget` the caller dispatches. +fn dispatch_tab_bar_target( + engine: &mut Engine, + group_id: GroupId, + target: Option, +) -> ClickTarget { + use crate::core::engine::TabBarClickTarget as T; + use crate::core::window::SplitDirection; + + match target { + Some(T::Tab(idx)) => { + engine.goto_tab(idx); + ClickTarget::TabBar + } + Some(T::CloseTab(idx)) => { if let Some(g) = engine.editor_groups.get_mut(&group_id) { - g.active_tab = tab_idx; + g.active_tab = idx; } engine.line_annotations.clear(); - return ClickTarget::CloseTab(group_id, tab_idx); + ClickTarget::CloseTab(group_id, idx) } - engine.goto_tab(tab_idx); - return ClickTarget::TabBar; + Some(T::SplitRight) => ClickTarget::SplitButton(group_id, SplitDirection::Vertical), + Some(T::SplitDown) => ClickTarget::SplitButton(group_id, SplitDirection::Horizontal), + Some(T::ActionMenu) => ClickTarget::ActionMenuButton(group_id), + Some(T::DiffPrev) => ClickTarget::DiffToolbarPrev, + Some(T::DiffNext) => ClickTarget::DiffToolbarNext, + Some(T::DiffToggle) => ClickTarget::DiffToolbarToggleFold, + None => ClickTarget::TabBar, } - ClickTarget::TabBar } /// Execute the engine-side action for a gutter click using shared resolution. @@ -255,6 +296,7 @@ pub(super) fn handle_mouse_click( char_width: f64, pango_layout: &pango::Layout, cached_layout: &render::ScreenLayout, + tab_pixel_hits: &TabPixelHitMap, tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, @@ -269,6 +311,7 @@ pub(super) fn handle_mouse_click( char_width, pango_layout, cached_layout, + tab_pixel_hits, tab_slot_positions, diff_btn_map, split_btn_map, @@ -336,76 +379,11 @@ pub(super) fn handle_mouse_click( } } -type TabSlotsMap = std::collections::HashMap>; - -/// Build tab slot positions (absolute pixel coords) for each group. -pub(super) fn build_gtk_tab_slots( - engine: &Engine, - width: f64, - height: f64, - line_height: f64, - tab_slot_positions: &TabSlotMap, -) -> (Vec, f32, TabSlotsMap) { - use crate::core::window::WindowRect; - - let tbh = render_mod::tab_bar_height_px(line_height, engine.settings.breadcrumbs); - let editor_bottom = gtk_editor_bottom(engine, width, height, line_height); - let content_bounds = WindowRect::new(0.0, 0.0, width, editor_bottom); - let mut group_rects = engine - .group_layout - .calculate_group_rects(content_bounds, tbh); - engine.adjust_group_rects_for_hidden_tabs(&mut group_rects, tbh); - - let bounds: Vec = group_rects - .iter() - .map(|(gid, grect)| { - let scroll_off = engine - .editor_groups - .get(gid) - .map(|g| g.tab_scroll_offset) - .unwrap_or(0); - render_mod::DropGroupBounds { - group_id: *gid, - x: grect.x as f32, - y: grect.y as f32, - width: grect.width as f32, - content_height: grect.height as f32, - tab_scroll_offset: scroll_off, - } - }) - .collect(); - - let mut slots_map = std::collections::HashMap::new(); - for gb in &bounds { - if let Some(slots) = tab_slot_positions.get(&gb.group_id.0) { - let abs_slots: Vec<(f32, f32)> = slots - .iter() - .map(|&(s, e)| (gb.x + s as f32, gb.x + e as f32)) - .collect(); - slots_map.insert(gb.group_id.0, abs_slots); - } - } - - (bounds, tbh as f32, slots_map) -} - -/// Compute the drop zone for a tab drag based on cursor position. -#[allow(clippy::too_many_arguments)] -pub(super) fn compute_tab_drop_zone( - engine: &Engine, - x: f64, - y: f64, - width: f64, - height: f64, - line_height: f64, - _char_width: f64, - tab_slot_positions: &TabSlotMap, -) -> crate::core::window::DropZone { - let (bounds, tbh, slots_map) = - build_gtk_tab_slots(engine, width, height, line_height, tab_slot_positions); - let (groups, eff_tbh) = render_mod::build_tab_drop_groups(&bounds, engine, tbh, &slots_map); - render_mod::compute_tab_drop_zone(x as f32, y as f32, &groups, eff_tbh) -} +// Tab-drag drop-zone geometry is now computed in `App::render_content` from the +// shared `render::screen_to_drop_group_bounds` pipeline and cached on the App for +// the drag hit-test to reuse — see `cached_drop_groups`. The former GTK-specific +// `build_gtk_tab_slots` / `compute_tab_drop_zone` helpers (which depended on the +// legacy per-backend pixel maps) were removed in #515. /// Handle mouse double-click — select word at position. #[allow(clippy::too_many_arguments)] @@ -417,6 +395,7 @@ pub(super) fn handle_mouse_double_click( char_width: f64, pango_layout: &pango::Layout, cached_layout: &render::ScreenLayout, + tab_pixel_hits: &TabPixelHitMap, tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, @@ -431,6 +410,7 @@ pub(super) fn handle_mouse_double_click( char_width, pango_layout, cached_layout, + tab_pixel_hits, tab_slot_positions, diff_btn_map, split_btn_map, @@ -451,6 +431,7 @@ pub(super) fn handle_mouse_drag( char_width: f64, pango_layout: &pango::Layout, cached_layout: &render::ScreenLayout, + tab_pixel_hits: &TabPixelHitMap, tab_slot_positions: &TabSlotMap, diff_btn_map: &DiffBtnMap, split_btn_map: &SplitBtnMap, @@ -465,6 +446,7 @@ pub(super) fn handle_mouse_drag( char_width, pango_layout, cached_layout, + tab_pixel_hits, tab_slot_positions, diff_btn_map, split_btn_map, diff --git a/src/gtk/draw.rs b/src/gtk/draw.rs index 320847de..a25073b3 100644 --- a/src/gtk/draw.rs +++ b/src/gtk/draw.rs @@ -352,20 +352,9 @@ pub(super) fn draw_editor( *bc.draw_layout.borrow_mut() = Some(bar_layout); } - // 5. Draw tab drag overlay (drop zone highlight + ghost label). - if engine.tab_drag.is_some() { - draw_tab_drag_overlay( - cr, - engine, - &theme, - width as f64, - height as f64, - line_height, - char_width, - &layout, - &tab_slot_positions_out.borrow(), - ); - } + // 5. Tab drag overlay is handled by the ShellApp path via + // TabGroupController::render (which draws the overlay internally). + // The legacy Relm4 draw path no longer supports drag-and-drop overlays. // 5b. Draw completion popup (on top of everything else). Cache // the layout so the click handler can hit-test items. @@ -1039,87 +1028,8 @@ pub(super) fn draw_h_scrollbars( } } -/// Draw the tab drag overlay: a semi-transparent highlight over the drop zone -/// and a ghost label near the cursor. -#[allow(clippy::too_many_arguments)] -pub(super) fn draw_tab_drag_overlay( - cr: &Context, - engine: &Engine, - theme: &Theme, - width: f64, - height: f64, - line_height: f64, - _char_width: f64, - pango_layout: &pango::Layout, - tab_slot_positions: &TabSlotMap, -) { - let (bounds, tbh, slots_map) = - super::click::build_gtk_tab_slots(engine, width, height, line_height, tab_slot_positions); - let (groups, eff_tbh) = render::build_tab_drop_groups(&bounds, engine, tbh, &slots_map); - let tbh = eff_tbh; - let cursor = engine - .tab_drag_mouse - .map(|(mx, my)| (mx as f32, my as f32)) - .unwrap_or((0.0, 0.0)); - let overlay = match render::compute_tab_drop_overlay( - &engine.tab_drop_zone, - &groups, - cursor, - tbh, - 2.0, - 12.0, - ) { - Some(o) => o, - None => return, - }; - - if let Some(h) = overlay.highlight { - let (cr_r, cr_g, cr_b) = theme.cursor.to_cairo(); - cr.set_source_rgba(cr_r, cr_g, cr_b, 0.15); - cr.rectangle(h.x as f64, h.y as f64, h.width as f64, h.height as f64); - cr.fill().ok(); - cr.set_source_rgba(cr_r, cr_g, cr_b, 0.5); - cr.set_line_width(2.0); - cr.rectangle(h.x as f64, h.y as f64, h.width as f64, h.height as f64); - cr.stroke().ok(); - } - - if let Some(bar) = overlay.insertion_bar { - let (cr_r, cr_g, cr_b) = theme.cursor.to_cairo(); - cr.set_source_rgb(cr_r, cr_g, cr_b); - cr.rectangle( - bar.x as f64, - bar.y as f64, - bar.width as f64, - bar.height as f64, - ); - cr.fill().ok(); - } - - if let (Some((mx, my)), Some(ref drag)) = (engine.tab_drag_mouse, &engine.tab_drag) { - let label = &drag.tab_name; - if !label.is_empty() { - pango_layout.set_text(label); - let (tw, th) = pango_layout.pixel_size(); - let gx = mx + 12.0; - let gy = my - th as f64 / 2.0; - let pad = 4.0; - let (gbr, gbg, gbb) = theme.background.to_cairo(); - cr.set_source_rgba(gbr, gbg, gbb, 0.85); - cr.rectangle( - gx - pad, - gy - pad, - tw as f64 + pad * 2.0, - th as f64 + pad * 2.0, - ); - cr.fill().ok(); - let (gfr, gfg, gfb) = theme.foreground.to_cairo(); - cr.set_source_rgba(gfr, gfg, gfb, 0.9); - cr.move_to(gx, gy); - pangocairo::show_layout(cr, pango_layout); - } - } -} +// draw_tab_drag_overlay removed — drag overlay is now handled directly in +// ShellApp::render_content via render::compute_tab_drop_overlay + backend.draw_drop_overlay. /// GTK tab bar renders via `Backend::draw_tab_bar`. /// diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 52fb4b45..07bfeaa8 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -54,6 +54,169 @@ fn ext_panel_name(id: &str) -> Option<&str> { type TabSlotMap = HashMap>; type TabCloseMap = HashMap>>; +/// Absolute per-group close-glyph hit rects captured during `render_content`. +/// Keyed by `group_id.0` → `(bar_y_top, bar_y_bottom, per-tab Some((x0, x1)))`. +/// All coordinates are in **absolute surface pixels** (same space as the raw +/// mouse position), so hover hit-testing needs no geometry re-derivation. The +/// x-ranges are the *tight* close-glyph zone (see [`CLOSE_*` metrics] and +/// [`tighten_close_bounds`]), matching the × highlight the rasteriser draws — +/// so a hover shows the exact box that a click would close. (#515) +type TabCloseAbsMap = HashMap>)>; + +// ── GTK editor-group tab-bar close-glyph metrics ───────────────────────────── +// The quadraui GTK rasteriser lays each non-compact tab out as +// `tab_pad | label | tab_inner_gap | × | tab_pad | tab_outer_gap` and reports a +// *padded* close-button hit zone spanning `[label_end, tab_right_edge]`. That +// zone is far wider than the drawn × glyph, so a click well before the glyph +// used to close the tab with no warning (#515). We trim the padded zone back to +// the glyph the rasteriser actually painted — plus the same 2px hover halo it +// draws behind the ×, so the clickable box equals the highlighted box. +// +// These mirror the non-compact constants in quadraui's `gtk::backend` +// (`tab_pad = 14`, `tab_inner_gap = 10`, `tab_outer_gap = 1`) and the 2px hover +// pad in `gtk::tab_bar`. Editor-group bars are always built with +// `compact: false` (see `render::build_tab_bar_primitive`). This duplication is +// the interim until quadraui exposes the tight glyph rect directly +// (quadraui#395 tracks the API gap); `tighten_close_bounds` is the single place +// it lives. +const CLOSE_TAB_INNER_GAP: f64 = 10.0; +const CLOSE_TAB_PAD: f64 = 14.0; +const CLOSE_TAB_OUTER_GAP: f64 = 1.0; +const CLOSE_HOVER_PAD: f64 = 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 +/// `tab_inner_gap` and trailing `tab_pad + tab_outer_gap` are dead padding that +/// should select the tab, not close it. Returns `None` if the padded zone is +/// degenerate (too small to contain a glyph). (#515) +fn tighten_close_bounds(start: f64, end: f64) -> Option<(f64, f64)> { + let tight_start = start + CLOSE_TAB_INNER_GAP - CLOSE_HOVER_PAD; + let tight_end = end - CLOSE_TAB_PAD - CLOSE_TAB_OUTER_GAP + CLOSE_HOVER_PAD; + if tight_end > tight_start { + Some((tight_start, tight_end)) + } else { + None + } +} + +/// Per-group pixel-accurate tab-bar hit geometry recovered from +/// [`quadraui::Backend::tab_bar_layout`] during the ShellApp `render_content` +/// pass. All x-ranges are **relative to the group's tab-bar left edge** — the +/// same space as `render::screen_zone_hit_test`'s `local_x`. +/// +/// This replaces the char-cell `hit_regions` approximation for GTK tab clicks. +/// GTK draws tabs with proportional-font Pango widths + fixed pixel padding +/// (`tab_pad`, `inner_gap`, close-glyph width), so a `name.chars() * char_width` +/// estimate under-measures every tab — shifting the tab/close boundaries and +/// making mid-tab clicks land on the close button and right-edge clicks land on +/// the next tab (#515 regression). The rasteriser reports the exact drawn +/// geometry, so we hit-test against that. (`hit_regions` stays authoritative for +/// the monospace TUI backend, whose char-cell layout matches its draw.) +#[derive(Default, Clone)] +pub(super) struct TabBarPixelHits { + /// `(start_x, end_x)` per tab index; `(0.0, 0.0)` for scrolled-off tabs. + pub slots: Vec<(f64, f64)>, + /// `Some((start_x, end_x))` close-button zone per tab, or `None`. + pub close: Vec>, + /// Right-segment hit zones (split / diff / action buttons) as + /// `(start_x, end_x, target)`, disjoint from the tab slots. + pub segments: Vec<(f64, f64, crate::core::engine::TabBarClickTarget)>, +} + +/// Key = `group_id.0` (single-group mode keys under the active group's id, which +/// is what `screen_zone_hit_test` reports for it). +type TabPixelHitMap = HashMap; + +/// Convert a rasteriser [`quadraui::TabBarHits`] (absolute pixel x, from +/// `Backend::tab_bar_layout`) plus its source [`quadraui::TabBar`] into a +/// [`TabBarPixelHits`] with every x-range shifted to be **relative to +/// `bar_left_x`** (the group tab bar's left edge). Right-segment ids are mapped +/// to their `TabBarClickTarget` using the same `"tab:*"` ids that +/// `build_tab_bar_primitive` emits (mirrors `draw::draw_tab_bar`). +fn tab_hits_to_pixel_hits( + hits: &quadraui::TabBarHits, + bar: &quadraui::TabBar, + bar_left_x: f64, +) -> TabBarPixelHits { + use crate::core::engine::TabBarClickTarget as T; + let rel = |a: f64, b: f64| (a - bar_left_x, b - bar_left_x); + let slots = hits + .slot_positions + .iter() + .map(|&(a, b)| { + if (a, b) == (0.0, 0.0) { + (0.0, 0.0) // scrolled-off sentinel — leave as zero-width + } else { + rel(a, b) + } + }) + .collect(); + // Trim the padded close zone the rasteriser reports down to the tight × + // glyph box (relative to the bar's left edge), so clicks/hover only fire on + // the drawn glyph — not the ~25px of surrounding tab padding. (#515) + let close = hits + .close_bounds + .iter() + .map(|c| c.and_then(|(a, b)| tighten_close_bounds(a, b).map(|(ta, tb)| rel(ta, tb)))) + .collect(); + let mut segments = Vec::new(); + for (i, seg) in bar.right_segments.iter().enumerate() { + let Some((a, b)) = hits.right_segment_bounds.get(i).copied() else { + continue; + }; + let Some(ref id) = seg.id else { continue }; + let target = match id.as_str() { + "tab:split_right" => Some(T::SplitRight), + "tab:split_down" => Some(T::SplitDown), + "tab:diff_prev" => Some(T::DiffPrev), + "tab:diff_next" => Some(T::DiffNext), + "tab:diff_toggle" => Some(T::DiffToggle), + "tab:action_menu" => Some(T::ActionMenu), + _ => None, + }; + if let Some(t) = target { + let (s, e) = rel(a, b); + segments.push((s, e, t)); + } + } + TabBarPixelHits { + slots, + close, + segments, + } +} + +/// Build the absolute close-glyph hit record for one tab bar from its +/// bar-relative (already-tightened) close bounds. `bar_left_x` is the bar's +/// absolute left edge; `y_top`/`y_bot` bracket the tab row. Consumed by +/// `tab_close_hit_test` for hover. (#515) +fn abs_close_record( + ph_close: &[Option<(f64, f64)>], + bar_left_x: f64, + y_top: f64, + y_bot: f64, +) -> (f64, f64, Vec>) { + let xs = ph_close + .iter() + .map(|c| c.map(|(a, b)| (a + bar_left_x, b + bar_left_x))) + .collect(); + (y_top, y_bot, xs) +} + +/// Collect the visible tab slots (absolute x-ranges) from a `TabBarHits`, +/// dropping the `(0.0, 0.0)` sentinels for scrolled-off / non-fitting tabs. +/// The result is a contiguous run starting at the tab bar's `scroll_offset`, +/// which the drop-zone reorder logic offsets back to absolute tab indices. +/// (#515) +fn abs_visible_slots(hits: &quadraui::TabBarHits) -> Vec<(f32, f32)> { + hits.slot_positions + .iter() + .filter(|&&(a, b)| (a, b) != (0.0, 0.0)) + .map(|&(a, b)| (a as f32, b as f32)) + .collect() +} + /// Cached diff toolbar button positions per group: group_id -> (prev_start, prev_end, next_start, next_end, fold_start, fold_end). /// Populated during draw_tab_bar, used for click hit-testing. type DiffBtnMap = HashMap; @@ -387,9 +550,22 @@ struct App { /// Cached tab slot widths per group, populated during draw_tab_bar for click hit-testing. /// Key = group_id.0 (or usize::MAX for single-group mode), Value = cumulative x positions. tab_slot_positions: Rc>, - /// Cached close-button bounds per tab per group, populated during - /// draw_tab_bar. Used by `tab_close_hit_test` for hover detection. - tab_close_bounds: Rc>, + /// Absolute tight close-glyph rects captured in `render_content`. Consumed + /// by `tab_close_hit_test` (hover) so it hit-tests against the exact drawn + /// geometry — including the activity-bar/sidebar x-offset — instead of + /// re-deriving group rects from a `(0,0)` content origin (which ignored the + /// offset and made hover never fire in ShellApp mode). (#515) + cached_tab_close_abs: Rc>, + /// Absolute visible tab-slot x-ranges per group (`group_id.0` → `[(x0,x1)]`), + /// captured in `render_content`. Feeds the tab drop-zone computation so a + /// short drag inside a group's own tab bar resolves to a `TabReorder` (with + /// an insertion bar) rather than a new-split overlay. (#515) + cached_tab_slots_abs: Rc>>>, + /// Pixel-accurate per-group tab-bar hit geometry from the ShellApp + /// `render_content` pass (via `Backend::tab_bar_layout`). Consumed by the + /// GTK tab-bar click hit-test instead of the char-cell `hit_regions`, which + /// don't match GTK's proportional-font tab layout. (#515) + cached_tab_pixel_hits: Rc>, /// Cached diff toolbar button pixel positions, populated during draw_tab_bar. diff_btn_map: Rc>, split_btn_map: Rc>, @@ -399,6 +575,20 @@ struct App { /// Cached ScreenLayout from the last draw_editor paint pass. Click handlers /// read this instead of recomputing geometry from engine state (#344). cached_screen_layout: Rc>>, + /// 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 + /// detection and the highlight always use one identical bounds source. (#515) + cached_drop_groups: Rc>>, + /// Effective tab-bar height (px) paired with `cached_drop_groups`. + cached_drop_tbh: Rc>, + /// Backend (line_height, char_width) captured at the instant the file + /// explorer tree was rendered. The backend's `current_line_height` is mutable + /// per-frame state and may differ by click time, which made the explorer + /// hit-test resolve the wrong row (it ran `tree_layout` at a different line + /// height than `draw_tree` used). Re-applied before hit-testing so draw and + /// hit agree. (#540 ShellApp port) + cached_explorer_metrics: Rc>, /// Pixel y-offset where the debug toolbar was last drawn. debug_toolbar_y_offset: Rc>, /// Pixel height of the debug toolbar (last draw). @@ -418,6 +608,10 @@ struct App { tab_dragging: bool, /// Start position of a potential tab drag (set on MouseClick in tab bar). tab_drag_start: Option<(f64, f64)>, + /// Source of the active tab drag: (group_id, tab_index). Set when drag starts. + tab_drag_source: Option<(core::window::GroupId, usize)>, + /// Most recently computed drop zone during an active tab drag. + tab_drag_drop_zone: core::window::DropZone, /// GTK window handle — set in `ShellApp::setup` once the runner creates the window. window: Option, /// Last time sc_refresh() was called for the Git sidebar auto-refresh. @@ -1172,12 +1366,17 @@ impl App { h_sb_hovered: false, tab_close_hover: None, tab_slot_positions: Rc::new(RefCell::new(HashMap::new())), - tab_close_bounds: Rc::new(RefCell::new(HashMap::new())), + cached_tab_close_abs: Rc::new(RefCell::new(HashMap::new())), + cached_tab_slots_abs: Rc::new(RefCell::new(HashMap::new())), + cached_tab_pixel_hits: Rc::new(RefCell::new(HashMap::new())), diff_btn_map: Rc::new(RefCell::new(HashMap::new())), split_btn_map: Rc::new(RefCell::new(HashMap::new())), action_btn_map: Rc::new(RefCell::new(HashMap::new())), status_segment_map: Rc::new(RefCell::new(HashMap::new())), cached_screen_layout: Rc::new(RefCell::new(None)), + cached_drop_groups: Rc::new(RefCell::new(Vec::new())), + cached_drop_tbh: Rc::new(Cell::new(0.0)), + cached_explorer_metrics: Rc::new(Cell::new((16.0, 8.0))), debug_toolbar_y_offset: Rc::new(Cell::new(0.0)), debug_toolbar_height: Rc::new(Cell::new(0.0)), terminal_resize_dragging: false, @@ -1185,6 +1384,8 @@ impl App { group_divider_dragging: None, tab_dragging: false, tab_drag_start: None, + tab_drag_source: None, + tab_drag_drop_zone: core::window::DropZone::None, window: None, last_sc_refresh: std::time::Instant::now(), last_tree_indicator_update: std::time::Instant::now(), @@ -1319,6 +1520,7 @@ impl App { self.cached_char_width, &editor_pl, layout, + &self.cached_tab_pixel_hits.borrow(), &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), @@ -1385,6 +1587,7 @@ impl App { self.cached_char_width, &editor_pl, layout, + &self.cached_tab_pixel_hits.borrow(), &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), @@ -1571,6 +1774,7 @@ impl App { // small String) and runtime toggles (`:set nonerdfonts`, // `:set guifont=…`) propagate without a restart. { + use quadraui::Backend; let e = self.engine.borrow(); let mut b = self.backend.borrow_mut(); b.set_nerd_fonts(e.settings.use_nerd_fonts); @@ -2693,13 +2897,13 @@ impl App { // Tab close button hover detection + tab tooltip. let engine = self.engine.borrow(); - let close_bounds_map = self.tab_close_bounds.borrow(); + let close_abs_map = self.cached_tab_close_abs.borrow(); let tab_hover = if mx >= 0.0 && lh > 0.0 { - tab_close_hit_test(&engine, &close_bounds_map, mx, my, da_w, da_h, lh) + tab_close_hit_test(&close_abs_map, mx, my) } else { None }; - drop(close_bounds_map); + drop(close_abs_map); let tooltip = if mx >= 0.0 && lh > 0.0 { tab_tooltip_hit_test(&engine, mx, my, da_w, da_h, lh, cw) } else { @@ -4137,6 +4341,7 @@ impl App { self.cached_char_width, &editor_pl, layout, + &self.cached_tab_pixel_hits.borrow(), &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), @@ -4396,20 +4601,20 @@ impl App { } // Tab drag-and-drop handling. if self.tab_dragging { - // Update drop zone while dragging. - let mut engine = self.engine.borrow_mut(); - engine.tab_drag_mouse = Some((x, y)); - let zone = compute_tab_drop_zone( - &engine, - x, - y, - width, - height, - self.cached_line_height, - self.cached_char_width, - &self.tab_slot_positions.borrow(), + // Update drop zone while dragging, using the per-group bounds cached by + // render_content. Cursor (x, y) and those bounds are both in absolute + // surface coordinates, so the hit-test matches what the overlay draws. + // (#515 — previously used relative 0-based bounds vs an absolute cursor, + // which misclassified the zone after a split.) + let groups = self.cached_drop_groups.borrow(); + let zone = render::compute_tab_drop_zone( + x as f32, + y as f32, + &groups, + self.cached_drop_tbh.get(), ); - engine.tab_drop_zone = zone; + drop(groups); + self.tab_drag_drop_zone = zone; self.draw_needed.set(true); return; } @@ -4433,6 +4638,7 @@ impl App { self.cached_char_width, &editor_pl, layout, + &self.cached_tab_pixel_hits.borrow(), &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), @@ -4448,8 +4654,8 @@ impl App { .get(&gid) .map(|g| g.active_tab) .unwrap_or(0); - engine.tab_drag_begin(gid, tidx); - engine.tab_drag_mouse = Some((x, y)); + self.tab_drag_source = Some((gid, tidx)); + self.tab_drag_drop_zone = core::window::DropZone::None; self.tab_dragging = true; self.tab_drag_start = None; self.draw_needed.set(true); @@ -4551,6 +4757,7 @@ impl App { self.cached_char_width, &editor_pl, layout, + &self.cached_tab_pixel_hits.borrow(), &self.tab_slot_positions.borrow(), &self.diff_btn_map.borrow(), &self.split_btn_map.borrow(), @@ -4592,9 +4799,13 @@ impl App { // Tab drag drop. if self.tab_dragging { self.tab_dragging = false; - let mut engine = self.engine.borrow_mut(); - let zone = engine.tab_drop_zone; - engine.tab_drag_drop(zone); + if let Some((src_gid, src_tab_idx)) = self.tab_drag_source.take() { + let zone = self.tab_drag_drop_zone; + self.tab_drag_drop_zone = core::window::DropZone::None; + self.engine + .borrow_mut() + .apply_tab_drop_zone(src_gid, src_tab_idx, zone); + } self.draw_needed.set(true); } self.tab_drag_start = None; @@ -6325,6 +6536,11 @@ impl App { ); let tree_event = { let mut b = self.backend.borrow_mut(); + // Re-apply the metrics the tree was drawn with so the + // hit-test row math matches the rendered rows. (#540) + let (lh, cw) = self.cached_explorer_metrics.get(); + b.set_current_line_height(lh); + b.set_current_char_width(cw); self.engine .borrow() .explorer_tree @@ -6358,6 +6574,57 @@ 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) + fn try_route_sidebar_mouse_event( + &mut self, + event: &quadraui::UiEvent, + ctx: &quadraui::ShellContext<'_>, + ) -> bool { + use quadraui::UiEvent; + + let Some(sb) = ctx.layout.sidebar_content_bounds else { + 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 pos = match event { + UiEvent::MouseDown { position, .. } + | UiEvent::DoubleClick { position, .. } + | UiEvent::Scroll { position, .. } => *position, + _ => return false, + }; + if pos.x < sb.x || pos.x >= sb.x + sb.width || pos.y < sb.y || pos.y >= sb.y + sb.height { + return false; + } + + // 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 = { + let engine = self.engine.borrow(); + engine.ext_panel_active.is_none() + && engine + .app_shell + .active_panel_id() + .map(|id| id.as_str() == PANEL_EXPLORER) + .unwrap_or(true) + }; + if !explorer_active { + return false; + } + + self.dispatch(Msg::ExplorerUiEvent(event.clone())); + self.draw_needed.set(true); + true + } + fn explorer_row_at(&self, y: f64) -> Option { let engine = self.engine.borrow(); let total = engine.explorer_rows.len(); @@ -7246,8 +7513,53 @@ impl quadraui::ShellApp for App { } } - // ── Draw tab bar ────────────────────────────────────────────────────── - if !engine.is_tab_bar_hidden(engine.active_group) { + // ── Draw tab bar(s) — one per editor group ──────────────────────────── + // Multi-group (post-split) layouts have a tab bar per group, each drawn + // at the top edge of its own bounds. Single-group draws one full-width + // bar at the editor top. Previously only the single-group primitive was + // drawn, so split groups rendered with no tab bar at all. (#515) + // Reset the pixel-accurate hit caches; repopulated per tab bar below so + // the click / hover hit-tests use the exact drawn geometry (#515). + let mut pixel_hits = self.cached_tab_pixel_hits.borrow_mut(); + let mut close_abs = self.cached_tab_close_abs.borrow_mut(); + let mut slots_abs = self.cached_tab_slots_abs.borrow_mut(); + pixel_hits.clear(); + close_abs.clear(); + slots_abs.clear(); + if let Some(ref split) = screen.editor_group_split { + for gtb in &split.group_tab_bars { + if engine.is_tab_bar_hidden(gtb.group_id) { + continue; + } + let bar_top = gtb.bounds.y - tab_bar_h; + let tb_rect = quadraui::Rect::new( + gtb.bounds.x as f32, + bar_top as f32, + gtb.bounds.width as f32, + tab_row_h as f32, + ); + let hover = self + .tab_close_hover + .and_then(|(gid, i)| (gid == gtb.group_id.0).then_some(i)); + let mut frame = QSL::new(); + frame.push(Surface::TabBar { + rect: tb_rect, + bar: >b.bar, + hovered_close: hover, + }); + frame.draw(backend); + // Recover the exact pixel geometry the rasteriser just drew and + // cache it (relative to the bar's left edge) for hit-testing. + let hits = backend.tab_bar_layout(tb_rect, >b.bar); + let ph = tab_hits_to_pixel_hits(&hits, >b.bar, tb_rect.x as f64); + close_abs.insert( + gtb.group_id.0, + abs_close_record(&ph.close, gtb.bounds.x, bar_top, bar_top + tab_row_h), + ); + slots_abs.insert(gtb.group_id.0, abs_visible_slots(&hits)); + pixel_hits.insert(gtb.group_id.0, ph); + } + } else if !engine.is_tab_bar_hidden(engine.active_group) { let tb_rect = quadraui::Rect::new(x as f32, y as f32, w as f32, tab_row_h as f32); let hover = self.tab_close_hover.map(|(_, i)| i); let mut frame = QSL::new(); @@ -7257,7 +7569,18 @@ impl quadraui::ShellApp for App { hovered_close: hover, }); frame.draw(backend); + let hits = backend.tab_bar_layout(tb_rect, &screen.tab_bar_primitive); + let ph = tab_hits_to_pixel_hits(&hits, &screen.tab_bar_primitive, tb_rect.x as f64); + close_abs.insert( + engine.active_group.0, + abs_close_record(&ph.close, x, y, y + tab_row_h), + ); + slots_abs.insert(engine.active_group.0, abs_visible_slots(&hits)); + pixel_hits.insert(engine.active_group.0, ph); } + drop(close_abs); + drop(slots_abs); + drop(pixel_hits); // ── Draw global status bar / wildmenu ───────────────────────────────── let status_y = @@ -7329,6 +7652,12 @@ impl quadraui::ShellApp for App { match active_id.as_str() { PANEL_EXPLORER => { render::populate_explorer_tree_controller(&engine, &theme); + // Capture the exact metrics the tree is drawn with so the + // click hit-test (which reads the backend's mutable + // current_line_height at a later, possibly-different time) can + // re-apply them and resolve the correct row. (#540) + self.cached_explorer_metrics + .set((backend.line_height() as f64, backend.char_width() as f64)); engine.explorer_tree_rect.set(q_sb); engine.explorer_viewport_rows.set(q_sb.height as usize); engine.explorer_tree.borrow().render(backend, q_sb); @@ -7397,6 +7726,63 @@ impl quadraui::ShellApp for App { } } } + + // ── Cache per-group tab-drop geometry ───────────────────────────────── + // Compute the absolute drop-group bounds from the shared screen layout and + // stash them so the drag hit-test (handle_mouse_drag_msg) and the overlay + // below use one identical source. + // + // Origin convention: in multi-group mode `gtb.bounds` are already absolute + // (built from absolute window rects), so the origin offset must be (0,0) — + // adding (x,y) again would double-count it and shift the highlight off the + // group (the prior "covers half the group" bug). Single-group mode returns + // (origin, size) directly, so it needs the real editor origin (x,y). (#515) + { + let drop_origin = if screen.editor_group_split.is_some() { + (0.0, 0.0) + } else { + (x as f32, y as f32) + }; + let bounds = render::screen_to_drop_group_bounds( + screen, + &engine, + drop_origin, + (w as f32, editor_area_h as f32), + ); + // Per-tab slot x-positions (absolute) were captured while drawing the + // tab bars above. Feeding them here makes a drag inside a group's own + // tab bar resolve to a `TabReorder` (insertion bar) instead of falling + // through to a new-split/center overlay. (#515) + let slots_abs = self.cached_tab_slots_abs.borrow(); + let (groups, eff_tbh) = + render::build_tab_drop_groups(&bounds, &engine, tab_bar_h as f32, &slots_abs); + drop(slots_abs); + *self.cached_drop_groups.borrow_mut() = groups; + self.cached_drop_tbh.set(eff_tbh); + } + + // ── Draw tab drag overlay ───────────────────────────────────────────── + // When a tab drag is in progress, paint the drop-zone highlight + insertion + // bar on top of all other content, using the geometry cached just above. + if self.tab_dragging { + let groups = self.cached_drop_groups.borrow(); + let eff_tbh = self.cached_drop_tbh.get(); + let (mx, my) = self.mouse_pos_cell.get(); + if let Some(ov) = render::compute_tab_drop_overlay( + &self.tab_drag_drop_zone, + &groups, + (mx as f32, my as f32), + eff_tbh, + 2.0, + lh as f32, + ) { + backend.draw_drop_overlay(&quadraui::DropOverlay { + highlight: ov.highlight, + insertion_bar: ov.insertion_bar, + ghost_position: Some(ov.ghost_position), + }); + } + } } fn handle( @@ -7407,6 +7793,19 @@ impl quadraui::ShellApp for App { ) -> quadraui::Reaction { use quadraui::{Key, MouseButton, NamedKey, UiEvent}; + // Pointer events over the sidebar content area are forwarded to the active + // panel's controller before the editor click path sees them. In ShellApp + // mode there is no per-panel DrawingArea, so without this the file explorer + // never receives clicks. (#540 ShellApp port) + if self.try_route_sidebar_mouse_event(&event, ctx) { + return if self.draw_needed.get() { + self.draw_needed.set(false); + quadraui::Reaction::Redraw + } else { + quadraui::Reaction::Continue + }; + } + match event { UiEvent::KeyPressed { key, modifiers, .. } => { let (key_name, unicode) = match key { @@ -7756,52 +8155,25 @@ fn h_scrollbar_hit_test( } /// Hit-test tab close buttons. Returns `Some((group_id.0, tab_idx))` if the -/// mouse is over a tab's × button, matching the same geometry as the click handler. -/// Tab-close hover hit-test driven by the rasteriser's cached -/// `close_bounds`. Each frame the GTK rasteriser publishes the exact -/// per-tab close-button rectangle (Pango pixel widths, not estimates) -/// to `App.tab_close_bounds`; this function consults those bounds -/// rather than re-deriving geometry from `name.chars() * char_width`, -/// which under-estimates Pango widths and shifts the close zone. -fn tab_close_hit_test( - engine: &Engine, - close_bounds_map: &TabCloseMap, - mx: f64, - my: f64, - da_w: f64, - da_h: f64, - line_height: f64, -) -> Option<(usize, usize)> { - let tab_row_height = (line_height * 1.6).ceil(); - let tab_bar_height = if engine.settings.breadcrumbs { - tab_row_height + line_height - } else { - tab_row_height - }; - let editor_bottom = gtk_editor_bottom(engine, da_w, da_h, line_height); - let content_bounds = core::WindowRect::new(0.0, 0.0, da_w, editor_bottom); - let mut group_rects = engine - .group_layout - .calculate_group_rects(content_bounds, tab_bar_height); - engine.adjust_group_rects_for_hidden_tabs(&mut group_rects, tab_bar_height); - - for (gid, grect) in &group_rects { - if engine.is_tab_bar_hidden(*gid) { - continue; - } - let tab_y = grect.y - tab_bar_height; - if my < tab_y || my >= tab_y + tab_row_height || mx < grect.x || mx >= grect.x + grect.width - { +/// mouse is over a tab's × glyph. +/// +/// Consults the **absolute** tight close-glyph rects captured during +/// `render_content` ([`App::cached_tab_close_abs`]). Those rects already fold in +/// the activity-bar/sidebar x-offset and the exact drawn Pango geometry, so this +/// is a plain point-in-rect test — no group-rect re-derivation. The previous +/// version rebuilt group rects from a `(0,0)` content origin, ignoring the +/// left-hand chrome offset, so hover never fired once a sidebar was open and the +/// × highlight silently disappeared (#515). Because the rects match the × +/// highlight the rasteriser draws, a hover shows exactly the box a click closes. +fn tab_close_hit_test(close_abs_map: &TabCloseAbsMap, mx: f64, my: f64) -> Option<(usize, usize)> { + for (gid, (y_top, y_bot, xs)) in close_abs_map { + if my < *y_top || my >= *y_bot { continue; } - let local_x = mx - grect.x; - let Some(close_bounds) = close_bounds_map.get(&gid.0) else { - continue; - }; - for (i, cb) in close_bounds.iter().enumerate() { + for (i, cb) in xs.iter().enumerate() { if let Some((cx_start, cx_end)) = cb { - if local_x >= *cx_start && local_x < *cx_end { - return Some((gid.0, i)); + if mx >= *cx_start && mx < *cx_end { + return Some((*gid, i)); } } } diff --git a/src/render.rs b/src/render.rs index f02c389d..c9413a63 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2847,7 +2847,7 @@ pub enum UiAction { /// Must call: `engine.open_tab_context_menu(group_id, tab_idx, x, y)` TabRightClick, /// Drag a tab → reorder or move between groups. - /// Must call: `engine.tab_drag_begin()`, `engine.tab_drag_drop(zone)` + /// Handled by `TabGroupController::handle_tab_drag_start/move/drop`. TabDragDrop, // ── Editor ─────────────────────────────────────────────────────── @@ -3566,6 +3566,15 @@ pub struct ScreenLayout { pub tab_scroll_offset: usize, /// Pre-built quadraui `TabBar` primitive for the single-group tab bar. pub tab_bar_primitive: quadraui::TabBar, + /// Hit regions (char-cell columns, relative to the tab bar's left edge) for + /// the single-group / active tab bar drawn from `tab_bar_primitive`. Empty in + /// multi-group mode (each group carries its own `hit_regions` on its + /// `GroupTabBar`). Lets backends resolve tab-bar clicks through the shared + /// `resolve_tab_bar_click` path instead of per-backend pixel maps. (#515) + pub tab_bar_hit_regions: Vec<( + crate::core::engine::TabBarHitRegion, + crate::core::engine::TabBarClickTarget, + )>, /// When `status_line_above_terminal` is OFF and the terminal panel is open, /// this carries the active window's status line to render as a dedicated row /// above the terminal panel. When `Some`, per-window `status_line` fields on @@ -5983,7 +5992,13 @@ pub fn build_screen_layout( .get(&gid) .map(|g| g.tab_scroll_offset) .unwrap_or(0); - let bar_width = bounds.width as u16; + // Hit regions are expressed in char-CELLS so they are + // backend-neutral. TUI passes char_width=1.0 (bounds already in + // cells); GTK passes pixel bounds + real char_width, so divide to + // recover cells. Without this, GTK's right-aligned button regions + // (split/diff/action) would land at pixel columns and never match + // a cell-converted click. (#515) + let bar_width = (bounds.width / char_width).round() as u16; let has_diff_toolbar = diff_toolbar.is_some(); let diff_label_cols = diff_toolbar .as_ref() @@ -6132,8 +6147,41 @@ pub fn build_screen_layout( Some(to_quadraui_color(theme.tab_active_accent)), ); + // Hit regions for the single-group / active tab bar, in char-cells. The bar + // spans the full editor content width (bounding box of all window rects); + // divide by char_width so the result is backend-neutral (TUI char_width=1.0). + // `has_split_buttons = true` mirrors the `true` passed to build_tab_bar_primitive + // above. Empty in multi-group mode (handled per-group on each GroupTabBar). (#515) + let tab_bar_hit_regions = if editor_group_split.is_some() || window_rects.is_empty() { + Vec::new() + } else { + let min_x = window_rects + .iter() + .map(|(_, r)| r.x) + .fold(f64::MAX, f64::min); + let max_r = window_rects + .iter() + .map(|(_, r)| r.x + r.width) + .fold(f64::MIN, f64::max); + let bar_width_cells = ((max_r - min_x) / char_width).round().max(0.0) as u16; + let diff_label_cols = diff_toolbar + .as_ref() + .and_then(|dt| dt.change_label.as_ref()) + .map(|l| l.len() as u16 + 1) + .unwrap_or(0); + compute_tab_bar_hit_regions( + &tab_bar, + tab_scroll_offset_single, + bar_width_cells, + diff_toolbar.is_some(), + diff_label_cols, + true, + ) + }; + ScreenLayout { tab_bar, + tab_bar_hit_regions, windows, global_status_bar, command, @@ -14772,14 +14820,14 @@ mod tests { assert_eq!(e.editor_groups.len(), 1, "start with one group"); let gid = e.active_group; - e.tab_drag_begin(gid, 1); // drag f1's tab - - // Drop to create a vertical split - e.tab_drag_drop(crate::core::window::DropZone::Split( + // Move tab 1 (f1) to create a vertical split + e.move_tab_to_new_split( + gid, + 1, gid, crate::core::window::SplitDirection::Vertical, false, - )); + ); assert_eq!( e.editor_groups.len(), 2, diff --git a/src/tui_main/mod.rs b/src/tui_main/mod.rs index 94c9340a..30036874 100644 --- a/src/tui_main/mod.rs +++ b/src/tui_main/mod.rs @@ -31,6 +31,8 @@ use mouse::*; #[allow(unused_imports)] use panels::*; #[allow(unused_imports)] +use quadraui::Backend; +#[allow(unused_imports)] use render_impl::*; // ─── Debug logging ──────────────────────────────────────────────────────────── @@ -977,6 +979,12 @@ fn event_loop( let mut tab_drag_start: Option<(u16, u16)> = None; // True while a tab drag is actively in progress. let mut tab_dragging: bool = false; + // Source pane of the active tab drag: (GroupId, tab_index). + let mut tui_drag_source: Option<(crate::core::window::GroupId, usize)> = None; + // Cursor position during the active tab drag (for ghost label). + let mut tui_drag_cursor: Option<(f64, f64)> = None; + // Most recently computed drop zone during an active tab drag. + let mut tui_tab_drop_zone: crate::core::window::DropZone = crate::core::window::DropZone::None; // Track unnamed register content so we only write to clipboard on changes. let mut last_clipboard_content: Option = None; @@ -1161,6 +1169,9 @@ fn event_loop( &mut completion_layout, &mut context_menu_layout, &mut backend, + tui_drag_source, + tui_drag_cursor, + &tui_tab_drop_zone, ); } }) @@ -1212,6 +1223,9 @@ fn event_loop( &mut completion_layout, &mut context_menu_layout, &mut backend, + tui_drag_source, + tui_drag_cursor, + &tui_tab_drop_zone, ); } }) @@ -2856,6 +2870,9 @@ fn event_loop( &mut explorer_drag_active, &mut tab_drag_start, &mut tab_dragging, + &mut tui_drag_source, + &mut tui_drag_cursor, + &mut tui_tab_drop_zone, &hover_link_rects, hover_popup_rect, editor_hover_popup_rect, @@ -2903,6 +2920,9 @@ fn event_loop( &mut explorer_drag_active, &mut tab_drag_start, &mut tab_dragging, + &mut tui_drag_source, + &mut tui_drag_cursor, + &mut tui_tab_drop_zone, &hover_link_rects, hover_popup_rect, editor_hover_popup_rect, diff --git a/src/tui_main/mouse.rs b/src/tui_main/mouse.rs index fa89ce3c..443deb0f 100644 --- a/src/tui_main/mouse.rs +++ b/src/tui_main/mouse.rs @@ -154,6 +154,9 @@ pub(super) fn handle_mouse( explorer_drag_active: &mut Option<(usize, Option)>, tab_drag_start: &mut Option<(u16, u16)>, tab_dragging: &mut bool, + tab_drag_source: &mut Option<(crate::core::window::GroupId, usize)>, + tab_drag_cursor: &mut Option<(f64, f64)>, + tab_drop_zone: &mut crate::core::window::DropZone, hover_link_rects: &[(u16, u16, u16, u16, String)], hover_popup_rect: Option<(u16, u16, u16, u16)>, editor_hover_popup_rect: Option<(u16, u16, u16, u16)>, @@ -872,8 +875,8 @@ pub(super) fn handle_mouse( } // Tab drag-and-drop: update drop zone while dragging. if *tab_dragging { - engine.tab_drag_mouse = Some((col as f64, row as f64)); - engine.tab_drop_zone = compute_tui_tab_drop_zone( + *tab_drag_cursor = Some((col as f64, row as f64)); + *tab_drop_zone = compute_tui_tab_drop_zone( engine, col, row, @@ -895,8 +898,9 @@ pub(super) fn handle_mouse( .get(&gid) .map(|g| g.active_tab) .unwrap_or(0); - engine.tab_drag_begin(gid, tidx); - engine.tab_drag_mouse = Some((col as f64, row as f64)); + *tab_drag_source = Some((gid, tidx)); + *tab_drag_cursor = Some((col as f64, row as f64)); + *tab_drop_zone = crate::core::window::DropZone::None; *tab_dragging = true; *tab_drag_start = None; return sidebar_width; @@ -1084,8 +1088,12 @@ pub(super) fn handle_mouse( if *tab_dragging { *tab_dragging = false; *tab_drag_start = None; - let zone = engine.tab_drop_zone; - engine.tab_drag_drop(zone); + if let Some((src_gid, src_tab_idx)) = tab_drag_source.take() { + let zone = *tab_drop_zone; + *tab_drop_zone = crate::core::window::DropZone::None; + engine.apply_tab_drop_zone(src_gid, src_tab_idx, zone); + } + *tab_drag_cursor = None; return sidebar_width; } *tab_drag_start = None; diff --git a/src/tui_main/render_impl.rs b/src/tui_main/render_impl.rs index 14102df0..e07efdb9 100644 --- a/src/tui_main/render_impl.rs +++ b/src/tui_main/render_impl.rs @@ -113,6 +113,9 @@ pub(super) fn draw_frame( // Set once per frame by the caller (cached theme); the migrated // call sites wrap their access in `backend.enter_frame_scope`. backend: &mut super::backend::TuiBackend, + tab_drag_source: Option<(crate::core::window::GroupId, usize)>, + tab_drag_cursor: Option<(f64, f64)>, + tab_drop_zone: &crate::core::window::DropZone, ) { let area = frame.area(); @@ -421,8 +424,17 @@ pub(super) fn draw_frame( }); // ── Tab drag overlay ──────────────────────────────────────────────────── - if engine.tab_drag.is_some() { - render_tab_drag_overlay(frame, engine, editor_area, screen, theme); + if tab_drag_source.is_some() { + render_tab_drag_overlay( + frame, + engine, + editor_area, + screen, + theme, + tab_drag_source, + tab_drag_cursor, + tab_drop_zone, + ); } // ── Tab hover tooltip (rendered on top of editor, below tab bar) ────── @@ -1235,12 +1247,21 @@ fn build_tui_tab_slots( map } +/// Render the tab drag overlay for the TUI path. +/// +/// `tab_drag_source` is the (GroupId, tab_index) captured when the drag started. +/// `tab_drag_cursor` is the current cursor position during the drag. +/// `tab_drop_zone` is the most recently computed drop zone. +#[allow(clippy::too_many_arguments)] pub(super) fn render_tab_drag_overlay( frame: &mut ratatui::Frame, engine: &Engine, editor_area: Rect, screen: &render::ScreenLayout, theme: &render::Theme, + tab_drag_source: Option<(crate::core::window::GroupId, usize)>, + tab_drag_cursor: Option<(f64, f64)>, + tab_drop_zone: &crate::core::window::DropZone, ) { let tab_slots = build_tui_tab_slots(screen, engine, editor_area.x as f32); let tbh_f = if engine.settings.breadcrumbs { @@ -1255,21 +1276,14 @@ pub(super) fn render_tab_drag_overlay( (editor_area.width as f32, editor_area.height as f32), ); let (groups, tbh) = render::build_tab_drop_groups(&bounds, engine, tbh_f, &tab_slots); - let cursor = engine - .tab_drag_mouse + let cursor = tab_drag_cursor .map(|(mx, my)| (mx as f32, my as f32)) .unwrap_or((0.0, 0.0)); - let overlay = match render::compute_tab_drop_overlay( - &engine.tab_drop_zone, - &groups, - cursor, - tbh, - 1.0, - 2.0, - ) { - Some(o) => o, - None => return, - }; + let overlay = + match render::compute_tab_drop_overlay(tab_drop_zone, &groups, cursor, tbh, 1.0, 2.0) { + Some(o) => o, + None => return, + }; let highlight_bg = RColor::Indexed(24); if let Some(h) = overlay.highlight { @@ -1303,8 +1317,24 @@ pub(super) fn render_tab_drag_overlay( ); } - if let (Some(ref drag), Some(_)) = (&engine.tab_drag, engine.tab_drag_mouse) { - let label = &drag.tab_name; + // Look up the tab label from engine using the captured drag source. + let drag_label: String = if let Some((src_gid, src_tab_idx)) = tab_drag_source { + engine + .editor_groups + .get(&src_gid) + .and_then(|g| g.tabs.get(src_tab_idx)) + .and_then(|t| { + let win = engine.windows.get(&t.active_window)?; + let state = engine.buffer_manager.get(win.buffer_id)?; + Some(state.display_name().to_string()) + }) + .unwrap_or_default() + } else { + String::new() + }; + + if tab_drag_cursor.is_some() && !drag_label.is_empty() { + let label = &drag_label; if !label.is_empty() { let gx = overlay.ghost_position.0 as u16; let gy = overlay.ghost_position.1 as u16; @@ -1749,6 +1779,9 @@ mod tests { &mut completion_layout, &mut context_menu_layout, &mut backend, + None, // tab_drag_source + None, // tab_drag_cursor + &crate::core::window::DropZone::None, // tab_drop_zone ); }) .unwrap(); diff --git a/tests/tab_drag.rs b/tests/tab_drag.rs index 1dc41770..4dbcb05b 100644 --- a/tests/tab_drag.rs +++ b/tests/tab_drag.rs @@ -1,43 +1,6 @@ mod common; use common::*; -use vimcode_core::core::window::{DropZone, SplitDirection}; - -// ── Tab drag: begin and cancel ────────────────────────────────────────────── - -#[test] -fn tab_drag_begin_sets_state() { - let mut e = engine_with("hello\n"); - let gid = e.active_group; - e.tab_drag_begin(gid, 0); - assert!(e.tab_drag.is_some()); - assert_eq!(e.tab_drag.as_ref().unwrap().source_group, gid); - assert_eq!(e.tab_drag.as_ref().unwrap().source_tab_index, 0); -} - -#[test] -fn tab_drag_cancel_clears_state() { - let mut e = engine_with("hello\n"); - let gid = e.active_group; - e.tab_drag_begin(gid, 0); - e.tab_drag_cancel(); - assert!(e.tab_drag.is_none()); - assert!(e.tab_drag_mouse.is_none()); - assert_eq!(e.tab_drop_zone, DropZone::None); -} - -// ── Tab drag: drop to center of same group (no-op) ───────────────────────── - -#[test] -fn tab_drag_drop_center_same_group_is_noop() { - let mut e = engine_with("hello\n"); - exec(&mut e, "tabnew"); - let gid = e.active_group; - let tabs_before = e.active_group().tabs.len(); - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Center(gid)); - assert_eq!(e.active_group().tabs.len(), tabs_before); - assert!(e.tab_drag.is_none()); -} +use vimcode_core::core::window::SplitDirection; // ── Tab drag: move tab to another group ───────────────────────────────────── @@ -53,8 +16,7 @@ fn move_tab_to_target_group() { assert_ne!(src, dst); // Move tab 1 from src to dst - e.tab_drag_begin(src, 1); - e.tab_drag_drop(DropZone::Center(dst)); + e.move_tab_to_target_group(src, 1, dst); // dst should now have 2 tabs assert_eq!(e.editor_groups.get(&dst).unwrap().tabs.len(), 2); @@ -75,8 +37,7 @@ fn move_last_tab_closes_source_group() { assert!(e.editor_groups.contains_key(&src)); // Move the only tab from src to dst - e.tab_drag_begin(src, 0); - e.tab_drag_drop(DropZone::Center(dst)); + e.move_tab_to_target_group(src, 0, dst); // src should be removed from the layout assert!(!e.editor_groups.contains_key(&src)); @@ -95,8 +56,7 @@ fn move_tab_to_new_split_right() { let gid = e.active_group; let groups_before = e.editor_groups.len(); - e.tab_drag_begin(gid, 1); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Vertical, false)); + e.move_tab_to_new_split(gid, 1, gid, SplitDirection::Vertical, false); // Should have one more group assert_eq!(e.editor_groups.len(), groups_before + 1); @@ -113,8 +73,7 @@ fn move_tab_to_new_split_left() { exec(&mut e, "tabnew"); let gid = e.active_group; - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Vertical, true)); + e.move_tab_to_new_split(gid, 0, gid, SplitDirection::Vertical, true); assert_eq!(e.editor_groups.len(), 2); assert!(!e.group_layout.is_single_group()); @@ -126,8 +85,7 @@ fn move_tab_to_new_split_top() { exec(&mut e, "tabnew"); let gid = e.active_group; - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Horizontal, true)); + e.move_tab_to_new_split(gid, 0, gid, SplitDirection::Horizontal, true); assert_eq!(e.editor_groups.len(), 2); } @@ -138,8 +96,7 @@ fn move_tab_to_new_split_bottom() { exec(&mut e, "tabnew"); let gid = e.active_group; - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(gid, SplitDirection::Horizontal, false)); + e.move_tab_to_new_split(gid, 0, gid, SplitDirection::Horizontal, false); assert_eq!(e.editor_groups.len(), 2); } @@ -154,9 +111,8 @@ fn split_with_last_tab_closes_source_and_creates_new() { e.open_editor_group(SplitDirection::Vertical); let other = e.active_group; - // Now drag the sole tab from 'gid' to split right of 'other' - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::Split(other, SplitDirection::Vertical, false)); + // Drag the sole tab from 'gid' to split right of 'other' + e.move_tab_to_new_split(gid, 0, other, SplitDirection::Vertical, false); // gid should be gone (had only 1 tab) assert!(!e.editor_groups.contains_key(&gid)); @@ -189,8 +145,7 @@ fn reorder_tab_via_drag_drop() { let gid = e.active_group; let tabs_before = e.active_group().tabs.len(); - e.tab_drag_begin(gid, 0); - e.tab_drag_drop(DropZone::TabReorder(gid, 2)); + e.reorder_tab_in_group(gid, 0, 2); // Same number of tabs, just reordered assert_eq!(e.active_group().tabs.len(), tabs_before); @@ -209,9 +164,8 @@ fn tab_reorder_to_different_group() { exec(&mut e, "tabnew"); // dst now has 2 tabs - // Drag tab 0 from src to position 1 in dst - e.tab_drag_begin(src, 0); - e.tab_drag_drop(DropZone::TabReorder(dst, 1)); + // Move tab 0 from src to position 1 in dst + e.move_tab_to_target_group_at(src, 0, dst, 1); assert_eq!(e.editor_groups.get(&dst).unwrap().tabs.len(), 3); assert_eq!(e.editor_groups.get(&src).unwrap().tabs.len(), 1);