Milan Jurkulak 3 kuukautta sitten
vanhempi
sitoutus
664ea7b9e8

+ 123 - 0
compositor/DECORATIONS.md

@@ -0,0 +1,123 @@
+# Server-side window decorations
+
+The mjdev compositor (`mjdevc`) draws GNOME-style window frames **itself**, in the
+C shim (`native/shim.c`), instead of letting each app draw its own titlebar (CSD).
+The frame is rendered with **cairo** into an image surface that is wrapped in a
+custom `wlr_buffer` and shown as a `wlr_scene_buffer` node, so it is GPU-composited
+like any other surface and moves/raises/minimizes together with its window.
+
+Policy (colors, what counts as an app window) is driven from the Kotlin side; the
+pixel-pushing lives in C. This follows the project's kotlin-first / compositor-only
+split: surface layering and frame rendering are genuine compositor concerns.
+
+## What the frame does
+
+- **Look:** rounded top corners, a soft vertical gradient derived from the desktop
+  background color, a hairline top highlight, the window title (cairo "toy" font
+  API — no pango dependency), and a 1px border down the sides and bottom.
+- **Three GNOME-style buttons** (right-aligned, order min · max · close):
+  - **minimize** → hides the window (`mjc_view_set_minimized`)
+  - **maximize** → toggles maximize (`mjc_view_set_maximized`)
+  - **close** → closes the window (`mjc_view_close`)
+  - hover highlights the button; the close button turns red on hover.
+- **Move:** drag the titlebar (anywhere that isn't a button) to move the window.
+- **Resize:** drag any **edge or corner**. An 8px hotzone around the frame starts an
+  interactive resize with the correct edges; works on all four sides + four corners.
+- **Cursors:**
+  - over the resize hotzone → directional resize cursor
+    (`n/s/e/w/ne/nw/se/sw-resize`),
+  - over a button → `pointer` (hand),
+  - over the draggable titlebar → `default` arrow.
+- **Active/inactive styling:** the focused window's titlebar is brighter; unfocused
+  windows are dimmed.
+- **Focus-follows-mouse:** moving the pointer over an app window focuses it (no
+  restack) — `MJC_FOCUS_FOLLOWS_MOUSE` in `shim.c`.
+
+## Which windows get a frame
+
+A window is decorated when it is a normal **app** window. It is **not** decorated when:
+
+- it is **shell chrome** — the desktop's own wallpaper, panels, menus, control
+  center (the Kotlin `Policy` marks these via `mjc_view_set_chrome(view, true)`),
+- it is **chromeless** — either the shell asked for it
+  (`mjc_view_set_decorated(view, false)` / IPC `set-decorated`), or the client itself
+  demanded client-side decorations through the xdg-decoration protocol (we honor the
+  client and leave it frameless),
+- it is an X11 override-redirect surface (menus, tooltips, popups).
+
+Decoration is decoupled from the stacking layer, so **always-on-top** and
+**always-on-bottom** app windows keep their frame.
+
+## Theme / colors over IPC
+
+The frame colors come from the shell's live background palette, sent over the IPC
+socket. Until the shell sends a palette, a dark-slate default is used.
+
+The desktop shell pushes them automatically: `ThemeManagerLinux.createFromPalette()`
+(invoked whenever the wallpaper/palette changes) calls
+`CompositorControl.setDecorationTheme(backgroundColor, textColor)`
+(`shared/.../helpers/compositor/CompositorControl.kt`), a one-shot IPC sender that
+no-ops when not running inside mjdevc. So changing the wallpaper re-colorizes every
+window frame.
+
+```jsonc
+// rgb channels 0..255; fg is optional and defaults to a contrasting
+// black/white chosen from the bg luminance
+{"cmd":"set-decoration-theme","bg_r":30,"bg_g":34,"bg_b":42}
+{"cmd":"set-decoration-theme","bg_r":30,"bg_g":34,"bg_b":42,"fg_r":240,"fg_g":240,"fg_b":240}
+```
+
+Other decoration-related IPC commands:
+
+```jsonc
+{"cmd":"set-decorated","id":3,"decorated":false}   // chromeless / frameless
+{"cmd":"always-on-top","id":3,"on":true}
+{"cmd":"always-on-bottom","id":3,"on":true}
+```
+
+## C / native API (`native/shim.h`)
+
+```c
+void mjc_view_set_chrome(mjc_view *view, bool chrome);       // shell surface, no frame
+void mjc_view_set_decorated(mjc_view *view, bool decorated); // chromeless toggle
+void mjc_set_decoration_theme(mjc_server *server,
+    float bg_r, float bg_g, float bg_b,
+    float fg_r, float fg_g, float fg_b);                     // colors in 0..1
+```
+
+## Build / packaging
+
+- `compositor.gradle.kts` adds `cairo` to the shim's pkg-config cflags and links it
+  into `mjdevc`.
+- `libcairo2` is declared in `gradle/libs.versions.toml`
+  (`app-compositor-runtime-deps`), the single source of truth that feeds the deb
+  `Depends`, the `installDesktop` apt step and `make-iso.sh`. `mjdevc` (the binary
+  that links cairo) ships via the deb and the ISO, so those are the package formats
+  that need the dependency.
+
+## Geometry constants (`shim.c`)
+
+```c
+#define MJC_DECO_TITLEBAR_H 36   // titlebar height
+#define MJC_DECO_RADIUS     10   // top-corner radius
+#define MJC_DECO_BORDER     1    // side/bottom border width
+#define MJC_DECO_BTN_R      9    // button circle radius
+#define MJC_DECO_BTN_GAP    8    // gap between buttons
+#define MJC_DECO_PAD        14   // title-left / buttons-right padding
+#define MJC_DECO_RESIZE     6    // edge/corner resize hotzone half-width
+```
+
+## Next steps / known limitations
+
+- **Maximized windows:** the titlebar is drawn directly above the content. When a
+  window is maximized to the full output, the titlebar sits at the very top (under
+  the shell top bar). Inset/work-area-aware maximize is a follow-up.
+- **GTK apps that ignore xdg-decoration** still draw their own CSD titlebar, so they
+  can show a double titlebar. A per-app override list can suppress this later.
+- **Window transparency (ARGB) + blur ("glass"):** the cairo buffer is already ARGB
+  (alpha-capable), so the frame foundation supports translucency. True window
+  transparency and background blur (frosted-glass) are a deliberate future step —
+  they need a blur pass in the render path (e.g. a scenefx-style wlroots scene), not
+  just the decoration layer.
+- **Drop shadows:** not yet drawn (kept off for now to avoid per-frame overdraw cost);
+  belongs with the same blur/scenefx work above.

+ 4 - 2
compositor/compositor.gradle.kts

@@ -34,12 +34,14 @@ fun pkgConfig(vararg args: String): List<String> = runCatching {
     else out.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
 }.getOrElse { emptyList() }
 
-val wlrPackages = arrayOf("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1")
+// cairo is added for server-side window decorations (the compositor renders the
+// GNOME-style titlebar + buttons into a cairo image surface used as a wlr_buffer)
+val wlrPackages = arrayOf("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1", "cairo")
 val wlrCflags: List<String>
     get() = pkgConfig("--cflags", *wlrPackages)
 val wlrLibs: List<String>
     get() = pkgConfig("--libs", *wlrPackages).ifEmpty {
-        listOf("-lwlroots-0.18", "-lwayland-server", "-lxkbcommon")
+        listOf("-lwlroots-0.18", "-lwayland-server", "-lxkbcommon", "-lcairo")
     }
 
 // the kotlin/native bundled linker does not search the system lib dirs

+ 662 - 11
compositor/native/shim.c

@@ -50,8 +50,29 @@
 #include <wlr/xwayland.h>
 #include <xkbcommon/xkbcommon.h>
 
+/* server-side window decorations: rendered with cairo into an image surface that
+ * is wrapped in a custom wlr_buffer and shown via a wlr_scene_buffer node */
+#include <math.h>
+#include <cairo.h>
+#include <drm_fourcc.h>
+#include <wlr/interfaces/wlr_buffer.h>
+
+/* _POSIX_C_SOURCE hides the XOPEN/GNU math constants from <math.h> */
+#ifndef M_PI
+#define M_PI 3.14159265358979323846
+#endif
+
 #include "shim.h"
 
+/* GNOME-style decoration geometry (logical px) */
+#define MJC_DECO_TITLEBAR_H 36
+#define MJC_DECO_RADIUS     10
+#define MJC_DECO_BORDER     3
+#define MJC_DECO_BTN_R      9   /* button circle radius */
+#define MJC_DECO_BTN_GAP    8   /* gap between button circles */
+#define MJC_DECO_PAD        14  /* horizontal padding (title left, buttons right) */
+#define MJC_DECO_RESIZE     6   /* edge/corner resize hotzone half-width (px) */
+
 enum mjc_cursor_mode {
     MJC_CURSOR_PASSTHROUGH,
     MJC_CURSOR_MOVE,
@@ -114,6 +135,11 @@ struct mjc_server {
     pid_t spawned_pids[64];
     int spawned_count;
 
+    /* server-side decoration theme (straight-alpha colors, 0..1) derived from the
+     * shell background; the compositor paints every normal-layer window's frame with it */
+    float deco_bg[3];
+    float deco_fg[3];
+
     struct wl_listener new_output;
     struct wl_listener new_xdg_toplevel;
     struct wl_listener new_xdg_popup;
@@ -146,6 +172,17 @@ struct mjc_view {
     /* floating geometry remembered while maximized (x/y are scene coords) */
     struct wlr_box saved_geo;
 
+    /* server-side decoration (only for normal-layer app windows). The titlebar is a
+     * cairo buffer node, the borders are scene rects; all are children of scene_tree so
+     * they move/raise/minimize with the window automatically. */
+    bool decorated;
+    bool deco_disabled;                    /* chromeless: never draw a server frame */
+    bool is_chrome;                         /* shell surface (wallpaper/panel/menu): no frame */
+    struct wlr_scene_buffer *deco_titlebar;
+    struct wlr_scene_rect *deco_border[3]; /* left, right, bottom */
+    int deco_w, deco_h;                    /* last rendered content size */
+    int hovered_button;                    /* -1 none, 0 min, 1 max, 2 close */
+
     /* xdg shell */
     struct wlr_xdg_toplevel *xdg_toplevel;
 
@@ -177,6 +214,7 @@ struct mjc_popup {
 
 struct mjc_decoration {
     struct wlr_xdg_toplevel_decoration_v1 *decoration;
+    struct mjc_view *view;
     struct wl_listener request_mode;
     struct wl_listener destroy;
 };
@@ -217,6 +255,9 @@ static struct wlr_surface *view_surface(struct mjc_view *view) {
     return view->xdg_toplevel->base->surface;
 }
 
+/* re-render a window's titlebar bitmap in place (focus/hover/title changes) */
+static void deco_repaint(struct mjc_view *view);
+
 static void emit_focus_change(struct mjc_server *server) {
     if (server->cbs.focus_change != NULL) {
         server->cbs.focus_change(server->ud, server->focused_view);
@@ -242,6 +283,7 @@ static void view_focus_internal(struct mjc_view *view, bool raise) {
     if (server->focused_view == view) {
         return;
     }
+    struct mjc_view *prev = server->focused_view;
     if (server->focused_view != NULL) {
         view_deactivate(server->focused_view);
     }
@@ -267,6 +309,11 @@ static void view_focus_internal(struct mjc_view *view, bool raise) {
         }
     }
     server->focused_view = view;
+    /* refresh active/inactive titlebar styling on both windows */
+    if (prev != NULL) {
+        deco_repaint(prev);
+    }
+    deco_repaint(view);
     emit_focus_change(server);
 }
 
@@ -530,6 +577,23 @@ static void seat_request_set_selection(struct wl_listener *listener, void *data)
 
 static void view_current_geometry(struct mjc_view *view, struct wlr_box *box);
 
+/* server-side decoration helpers (defined after the interactive-grab section) */
+static void view_decoration_update(struct mjc_view *view);
+static void view_decoration_destroy(struct mjc_view *view);
+/* returns true when the press/release was consumed by a window's decoration */
+static bool deco_handle_button(struct mjc_server *server,
+    double lx, double ly, bool pressed);
+/* recompute which titlebar button (if any) the pointer hovers, re-rendering on change */
+static void deco_update_hover(struct mjc_server *server);
+/* layout-space top-left and width of a decorated view's titlebar; false if undecorated */
+static bool deco_titlebar_box(struct mjc_view *view,
+    double *tx, double *ty, int *w);
+/* resize-edge mask under (lx,ly) for a decorated window (0 = not on an edge) */
+static uint32_t deco_resize_edges(struct mjc_server *server,
+    double lx, double ly, struct mjc_view **out);
+/* xcursor name for a resize-edge mask */
+static const char *deco_resize_cursor(uint32_t edges);
+
 static void process_cursor_move(struct mjc_server *server) {
     struct mjc_view *view = server->grabbed_view;
     if (view == NULL || view->scene_tree == NULL) {
@@ -616,10 +680,44 @@ static void process_cursor_motion(struct mjc_server *server, uint32_t time) {
     struct wlr_surface *surface = NULL;
     struct mjc_view *view = desktop_view_at(server, server->cursor->x,
         server->cursor->y, &surface, &sx, &sy);
-    if (surface == NULL) {
-        wlr_cursor_set_xcursor(server->cursor, server->cursor_mgr, "default");
+
+    /* server-side decorations own the pointer over the frame: refresh button hover
+     * and pick the cursor (resize on edges/corners, hand over buttons, arrow over the
+     * draggable titlebar). When the frame owns the pointer the client gets no focus. */
+    deco_update_hover(server);
+    struct mjc_view *rview = NULL;
+    uint32_t redges = deco_resize_edges(server, server->cursor->x,
+        server->cursor->y, &rview);
+    bool deco_owns = false;
+    const char *deco_cursor = NULL;
+    if (redges != 0) {
+        deco_cursor = deco_resize_cursor(redges);
+        deco_owns = true;
+    } else {
+        struct mjc_view *tv;
+        wl_list_for_each(tv, &server->views, link) {
+            double tx, ty;
+            int w;
+            if (!deco_titlebar_box(tv, &tx, &ty, &w)) {
+                continue;
+            }
+            if (server->cursor->x >= tx && server->cursor->x < tx + w &&
+                    server->cursor->y >= ty &&
+                    server->cursor->y < ty + MJC_DECO_TITLEBAR_H) {
+                deco_cursor = (tv->hovered_button >= 0) ? "pointer" : "default";
+                deco_owns = true;
+                break;
+            }
+        }
     }
-    if (surface != NULL) {
+
+    if (deco_owns) {
+        wlr_cursor_set_xcursor(server->cursor, server->cursor_mgr, deco_cursor);
+        wlr_seat_pointer_clear_focus(seat);
+    } else if (surface == NULL) {
+        wlr_cursor_set_xcursor(server->cursor, server->cursor_mgr, "default");
+        wlr_seat_pointer_clear_focus(seat);
+    } else {
         wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
         wlr_seat_pointer_notify_motion(seat, time, sx, sy);
         /* focus-follows-mouse: focusable view under the pointer gets keyboard focus (no
@@ -628,8 +726,6 @@ static void process_cursor_motion(struct mjc_server *server, uint32_t time) {
         if (MJC_FOCUS_FOLLOWS_MOUSE && view != NULL && view->focusable) {
             view_focus_internal(view, false);
         }
-    } else {
-        wlr_seat_pointer_clear_focus(seat);
     }
 
     // broadcast the pointer position to ipc clients (throttled), so the shell can do
@@ -668,6 +764,12 @@ static void server_cursor_button(struct wl_listener *listener, void *data) {
         server->cursor_mode = MJC_CURSOR_PASSTHROUGH;
         server->grabbed_view = NULL;
     } else {
+        /* a press over a window's titlebar/buttons is handled by the decoration
+         * (focus + move grab, or a min/max/close action) and must not fall through
+         * to client focus */
+        if (deco_handle_button(server, server->cursor->x, server->cursor->y, true)) {
+            return;
+        }
         double sx, sy;
         struct wlr_surface *surface = NULL;
         struct mjc_view *view = desktop_view_at(server,
@@ -741,6 +843,461 @@ static void begin_interactive(struct mjc_view *view,
     }
 }
 
+/* ------------------------------------------------------------------ */
+/* server-side decorations                                             */
+/* ------------------------------------------------------------------ */
+
+/* a wlr_buffer backed by a cairo ARGB32 image surface. cairo ARGB32 is
+ * premultiplied and laid out as B,G,R,A on little-endian, which matches
+ * DRM_FORMAT_ARGB8888 — so the scene can upload it directly as a texture. */
+struct mjc_cairo_buffer {
+    struct wlr_buffer base;
+    cairo_surface_t *surface;
+};
+
+static void cairo_buffer_destroy(struct wlr_buffer *wlr_buffer) {
+    struct mjc_cairo_buffer *buf = wl_container_of(wlr_buffer, buf, base);
+    cairo_surface_destroy(buf->surface);
+    free(buf);
+}
+
+static bool cairo_buffer_begin_data_ptr_access(struct wlr_buffer *wlr_buffer,
+        uint32_t flags, void **data, uint32_t *format, size_t *stride) {
+    struct mjc_cairo_buffer *buf = wl_container_of(wlr_buffer, buf, base);
+    if (flags & WLR_BUFFER_DATA_PTR_ACCESS_WRITE) {
+        return false;
+    }
+    *data = cairo_image_surface_get_data(buf->surface);
+    *format = DRM_FORMAT_ARGB8888;
+    *stride = (size_t) cairo_image_surface_get_stride(buf->surface);
+    return true;
+}
+
+static void cairo_buffer_end_data_ptr_access(struct wlr_buffer *wlr_buffer) {
+    (void) wlr_buffer;
+}
+
+static const struct wlr_buffer_impl cairo_buffer_impl = {
+    .destroy = cairo_buffer_destroy,
+    .begin_data_ptr_access = cairo_buffer_begin_data_ptr_access,
+    .end_data_ptr_access = cairo_buffer_end_data_ptr_access,
+};
+
+static struct mjc_cairo_buffer *cairo_buffer_create(int width, int height) {
+    struct mjc_cairo_buffer *buf = calloc(1, sizeof(*buf));
+    if (buf == NULL) {
+        return NULL;
+    }
+    buf->surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
+    if (cairo_surface_status(buf->surface) != CAIRO_STATUS_SUCCESS) {
+        cairo_surface_destroy(buf->surface);
+        free(buf);
+        return NULL;
+    }
+    wlr_buffer_init(&buf->base, &cairo_buffer_impl, width, height);
+    return buf;
+}
+
+/* content-surface offset inside the view's scene_tree (xdg windows carry a
+ * geometry offset for their CSD margins; xwayland surfaces sit at 0,0) */
+static void view_geo_offset(struct mjc_view *view, int *ox, int *oy) {
+    if (view->is_xwayland) {
+        *ox = 0;
+        *oy = 0;
+    } else {
+        struct wlr_box geo;
+        wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &geo);
+        *ox = geo.x;
+        *oy = geo.y;
+    }
+}
+
+static void view_content_size(struct mjc_view *view, int *w, int *h) {
+    struct wlr_box box;
+    view_current_geometry(view, &box);
+    *w = box.width;
+    *h = box.height;
+}
+
+/* center x of titlebar button i (0=min, 1=max, 2=close), right-aligned GNOME order */
+static int deco_button_cx(int width, int i) {
+    int step = 2 * MJC_DECO_BTN_R + MJC_DECO_BTN_GAP;
+    int close_cx = width - MJC_DECO_PAD - MJC_DECO_BTN_R;
+    return close_cx - (2 - i) * step;
+}
+
+static bool view_should_decorate(struct mjc_view *view) {
+    if (!view->mapped || view->scene_tree == NULL) {
+        return false;
+    }
+    /* chrome (shell wallpaper/panels/menus) and chromeless windows get no frame;
+     * everything else is a normal app window and is decorated in any layer, so
+     * always-on-top / always-on-bottom apps keep their titlebar */
+    if (view->is_chrome || view->deco_disabled) {
+        return false;
+    }
+    if (view->is_xwayland && view->xsurface != NULL &&
+            view->xsurface->override_redirect) {
+        return false;
+    }
+    return true;
+}
+
+/* a path with rounded top corners and square bottom corners */
+static void deco_rounded_top(cairo_t *cr, double x, double y,
+        double w, double h, double r) {
+    cairo_new_sub_path(cr);
+    cairo_arc(cr, x + r, y + r, r, M_PI, 1.5 * M_PI);
+    cairo_arc(cr, x + w - r, y + r, r, 1.5 * M_PI, 2.0 * M_PI);
+    cairo_line_to(cr, x + w, y + h);
+    cairo_line_to(cr, x, y + h);
+    cairo_close_path(cr);
+}
+
+/* render the titlebar of `width` px into a fresh cairo buffer and hand it to the
+ * scene node (the scene takes a ref; we drop ours) */
+static void deco_render(struct mjc_view *view, int width) {
+    if (view->deco_titlebar == NULL || width <= 0) {
+        return;
+    }
+    struct mjc_server *server = view->server;
+    int h = MJC_DECO_TITLEBAR_H;
+    struct mjc_cairo_buffer *buf = cairo_buffer_create(width, h);
+    if (buf == NULL) {
+        return;
+    }
+    cairo_t *cr = cairo_create(buf->surface);
+
+    cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
+    cairo_paint(cr);
+    cairo_set_operator(cr, CAIRO_OPERATOR_OVER);
+
+    const float *bg = server->deco_bg;
+    const float *fg = server->deco_fg;
+    bool active = (server->focused_view == view);
+    double dim = active ? 0.0 : 0.10;
+
+    /* titlebar fill: soft vertical gradient derived from the theme bg */
+    cairo_pattern_t *grad = cairo_pattern_create_linear(0, 0, 0, h);
+    cairo_pattern_add_color_stop_rgb(grad, 0.0,
+        fmin(bg[0] + 0.10 - dim, 1.0), fmin(bg[1] + 0.10 - dim, 1.0),
+        fmin(bg[2] + 0.10 - dim, 1.0));
+    cairo_pattern_add_color_stop_rgb(grad, 1.0,
+        fmax(bg[0] - dim, 0.0), fmax(bg[1] - dim, 0.0), fmax(bg[2] - dim, 0.0));
+    deco_rounded_top(cr, 0, 0, width, h, MJC_DECO_RADIUS);
+    cairo_set_source(cr, grad);
+    cairo_fill(cr);
+    cairo_pattern_destroy(grad);
+
+    /* hairline top highlight for depth */
+    cairo_set_source_rgba(cr, 1, 1, 1, active ? 0.10 : 0.05);
+    cairo_set_line_width(cr, 1.0);
+    deco_rounded_top(cr, 0.5, 0.5, width - 1, h, MJC_DECO_RADIUS);
+    cairo_stroke(cr);
+
+    /* title text (cairo toy font api, no pango dependency) */
+    const char *title = mjc_view_title(view);
+    if (title != NULL && title[0] != '\0') {
+        cairo_select_font_face(cr, "Sans",
+            CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD);
+        cairo_set_font_size(cr, 13.0);
+        cairo_font_extents_t fe;
+        cairo_font_extents(cr, &fe);
+        double clip_r = deco_button_cx(width, 0) - MJC_DECO_BTN_R - 8;
+        cairo_save(cr);
+        cairo_rectangle(cr, 0, 0, clip_r > 0 ? clip_r : 0, h);
+        cairo_clip(cr);
+        cairo_set_source_rgba(cr, fg[0], fg[1], fg[2], active ? 0.95 : 0.6);
+        cairo_move_to(cr, MJC_DECO_PAD, (h + fe.ascent - fe.descent) / 2.0);
+        cairo_show_text(cr, title);
+        cairo_restore(cr);
+    }
+
+    /* three GNOME-style circular buttons: min, max, close */
+    double cy = h / 2.0;
+    for (int i = 0; i < 3; i++) {
+        double cx = deco_button_cx(width, i);
+        bool hov = (view->hovered_button == i);
+        bool is_close = (i == 2);
+
+        if (is_close && hov) {
+            cairo_set_source_rgba(cr, 0.86, 0.22, 0.20, 1.0);
+        } else {
+            double a = hov ? 0.22 : 0.12;
+            cairo_set_source_rgba(cr, fg[0], fg[1], fg[2], active ? a : a * 0.6);
+        }
+        cairo_arc(cr, cx, cy, MJC_DECO_BTN_R, 0, 2 * M_PI);
+        cairo_fill(cr);
+
+        if (is_close && hov) {
+            cairo_set_source_rgba(cr, 1, 1, 1, 1.0);
+        } else {
+            cairo_set_source_rgba(cr, fg[0], fg[1], fg[2], active ? 0.92 : 0.55);
+        }
+        cairo_set_line_width(cr, 1.4);
+        double g = 4.0;
+        if (i == 0) {            /* minimize: bottom dash */
+            cairo_move_to(cr, cx - g, cy + 2.5);
+            cairo_line_to(cr, cx + g, cy + 2.5);
+            cairo_stroke(cr);
+        } else if (i == 1) {     /* maximize: square */
+            cairo_rectangle(cr, cx - g, cy - g, 2 * g, 2 * g);
+            cairo_stroke(cr);
+        } else {                 /* close: x */
+            cairo_move_to(cr, cx - g, cy - g);
+            cairo_line_to(cr, cx + g, cy + g);
+            cairo_move_to(cr, cx + g, cy - g);
+            cairo_line_to(cr, cx - g, cy + g);
+            cairo_stroke(cr);
+        }
+    }
+
+    cairo_destroy(cr);
+    cairo_surface_flush(buf->surface);
+
+    wlr_scene_buffer_set_buffer(view->deco_titlebar, &buf->base);
+    wlr_buffer_drop(&buf->base);
+}
+
+static void deco_repaint(struct mjc_view *view) {
+    if (view->decorated && view->deco_w > 0) {
+        deco_render(view, view->deco_w);
+    }
+}
+
+static void view_decoration_destroy(struct mjc_view *view) {
+    if (view->deco_titlebar != NULL) {
+        wlr_scene_node_destroy(&view->deco_titlebar->node);
+        view->deco_titlebar = NULL;
+    }
+    for (int i = 0; i < 3; i++) {
+        if (view->deco_border[i] != NULL) {
+            wlr_scene_node_destroy(&view->deco_border[i]->node);
+            view->deco_border[i] = NULL;
+        }
+    }
+    view->decorated = false;
+    view->deco_w = 0;
+    view->deco_h = 0;
+    view->hovered_button = -1;
+}
+
+/* create/refresh/remove a window's decoration to match its current state */
+static void view_decoration_update(struct mjc_view *view) {
+    if (!view_should_decorate(view)) {
+        view_decoration_destroy(view);
+        return;
+    }
+    int w, h, ox, oy;
+    view_content_size(view, &w, &h);
+    view_geo_offset(view, &ox, &oy);
+    if (w <= 0 || h <= 0) {
+        return;
+    }
+    struct mjc_server *server = view->server;
+    int tb = MJC_DECO_TITLEBAR_H;
+    int b = MJC_DECO_BORDER;
+
+    if (view->deco_titlebar == NULL) {
+        view->deco_titlebar = wlr_scene_buffer_create(view->scene_tree, NULL);
+        const float border_col[4] = {
+            server->deco_bg[0] * 0.6f, server->deco_bg[1] * 0.6f,
+            server->deco_bg[2] * 0.6f, 1.0f,
+        };
+        for (int i = 0; i < 3; i++) {
+            view->deco_border[i] =
+                wlr_scene_rect_create(view->scene_tree, 1, 1, border_col);
+        }
+        view->hovered_button = -1;
+        view->decorated = true;
+        view->deco_w = 0;
+    }
+
+    if (w != view->deco_w) {
+        deco_render(view, w);
+    }
+    view->deco_w = w;
+    view->deco_h = h;
+
+    /* place relative to the content surface inside scene_tree; the titlebar sits
+     * directly above the content, the borders frame the sides and bottom */
+    wlr_scene_node_set_position(&view->deco_titlebar->node, ox, oy - tb);
+    wlr_scene_rect_set_size(view->deco_border[0], b, tb + h);
+    wlr_scene_node_set_position(&view->deco_border[0]->node, ox - b, oy - tb);
+    wlr_scene_rect_set_size(view->deco_border[1], b, tb + h);
+    wlr_scene_node_set_position(&view->deco_border[1]->node, ox + w, oy - tb);
+    wlr_scene_rect_set_size(view->deco_border[2], w + 2 * b, b);
+    wlr_scene_node_set_position(&view->deco_border[2]->node, ox - b, oy + h);
+}
+
+/* layout-space top-left and size of a decorated view's titlebar */
+static bool deco_titlebar_box(struct mjc_view *view,
+        double *tx, double *ty, int *w) {
+    if (!view->decorated || view->deco_titlebar == NULL) {
+        return false;
+    }
+    int ox, oy, h;
+    view_geo_offset(view, &ox, &oy);
+    view_content_size(view, w, &h);
+    *tx = view->scene_tree->node.x + ox;
+    *ty = view->scene_tree->node.y + oy - MJC_DECO_TITLEBAR_H;
+    return true;
+}
+
+/* button index (0..2) under (lx,ly), or -1 if over the titlebar but not a button */
+static int deco_button_index(double tx, double ty, int w, double lx, double ly) {
+    double cy = ty + MJC_DECO_TITLEBAR_H / 2.0;
+    for (int i = 0; i < 3; i++) {
+        double cx = tx + deco_button_cx(w, i);
+        double dx = lx - cx, dy = ly - cy;
+        double rr = (MJC_DECO_BTN_R + 2) * (MJC_DECO_BTN_R + 2);
+        if (dx * dx + dy * dy <= rr) {
+            return i;
+        }
+    }
+    return -1;
+}
+
+/* resize-edge mask under (lx,ly) for the first decorated, non-maximized window
+ * whose frame border zone the pointer is in; 0 when the pointer is not on an edge */
+static uint32_t deco_resize_edges(struct mjc_server *server,
+        double lx, double ly, struct mjc_view **out) {
+    struct mjc_view *view;
+    wl_list_for_each(view, &server->views, link) {
+        if (!view->decorated || view->scene_tree == NULL || view->maximized) {
+            continue;
+        }
+        int ox, oy, w, h;
+        view_geo_offset(view, &ox, &oy);
+        view_content_size(view, &w, &h);
+        double x0 = view->scene_tree->node.x + ox - MJC_DECO_BORDER;
+        double y0 = view->scene_tree->node.y + oy - MJC_DECO_TITLEBAR_H;
+        double x1 = view->scene_tree->node.x + ox + w + MJC_DECO_BORDER;
+        double y1 = view->scene_tree->node.y + oy + h + MJC_DECO_BORDER;
+        double r = MJC_DECO_RESIZE;
+        if (lx < x0 - r || lx > x1 + r || ly < y0 - r || ly > y1 + r) {
+            continue;
+        }
+        uint32_t edges = 0;
+        if (ly <= y0 + r) {
+            edges |= WLR_EDGE_TOP;
+        } else if (ly >= y1 - r) {
+            edges |= WLR_EDGE_BOTTOM;
+        }
+        if (lx <= x0 + r) {
+            edges |= WLR_EDGE_LEFT;
+        } else if (lx >= x1 - r) {
+            edges |= WLR_EDGE_RIGHT;
+        }
+        if (edges != 0) {
+            if (out != NULL) {
+                *out = view;
+            }
+            return edges;
+        }
+    }
+    return 0;
+}
+
+static const char *deco_resize_cursor(uint32_t edges) {
+    bool top = edges & WLR_EDGE_TOP, bottom = edges & WLR_EDGE_BOTTOM;
+    bool left = edges & WLR_EDGE_LEFT, right = edges & WLR_EDGE_RIGHT;
+    if (top && left) return "nw-resize";
+    if (top && right) return "ne-resize";
+    if (bottom && left) return "sw-resize";
+    if (bottom && right) return "se-resize";
+    if (top) return "n-resize";
+    if (bottom) return "s-resize";
+    if (left) return "w-resize";
+    if (right) return "e-resize";
+    return "default";
+}
+
+static void deco_update_hover(struct mjc_server *server) {
+    struct mjc_view *view;
+    wl_list_for_each(view, &server->views, link) {
+        double tx, ty;
+        int w;
+        if (!deco_titlebar_box(view, &tx, &ty, &w)) {
+            continue;
+        }
+        int want = -1;
+        if (server->cursor->x >= tx && server->cursor->x < tx + w &&
+                server->cursor->y >= ty &&
+                server->cursor->y < ty + MJC_DECO_TITLEBAR_H) {
+            want = deco_button_index(tx, ty, w, server->cursor->x, server->cursor->y);
+        }
+        if (want != view->hovered_button) {
+            view->hovered_button = want;
+            deco_repaint(view);
+        }
+    }
+}
+
+static bool deco_handle_button(struct mjc_server *server,
+        double lx, double ly, bool pressed) {
+    if (!pressed) {
+        return false;
+    }
+
+    /* edge/corner of a window frame => start an interactive resize. Mirrors the
+     * RESIZE branch of begin_interactive but without the focused-surface guard
+     * (the pointer is over the decoration, not the client surface). */
+    struct mjc_view *rv = NULL;
+    uint32_t redges = deco_resize_edges(server, lx, ly, &rv);
+    if (redges != 0 && rv != NULL) {
+        if (rv->focusable) {
+            mjc_view_focus(rv);
+        }
+        struct wlr_box geo_box;
+        view_current_geometry(rv, &geo_box);
+        double border_x = geo_box.x +
+            ((redges & WLR_EDGE_RIGHT) ? geo_box.width : 0);
+        double border_y = geo_box.y +
+            ((redges & WLR_EDGE_BOTTOM) ? geo_box.height : 0);
+        server->grabbed_view = rv;
+        server->cursor_mode = MJC_CURSOR_RESIZE;
+        server->grab_x = server->cursor->x - border_x;
+        server->grab_y = server->cursor->y - border_y;
+        server->grab_geobox = geo_box;
+        server->resize_edges = redges;
+        return true;
+    }
+
+    struct mjc_view *view;
+    wl_list_for_each(view, &server->views, link) {
+        double tx, ty;
+        int w;
+        if (!deco_titlebar_box(view, &tx, &ty, &w)) {
+            continue;
+        }
+        if (lx < tx || lx >= tx + w || ly < ty ||
+                ly >= ty + MJC_DECO_TITLEBAR_H) {
+            continue;
+        }
+        int btn = deco_button_index(tx, ty, w, lx, ly);
+        if (view->focusable) {
+            mjc_view_focus(view);
+        }
+        if (btn == 0) {
+            mjc_view_set_minimized(view, true);
+        } else if (btn == 1) {
+            mjc_view_set_maximized(view, !view->maximized);
+        } else if (btn == 2) {
+            mjc_view_close(view);
+        } else {
+            /* drag the titlebar => interactive move (no focused-surface guard
+             * because the pointer is over the decoration, not the client) */
+            server->grabbed_view = view;
+            server->cursor_mode = MJC_CURSOR_MOVE;
+            server->grab_x = server->cursor->x - view->scene_tree->node.x;
+            server->grab_y = server->cursor->y - view->scene_tree->node.y;
+        }
+        return true;
+    }
+    return false;
+}
+
 /* ------------------------------------------------------------------ */
 /* view common                                                         */
 /* ------------------------------------------------------------------ */
@@ -822,8 +1379,9 @@ static void xdg_toplevel_map(struct wl_listener *listener, void *data) {
     struct mjc_view *view = wl_container_of(listener, view, map);
     view->mapped = true;
     view_center(view);
-    view_emit_map(view);
+    view_emit_map(view);    /* policy assigns the layer here (synchronous) */
     mjc_view_focus(view);
+    view_decoration_update(view);
 }
 
 static void xdg_toplevel_unmap(struct wl_listener *listener, void *data) {
@@ -831,6 +1389,7 @@ static void xdg_toplevel_unmap(struct wl_listener *listener, void *data) {
     struct mjc_view *view = wl_container_of(listener, view, unmap);
     view->mapped = false;
     clear_focus_if(view->server, view);
+    view_decoration_destroy(view);
     view_emit_unmap(view);
 }
 
@@ -861,6 +1420,10 @@ static void xdg_toplevel_commit(struct wl_listener *listener, void *data) {
             }
         }
     }
+    /* keep the frame sized/placed to the (possibly just-resized) window */
+    if (view->decorated || view_should_decorate(view)) {
+        view_decoration_update(view);
+    }
 }
 
 static void xdg_toplevel_destroy(struct wl_listener *listener, void *data) {
@@ -918,6 +1481,7 @@ static void xdg_toplevel_request_fullscreen(struct wl_listener *listener, void *
 static void xdg_toplevel_set_title(struct wl_listener *listener, void *data) {
     (void) data;
     struct mjc_view *view = wl_container_of(listener, view, set_title);
+    deco_repaint(view);
     if (view->server->cbs.view_title != NULL) {
         view->server->cbs.view_title(view->server->ud, view,
             view->xdg_toplevel->title);
@@ -940,6 +1504,12 @@ static void server_new_xdg_toplevel(struct wl_listener *listener, void *data) {
 
     struct mjc_view *view = view_create(server, false);
     view->xdg_toplevel = xdg_toplevel;
+    /* wayland toplevels default to frameless: a client that draws its own CSD (GTK)
+     * never negotiates xdg-decoration, and adding a server frame would double it up.
+     * The frame is enabled only when the client requests SERVER_SIDE (Qt, libdecor,
+     * SDL…) in decoration_request_mode. xwayland/X11 windows have no CSD and are
+     * always decorated. */
+    view->deco_disabled = true;
     view->scene_tree = wlr_scene_xdg_surface_create(
         server->layers[MJC_LAYER_NORMAL], xdg_toplevel->base);
     view->scene_tree->node.data = view;
@@ -1011,15 +1581,24 @@ static void server_new_xdg_popup(struct wl_listener *listener, void *data) {
     wl_signal_add(&xdg_popup->events.destroy, &popup->destroy);
 }
 
-/* decorations - clients draw their own buttons (CSD), the compositor
- * renders no frames, so server side mode is never granted */
+/* decorations - the compositor draws GNOME-style server-side frames. We grant
+ * SERVER_SIDE unless the client explicitly demands client-side decorations, in
+ * which case we honor it and leave that window chromeless (no server frame). */
 
 static void decoration_request_mode(struct wl_listener *listener, void *data) {
     (void) data;
     struct mjc_decoration *deco = wl_container_of(listener, deco, request_mode);
-    if (deco->decoration->toplevel->base->initialized) {
-        wlr_xdg_toplevel_decoration_v1_set_mode(deco->decoration,
-            WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE);
+    if (!deco->decoration->toplevel->base->initialized) {
+        return;
+    }
+    bool client_wants_csd = deco->decoration->requested_mode ==
+        WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE;
+    wlr_xdg_toplevel_decoration_v1_set_mode(deco->decoration, client_wants_csd
+        ? WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE
+        : WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE);
+    if (deco->view != NULL) {
+        deco->view->deco_disabled = client_wants_csd;
+        view_decoration_update(deco->view);
     }
 }
 
@@ -1037,6 +1616,9 @@ static void server_new_toplevel_decoration(struct wl_listener *listener,
     struct wlr_xdg_toplevel_decoration_v1 *decoration = data;
     struct mjc_decoration *deco = calloc(1, sizeof(*deco));
     deco->decoration = decoration;
+    /* the view was stashed on the xdg surface in server_new_xdg_toplevel */
+    struct wlr_scene_tree *tree = decoration->toplevel->base->data;
+    deco->view = tree != NULL ? tree->node.data : NULL;
     deco->request_mode.notify = decoration_request_mode;
     wl_signal_add(&decoration->events.request_mode, &deco->request_mode);
     deco->destroy.notify = decoration_destroy;
@@ -1071,6 +1653,7 @@ static void xwayland_surface_map(struct wl_listener *listener, void *data) {
     if (!xsurface->override_redirect) {
         mjc_view_focus(view);
     }
+    view_decoration_update(view);
 }
 
 static void xwayland_surface_unmap(struct wl_listener *listener, void *data) {
@@ -1081,6 +1664,8 @@ static void xwayland_surface_unmap(struct wl_listener *listener, void *data) {
     /* emit while the scene node still exists so the policy side can
      * read the last window geometry */
     view_emit_unmap(view);
+    /* tear down the frame before the scene_tree (its children) is destroyed */
+    view_decoration_destroy(view);
     if (view->scene_tree != NULL) {
         wlr_scene_node_destroy(&view->scene_tree->node);
         view->scene_tree = NULL;
@@ -1117,6 +1702,7 @@ static void xwayland_surface_destroy(struct wl_listener *listener, void *data) {
     wl_list_remove(&view->set_title.link);
     wl_list_remove(&view->set_app_id.link);
     wl_list_remove(&view->set_geometry.link);
+    view_decoration_destroy(view);
     if (view->scene_tree != NULL) {
         wlr_scene_node_destroy(&view->scene_tree->node);
         view->scene_tree = NULL;
@@ -1133,6 +1719,9 @@ static void xwayland_surface_request_configure(struct wl_listener *listener,
     if (view->scene_tree != NULL) {
         wlr_scene_node_set_position(&view->scene_tree->node, event->x, event->y);
     }
+    if (view->decorated) {
+        view_decoration_update(view);
+    }
 }
 
 static void xwayland_surface_set_geometry(struct wl_listener *listener,
@@ -1143,6 +1732,9 @@ static void xwayland_surface_set_geometry(struct wl_listener *listener,
         wlr_scene_node_set_position(&view->scene_tree->node,
             view->xsurface->x, view->xsurface->y);
     }
+    if (view->decorated) {
+        view_decoration_update(view);
+    }
 }
 
 static void xwayland_surface_request_move(struct wl_listener *listener,
@@ -1182,6 +1774,7 @@ static void xwayland_surface_request_maximize(struct wl_listener *listener,
 static void xwayland_surface_set_title(struct wl_listener *listener, void *data) {
     (void) data;
     struct mjc_view *view = wl_container_of(listener, view, set_title);
+    deco_repaint(view);
     if (view->server->cbs.view_title != NULL) {
         view->server->cbs.view_title(view->server->ud, view,
             view->xsurface->title);
@@ -1312,6 +1905,15 @@ bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdat
     server->cbs = *callbacks;
     server->ud = userdata;
 
+    /* default decoration theme (dark slate + near-white); the shell overrides this
+     * from its live background palette via mjc_set_decoration_theme over IPC */
+    server->deco_bg[0] = 0.16f;
+    server->deco_bg[1] = 0.17f;
+    server->deco_bg[2] = 0.20f;
+    server->deco_fg[0] = 0.92f;
+    server->deco_fg[1] = 0.93f;
+    server->deco_fg[2] = 0.95f;
+
     wlr_log_init(WLR_INFO, NULL);
 
     /* nested-output size, 16:9 default; override with MJDEVC_OUTPUT=WIDTHxHEIGHT */
@@ -1720,6 +2322,11 @@ void mjc_view_set_maximized(mjc_view *view, bool maximized) {
             view->pending_center = true;
         }
     }
+    /* xwayland sizes itself synchronously here (no commit round-trip), so refresh
+     * the frame now; xdg follows up on its next commit */
+    if (view->decorated) {
+        view_decoration_update(view);
+    }
 }
 
 void mjc_view_set_layer(mjc_view *view, mjc_layer layer) {
@@ -1728,6 +2335,9 @@ void mjc_view_set_layer(mjc_view *view, mjc_layer layer) {
         wlr_scene_node_reparent(&view->scene_tree->node,
             view->server->layers[layer]);
     }
+    /* only normal-layer app windows get a server-side frame; shell surfaces
+     * (wallpaper, panels, menus) live in other layers and stay undecorated */
+    view_decoration_update(view);
 }
 
 void mjc_view_set_focusable(mjc_view *view, bool focusable) {
@@ -1737,6 +2347,44 @@ void mjc_view_set_focusable(mjc_view *view, bool focusable) {
     }
 }
 
+void mjc_view_set_chrome(mjc_view *view, bool chrome) {
+    view->is_chrome = chrome;
+    view_decoration_update(view);
+}
+
+void mjc_view_set_decorated(mjc_view *view, bool decorated) {
+    view->deco_disabled = !decorated;
+    view_decoration_update(view);
+}
+
+void mjc_set_decoration_theme(mjc_server *server,
+        float bg_r, float bg_g, float bg_b,
+        float fg_r, float fg_g, float fg_b) {
+    server->deco_bg[0] = bg_r;
+    server->deco_bg[1] = bg_g;
+    server->deco_bg[2] = bg_b;
+    server->deco_fg[0] = fg_r;
+    server->deco_fg[1] = fg_g;
+    server->deco_fg[2] = fg_b;
+    /* re-skin every currently decorated window with the new palette */
+    struct mjc_view *view;
+    wl_list_for_each(view, &server->views, link) {
+        if (view->decorated) {
+            view->deco_w = 0; /* force a re-render at the same width */
+            view_decoration_update(view);
+            /* refresh the border tint too */
+            const float border_col[4] = {
+                bg_r * 0.6f, bg_g * 0.6f, bg_b * 0.6f, 1.0f,
+            };
+            for (int i = 0; i < 3; i++) {
+                if (view->deco_border[i] != NULL) {
+                    wlr_scene_rect_set_color(view->deco_border[i], border_col);
+                }
+            }
+        }
+    }
+}
+
 void mjc_view_set_position(mjc_view *view, int x, int y) {
     if (view->scene_tree != NULL) {
         wlr_scene_node_set_position(&view->scene_tree->node, x, y);
@@ -1777,6 +2425,9 @@ void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height)
                 x - off.x, y - off.y);
         }
     }
+    if (view->decorated) {
+        view_decoration_update(view);
+    }
 }
 
 /* ------------------------------------------------------------------ */

+ 8 - 0
compositor/native/shim.h

@@ -113,6 +113,14 @@ void mjc_view_set_focusable(mjc_view *view, bool focusable);
 void mjc_view_set_position(mjc_view *view, int x, int y);
 void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height);
 
+/* server-side decorations: mark a surface as shell chrome (no frame), toggle a
+ * window's frame (chromeless), and set the frame color theme (rgb in 0..1). */
+void mjc_view_set_chrome(mjc_view *view, bool chrome);
+void mjc_view_set_decorated(mjc_view *view, bool decorated);
+void mjc_set_decoration_theme(mjc_server *server,
+    float bg_r, float bg_g, float bg_b,
+    float fg_r, float fg_g, float fg_b);
+
 /* input injection (ipc-driven, for automated/headless testing) */
 void mjc_pointer_move(mjc_server *server, int x, int y);
 void mjc_pointer_button(mjc_server *server, uint32_t button, bool pressed);

+ 14 - 0
compositor/scripts/run-nested-visible.sh

@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# Launches the nested mjdev compositor VISIBLY (window on the user's screen) with the
+# desktop shell + a test app. Run this inside a visible terminal so the user can watch
+# it live. Logs are tee'd so they can also be inspected.
+set -u
+cd /home/mimo/Plocha/mjdev-desktop
+export DISPLAY=:0 WLR_BACKENDS=x11 MJDEVC_OUTPUT=1280x720
+for f in /run/user/1000/.mutter-Xwaylandauth.*; do [ -f "$f" ] && export XAUTHORITY="$f"; done
+unset WAYLAND_DISPLAY
+BIN=compositor/build/bin/linuxX64/mjdevcReleaseExecutable/mjdevc.kexe
+LAUNCH=packages/main/app/mjdev-desktop/bin/mjdev-desktop
+echo "== launching mjdevc =="
+"$BIN" --shell-cmd "$LAUNCH" --startup "sleep 9; exec gnome-calculator" 2>&1 \
+  | tee /home/mimo/Plocha/mjdev-desktop/compositor/build/nested.log

+ 43 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Ipc.kt

@@ -27,14 +27,20 @@ import kotlinx.serialization.json.put
 import mjdev.compositor.shim.MJC_EVENT_ERROR
 import mjdev.compositor.shim.MJC_EVENT_HANGUP
 import mjdev.compositor.shim.MJC_EVENT_READABLE
+import mjdev.compositor.shim.MJC_LAYER_BOTTOM
+import mjdev.compositor.shim.MJC_LAYER_NORMAL
+import mjdev.compositor.shim.MJC_LAYER_TOP
 import mjdev.compositor.shim.mjc_loop_add_fd
 import mjdev.compositor.shim.mjc_loop_remove_fd
 import mjdev.compositor.shim.mjc_key
 import mjdev.compositor.shim.mjc_pointer_button
 import mjdev.compositor.shim.mjc_pointer_move
+import mjdev.compositor.shim.mjc_set_decoration_theme
 import mjdev.compositor.shim.mjc_view_focus
 import mjdev.compositor.shim.mjc_view_close
 import mjdev.compositor.shim.mjc_view_is_maximized
+import mjdev.compositor.shim.mjc_view_set_decorated
+import mjdev.compositor.shim.mjc_view_set_layer
 import mjdev.compositor.shim.mjc_view_set_maximized
 import mjdev.compositor.shim.mjc_view_set_minimized
 import mjdev.compositor.shim.mjc_view_set_position
@@ -294,6 +300,43 @@ class IpcServer(private val c: Compositor) {
                 ok { }
             }
 
+            // server-side decoration frame color, rgb channels 0..255; fg is optional
+            // and defaults to a contrasting black/white based on bg luminance
+            "set-decoration-theme" -> {
+                val br = request["bg_r"]?.jsonPrimitive?.int ?: return error("missing bg_r")
+                val bgc = request["bg_g"]?.jsonPrimitive?.int ?: return error("missing bg_g")
+                val bb = request["bg_b"]?.jsonPrimitive?.int ?: return error("missing bg_b")
+                val lum = (0.2126 * br + 0.7152 * bgc + 0.0722 * bb) / 255.0
+                val defFg = if (lum > 0.5) 0 else 255
+                val fr = request["fg_r"]?.jsonPrimitive?.int ?: defFg
+                val fgc = request["fg_g"]?.jsonPrimitive?.int ?: defFg
+                val fb = request["fg_b"]?.jsonPrimitive?.int ?: defFg
+                mjc_set_decoration_theme(
+                    c.server,
+                    br / 255f, bgc / 255f, bb / 255f,
+                    fr / 255f, fgc / 255f, fb / 255f,
+                )
+                ok { }
+            }
+
+            // chromeless / frameless toggle for a specific window
+            "set-decorated" -> withWindow(request) { info ->
+                val decorated = request["decorated"]
+                    ?.jsonPrimitive?.content?.toBoolean() ?: true
+                mjc_view_set_decorated(info.ptr, decorated)
+            }
+
+            // window stacking: always-on-top / always-on-bottom (apps keep their frame)
+            "always-on-top" -> withWindow(request) { info ->
+                val on = request["on"]?.jsonPrimitive?.content?.toBoolean() ?: true
+                mjc_view_set_layer(info.ptr, if (on) MJC_LAYER_TOP else MJC_LAYER_NORMAL)
+            }
+
+            "always-on-bottom" -> withWindow(request) { info ->
+                val on = request["on"]?.jsonPrimitive?.content?.toBoolean() ?: true
+                mjc_view_set_layer(info.ptr, if (on) MJC_LAYER_BOTTOM else MJC_LAYER_NORMAL)
+            }
+
             else -> error("unknown cmd $cmd")
         }
     }

+ 4 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Policy.kt

@@ -16,6 +16,7 @@ import mjdev.compositor.shim.MJC_LAYER_TOP
 import mjdev.compositor.shim.MJC_MOD_ALT
 import mjdev.compositor.shim.mjc_view_close
 import mjdev.compositor.shim.mjc_view_focus
+import mjdev.compositor.shim.mjc_view_set_chrome
 import mjdev.compositor.shim.mjc_view_set_focusable
 import mjdev.compositor.shim.mjc_view_set_layer
 
@@ -48,6 +49,9 @@ class Policy(private val c: Compositor) {
             return
         }
         info.role = titleRole ?: "window"
+        // shell surfaces (wallpaper, panels, menus, control center) are our own UI and
+        // must never get a server-side frame; app windows stay decorated
+        mjc_view_set_chrome(info.ptr, true)
         when (info.role) {
             // ChromeWindow names: DesktopWindow/FullScreenWindow carry the wallpaper
             "wallpaper", "background", "desktop",

+ 1 - 1
gradle/libs.versions.toml

@@ -14,7 +14,7 @@ app-vendor = "mjdev"
 # deb Depends, the installDesktop apt step and make-iso.sh. Space-separated (shell-friendly); the
 # deb packaging joins it with ", ". A clean/GNOME box lacks these (GNOME uses mutter, not wlroots),
 # so without them mjdevc fails to load (libwlroots-0.18.so missing) and the session black-screens.
-app-compositor-runtime-deps = "libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0 libgl1-mesa-dri dbus dbus-user-session"
+app-compositor-runtime-deps = "libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0 libgl1-mesa-dri libcairo2 dbus dbus-user-session"
 java-language-version = "17"
 # app specific android
 android-compile-sdk = "36"

+ 60 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/compositor/CompositorControl.kt

@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.desktop.helpers.compositor
+
+import androidx.compose.ui.graphics.Color
+import org.mjdev.desktop.log.Log
+import java.net.StandardProtocolFamily
+import java.net.UnixDomainSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.SocketChannel
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * One-shot control commands to the mjdev compositor over its IPC socket.
+ *
+ * The desktop shell runs inside mjdevc; the compositor draws the server-side window
+ * frames and colorizes them from the theme the shell sends here. When no compositor
+ * socket is present (running outside mjdevc) every call is a silent no-op, so this is
+ * safe to call unconditionally.
+ */
+object CompositorControl {
+    private fun socketPath(): Path? {
+        val dir = System.getenv("XDG_RUNTIME_DIR") ?: return null
+        val path = Path.of(dir, "mjdev-compositor.sock")
+        return if (Files.exists(path)) path else null
+    }
+
+    private fun send(line: String) {
+        val path = socketPath() ?: return
+        runCatching {
+            SocketChannel.open(StandardProtocolFamily.UNIX).use { channel ->
+                channel.connect(UnixDomainSocketAddress.of(path))
+                channel.write(ByteBuffer.wrap((line + "\n").toByteArray(StandardCharsets.UTF_8)))
+            }
+        }.onFailure { e -> Log.w("CompositorControl: send failed: ${e.message}") }
+    }
+
+    private fun chan(v: Float) = (v.coerceIn(0f, 1f) * 255f).toInt()
+
+    /**
+     * Pushes the window-frame color theme to the compositor. [bg] tints the titlebar/
+     * border, [fg] the title text and button glyphs (auto-contrast on the compositor
+     * side if omitted, but we send the shell's text color for consistency).
+     */
+    fun setDecorationTheme(bg: Color, fg: Color) {
+        send(
+            """{"cmd":"set-decoration-theme",""" +
+                """"bg_r":${chan(bg.red)},"bg_g":${chan(bg.green)},"bg_b":${chan(bg.blue)},""" +
+                """"fg_r":${chan(fg.red)},"fg_g":${chan(fg.green)},"fg_b":${chan(fg.blue)}}""",
+        )
+    }
+}

+ 4 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/theme/linux/ThemeManagerLinux.kt

@@ -25,6 +25,7 @@ import org.mjdev.desktop.extensions.PathExt.mkdirs
 import org.mjdev.desktop.extensions.PathExt.parentFile
 import org.mjdev.desktop.extensions.PathExt.writeText
 import org.mjdev.desktop.extensions.StringExt.hexRgb
+import org.mjdev.desktop.helpers.compositor.CompositorControl
 import org.mjdev.desktop.helpers.system.shell.Shell
 import org.mjdev.desktop.log.Log
 import org.mjdev.desktop.managers.theme.base.ThemeManagerStub
@@ -73,6 +74,9 @@ class ThemeManagerLinux(
             createCssFile(gtk4CssFile)
             createCssFile(systemGtk3CssFile)
             createCssFile(systemGtk4CssFile)
+            // colorize the compositor's server-side window frames from the same palette
+            // (no-op when not running inside mjdevc)
+            CompositorControl.setDecorationTheme(palette.backgroundColor, palette.textColor)
         }.onFailure { e ->
             Log.e(e)
         }