Skip to content

gpui: Allow OS caption/buttons for custom Windows titlebar#48330

Merged
reflectronic merged 1 commit intozed-industries:mainfrom
momota1029:gpui/windows-nc-hit-test
Feb 9, 2026
Merged

gpui: Allow OS caption/buttons for custom Windows titlebar#48330
reflectronic merged 1 commit intozed-industries:mainfrom
momota1029:gpui/windows-nc-hit-test

Conversation

@momota1029
Copy link
Copy Markdown
Contributor

@momota1029 momota1029 commented Feb 4, 2026

Summary

Fixes an issue where GPUI's handling of WM_NCLBUTTONDOWN prevented Windows from processing default titlebar interactions (dragging, caption buttons, and border resize).

Changes

  • Allow OS to handle WM_NCLBUTTONDOWN events for HTCAPTION, caption button areas, and resize border areas (HTLEFT, HTRIGHT, HTTOP, HTBOTTOM, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT)
  • Use current cursor position in WM_NCHITTEST for accurate hit-testing

Testing

  • Manual testing on Windows: titlebar dragging works
  • Manual testing on Windows: minimize/maximize/close buttons work
  • Manual testing on Windows: window border resize works in all directions

Release Notes:

  • N/A

@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label Feb 4, 2026
@maxdeviant maxdeviant changed the title gpui: allow OS caption/buttons for custom Windows titlebar gpui: Allow OS caption/buttons for custom Windows titlebar Feb 4, 2026
@zed-industries-bot
Copy link
Copy Markdown
Contributor

Warnings
⚠️

This PR is missing release notes.

Please add a "Release Notes" section that describes the change:

Release Notes:

- Added/Fixed/Improved ...

If your change is not user-facing, you can use "N/A" for the entry:

Release Notes:

- N/A

Generated by 🚫 dangerJS against 2784926

@momota1029 momota1029 marked this pull request as draft February 5, 2026 08:22
@momota1029 momota1029 marked this pull request as ready for review February 5, 2026 08:22
@momota1029 momota1029 marked this pull request as draft February 5, 2026 08:23
@momota1029 momota1029 marked this pull request as ready for review February 5, 2026 08:23
@reflectronic
Copy link
Copy Markdown
Member

Can you explain the behavior you're seeing that you are trying to fix here? We already pass WM_NCLBUTTONDOWN events to the OS if they are not handled by the application. The caption buttons we use in Zed, for example (https://github.com/zed-industries/zed/blob/main/crates/platform_title_bar/src/platforms/platform_windows.rs), are not interactive elements, so handled is already false.

Is this for your own GPUI application? You should be able to make the caption buttons and title bar dragging work using a similar pattern as in the code I linked.

@momota1029
Copy link
Copy Markdown
Contributor Author

Yes, this is for my own GPUI application. Thank you for pointing me to the Zed caption button implementation — I was able to narrow down the issue further.

The problem is reproducible with just .track_focus() on a root element. When a focusable element receives a MouseDownEvent, the focus listener calls window.prevent_default() (to prevent parent focusables from stealing focus). This sets default_prevented = true on the DispatchEventResult.

In handle_nc_mouse_down_msg, the check is:

let handled = !result.propagate || result.default_prevented;

Since default_prevented is true, handled becomes true, and Some(0) is returned — DefWindowProcW is never called, so titlebar dragging and caption buttons stop working.

Zed's own titlebar works because platform_title_bar caption buttons are not focusable interactive elements, so handled stays false.

Here is a minimal reproduction. Save it as crates/gpui/examples/custom_titlebar.rs, add the [[example]] entry to crates/gpui/Cargo.toml, and run with cargo run -p gpui --example custom_titlebar. Removing the .track_focus() line makes everything work again.

use gpui::{
    App, Application, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds,
    WindowControlArea, WindowOptions, actions, div, prelude::*, px, rgb, size,
};

actions!(custom_titlebar, [Quit]);

struct Demo {
    focus_handle: FocusHandle,
}

