diff --git a/app/src/tab.rs b/app/src/tab.rs index 7292a124ed..285d17884a 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -766,6 +766,8 @@ pub struct TabComponent<'a> { for_drag_ghost: bool, /// Set when rendered as a member of a horizontal tab group. grouped_member: bool, + /// Set when this member is the sole tab in its group. + sole_grouped_member: bool, } /// Structure that holds TabComponent styles. @@ -927,6 +929,7 @@ impl<'a> TabComponent<'a> { background_opacity, for_drag_ghost: false, grouped_member: false, + sole_grouped_member: false, } } @@ -939,8 +942,11 @@ impl<'a> TabComponent<'a> { /// Marks this tab as a member of a horizontal tab group. See the /// [`TabComponent`] `grouped_member` field for the rendering differences. - pub fn for_grouped_member(mut self) -> Self { + /// Pass `is_sole_member = true` when this is the only tab in its group so + /// the per-tab `Draggable` is suppressed and the parent group drag fires. + pub fn for_grouped_member(mut self, is_sole_member: bool) -> Self { self.grouped_member = true; + self.sole_grouped_member = is_sole_member; self } @@ -1684,7 +1690,7 @@ impl UiComponent for TabComponent<'_> { let mouse_close_state = self.tab.close_mouse_state.clone(); // Capture before `self` is moved into the Hoverable closure below. let for_drag_ghost = self.for_drag_ghost; - let grouped_member = self.grouped_member; + let sole_grouped_member = self.sole_grouped_member; // Extract values before moving self into closure let tooltip_text = self.tooltip_message.clone(); @@ -1877,11 +1883,17 @@ impl UiComponent for TabComponent<'_> { // position cache, breaking `tab_insertion_index_for_cursor`. let full_tab: Box = if for_drag_ghost { constrained_tab - } else if grouped_member { - // Skipping dragging within a group for now. - // TODO(johnturcoo) support dragging tabs within a group. + } else if sole_grouped_member { + // Sole member of a group: skip the per-tab `Draggable` so the + // parent group's `Draggable` picks up the drag instead. Dragging + // the only member of a group drags the entire group, preventing + // accidental orphaning. Keep `SavePosition` so hit-testing / + // neighbor-rect math still works. SavePosition::new(constrained_tab, &tab_position_id(tab_index)).finish() } else { + // Grouped members use the same `Draggable` wrapper as regular tabs + // so dragging fires `DragTab`; the workspace's `on_tab_drag` + // handles cross-group reassignment. let draggable = Draggable::new(draggable_state, constrained_tab) .on_drag_start(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::StartTabDrag)) .on_drag(move |ctx, _, rect, _| { @@ -1899,7 +1911,6 @@ impl UiComponent for TabComponent<'_> { let tab_with_drag: Box = draggable.finish(); SavePosition::new(tab_with_drag, &tab_position_id(tab_index)).finish() }; - if FeatureFlag::NewTabStyling.is_enabled() { Shrinkable::new(1.0, full_tab) } else { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 27b3c54439..143440451d 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -83,11 +83,12 @@ use warpui::clipboard::ClipboardContent; use warpui::elements::Percentage; use warpui::elements::{ Align, Border, CacheOption, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, - CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DraggableState, DropTarget, - Element, Empty, EventHandler, Expanded, Fill as ElementFill, Flex, Highlight, Hoverable, - Icon as WarpUiIcon, Image, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, - OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor, - PositionedElementOffsetBounds, Radius, Rect, SavePosition, Shrinkable, Stack, Text, + CornerRadius, CrossAxisAlignment, Dismiss, DispatchEventResult, DragAxis, Draggable, + DraggableState, DropTarget, Element, Empty, EventHandler, Expanded, Fill as ElementFill, Flex, + Highlight, Hoverable, Icon as WarpUiIcon, Image, MainAxisAlignment, MainAxisSize, + MouseInBehavior, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, + ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Rect, + SavePosition, Shrinkable, Stack, Text, }; use warpui::fonts::{Properties, Weight}; use warpui::geometry::vector::{vec2f, Vector2F}; @@ -109,7 +110,7 @@ use warpui::{ use self::vertical_tabs::telemetry::{VerticalTabsDisplayOption, VerticalTabsTelemetryEvent}; use self::vertical_tabs::{ - pane_summary_kind, render_detail_sidecar, render_settings_popup, + htab_group_position_id, pane_summary_kind, render_detail_sidecar, render_settings_popup, render_summary_pane_kind_icon_circle, render_summary_pane_kind_icons, vtab_group_position_id, SummaryPaneKind, SummaryPaneKindIcons, VerticalTabsPanelState, VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID, @@ -18807,6 +18808,10 @@ impl Workspace { } else { TabCloseButtonPosition::default() }; + // When a group has only one member, suppress that member's per-tab + // `Draggable` so the parent group's `Draggable` picks up the drag + // instead, dragging the whole group rather than orphaning it. + let is_sole_member = group_has_single_member(&self.tabs, group.id); for idx in member_range { let tab = &self.tabs[idx]; let member = TabComponent::new( @@ -18818,23 +18823,55 @@ impl Workspace { false, ctx, ) - .for_grouped_member() + .for_grouped_member(is_sole_member) .build() .finish(); row.add_child(member); } } - let container = Container::new(row.finish()).with_border( - Border::all(1.) - // Left border only on the first slot to avoid double borders. - .with_sides(false, is_first_in_bar, false, true) - .with_border_fill(internal_colors::fg_overlay_1(theme)), - ); + let container = Container::new(row.finish()) + .with_border( + Border::all(1.) + // Left border only on the first slot to avoid double borders. + .with_sides(false, is_first_in_bar, false, true) + .with_border_fill(internal_colors::fg_overlay_1(theme)), + ) + .finish(); + + let group_id = group.id; + let group_draggable_state = group.draggable_state.clone(); + let positioned_container = Draggable::new(group_draggable_state, container) + .on_drag_start(move |ctx, _, _| { + ctx.dispatch_typed_action(WorkspaceAction::StartGroupDrag(group_id)); + }) + .on_drag(move |ctx, _, rect, _| { + ctx.dispatch_typed_action(WorkspaceAction::DragGroup { + group_id, + position: rect, + }); + }) + .on_drop(move |ctx, _, _, _| { + ctx.dispatch_typed_action(WorkspaceAction::DropGroup); + }) + .with_drag_axis(DragAxis::HorizontalOnly) + // Yield to a nested per-tab `Draggable` when it claims the mouse-down + // so dragging a member tab fires `DragTab`, not group drag. + .with_defer_to_handled_child_mouse_down() + .finish(); + + let positioned_container = + SavePosition::new(positioned_container, &htab_group_position_id(group_id)).finish(); + // Flex = header + one per member so the group shrinks proportionally - // with sibling tabs. - let group_flex = 1.0 + run_len as f32; - Shrinkable::new(group_flex, container.finish()).finish() + // with sibling tabs. A collapsed group renders only its header, so it + // should claim a single slot's worth of flex. + let group_flex = if is_collapsed { + 1.0 + } else { + 1.0 + run_len as f32 + }; + Shrinkable::new(group_flex, positioned_container).finish() } /// Header (icon collage + name) for a horizontal tab group. Click @@ -19612,12 +19649,17 @@ impl Workspace { }; let drag_model = CrossWindowTabDrag::as_ref(ctx); + let is_any_group_dragging = self + .tab_groups + .values() + .any(|g| g.draggable_state.is_dragging()); let tab_bar_state = TabBarState { tab_count: self.tabs.len(), active_tab_index, is_any_tab_renaming: self.current_workspace_state.is_tab_being_renamed(), is_any_tab_dragging: self.current_workspace_state.is_tab_being_dragged - || drag_model.is_active(), + || drag_model.is_active() + || is_any_group_dragging, hover_fixed_width, }; // Collapse the detached-placeholder slot to 0 width while it @@ -26623,12 +26665,16 @@ impl Workspace { FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs; let groups_enabled = FeatureFlag::GroupedTabs.is_enabled(); - if use_vertical_tabs && groups_enabled { + if groups_enabled { // Reassign membership when the dragged tab's midpoint enters a // different expanded group. Collapsed groups are handled by the // safety-net hop below so we don't drop into it. - let midpoint_drag_y = (position.min_y() + position.max_y()) / 2.; - let hovered_group = self.target_group_at_y(midpoint_drag_y, ctx); + let midpoint_drag = if use_vertical_tabs { + (position.min_y() + position.max_y()) / 2. + } else { + (position.min_x() + position.max_x()) / 2. + }; + let hovered_group = self.target_group_at_axis(midpoint_drag, use_vertical_tabs, ctx); let source_group = self.tabs[current_index].group_id; let expanded_target = hovered_group.filter(|gid| !self.tab_groups.get(gid).is_some_and(|g| g.collapsed)); @@ -26770,8 +26816,11 @@ impl Workspace { ) -> usize { let midpoint_drag_x = (drag_position.min_x() + drag_position.max_x()) / 2.; + // Use `neighbor_drag_rect` (rather than reading `tab_position_id` + // directly) so members of a collapsed group fall back to the group's + // visible container rect instead of their stale last-expanded rect. let maybe_left_tab = if current_index > 0 { - ctx.element_position_by_id(tab_position_id(current_index - 1)) + self.neighbor_drag_rect(current_index - 1, false, ctx) } else { None }; @@ -26782,7 +26831,7 @@ impl Workspace { } let maybe_right_tab = if current_index < self.tabs.len() - 1 { - ctx.element_position_by_id(tab_position_id(current_index + 1)) + self.neighbor_drag_rect(current_index + 1, false, ctx) } else { None }; @@ -26808,7 +26857,7 @@ impl Workspace { let midpoint_drag_y = (drag_position.min_y() + drag_position.max_y()) / 2.; let maybe_above_tab = if current_index > 0 { - self.neighbor_drag_rect(current_index - 1, ctx) + self.neighbor_drag_rect(current_index - 1, true, ctx) } else { None }; @@ -26820,7 +26869,7 @@ impl Workspace { } let maybe_below_tab = if current_index < self.tabs.len() - 1 { - self.neighbor_drag_rect(current_index + 1, ctx) + self.neighbor_drag_rect(current_index + 1, true, ctx) } else { None }; @@ -26834,16 +26883,32 @@ impl Workspace { current_index } - /// Returns the group whose saved container rect contains `cursor_y`, if any. - /// A small edge margin at each end of the rect is treated as "between groups" - /// so the cursor can land in the ungrouped zone between adjacent groups. - fn target_group_at_y(&self, cursor_y: f32, ctx: &mut ViewContext) -> Option { + /// Returns the group whose saved container rect contains `cursor` along + /// the active axis, if any. A small edge margin at each end of the rect + /// is treated as "between groups" so the cursor can land in the + /// ungrouped zone between adjacent groups. `is_vertical` picks the + /// layout-correct `SavePosition` id and the axis to test along. + fn target_group_at_axis( + &self, + cursor: f32, + is_vertical: bool, + ctx: &mut ViewContext, + ) -> Option { const EDGE_MARGIN: f32 = 6.0; self.tab_groups.keys().copied().find(|group_id| { - ctx.element_position_by_id(vtab_group_position_id(*group_id)) - .is_some_and(|rect| { - rect.min_y() + EDGE_MARGIN <= cursor_y && cursor_y <= rect.max_y() - EDGE_MARGIN - }) + let id = if is_vertical { + vtab_group_position_id(*group_id) + } else { + htab_group_position_id(*group_id) + }; + ctx.element_position_by_id(id).is_some_and(|rect| { + let (min, max) = if is_vertical { + (rect.min_y(), rect.max_y()) + } else { + (rect.min_x(), rect.max_x()) + }; + min + EDGE_MARGIN <= cursor && cursor <= max - EDGE_MARGIN + }) }) } @@ -26852,10 +26917,13 @@ impl Workspace { /// For members of a collapsed group the per-tab `tab_position_id` rect /// is stale (the tab is no longer painted, but `PositionCache` keeps the /// last painted rect). Use the group container's rect instead so - /// midpoint comparisons fire at the visible header. + /// midpoint comparisons fire at the visible header. `is_vertical` + /// picks the layout-correct save-position id for the collapsed-group + /// fallback. fn neighbor_drag_rect( &self, neighbor_index: usize, + is_vertical: bool, ctx: &mut ViewContext, ) -> Option { let neighbor_group_id = self.tabs.get(neighbor_index).and_then(|t| t.group_id); @@ -26864,14 +26932,25 @@ impl Workspace { .is_some_and(|g| g.collapsed); if neighbor_in_collapsed_group { - return ctx.element_position_by_id(vtab_group_position_id(neighbor_group_id.unwrap())); + let group_id = neighbor_group_id.unwrap(); + let id = if is_vertical { + vtab_group_position_id(group_id) + } else { + htab_group_position_id(group_id) + }; + return ctx.element_position_by_id(id); } ctx.element_position_by_id(tab_position_id(neighbor_index)) } - /// Swaps the group's entire member block with its above/below neighbor - /// when the dragged header's Y midpoint crosses the neighbor's midpoint. + /// Swaps the group's entire member block with its preceding/following + /// neighbor when the drag midpoint crosses the appropriate threshold on + /// the active axis. Per-axis thresholds match the per-tab swap logic: + /// vertical compares against the neighbor's midpoint (mirrors + /// `calculate_updated_tab_index_vertical`); horizontal compares against + /// the neighbor's leading/trailing edge (mirrors + /// `calculate_updated_tab_index`). pub(crate) fn on_group_drag( &mut self, group_id: TabGroupId, @@ -26881,20 +26960,47 @@ impl Workspace { let Some((first, last)) = group_member_index_range(&self.tabs, group_id) else { return; }; - let midpoint_drag_y = (position.min_y() + position.max_y()) / 2.; + let is_vertical = uses_vertical_tabs(ctx); + let midpoint_drag = if is_vertical { + (position.min_y() + position.max_y()) / 2. + } else { + (position.min_x() + position.max_x()) / 2. + }; + // Threshold for swapping toward the start of the bar (compared + // against the previous neighbor's rect). Vertical uses the + // neighbor's midpoint; horizontal uses its trailing edge so the + // swap fires as soon as the drag midpoint crosses into the + // neighbor, matching the per-tab horizontal swap rule. + let swap_before_threshold = |rect: RectF| -> f32 { + if is_vertical { + (rect.min_y() + rect.max_y()) / 2. + } else { + rect.max_x() + } + }; + // Threshold for swapping toward the end of the bar (compared + // against the next neighbor's rect). Vertical uses the neighbor's + // midpoint; horizontal uses its leading edge. + let swap_after_threshold = |rect: RectF| -> f32 { + if is_vertical { + (rect.min_y() + rect.max_y()) / 2. + } else { + rect.min_x() + } + }; - // Swap up: check the neighbor directly above the group's first member. + // Swap toward the start of the bar: check the neighbor directly + // before the group's first member. if first > 0 { - let above_index = first - 1; - if let Some(rect) = self.neighbor_drag_rect(above_index, ctx) { - let neighbor_midpoint = (rect.min_y() + rect.max_y()) / 2.; - if midpoint_drag_y < neighbor_midpoint { - let target = if let Some(other_gid) = self.tabs[above_index].group_id { + let before_index = first - 1; + if let Some(rect) = self.neighbor_drag_rect(before_index, is_vertical, ctx) { + if midpoint_drag < swap_before_threshold(rect) { + let target = if let Some(other_gid) = self.tabs[before_index].group_id { group_member_index_range(&self.tabs, other_gid) .map(|(f, _)| f) - .unwrap_or(above_index) + .unwrap_or(before_index) } else { - above_index + before_index }; self.move_group_block(group_id, target, ctx); return; @@ -26902,23 +27008,23 @@ impl Workspace { } } - // Swap down: check the neighbor directly below the group's last member. - // Pass `below_block_last + 1` (the pre-drain target index); `move_group_block` - // accounts for the drain internally when `target > last`. + // Swap toward the end of the bar: check the neighbor directly after + // the group's last member. Pass `after_block_last + 1` (the + // pre-drain target index); `move_group_block` accounts for the + // drain internally when `target > last`. if last + 1 < self.tabs.len() { - let below_index = last + 1; - if let Some(rect) = self.neighbor_drag_rect(below_index, ctx) { - let neighbor_midpoint = (rect.min_y() + rect.max_y()) / 2.; - if midpoint_drag_y > neighbor_midpoint { - let below_block_last = if let Some(other_gid) = self.tabs[below_index].group_id + let after_index = last + 1; + if let Some(rect) = self.neighbor_drag_rect(after_index, is_vertical, ctx) { + if midpoint_drag > swap_after_threshold(rect) { + let after_block_last = if let Some(other_gid) = self.tabs[after_index].group_id { group_member_index_range(&self.tabs, other_gid) .map(|(_, l)| l) - .unwrap_or(below_index) + .unwrap_or(after_index) } else { - below_index + after_index }; - self.move_group_block(group_id, below_block_last + 1, ctx); + self.move_group_block(group_id, after_block_last + 1, ctx); } } } @@ -27054,6 +27160,15 @@ fn group_member_index_range(tabs: &[TabData], group_id: TabGroupId) -> Option<(u Some((first, last)) } +/// Returns `true` when `group_id` has exactly one member in `tabs`. Shared +/// by both horizontal and vertical tab rendering to detect the "sole grouped +/// member" case, where the per-tab `Draggable` is suppressed so the parent +/// group's `Draggable` picks up the drag — dragging the only member of a +/// group drags the entire group rather than orphaning it. +pub(super) fn group_has_single_member(tabs: &[TabData], group_id: TabGroupId) -> bool { + group_member_index_range(tabs, group_id).is_some_and(|(first, last)| first == last) +} + /// Returns every tab-bar-equivalent rect laid out in `window_id` (horizontal /// tab bar and/or vertical tabs panel). Both must be considered because a /// window with vertical tabs still renders the horizontal bar at the top. diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index fa33643b2f..9458b6c6e7 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -138,6 +138,13 @@ pub(crate) fn vtab_group_position_id(group_id: TabGroupId) -> String { format!("vertical_tabs:group:{group_id:?}") } +/// Save-position id for a horizontal tab group's container rect, used for +/// drop hit-testing and as the collapsed-group fallback in horizontal-axis +/// drag math. +pub(crate) fn htab_group_position_id(group_id: TabGroupId) -> String { + format!("horizontal_tabs:group:{group_id:?}") +} + fn terminal_title_fallback_font(agent_text: &TerminalAgentText) -> TerminalPrimaryLineFont { if agent_text.cli_agent.is_some() { TerminalPrimaryLineFont::Ui @@ -2242,7 +2249,7 @@ fn render_tab_group_internal( let is_sole_group_member = in_tab_group && tab .group_id - .is_some_and(|gid| super::group_member_indices(&workspace.tabs, gid).count() == 1); + .is_some_and(|gid| super::group_has_single_member(&workspace.tabs, gid)); let draggable: Box = if is_parent_group_dragging || is_sole_group_member { group_element