Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
97785e8
feat(#515): migrate editor-group drag-and-drop to quadraui TabGroupCo…
JDonaghy Jun 29, 2026
f15d56f
fix(#515): wire tab drag overlay into ShellApp render_content
JDonaghy Jun 29, 2026
7a3e003
fix(#515): correct misleading comment about overlay draw path in draw.rs
JDonaghy Jun 29, 2026
bb97597
fix(#515): fix three GTK tab-bar regressions in ShellApp render path
JDonaghy Jun 29, 2026
a97b989
fix(#540): route ShellApp sidebar clicks to the file explorer
JDonaghy Jun 30, 2026
807dacf
debug(#540): add gated hit-test logging for explorer + tab clicks
JDonaghy Jun 30, 2026
9d48b83
debug(#540): add click-routing + render line-height probes
JDonaghy Jun 30, 2026
c3150c7
fix(#540): explorer click row offset (draw vs hit line-height) + tab …
JDonaghy Jun 30, 2026
a007d03
debug(#540): bisection probes to locate plain tab-click divert
JDonaghy Jun 30, 2026
3af6f3e
debug(#540): finer tab-click bisection probes (C/D/E + breadcrumb res…
JDonaghy Jun 30, 2026
69c4454
debug(#540): probe F + editor-hover diagnostic for tab-click divert
JDonaghy Jun 30, 2026
20c2aaf
debug(#540): report dialog_open state at probe F
JDonaghy Jun 30, 2026
57dcd87
chore(#540): remove tab/explorer hit-test debug probes
JDonaghy Jun 30, 2026
78de511
refactor(#515): unify tab drag-drop application in core; drop dead co…
JDonaghy Jun 30, 2026
06a62d0
fix(#515): hit-test GTK tabs against drawn pixel geometry, not char c…
JDonaghy Jul 1, 2026
1a30658
fix(#515): tight close-glyph hit-test + in-bar tab reorder
JDonaghy Jul 1, 2026
92cd281
fix(#515): Ctrl+\ split-right in GTK — accept raw "\\" key_name in en…
JDonaghy Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/core/engine/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 1 addition & 20 deletions src/core/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<TabDragState>,
/// 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<BufferId>,
Expand Down Expand Up @@ -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(),
Expand Down
158 changes: 123 additions & 35 deletions src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -15864,17 +15875,15 @@ 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");
let gid = e.active_group;
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());
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -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");
Expand Down
Loading