impl Render for Demo {
    fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .id("root")
            .track_focus(&self.focus_handle) // <-- causes the bug
            .flex()
            .flex_col()
            .bg(rgb(0x1e1e1e))
            .text_color(rgb(0xdddddd))
            .size_full()
            .child(
                div()
                    .flex()
                    .flex_row()
                    .h(px(32.))
                    .bg(rgb(0x2d2d2d))
                    .child(
                        div()
                            .flex_1()
                            .h_full()
                            .flex()
                            .items_center()
                            .px_2()
                            .window_control_area(WindowControlArea::Drag)
                            .child("Drag here"),
                    )
                    .child(
                        div()
                            .id("minimize")
                            .flex().items_center().justify_center()
                            .w(px(36.)).h_full()
                            .hover(|s| s.bg(rgb(0x555555)))
                            .window_control_area(WindowControlArea::Min)
                            .child("\u{2500}"),
                    )
                    .child(
                        div()
                            .id("maximize")
                            .flex().items_center().justify_center()
                            .w(px(36.)).h_full()
                            .hover(|s| s.bg(rgb(0x555555)))
                            .window_control_area(WindowControlArea::Max)
                            .child(if window.is_maximized() { "\u{25A3}" } else { "\u{25A1}" }),
                    )
                    .child(
                        div()
                            .id("close")
                            .flex().items_center().justify_center()
                            .w(px(36.)).h_full()
                            .hover(|s| s.bg(rgb(0xe81123)))
                            .window_control_area(WindowControlArea::Close)
                            .child("\u{2715}"),
                    ),
            )
            .child(
                div().flex_1().p_4().child(
                    "Try dragging the titlebar or clicking the caption buttons.",
                ),
            )
    }
}

fn main() {
    Application::new().run(|cx: &mut App| {
        cx.on_action(|_: &Quit, cx| cx.quit());
        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
        cx.open_window(
            WindowOptions {
                titlebar: None,
                window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                    None, size(px(500.0), px(300.0)), cx,
                ))),
                ..Default::default()
            },
            |_, cx| cx.new(|cx| Demo { focus_handle: cx.focus_handle() }),
        )
        .unwrap();
        cx.activate(true);
    });
}

@momota1029 momota1029 force-pushed the gpui/windows-nc-hit-test branch from 2784926 to 3d3af76 Compare February 6, 2026 02:50
@reflectronic
Copy link
Copy Markdown
Member

I think the only change needed here, then, is:

-            let result = func(input);
-            let handled = !result.propagate || result.default_prevented;
+            let handled = !func(input).propagate;

I don't understand why default_prevented is in this condition and removing it doesn't seem to cause issues in Zed. The other handlers (e.g. handle_nc_mouse_up_msg) aren't written this way, either.

@momota1029 momota1029 force-pushed the gpui/windows-nc-hit-test branch from 3d3af76 to 2993d25 Compare February 9, 2026 19:46
@momota1029
Copy link
Copy Markdown
Contributor Author

Thanks, agreed.

I updated handle_nc_mouse_down_msg to use only propagate as the handled signal:

let handled = !func(input).propagate;

I re-tested with the track_focus() custom-titlebar repro, and titlebar dragging + caption buttons work correctly with this change.

I also reduced the PR to this minimal behavior change.

Copy link
Copy Markdown
Member

@reflectronic reflectronic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you

@reflectronic reflectronic merged commit fb0af50 into zed-industries:main Feb 9, 2026
43 of 45 checks passed
@Serophots
Copy link
Copy Markdown
Contributor

This change has introduced a regression (not in zed but in some GPUI apps) on Windows when you have a button in the navbar draggable area. Seems to work fine for MacOS.

I've modified the above example to include a button. On Windows, when clicked, the event handler is not called anymore.

use gpui::{
    App, Application, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds,
    WindowControlArea, WindowOptions, actions, div, prelude::*, px, rgb, size,
};

actions!(custom_titlebar, [Quit]);

struct Demo {
    focus_handle: FocusHandle,
}

impl Render for Demo {
    fn render(&mut self, window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .id("root")
            .track_focus(&self.focus_handle)
            .flex()
            .flex_col()
            .bg(rgb(0x1e1e1e))
            .text_color(rgb(0xdddddd))
            .size_full()
            .child(
                div()
                    .flex()
                    .flex_row()
                    .h(px(32.))
                    .bg(rgb(0x2d2d2d))
                    .child(
                        div()
                            .flex_1()
                            .h_full()
                            .flex()
                            .items_center()
                            .px_2()
                            .window_control_area(WindowControlArea::Drag)
                            .child("Drag here")
                            .child(
                                div()
                                    .child("Button here")
                                    .id("button")
                                    .cursor_pointer()
                                    .on_click(|_, _, _| {
                                        println!("CLICKED");
                                    }),
                            ),
                    )
                    .child(
                        div()
                            .id("minimize")
                            .flex()
                            .items_center()
                            .justify_center()
                            .w(px(36.))
                            .h_full()
                            .hover(|s| s.bg(rgb(0x555555)))
                            .window_control_area(WindowControlArea::Min)
                            .child("\u{2500}"),
                    )
                    .child(
                        div()
                            .id("maximize")
                            .flex()
                            .items_center()
                            .justify_center()
                            .w(px(36.))
                            .h_full()
                            .hover(|s| s.bg(rgb(0x555555)))
                            .window_control_area(WindowControlArea::Max)
                            .child(if window.is_maximized() {
                                "\u{25A3}"
                            } else {
                                "\u{25A1}"
                            }),
                    )
                    .child(
                        div()
                            .id("close")
                            .flex()
                            .items_center()
                            .justify_center()
                            .w(px(36.))
                            .h_full()
                            .hover(|s| s.bg(rgb(0xe81123)))
                            .window_control_area(WindowControlArea::Close)
                            .child("\u{2715}"),
                    ),
            )
            .child(
                div()
                    .flex_1()
                    .p_4()
                    .child("Try dragging the titlebar or clicking the caption buttons."),
            )
    }
}

