Skip to content
78 changes: 76 additions & 2 deletions crates/base/src/dock/dock_area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ pub struct DockArea {
tiles: HashMap<NodeId, Cached<TilesState>>,
panels: HashMap<PanelId, Arc<dyn PanelView>>,

/// Tab-group leaf rects, recorded in `on_prepaint`, for host-painted
/// spatial overlays. Pruned to live nodes on `reconcile`.
node_bounds: HashMap<NodeId, Bounds<Pixels>>,

locked: bool,
zoomed: Option<Zoomed>,
focus_handle: FocusHandle,
Expand Down Expand Up @@ -170,6 +174,7 @@ impl DockArea {
splits: HashMap::new(),
tiles: HashMap::new(),
panels: HashMap::new(),
node_bounds: HashMap::new(),
locked: false,
zoomed: None,
focus_handle: cx.focus_handle(),
Expand Down Expand Up @@ -209,6 +214,12 @@ impl DockArea {
self.bounds
}

/// Last-rendered rect of tab-group leaf `node`, or `None` if it is not a
/// rendered tab group.
pub fn node_bounds(&self, node: NodeId) -> Option<Bounds<Pixels>> {
self.node_bounds.get(&node).copied()
}

/// The tree for one region, or `None` for a dock that does not exist.
///
/// The `Option` is in the signature rather than hidden behind a panic
Expand Down Expand Up @@ -598,6 +609,11 @@ impl DockArea {
window: &mut Window,
cx: &mut Context<Self>,
) {
// A panel this area does not own (e.g. dropped from a nested dock) has
// no backing entity here; inserting it would strand a ghost tab.
if self.panel(panel).is_none() {
return;
}
let Some(destination) = self.placement_of_node(target_node(&target)) else {
return;
};
Expand Down Expand Up @@ -681,7 +697,10 @@ impl DockArea {
self.commit(result, window, cx);
}

fn remove_panel_id(&mut self, panel: PanelId, window: &mut Window, cx: &mut Context<Self>) {
/// Remove a panel by id. Unlike [`Self::remove_panel`] this needs no live
/// `Entity`, so it can close a panel held only by `PanelId` (e.g. an
/// unresolved `InvalidPanel` leaf from a restored layout).
pub fn remove_panel_id(&mut self, panel: PanelId, window: &mut Window, cx: &mut Context<Self>) {
let Some(region) = self.placement_of_panel(panel) else {
return;
};
Expand Down Expand Up @@ -1089,6 +1108,8 @@ impl DockArea {
self.groups.retain(|node, _| live_nodes.contains(node));
self.splits.retain(|node, _| live_nodes.contains(node));
self.tiles.retain(|node, _| live_nodes.contains(node));
// A removed leaf must report no bounds, not a stale rect.
self.node_bounds.retain(|node, _| live_nodes.contains(node));

let departed: Vec<Arc<dyn PanelView>> = self
.panels
Expand Down Expand Up @@ -1499,7 +1520,23 @@ impl DockArea {
.into_any_element()
}
PaneRef::Tabs { .. } => match self.groups.get(&node.id()) {
Some(cached) => cached.entity.clone().into_any_element(),
Some(cached) => {
// Wrapper records the group's rect for spatial overlays; it
// only holds bounds, so sizing stays on `resizable_panel`.
let node_id = node.id();
let area = self.this.clone();
// The probe must precede the content child so it measures
// the wrapper's origin, not a point below it.
div()
.size_full()
.on_prepaint(move |bounds, _, cx| {
_ = area.update(cx, |area, _| {
area.node_bounds.insert(node_id, bounds);
});
})
.child(cached.entity.clone())
.into_any_element()
}
None => Empty.into_any_element(),
},
PaneRef::Tiles { .. } => match self.tiles.get(&node.id()) {
Expand Down Expand Up @@ -2653,6 +2690,21 @@ mod tests {
);
}

/// Moving a panel this area does not own is a no-op, not a ghost insert.
#[gpui::test]
fn a_move_of_an_unowned_panel_is_ignored(cx: &mut TestAppContext) {
let log = Log::default();
let (area, _panels, cx) = one_group(&log, &["Alpha", "Beta"], None, cx);
let group = child_node(&area, 0, cx);
let before = cx.read(|cx| area.read(cx).dump(cx));

// A PanelId from nowhere, as if dropped from another DockArea.
move_panel_into(&area, PanelId::from_u64(9_999_999), group, None, true, cx);

let after = cx.read(|cx| area.read(cx).dump(cx));
assert_eq!(before, after, "an unowned panel move must not touch the tree");
}

/// The other drop geometry: a placement whose axis differs from the
/// parent's wraps the target in a fresh split, so the sizes are decided
/// by a `ResizableState` that has never been measured.
Expand Down Expand Up @@ -4709,4 +4761,26 @@ mod tests {
"the area must not fill itself with a group that never zoomed"
);
}

/// `remove_panel_id` drops the panel named only by its `PanelId`.
#[gpui::test]
fn remove_panel_id_drops_the_panel_it_names(cx: &mut TestAppContext) {
let log = Log::default();
let (area, alpha, cx) = two_groups(&log, cx);
let alpha_id = panel_id_of(&alpha);
assert!(
cx.read(|cx| area.read(cx).panel(alpha_id).is_some()),
"alpha starts owned by the area"
);

cx.update(|window, cx| {
area.update(cx, |area, cx| area.remove_panel_id(alpha_id, window, cx));
});
cx.run_until_parked();

assert!(
cx.read(|cx| area.read(cx).panel(alpha_id).is_none()),
"remove_panel_id removes the panel identified only by its id"
);
}
}
14 changes: 12 additions & 2 deletions crates/base/src/dock/tab_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ impl TabGroup {
zoomed: self.zoomed,
collapsed: self.constraints.is_collapsed(),
closable: self.is_closable(cx),
close_permitted: self.constraints.is_closable(),
locked: self.is_locked(),
draggable: self.draggable(cx),
droppable: self.droppable(),
Expand Down Expand Up @@ -784,6 +785,7 @@ pub struct TabGroupContext {
draggable: bool,
droppable: bool,
closable: bool,
close_permitted: bool,
drop_indicator: Option<DropIndicator>,
on_select_tab: SelectTabHandler,
on_close: ClosePanelHandler,
Expand Down Expand Up @@ -827,12 +829,20 @@ impl TabGroupContext {
self.collapsed
}

/// Whether closing the displayed panel is allowed at all, so a skin knows
/// whether to offer a Close control.
/// Whether the *active* panel can be closed. For a per-tab control use
/// [`Self::is_close_permitted`] with the tab's own [`PanelView::closable`].
pub fn is_closable(&self) -> bool {
self.closable
}

/// Whether the container permits closing panels at all -- the group-level
/// half of [`TabGroup::close_panel`]'s gate, before the per-panel check.
/// False for a dock's last group. Combine with [`Self::is_draggable`] and
/// the tab's own `closable` for a per-tab close control.
pub fn is_close_permitted(&self) -> bool {
self.close_permitted
}

pub fn is_locked(&self) -> bool {
self.locked
}
Expand Down
37 changes: 35 additions & 2 deletions crates/base/src/input/base/native.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#[cfg(target_os = "macos")]
mod macos {
use std::{cell::RefCell, collections::HashMap, mem, ptr, sync::Once};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
mem, ptr,
sync::Once,
};

use gpui::Window;
use objc2::{
Expand All @@ -17,6 +22,10 @@ mod macos {
thread_local! {
static CONTENT_TYPES: RefCell<HashMap<usize, Retained<NSString>>> =
RefCell::new(HashMap::new());

/// Windows whose raw `window_handle()` has no view, cached so the
/// panicking probe below runs once per window, not once per frame.
static HANDLE_UNAVAILABLE: RefCell<HashSet<gpui::WindowId>> = RefCell::new(HashSet::new());
}

pub fn set_text_content_type(window: &Window, content_type: Option<&str>) {
Expand Down Expand Up @@ -44,7 +53,31 @@ mod macos {
}

fn ns_view(window: &Window) -> Option<&AnyObject> {
let handle = HasWindowHandle::window_handle(window).ok()?;
// A test window's raw `window_handle()` panics instead of returning
// `Err`. Probe under `catch_unwind` once per window, caching failures;
// this runs every focused frame. `Window::window_handle` is a separate,
// non-panicking call, safe as the cache key.
let window_id = window.window_handle().window_id();
if HANDLE_UNAVAILABLE.with(|seen| seen.borrow().contains(&window_id)) {
return None;
}
let handle = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
HasWindowHandle::window_handle(window)
})) {
Ok(Ok(handle)) => handle,
// A panic is expected on a test window; on a real one it is a
// genuine failure, so warn once instead of swallowing it.
other => {
let first = HANDLE_UNAVAILABLE.with(|seen| seen.borrow_mut().insert(window_id));
if first && matches!(other, Err(_)) {
tracing::warn!(
?window_id,
"window handle probe panicked; skipping native text-content wiring"
);
}
return None;
}
};
let RawWindowHandle::AppKit(handle) = handle.as_raw() else {
return None;
};
Expand Down
9 changes: 8 additions & 1 deletion crates/base/src/macos_accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ extern "C" fn hit_test_forwarder(this: &NSWindow, _cmd: Sel, point: NSPoint) ->
}

fn ns_view(window: &Window) -> Option<&NSView> {
let handle = HasWindowHandle::window_handle(window).ok()?;
// A test window's `window_handle()` panics instead of returning `Err`.
// Probe only that call under `catch_unwind` (the forwarder is then not
// installed); kept narrow so a real failure below still surfaces.
let handle = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
HasWindowHandle::window_handle(window).ok()
}))
.ok()
.flatten()?;
let RawWindowHandle::AppKit(handle) = handle.as_raw() else {
return None;
};
Expand Down
119 changes: 118 additions & 1 deletion crates/component/src/dock/dock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ mod tests {
use gpui_base::dock::DockAreaRenderer;

use crate::dock::{
DockArea, DockLayout, DockPlacement, DockSkin,
DockArea, DockLayout, DockPlacement, DockSkin, PaneRef,
test_support::{MeasuredProbe, SizedProbe},
};

Expand Down Expand Up @@ -455,4 +455,121 @@ mod tests {
);
assert_eq!(left, Some(px(240.)), "the left dock follows the pointer");
}

/// `node_bounds` records each center leaf's rect for host-painted overlays.
/// Regression guard for the `on_prepaint` probe order: the probe must
/// precede the pane content, or `origin.y` picks up the content's height and
/// any overlay lands off-screen. Asserts the two leaves are top-anchored and
/// tiled.
#[gpui::test]
fn node_bounds_capture_leaf_rects_at_the_docks_top(cx: &mut TestAppContext) {
cx.update(|cx| crate::init(cx));
let (area, cx) = cx.add_window_view(|window, cx| {
DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
});
cx.simulate_resize(size(px(800.), px(600.)));
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.set_center(
DockLayout::h_split()
.child(DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)), None)
.child(DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)), None),
window,
cx,
);
});
});
cx.run_until_parked();
// Force a real paint so the leaves' `on_prepaint` probes fire.
cx.update(|window, cx| window.draw(cx).clear(cx));

