From bbaa2023e092e5d1927ed5c9a6b93ca8bb0754a3 Mon Sep 17 00:00:00 2001
From: dim-ghub <59436063+dim-ghub@users.noreply.github.com>
Date: Thu, 10 Sep 2026 15:00:01 -0500
Subject: [PATCH] Open a window as a wlr-layer-shell surface via
WindowOptions.layerShell.
The GPUI fork in zed/ already implements layer-shell at the Rust level
(WindowKind::LayerShell, layer_shell::LayerShellOptions), but nothing
reached JS: WindowOptions had no way to ask for it, so @gpuix/react
could only open normal floating windows. A panel, dock, notification
shade or wallpaper needs an edge-anchored surface with an exclusive
zone, which is exactly what layer-shell is for.
Add to the napi WindowOptions:
- layerShell?: LayerShellOptions - namespace, layer (background |
bottom | top | overlay), anchor (top / bottom / left / right, two
opposite edges stretch that axis), exclusiveZone, exclusiveEdge,
margin ([top, right, bottom, left]), keyboardInteractivity (none |
on-demand | exclusive). All optional; the default anchor is a top
bar (top | left | right) and the default layer is top.
- appId?: string - Wayland app_id / X11 WM_CLASS, useful on its own
and needed for a compositor to target a layer surface by rule.
to_gpui_window_options maps layerShell onto WindowKind::LayerShell and
drops the titlebar. The WindowKind variant only exists on a
wayland-enabled Linux gpui build, so the mapping is
cfg(target_os = "linux"); on macOS and Windows layerShell is ignored
and appId still applies. init_threaded opens a layer surface at the
origin instead of Bounds::centered, since the compositor places it
from its anchor.
index.d.ts is hand-edited rather than regenerated: the @napi-rs/cli
in node_modules emits a much smaller index.d.ts than the committed
one (it drops every doc comment and all of TestGpuixRenderer's
methods), so a full `napi build` regen here would be a large
destructive diff unrelated to this change. The added block matches
the shape napi produces for these structs.
Tests in renderer.rs cover the mapping (anchor union, exclusive zone
and edge, margin, keyboard interactivity, titlebar dropped) and that a
window with no layerShell stays Normal with a titlebar.
Verified on Hyprland (3 monitors): a @gpuix/react app calling
render(, { layerShell: { anchor: ['top','left','right'],
exclusiveZone: 34 } }) opens a 34px top bar, hyprctl reports the
kite-panel layer surface and reserved: [0, 34, 0, 0], and a React
setInterval clock re-renders live on the surface.
---
.changeset/layer-shell-window-kind.md | 26 ++++
packages/native/index.d.ts | 57 +++++++
packages/native/src/renderer.rs | 208 +++++++++++++++++++++++++-
3 files changed, 285 insertions(+), 6 deletions(-)
create mode 100644 .changeset/layer-shell-window-kind.md
diff --git a/.changeset/layer-shell-window-kind.md b/.changeset/layer-shell-window-kind.md
new file mode 100644
index 00000000..fe75a620
--- /dev/null
+++ b/.changeset/layer-shell-window-kind.md
@@ -0,0 +1,26 @@
+---
+'@gpuix/native': minor
+'@gpuix/react': patch
+---
+
+Add `layerShell` and `appId` to `WindowOptions` so a window can open as a Wayland `wlr-layer-shell` surface.
+
+`render(node, { layerShell })` opens the window as a compositor-anchored surface (bar, dock, notification overlay, wallpaper) instead of a normal floating window: no native titlebar, positioned by its `anchor` rather than by `window_bounds.origin`, with an optional `exclusiveZone` so tiled windows keep clear of it.
+
+```tsx
+render(, {
+ appId: 'my-panel',
+ height: 34,
+ windowBackground: 'opaque',
+ focus: false,
+ layerShell: {
+ namespace: 'my-panel',
+ layer: 'top',
+ anchor: ['top', 'left', 'right'],
+ exclusiveZone: 34,
+ keyboardInteractivity: 'none',
+ },
+})
+```
+
+Options map to GPUI's `WindowKind::LayerShell(LayerShellOptions)`: `layer` (`background` | `bottom` | `top` | `overlay`), `anchor` (any of `top` / `bottom` / `left` / `right`; two opposite edges stretch that axis), `exclusiveZone`, `exclusiveEdge`, `margin` (`[top, right, bottom, left]`), `keyboardInteractivity` (`none` | `on-demand` | `exclusive`). Linux/Wayland only; ignored on macOS and Windows. `appId` also sets the Wayland `app_id` / X11 `WM_CLASS` on a normal window.
diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts
index 3c018a58..282cce91 100644
--- a/packages/native/index.d.ts
+++ b/packages/native/index.d.ts
@@ -584,6 +584,52 @@ export interface WindowInsets {
effective: EdgeInsets
}
+/**
+ * Wayland `wlr-layer-shell` surface options. Linux/Wayland only; ignored on
+ * every other platform. When present on `WindowOptions`, the window is opened
+ * as a compositor-anchored surface (a bar, dock, notification overlay or
+ * wallpaper) with no native titlebar instead of a normal floating window.
+ * `width` / `height` then only constrain the axis the surface is not
+ * stretched along by its `anchor`.
+ */
+export interface LayerShellOptions {
+ /**
+ * Compositor surface namespace, used for window rules. Cannot be changed
+ * after the surface is created. Defaults to the empty string.
+ */
+ namespace?: string
+ /** `"background"` | `"bottom"` | `"top"` | `"overlay"`. Defaults to `"top"`. */
+ layer?: string
+ /**
+ * Screen edges to anchor to: any combination of `"top"`, `"bottom"`,
+ * `"left"`, `"right"`. Anchoring two opposite edges stretches the surface
+ * across that axis. Defaults to `["top", "left", "right"]` (a top bar).
+ */
+ anchor?: Array
+ /**
+ * Logical pixels to reserve along the anchored edge so other windows do
+ * not overlap the surface. `0` lets the compositor decide, a negative
+ * value asks it not to reserve any space.
+ */
+ exclusiveZone?: number
+ /**
+ * Which edge the exclusive zone applies to when it cannot be inferred from
+ * a single-edge `anchor`. Same values as one `anchor` entry.
+ */
+ exclusiveEdge?: string
+ /**
+ * Gap between the surface and its anchor edge(s), in CSS order:
+ * `[top, right, bottom, left]`. Must have exactly four entries or it is
+ * ignored.
+ */
+ margin?: Array
+ /**
+ * `"none"` | `"on-demand"` | `"exclusive"`. Defaults to `"none"`: a bar or
+ * overlay that never takes keyboard focus.
+ */
+ keyboardInteractivity?: string
+}
+
export interface WindowOptions {
title?: string
/**
@@ -619,6 +665,17 @@ export interface WindowOptions {
* `activateWindow()` to reveal it. Ignored on Linux.
*/
show?: boolean
+ /**
+ * Application id: Wayland `app_id` / X11 `WM_CLASS`. Desktop environments
+ * use it to group windows and match window rules. Required in practice for
+ * a `layerShell` surface a compositor is meant to target by rule.
+ */
+ appId?: string
+ /**
+ * Open the window as a Wayland `wlr-layer-shell` surface instead of a
+ * normal window. Linux/Wayland only; ignored elsewhere.
+ */
+ layerShell?: LayerShellOptions
}
export interface WindowSize {
diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs
index 8aaf8f93..bfa87dfe 100644
--- a/packages/native/src/renderer.rs
+++ b/packages/native/src/renderer.rs
@@ -1142,11 +1142,17 @@ impl GpuixRenderer {
.run(move |cx| {
crate::custom_elements::input::init(cx);
crate::custom_elements::img::init(cx);
- let bounds = gpui::Bounds::centered(
- None,
- gpui::size(gpui::px(width as f32), gpui::px(height as f32)),
- cx,
- );
+ let size = gpui::size(gpui::px(width as f32), gpui::px(height as f32));
+ // A layer-shell surface is positioned by the compositor from its
+ // anchor, so it opens at the origin; a normal window is centered.
+ let bounds = if window_options.layer_shell.is_some() {
+ gpui::Bounds {
+ origin: gpui::point(gpui::px(0.0), gpui::px(0.0)),
+ size,
+ }
+ } else {
+ gpui::Bounds::centered(None, size, cx)
+ };
let window = match cx.open_window(
to_gpui_window_options(&window_options, bounds),
|_window, cx| {
@@ -5787,6 +5793,40 @@ pub struct DebugFrameOverlayStats {
pub samples: f64,
}
+/// Wayland `wlr-layer-shell` surface options. Linux/Wayland only; ignored on
+/// every other platform. When present on `WindowOptions`, the window is opened
+/// as a compositor-anchored surface (a bar, dock, notification overlay or
+/// wallpaper) with no native titlebar instead of a normal floating window.
+/// `width` / `height` then only constrain the axis the surface is not
+/// stretched along by its `anchor`.
+#[derive(Debug, Clone, Default)]
+#[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), napi(object))]
+pub struct LayerShellOptions {
+ /// Compositor surface namespace, used for window rules. Cannot be changed
+ /// after the surface is created. Defaults to the empty string.
+ pub namespace: Option,
+ /// `"background"` | `"bottom"` | `"top"` | `"overlay"`. Defaults to `"top"`.
+ pub layer: Option,
+ /// Screen edges to anchor to: any combination of `"top"`, `"bottom"`,
+ /// `"left"`, `"right"`. Anchoring two opposite edges stretches the surface
+ /// across that axis. Defaults to `["top", "left", "right"]` (a top bar).
+ pub anchor: Option>,
+ /// Logical pixels to reserve along the anchored edge so other windows do
+ /// not overlap the surface. `0` lets the compositor decide, a negative
+ /// value asks it not to reserve any space.
+ pub exclusive_zone: Option,
+ /// Which edge the exclusive zone applies to when it cannot be inferred from
+ /// a single-edge `anchor`. Same values as one `anchor` entry.
+ pub exclusive_edge: Option,
+ /// Gap between the surface and its anchor edge(s), in CSS order:
+ /// `[top, right, bottom, left]`. Must have exactly four entries or it is
+ /// ignored.
+ pub margin: Option>,
+ /// `"none"` | `"on-demand"` | `"exclusive"`. Defaults to `"none"`: a bar or
+ /// overlay that never takes keyboard focus.
+ pub keyboard_interactivity: Option,
+}
+
#[derive(Debug, Clone)]
#[cfg_attr(not(all(target_arch = "wasm32", target_os = "unknown")), napi(object))]
pub struct WindowOptions {
@@ -5816,6 +5856,13 @@ pub struct WindowOptions {
/// Show the window when it opens. `false` opens it hidden; call
/// `activateWindow()` to reveal it. Ignored on Linux.
pub show: Option,
+ /// Application id: Wayland `app_id` / X11 `WM_CLASS`. Desktop environments
+ /// use it to group windows and match window rules. Required in practice for
+ /// a `layerShell` surface a compositor is meant to target by rule.
+ pub app_id: Option,
+ /// Open the window as a Wayland `wlr-layer-shell` surface instead of a
+ /// normal window. Linux/Wayland only; ignored elsewhere.
+ pub layer_shell: Option,
}
impl Default for WindowOptions {
@@ -5836,10 +5883,69 @@ impl Default for WindowOptions {
traffic_light_y: None,
focus: Some(true),
show: Some(true),
+ app_id: None,
+ layer_shell: None,
}
}
}
+#[cfg(target_os = "linux")]
+fn layer_shell_anchor_bit(name: &str) -> gpui::layer_shell::Anchor {
+ use gpui::layer_shell::Anchor;
+ match name.trim().to_ascii_lowercase().as_str() {
+ "top" => Anchor::TOP,
+ "bottom" => Anchor::BOTTOM,
+ "left" => Anchor::LEFT,
+ "right" => Anchor::RIGHT,
+ _ => Anchor::empty(),
+ }
+}
+
+#[cfg(target_os = "linux")]
+fn to_layer_shell_options(options: &LayerShellOptions) -> gpui::layer_shell::LayerShellOptions {
+ use gpui::layer_shell::{Anchor, KeyboardInteractivity, Layer};
+
+ let layer = match options.layer.as_deref() {
+ Some("background") => Layer::Background,
+ Some("bottom") => Layer::Bottom,
+ Some("overlay") => Layer::Overlay,
+ _ => Layer::Top,
+ };
+ let anchor = match options.anchor.as_deref() {
+ Some(names) if !names.is_empty() => names
+ .iter()
+ .fold(Anchor::empty(), |acc, name| acc | layer_shell_anchor_bit(name)),
+ _ => Anchor::TOP | Anchor::LEFT | Anchor::RIGHT,
+ };
+ let keyboard_interactivity = match options.keyboard_interactivity.as_deref() {
+ Some("on-demand") => KeyboardInteractivity::OnDemand,
+ Some("exclusive") => KeyboardInteractivity::Exclusive,
+ _ => KeyboardInteractivity::None,
+ };
+ let margin = options.margin.as_ref().and_then(|m| match m.as_slice() {
+ [top, right, bottom, left] => Some((
+ gpui::px(*top as f32),
+ gpui::px(*right as f32),
+ gpui::px(*bottom as f32),
+ gpui::px(*left as f32),
+ )),
+ _ => None,
+ });
+
+ gpui::layer_shell::LayerShellOptions {
+ namespace: options.namespace.clone().unwrap_or_default(),
+ layer,
+ anchor,
+ exclusive_zone: options.exclusive_zone.map(|z| gpui::px(z as f32)),
+ exclusive_edge: options
+ .exclusive_edge
+ .as_deref()
+ .map(layer_shell_anchor_bit),
+ margin,
+ keyboard_interactivity,
+ }
+}
+
fn to_gpui_window_options(
options: &WindowOptions,
bounds: gpui::Bounds,
@@ -5868,7 +5974,8 @@ fn to_gpui_window_options(
} else {
gpui::WindowBounds::Windowed(bounds)
};
- gpui::WindowOptions {
+ #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
+ let mut gpui_options = gpui::WindowOptions {
window_bounds: Some(window_bounds),
titlebar: Some(gpui::TitlebarOptions {
title: Some(title.into()),
@@ -5880,8 +5987,20 @@ fn to_gpui_window_options(
window_min_size,
focus: options.focus.unwrap_or(true),
show: options.show.unwrap_or(true),
+ app_id: options.app_id.clone(),
..Default::default()
+ };
+
+ // A layer-shell surface has no titlebar and is placed by the compositor
+ // from its anchor, not by `window_bounds.origin`. The `WindowKind` variant
+ // only exists on a Wayland-enabled Linux gpui build.
+ #[cfg(target_os = "linux")]
+ if let Some(layer_shell) = &options.layer_shell {
+ gpui_options.titlebar = None;
+ gpui_options.kind = gpui::WindowKind::LayerShell(to_layer_shell_options(layer_shell));
}
+
+ gpui_options
}
#[cfg(test)]
@@ -6294,4 +6413,81 @@ mod window_options_tests {
Some(gpui::size(gpui::px(320.0), gpui::px(240.0)))
);
}
+
+ #[test]
+ fn app_id_is_forwarded() {
+ let gpui_options = mapped(WindowOptions {
+ app_id: Some("kite-panel".to_string()),
+ ..WindowOptions::default()
+ });
+ assert_eq!(gpui_options.app_id.as_deref(), Some("kite-panel"));
+ }
+
+ #[test]
+ fn without_layer_shell_the_window_is_normal_with_a_titlebar() {
+ let gpui_options = mapped(WindowOptions::default());
+ assert_eq!(gpui_options.kind, gpui::WindowKind::Normal);
+ assert!(gpui_options.titlebar.is_some());
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn layer_shell_options_map_to_a_layer_shell_window_kind() {
+ use gpui::layer_shell::{Anchor, KeyboardInteractivity, Layer};
+
+ let gpui_options = mapped(WindowOptions {
+ app_id: Some("kite-panel".to_string()),
+ layer_shell: Some(LayerShellOptions {
+ namespace: Some("kite-panel".to_string()),
+ layer: Some("top".to_string()),
+ anchor: Some(vec![
+ "top".to_string(),
+ "left".to_string(),
+ "right".to_string(),
+ ]),
+ exclusive_zone: Some(34.0),
+ exclusive_edge: Some("top".to_string()),
+ margin: Some(vec![0.0, 0.0, 0.0, 0.0]),
+ keyboard_interactivity: Some("none".to_string()),
+ }),
+ ..WindowOptions::default()
+ });
+
+ assert!(gpui_options.titlebar.is_none());
+ match gpui_options.kind {
+ gpui::WindowKind::LayerShell(opts) => {
+ assert_eq!(opts.namespace, "kite-panel");
+ assert_eq!(opts.layer, Layer::Top);
+ assert_eq!(opts.anchor, Anchor::TOP | Anchor::LEFT | Anchor::RIGHT);
+ assert_eq!(opts.exclusive_zone, Some(gpui::px(34.0)));
+ assert_eq!(opts.exclusive_edge, Some(Anchor::TOP));
+ assert_eq!(
+ opts.margin,
+ Some((gpui::px(0.0), gpui::px(0.0), gpui::px(0.0), gpui::px(0.0)))
+ );
+ assert_eq!(
+ opts.keyboard_interactivity,
+ KeyboardInteractivity::None
+ );
+ }
+ other => panic!("expected a layer-shell window kind, got {other:?}"),
+ }
+ }
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn layer_shell_anchor_defaults_to_a_top_bar() {
+ use gpui::layer_shell::Anchor;
+
+ let gpui_options = mapped(WindowOptions {
+ layer_shell: Some(LayerShellOptions::default()),
+ ..WindowOptions::default()
+ });
+ match gpui_options.kind {
+ gpui::WindowKind::LayerShell(opts) => {
+ assert_eq!(opts.anchor, Anchor::TOP | Anchor::LEFT | Anchor::RIGHT);
+ }
+ other => panic!("expected a layer-shell window kind, got {other:?}"),
+ }
+ }
}