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:?}"),
+ }
+ }
}