let (left_id, right_id) = cx.update(|_, cx| {
let area = area.read(cx);
let tree = area.layout(DockPlacement::Center).expect("a center tree");
let PaneRef::Split { children, .. } = tree.root().kind() else {
panic!("the center root is a horizontal split");
};
(children[0].id(), children[1].id())
});

let (left, right) = cx.update(|_, cx| {
let area = area.read(cx);
(
area.node_bounds(left_id).expect("left leaf rect captured during paint"),
area.node_bounds(right_id).expect("right leaf rect captured during paint"),
)
});

// Two leaves side by side: a shared top edge and height, tiled along x.
assert_eq!(left.origin.y, right.origin.y, "the two leaves share a top edge");
assert_eq!(left.size.height, right.size.height, "the two leaves are the same height");
assert!(left.size.height > px(0.), "the leaf has a real height");
assert!(left.origin.x < right.origin.x, "the left leaf sits left of the right");

// Top-anchored: the probe recorded the wrapper's origin, not a static
// position below the content. The regression set `origin.y` to the pane
// height, pushing the rect (and any overlay) off the bottom of the view.
assert!(
left.origin.y < px(1.),
"the leaf rect starts at the dock's top; got origin.y {:?}",
left.origin.y,
);
assert!(
left.origin.y + left.size.height <= px(601.),
"the leaf rect fits inside the 600px window; got origin.y {:?} + height {:?}",
left.origin.y,
left.size.height,
);
}

