Shell: a control in a drag region occludes it, so it takes its press on Windows, where gpui answers the hit test with the caption over any Drag hitbox left uncovered: window buttons and title bar slots occlude, and a column header's item row does while staying the scroll container, its title a drag region of its own; the mini window drags by its title alone, keeping its hover
CI / platforms (ubuntu-24.04) (push) Waiting to run
CI / site (push) Successful in 29s
CI / platforms (windows-latest) (push) Successful in 4m19s
CI / crate (push) Failing after 6m18s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-15 18:11:10 -04:00
1 parent 90bd37b550
commit cdc5ce8247
5 files changed
+168 -44

No files matched your search

+1
View File
@@ -66,6 +66,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
- gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`).
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
- A control inside a `drag_region` occludes it. On Windows gpui answers the system's hit test with the caption wherever a `Drag` hitbox lies under the pointer unoccluded, and Windows then keeps the press: no click reaches the control. `block_mouse_except_scroll` is not enough, since that answer reads every hitbox under the pointer. Window buttons occlude, which lets them answer as close, maximize and minimize, and so do a title bar's slots; a column header's item row occludes, staying the scroll container, and its title is a drag region of its own; the mini window drags by its title alone, so its buttons sit outside the region and its hover still shows the bar.
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
+41 -35
View File
@@ -102,11 +102,13 @@ impl WindowControls {
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.platform.unwrap_or(cx.theme().platform) {
let controls = match self.platform.unwrap_or(cx.theme().platform) {
Platform::Mac => traffic_lights(self.id, self.on_close, window, cx),
Platform::Windows => caption_buttons(self.id, self.on_close, window, cx),
Platform::Linux => round_buttons(self.id, self.on_close, window, cx),
}
};
// Windows takes a drag region under controls as caption.
controls.occlude()
}
}
@@ -115,7 +117,7 @@ fn traffic_lights(
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
let active = window.is_window_active();
@@ -154,7 +156,6 @@ fn traffic_lights(
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(glyph)
}))
.into_any_element()
}
fn caption_buttons(
@@ -162,7 +163,7 @@ fn caption_buttons(
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
let maximize = if window.is_maximized() {
@@ -207,7 +208,6 @@ fn caption_buttons(
.group_hover_color(group, glyph),
)
}))
.into_any_element()
}
fn round_buttons(
@@ -215,7 +215,7 @@ fn round_buttons(
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
let offered = window.window_controls();
@@ -224,32 +224,38 @@ fn round_buttons(
(Control::Maximize, IconName::Square, offered.maximize),
(Control::Close, IconName::X, true),
];
div()
.id(id)
.flex()
.items_center()
.gap_2()
.children(
buttons
.into_iter()
.filter(|(_, _, shown)| *shown)
.map(|(control, icon, _)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded_full()
.bg(colors.hover)
.hover(|style| style.bg(colors.active))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
}),
)
.into_any_element()
div().id(id).flex().items_center().gap_2().children(
buttons
.into_iter()
.filter(|(_, _, shown)| *shown)
.map(|(control, icon, _)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded_full()
.bg(colors.hover)
.hover(|style| style.bg(colors.active))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
}),
)
}
/// A slot's controls, occluding the drag region beneath.
fn slot(items: SmallVec<[AnyElement; 2]>) -> Option<Div> {
(!items.is_empty()).then(|| {
div()
.flex()
.items_center()
.gap_2()
.occlude()
.children(items)
})
}
/// Whether the system draws the window controls, and whose manner they take.
@@ -404,7 +410,7 @@ impl RenderOnce for TitleBar {
false => start.pl_3(),
})
.children(leading)
.children(self.leading),
.children(slot(self.leading)),
);
let end = div().flex().justify_end().child(
div()
@@ -412,7 +418,7 @@ impl RenderOnce for TitleBar {
.items_center()
.gap_2()
.when(centered, |end| end.pr_2())
.children(self.actions)
.children(slot(self.actions))
.children(trailing.map(|controls| {
div()
.flex_none()
+13 -5
View File
@@ -149,7 +149,7 @@ impl RenderOnce for ColumnShell {
items(
column.id.clone(),
column.leading,
title(column.title, cx),
title(column.id.clone(), column.title, window, cx),
column.actions,
)
.min_w(cx.theme().control_height(ControlSize::Lg))
@@ -212,7 +212,7 @@ fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful<gp
fn items(
id: ElementId,
leading: SmallVec<[AnyElement; 2]>,
title: gpui::Div,
title: gpui::Stateful<gpui::Div>,
actions: SmallVec<[AnyElement; 2]>,
) -> gpui::Div {
let group = || div().flex_none().flex().items_center().gap_2();
@@ -224,16 +224,24 @@ fn items(
.items_center()
.gap_2()
.overflow_x_scroll()
// Windows takes a drag region under controls as caption.
.occlude()
.child(group().children(leading))
.child(title)
.child(group().children(actions)),
)
}
/// The title, adding nothing to the least width; a spacer without one.
fn title(title: Option<SharedString>, cx: &App) -> gpui::Div {
/// Title or spacer: drags the window, adds no width.
fn title(
id: ElementId,
title: Option<SharedString>,
window: &mut Window,
cx: &mut App,
) -> gpui::Stateful<gpui::Div> {
let region = drag_region((id, "title"), window, cx);
let theme = cx.theme();
div().flex_1().min_w_0().h_full().relative().child(
region.flex_1().min_w_0().h_full().relative().child(
div()
.absolute()
.inset_0()
+102 -1
View File
@@ -3,7 +3,7 @@ use gpui::{
ParentElement, Point, Render, TestAppContext, Window, div, point, prelude::*, px,
};
use super::TitleBar;
use super::{ColumnShell, ShellColumn, TitleBar, WindowControls};
use crate::theme::{Platform, Theme};
struct Bar {
@@ -71,3 +71,104 @@ fn a_wobbling_press_on_the_bar_moves_the_window(cx: &mut TestAppContext) {
.expect("drawn controls");
wobble(point(controls.left() - px(200.0), controls.center().y), cx);
}
/// Counts presses that reach the box under the controls.
struct Under {
platform: Platform,
presses: usize,
}
impl Render for Under {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
div()
.id("under")
.flex()
.items_start()
.w_full()
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
view.update(cx, |under, _| under.presses += 1)
})
.child(
div()
.debug_selector(|| "controls".into())
.child(WindowControls::new("controls").platform(self.platform)),
)
}
}
/// Each style's controls hide what lies under them.
#[gpui::test]
fn window_controls_occlude_what_lies_under(cx: &mut TestAppContext) {
cx.update(Theme::init);
for platform in [Platform::Windows, Platform::Mac, Platform::Linux] {
let (view, cx) = cx.add_window_view(move |_, _| Under {
platform,
presses: 0,
});
cx.run_until_parked();
let controls = cx.debug_bounds("controls").expect("drawn controls");
cx.simulate_event(MouseDownEvent {
button: MouseButton::Left,
position: controls.center(),
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
});
assert_eq!(
view.read_with(cx, |under, _| under.presses),
0,
"{platform:?}"
);
}
}
/// Title bar and column header with inert slot items.
struct Slotted;
impl Render for Slotted {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let item = |name: &'static str| div().debug_selector(move || name.into()).size_6();
let column = ShellColumn::new("main")
.leading(item("shell-lead"))
.action(item("shell-act"));
div()
.size_full()
.flex()
.flex_col()
.child(
TitleBar::new("bar")
.platform(Platform::Windows)
.child(item("bar-lead"))
.action(item("bar-act")),
)
.child(
ColumnShell::new("shell")
.platform(Platform::Windows)
.column(column),
)
}
}
/// A press on a slot item never moves the window.
#[gpui::test]
fn a_wobbling_press_on_a_slot_moves_no_window(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Slotted);
cx.run_until_parked();
for name in ["bar-lead", "bar-act", "shell-lead", "shell-act"] {
let item = cx.debug_bounds(name).expect("a slot item");
wobble(item.center(), cx);
}
}
/// A column's title still moves the window; a move panics.
#[gpui::test]
#[should_panic(expected = "not implemented")]
fn a_wobbling_press_on_a_column_title_moves_the_window(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Slotted);
cx.run_until_parked();
let items = cx.debug_bounds("column-shell-items-0").expect("the items");
wobble(items.center(), cx);
}
+11 -3
View File
@@ -212,7 +212,7 @@ impl MiniWindow {
impl Render for MiniWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bar = drag_region("mini-bar", window, cx);
let drag = drag_region("mini-bar", window, cx);
let theme = cx.theme();
let back = self.back;
div()
@@ -225,7 +225,8 @@ impl Render for MiniWindow {
.font_family(theme.font_family.clone())
.child(self.view.clone())
.child(
bar.absolute()
div()
.absolute()
.top_0()
.left_0()
.right_0()
@@ -241,7 +242,14 @@ impl Render for MiniWindow {
.text_color(theme.colors.fg_muted)
.invisible()
.group_hover("mini-window", |style| style.visible())
.child(div().flex_1().child(self.title.clone()))
// Only the title drags: Windows takes the region as caption.
.child(
drag.flex_1()
.h_full()
.flex()
.items_center()
.child(self.title.clone()),
)
.when_some(back, |bar, back| {
bar.child(
IconButton::new("mini-expand", IconName::Maximize2)