Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
234 changes: 226 additions & 8 deletions crates/bitty-runtime/src/runtime/workspaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,13 @@ impl Runtime {
if self.focus.focused() == Some(id) {
if let Some(first) = self.layout.leaf_ids().into_iter().next() {
self.focus = Focus::with_focus(first);
} else if let Some(next) = self.pinned.ids().last().copied() {
// CTX-1337 (#2222): discarding one pin over an empty base
// must hand focus to the topmost surviving pin. Clearing
// focus here left a painted pin untypable.
self.focus = Focus::with_focus(next);
} else if let Some(next) = self.floats.paint_ids().last().copied() {
self.focus = Focus::with_focus(next);
} else {
self.focus.clear();
}
Expand All @@ -680,6 +687,39 @@ impl Runtime {
true
}

/// Retires the live layout's sole tiled leaf after its shell closed while
/// a pinned panel survives (issue #2222 / CTX-1337).
///
/// Pinned panels are window-global and live outside every workspace
/// layout (CTX-1077), so unlike a workspace-local float they never cover
/// the tiled base. A closed last tiled panel therefore leaves an empty
/// base that the pin composites over; keeping the session-less leaf
/// would strand a ghost cursor and drop input (the #2221 family). Remove
/// the leaf and bind both focus and primary-grid ownership to the
/// topmost pin — the accepted focus/primary states for a composited view
/// outside the layout (`Runtime::set_focus`, `discard_pinned`).
///
/// Returns the surviving pin, or `None` with state untouched when
/// `closed` is not the sole live leaf or no pin survives.
pub fn retire_sole_tiled_leaf_for_pinned(&mut self, closed: ViewId) -> Option<ViewId> {
let leaves = self.layout.leaf_ids();
if leaves.len() != 1 || leaves[0] != closed {
return None;
}
let survivor = self.pinned.ids().last().copied()?;
let mut layout = self.layout.clone();
layout.remove_leaf(closed)?;
// Install the empty base through the explicit-close funnel: it
// re-homes the primary owner that left the tree and clears the
// layout-derived focus. The pin is re-bound immediately below.
self.set_layout_closing(layout, closed);
let focused = self.set_focus(survivor);
debug_assert!(focused, "pinned survivor must be focusable");
self.primary_view = Some(survivor);
self.sync_primary_geometry();
Some(survivor)
}

/// Discards `id` from whichever floating store holds it (issue #2141).
///
/// Returns `true` when `id` was a detached float or pinned leaf.
Expand Down Expand Up @@ -1058,17 +1098,42 @@ impl Runtime {
}
// CTX-1077: the stashed focus may name a leaf that left every layout
// since (pinned, then unpinned into another workspace, then switched
// back here). Clamp to the first live leaf so focus never dangles
// behind the read guard; a still-pinned focus is kept as-is.
// back here). Clamp to a live view of this slot so focus never
// dangles behind the read guard; a still-pinned focus is kept as-is.
// CTX-1147: a focus naming a detached float of the loaded slot is
// kept the same way (floats ride the slot like layout leaves).
if self.focus.focused().is_some_and(|id| {
!self.layout.leaf_ids().contains(&id)
&& !self.pinned.contains(id)
&& !self.floats.contains(id)
// CTX-1337 (#2222): an empty base can legitimately hold only a pin,
// but once that pin was unpinned into another workspace it is no
// longer live here. Clear the stale focus and the primary ownership
// it carried; otherwise input from the empty surface would route
// into a hidden foreign shell.
if let Some(focused) = self.focus.focused().filter(|id| {
!self.layout.leaf_ids().contains(id)
&& !self.pinned.contains(*id)
&& !self.floats.contains(*id)
}) {
if let Some(first) = self.layout.leaf_ids().into_iter().next() {
self.focus = Focus::with_focus(first);
let replacement = self
.layout
.leaf_ids()
.into_iter()
.next()
.or_else(|| self.floats.paint_ids().last().copied())
.or_else(|| self.pinned.ids().last().copied());
match replacement {
Some(next) => {
self.focus = Focus::with_focus(next);
if self.primary_view == Some(focused) {
self.primary_view = Some(next);
}
}
None => {
self.focus.clear();
// No live view is left in this slot, so the runtime-global
// primary has no surface to paint here. Leaving it on a
// foreign leaf would make `route_input_bytes` fall back
// into a hidden shell from the empty surface.
self.primary_view = None;
}
}
}
if let Some(focused) = self.focus.focused() {
Expand Down Expand Up @@ -4376,6 +4441,159 @@ mod tests {
assert_eq!(rt.focused_view(), Some(second));
}

#[test]
fn retire_sole_tiled_leaf_for_pinned_issue_2222() {
// Issue #2222: an empty tiled base with a surviving pin is a valid
// composited state. The dead leaf retires and the pin becomes both
// focus and primary owner; without a pin the call is a no-op.
let mut rt = fresh();
let host = rt.focused_view().expect("focus");
assert!(
rt.retire_sole_tiled_leaf_for_pinned(host).is_none(),
"no pin survivor: state untouched"
);
assert_eq!(rt.layout().leaf_ids(), vec![host]);

let pinned = ViewId::new(2);
let leaf = rt.layout().find_leaf(host).cloned().expect("leaf");
rt.set_layout(LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(leaf),
LayoutNode::leaf(View::new(pinned, 40, 24)),
));
assert!(rt.set_focus(pinned));
rt.pin_floating(pinned).expect("pin");
assert_eq!(rt.pinned_views(), vec![pinned]);
assert_eq!(rt.layout().leaf_ids(), vec![host]);
assert_eq!(rt.focused_view(), Some(host));
assert_eq!(rt.primary_view(), Some(host));

assert_eq!(
rt.retire_sole_tiled_leaf_for_pinned(host),
Some(pinned),
"the pin survives as the empty base's owner"
);
assert!(
rt.layout().leaf_ids().is_empty(),
"the dead host leaf must be removed"
);
assert_eq!(rt.focused_view(), Some(pinned));
assert_eq!(rt.primary_view(), Some(pinned));
assert!(
rt.retire_sole_tiled_leaf_for_pinned(host).is_none(),
"already retired: no second mutation"
);
}

#[test]
fn load_empty_base_clears_foreign_focus_after_cross_workspace_unpin_issue_2222() {
// Issue #2222 review F1: the pin-only base in ws1 names the pin as
// focus. Unpinning it into ws2 makes it a ws2 leaf; switching back to
// ws1 must not keep focus/primary on that foreign view, or empty-
// surface input would route into the hidden ws2 shell.
let mut rt = fresh();
let host = rt.focused_view().expect("focus");
let pinned = ViewId::new(2);
let leaf = rt.layout().find_leaf(host).cloned().expect("leaf");
rt.set_layout(LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(leaf),
LayoutNode::leaf(View::new(pinned, 40, 24)),
));
assert!(rt.set_focus(pinned));
rt.pin_floating(pinned).expect("pin");
assert_eq!(rt.layout().leaf_ids(), vec![host]);
assert_eq!(
rt.retire_sole_tiled_leaf_for_pinned(host),
Some(pinned),
"ws1 reaches the pin-only empty base"
);
assert_eq!(rt.layout().leaf_ids(), Vec::new());
assert_eq!(rt.focused_view(), Some(pinned));
assert_eq!(rt.primary_view(), Some(pinned));

rt.workspace_new().expect("second workspace opens");
assert_eq!(rt.workspace_count(), 2);
assert_eq!(rt.active_workspace_index(), 1);
assert!(rt.set_focus(pinned), "the composited pin is focusable");
assert_eq!(rt.unpin_floating(pinned), Ok(pinned));
assert!(rt.layout().leaf_ids().contains(&pinned));
assert!(rt.pinned_views().is_empty());

assert!(rt.workspace_switch(0));
assert_eq!(
rt.layout().leaf_ids(),
Vec::new(),
"ws1 still holds the pin-only empty base"
);
assert_eq!(rt.focused_view(), None, "foreign pin leaves no focus");
assert_eq!(
rt.primary_view(),
None,
"empty surface must not keep a foreign primary owner"
);
assert!(
!rt.push_input_bytes(b"x"),
"empty-surface input must not reach the hidden ws2 shell"
);
}

#[test]
fn discard_pinned_falls_back_to_surviving_pin_on_empty_base_issue_2222() {
// Issue #2222 review F2: with two pins over an empty base, closing
// the focused pin must hand focus and primary to the topmost survivor
// instead of clearing both and leaving a painted pin untypable.
let mut rt = fresh();
let host = rt.focused_view().expect("focus");
let first_pin = ViewId::new(2);
let second_pin = ViewId::new(3);
let leaf = rt.layout().find_leaf(host).cloned().expect("leaf");
rt.set_layout(LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(leaf),
LayoutNode::split(
SplitAxis::Horizontal,
0.5,
LayoutNode::leaf(View::new(first_pin, 40, 24)),
LayoutNode::leaf(View::new(second_pin, 40, 24)),
),
));
assert!(rt.set_focus(first_pin));
rt.pin_floating(first_pin).expect("first pin");
assert!(rt.set_focus(second_pin));
rt.pin_floating(second_pin).expect("second pin");
assert_eq!(rt.pinned_views(), vec![first_pin, second_pin]);
assert_eq!(rt.layout().leaf_ids(), vec![host]);
assert_eq!(
rt.retire_sole_tiled_leaf_for_pinned(host),
Some(second_pin),
"topmost pin owns the empty base"
);
assert_eq!(rt.focused_view(), Some(second_pin));
assert_eq!(rt.primary_view(), Some(second_pin));

assert!(rt.discard_pinned(second_pin));
assert_eq!(rt.pinned_views(), vec![first_pin]);
assert_eq!(
rt.focused_view(),
Some(first_pin),
"the surviving pin must keep focus"
);
assert_eq!(
rt.primary_view(),
Some(first_pin),
"the surviving pin must keep primary ownership"
);

assert!(rt.discard_pinned(first_pin));
assert!(rt.layout().leaf_ids().is_empty());
assert_eq!(rt.focused_view(), None, "no live view remains");
assert_eq!(rt.primary_view(), None);
}

#[test]
fn window_leaf_count_covers_floats_pins_and_inactive_floats_issue_2141() {
let mut rt = fresh();
Expand Down
Loading
Loading