/// A removed leaf reports `None`, not the rect it was last drawn with.
#[gpui::test]
fn node_bounds_drops_a_removed_leaf(cx: &mut TestAppContext) {
cx.update(|cx| crate::init(cx));
let (area, cx) = cx.add_window_view(|window, cx| {
DockArea::new("test", None, window, cx).with_renderer(DockSkin::new(cx))
});
cx.simulate_resize(size(px(800.), px(600.)));
cx.update(|window, cx| {
area.update(cx, |area, cx| {
area.set_center(
DockLayout::h_split()
.child(DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)), None)
.child(DockLayout::tabs().panel(MeasuredProbe::new(Rc::default(), cx)), None),
window,
cx,
);
});
});
cx.run_until_parked();
cx.update(|window, cx| window.draw(cx).clear(cx));

let (right_id, right_panel) = cx.update(|_, cx| {
let area = area.read(cx);
let tree = area.layout(DockPlacement::Center).expect("a center tree");
let PaneRef::Split { children, .. } = tree.root().kind() else {
panic!("the center root is a horizontal split");
};
let right = &children[1];
let PaneRef::Tabs { panels, .. } = right.kind() else {
panic!("the right child is a tab group");
};
(right.id(), panels[0])
});

assert!(
cx.update(|_, cx| area.read(cx).node_bounds(right_id).is_some()),
"the right leaf's rect is captured while it is on screen"
);

cx.update(|window, cx| {
area.update(cx, |area, cx| area.remove_panel_id(right_panel, window, cx));
});
cx.run_until_parked();

assert!(
cx.update(|_, cx| area.read(cx).node_bounds(right_id).is_none()),
"a removed leaf reports no bounds, not the rect it was last drawn with"
);
}
}
Loading
Loading