Find a file
Philipp Schaffrath 546a16d64f
gpui: Add parent-anchored native popup windows (with wayland xdg_popup implementation only so far) (#60232)
# Objective

gpui can't show UI that extends past the window it belongs to. Menus,
dropdowns and tooltips are drawn as elements inside the window, so they
clip at its edges. This PR adds a window kind for platform-native popups
anchored to a parent window, as groundwork for real native menus,
dropdowns and tooltips.

## Solution

`WindowKind::AnchoredPopup(PopupOptions)` opens a popup positioned
relative to a parent window. Instead of giving the popup an absolute
position, you describe where it should go and the platform figures out
the rest:

- `parent`: the window to anchor to
- `anchor_rect`: a rectangle in the parent, e.g. the button that opened
the menu
- `anchor` and `gravity`: which point of that rect to attach to, and
which direction to grow
- `constraint_adjustment`: what the platform may do if the popup would
leave the screen (slide, flip, resize)
- `grab`: menu behavior, the popup takes focus and is dismissed when
clicking outside the app

The popup's size comes from `WindowOptions::window_bounds`.

This model mirrors Wayland's `xdg_positioner`, where the compositor owns
positioning and the client can only describe intent. Since that's the
most restrictive case, the other platforms can implement the same
description later with simple math against screen bounds.

Only Wayland is implemented so far, via `xdg_popup` on top of the
existing surface implementation. Popups can be parented to toplevels,
layer-shell surfaces (a menu opened from a panel) and other popups
(nested menus). macOS, Windows, X11 and web reject the kind with
`PopupNotSupportedError`, so callers can detect that and fall back to
in-window popovers.

Some Wayland details that might help during review:

- Anchor rects are translated from gpui coordinates into the parent's
window geometry space and clamped to it. A rect outside the geometry, or
with zero size, is a fatal protocol error
- Resizing a mapped popup goes through `xdg_popup.reposition`
- Mouse press serials are now recorded on press only, not release.
Compositors decline grabs and interactive moves that reference a release
serial

## Testing

Tested manually on Wayland with an example app: the menu opens anchored
below its button, extends past the parent window, flips above the button
near the bottom of the screen, and a grabbing popup is dismissed when
clicking into another application.

Nested menus were tested in one of my projects (ignore that they are
ugly, that's just a prototype 😛):


https://github.com/user-attachments/assets/2cd3e2e9-87f7-4b02-986f-48e5633e205c




I also have a complete runnable example demonstrating it. I did not add
it to the PR, because this might give the impression that
`WindowKind::AnchoredPopup` are a complete implementation, despite only
working on wayland so far:

<details>
  <summary>Click to view example</summary>

```rust
//! Example and manual test for platform-native popups (`WindowKind::AnchoredPopup`).
//!
//! A native popup is a real, parent-anchored window that can extend beyond its parent onto the
//! screen, unlike gpui's in-window popovers. Run it, open the menu, and confirm the points listed
//! in the window. On a platform without an implementation the button reports that popups are not
//! supported instead of opening anything.
//!
//! Run with: cargo run -p gpui --example popup

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{
    AnyWindowHandle, App, Bounds, Context, MouseButton, SharedString, Window, WindowBounds,
    WindowHandle, WindowKind, WindowOptions, div, point, popup::*, prelude::*, px, rgb, size,
};
use gpui_platform::application;

/// The trigger button, at a fixed position so the popup can anchor to a known rectangle. Real code
/// would anchor to the measured bounds of whatever element opens the popup.
const BUTTON_BOUNDS: Bounds<gpui::Pixels> = Bounds {
    origin: point(px(24.), px(24.)),
    size: size(px(200.), px(32.)),
};

const POPUP_SIZE: gpui::Size<gpui::Pixels> = size(px(260.), px(320.));

struct Menu;

impl Render for Menu {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let item = |label: &str| {
            div()
                .id(label.to_string())
                .px_3()
                .py_1()
                .rounded_sm()
                .hover(|this| this.bg(rgb(0x3a3a3a)))
                .cursor_pointer()
                .child(label.to_string())
                .on_click(|_, window, _| window.remove_window())
        };

        div()
            .id("menu-root")
            .size_full()
            .p_1()
            .flex()
            .flex_col()
            .gap_0p5()
            .bg(rgb(0x2a2a2a))
            .text_color(gpui::white())
            .rounded_md()
            .border_1()
            .border_color(rgb(0x454545))
            .child(item("Foo"))
            .child(item("Bar"))
            .child(item("Baz"))
            .child(item("Qux"))
            .child(item("Alice"))
            .child(item("Bob"))
    }
}

struct PopupExample {
    menu: Option<WindowHandle<Menu>>,
    status: SharedString,
}

impl Default for PopupExample {
    fn default() -> Self {
        Self {
            menu: None,
            status: "Click \"Open menu\" to open a native popup.".into(),
        }
    }
}

impl PopupExample {
    /// Closes the menu if it is open. Returns true if a menu was actually open.
    fn close_menu(&mut self, cx: &mut App) -> bool {
        match self.menu.take() {
            Some(menu) => menu
                .update(cx, |_, window, _| window.remove_window())
                .is_ok(),
            None => false,
        }
    }

    fn toggle_menu(&mut self, parent: AnyWindowHandle, cx: &mut App) {
        if self.close_menu(cx) {
            return;
        }
        match open_menu(parent, cx) {
            Ok(menu) => {
                self.menu = Some(menu);
                self.status = "Menu open. Dismiss it by selecting an item, clicking elsewhere in \
                    this window, or clicking another application."
                    .into();
            }
            // A real application would fall back to an in-window popover here.
            Err(error) => {
                self.status = format!("Failed to open a native popup: {error}").into();
                log::error!("failed to open popup: {error}");
            }
        }
    }
}

impl Render for PopupExample {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let bullet = |text: &str| div().child(format!("• {text}"));

        div()
            .id("root")
            .size_full()
            .bg(rgb(0xf7f7f7))
            .text_color(rgb(0x222222))
            // Same-app clicks don't auto-dismiss a grabbing popup (see `PopupOptions::grab`).
            .on_mouse_down(
                MouseButton::Left,
                cx.listener(|this, _, _window, cx| {
                    this.close_menu(cx);
                }),
            )
            .child(
                div()
                    .size_full()
                    .p_5()
                    .pt(px(76.))
                    .flex()
                    .flex_col()
                    .gap_3()
                    .child(div().text_xl().child("Native popup test"))
                    .child(div().text_sm().child(
                        "WindowKind::AnchoredPopup opens a real, parent-anchored window that can \
                         extend past this window onto the screen. Only some platforms implement \
                         it so far.",
                    ))
                    .child(
                        div()
                            .flex()
                            .flex_col()
                            .gap_1()
                            .text_sm()
                            .text_color(rgb(0x555555))
                            .child(div().child("Verify:"))
                            .child(bullet("The menu opens anchored below the button."))
                            .child(bullet(
                                "The menu extends past the bottom edge of this window.",
                            ))
                            .child(bullet(
                                "Near the bottom of the screen, the menu flips above the button.",
                            ))
                            .child(bullet("Clicking another application dismisses the menu."))
                            .child(bullet(
                                "Selecting an item or clicking in this window dismisses it.",
                            )),
                    )
                    .child(
                        div()
                            .text_sm()
                            .text_color(rgb(0x333333))
                            .child(self.status.clone()),
                    ),
            )
            .child(
                div()
                    .absolute()
                    .left(BUTTON_BOUNDS.origin.x)
                    .top(BUTTON_BOUNDS.origin.y)
                    .w(BUTTON_BOUNDS.size.width)
                    .h(BUTTON_BOUNDS.size.height)
                    .flex()
                    .items_center()
                    .justify_center()
                    .bg(rgb(0xffffff))
                    .border_1()
                    .border_color(rgb(0xd0d0d0))
                    .rounded_md()
                    .cursor_pointer()
                    .id("open-menu")
                    .active(|this| this.bg(rgb(0xeeeeee)))
                    .child("Open menu ▾")
                    // Open on mouse-down, not on click, so the grab is taken while the button is still held.
                    .on_mouse_down(
                        MouseButton::Left,
                        cx.listener(|this, _, window, cx| {
                            // Don't let the window handler above close the menu we are opening.
                            cx.stop_propagation();
                            this.toggle_menu(window.window_handle(), cx);
                        }),
                    ),
            )
    }
}

fn open_menu(parent: AnyWindowHandle, cx: &mut App) -> anyhow::Result<WindowHandle<Menu>> {
    cx.open_window(
        WindowOptions {
            titlebar: None,
            // Sizes the popup. The platform decides the position, so the origin is ignored.
            window_bounds: Some(WindowBounds::Windowed(Bounds {
                origin: point(px(0.), px(0.)),
                size: POPUP_SIZE,
            })),
            kind: WindowKind::AnchoredPopup(PopupOptions {
                parent,
                anchor_rect: BUTTON_BOUNDS,
                // Anchor to the button's bottom-left and grow down-right so the menu drops beneath it.
                anchor: PopupAnchor::BottomLeft,
                gravity: PopupGravity::BottomRight,
                // Slide horizontally and flip vertically if the menu would leave the screen.
                constraint_adjustment: PopupConstraintAdjustment::SLIDE_X
                    | PopupConstraintAdjustment::FLIP_Y,
                offset: point(px(0.), px(4.)),
                // Grab input so the compositor dismisses the popup on clicks into other applications.
                grab: true,
            }),
            ..Default::default()
        },
        |_, cx| cx.new(|_| Menu),
    )
}

fn run_example() {
    application().run(|cx: &mut App| {
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(Bounds {
                    origin: point(px(100.), px(100.)),
                    size: size(px(420.), px(300.)),
                })),
                ..Default::default()
            },
            |_, cx| cx.new(|_| PopupExample::default()),
        )
        .unwrap();
        cx.activate(true);
    });
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run_example();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    run_example();
}
```

</details>

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- N/A
2026-07-08 19:22:23 +00:00
.agents/skills Add dylint lint library for Zed-specific patterns (#58496) 2026-07-03 22:05:34 +00:00
.cargo livekit: Use our build of libwebrtc.a (#51433) 2026-03-16 10:47:36 +01:00
.cloudflare Staged docs releases (#50136) 2026-04-30 11:10:14 +00:00
.config Increase timeout for test_random_blocks (#50724) 2026-03-04 12:25:12 -05:00
.factory Add humanizer skill for AI writing pattern detection (#50021) 2026-02-24 14:58:52 -06:00
.github Switch Guild board automation to role checks (#60606) 2026-07-08 16:52:33 +00:00
.zed agent: Remove old edit file tool (#55612) 2026-05-04 09:54:39 +00:00
assets Add support for format_on_save only changed lines (#49964) 2026-07-08 16:53:11 +00:00
ci Move Nightly release to gh-workflow (#41349) 2025-10-28 13:57:23 -06:00
crates gpui: Add parent-anchored native popup windows (with wayland xdg_popup implementation only so far) (#60232) 2026-07-08 19:22:23 +00:00
docs Add support for format_on_save only changed lines (#49964) 2026-07-08 16:53:11 +00:00
extensions ci: Set and enforce more default permissions (#60222) 2026-07-06 16:02:54 +00:00
legal Document provider safety retention for designated Zed-hosted models (#58967) 2026-06-09 21:54:48 +00:00
nix agent: Sandboxing polish (#60173) 2026-07-01 14:55:30 +00:00
script Switch Guild board automation to role checks (#60606) 2026-07-08 16:52:33 +00:00
tooling Add license symlinks to lint test fixture crates (#60505) 2026-07-07 01:35:06 +00:00
.git-blame-ignore-revs Add PR 50413 to .git-blame-ignore-revs (#50421) 2026-03-01 00:50:33 +01:00
.gitattributes windows: Make sure zed.sh using the correct line ending (#37650) 2025-09-05 16:25:55 +00:00
.gitignore gpui: Accesskit support (#56065) 2026-05-27 18:17:59 +00:00
.mailmap Update .mailmap (#47413) 2026-01-22 23:57:26 +05:30
.prettierrc ci: Add check for formatting default.json (#30034) 2025-05-06 18:55:26 +00:00
.rules Update AI rules to reflect that AsyncApp updates are now infallible (#54818) 2026-04-24 19:04:32 +00:00
AGENTS.md ai: Symlink an AGENTS.md file to .rules (#45939) 2026-01-19 15:29:42 +01:00
Cargo.lock search: Bump fancy-regex dependency and enable CRLF mode (#55471) 2026-07-08 16:56:45 +00:00
Cargo.toml search: Bump fancy-regex dependency and enable CRLF mode (#55471) 2026-07-08 16:56:45 +00:00
CLAUDE.md Initial .rules file for agent with symlinks for other rules file paths (#29014) 2025-04-17 23:41:23 +00:00
clippy.toml Revert "Revert scheduler update (#46659)" (#46671) 2026-01-14 07:19:13 +00:00
CODE_OF_CONDUCT.md Remove community content from docs and point to zed.dev (#19895) 2024-10-29 09:44:58 -04:00
compose.yml Remove Postgres and stripe-mock from Docker Compose (#48313) 2026-02-04 03:42:58 +00:00
CONTRIBUTING.md Re-add section about extension API to CONTRIBUTING.md (#59702) 2026-06-22 12:55:51 +00:00
debug.plist
default.nix nix: Use flake-parts, partitions, and treefmt-nix (#45321) 2026-02-02 14:26:42 +00:00
Dockerfile-collab Bump Rust version to 1.94 (#51086) 2026-03-30 09:06:59 +00:00
Dockerfile-collab.dockerignore ci: Move collab to Dockerfile-collab (#18515) 2024-09-30 16:14:26 -04:00
Dockerfile-cross.dockerignore Add remote server cross compilation (#19136) 2024-10-12 23:23:56 -07:00
Dockerfile-distros Removal of mold/wild scripts and mentions in docs (#53078) 2026-04-08 21:20:02 +03:00
Dockerfile-distros.dockerignore Support More Linux (#18480) 2024-09-30 17:46:21 -04:00
flake.lock Rust 1.95 (#55104) 2026-04-29 10:27:47 +00:00
flake.nix ci: Remove garnix substitutor (#58033) 2026-05-29 05:56:42 +00:00
GEMINI.md Add missing GEMINI.md rule file for gemini-cli (#38885) 2025-10-02 09:47:29 -04:00
LICENSE-APACHE Update license year (#24191) 2025-02-04 09:02:59 -05:00
LICENSE-GPL Licenses: change license fields in Cargo.toml to AGPL-3.0-or-later. (#5535) 2024-01-27 13:51:16 +01:00
livekit.yaml Add LiveKit server to Docker Compose (#7907) 2024-02-16 10:49:48 -05:00
lychee.toml ci: Check for broken links (#30844) 2025-06-06 09:39:35 +00:00
Procfile Update instructions for local collaboration (#35689) 2025-08-06 11:10:28 -07:00
Procfile.web Remove PostgREST (#41299) 2025-10-27 13:27:59 -04:00
README.md docs: Remove readme junk (#60008) 2026-06-27 15:06:29 +00:00
renovate.json Remove workspace-hack (#40216) 2025-10-17 18:58:14 +00:00
REVIEWERS.conl Remove past reviewer (#51767) 2026-03-17 16:34:35 +00:00
rust-toolchain.toml Rust 1.95 (#55104) 2026-04-29 10:27:47 +00:00
rustfmt.toml nix: Use flake-parts, partitions, and treefmt-nix (#45321) 2026-02-02 14:26:42 +00:00
shell.nix nix: Use flake-parts, partitions, and treefmt-nix (#45321) 2026-02-02 14:26:42 +00:00
typos.toml Update bundled JSON schemas (2026-04-29) (#58948) 2026-06-25 17:30:12 +00:00

Zed

Zed CI

Welcome to Zed, a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter.


Installation

On macOS, Linux, and Windows you can download Zed directly or install Zed via your local package manager (macOS/Linux/Windows).

Other platforms are not yet available:

Developing Zed

Contributing

See CONTRIBUTING.md for ways you can contribute to Zed.

Also... we're hiring! Check out our jobs page for open roles.

Licensing

Zed source code is licensed primarily under GPL-3.0-or-later, with Apache-2.0 components where marked.

License information for third party dependencies must be correctly provided for CI to pass.

We use cargo-about to automatically comply with open source licenses. If CI is failing, check the following:

  • Is it showing a no license specified error for a crate you've created? If so, add publish = false under [package] in your crate's Cargo.toml.
  • Is the error failed to satisfy license requirements for a dependency? If so, first determine what license the project has and whether this system is sufficient to comply with this license's requirements. If you're unsure, ask a lawyer. Once you've verified that this system is acceptable add the license's SPDX identifier to the accepted array in script/licenses/zed-licenses.toml.
  • Is cargo-about unable to find the license for a dependency? If so, add a clarification field at the end of script/licenses/zed-licenses.toml, as specified in the cargo-about book.

Sponsorship

Zed is developed by Zed Industries, Inc., a for-profit company.

If youd like to financially support the project, you can do so via GitHub Sponsors. Sponsorships go directly to Zed Industries and are used as general company revenue. There are no perks or entitlements associated with sponsorship.