fn main() {
    Application::new().run(|cx: &mut App| {
        cx.on_action(|_: &Quit, cx| cx.quit());
        cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
        cx.open_window(
            WindowOptions {
                titlebar: None,
                window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                    None,
                    size(px(500.0), px(300.0)),
                    cx,
                ))),
                ..Default::default()
            },
            |_, cx| {
                cx.new(|cx| Demo {
                    focus_handle: cx.focus_handle(),
                })
            },
        )
        .unwrap();
        cx.activate(true);
    });
}

naaiyy added a commit to Glass-HQ/Glass that referenced this pull request Feb 16, 2026
Key changes:
- Side-by-side diff UX improvements (zed-industries#48821) - major diff view polish
- Display map refactoring - large cleanup of display_map.rs (~1000 line reduction)
- Split editor growth (zed-industries#48753) - significant expansion of split.rs
- Multi-char folds fix (zed-industries#48721)
- New multi workspace (zed-industries#47795, then reverted zed-industries#48776)
- Default view mode setting for SplittableEditor (zed-industries#48440)
- macOS drag-drop fix: reset external_files_dragged (zed-industries#48727)
- Windows: OS caption/buttons for custom titlebar (zed-industries#48330)
- Windows timer resolution guard (zed-industries#48379)
- Bedrock Claude Opus 4.6 model (zed-industries#48525)
- MCP servers: fix disabled servers disappearing after restart (zed-industries#47758)
- Shell command parser extracted to shared crate (zed-industries#48660)
- Format-on-save for streaming edit file tool (zed-industries#48663)
- Agent: insert images at cursor position (zed-industries#48779)
- Project panel: improved file/folder creation in folded paths (zed-industries#46750)
- Folding ranges panic fix (zed-industries#48809)
- REPL: shutdown all kernels on app quit (zed-industries#48760)
- Extension CI improvements
- Security updates: time v0.3.47, git2 v0.20.4

Conflict resolution:
- collab (Cargo.toml, extensions API, db, tests): deleted
- GPUI (8 files): deleted from Glass (handled in Obsydian-HQ/gpui)
- Cargo.lock: took upstream

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Veykril added a commit that referenced this pull request Feb 17, 2026
reflectronic added a commit that referenced this pull request Feb 21, 2026
#48330 caused the title bar to
start eating input events below these controls. We should find a way to
make the title bar handling less busted, but this will do for now.

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects

Release Notes:

- N/A
github-actions Bot pushed a commit that referenced this pull request Feb 21, 2026
#48330 caused the title bar to
start eating input events below these controls. We should find a way to
make the title bar handling less busted, but this will do for now.

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects

Release Notes:

- N/A
github-actions Bot pushed a commit that referenced this pull request Feb 21, 2026
#48330 caused the title bar to
start eating input events below these controls. We should find a way to
make the title bar handling less busted, but this will do for now.

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects

Release Notes:

- N/A
zed-zippy Bot added a commit that referenced this pull request Feb 21, 2026
…) (cherry-pick to stable) (#49783)

Cherry-pick of #49781 to stable

----
#48330 caused the title bar to
start eating input events below these controls. We should find a way to
make the title bar handling less busted, but this will do for now.

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects

Release Notes:

- N/A

Co-authored-by: John Tur <[email protected]>
zed-zippy Bot added a commit that referenced this pull request Feb 21, 2026
…) (cherry-pick to preview) (#49782)

Cherry-pick of #49781 to preview

----
#48330 caused the title bar to
start eating input events below these controls. We should find a way to
make the title bar handling less busted, but this will do for now.

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects

Release Notes:

- N/A

Co-authored-by: John Tur <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants