Nimbin[12]?Sway / SWOv / commits / a38020a

SWOv git · main

SWOv - window overview for sway

sway wayland c overview · first commit 2026-08-21 · last commit 2026-09-20 (19 days ago) · synced 3 days ago · upstream: github.com/nimbin2/SWOv

C 91.5% Markdown 7.6%
git clone https://git.christianimmanuel.de/sway/SWOv.gitwget https://git.christianimmanuel.de/sway/SWOv/archive/SWOv.tar.gz

init

Christian Immanuel · 2026-08-21 00:54 · a38020ae61f655ddf5fb369e3e740d7aa5dd3188

 README.md      |  254 ++++
 config.example |   87 ++
 screenshot.png |  Bin 0 -> 202129 bytes
 swov.c         | 4016 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 4357 insertions(+)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ce5a9de
--- /dev/null
+++ b/README.md
@@ -0,0 +1,254 @@
+# swov
+
+A fast window and workspace overview for [Sway](https://swaywm.org), drawn with
+SDL3.
+
+Every workspace becomes a tile, and inside that tile the windows sit exactly
+where they sit on the real screen — same proportions, same positions. Click a
+window to focus it, click a tile to switch to that workspace, or drive the whole
+thing from the keyboard.
+
+![Screenshot](screenshot.png)
+
+## Features
+
+- Talks to the Sway IPC socket directly. No `swaymsg`, no `jq`, no shelling out.
+- Tiles keep the aspect ratio of the monitor, so a tile is a true miniature of
+  the workspace.
+- Select a whole workspace and press a digit to move **everything** on it to
+  another workspace. Top level containers are moved as containers, so splits and
+  tabs survive the move intact.
+- Drag and drop: move a window to another workspace, drop it on the left, right,
+  top or bottom edge of another window to place it there — the container is
+  split horizontally or vertically to match — or drag a whole workspace to
+  reorder it.
+- Cards adapt to the space they get: icon + name + title when there is room,
+  icon left / text right on wide flat cards, name only when things get tight.
+  Long titles are shortened with a real ellipsis measured against the available
+  width — never squashed, never cut mid-character.
+- Tabbed and stacked containers, which report one identical rectangle for all
+  their children, are split into slices with a shared outline.
+- Floating and fullscreen windows are drawn as see-through frames with a name
+  plate at the top edge, so the windows underneath stay readable instead of
+  disappearing behind a solid card.
+- Windows whose `app_id` says nothing useful — GTK apps started without one
+  report `GTK Application`, and several Electron builds are no better — are
+  identified from the process behind the window and from the window title, so a
+  card reads `Claude` rather than `GTK Application`.
+- Icons come from `.desktop` files (including `StartupWMClass` matching) and the
+  icon theme; SVGs are rasterised at the size they are drawn at. Applications
+  without an icon get a generated letter tile.
+- Hover feedback on tiles and windows, spatial keyboard selection that crosses
+  tile borders, multi-selection, and type-to-filter.
+- Everything is configurable — sizes, spacing, fonts, colors, where the text
+  lines sit — from a config file or straight from the command line.
+
+## Building
+
+Debian trixie and newer ship SDL3:
+
+```sh
+sudo apt install build-essential pkg-config \
+     libsdl3-dev libsdl3-image-dev libsdl3-ttf-dev
+```
+
+On Debian bookworm the SDL3 packages are not available yet; build
+[SDL](https://github.com/libsdl-org/SDL),
+[SDL_image](https://github.com/libsdl-org/SDL_image) and
+[SDL_ttf](https://github.com/libsdl-org/SDL_ttf) from source (release 3.2 or
+newer) and point `PKG_CONFIG_PATH` at your install prefix.
+
+```sh
+cc -std=c11 -O2 -Wall -Wextra -o swov swov.c \
+   $(pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf)
+install -Dm755 swov ~/.local/bin/swov
+```
+
+Runtime requirements: a running Sway session (`$SWAYSOCK`) and any TTF font.
+`fc-match` is used once to resolve the desktop font; without fontconfig a few
+well known font paths are tried instead.
+
+## Using it
+
+```
+bindsym $mod+Tab exec swov
+for_window [app_id="swov"] floating enable, border none
+```
+
+The overlay covers the focused output, dims what is behind it, and closes itself
+when it loses focus.
+
+### Keys
+
+| key | action |
+| --- | --- |
+| `Enter` / left click | focus the selected window; on a whole workspace, switch to it |
+| left click on a tile | switch to that workspace and leave |
+| drag a window | move it: onto a tile, or onto an edge of another window |
+| drag a workspace | reorder it: swap, insert, or drop on a free number |
+| right click on a tile | select that workspace without switching |
+| arrows or `h j k l` | move the selection; the first press steps into the workspace, further presses cross into neighbouring tiles |
+| `Tab` / `Shift+Tab` | next / previous workspace, at workspace level |
+| `w` | toggle between window selection and whole-workspace selection |
+| mouse wheel | next / previous workspace |
+| `space` / right click | mark a window; at workspace level, mark all of them |
+| `a` | mark or unmark every window of the workspace |
+| `c` | clear all marks |
+| `0`–`9` | move marked or selected windows there; with a workspace selected, move the whole workspace, layout and all |
+| `Ctrl+0`–`9` | switch to that workspace and leave |
+| `x`, `Delete`, middle click | close marked or selected windows |
+| `/` | filter by application, title or workspace name |
+| `r` | reload the tree |
+| `Esc` | cancel a drag, otherwise quit |
+| `q`, click outside | quit |
+
+Nothing is selected inside a workspace when swov opens: the visible workspace is
+highlighted as a whole, so a digit key immediately moves that workspace
+somewhere else. Press an arrow key or `w` to descend to single windows.
+`start_selection=none` starts with no highlight at all, `start_selection=window`
+picks the first window.
+
+A click only acts when the press and the release land on the same window or the
+same workspace, so nothing is focused by accident at the end of a drag.
+
+Digits are read bare or with `ctrl`; a shifted digit is left alone, because on
+plenty of layouts `shift+7` is how you type `/`.
+
+### Drag and drop
+
+Press and move to start a drag; the drop happens on release, and `Esc` cancels.
+
+**Dragging a window**
+
+- onto a workspace tile — moves it there
+- onto a free number — creates that workspace and puts the window in it
+- onto the left or right edge of another window — lands beside it, the container
+  is split horizontally
+- onto the top or bottom edge — lands above or below it, split vertically
+
+A bar shows the edge you are about to drop on, so the resulting split is visible
+before you let go.
+
+**Dragging a workspace** (grab it by its header strip, not by a window)
+
+- onto the middle of another workspace — the two swap numbers
+- onto the left or right quarter of another workspace — inserts there, pushing
+  the occupied run up by one: with 1, 2 and 5 in use, dropping before 2 makes
+  the old 2 become 3 and stops, because 4 is free
+- onto a **ghost slot** — see below
+
+**Ghost slots**
+
+Pick a workspace up and every free number from 0 to 10 joins the grid as a small
+placeholder tile; the existing tiles glide aside to make room rather than
+snapping (`anim_ms`, 160 ms by default, 0 to switch it off). Drop on a ghost and
+the workspace simply takes that number — no shifting, nothing else moves.
+
+Dragging a window does the same as soon as the window leaves the workspace it
+lives on, which is how you spin a new workspace out of a single window: drop it
+on a free number and sway creates the workspace on the spot. Once they are out they stay out for the rest of the drag, and the test for
+"has it left?" uses the rectangle the workspace occupied when the drag started,
+not where the tile currently sits. Both matter: the ghosts push the tiles around,
+so a threshold that moves with them chases the pointer and the grid shudders —
+especially when the drag heads the same way the tiles are sliding.
+
+On the drop the ghosts shrink and fade out rather than blinking away, and the
+remaining tiles glide back into place.
+
+Workspaces named like `7:chat` keep their label: dropped on 8 they become
+`8:chat`.
+
+## Configuration
+
+swov reads `${XDG_CONFIG_HOME:-~/.config}/swov/config`, a plain `key=value` file
+with `#` comments. See `config.example`, which lists every key with its default.
+
+Every one of those keys is also a command line option, in whichever spelling you
+prefer:
+
+```sh
+swov ui_scale=1.2 hl=ff8800
+swov --ssaa=1 --header_pos=top-left
+swov -s bg=0d1117ff -s win_gap=8
+```
+
+```
+usage: swov [options] [key=value ...]
+  -c, --config PATH   read this config file instead of the default
+  -n, --no-config     ignore the config file
+      --shot PATH     render one frame to a PNG and exit (handy for tuning)
+  -h, --help          the full key and option list
+```
+
+`--shot` renders one frame and exits, so colors and sizes can be tried without
+opening the overlay over and over: `swov --shot /tmp/o.png hl=ff8800`.
+
+### Keys worth knowing
+
+- `bg` — the scrim. The default `0d1117cc` dims the desktop behind the overlay so
+  the workspace you are actually on stops standing out. `0d111700` gives a fully
+  transparent background, `0d1117ff` a solid one. Real background blur is a
+  compositor feature and cannot be done from inside the window.
+- `header_pos` / `hints_pos` — where the two text lines go: `none`, or
+  `top`/`bottom` combined with `left`/`center`/`right`. Defaults are
+  `top-right` for the header and `bottom-center` for the key hints; when both
+  end up in the same band they dodge each other.
+- `win_gap` / `screen_pad` — space between two windows, and the border kept free
+  between the mini screen and the windows in it. Raise them for an airier grid.
+- `badge_top` — how much air the workspace number gets above it.
+- `float_alpha` — how solid floating and fullscreen windows are (0.62 by
+  default). The value is scaled down further the more of the workspace the
+  window covers, so a fullscreen window all but disappears into its frame while
+  a small dialog stays clearly visible. `1.0` makes them solid again.
+- `anim_ms` — how long tiles take to glide when the ghost slots appear and
+  disappear. `0` makes the change instant.
+- `start_selection` — `workspace` (default), `none` or `window`.
+- `ssaa` — supersampling, 1 to 4. Fonts are opened at the scaled size, so this
+  sharpens text as well as edges. It is reduced automatically on very large
+  screens; on a 4K monitor expect it to drop to 1.
+- `ui_scale` — one number that scales all four text sizes at once.
+- `rows` / `cols` — force the grid. By default swov picks whatever yields the
+  largest tiles.
+
+## Speed
+
+Startup is around 100 ms, and most of that is SDL bringing up a window.
+
+- The Sway tree is read over the IPC socket and parsed in process. No `swaymsg`,
+  no `jq`, no `/bin/sh` — three process spawns saved before anything is drawn.
+- Icon themes are scanned once into a preference ordered directory list, so
+  resolving an icon is a handful of `access()` calls instead of the thousands a
+  blind size and extension sweep would cost. Results are cached per application,
+  so ten terminals load one icon.
+- `.desktop` files are indexed lazily, and not at all when `icons=0`.
+- fontconfig is consulted once for the regular and the bold cut together, and
+  skipped entirely when `font` is given as a path.
+- The overview subscribes to sway's window and workspace events on a second
+  socket. Sway acknowledges a command before its layout transaction commits, so
+  re-reading the tree immediately gives stale geometry — this is what used to
+  leave a workspace showing the wrong windows after a move. Now the reload waits
+  for sway to say it is done, and the overview also stays correct when something
+  changes behind it.
+- The event loop repaints only when something actually changed. Mouse motion
+  that does not change what is under the cursor does not cost a frame, so an
+  idle overlay is essentially free.
+- Text is rendered to textures once per layout and tinted per state, so hover and
+  selection changes cost nothing extra.
+
+If you want it leaner still: `ssaa=1` halves the fill rate, `shadow=0` removes
+several rounded-rect fills per tile, and `icons=0` skips the `.desktop` and icon
+theme work completely.
+
+## Notes
+
+- Windows are read from the Sway tree, so what you see is the geometry Sway
+  reports, gaps and borders included.
+- When sway reports a useless `app_id`, swov reads `/proc/<pid>/cmdline` to find
+  the real executable, matches that against the `.desktop` index, and otherwise
+  falls back to the tail of the window title.
+- Windows in the scratchpad are not shown.
+- Moving windows with `0`–`9` keeps the overlay open and refreshes it, so several
+  windows can be shuffled in one visit. Set `quit_after_action=1` to have it
+  close instead.
+- Reordering workspaces works by renaming them, which is the only thing sway
+  offers. A swap goes through a temporary name, so nothing collides halfway.
diff --git a/config.example b/config.example
new file mode 100644
index 0000000..fbb63c6
--- /dev/null
+++ b/config.example
@@ -0,0 +1,87 @@
+# swov — example configuration
+#
+# Copy to ${XDG_CONFIG_HOME:-~/.config}/swov/config and edit.
+# Every value below is the built-in default, so an empty file behaves the same.
+# Single settings can also be overridden on the command line:
+#     swov ui_scale=1.2 ssaa=1        (or --ui_scale=1.2, or -s ui_scale=1.2)
+
+# --- rendering ---
+ssaa=2             # full-scene supersampling 1..4 (smooths edges *and* text)
+                   # alias: aa.  automatically reduced on very large screens
+icons=1            # show .desktop icons (PNG/SVG/XPM); 0 = text only
+icon_px=40         # icon edge length; shrinks by itself on small cards
+shadow=1           # drop shadows under tiles and floating windows
+shadow_layers=3    # fewer layers = cheaper, harder edged
+anim_ms=160        # how long tiles take to glide when the ghost slots come
+                   # and go, in and out. 0 turns the animation off
+vsync=1
+
+# --- text ---
+ui_scale=1.0       # master text size. 1.3 = bigger, 0.85 = smaller
+                   # aliases: font_scale, text_scale
+ws_px=26           # workspace number in the tile header   (all four are
+label_px=16        # application name on a window card       multiplied by
+title_px=13        # window title, the long text             ui_scale, so
+hint_px=14         # header line and the key hints           tweak one or all)
+
+# By default swayov uses the desktop font (via fontconfig).
+# Set a family name or a .ttf path to override:
+# font=Inter
+# font=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
+# font_bold=Inter Semibold      # defaults to the bold cut of `font`
+
+# --- layout ---
+cols=0             # 0 = pick the grid that gives the largest tiles
+rows=0             # set e.g. rows=2 if your workspaces are really 2 rows
+margin=26          # space around the whole grid
+gap=14             # space between tiles
+pad=10             # padding inside a tile
+win_gap=5          # space between two windows inside a workspace
+screen_pad=6       # border kept free between the mini screen and the windows
+badge_top=7        # space above the workspace number in a tile corner
+float_alpha=0.62   # how solid a floating or fullscreen window is drawn. It is
+                   # further reduced the more of the workspace it covers, so a
+                   # fullscreen window stays a frame you can see through.
+                   # 1.0 = as solid as a tiled window
+radius=14          # corner radius
+border=3           # tile border thickness
+show_empty=1       # keep empty workspaces in the grid (keeps numbering stable)
+start_selection=workspace
+                   # what is selected when swov opens:
+                   #   workspace = the visible workspace, no window (default)
+                   #   none      = nothing at all
+                   #   window    = the first window of the visible workspace
+all_outputs=0      # 1 = show every monitor, not just the focused one
+show_header=1      # the "DP-1  16 windows on 9 workspaces" line
+show_hints=1       # the key hints
+header_pos=top-right
+hints_pos=bottom-center
+                   # none | top-left | top-center | top-right |
+                   # bottom-left | bottom-center | bottom-right
+                   # when both share a band the hints step aside and, if it
+                   # is still tight, get shortened with an ellipsis
+quit_on_focus_loss=1
+quit_after_action=0  # 1 = close the overlay after moving windows with 0-9
+
+# --- colors: #rrggbb or #rrggbbaa ---
+# bg alpha < ff => transparent overlay (needs a compositor, sway qualifies)
+bg=0d1117cc        # dims everything behind the overlay, so the workspace you
+                   # are actually on stops competing for attention.
+                   # 0d111700 = fully transparent, 0d1117ff = opaque
+tile=1e2733f2      # workspace tile                        alias: ring
+tile_sel=26313ff2  # selected workspace tile               alias: ring2
+tile_hover=2b3644f2
+mini_bg=11171f9e   # the inset "screen" inside a tile      alias: center
+card=33404ff7      # window card
+card_hover=46566af7 #                                      alias: hover
+card_focus=3b4a5bf7 # card of the window sway has focused
+hl=cb9b00ff        # the orange: selection, badges, marks
+text=e8e8e8ff      # bright text
+subtext=b3c0cdff   # application names
+dim=5a6b7aff       # counters and window titles
+hint=a7b5c4ff      # the header line and the key hints
+accent=89afc4ff    # hover borders, filter box, tab groups
+hltext=141414ff    # text on an orange badge
+urgent=e0533cff    # urgent windows and workspaces
+outline=0a0e1499   # thin outlines
+shadow_color=00000073
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..1c0e5a9
Binary files /dev/null and b/screenshot.png differ
diff --git a/swov.c b/swov.c
new file mode 100644
index 0000000..41ecb3f
--- /dev/null
+++ b/swov.c
@@ -0,0 +1,4016 @@
+/* swov — a fast window/workspace overview for Sway (SDL3)
+ *
+ * Build:
+ *   cc -std=c11 -O2 -Wall -Wextra -o swov swov.c \
+ *      $(pkg-config --cflags --libs sdl3 sdl3-image sdl3-ttf)
+ *
+ * Runtime deps: a running Sway session ($SWAYSOCK). No jq, no shell-outs:
+ * the program talks to the Sway IPC socket directly. fontconfig (fc-match)
+ * is used if present to pick the desktop font, otherwise a few well known
+ * font paths are tried.
+ *
+ * See README.md for configuration and key bindings.
+ */
+
+#define _POSIX_C_SOURCE 200809L
+
+#include <SDL3/SDL.h>
+#include <SDL3_image/SDL_image.h>
+#include <SDL3_ttf/SDL_ttf.h>
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#define APP_ID          "swov"
+#define MAX_WINDOWS     512
+#define MAX_WORKSPACES  64
+#define MAX_DESKTOPS    4096
+
+/* ------------------------------------------------------------------ util */
+
+static void die(const char *fmt, ...)
+{
+    va_list ap;
+    va_start(ap, fmt);
+    fputs("swov: ", stderr);
+    vfprintf(stderr, fmt, ap);
+    va_end(ap);
+    fputc('\n', stderr);
+    exit(1);
+}
+
+static void *xmalloc(size_t n)
+{
+    void *p = malloc(n ? n : 1);
+    if (!p) die("out of memory");
+    return p;
+}
+
+static void *xrealloc(void *p, size_t n)
+{
+    void *q = realloc(p, n ? n : 1);
+    if (!q) die("out of memory");
+    return q;
+}
+
+static char *xstrdup(const char *s)
+{
+    size_t n = strlen(s ? s : "") + 1;
+    char *p = (char *)xmalloc(n);
+    memcpy(p, s ? s : "", n);
+    return p;
+}
+
+static char *fmt_alloc(const char *fmt, ...)
+{
+    va_list ap;
+    va_start(ap, fmt);
+    int n = vsnprintf(NULL, 0, fmt, ap);
+    va_end(ap);
+    if (n < 0) return xstrdup("");
+    char *buf = (char *)xmalloc((size_t)n + 1);
+    va_start(ap, fmt);
+    vsnprintf(buf, (size_t)n + 1, fmt, ap);
+    va_end(ap);
+    return buf;
+}
+
+static bool file_readable(const char *p) { return p && access(p, R_OK) == 0; }
+
+static bool is_dir(const char *p)
+{
+    struct stat st;
+    return p && stat(p, &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+static char *str_trim(char *s)
+{
+    while (*s == ' ' || *s == '\t') s++;
+    size_t n = strlen(s);
+    while (n && (s[n-1]==' '||s[n-1]=='\t'||s[n-1]=='\r'||s[n-1]=='\n')) s[--n] = 0;
+    return s;
+}
+
+static int ci_cmp(const char *a, const char *b)
+{
+    while (*a && *b) {
+        int ca = tolower((unsigned char)*a++);
+        int cb = tolower((unsigned char)*b++);
+        if (ca != cb) return ca - cb;
+    }
+    return (unsigned char)*a - (unsigned char)*b;
+}
+
+/* case-insensitive substring search (used by the filter) */
+static bool ci_contains(const char *hay, const char *needle)
+{
+    if (!needle || !*needle) return true;
+    if (!hay) return false;
+    size_t nl = strlen(needle);
+    for (const char *p = hay; *p; ++p) {
+        size_t i = 0;
+        while (i < nl && p[i] &&
+               tolower((unsigned char)p[i]) == tolower((unsigned char)needle[i])) i++;
+        if (i == nl) return true;
+    }
+    return false;
+}
+
+static bool str_all_digits(const char *s)
+{
+    if (!s || !*s) return false;
+    for (const char *p = s; *p; ++p) if (!isdigit((unsigned char)*p)) return false;
+    return true;
+}
+
+/* ---------------------------------------------------------------- colors */
+
+static SDL_FColor rgba(uint32_t v)
+{
+    return (SDL_FColor){ ((v >> 24) & 0xff) / 255.0f,
+                         ((v >> 16) & 0xff) / 255.0f,
+                         ((v >>  8) & 0xff) / 255.0f,
+                         ((v      ) & 0xff) / 255.0f };
+}
+
+static bool parse_color(const char *s, SDL_FColor *out)
+{
+    if (!s) return false;
+    while (*s == '#' || *s == ' ') s++;
+    size_t n = strlen(s);
+    if (n != 6 && n != 8) return false;
+    char buf[9];
+    memcpy(buf, s, n);
+    buf[n] = 0;
+    for (size_t i = 0; i < n; ++i) if (!isxdigit((unsigned char)buf[i])) return false;
+
+    unsigned long v = strtoul(buf, NULL, 16);
+    if (n == 6) v = (v << 8) | 0xff;
+    *out = rgba((uint32_t)v);
+    return true;
+}
+
+/* c blended towards `to` by t (0..1); alpha kept from c */
+static SDL_FColor mix(SDL_FColor c, SDL_FColor to, float t)
+{
+    return (SDL_FColor){ c.r + (to.r - c.r) * t,
+                         c.g + (to.g - c.g) * t,
+                         c.b + (to.b - c.b) * t,
+                         c.a + (to.a - c.a) * t };
+}
+
+static SDL_FColor with_alpha(SDL_FColor c, float a) { c.a = a; return c; }
+
+/* ---------------------------------------------------------------- config */
+
+typedef struct {
+    /* rendering */
+    int   ssaa;              /* 1..4 full scene supersampling */
+    int   icons;             /* show .desktop icons */
+    int   icon_px;           /* icon edge length (logical px, before ui_scale) */
+    int   shadow;
+    int   vsync;
+
+    /* text */
+    float ui_scale;
+    int   ws_px;             /* workspace number badge */
+    int   label_px;          /* app name on a window card */
+    int   title_px;          /* window title (the long text) */
+    int   hint_px;           /* footer / header text */
+    char  font[512];
+    char  font_bold[512];
+
+    /* layout */
+    int   cols, rows;        /* 0 = automatic */
+    float margin, gap, pad, radius, border;
+    float win_gap;           /* space between two window cards            */
+    float badge_top;         /* space above the workspace number          */
+    float float_alpha;       /* opacity of floating / fullscreen cards    */
+    float screen_pad;        /* border between mini screen and the cards  */
+    int   shadow_layers;
+    float anim_ms;           /* tile glide duration, 0 disables            */
+    int   start_selection;   /* 0 none, 1 workspace, 2 first window       */
+    char  header_pos[16];    /* none|top-left|top-center|...|bottom-right */
+    char  hints_pos[16];
+    int   show_empty;
+    int   all_outputs;
+    int   show_header;
+    int   show_hints;
+    int   quit_on_focus_loss;
+    int   quit_after_action;
+
+    /* colors */
+    SDL_FColor bg, tile, tile_sel, tile_hover, mini_bg;
+    SDL_FColor card, card_hover, card_focus;
+    SDL_FColor hl, text, subtext, dim, accent, hltext, shadow_col, outline, urgent, hint;
+} Cfg;
+
+static Cfg cfg_defaults(void)
+{
+    Cfg c;
+    memset(&c, 0, sizeof(c));
+
+    c.ssaa       = 2;
+    c.icons      = 1;
+    c.icon_px    = 40;
+    c.shadow     = 1;
+    c.vsync      = 1;
+
+    c.ui_scale   = 1.0f;
+    c.ws_px      = 26;
+    c.label_px   = 16;
+    c.title_px   = 13;
+    c.hint_px    = 14;
+
+    c.cols = c.rows = 0;
+    c.margin  = 26.0f;
+    c.win_gap    = 5.0f;
+    c.badge_top  = 7.0f;
+    c.float_alpha = 0.62f;
+    c.screen_pad = 6.0f;
+    c.shadow_layers = 3;
+    c.anim_ms = 160.0f;
+    c.start_selection = 1;
+    snprintf(c.header_pos, sizeof(c.header_pos), "%s", "top-right");
+    snprintf(c.hints_pos,  sizeof(c.hints_pos),  "%s", "bottom-center");
+    c.gap     = 14.0f;
+    c.pad     = 10.0f;
+    c.radius  = 14.0f;
+    c.border  = 3.0f;
+
+    c.show_empty         = 1;
+    c.all_outputs        = 0;
+    c.show_header        = 1;
+    c.show_hints         = 1;
+    c.quit_on_focus_loss = 1;
+    c.quit_after_action  = 0;
+
+    /* palette: same family as the appwheel config */
+    c.bg         = rgba(0x0d1117cc);  /* dims whatever is behind the overlay*/
+    c.tile       = rgba(0x1e2733f2);  /* workspace tile                     */
+    c.tile_sel   = rgba(0x26313ff2);  /* selected workspace tile            */
+    c.tile_hover = rgba(0x2b3644f2);  /* hovered workspace tile             */
+    c.mini_bg    = rgba(0x11171f9e);  /* inset "screen" inside a tile       */
+    c.card       = rgba(0x33404ff7);  /* window card                        */
+    c.card_hover = rgba(0x46566af7);  /* hovered window card                */
+    c.card_focus = rgba(0x3b4a5bf7);  /* card of the window sway focuses    */
+    c.hl         = rgba(0xcb9b00ff);  /* the orange                         */
+    c.text       = rgba(0xe8e8e8ff);
+    c.subtext    = rgba(0xb3c0cdff);
+    c.dim        = rgba(0x5a6b7aff);
+    c.accent     = rgba(0x89afc4ff);
+    c.hltext     = rgba(0x141414ff);
+    c.shadow_col = rgba(0x00000073);
+    c.outline    = rgba(0x0a0e1499);
+    c.urgent     = rgba(0xe0533cff);
+    c.hint       = rgba(0xa7b5c4ff);  /* header line and the key hints       */
+    return c;
+}
+
+static bool key_is(const char *k, const char *a) { return ci_cmp(k, a) == 0; }
+
+static void cfg_set(Cfg *c, const char *k, const char *v)
+{
+    /* rendering */
+    if (key_is(k,"ssaa") || key_is(k,"aa")) {
+        c->ssaa = atoi(v); if (c->ssaa < 1) c->ssaa = 1; if (c->ssaa > 4) c->ssaa = 4;
+    }
+    else if (key_is(k,"icons"))        c->icons = atoi(v) != 0;
+    else if (key_is(k,"icon_px"))      c->icon_px = atoi(v);
+    else if (key_is(k,"shadow"))       c->shadow = atoi(v) != 0;
+    else if (key_is(k,"vsync"))        c->vsync = atoi(v) != 0;
+
+    /* text */
+    else if (key_is(k,"ui_scale") || key_is(k,"font_scale") || key_is(k,"text_scale"))
+        c->ui_scale = (float)atof(v);
+    else if (key_is(k,"ws_px") || key_is(k,"workspace_px")) c->ws_px = atoi(v);
+    else if (key_is(k,"label_px") || key_is(k,"app_px"))    c->label_px = atoi(v);
+    else if (key_is(k,"title_px"))                          c->title_px = atoi(v);
+    else if (key_is(k,"hint_px") || key_is(k,"count_px"))   c->hint_px = atoi(v);
+    else if (key_is(k,"font"))       snprintf(c->font, sizeof(c->font), "%s", v);
+    else if (key_is(k,"font_bold"))  snprintf(c->font_bold, sizeof(c->font_bold), "%s", v);
+
+    /* layout */
+    else if (key_is(k,"cols"))          c->cols = atoi(v);
+    else if (key_is(k,"rows"))          c->rows = atoi(v);
+    else if (key_is(k,"margin"))        c->margin = (float)atof(v);
+    else if (key_is(k,"gap"))           c->gap = (float)atof(v);
+    else if (key_is(k,"pad"))           c->pad = (float)atof(v);
+    else if (key_is(k,"radius") || key_is(k,"corner")) c->radius = (float)atof(v);
+    else if (key_is(k,"border"))        c->border = (float)atof(v);
+    else if (key_is(k,"win_gap") || key_is(k,"window_gap")) c->win_gap = (float)atof(v);
+    else if (key_is(k,"screen_pad"))    c->screen_pad = (float)atof(v);
+    else if (key_is(k,"badge_top") || key_is(k,"badge_scale")) c->badge_top = (float)atof(v);
+    else if (key_is(k,"float_alpha") || key_is(k,"floating_alpha"))
+        c->float_alpha = (float)atof(v);
+    else if (key_is(k,"shadow_layers")) c->shadow_layers = atoi(v);
+    else if (key_is(k,"anim_ms") || key_is(k,"animation")) c->anim_ms = (float)atof(v);
+    else if (key_is(k,"start_selection")) {
+        c->start_selection = key_is(v,"none") ? 0 : key_is(v,"window") ? 2 : 1;
+    }
+    else if (key_is(k,"header_pos")) snprintf(c->header_pos, sizeof(c->header_pos), "%s", v);
+    else if (key_is(k,"hints_pos"))  snprintf(c->hints_pos,  sizeof(c->hints_pos),  "%s", v);
+    else if (key_is(k,"show_empty"))    c->show_empty = atoi(v) != 0;
+    else if (key_is(k,"all_outputs"))   c->all_outputs = atoi(v) != 0;
+    else if (key_is(k,"show_header"))   c->show_header = atoi(v) != 0;
+    else if (key_is(k,"show_hints"))    c->show_hints = atoi(v) != 0;
+    else if (key_is(k,"quit_on_focus_loss")) c->quit_on_focus_loss = atoi(v) != 0;
+    else if (key_is(k,"quit_after_action"))  c->quit_after_action = atoi(v) != 0;
+
+    /* colors */
+    else if (key_is(k,"bg"))          parse_color(v, &c->bg);
+    else if (key_is(k,"tile") || key_is(k,"ring"))       parse_color(v, &c->tile);
+    else if (key_is(k,"tile_sel") || key_is(k,"ring2"))  parse_color(v, &c->tile_sel);
+    else if (key_is(k,"tile_hover"))  parse_color(v, &c->tile_hover);
+    else if (key_is(k,"mini_bg") || key_is(k,"center"))  parse_color(v, &c->mini_bg);
+    else if (key_is(k,"card"))        parse_color(v, &c->card);
+    else if (key_is(k,"card_hover") || key_is(k,"hover")) parse_color(v, &c->card_hover);
+    else if (key_is(k,"card_focus"))  parse_color(v, &c->card_focus);
+    else if (key_is(k,"hl"))          parse_color(v, &c->hl);
+    else if (key_is(k,"text"))        parse_color(v, &c->text);
+    else if (key_is(k,"subtext"))     parse_color(v, &c->subtext);
+    else if (key_is(k,"dim"))         parse_color(v, &c->dim);
+    else if (key_is(k,"accent"))      parse_color(v, &c->accent);
+    else if (key_is(k,"hltext"))      parse_color(v, &c->hltext);
+    else if (key_is(k,"shadow_color")) parse_color(v, &c->shadow_col);
+    else if (key_is(k,"outline"))     parse_color(v, &c->outline);
+    else if (key_is(k,"urgent"))      parse_color(v, &c->urgent);
+    else if (key_is(k,"hint"))        parse_color(v, &c->hint);
+    else fprintf(stderr, "swov: unknown config key '%s' (ignored)\n", k);
+}
+
+static char *expand_tilde(const char *p)
+{
+    if (p && p[0] == '~' && (p[1] == '/' || p[1] == 0)) {
+        const char *home = getenv("HOME");
+        if (home) return fmt_alloc("%s%s", home, p + 1);
+    }
+    return xstrdup(p ? p : "");
+}
+
+static bool cfg_load_file(Cfg *c, const char *path)
+{
+    FILE *f = fopen(path, "r");
+    if (!f) return false;
+    char line[1024];
+    while (fgets(line, sizeof(line), f)) {
+        char *s = str_trim(line);
+        if (!*s || *s == '#' || *s == ';') continue;
+        char *eq = strchr(s, '=');
+        if (!eq) continue;
+        *eq = 0;
+        char *k = str_trim(s);
+        char *v = str_trim(eq + 1);
+        char *hash = strchr(v, '#');           /* trailing comment */
+        if (hash && hash > v && hash[-1] == ' ') { *hash = 0; v = str_trim(v); }
+        if (*k) cfg_set(c, k, v);
+    }
+    fclose(f);
+    return true;
+}
+
+static char *default_config_path(void)
+{
+    const char *xdg = getenv("XDG_CONFIG_HOME");
+    if (xdg && *xdg) return fmt_alloc("%s/swov/config", xdg);
+    const char *home = getenv("HOME");
+    if (home) return fmt_alloc("%s/.config/swov/config", home);
+    return NULL;
+}
+
+/* ----------------------------------------------------------- json parser
+ * Just enough JSON to read sway's IPC replies: no streaming, no comments,
+ * numbers as double, strings unescaped to UTF-8.
+ */
+
+typedef enum { J_NULL, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ } JType;
+
+typedef struct JV JV;
+struct JV {
+    JType type;
+    bool   b;
+    double num;
+    char  *str;              /* J_STR */
+    JV   **items;            /* J_ARR / J_OBJ values */
+    char **keys;             /* J_OBJ keys */
+    int    count, cap;
+};
+
+static void jfree(JV *v)
+{
+    if (!v) return;
+    switch (v->type) {
+    case J_STR: free(v->str); break;
+    case J_ARR:
+        for (int i = 0; i < v->count; ++i) jfree(v->items[i]);
+        free(v->items);
+        break;
+    case J_OBJ:
+        for (int i = 0; i < v->count; ++i) { free(v->keys[i]); jfree(v->items[i]); }
+        free(v->items);
+        free(v->keys);
+        break;
+    default: break;
+    }
+    free(v);
+}
+
+static JV *jnew(JType t)
+{
+    JV *v = (JV *)xmalloc(sizeof(JV));
+    memset(v, 0, sizeof(*v));
+    v->type = t;
+    return v;
+}
+
+static void jpush(JV *v, char *key, JV *val)
+{
+    if (v->count == v->cap) {
+        v->cap = v->cap ? v->cap * 2 : 8;
+        v->items = (JV **)xrealloc(v->items, (size_t)v->cap * sizeof(JV *));
+        if (v->type == J_OBJ)
+            v->keys = (char **)xrealloc(v->keys, (size_t)v->cap * sizeof(char *));
+    }
+    if (v->type == J_OBJ) v->keys[v->count] = key;
+    v->items[v->count++] = val;
+}
+
+static void utf8_append(char **dst, size_t *len, size_t *cap, uint32_t cp)
+{
+    char tmp[4];
+    int n = 0;
+    if (cp < 0x80) { tmp[0] = (char)cp; n = 1; }
+    else if (cp < 0x800) {
+        tmp[0] = (char)(0xc0 | (cp >> 6));
+        tmp[1] = (char)(0x80 | (cp & 0x3f));
+        n = 2;
+    } else if (cp < 0x10000) {
+        tmp[0] = (char)(0xe0 | (cp >> 12));
+        tmp[1] = (char)(0x80 | ((cp >> 6) & 0x3f));
+        tmp[2] = (char)(0x80 | (cp & 0x3f));
+        n = 3;
+    } else {
+        tmp[0] = (char)(0xf0 | (cp >> 18));
+        tmp[1] = (char)(0x80 | ((cp >> 12) & 0x3f));
+        tmp[2] = (char)(0x80 | ((cp >> 6) & 0x3f));
+        tmp[3] = (char)(0x80 | (cp & 0x3f));
+        n = 4;
+    }
+    if (*len + (size_t)n + 1 > *cap) {
+        *cap = (*cap ? *cap * 2 : 32) + (size_t)n;
+        *dst = (char *)xrealloc(*dst, *cap);
+    }
+    memcpy(*dst + *len, tmp, (size_t)n);
+    *len += (size_t)n;
+    (*dst)[*len] = 0;
+}
+
+static const char *jskip_ws(const char *p)
+{
+    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
+    return p;
+}
+
+static const char *jparse_string(const char *p, char **out)
+{
+    /* p points at the opening quote */
+    size_t len = 0, cap = 32;
+    char *s = (char *)xmalloc(cap);
+    s[0] = 0;
+    p++;
+    while (*p && *p != '"') {
+        if (*p == '\\') {
+            p++;
+            uint32_t cp = 0;
+            switch (*p) {
+            case 'n': cp = '\n'; p++; break;
+            case 't': cp = '\t'; p++; break;
+            case 'r': cp = '\r'; p++; break;
+            case 'b': cp = '\b'; p++; break;
+            case 'f': cp = '\f'; p++; break;
+            case '/': cp = '/';  p++; break;
+            case '"': cp = '"';  p++; break;
+            case '\\': cp = '\\'; p++; break;
+            case 'u': {
+                p++;
+                char hex[5] = {0};
+                for (int i = 0; i < 4 && p[i]; ++i) hex[i] = p[i];
+                cp = (uint32_t)strtoul(hex, NULL, 16);
+                p += 4;
+                if (cp >= 0xd800 && cp <= 0xdbff && p[0] == '\\' && p[1] == 'u') {
+                    char hex2[5] = {0};
+                    for (int i = 0; i < 4 && p[2 + i]; ++i) hex2[i] = p[2 + i];
+                    uint32_t lo = (uint32_t)strtoul(hex2, NULL, 16);
+                    if (lo >= 0xdc00 && lo <= 0xdfff) {
+                        cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00);
+                        p += 6;
+                    }
+                }
+                break;
+            }
+            default:
+                if (!*p) { free(s); *out = NULL; return NULL; }
+                cp = (unsigned char)*p++;
+                break;
+            }
+            utf8_append(&s, &len, &cap, cp);
+        } else {
+            if (len + 2 > cap) { cap *= 2; s = (char *)xrealloc(s, cap); }
+            s[len++] = *p++;
+            s[len] = 0;
+        }
+    }
+    if (*p != '"') { free(s); *out = NULL; return NULL; }
+    *out = s;
+    return p + 1;
+}
+
+static const char *jparse_value(const char *p, JV **out);
+
+static const char *jparse_container(const char *p, JV **out, bool is_obj)
+{
+    JV *v = jnew(is_obj ? J_OBJ : J_ARR);
+    p = jskip_ws(p + 1);
+    char close = is_obj ? '}' : ']';
+    if (*p == close) { *out = v; return p + 1; }
+
+    for (;;) {
+        char *key = NULL;
+        if (is_obj) {
+            p = jskip_ws(p);
+            if (*p != '"') { jfree(v); return NULL; }
+            p = jparse_string(p, &key);
+            if (!p) { jfree(v); return NULL; }
+            p = jskip_ws(p);
+            if (*p != ':') { free(key); jfree(v); return NULL; }
+            p++;
+        }
+        JV *child = NULL;
+        p = jparse_value(jskip_ws(p), &child);
+        if (!p) { free(key); jfree(v); return NULL; }
+        jpush(v, key, child);
+
+        p = jskip_ws(p);
+        if (*p == ',') { p++; continue; }
+        if (*p == close) { p++; break; }
+        jfree(v);
+        return NULL;
+    }
+    *out = v;
+    return p;
+}
+
+static const char *jparse_value(const char *p, JV **out)
+{
+    p = jskip_ws(p);
+    switch (*p) {
+    case '{': return jparse_container(p, out, true);
+    case '[': return jparse_container(p, out, false);
+    case '"': {
+        JV *v = jnew(J_STR);
+        p = jparse_string(p, &v->str);
+        if (!p) { jfree(v); return NULL; }
+        *out = v;
+        return p;
+    }
+    case 't':
+        if (strncmp(p, "true", 4)) return NULL;
+        *out = jnew(J_BOOL); (*out)->b = true; return p + 4;
+    case 'f':
+        if (strncmp(p, "false", 5)) return NULL;
+        *out = jnew(J_BOOL); (*out)->b = false; return p + 5;
+    case 'n':
+        if (strncmp(p, "null", 4)) return NULL;
+        *out = jnew(J_NULL); return p + 4;
+    default: {
+        char *end = NULL;
+        double d = strtod(p, &end);
+        if (end == p) return NULL;
+        JV *v = jnew(J_NUM);
+        v->num = d;
+        *out = v;
+        return end;
+    }
+    }
+}
+
+static JV *jparse(const char *text)
+{
+    JV *v = NULL;
+    const char *p = jparse_value(text, &v);
+    if (!p) { jfree(v); return NULL; }
+    return v;
+}
+
+static JV *jget(const JV *o, const char *key)
+{
+    if (!o || o->type != J_OBJ) return NULL;
+    for (int i = 0; i < o->count; ++i)
+        if (o->keys[i] && strcmp(o->keys[i], key) == 0) return o->items[i];
+    return NULL;
+}
+
+static const char *jstr(const JV *o, const char *key, const char *def)
+{
+    JV *v = jget(o, key);
+    return (v && v->type == J_STR) ? v->str : def;
+}
+
+static int jint(const JV *o, const char *key, int def)
+{
+    JV *v = jget(o, key);
+    return (v && v->type == J_NUM) ? (int)v->num : def;
+}
+
+static bool jbool(const JV *o, const char *key, bool def)
+{
+    JV *v = jget(o, key);
+    return (v && v->type == J_BOOL) ? v->b : def;
+}
+
+/* -------------------------------------------------------------- sway ipc */
+
+enum {
+    IPC_RUN_COMMAND    = 0,
+    IPC_GET_WORKSPACES = 1,
+    IPC_GET_TREE       = 4,
+    IPC_SUBSCRIBE      = 2
+};
+
+static int sway_fd = -1;      /* request socket  */
+static int sway_evt_fd = -1;  /* event socket    */
+
+static int sway_connect(void)
+{
+    const char *path = getenv("SWAYSOCK");
+    if (!path || !*path) path = getenv("I3SOCK");
+    if (!path || !*path) return -1;
+
+    int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+    if (fd < 0) return -1;
+
+    struct sockaddr_un addr;
+    memset(&addr, 0, sizeof(addr));
+    addr.sun_family = AF_UNIX;
+    snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path);
+    if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+        close(fd);
+        return -1;
+    }
+    return fd;
+}
+
+static bool write_all(int fd, const void *buf, size_t n)
+{
+    const char *p = (const char *)buf;
+    while (n) {
+        ssize_t w = write(fd, p, n);
+        if (w < 0) { if (errno == EINTR) continue; return false; }
+        p += w;
+        n -= (size_t)w;
+    }
+    return true;
+}
+
+static bool read_all(int fd, void *buf, size_t n)
+{
+    char *p = (char *)buf;
+    while (n) {
+        ssize_t r = read(fd, p, n);
+        if (r < 0) { if (errno == EINTR) continue; return false; }
+        if (r == 0) return false;
+        p += r;
+        n -= (size_t)r;
+    }
+    return true;
+}
+
+/* Returns a malloc'ed, NUL-terminated payload (caller frees), or NULL. */
+static char *sway_request(uint32_t type, const char *payload)
+{
+    if (sway_fd < 0) return NULL;
+
+    size_t plen = payload ? strlen(payload) : 0;
+    char hdr[14];
+    memcpy(hdr, "i3-ipc", 6);
+    uint32_t l = (uint32_t)plen, t = type;
+    memcpy(hdr + 6, &l, 4);
+    memcpy(hdr + 10, &t, 4);
+
+    if (!write_all(sway_fd, hdr, sizeof(hdr))) return NULL;
+    if (plen && !write_all(sway_fd, payload, plen)) return NULL;
+
+    char rhdr[14];
+    if (!read_all(sway_fd, rhdr, sizeof(rhdr))) return NULL;
+    if (memcmp(rhdr, "i3-ipc", 6) != 0) return NULL;
+
+    uint32_t rlen = 0;
+    memcpy(&rlen, rhdr + 6, 4);
+    if (rlen > (64u << 20)) return NULL;
+
+    char *body = (char *)xmalloc(rlen + 1);
+    if (rlen && !read_all(sway_fd, body, rlen)) { free(body); return NULL; }
+    body[rlen] = 0;
+    return body;
+}
+
+static JV *sway_query(uint32_t type)
+{
+    char *body = sway_request(type, NULL);
+    if (!body) return NULL;
+    JV *v = jparse(body);
+    free(body);
+    return v;
+}
+
+static bool sway_cmd(const char *fmt, ...)
+{
+    va_list ap;
+    va_start(ap, fmt);
+    int n = vsnprintf(NULL, 0, fmt, ap);
+    va_end(ap);
+    if (n < 0) return false;
+
+    char *cmd = (char *)xmalloc((size_t)n + 1);
+    va_start(ap, fmt);
+    vsnprintf(cmd, (size_t)n + 1, fmt, ap);
+    va_end(ap);
+
+    char *reply = sway_request(IPC_RUN_COMMAND, cmd);
+    free(cmd);
+    if (!reply) return false;
+
+    bool ok = true;
+    JV *v = jparse(reply);
+    if (v && v->type == J_ARR) {
+        for (int i = 0; i < v->count; ++i)
+            if (!jbool(v->items[i], "success", true)) ok = false;
+    }
+    jfree(v);
+    free(reply);
+    return ok;
+}
+
+/* Sway answers a command as soon as it is queued, but the tree only carries
+ * the new geometry once the transaction has committed. Rather than sleeping
+ * and hoping, we subscribe to window and workspace events on a second socket
+ * and reload when sway tells us it is done. That also keeps the overview
+ * correct while it is open. */
+static bool sway_subscribe_events(void)
+{
+    sway_evt_fd = sway_connect();
+    if (sway_evt_fd < 0) return false;
+
+    const char *payload = "[\"window\",\"workspace\"]";
+    char hdr[14];
+    memcpy(hdr, "i3-ipc", 6);
+    uint32_t l = (uint32_t)strlen(payload), t = IPC_SUBSCRIBE;
+    memcpy(hdr + 6, &l, 4);
+    memcpy(hdr + 10, &t, 4);
+
+    if (!write_all(sway_evt_fd, hdr, sizeof(hdr)) ||
+        !write_all(sway_evt_fd, payload, l)) {
+        close(sway_evt_fd);
+        sway_evt_fd = -1;
+        return false;
+    }
+
+    char rhdr[14];
+    uint32_t rlen = 0;
+    if (!read_all(sway_evt_fd, rhdr, sizeof(rhdr))) { close(sway_evt_fd); sway_evt_fd = -1; return false; }
+    memcpy(&rlen, rhdr + 6, 4);
+    if (rlen && rlen < (1u << 20)) {
+        char *body = (char *)xmalloc(rlen + 1);
+        read_all(sway_evt_fd, body, rlen);
+        free(body);
+    }
+
+    int flags = fcntl(sway_evt_fd, F_GETFL, 0);
+    if (flags >= 0) fcntl(sway_evt_fd, F_SETFL, flags | O_NONBLOCK);
+    return true;
+}
+
+/* true when sway reported at least one change since the last call */
+static bool sway_events_pending(void)
+{
+    if (sway_evt_fd < 0) return false;
+    bool any = false;
+
+    for (;;) {
+        char hdr[14];
+        ssize_t r = recv(sway_evt_fd, hdr, sizeof(hdr), MSG_DONTWAIT);
+        if (r <= 0) break;
+        if ((size_t)r < sizeof(hdr)) {              /* rest of the header */
+            if (!read_all(sway_evt_fd, hdr + r, sizeof(hdr) - (size_t)r)) break;
+        }
+        uint32_t rlen = 0;
+        memcpy(&rlen, hdr + 6, 4);
+        if (rlen > (16u << 20)) break;
+        if (rlen) {
+            char *body = (char *)xmalloc(rlen + 1);
+            bool ok = read_all(sway_evt_fd, body, rlen);
+            free(body);
+            if (!ok) break;
+        }
+        any = true;
+    }
+    return any;
+}
+
+/* --------------------------------------------------------------- globals */
+
+static Cfg           C;
+static SDL_Renderer *REN;
+static TTF_Font     *F_BADGE;   /* workspace number            */
+static TTF_Font     *F_LABEL;   /* app name on a window card   */
+static TTF_Font     *F_TITLE;   /* window title (small)        */
+static TTF_Font     *F_HINT;    /* header / footer             */
+static float         SC = 1.0f; /* supersampling scale factor  */
+
+static int px(float logical) { return (int)(logical * SC + 0.5f); }
+
+/* ----------------------------------------------------------------- text */
+
+typedef struct { SDL_Texture *t; int w, h; } Tex;
+
+static void tex_free(Tex *x)
+{
+    if (x->t) SDL_DestroyTexture(x->t);
+    x->t = NULL;
+    x->w = x->h = 0;
+}
+
+/* Text is always rendered white and tinted at draw time, so one texture can
+ * be reused for normal / hovered / selected states. */
+static Tex text_make(TTF_Font *f, const char *s)
+{
+    Tex out = {0};
+    if (!f || !s || !*s) return out;
+    SDL_Color white = { 255, 255, 255, 255 };
+    SDL_Surface *surf = TTF_RenderText_Blended(f, s, strlen(s), white);
+    if (!surf) return out;
+    out.t = SDL_CreateTextureFromSurface(REN, surf);
+    out.w = surf->w;
+    out.h = surf->h;
+    SDL_DestroySurface(surf);
+    if (out.t) SDL_SetTextureScaleMode(out.t, SDL_SCALEMODE_LINEAR);
+    return out;
+}
+
+/* Render `s`, shortened with an ellipsis so that it fits into max_w pixels. */
+static Tex text_make_fit(TTF_Font *f, const char *s, int max_w)
+{
+    Tex out = {0};
+    if (!f || !s || !*s || max_w <= 0) return out;
+
+    int w = 0, h = 0;
+    if (TTF_GetStringSize(f, s, strlen(s), &w, &h) && w <= max_w)
+        return text_make(f, s);
+
+    const char *ell = "\xe2\x80\xa6";           /* … */
+    int ew = 0, eh = 0;
+    TTF_GetStringSize(f, ell, strlen(ell), &ew, &eh);
+
+    int avail = max_w - ew;
+    if (avail <= 0) return out;
+
+    int mw = 0;
+    size_t fit = 0;
+    if (!TTF_MeasureString(f, s, strlen(s), avail, &mw, &fit) || fit == 0)
+        return out;
+
+    char *buf = (char *)xmalloc(fit + strlen(ell) + 1);
+    memcpy(buf, s, fit);
+    memcpy(buf + fit, ell, strlen(ell) + 1);
+    out = text_make(f, buf);
+    free(buf);
+    return out;
+}
+
+static void tex_draw(Tex x, float dx, float dy, SDL_FColor col)
+{
+    if (!x.t) return;
+    SDL_SetTextureColorModFloat(x.t, col.r, col.g, col.b);
+    SDL_SetTextureAlphaModFloat(x.t, col.a);
+    SDL_FRect dst = { dx, dy, (float)x.w, (float)x.h };
+    SDL_RenderTexture(REN, x.t, NULL, &dst);
+}
+
+static void tex_draw_center(Tex x, float cx, float dy, SDL_FColor col)
+{
+    tex_draw(x, cx - x.w * 0.5f, dy, col);
+}
+
+/* Centre a line inside a box. A text texture is a full line box: it reserves
+ * room for descenders even when the string has none, so plain centring makes
+ * digits and capitals look too high. Half the descent puts them right. */
+static void tex_draw_in_box(Tex x, SDL_FRect box, TTF_Font *f, SDL_FColor col)
+{
+    if (!x.t) return;
+    float desc = f ? (float)-TTF_GetFontDescent(f) : 0.0f;
+    float y = box.y + (box.h - (float)x.h) * 0.5f - desc * 0.5f;
+    tex_draw(x, box.x + (box.w - (float)x.w) * 0.5f, y, col);
+}
+
+/* ------------------------------------------------------------- primitives */
+
+static void set_col(SDL_FColor c)
+{
+    SDL_SetRenderDrawColorFloat(REN, c.r, c.g, c.b, c.a);
+}
+
+static void fill_round_rect(SDL_FRect r, float rad, SDL_FColor c)
+{
+    if (r.w <= 0.0f || r.h <= 0.0f) return;
+    rad = SDL_min(rad, SDL_min(r.w, r.h) * 0.5f);
+    set_col(c);
+    if (rad <= 0.5f) { SDL_RenderFillRect(REN, &r); return; }
+
+    SDL_FRect mid = { r.x, r.y + rad, r.w, r.h - 2.0f * rad };
+    SDL_RenderFillRect(REN, &mid);
+
+    int steps = (int)SDL_ceilf(rad);
+    for (int i = 0; i < steps; ++i) {
+        float dy = rad - (float)i - 0.5f;
+        float dx = SDL_sqrtf(SDL_max(0.0f, rad * rad - dy * dy));
+        float x0 = r.x + rad - dx;
+        float w  = r.w - 2.0f * rad + 2.0f * dx;
+        SDL_FRect top = { x0, r.y + (float)i, w, 1.0f };
+        SDL_FRect bot = { x0, r.y + r.h - (float)i - 1.0f, w, 1.0f };
+        SDL_RenderFillRect(REN, &top);
+        SDL_RenderFillRect(REN, &bot);
+    }
+}
+
+/* rounded rectangle outline of thickness t, drawn inside r */
+static void stroke_round_rect(SDL_FRect r, float rad, float t, SDL_FColor c)
+{
+    if (t <= 0.0f || r.w <= 0.0f || r.h <= 0.0f) return;
+    rad = SDL_min(rad, SDL_min(r.w, r.h) * 0.5f);
+    t   = SDL_min(t, SDL_min(r.w, r.h) * 0.5f);
+    set_col(c);
+
+    if (rad <= 0.5f) {
+        SDL_FRect e[4] = {
+            { r.x, r.y, r.w, t },
+            { r.x, r.y + r.h - t, r.w, t },
+            { r.x, r.y + t, t, r.h - 2.0f * t },
+            { r.x + r.w - t, r.y + t, t, r.h - 2.0f * t }
+        };
+        SDL_RenderFillRects(REN, e, 4);
+        return;
+    }
+
+    SDL_FRect edges[4] = {
+        { r.x + rad, r.y, r.w - 2.0f * rad, t },                        /* top    */
+        { r.x + rad, r.y + r.h - t, r.w - 2.0f * rad, t },              /* bottom */
+        { r.x, r.y + rad, t, r.h - 2.0f * rad },                        /* left   */
+        { r.x + r.w - t, r.y + rad, t, r.h - 2.0f * rad }               /* right  */
+    };
+    SDL_RenderFillRects(REN, edges, 4);
+
+    float ri = rad - t;
+    int steps = (int)SDL_ceilf(rad);
+    for (int i = 0; i < steps; ++i) {
+        float dy  = rad - (float)i - 0.5f;
+        float dxo = SDL_sqrtf(SDL_max(0.0f, rad * rad - dy * dy));
+        float dxi = (ri > 0.0f && SDL_fabsf(dy) < ri)
+                        ? SDL_sqrtf(SDL_max(0.0f, ri * ri - dy * dy)) : 0.0f;
+        float seg = dxo - dxi;
+        if (seg <= 0.0f) continue;
+
+        float lx = r.x + rad - dxo;
+        float rx = r.x + r.w - rad + dxi;
+        float ty = r.y + (float)i;
+        float by = r.y + r.h - (float)i - 1.0f;
+
+        SDL_FRect q[4] = {
+            { lx, ty, seg, 1.0f },
+            { rx, ty, seg, 1.0f },
+            { lx, by, seg, 1.0f },
+            { rx, by, seg, 1.0f }
+        };
+        SDL_RenderFillRects(REN, q, 4);
+    }
+}
+
+/* soft drop shadow: a few expanded, fading rounded rects */
+static void drop_shadow(SDL_FRect r, float rad, float size, SDL_FColor c)
+{
+    if (!C.shadow || size <= 0.0f) return;
+    int layers = C.shadow_layers > 0 ? C.shadow_layers : 3;
+    for (int i = layers; i >= 1; --i) {
+        float g = size * (float)i / (float)layers;
+        SDL_FRect s = { r.x - g, r.y - g, r.w + 2.0f * g, r.h + 2.0f * g };
+        SDL_FColor col = with_alpha(c, c.a / (float)(layers * 2));
+        fill_round_rect(s, rad + g, col);
+    }
+}
+
+/* ------------------------------------------------------------ icon lookup
+ * app_id (or WM_CLASS) -> .desktop file -> Icon= -> a file on disk.
+ * Everything is cached: the .desktop index is built once and lazily, the
+ * resulting textures are shared between windows of the same application.
+ */
+
+typedef struct {
+    char *id;        /* desktop file basename without .desktop */
+    char *path;
+    char *wmclass;   /* StartupWMClass, may be NULL */
+    char *icon;      /* Icon=, may be NULL */
+    char *name;      /* Name=, may be NULL */
+} DesktopEntry;
+
+static DesktopEntry *g_desktops = NULL;
+static int  g_desktop_count = 0;
+static bool g_desktop_indexed = false;
+
+static void desktop_scan_dir(const char *dir)
+{
+    DIR *d = opendir(dir);
+    if (!d) return;
+    struct dirent *de;
+    while ((de = readdir(d)) && g_desktop_count < MAX_DESKTOPS) {
+        const char *n = de->d_name;
+        size_t len = strlen(n);
+        if (len < 9 || strcmp(n + len - 8, ".desktop") != 0) continue;
+
+        char *path = fmt_alloc("%s/%s", dir, n);
+        FILE *f = fopen(path, "r");
+        if (!f) { free(path); continue; }
+
+        char *icon = NULL, *wmclass = NULL, *dname = NULL;
+        char line[1024];
+        while (fgets(line, sizeof(line), f)) {
+            if (!icon && strncmp(line, "Icon=", 5) == 0) {
+                char *v = str_trim(line + 5);
+                if (*v) icon = xstrdup(v);
+            } else if (!wmclass && strncmp(line, "StartupWMClass=", 15) == 0) {
+                char *v = str_trim(line + 15);
+                if (*v) wmclass = xstrdup(v);
+            } else if (!dname && strncmp(line, "Name=", 5) == 0) {
+                char *v = str_trim(line + 5);
+                if (*v) dname = xstrdup(v);
+            }
+            if (icon && wmclass && dname) break;
+        }
+        fclose(f);
+
+        if (!icon && !dname) { free(wmclass); free(path); continue; }
+
+        DesktopEntry *e = &g_desktops[g_desktop_count++];
+        e->path    = path;
+        e->icon    = icon;
+        e->wmclass = wmclass;
+        e->name    = dname;
+        e->id      = xstrdup(n);
+        e->id[len - 8] = 0;
+    }
+    closedir(d);
+}
+
+static void desktop_index_build(void)
+{
+    if (g_desktop_indexed) return;
+    g_desktop_indexed = true;
+    g_desktops = (DesktopEntry *)xmalloc(sizeof(DesktopEntry) * MAX_DESKTOPS);
+
+    const char *home = getenv("HOME");
+    const char *xdg_data_home = getenv("XDG_DATA_HOME");
+    const char *xdg_data_dirs = getenv("XDG_DATA_DIRS");
+
+    char *dirs[16];
+    int nd = 0;
+
+    if (xdg_data_home && *xdg_data_home)
+        dirs[nd++] = fmt_alloc("%s/applications", xdg_data_home);
+    else if (home)
+        dirs[nd++] = fmt_alloc("%s/.local/share/applications", home);
+    if (home) dirs[nd++] = fmt_alloc("%s/.local/share/flatpak/exports/share/applications", home);
+
+    if (xdg_data_dirs && *xdg_data_dirs) {
+        char *copy = xstrdup(xdg_data_dirs);
+        char *save = NULL;
+        for (char *tok = strtok_r(copy, ":", &save); tok && nd < 14; tok = strtok_r(NULL, ":", &save))
+            if (*tok) dirs[nd++] = fmt_alloc("%s/applications", tok);
+        free(copy);
+    } else {
+        dirs[nd++] = xstrdup("/usr/local/share/applications");
+        dirs[nd++] = xstrdup("/usr/share/applications");
+    }
+    if (nd < 15) dirs[nd++] = xstrdup("/var/lib/flatpak/exports/share/applications");
+
+    for (int i = 0; i < nd; ++i) {
+        if (is_dir(dirs[i])) desktop_scan_dir(dirs[i]);
+        free(dirs[i]);
+    }
+}
+
+/* org.qutebrowser.qutebrowser -> qutebrowser, org.telegram.desktop -> telegram */
+static const char *short_app_name(const char *app_id)
+{
+    static const char *generic[] = { "desktop", "app", "App", "gui", "GUI", "client",
+                                     "Client", "bin", "Bin", "gtk", "qt", "Desktop" };
+    if (!app_id || !*app_id) return "unknown";
+
+    const char *dot = strrchr(app_id, '.');
+    if (!dot || !dot[1] || strchr(app_id, '.') == dot) return app_id;
+
+    for (size_t i = 0; i < SDL_arraysize(generic); ++i) {
+        if (ci_cmp(dot + 1, generic[i]) != 0) continue;
+        /* the last part says nothing: use the one before it */
+        static char buf[128];
+        const char *p = dot - 1;
+        while (p > app_id && *p != '.') p--;
+        if (*p == '.') p++;
+        size_t n = (size_t)(dot - p);
+        if (n == 0 || n >= sizeof(buf)) break;
+        memcpy(buf, p, n);
+        buf[n] = 0;
+        return buf;
+    }
+    return dot + 1;
+}
+
+
+static const DesktopEntry *desktop_find(const char *app_id)
+{
+    if (!app_id || !*app_id || !C.icons) return NULL;
+    desktop_index_build();
+
+    for (int i = 0; i < g_desktop_count; ++i)                    /* exact id     */
+        if (ci_cmp(g_desktops[i].id, app_id) == 0) return &g_desktops[i];
+    for (int i = 0; i < g_desktop_count; ++i)                    /* WM_CLASS     */
+        if (g_desktops[i].wmclass && ci_cmp(g_desktops[i].wmclass, app_id) == 0)
+            return &g_desktops[i];
+
+    const char *shrt = short_app_name(app_id);
+    for (int i = 0; i < g_desktop_count; ++i)                    /* short name   */
+        if (ci_cmp(g_desktops[i].id, shrt) == 0) return &g_desktops[i];
+    for (int i = 0; i < g_desktop_count; ++i) {                  /* id suffix    */
+        const char *dot = strrchr(g_desktops[i].id, '.');
+        if (dot && ci_cmp(dot + 1, shrt) == 0) return &g_desktops[i];
+    }
+    return NULL;
+}
+
+/* Some toolkits hand sway an app_id that identifies nothing: GTK apps
+ * launched without one report "GTK Application", and several Electron
+ * builds are no better. In that case the process behind the window knows
+ * more than the window does. */
+static bool app_id_is_useless(const char *id)
+{
+    static const char *bad[] = {
+        "", "gtk application", "gtk-application", "unknown", "wayland",
+        "xwayland", "electron", "toplevel", "window", "main", "app"
+    };
+    if (!id) return true;
+    for (size_t i = 0; i < SDL_arraysize(bad); ++i)
+        if (ci_cmp(id, bad[i]) == 0) return true;
+    return false;
+}
+
+static bool is_interpreter(const char *name)
+{
+    static const char *interp[] = {
+        "electron", "node", "python", "python3", "java", "sh", "bash", "zsh",
+        "wine", "wine64", "wine-preloader", "flatpak", "snap", "env", "steam"
+    };
+    for (size_t i = 0; i < SDL_arraysize(interp); ++i)
+        if (ci_cmp(name, interp[i]) == 0) return true;
+    return false;
+}
+
+static const char *base_name(const char *path)
+{
+    const char *slash = strrchr(path, '/');
+    return slash ? slash + 1 : path;
+}
+
+/* argv[0] of the process, or its comm name; empty when nothing usable */
+static void proc_app_name(int pid, char *out, size_t cap)
+{
+    out[0] = 0;
+    if (pid <= 1) return;
+
+    char path[64];
+    snprintf(path, sizeof(path), "/proc/%d/cmdline", pid);
+    FILE *f = fopen(path, "r");
+    if (f) {
+        char buf[1024];
+        size_t n = fread(buf, 1, sizeof(buf) - 1, f);
+        fclose(f);
+        buf[n] = 0;
+        if (n) {
+            const char *arg0 = base_name(buf);
+            if (*arg0 && !is_interpreter(arg0)) {
+                snprintf(out, cap, "%.*s", (int)cap - 1, arg0);
+                return;
+            }
+            /* an interpreter: the script or the app directory names it */
+            size_t off = strlen(buf) + 1;
+            while (off < n && out[0] == 0) {
+                const char *arg = buf + off;
+                if (*arg != '-') {
+                    const char *b = base_name(arg);
+                    if (*b && !is_interpreter(b) && !strchr(b, '=')) {
+                        snprintf(out, cap, "%.*s", (int)cap - 1, b);
+                        break;
+                    }
+                }
+                off += strlen(arg) + 1;
+            }
+            if (out[0]) return;
+        }
+    }
+
+    snprintf(path, sizeof(path), "/proc/%d/comm", pid);
+    f = fopen(path, "r");
+    if (!f) return;
+    char comm[128] = {0};
+    if (fgets(comm, sizeof(comm), f)) {
+        str_trim(comm);
+        if (comm[0] && !is_interpreter(comm)) snprintf(out, cap, "%.*s", (int)cap - 1, comm);
+    }
+    fclose(f);
+}
+
+/* what we print on a card: the .desktop Name= if we have one */
+static const char *app_display_name(const char *app_id)
+{
+    const DesktopEntry *de = desktop_find(app_id);
+    if (de && de->name && *de->name) return de->name;
+    return short_app_name(app_id);
+}
+
+static char *icon_theme_name(void)
+{
+    const char *home = getenv("HOME");
+    if (home) {
+        char *p = fmt_alloc("%s/.config/gtk-3.0/settings.ini", home);
+        FILE *f = fopen(p, "r");
+        free(p);
+        if (f) {
+            char line[512];
+            while (fgets(line, sizeof(line), f)) {
+                if (strncmp(line, "gtk-icon-theme-name", 19) == 0) {
+                    char *eq = strchr(line, '=');
+                    if (eq) {
+                        char *v = str_trim(eq + 1);
+                        if (*v) { fclose(f); return xstrdup(v); }
+                    }
+                }
+            }
+            fclose(f);
+        }
+    }
+    return xstrdup("hicolor");
+}
+
+/* The theme directories are scanned once, in preference order. Looking an
+ * icon up afterwards is a handful of access() calls instead of the thousands
+ * a blind size/extension sweep would cost. */
+typedef struct { char *path; int size; } IconDir;
+static IconDir *g_icon_dirs = NULL;
+static int  g_icon_dir_count = 0, g_icon_dir_cap = 0;
+static bool g_icon_dirs_built = false;
+
+static void icon_dir_add(const char *path, int size)
+{
+    if (!is_dir(path)) return;
+    if (g_icon_dir_count == g_icon_dir_cap) {
+        g_icon_dir_cap = g_icon_dir_cap ? g_icon_dir_cap * 2 : 32;
+        g_icon_dirs = (IconDir *)xrealloc(g_icon_dirs, (size_t)g_icon_dir_cap * sizeof(IconDir));
+    }
+    g_icon_dirs[g_icon_dir_count].path = xstrdup(path);
+    g_icon_dirs[g_icon_dir_count].size = size;
+    g_icon_dir_count++;
+}
+
+static int cmp_icon_dir(const void *A, const void *B)
+{
+    const IconDir *a = (const IconDir *)A, *b = (const IconDir *)B;
+    if (a->size != b->size) {
+        if (a->size == 0) return -1;           /* scalable wins: crisp at any size */
+        if (b->size == 0) return 1;
+        return b->size - a->size;              /* then big before small */
+    }
+    return 0;
+}
+
+static void icon_dirs_build(void)
+{
+    if (g_icon_dirs_built) return;
+    g_icon_dirs_built = true;
+
+    static char *theme = NULL;
+    if (!theme) theme = icon_theme_name();
+
+    const char *home = getenv("HOME");
+    char *roots[4];
+    int nr = 0;
+    if (home) {
+        roots[nr++] = fmt_alloc("%s/.local/share/icons", home);
+        roots[nr++] = fmt_alloc("%s/.icons", home);
+    }
+    roots[nr++] = xstrdup("/usr/share/icons");
+    roots[nr++] = xstrdup("/usr/local/share/icons");
+
+    const char *themes[] = { theme, "Adwaita", "breeze", "Papirus", "hicolor" };
+    const char *cats[]   = { "apps", "applications" };
+
+    for (int r = 0; r < nr; ++r) {
+        for (size_t t = 0; t < SDL_arraysize(themes); ++t) {
+            if (!themes[t] || !*themes[t]) continue;
+            char *tdir = fmt_alloc("%s/%s", roots[r], themes[t]);
+            DIR *d = opendir(tdir);
+            if (!d) { free(tdir); continue; }
+
+            struct dirent *de;
+            while ((de = readdir(d))) {
+                const char *n = de->d_name;
+                if (n[0] == '.') continue;
+                int size = -1;
+                if (strcmp(n, "scalable") == 0) size = 0;
+                else if (isdigit((unsigned char)n[0])) size = atoi(n);   /* 48x48 */
+                if (size < 0) continue;
+                if (strstr(n, "@2x")) continue;                          /* skip hidpi */
+                for (size_t c = 0; c < SDL_arraysize(cats); ++c) {
+                    char *p = fmt_alloc("%s/%s/%s", tdir, n, cats[c]);
+                    icon_dir_add(p, size);
+                    free(p);
+                }
+            }
+            closedir(d);
+            free(tdir);
+        }
+    }
+    for (int i = 0; i < nr; ++i) free(roots[i]);
+
+    qsort(g_icon_dirs, (size_t)g_icon_dir_count, sizeof(IconDir), cmp_icon_dir);
+    icon_dir_add("/usr/share/pixmaps", 0);
+}
+
+static char *icon_lookup(const char *name, int want_px)
+{
+    (void)want_px;
+    if (!name || !*name) return NULL;
+    if (name[0] == '/') return file_readable(name) ? xstrdup(name) : NULL;
+
+    icon_dirs_build();
+
+    const char *exts[] = { ".svg", ".png", ".xpm" };
+    bool has_ext = strstr(name, ".png") || strstr(name, ".svg") || strstr(name, ".xpm");
+
+    for (int d = 0; d < g_icon_dir_count; ++d) {
+        for (size_t e = 0; e < SDL_arraysize(exts); ++e) {
+            char *p = fmt_alloc("%s/%s%s", g_icon_dirs[d].path, name, has_ext ? "" : exts[e]);
+            if (file_readable(p)) return p;
+            free(p);
+            if (has_ext) break;
+        }
+    }
+    return NULL;
+}
+
+static SDL_Texture *icon_load_file(const char *path, int want_px)
+{
+    SDL_Surface *surf = NULL;
+    size_t n = strlen(path);
+    if (n > 4 && ci_cmp(path + n - 4, ".svg") == 0) {
+        SDL_IOStream *io = SDL_IOFromFile(path, "rb");
+        if (io) {
+            surf = IMG_LoadSizedSVG_IO(io, want_px, want_px);
+            SDL_CloseIO(io);
+        }
+    }
+    if (!surf) surf = IMG_Load(path);
+    if (!surf) return NULL;
+
+    SDL_Texture *t = SDL_CreateTextureFromSurface(REN, surf);
+    SDL_DestroySurface(surf);
+    if (t) {
+        SDL_SetTextureScaleMode(t, SDL_SCALEMODE_LINEAR);
+        SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND);
+    }
+    return t;
+}
+
+/* a rounded tile with the first letter, used when no icon is found */
+static SDL_Texture *icon_make_letter(const char *app_id, int want_px)
+{
+    const char *nm = short_app_name(app_id);
+    char letter[8] = {0};
+    /* copy one UTF-8 code point, upper-cased if ASCII */
+    unsigned char c0 = (unsigned char)nm[0];
+    int clen = (c0 < 0x80) ? 1 : (c0 < 0xe0) ? 2 : (c0 < 0xf0) ? 3 : 4;
+    memcpy(letter, nm, (size_t)clen);
+    if (clen == 1) letter[0] = (char)toupper(c0);
+
+    SDL_Texture *t = SDL_CreateTexture(REN, SDL_PIXELFORMAT_RGBA32,
+                                       SDL_TEXTUREACCESS_TARGET, want_px, want_px);
+    if (!t) return NULL;
+    SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND);
+    SDL_SetTextureScaleMode(t, SDL_SCALEMODE_LINEAR);
+
+    SDL_Texture *prev = SDL_GetRenderTarget(REN);
+    SDL_SetRenderTarget(REN, t);
+    SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_NONE);
+    set_col((SDL_FColor){0, 0, 0, 0});
+    SDL_RenderClear(REN);
+    SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_BLEND);
+
+    SDL_FRect box = { 0, 0, (float)want_px, (float)want_px };
+    fill_round_rect(box, want_px * 0.24f, mix(C.hl, C.tile, 0.55f));
+    stroke_round_rect(box, want_px * 0.24f, SDL_max(1.0f, want_px * 0.05f),
+                      with_alpha(C.hl, 0.85f));
+
+    Tex g = text_make(F_LABEL, letter);
+    if (g.t) {
+        float s = SDL_min((float)want_px * 0.62f / SDL_max(1, g.h), 1.6f);
+        SDL_FRect dst = { (want_px - g.w * s) * 0.5f, (want_px - g.h * s) * 0.5f,
+                          g.w * s, g.h * s };
+        SDL_SetTextureColorModFloat(g.t, C.text.r, C.text.g, C.text.b);
+        SDL_RenderTexture(REN, g.t, NULL, &dst);
+        tex_free(&g);
+    }
+
+    SDL_SetRenderTarget(REN, prev);
+    return t;
+}
+
+typedef struct { char key[192]; SDL_Texture *tex; } IconCacheEntry;
+static IconCacheEntry g_icons[256];
+static int g_icon_count = 0;
+
+static SDL_Texture *icon_for_app(const char *app_id, int want_px)
+{
+    if (!C.icons) return NULL;
+    const char *key = (app_id && *app_id) ? app_id : "unknown";
+
+    for (int i = 0; i < g_icon_count; ++i)
+        if (strcmp(g_icons[i].key, key) == 0) return g_icons[i].tex;
+
+    SDL_Texture *tex = NULL;
+    const DesktopEntry *de = desktop_find(key);
+    if (de && de->icon) {
+        char *path = icon_lookup(de->icon, want_px);
+        if (path) { tex = icon_load_file(path, want_px); free(path); }
+    }
+    if (!tex) {                                   /* app_id often *is* the icon */
+        char *path = icon_lookup(key, want_px);
+        if (!path) path = icon_lookup(short_app_name(key), want_px);
+        if (path) { tex = icon_load_file(path, want_px); free(path); }
+    }
+    if (!tex) tex = icon_make_letter(key, want_px);
+
+    if (g_icon_count < (int)SDL_arraysize(g_icons)) {
+        snprintf(g_icons[g_icon_count].key, sizeof(g_icons[0].key), "%s", key);
+        g_icons[g_icon_count].tex = tex;
+        g_icon_count++;
+    }
+    return tex;
+}
+
+static void icons_free(void)
+{
+    for (int i = 0; i < g_icon_count; ++i)
+        if (g_icons[i].tex) SDL_DestroyTexture(g_icons[i].tex);
+    g_icon_count = 0;
+
+    for (int i = 0; i < g_desktop_count; ++i) {
+        free(g_desktops[i].id);
+        free(g_desktops[i].path);
+        free(g_desktops[i].icon);
+        free(g_desktops[i].wmclass);
+        free(g_desktops[i].name);
+    }
+    for (int i = 0; i < g_icon_dir_count; ++i) free(g_icon_dirs[i].path);
+    free(g_icon_dirs);
+    g_icon_dirs = NULL;
+    g_icon_dir_count = g_icon_dir_cap = 0;
+    g_icon_dirs_built = false;
+
+    free(g_desktops);
+    g_desktops = NULL;
+    g_desktop_count = 0;
+    g_desktop_indexed = false;
+}
+
+/* ----------------------------------------------------------------- model */
+
+typedef struct {
+    int   con_id;
+    int   pid;
+    char  app_id[128];      /* what sway reports                            */
+    char  app_key[128];     /* what we look the icon and the name up with   */
+    char  title[512];
+    char  ws_name[64];
+    char  output[64];
+
+    int   x, y, w, h;          /* absolute geometry, as sway reports it */
+    bool  floating, focused, urgent, fullscreen, sticky;
+    bool  marked;              /* multi-selection */
+    bool  match;               /* passes the current filter */
+
+    int   ws;                  /* index into WSS */
+    SDL_FRect card;            /* where it is drawn (render pixels) */
+    int   group;               /* head index of a tabbed/stacked group, or -1 */
+
+    int   lay_mode;            /* how the card contents are arranged */
+    float lay_icon;            /* icon edge length on this card */
+    bool  lay_sub;             /* is there room for the title */
+
+    SDL_Texture *icon;         /* borrowed from the icon cache */
+    Tex   label, subtitle;
+} Win;
+
+typedef struct {
+    char  name[64];
+    char  output[64];
+    int   num;
+    bool  focused, visible, urgent;
+    int   rx, ry, rw, rh;      /* workspace rect */
+
+    int   first, count;        /* range in WINS */
+    int   sel;                 /* selected window, relative to first; -1 = the
+                                * workspace itself is selected             */
+    int   top[64];             /* top level containers, in order           */
+    int   ntop;
+
+    SDL_FRect tile, screen;    /* layout (render pixels) */
+    SDL_FRect tile_from;       /* where this tile animates from */
+    Tex   badge, sub;
+} Ws;
+
+static Win WINS[MAX_WINDOWS];
+static int NWIN = 0;
+static Ws  WSS[MAX_WORKSPACES];
+static int NWS = 0;
+
+static char FOCUSED_OUTPUT[64] = {0};   /* output sway currently focuses */
+static char CUR_OUTPUT[64]     = {0};   /* output the tree walk is inside */
+
+static void model_free(void)
+{
+    for (int i = 0; i < NWIN; ++i) { tex_free(&WINS[i].label); tex_free(&WINS[i].subtitle); }
+    for (int i = 0; i < NWS; ++i)  { tex_free(&WSS[i].badge);  tex_free(&WSS[i].sub); }
+    NWIN = NWS = 0;
+}
+
+static bool node_is_view(const JV *n)
+{
+    if (jget(n, "app_id") && jget(n, "app_id")->type == J_STR) return true;
+    const JV *wp = jget(n, "window_properties");
+    return wp && jget(wp, "class") && jget(wp, "class")->type == J_STR;
+}
+
+static void collect_views(const JV *node, Ws *ws, bool floating)
+{
+    if (!node || node->type != J_OBJ) return;
+
+    if (node_is_view(node) && NWIN < MAX_WINDOWS) {
+        Win *w = &WINS[NWIN];
+        memset(w, 0, sizeof(*w));
+
+        const char *app = jstr(node, "app_id", NULL);
+        if (!app) {
+            const JV *wp = jget(node, "window_properties");
+            app = wp ? jstr(wp, "class", NULL) : NULL;
+        }
+        snprintf(w->app_id, sizeof(w->app_id), "%s", app ? app : "unknown");
+        memcpy(w->app_key, w->app_id, sizeof(w->app_key));
+        snprintf(w->title,  sizeof(w->title),  "%s", jstr(node, "name", ""));
+        snprintf(w->ws_name, sizeof(w->ws_name), "%s", ws->name);
+        snprintf(w->output,  sizeof(w->output),  "%s", ws->output);
+
+        w->con_id     = jint(node, "id", 0);
+        w->pid        = jint(node, "pid", 0);
+        w->focused    = jbool(node, "focused", false);
+        w->urgent     = jbool(node, "urgent", false);
+        w->sticky     = jbool(node, "sticky", false);
+        w->floating   = floating;
+        w->fullscreen = jint(node, "fullscreen_mode", 0) != 0;
+        w->match      = true;
+        w->ws         = (int)(ws - WSS);
+
+        const JV *r = jget(node, "rect");
+        w->x = jint(r, "x", ws->rx);
+        w->y = jint(r, "y", ws->ry);
+        w->w = jint(r, "width",  100);
+        w->h = jint(r, "height", 100);
+        if (w->w <= 0) w->w = 100;
+        if (w->h <= 0) w->h = 100;
+
+        NWIN++;
+        ws->count++;
+        return;                                  /* views have no children */
+    }
+
+    const JV *nodes = jget(node, "nodes");
+    if (nodes && nodes->type == J_ARR)
+        for (int i = 0; i < nodes->count; ++i) collect_views(nodes->items[i], ws, floating);
+
+    const JV *fl = jget(node, "floating_nodes");
+    if (fl && fl->type == J_ARR)
+        for (int i = 0; i < fl->count; ++i) collect_views(fl->items[i], ws, true);
+}
+
+static int cmp_win_pos(const void *A, const void *B)
+{
+    const Win *a = (const Win *)A, *b = (const Win *)B;
+    bool af = a->floating || a->fullscreen;      /* both are drawn on top */
+    bool bf = b->floating || b->fullscreen;
+    if (af != bf) return af ? 1 : -1;
+    if (a->y != b->y) return a->y < b->y ? -1 : 1;
+    if (a->x != b->x) return a->x < b->x ? -1 : 1;
+    return a->con_id - b->con_id;
+}
+
+static void walk_outputs(const JV *node)
+{
+    if (!node || node->type != J_OBJ) return;
+    const char *type = jstr(node, "type", "");
+
+    if (strcmp(type, "workspace") == 0) {
+        const char *name = jstr(node, "name", "?");
+        if (strcmp(name, "__i3_scratchpad") == 0) return;
+        if (NWS >= MAX_WORKSPACES) return;
+
+        Ws *ws = &WSS[NWS];
+        memset(ws, 0, sizeof(*ws));
+        snprintf(ws->name, sizeof(ws->name), "%s", name);
+        snprintf(ws->output, sizeof(ws->output), "%s", CUR_OUTPUT);
+        ws->num     = jint(node, "num", str_all_digits(name) ? atoi(name) : -1);
+        ws->focused = jbool(node, "focused", false);
+        ws->urgent  = jbool(node, "urgent", false);
+
+        const JV *r = jget(node, "rect");
+        ws->rx = jint(r, "x", 0);
+        ws->ry = jint(r, "y", 0);
+        ws->rw = jint(r, "width",  1920);
+        ws->rh = jint(r, "height", 1080);
+
+        ws->first = NWIN;
+        ws->count = 0;
+        ws->sel   = -1;
+        ws->ntop  = 0;
+        NWS++;
+
+        /* remember the direct children: moving those keeps their layout */
+        const JV *kids[2] = { jget(node, "nodes"), jget(node, "floating_nodes") };
+        for (int a = 0; a < 2; ++a)
+            if (kids[a] && kids[a]->type == J_ARR)
+                for (int b = 0; b < kids[a]->count && ws->ntop < 64; ++b) {
+                    int id = jint(kids[a]->items[b], "id", 0);
+                    if (id) ws->top[ws->ntop++] = id;
+                }
+
+        collect_views(node, ws, false);
+
+        if (ws->count > 1)
+            qsort(&WINS[ws->first], (size_t)ws->count, sizeof(Win), cmp_win_pos);
+        for (int i = 0; i < ws->count; ++i) WINS[ws->first + i].ws = (int)(ws - WSS);
+        return;
+    }
+
+    if (strcmp(type, "output") == 0) {
+        const char *name = jstr(node, "name", "");
+        if (strcmp(name, "__i3") == 0) return;
+        if (!C.all_outputs && FOCUSED_OUTPUT[0] && strcmp(name, FOCUSED_OUTPUT) != 0) return;
+        snprintf(CUR_OUTPUT, sizeof(CUR_OUTPUT), "%s", name);
+    }
+
+    const JV *nodes = jget(node, "nodes");
+    if (nodes && nodes->type == J_ARR)
+        for (int i = 0; i < nodes->count; ++i) walk_outputs(nodes->items[i]);
+}
+
+static int cmp_ws(const void *A, const void *B)
+{
+    const Ws *a = (const Ws *)A, *b = (const Ws *)B;
+    int o = strcmp(a->output, b->output);
+    if (o) return o;
+    if (a->num >= 0 && b->num >= 0 && a->num != b->num) return a->num < b->num ? -1 : 1;
+    if ((a->num >= 0) != (b->num >= 0)) return a->num >= 0 ? -1 : 1;
+    return strcmp(a->name, b->name);
+}
+
+/* Fetch workspaces + tree and rebuild the model. Returns false on failure. */
+static bool model_reload(void)
+{
+    model_free();
+
+    char focused[64] = {0};
+    JV *wsr = sway_query(IPC_GET_WORKSPACES);
+    if (wsr && wsr->type == J_ARR) {
+        for (int i = 0; i < wsr->count; ++i)
+            if (jbool(wsr->items[i], "focused", false))
+                snprintf(focused, sizeof(focused), "%s", jstr(wsr->items[i], "output", ""));
+    }
+
+    /* sway reports no focused workspace for a moment after a rename; keeping
+     * the previous output beats falling back to "every output" */
+    if (focused[0]) snprintf(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), "%s", focused);
+
+    JV *tree = sway_query(IPC_GET_TREE);
+    if (!tree) { jfree(wsr); return false; }
+
+    CUR_OUTPUT[0] = 0;
+    walk_outputs(tree);
+
+    /* per-output names + visible flags from get_workspaces */
+    if (wsr && wsr->type == J_ARR) {
+        for (int i = 0; i < NWS; ++i) {
+            for (int j = 0; j < wsr->count; ++j) {
+                const JV *o = wsr->items[j];
+                if (strcmp(jstr(o, "name", ""), WSS[i].name) != 0) continue;
+                snprintf(WSS[i].output, sizeof(WSS[i].output), "%s", jstr(o, "output", ""));
+                WSS[i].visible = jbool(o, "visible", false);
+                WSS[i].focused = jbool(o, "focused", WSS[i].focused);
+                WSS[i].urgent  = jbool(o, "urgent",  WSS[i].urgent);
+                WSS[i].num     = jint(o, "num", WSS[i].num);
+                break;
+            }
+        }
+    }
+
+    jfree(wsr);
+    jfree(tree);
+
+    /* drop empty workspaces if the user does not want them */
+    if (!C.show_empty) {
+        int k = 0;
+        for (int i = 0; i < NWS; ++i)
+            if (WSS[i].count > 0) WSS[k++] = WSS[i];
+        NWS = k;
+    }
+
+    /* sorting workspaces moves them around; fix up window -> workspace links */
+    if (NWS > 1) qsort(WSS, (size_t)NWS, sizeof(Ws), cmp_ws);
+    for (int i = 0; i < NWS; ++i)
+        for (int j = 0; j < WSS[i].count; ++j) WINS[WSS[i].first + j].ws = i;
+
+    for (int i = 0; i < NWIN; ++i) {
+        Win *w = &WINS[i];
+        if (app_id_is_useless(w->app_id)) {          /* ask the process instead */
+            char nm[128];
+            proc_app_name(w->pid, nm, sizeof(nm));
+            if (nm[0]) snprintf(w->app_key, sizeof(w->app_key), "%s", nm);
+        }
+        w->icon = icon_for_app(w->app_key, px((float)C.icon_px * 1.6f));
+    }
+
+    return true;
+}
+
+/* ---------------------------------------------------------------- layout */
+
+typedef struct { int ws; int num; SDL_FRect tile, from; } Slot;  /* ws < 0 = ghost */
+static Slot SLOTS[MAX_WORKSPACES + 16];
+static int  NSLOTS;
+static bool drag_ws_mode;         /* a workspace is being dragged right now  */
+static int  ghost_lo = -1, ghost_hi = -1;   /* free numbers currently offered */
+
+static Slot   DYING[16];          /* ghosts on their way out                 */
+static int    NDYING;
+static Uint64 dying_start;
+static bool   laid_out_once;
+
+static Uint64 anim_start;         /* tiles glide when the grid changes       */
+
+static bool anim_running(void)
+{
+    if (C.anim_ms <= 0.0f) return false;
+    if (anim_start && (float)(SDL_GetTicks() - anim_start) < C.anim_ms) return true;
+    return NDYING > 0 && (float)(SDL_GetTicks() - dying_start) < C.anim_ms;
+}
+
+static float phase_since(Uint64 start)
+{
+    if (!start || C.anim_ms <= 0.0f) return 1.0f;
+    float t = (float)(SDL_GetTicks() - start) / C.anim_ms;
+    if (t >= 1.0f) return 1.0f;
+    if (t <= 0.0f) return 0.0f;
+    float inv = 1.0f - t;
+    return 1.0f - inv * inv * inv;
+}
+
+/* 0..1, ease-out so the movement settles instead of stopping dead */
+static float anim_phase(void)
+{
+    if (!anim_start || C.anim_ms <= 0.0f) return 1.0f;
+    float t = (float)(SDL_GetTicks() - anim_start) / C.anim_ms;
+    if (t >= 1.0f) return 1.0f;
+    if (t <= 0.0f) return 0.0f;
+    float inv = 1.0f - t;
+    return 1.0f - inv * inv * inv;
+}
+
+static SDL_FRect rect_lerp(SDL_FRect a, SDL_FRect b, float t)
+{
+    return (SDL_FRect){ a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t,
+                        a.w + (b.w - a.w) * t, a.h + (b.h - a.h) * t };
+}
+
+static SDL_FRect rect_shrink(SDL_FRect r, float f)
+{
+    float w = r.w * f, h = r.h * f;
+    return (SDL_FRect){ r.x + (r.w - w) * 0.5f, r.y + (r.h - h) * 0.5f, w, h };
+}
+
+static int   RW, RH;              /* size of the render target, in pixels   */
+static float HEADER_H, FOOTER_H;
+
+/* position strings: none | top-left | top-center | top-right |
+ *                          bottom-left | bottom-center | bottom-right       */
+static bool pos_is_none(const char *p)   { return !p || !*p || ci_cmp(p, "none") == 0 ||
+                                                  ci_cmp(p, "off") == 0 || ci_cmp(p, "hidden") == 0; }
+static bool pos_is_top(const char *p)    { return !pos_is_none(p) && strncmp(p, "top", 3) == 0; }
+static bool pos_is_bottom(const char *p) { return !pos_is_none(p) && !pos_is_top(p); }
+
+/* x of a text of width w for the horizontal half of the position string */
+static float pos_x(const char *p, float w, float margin)
+{
+    const char *dash = strchr(p ? p : "", '-');
+    const char *h = dash ? dash + 1 : "right";
+    if (ci_cmp(h, "left") == 0)   return margin;
+    if (ci_cmp(h, "center") == 0 || ci_cmp(h, "centre") == 0)
+        return ((float)RW - w) * 0.5f;
+    return (float)RW - margin - w;
+}
+
+static int  sel_ws = 0;
+static bool sel_active = true;   /* false: no tile and no window highlighted */
+static int  hov_ws = -1, hov_win = -1;
+static char query[128];
+static int  qlen = 0;
+static bool filtering = false;
+
+/* NULL means "the workspace itself is selected, not a window in it" */
+static Win *ws_sel_win(Ws *w)
+{
+    if (w->count <= 0 || w->sel < 0) return NULL;
+    if (w->sel >= w->count) w->sel = w->count - 1;
+    return &WINS[w->first + w->sel];
+}
+
+static bool rect_overlap_same(const Win *a, const Win *b)
+{
+    int tol = 6;
+    return SDL_abs(a->x - b->x) <= tol && SDL_abs(a->y - b->y) <= tol &&
+           SDL_abs(a->w - b->w) <= tol && SDL_abs(a->h - b->h) <= tol;
+}
+
+static void layout_cards(Ws *ws)
+{
+    if (ws->count <= 0) return;
+
+    /* the windows live in an area that keeps an equal border on all four
+     * sides of the mini screen, so nothing ever touches the tile frame */
+    float sp = C.screen_pad * SC;
+    SDL_FRect area = { ws->screen.x + sp, ws->screen.y + sp,
+                       ws->screen.w - 2.0f * sp, ws->screen.h - 2.0f * sp };
+    if (area.w < 8.0f || area.h < 8.0f) area = ws->screen;
+
+    float sx = area.w / (float)SDL_max(1, ws->rw);
+    float sy = area.h / (float)SDL_max(1, ws->rh);
+    float inset = C.win_gap * SC * 0.5f;
+    float min_w = 74.0f * SC, min_h = 52.0f * SC;
+    min_w = SDL_min(min_w, area.w);
+    min_h = SDL_min(min_h, area.h);
+
+    for (int i = 0; i < ws->count; ++i) {
+        Win *w = &WINS[ws->first + i];
+        SDL_FRect r = {
+            area.x + (float)(w->x - ws->rx) * sx + inset,
+            area.y + (float)(w->y - ws->ry) * sy + inset,
+            (float)w->w * sx - 2.0f * inset,
+            (float)w->h * sy - 2.0f * inset
+        };
+        if (r.w < min_w) { r.x -= (min_w - r.w) * 0.5f; r.w = min_w; }
+        if (r.h < min_h) { r.y -= (min_h - r.h) * 0.5f; r.h = min_h; }
+        w->card = r;
+    }
+
+    /* Tabbed / stacked containers report identical geometry for every child.
+     * Split such a group into side by side slices so all of them stay
+     * visible and clickable. */
+    bool done[MAX_WINDOWS];
+    memset(done, 0, sizeof(bool) * (size_t)ws->count);
+    for (int i = 0; i < ws->count; ++i) WINS[ws->first + i].group = -1;
+
+    for (int i = 0; i < ws->count; ++i) {
+        if (done[i]) continue;
+        int group[64];
+        int n = 0;
+        group[n++] = i;
+        for (int j = i + 1; j < ws->count && n < 64; ++j) {
+            if (done[j]) continue;
+            if (WINS[ws->first + i].floating != WINS[ws->first + j].floating) continue;
+            if (rect_overlap_same(&WINS[ws->first + i], &WINS[ws->first + j])) {
+                group[n++] = j;
+                done[j] = true;
+            }
+        }
+        done[i] = true;
+        if (n < 2) continue;
+
+        float ge = 3.0f * SC;                    /* room for the group outline */
+        SDL_FRect base = WINS[ws->first + i].card;
+        base.x += ge; base.y += ge; base.w -= 2.0f * ge; base.h -= 2.0f * ge;
+        if (base.w < 4.0f || base.h < 4.0f) base = WINS[ws->first + i].card;
+        bool horiz = base.w >= base.h;
+        for (int k = 0; k < n; ++k) WINS[ws->first + group[k]].group = i;
+        for (int k = 0; k < n; ++k) {
+            SDL_FRect r = base;
+            if (horiz) {
+                r.w = (base.w - (float)(n - 1) * inset * 2.0f) / (float)n;
+                r.x = base.x + (float)k * (r.w + inset * 2.0f);
+            } else {
+                r.h = (base.h - (float)(n - 1) * inset * 2.0f) / (float)n;
+                r.y = base.y + (float)k * (r.h + inset * 2.0f);
+            }
+            WINS[ws->first + group[k]].card = r;
+        }
+    }
+
+    /* keep everything inside the tile */
+    for (int i = 0; i < ws->count; ++i) {
+        SDL_FRect *r = &WINS[ws->first + i].card;
+        if (r->w > area.w) r->w = area.w;
+        if (r->h > area.h) r->h = area.h;
+        if (r->x < area.x) r->x = area.x;
+        if (r->y < area.y) r->y = area.y;
+        if (r->x + r->w > area.x + area.w) r->x = area.x + area.w - r->w;
+        if (r->y + r->h > area.y + area.h) r->y = area.y + area.h - r->h;
+    }
+}
+
+enum { CL_ICON, CL_STACK, CL_ROW, CL_TEXT };
+
+/* Decide how much fits on a card: icon + app name + title, icon + name,
+ * a row (icon left, text right) on wide flat cards, or just an icon. */
+static void compute_card_layout(Win *w)
+{
+    float pad    = 7.0f * SC;
+    float availw = w->card.w - 2.0f * pad;
+    float availh = w->card.h - 2.0f * pad;
+    float lab_h  = (float)TTF_GetFontHeight(F_LABEL);
+    float sub_h  = (float)TTF_GetFontHeight(F_TITLE);
+    float g1 = 4.0f * SC, g2 = 1.0f * SC;
+    float icon_max = C.icons ? SDL_min((float)C.icon_px * SC,
+                                       SDL_min(availw * 0.72f, availh * 0.72f)) : 0.0f;
+    float icon_min = 16.0f * SC;
+
+    w->lay_mode = CL_ICON;
+    w->lay_icon = icon_max;
+    w->lay_sub  = false;
+
+    if (availw <= 4.0f || availh <= 4.0f) return;
+
+    if (availh >= icon_max + g1 + lab_h + g2 + sub_h) {          /* everything */
+        w->lay_mode = CL_STACK;
+        w->lay_icon = icon_max;
+        w->lay_sub  = true;
+        return;
+    }
+    if (availw >= availh * 1.15f && availw >= lab_h * 5.0f &&
+        availh >= lab_h + g2 + sub_h) {                          /* wide and flat */
+        w->lay_mode = CL_ROW;
+        w->lay_icon = C.icons ? SDL_min(icon_max, SDL_min(availh * 0.9f, availw * 0.3f)) : 0.0f;
+        if (w->lay_icon < icon_min) w->lay_icon = 0.0f;
+        w->lay_sub  = true;
+        return;
+    }
+    if (availh - (lab_h + g1 + g2 + sub_h) >= icon_min) {        /* shrink the icon */
+        w->lay_mode = CL_STACK;
+        w->lay_icon = availh - (lab_h + g1 + g2 + sub_h);
+        w->lay_sub  = true;
+        return;
+    }
+    if (availh >= icon_min + g1 + lab_h) {                       /* icon + name */
+        w->lay_mode = CL_STACK;
+        w->lay_icon = SDL_min(icon_max, availh - lab_h - g1);
+        w->lay_sub  = false;
+        return;
+    }
+    if (availh >= lab_h) {                                       /* name only */
+        w->lay_mode = CL_TEXT;
+        w->lay_icon = 0.0f;
+        w->lay_sub  = false;
+        return;
+    }
+    w->lay_icon = SDL_min(availw, availh);                       /* icon only */
+}
+
+static float card_text_width(const Win *w)
+{
+    float pad = 7.0f * SC;
+    float availw = w->card.w - 2.0f * pad;
+    if (w->lay_mode == CL_ROW && w->lay_icon > 0.0f) availw -= w->lay_icon + 6.0f * SC;
+    return availw;
+}
+
+/* The name printed in bold on a card. Sway's app_id is used when it says
+ * something; otherwise the process behind the window has already been asked
+ * (app_key), and failing that the tail of the title is the best guess:
+ * "document.txt - Some Editor". */
+static const char *card_label_name(const Win *w)
+{
+    /* sway knew the app: that is the best answer */
+    if (!app_id_is_useless(w->app_id)) return app_display_name(w->app_id);
+
+    /* the process behind it matched a .desktop file: also solid */
+    const DesktopEntry *de = desktop_find(w->app_key);
+    if (de && de->name && *de->name) return de->name;
+
+    /* otherwise the tail of the title names the app more often than a binary
+     * does: "document.txt - Some Editor", or plainly "Claude" */
+    if (w->title[0]) {
+        const char *tail = NULL;
+        for (const char *p = strstr(w->title, " - "); p; p = strstr(p + 3, " - ")) tail = p + 3;
+        for (const char *p = strstr(w->title, " \xe2\x80\x94 "); p; p = strstr(p + 5, " \xe2\x80\x94 "))
+            tail = p + 5;
+        if (tail && *tail) return tail;
+
+        /* no separator at all: a short title is usually the app name itself
+         * ("Claude"), a long one is a document and the binary is the better
+         * answer */
+        if (strlen(w->title) <= 24) return w->title;
+    }
+
+    /* last resort: the executable name, without its extension */
+    if (!app_id_is_useless(w->app_key)) {
+        static char buf[128];
+        snprintf(buf, sizeof(buf), "%s", w->app_key);
+        char *dot = strrchr(buf, '.');
+        if (dot && (ci_cmp(dot, ".py") == 0 || ci_cmp(dot, ".sh") == 0 ||
+                    ci_cmp(dot, ".js") == 0 || ci_cmp(dot, ".bin") == 0 ||
+                    ci_cmp(dot, ".exe") == 0)) *dot = 0;
+        if (buf[0]) return buf;
+    }
+    return w->title[0] ? w->title : "unknown";
+}
+
+static void build_texts(void)
+{
+    for (int i = 0; i < NWS; ++i) {
+        Ws *ws = &WSS[i];
+        tex_free(&ws->badge);
+        tex_free(&ws->sub);
+
+        ws->badge = text_make(F_BADGE, ws->name);
+
+        char sub[192];
+        if (ws->count == 0)          snprintf(sub, sizeof(sub), "empty");
+        else if (ws->count == 1)     snprintf(sub, sizeof(sub), "1 window");
+        else                         snprintf(sub, sizeof(sub), "%d windows", ws->count);
+        if (C.all_outputs && ws->output[0]) {
+            char tmp[256];
+            snprintf(tmp, sizeof(tmp), "%s \xc2\xb7 %s", ws->output, sub);
+            snprintf(sub, sizeof(sub), "%.191s", tmp);
+        }
+        ws->sub = text_make(F_HINT, sub);
+    }
+
+    for (int i = 0; i < NWIN; ++i) {
+        Win *w = &WINS[i];
+        tex_free(&w->label);
+        tex_free(&w->subtitle);
+
+        compute_card_layout(w);
+        int maxw = (int)card_text_width(w);
+        if (maxw < 12 || w->lay_mode == CL_ICON) continue;
+
+        const char *name = card_label_name(w);
+        w->label = text_make_fit(F_LABEL, name, maxw);
+        if (w->lay_sub && strcmp(name, w->title) != 0)
+            w->subtitle = text_make_fit(F_TITLE, w->title, maxw);
+    }
+}
+
+static void layout(void)
+{
+    float m   = C.margin * SC;
+    float gap = C.gap * SC;
+
+    bool head_on  = C.show_header && !pos_is_none(C.header_pos);
+    bool hints_on = C.show_hints  && !pos_is_none(C.hints_pos);
+    float line = (float)C.hint_px * SC * C.ui_scale * 2.4f;
+
+    bool top    = (head_on && pos_is_top(C.header_pos))    || (hints_on && pos_is_top(C.hints_pos));
+    bool bottom = (head_on && pos_is_bottom(C.header_pos)) || (hints_on && pos_is_bottom(C.hints_pos));
+
+    HEADER_H = top ? line : m;
+    FOOTER_H = bottom ? line : 0.0f;
+
+    float ax = m;
+    float ay = HEADER_H;
+    float aw = (float)RW - 2.0f * m;
+    float ah = (float)RH - HEADER_H - FOOTER_H - m;
+    if (aw < 40.0f || ah < 40.0f || NWS == 0) return;
+
+    /* aspect of the screen we are mirroring */
+    float aspect = 16.0f / 9.0f;
+    for (int i = 0; i < NWS; ++i)
+        if (WSS[i].rw > 0 && WSS[i].rh > 0) { aspect = (float)WSS[i].rw / (float)WSS[i].rh; break; }
+
+    int best_rows = 1, best_cols = NWS;
+    float best_area = -1.0f;
+
+    if (C.rows > 0 || C.cols > 0) {
+        best_rows = C.rows > 0 ? C.rows : (NWS + C.cols - 1) / C.cols;
+        best_cols = C.cols > 0 ? C.cols : (NWS + C.rows - 1) / C.rows;
+        if (best_rows * best_cols < NWS) best_cols = (NWS + best_rows - 1) / best_rows;
+    } else {
+        for (int rows = 1; rows <= NWS; ++rows) {
+            int cols = (NWS + rows - 1) / rows;
+            float tw = (aw - gap * (float)(cols - 1)) / (float)cols;
+            float th = (ah - gap * (float)(rows - 1)) / (float)rows;
+            if (tw <= 0.0f || th <= 0.0f) continue;
+            /* tiles keep the screen aspect (plus room for the tile header) */
+            float ch = tw / aspect;
+            if (ch > th) tw = th * aspect; else th = ch;
+            float area = tw * th;
+            if (area > best_area) { best_area = area; best_rows = rows; best_cols = cols; }
+        }
+    }
+
+    /* While a workspace is dragged every free number 0..10 becomes a small
+     * ghost slot, so it can be dropped on a workspace that does not exist
+     * yet. Otherwise the slots are just the workspaces themselves. */
+    /* Slots are the workspaces themselves. Only while something is dragged
+     * into a gap do the free numbers of that gap join them, as small ghost
+     * tiles to drop on — they are not shown before they mean anything. */
+    /* Where everything is *right now*, mid-glide included: a relayout during
+     * an animation has to continue from what the eye sees, otherwise the
+     * movement snaps. */
+    Slot old[MAX_WORKSPACES + 16];
+    int  nold = SDL_min(NSLOTS, (int)SDL_arraysize(SLOTS));
+    float ph_now = anim_phase();
+    for (int i = 0; i < nold; ++i) {
+        old[i] = SLOTS[i];
+        old[i].tile = rect_lerp(SLOTS[i].from, SLOTS[i].tile, ph_now);
+    }
+
+    NSLOTS = 0;
+    for (int i = 0; i < NWS && NSLOTS < (int)SDL_arraysize(SLOTS); ++i) {
+        if (WSS[i].num < 0) continue;
+        SLOTS[NSLOTS].ws  = i;
+        SLOTS[NSLOTS].num = WSS[i].num;
+        NSLOTS++;
+    }
+    for (int n = ghost_lo; ghost_lo >= 0 && n <= ghost_hi &&
+                           NSLOTS < (int)SDL_arraysize(SLOTS); ++n) {
+        bool taken = false;
+        for (int i = 0; i < NWS; ++i) if (WSS[i].num == n) { taken = true; break; }
+        if (taken) continue;
+        SLOTS[NSLOTS].ws  = -1;
+        SLOTS[NSLOTS].num = n;
+        NSLOTS++;
+    }
+    for (int i = 0; i + 1 < NSLOTS; ++i)              /* order by number */
+        for (int j = i + 1; j < NSLOTS; ++j)
+            if (SLOTS[j].num < SLOTS[i].num) {
+                Slot t = SLOTS[i]; SLOTS[i] = SLOTS[j]; SLOTS[j] = t;
+            }
+    for (int i = 0; i < NWS && NSLOTS < (int)SDL_arraysize(SLOTS); ++i)
+        if (WSS[i].num < 0) {                         /* named workspaces last */
+            SLOTS[NSLOTS].ws  = i;
+            SLOTS[NSLOTS].num = -1;
+            NSLOTS++;
+        }
+
+    int n_tiles = NSLOTS;
+    if (C.rows <= 0 && C.cols <= 0 && n_tiles != NWS) {   /* redo the grid search */
+        best_area = -1.0f;
+        for (int rows = 1; rows <= n_tiles; ++rows) {
+            int cols = (n_tiles + rows - 1) / rows;
+            float tw = (aw - gap * (float)(cols - 1)) / (float)cols;
+            float th = (ah - gap * (float)(rows - 1)) / (float)rows;
+            if (tw <= 0.0f || th <= 0.0f) continue;
+            float ch = tw / aspect;
+            if (ch > th) tw = th * aspect; else th = ch;
+            float area = tw * th;
+            if (area > best_area) { best_area = area; best_rows = rows; best_cols = cols; }
+        }
+    }
+
+    int cols = best_cols, rows = best_rows;
+    if (cols * rows < n_tiles) cols = (n_tiles + rows - 1) / rows;
+    float tw = (aw - gap * (float)(cols - 1)) / (float)cols;
+    float th = (ah - gap * (float)(rows - 1)) / (float)rows;
+    float head_h = (float)C.ws_px * SC * 1.55f;
+    float body_aspect_h = tw / aspect + head_h;
+    if (body_aspect_h < th) th = body_aspect_h;
+
+    float used_w = (float)cols * tw + (float)(cols - 1) * gap;
+    float used_h = (float)rows * th + (float)(rows - 1) * gap;
+    float ox = ax + (aw - used_w) * 0.5f;
+    float oy = ay + (ah - used_h) * 0.5f;
+
+    for (int sidx = 0; sidx < NSLOTS; ++sidx) {
+        int r = sidx / cols, c = sidx % cols;
+        SLOTS[sidx].tile = (SDL_FRect){ ox + (float)c * (tw + gap),
+                                        oy + (float)r * (th + gap), tw, th };
+        if (SLOTS[sidx].ws < 0) continue;                 /* ghost: nothing to lay out */
+
+        Ws *ws = &WSS[SLOTS[sidx].ws];
+        ws->tile = SLOTS[sidx].tile;
+
+        float p = C.pad * SC;
+        SDL_FRect body = { ws->tile.x + p,
+                           ws->tile.y + head_h,
+                           ws->tile.w - 2.0f * p,
+                           ws->tile.h - head_h - p };
+        if (body.h < 8.0f) body.h = 8.0f;
+
+        /* letterbox the body to the real screen aspect so the mini layout
+         * has exactly the proportions of the monitor */
+        float wa = (ws->rw > 0 && ws->rh > 0) ? (float)ws->rw / (float)ws->rh : aspect;
+        float sw = body.w, sh = body.w / wa;
+        if (sh > body.h) { sh = body.h; sw = body.h * wa; }
+        ws->screen = (SDL_FRect){ body.x + (body.w - sw) * 0.5f,
+                                  body.y + (body.h - sh) * 0.5f, sw, sh };
+        layout_cards(ws);
+    }
+
+    /* match every slot with where it was a moment ago; things that were not
+     * there yet grow out of their own centre */
+    bool moved = false;
+    for (int i = 0; i < NSLOTS; ++i) {
+        SLOTS[i].from = rect_shrink(SLOTS[i].tile, 0.55f);
+        bool matched = false;
+        for (int j = 0; j < nold; ++j) {
+            bool same = (SLOTS[i].ws >= 0)
+                          ? (old[j].ws == SLOTS[i].ws)
+                          : (old[j].ws < 0 && old[j].num == SLOTS[i].num);
+            if (same && old[j].tile.w > 0.0f) {
+                SLOTS[i].from = old[j].tile;
+                matched = true;
+                break;
+            }
+        }
+        if (!laid_out_once) SLOTS[i].from = SLOTS[i].tile;      /* no intro */
+        if (!matched || SDL_fabsf(SLOTS[i].from.x - SLOTS[i].tile.x) > 0.5f ||
+                        SDL_fabsf(SLOTS[i].from.y - SLOTS[i].tile.y) > 0.5f ||
+                        SDL_fabsf(SLOTS[i].from.w - SLOTS[i].tile.w) > 0.5f)
+            moved = true;
+        if (SLOTS[i].ws >= 0) WSS[SLOTS[i].ws].tile_from = SLOTS[i].from;
+    }
+
+    /* ghosts that are no longer part of the grid shrink away instead of
+     * blinking out; they are drawn from this list until they are done */
+    int dying = 0;
+    for (int j = 0; j < nold && dying < (int)SDL_arraysize(DYING); ++j) {
+        if (old[j].ws >= 0) continue;
+        bool still_there = false;
+        for (int i = 0; i < NSLOTS; ++i)
+            if (SLOTS[i].ws < 0 && SLOTS[i].num == old[j].num) { still_there = true; break; }
+        if (still_there) continue;
+
+        DYING[dying].ws   = -1;
+        DYING[dying].num  = old[j].num;
+        DYING[dying].from = old[j].tile;
+        DYING[dying].tile = rect_shrink(old[j].tile, 0.55f);
+        dying++;
+    }
+    if (dying > 0) {                       /* a later relayout must not eat them */
+        NDYING = dying;
+        dying_start = SDL_GetTicks();
+        moved = true;
+    }
+
+    if (moved && laid_out_once) anim_start = SDL_GetTicks();
+    laid_out_once = true;
+
+    build_texts();
+}
+
+/* ------------------------------------------------------------- navigation */
+
+static bool win_visible(const Win *w) { return w->match; }
+
+static int ws_first_visible(const Ws *ws)
+{
+    for (int i = 0; i < ws->count; ++i)
+        if (win_visible(&WINS[ws->first + i])) return i;
+    return -1;
+}
+
+/* select a workspace; keep_window=false drops down to workspace level */
+static void select_ws(int idx, bool keep_window)
+{
+    if (NWS == 0) return;
+    sel_ws = (idx % NWS + NWS) % NWS;
+    Ws *ws = &WSS[sel_ws];
+    if (!keep_window || ws->count == 0) { ws->sel = -1; return; }
+    if (ws->sel >= ws->count || (ws->sel >= 0 && !win_visible(&WINS[ws->first + ws->sel]))) {
+        int v = ws_first_visible(ws);
+        ws->sel = v;
+    }
+}
+
+static void step_ws(int dir)
+{
+    for (int i = 1; i <= NWS; ++i) {
+        int cand = (((sel_ws + dir * i) % NWS) + NWS) % NWS;
+        if (qlen == 0 || WSS[cand].count == 0) { select_ws(cand, false); return; }
+        if (ws_first_visible(&WSS[cand]) >= 0) { select_ws(cand, false); return; }
+    }
+}
+
+static float rect_cx(SDL_FRect r) { return r.x + r.w * 0.5f; }
+static float rect_cy(SDL_FRect r) { return r.y + r.h * 0.5f; }
+
+/* move the selection one step into direction (dx, dy); crosses tiles */
+static void navigate(int dx, int dy)
+{
+    if (NWS == 0) return;
+    Ws *ws = &WSS[sel_ws];
+
+    if (ws->sel < 0 && ws->count > 0) {          /* step into the workspace */
+        int v = ws_first_visible(ws);
+        if (v >= 0) { ws->sel = v; return; }
+    }
+    Win *cur = ws->count ? ws_sel_win(ws) : NULL;
+
+    float fx = cur ? rect_cx(cur->card) : rect_cx(ws->tile);
+    float fy = cur ? rect_cy(cur->card) : rect_cy(ws->tile);
+
+    int best = -1;
+    float best_score = 1e30f;
+
+    for (int i = 0; i < ws->count; ++i) {
+        Win *w = &WINS[ws->first + i];
+        if (w == cur || !win_visible(w)) continue;
+        float cx = rect_cx(w->card) - fx, cy = rect_cy(w->card) - fy;
+        float along = cx * (float)dx + cy * (float)dy;
+        float perp  = SDL_fabsf(cx * (float)dy - cy * (float)dx);
+        if (along <= 4.0f * SC) continue;
+        float score = along + perp * 2.0f;
+        if (score < best_score) { best_score = score; best = i; }
+    }
+    if (best >= 0) { ws->sel = best; return; }
+
+    /* nothing in that direction inside the tile: jump to the next tile */
+    int best_ws = -1;
+    best_score = 1e30f;
+    for (int i = 0; i < NWS; ++i) {
+        if (i == sel_ws) continue;
+        float cx = rect_cx(WSS[i].tile) - rect_cx(ws->tile);
+        float cy = rect_cy(WSS[i].tile) - rect_cy(ws->tile);
+        float along = cx * (float)dx + cy * (float)dy;
+        float perp  = SDL_fabsf(cx * (float)dy - cy * (float)dx);
+        if (along <= 1.0f) continue;
+        float score = along + perp * 3.0f;
+        if (score < best_score) { best_score = score; best_ws = i; }
+    }
+    if (best_ws >= 0) {
+        select_ws(best_ws, false);
+        Ws *nw = &WSS[sel_ws];
+        if (nw->count > 0) {                       /* land on the closest card */
+            int bi = -1;
+            float bs = 1e30f;
+            for (int i = 0; i < nw->count; ++i) {
+                Win *w = &WINS[nw->first + i];
+                if (!win_visible(w)) continue;
+                float d = SDL_fabsf(rect_cx(w->card) - fx) + SDL_fabsf(rect_cy(w->card) - fy);
+                if (d < bs) { bs = d; bi = i; }
+            }
+            if (bi >= 0) nw->sel = bi;
+        }
+    }
+}
+
+static void apply_filter(void)
+{
+    for (int i = 0; i < NWIN; ++i) {
+        Win *w = &WINS[i];
+        w->match = (qlen == 0) || ci_contains(w->title, query) ||
+                   ci_contains(w->app_id, query) || ci_contains(w->app_key, query) ||
+                   ci_contains(w->ws_name, query);
+    }
+    if (qlen > 0) {                       /* jump to the first workspace with a hit */
+        for (int i = 0; i < NWS; ++i) {
+            int v = ws_first_visible(&WSS[i]);
+            if (v >= 0) { sel_ws = i; WSS[i].sel = v; return; }
+        }
+    } else {
+        select_ws(sel_ws, true);
+    }
+}
+
+static int hit_test(float mx, float my, int *out_ws)
+{
+    *out_ws = -1;
+    for (int i = 0; i < NWS; ++i) {
+        Ws *ws = &WSS[i];
+        if (mx < ws->tile.x || mx >= ws->tile.x + ws->tile.w ||
+            my < ws->tile.y || my >= ws->tile.y + ws->tile.h) continue;
+        *out_ws = i;
+        for (int j = ws->count - 1; j >= 0; --j) {      /* floats are last = on top */
+            Win *w = &WINS[ws->first + j];
+            SDL_FRect r = w->card;
+            if (mx >= r.x && mx < r.x + r.w && my >= r.y && my < r.y + r.h)
+                return ws->first + j;
+        }
+        return -1;
+    }
+    return -1;
+}
+
+static char *escape_arg(const char *s)
+{
+    size_t n = strlen(s);
+    char *out = (char *)xmalloc(n * 2 + 1);
+    char *o = out;
+    for (const char *p = s; *p; ++p) {
+        if (*p == '"' || *p == '\\') *o++ = '\\';
+        *o++ = *p;
+    }
+    *o = 0;
+    return out;
+}
+
+/* ------------------------------------------------------------ drag & drop
+ * A press only arms a drag. If the pointer stays put, the release focuses
+ * (that is the "press and release on the same thing" rule); if it moves, we
+ * are dragging either a window or a whole workspace.
+ */
+
+typedef enum {
+    DROP_NONE,
+    DROP_WIN_WS,     /* window onto a workspace tile                       */
+    DROP_WIN_NEAR,   /* window next to another window, splitting h or v    */
+    DROP_WS_SWAP,    /* workspace onto another workspace                   */
+    DROP_WS_NUM,     /* workspace onto a free number (a ghost slot)        */
+    DROP_WIN_NEWWS   /* window onto a free number: sway creates it          */
+} DropKind;
+
+enum { EDGE_LEFT, EDGE_RIGHT, EDGE_TOP, EDGE_BOTTOM };
+
+static bool  press_down;
+static float press_x, press_y;
+static int   press_win = -1, press_ws = -1;
+static bool  drag_active;
+static float drag_x, drag_y;
+static SDL_FRect drag_src_tile;   /* where the dragged window's workspace was
+                                   * when the drag began — a fixed rectangle,
+                                   * so the reflow cannot move the threshold */
+
+static DropKind drop_kind;
+static int   drop_ws = -1, drop_win = -1, drop_num = -1, drop_edge = EDGE_RIGHT;
+static bool  drop_insert;        /* WS_SWAP position means "insert here"   */
+
+static void layout(void);
+/* the workspace sway is showing right now */
+static void select_current_workspace(void)
+{
+    sel_ws = 0;
+    for (int i = 0; i < NWS; ++i)
+        if (WSS[i].focused) { sel_ws = i; return; }
+    for (int i = 0; i < NWS; ++i)
+        if (WSS[i].visible) { sel_ws = i; return; }
+}
+
+static void reload_model(void);
+
+static int slot_at(float x, float y)
+{
+    for (int i = 0; i < NSLOTS; ++i) {
+        SDL_FRect t = SLOTS[i].tile;
+        if (x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h) return i;
+    }
+    return -1;
+}
+
+/* which side of a card the pointer is closest to */
+static int edge_at(SDL_FRect r, float x, float y)
+{
+    float dx = (x - (r.x + r.w * 0.5f)) / SDL_max(1.0f, r.w);
+    float dy = (y - (r.y + r.h * 0.5f)) / SDL_max(1.0f, r.h);
+    if (SDL_fabsf(dx) >= SDL_fabsf(dy)) return dx < 0.0f ? EDGE_LEFT : EDGE_RIGHT;
+    return dy < 0.0f ? EDGE_TOP : EDGE_BOTTOM;
+}
+
+static bool point_in(SDL_FRect r, float x, float y)
+{
+    return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
+}
+
+static void set_ghosts(int lo, int hi)
+{
+    if (lo == ghost_lo && hi == ghost_hi) return;
+    ghost_lo = lo;
+    ghost_hi = hi;
+    layout();      /* the ghosts take their place, layout() starts the glide */
+}
+
+/* A workspace being dragged always wants the free numbers on screen. A window
+ * asks for them once it leaves the workspace it lives on — and then keeps
+ * them for the rest of the drag. Toggling them back off would move that very
+ * workspace back under the pointer, which asks for them again: the tiles would
+ * shudder along the border instead of settling. */
+static void update_ghosts(float x, float y)
+{
+    if (ghost_lo >= 0) return;                     /* already out, leave them */
+    if (anim_running()) return;                    /* never decide mid-glide  */
+
+    bool want = false;
+    if (drag_ws_mode) {
+        want = true;
+    } else if (press_win >= 0) {
+        want = !point_in(drag_src_tile, x, y);
+    }
+    if (want) set_ghosts(0, 10);
+}
+
+static void drag_update_target(float x, float y)
+{
+    drop_kind = DROP_NONE;
+    drop_ws = drop_win = drop_num = -1;
+    drop_insert = false;
+
+    update_ghosts(x, y);
+
+    int gs = slot_at(x, y);
+    if (gs >= 0 && SLOTS[gs].ws < 0) {                /* a ghost: a free number */
+        drop_kind = drag_ws_mode ? DROP_WS_NUM : DROP_WIN_NEWWS;
+        drop_num  = SLOTS[gs].num;
+        return;
+    }
+
+    if (drag_ws_mode) {
+        int si = gs;
+        if (si < 0) return;
+        if (SLOTS[si].ws == press_ws) return;         /* itself */
+
+        SDL_FRect t = SLOTS[si].tile;
+        float rel = (x - t.x) / SDL_max(1.0f, t.w);
+        drop_ws  = SLOTS[si].ws;
+        drop_num = SLOTS[si].num;
+        if (rel < 0.22f || rel > 0.78f) {             /* the edges insert */
+            drop_kind   = DROP_WS_SWAP;
+            drop_insert = true;
+            drop_edge   = rel < 0.22f ? EDGE_LEFT : EDGE_RIGHT;
+        } else {
+            drop_kind = DROP_WS_SWAP;                 /* the middle swaps */
+        }
+        return;
+    }
+
+    int ws_idx = -1;
+    int win_idx = hit_test(x, y, &ws_idx);
+    if (ws_idx < 0) return;
+
+    if (win_idx >= 0 && win_idx != press_win) {
+        drop_kind = DROP_WIN_NEAR;
+        drop_win  = win_idx;
+        drop_ws   = ws_idx;
+        drop_edge = edge_at(WINS[win_idx].card, x, y);
+        return;
+    }
+    if (win_idx < 0) {
+        drop_kind = DROP_WIN_WS;
+        drop_ws   = ws_idx;
+    }
+}
+
+/* "7:chat" keeps its label when it becomes workspace 8 */
+static char *ws_name_with_num(const Ws *ws, int num)
+{
+    const char *colon = strchr(ws->name, ':');
+    if (colon) return fmt_alloc("%d%s", num, colon);
+    return fmt_alloc("%d", num);
+}
+
+static void ws_rename(const Ws *ws, const char *to)
+{
+    char *from = escape_arg(ws->name);
+    char *dst  = escape_arg(to);
+    sway_cmd("rename workspace \"%s\" to \"%s\"", from, dst);
+    free(from);
+    free(dst);
+}
+
+static void act_ws_assign(int ws_idx, int num)
+{
+    if (ws_idx < 0 || ws_idx >= NWS) return;
+    char *to = ws_name_with_num(&WSS[ws_idx], num);
+    ws_rename(&WSS[ws_idx], to);
+    free(to);
+}
+
+static void act_ws_swap(int a, int b)
+{
+    if (a < 0 || b < 0 || a == b) return;
+    int na = WSS[a].num, nb = WSS[b].num;
+    if (na < 0 || nb < 0) return;
+
+    char *tmp = fmt_alloc("swov_tmp_%d", na);
+    ws_rename(&WSS[a], tmp);
+    act_ws_assign(b, na);
+
+    char *to = ws_name_with_num(&WSS[a], nb);          /* a is called tmp now */
+    char *from = escape_arg(tmp);
+    char *dst  = escape_arg(to);
+    sway_cmd("rename workspace \"%s\" to \"%s\"", from, dst);
+    free(from);
+    free(dst);
+    free(to);
+    free(tmp);
+}
+
+/* Insert the dragged workspace at `num`, pushing the occupied run up by one.
+ * With 1, 2, 5 on screen, inserting at 2 moves 2 to 3 and stops there. */
+static void act_ws_insert(int ws_idx, int num)
+{
+    if (ws_idx < 0 || num < 0) return;
+    if (WSS[ws_idx].num == num) return;
+
+    int run[MAX_WORKSPACES];
+    int n = 0;
+    for (int want = num; n < MAX_WORKSPACES; ++want) {
+        int found = -1;
+        for (int i = 0; i < NWS; ++i)
+            if (i != ws_idx && WSS[i].num == want) { found = i; break; }
+        if (found < 0) break;
+        run[n++] = found;
+    }
+    for (int i = n - 1; i >= 0; --i)                   /* top down, no clashes */
+        act_ws_assign(run[i], WSS[run[i]].num + 1);
+    act_ws_assign(ws_idx, num);
+}
+
+/* Put the dragged window next to `target`, splitting the target the right
+ * way first. A mark is the only reliable way to say "there" to sway. */
+static void act_win_drop_near(Win *drag, Win *target, int edge)
+{
+    if (!drag || !target || drag == target) return;
+    bool horiz = (edge == EDGE_LEFT || edge == EDGE_RIGHT);
+
+    sway_cmd("[con_id=%d] mark --add _swov_drop", target->con_id);
+    sway_cmd("[con_id=%d] split %s", target->con_id, horiz ? "h" : "v");
+    sway_cmd("[con_id=%d] move container to mark _swov_drop", drag->con_id);
+    if (edge == EDGE_LEFT || edge == EDGE_TOP)
+        sway_cmd("[con_id=%d] move %s", drag->con_id, horiz ? "left" : "up");
+    sway_cmd("unmark _swov_drop");
+}
+
+static void drag_finish(void)
+{
+    switch (drop_kind) {
+    case DROP_WIN_WS:
+        if (press_win >= 0 && drop_ws >= 0 && WINS[press_win].ws != drop_ws) {
+            Ws *ws = &WSS[drop_ws];
+            if (ws->num >= 0)
+                sway_cmd("[con_id=%d] move container to workspace number %d",
+                         WINS[press_win].con_id, ws->num);
+            else {
+                char *e = escape_arg(ws->name);
+                sway_cmd("[con_id=%d] move container to workspace \"%s\"",
+                         WINS[press_win].con_id, e);
+                free(e);
+            }
+        }
+        break;
+
+    case DROP_WIN_NEAR:
+        if (press_win >= 0 && drop_win >= 0)
+            act_win_drop_near(&WINS[press_win], &WINS[drop_win], drop_edge);
+        break;
+
+    case DROP_WS_SWAP:
+        if (drop_insert) {
+            int num = drop_num + (drop_edge == EDGE_RIGHT ? 1 : 0);
+            act_ws_insert(press_ws, num);
+        } else {
+            act_ws_swap(press_ws, drop_ws);
+        }
+        break;
+
+    case DROP_WS_NUM:
+        act_ws_assign(press_ws, drop_num);
+        break;
+
+    case DROP_WIN_NEWWS:                     /* sway creates the workspace */
+        if (press_win >= 0 && drop_num >= 0)
+            sway_cmd("[con_id=%d] move container to workspace number %d",
+                     WINS[press_win].con_id, drop_num);
+        break;
+
+    case DROP_NONE:
+    default:
+        break;
+    }
+
+    drag_active = drag_ws_mode = false;
+    drop_kind = DROP_NONE;
+    press_win = press_ws = -1;
+    ghost_lo = ghost_hi = -1;
+    layout();
+    reload_model();          /* the event socket refreshes again once sway settles */
+}
+
+static void drag_cancel(void)
+{
+    bool had_ghosts = ghost_lo >= 0;
+    drag_active = drag_ws_mode = false;
+    drop_kind = DROP_NONE;
+    press_down = false;
+    press_win = press_ws = -1;
+    ghost_lo = ghost_hi = -1;
+    if (had_ghosts) layout();
+}
+
+/* ---------------------------------------------------------------- chrome */
+
+static Tex T_HEADER, T_HINTS, T_QUERY;
+
+static void rebuild_chrome(void)
+{
+    tex_free(&T_HEADER);
+    tex_free(&T_HINTS);
+    tex_free(&T_QUERY);
+
+    if (C.show_header) {
+        char buf[256];
+        snprintf(buf, sizeof(buf), "%s   %d window%s on %d workspace%s",
+                 FOCUSED_OUTPUT[0] ? FOCUSED_OUTPUT : "sway",
+                 NWIN, NWIN == 1 ? "" : "s", NWS, NWS == 1 ? "" : "s");
+        T_HEADER = text_make(F_HINT, buf);
+    }
+    if (C.show_hints) {
+        /* when both lines share a band the hints get whatever width is left */
+        int avail = RW - (int)(C.margin * SC * 2.0f);
+        bool same_band = C.show_header && !pos_is_none(C.header_pos) &&
+                         !pos_is_none(C.hints_pos) &&
+                         pos_is_top(C.header_pos) == pos_is_top(C.hints_pos);
+        if (same_band && T_HEADER.t) avail -= T_HEADER.w + (int)(C.margin * SC);
+
+        T_HINTS = text_make_fit(F_HINT,
+            "\xe2\x86\xb5 focus    drag to move    tab workspace    0-9 move    "
+            "ctrl+0-9 go to    space mark    x close    / filter    esc quit", avail);
+    }
+
+    if (filtering) {
+        char buf[192];
+        snprintf(buf, sizeof(buf), "filter:  %s\xe2\x96\x8f", query);
+        T_QUERY = text_make(F_HINT, buf);
+    }
+}
+
+/* --------------------------------------------------------------- drawing */
+
+/* While the grid is settling, a workspace is drawn where it currently is on
+ * its way, and everything inside it rides along on the same transform. */
+static float XF_SX = 1.0f, XF_SY = 1.0f, XF_X0, XF_Y0, XF_DX, XF_DY;
+static bool  XF_ON;
+
+static void xf_set(SDL_FRect from, SDL_FRect to)
+{
+    XF_ON = true;
+    XF_SX = (from.w > 0.0f) ? to.w / from.w : 1.0f;
+    XF_SY = (from.h > 0.0f) ? to.h / from.h : 1.0f;
+    XF_X0 = from.x;
+    XF_Y0 = from.y;
+    XF_DX = to.x;
+    XF_DY = to.y;
+}
+
+static void xf_off(void) { XF_ON = false; XF_SX = XF_SY = 1.0f; }
+
+static SDL_FRect xf(SDL_FRect r)
+{
+    if (!XF_ON) return r;
+    return (SDL_FRect){ XF_DX + (r.x - XF_X0) * XF_SX, XF_DY + (r.y - XF_Y0) * XF_SY,
+                        r.w * XF_SX, r.h * XF_SY };
+}
+
+static void draw_icon(SDL_Texture *icon, float cx, float top, float size)
+{
+    if (!icon) return;
+    float w = size, h = size;
+    float tw = 0.0f, th = 0.0f;
+    if (SDL_GetTextureSize(icon, &tw, &th) && tw > 0.0f && th > 0.0f) {
+        if (tw > th) h = size * (th / tw);
+        else if (th > tw) w = size * (tw / th);
+    }
+    SDL_FRect dst = { cx - w * 0.5f, top + (size - h) * 0.5f, w, h };
+    SDL_RenderTexture(REN, icon, NULL, &dst);
+}
+
+static void draw_card(Win *w, bool tile_selected, bool is_hovered)
+{
+    SDL_FRect r = xf(w->card);
+    if (r.w < 4.0f || r.h < 4.0f) return;
+
+    bool selected = tile_selected && sel_active && ws_sel_win(&WSS[w->ws]) == w;
+    bool dimmed   = !w->match;
+    float rad     = SDL_min(C.radius * SC * 0.62f, SDL_min(r.w, r.h) * 0.32f);
+
+    SDL_FColor fill = w->focused ? C.card_focus : C.card;
+    if (is_hovered) fill = C.card_hover;
+    if (selected)   fill = mix(fill, C.hl, is_hovered ? 0.16f : 0.10f);
+    if (dimmed)     fill = with_alpha(mix(fill, C.tile, 0.6f), fill.a * 0.45f);
+
+    /* A floating dialog, and especially a fullscreen window, sits on top of
+     * the tiled ones. Drawing it solid would hide the whole workspace, so it
+     * gets see-through — the more of the screen it covers, the more so. Its
+     * border, icon and label stay at full strength, so it is still obvious
+     * which window it is. */
+    float over = 1.0f, cover = 0.0f;
+    bool on_top = w->floating || w->fullscreen;
+    if (on_top) {
+        const Ws *ws = &WSS[w->ws];
+        float screen_area = ws->screen.w * ws->screen.h;
+        cover = (screen_area > 1.0f) ? (w->card.w * w->card.h) / screen_area : 1.0f;
+        cover = SDL_clamp(cover, 0.0f, 1.0f);
+        over = C.float_alpha * (1.0f - 0.55f * cover);
+        fill = with_alpha(fill, fill.a * over);
+    }
+    /* Anything drawn on top of other windows gets a frame and a name plate
+     * rather than a filled card: its label would otherwise sit in the middle,
+     * exactly where the label of the window showing through already is. */
+    bool as_overlay = on_top;
+
+    if (w->floating) drop_shadow(r, rad, 9.0f * SC, with_alpha(C.shadow_col, C.shadow_col.a * over));
+    fill_round_rect(r, rad, fill);
+
+    /* border: selection beats hover beats mark beats plain */
+    float bw = SDL_max(1.0f, C.border * SC * 0.75f);
+    if (selected)          stroke_round_rect(r, rad, bw * 1.35f, C.hl);
+    else if (is_hovered)   stroke_round_rect(r, rad, bw, C.accent);
+    else if (w->marked)    stroke_round_rect(r, rad, bw, with_alpha(C.hl, 0.85f));
+    else if (w->urgent)    stroke_round_rect(r, rad, bw, C.urgent);
+    else if (on_top)       stroke_round_rect(r, rad, SDL_max(1.0f, bw * 0.7f),
+                                             with_alpha(C.accent, dimmed ? 0.25f : 0.5f));
+    else                   stroke_round_rect(r, rad, SDL_max(1.0f, bw * 0.4f),
+                                             with_alpha(C.outline, dimmed ? 0.3f : 0.7f));
+
+    /* the window sway has focused gets an accent bar on its left edge */
+    if (w->focused && !dimmed) {
+        SDL_FRect bar = { r.x + bw * 0.6f, r.y + rad * 0.7f,
+                          SDL_max(2.0f, 3.0f * SC), r.h - rad * 1.4f };
+        fill_round_rect(bar, bar.w * 0.5f, C.hl);
+    }
+
+    /* multi selection marker */
+    if (w->marked) {
+        float d = SDL_max(6.0f, 9.0f * SC);
+        SDL_FRect dot = { r.x + r.w - d - 5.0f * SC, r.y + 5.0f * SC, d, d };
+        fill_round_rect(dot, d * 0.5f, C.hl);
+    }
+
+    float pad = 7.0f * SC;
+    float a   = dimmed ? 0.45f : 1.0f;
+    SDL_FColor lab_col = (selected || is_hovered) ? C.text : C.subtext;
+    if (w->urgent) lab_col = C.urgent;
+    SDL_FColor sub_col = mix(C.dim, C.text, (selected || is_hovered) ? 0.55f : 0.32f);
+    lab_col = with_alpha(lab_col, lab_col.a * a);
+    sub_col = with_alpha(sub_col, sub_col.a * a);
+
+    bool has_icon = C.icons && w->icon && w->lay_icon >= 10.0f * SC;
+    if (has_icon) SDL_SetTextureAlphaModFloat(w->icon, a);
+
+    if (as_overlay && w->label.t) {
+        /* Centre would land on top of the cards showing through, so the name
+         * rides in a small plate at the top edge instead. */
+        float ip = 6.0f * SC;
+        float ih = SDL_min(22.0f * SC, (float)C.icon_px * SC * 0.6f);
+        float iw = (has_icon && ih >= 10.0f * SC) ? ih + 5.0f * SC : 0.0f;
+        float pw = iw + (float)w->label.w + 2.0f * ip;
+        float phh = SDL_max((float)w->label.h, ih) + ip;
+
+        if (pw > r.w - 4.0f * SC) {            /* tight: drop the icon first */
+            iw = 0.0f;
+            pw = (float)w->label.w + 2.0f * ip;
+            phh = (float)w->label.h + ip;
+        }
+
+        if (pw <= r.w - 4.0f * SC && phh <= r.h * 0.6f) {
+            SDL_FRect plate = { r.x + (r.w - pw) * 0.5f, r.y + 7.0f * SC, pw, phh };
+            fill_round_rect(plate, phh * 0.32f, with_alpha(mix(C.card, C.bg, 0.25f), 0.94f));
+            stroke_round_rect(plate, phh * 0.32f, SDL_max(1.0f, 1.4f * SC),
+                              with_alpha(C.accent, 0.55f));
+
+            float tx = plate.x + ip;
+            if (iw > 0.0f && has_icon) {
+                draw_icon(w->icon, tx + ih * 0.5f, plate.y + (phh - ih) * 0.5f, ih);
+                tx += iw;
+            }
+            tex_draw(w->label, tx, plate.y + (phh - (float)w->label.h) * 0.5f,
+                     with_alpha(C.text, a));
+            if (has_icon) SDL_SetTextureAlphaModFloat(w->icon, 1.0f);
+            return;
+        }
+    }
+
+    float lab_h = w->label.t    ? (float)w->label.h    : 0.0f;
+    float sub_h = w->subtitle.t ? (float)w->subtitle.h : 0.0f;
+    float g1 = 4.0f * SC, g2 = 1.0f * SC;
+
+    switch (w->lay_mode) {
+    case CL_ROW: {
+        float tx = r.x + pad;
+        if (has_icon) {
+            draw_icon(w->icon, tx + w->lay_icon * 0.5f,
+                      r.y + (r.h - w->lay_icon) * 0.5f, w->lay_icon);
+            tx += w->lay_icon + 6.0f * SC;
+        }
+        float block = lab_h + (sub_h > 0.0f ? g2 + sub_h : 0.0f);
+        float y = r.y + (r.h - block) * 0.5f;
+        tex_draw(w->label, tx, y, lab_col);
+        if (sub_h > 0.0f) tex_draw(w->subtitle, tx, y + lab_h + g2, sub_col);
+        break;
+    }
+    case CL_TEXT: {
+        float y = r.y + (r.h - lab_h) * 0.5f;
+        tex_draw_center(w->label, r.x + r.w * 0.5f, y, lab_col);
+        break;
+    }
+    case CL_ICON: {
+        if (has_icon)
+            draw_icon(w->icon, r.x + r.w * 0.5f,
+                      r.y + (r.h - w->lay_icon) * 0.5f, w->lay_icon);
+        break;
+    }
+    default: {                                     /* CL_STACK */
+        float icon = has_icon ? w->lay_icon : 0.0f;
+        float block = icon + (lab_h > 0.0f ? g1 + lab_h : 0.0f) +
+                             (sub_h > 0.0f ? g2 + sub_h : 0.0f);
+        float cx = r.x + r.w * 0.5f;
+        /* a window drawn on top keeps to the upper edge, where it is not
+         * sitting on the label of whatever shows through beneath it */
+        float y  = as_overlay ? r.y + pad : r.y + (r.h - block) * 0.5f;
+        if (has_icon) { draw_icon(w->icon, cx, y, icon); y += icon; }
+        if (lab_h > 0.0f) { y += g1; tex_draw_center(w->label, cx, y, lab_col); y += lab_h; }
+        if (sub_h > 0.0f) { y += g2; tex_draw_center(w->subtitle, cx, y, sub_col); }
+        break;
+    }
+    }
+
+    if (has_icon) SDL_SetTextureAlphaModFloat(w->icon, 1.0f);
+}
+
+static void draw_workspace(int idx)
+{
+    Ws *ws = &WSS[idx];
+    bool selected = sel_active && (idx == sel_ws);
+    bool hovered  = (idx == hov_ws);
+    float rad = C.radius * SC;
+
+    float t = anim_phase();
+    if (t < 1.0f && ws->tile_from.w > 0.0f) xf_set(ws->tile, rect_lerp(ws->tile_from, ws->tile, t));
+    else xf_off();
+
+    SDL_FRect tile = xf(ws->tile);
+
+    bool has_hit = qlen == 0 || ws->count == 0 || ws_first_visible(ws) >= 0;
+
+    SDL_FColor fill = selected ? C.tile_sel : (hovered ? C.tile_hover : C.tile);
+    if (!has_hit) fill = with_alpha(mix(fill, C.bg, 0.35f), fill.a * 0.75f);
+
+    drop_shadow(tile, rad, 7.0f * SC, C.shadow_col);
+    fill_round_rect(tile, rad, fill);
+
+    SDL_FColor bc = C.outline;
+    float bw = C.border * SC;
+    if (selected)          bc = C.hl;
+    else if (hovered)      bc = with_alpha(C.accent, 0.9f);
+    else if (ws->urgent)   bc = C.urgent;
+    else if (ws->visible)  bc = with_alpha(C.accent, 0.45f);
+    else                   bw = SDL_max(1.0f, C.border * SC * 0.45f);
+    stroke_round_rect(tile, rad, bw, bc);
+
+    /* ---- tile header: window count left, workspace number right ---- */
+    float p = C.pad * SC;
+    float top = tile.y + C.badge_top * SC;
+
+    bool live = ws->visible || ws->focused;
+    SDL_FColor num_col = selected ? C.hl
+                       : live     ? C.text
+                                  : mix(C.subtext, C.tile, 0.25f);
+    if (ws->urgent && !selected) num_col = C.urgent;
+
+    tex_draw(ws->badge, tile.x + tile.w - p - (float)ws->badge.w, top, num_col);
+
+    if (ws->sub.t) {
+        /* the count sits on the same optical line as the number */
+        float base = top + ((float)ws->badge.h - (float)ws->sub.h) * 0.62f;
+        SDL_FColor col = ws->count ? mix(C.dim, C.text, selected ? 0.45f : 0.25f)
+                                   : with_alpha(C.dim, 0.85f);
+        tex_draw(ws->sub, tile.x + p, base, col);
+    }
+
+    /* ---- the mini screen ---- */
+    SDL_FRect screen = xf(ws->screen);
+    float srad = SDL_min(rad * 0.7f, SDL_min(screen.w, screen.h) * 0.5f);
+    fill_round_rect(screen, srad, C.mini_bg);
+    stroke_round_rect(screen, srad, SDL_max(1.0f, 1.2f * SC), with_alpha(C.outline, 0.55f));
+
+    if (ws->count == 0) {
+        /* nothing here: keep the tile obviously "a screen, but empty" */
+        xf_off();
+        return;
+    }
+
+    /* a container whose children share one rectangle (tabbed or stacked) is
+     * drawn as slices; a shared outline shows that they belong together */
+    for (int i = 0; i < ws->count; ++i) {
+        if (WINS[ws->first + i].group != i) continue;
+        SDL_FRect u = WINS[ws->first + i].card;
+        for (int j = i + 1; j < ws->count; ++j) {
+            if (WINS[ws->first + j].group != i) continue;
+            SDL_FRect c = WINS[ws->first + j].card;
+            float x1 = SDL_max(u.x + u.w, c.x + c.w), y1 = SDL_max(u.y + u.h, c.y + c.h);
+            u.x = SDL_min(u.x, c.x);
+            u.y = SDL_min(u.y, c.y);
+            u.w = x1 - u.x;
+            u.h = y1 - u.y;
+        }
+        u = xf(u);
+        float e = 3.0f * SC;
+        SDL_FRect box = { u.x - e, u.y - e, u.w + 2.0f * e, u.h + 2.0f * e };
+        stroke_round_rect(box, C.radius * SC * 0.7f, SDL_max(1.0f, 1.6f * SC),
+                          with_alpha(C.accent, 0.5f));
+    }
+
+    for (int i = 0; i < ws->count; ++i) {
+        int gi = ws->first + i;
+        draw_card(&WINS[gi], selected, gi == hov_win);
+    }
+    xf_off();
+}
+
+
+/* ---- what the drop would do, shown while the button is still held ---- */
+static void draw_drop_indicator(void)
+{
+    float thick = SDL_max(2.0f, C.border * SC * 1.2f);
+
+    switch (drop_kind) {
+    case DROP_WIN_WS:
+        if (drop_ws >= 0) {
+            SDL_FRect r = WSS[drop_ws].screen;
+            fill_round_rect(r, C.radius * SC * 0.7f, with_alpha(C.hl, 0.13f));
+            stroke_round_rect(r, C.radius * SC * 0.7f, thick, C.hl);
+        }
+        break;
+
+    case DROP_WIN_NEAR:
+        if (drop_win >= 0) {
+            SDL_FRect r = WINS[drop_win].card;
+            stroke_round_rect(r, C.radius * SC * 0.6f, SDL_max(1.0f, thick * 0.6f),
+                              with_alpha(C.accent, 0.8f));
+
+            /* the bar shows which side the window lands on, and therefore
+             * whether the container ends up split horizontally or vertically */
+            float b = thick * 1.6f;
+            SDL_FRect bar = r;
+            if (drop_edge == EDGE_LEFT)        bar = (SDL_FRect){ r.x - b * 0.5f, r.y, b, r.h };
+            else if (drop_edge == EDGE_RIGHT)  bar = (SDL_FRect){ r.x + r.w - b * 0.5f, r.y, b, r.h };
+            else if (drop_edge == EDGE_TOP)    bar = (SDL_FRect){ r.x, r.y - b * 0.5f, r.w, b };
+            else                               bar = (SDL_FRect){ r.x, r.y + r.h - b * 0.5f, r.w, b };
+            fill_round_rect(bar, b * 0.5f, C.hl);
+        }
+        break;
+
+    case DROP_WS_SWAP:
+        if (drop_ws >= 0) {
+            SDL_FRect t = WSS[drop_ws].tile;
+            if (drop_insert) {
+                float b = thick * 1.8f;
+                float x = (drop_edge == EDGE_LEFT) ? t.x - b : t.x + t.w;
+                SDL_FRect bar = { x, t.y, b, t.h };
+                fill_round_rect(bar, b * 0.5f, C.hl);
+            } else {
+                fill_round_rect(t, C.radius * SC, with_alpha(C.hl, 0.16f));
+                stroke_round_rect(t, C.radius * SC, thick, C.hl);
+            }
+        }
+        break;
+
+    default:
+        break;
+    }
+
+    /* the thing being dragged, held under the pointer */
+    if (drag_ws_mode && press_ws >= 0) {
+        SDL_FRect t = WSS[press_ws].tile;
+        float w = t.w * 0.34f, h = t.h * 0.34f;
+        SDL_FRect g = { drag_x - w * 0.5f, drag_y - h * 0.5f, w, h };
+        fill_round_rect(g, C.radius * SC * 0.7f, with_alpha(C.tile_sel, 0.92f));
+        stroke_round_rect(g, C.radius * SC * 0.7f, thick, C.hl);
+        tex_draw_in_box(WSS[press_ws].badge, g, F_BADGE, C.text);
+    } else if (press_win >= 0) {
+        Win *w = &WINS[press_win];
+        SDL_FRect r = w->card;
+        SDL_FRect g = { drag_x - r.w * 0.5f, drag_y - r.h * 0.5f, r.w, r.h };
+        fill_round_rect(g, C.radius * SC * 0.6f, with_alpha(C.card_hover, 0.9f));
+        stroke_round_rect(g, C.radius * SC * 0.6f, thick, C.hl);
+        if (w->icon && C.icons) {
+            float sz = SDL_min(SDL_min(g.w, g.h) * 0.5f, (float)C.icon_px * SC);
+            draw_icon(w->icon, g.x + g.w * 0.5f, g.y + (g.h - sz) * 0.5f, sz);
+        }
+    }
+}
+
+static void render(void)
+{
+    SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_NONE);
+    set_col(C.bg);
+    SDL_RenderClear(REN);
+    SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_BLEND);
+
+    if (NWS == 0) {
+        Tex t = text_make(F_LABEL, "no windows found on this output");
+        tex_draw_center(t, (float)RW * 0.5f, (float)RH * 0.5f, C.subtext);
+        tex_free(&t);
+        return;
+    }
+
+    if (ghost_lo >= 0)                            /* free numbers to drop on */
+        for (int i = 0; i < NSLOTS; ++i) {
+            if (SLOTS[i].ws >= 0) continue;
+            float ph = anim_phase();
+            SDL_FRect t = rect_lerp(SLOTS[i].from, SLOTS[i].tile, ph);
+            float ix = t.w * 0.22f, iy = t.h * 0.22f;
+            SDL_FRect g = { t.x + ix, t.y + iy, t.w - 2.0f * ix, t.h - 2.0f * iy };
+            bool hot = (drop_kind == DROP_WS_NUM || drop_kind == DROP_WIN_NEWWS) &&
+                       drop_num == SLOTS[i].num;
+
+            fill_round_rect(g, C.radius * SC * 0.8f,
+                            with_alpha(hot ? C.hl : C.tile, (hot ? 0.30f : 0.55f) * ph));
+            stroke_round_rect(g, C.radius * SC * 0.8f, SDL_max(1.0f, 2.0f * SC),
+                              with_alpha(hot ? C.hl : C.accent, (hot ? 1.0f : 0.35f) * ph));
+
+            char num[8];
+            snprintf(num, sizeof(num), "%d", SLOTS[i].num);
+            Tex t2 = text_make(F_BADGE, num);
+            tex_draw_in_box(t2, g, F_BADGE, with_alpha(hot ? C.hl : C.dim, 0.9f * ph));
+            tex_free(&t2);
+        }
+
+    if (NDYING > 0) {                             /* ghosts fading away */
+        float ph = phase_since(dying_start);
+        if (ph >= 1.0f) NDYING = 0;
+        for (int i = 0; i < NDYING; ++i) {
+            SDL_FRect t = rect_lerp(DYING[i].from, DYING[i].tile, ph);
+            float ix = t.w * 0.22f, iy = t.h * 0.22f;
+            SDL_FRect g = { t.x + ix, t.y + iy, t.w - 2.0f * ix, t.h - 2.0f * iy };
+            float a = 1.0f - ph;
+
+            fill_round_rect(g, C.radius * SC * 0.8f, with_alpha(C.tile, 0.55f * a));
+            stroke_round_rect(g, C.radius * SC * 0.8f, SDL_max(1.0f, 2.0f * SC),
+                              with_alpha(C.accent, 0.35f * a));
+
+            char num[8];
+            snprintf(num, sizeof(num), "%d", DYING[i].num);
+            Tex t2 = text_make(F_BADGE, num);
+            tex_draw_in_box(t2, g, F_BADGE, with_alpha(C.dim, 0.9f * a));
+            tex_free(&t2);
+        }
+    }
+
+    for (int i = 0; i < NWS; ++i)
+        if (i != sel_ws) draw_workspace(i);
+    draw_workspace(sel_ws);                       /* selection is drawn last */
+
+    if (drag_active) draw_drop_indicator();
+
+    float m = C.margin * SC;
+    float head_x0 = (float)RW, head_x1 = 0.0f;
+    bool  head_top = pos_is_top(C.header_pos);
+
+    if (T_HEADER.t && !pos_is_none(C.header_pos)) {
+        float y = head_top ? (HEADER_H - (float)T_HEADER.h) * 0.5f
+                           : (float)RH - FOOTER_H * 0.5f - (float)T_HEADER.h * 0.5f;
+        head_x0 = pos_x(C.header_pos, (float)T_HEADER.w, m);
+        head_x1 = head_x0 + (float)T_HEADER.w;
+        tex_draw(T_HEADER, head_x0, y, C.hint);
+    }
+
+    if (T_QUERY.t) {
+        float p = 10.0f * SC;
+        SDL_FRect box = { (float)RW * 0.5f - ((float)T_QUERY.w * 0.5f + p),
+                          (HEADER_H - (float)T_QUERY.h) * 0.55f - p * 0.5f,
+                          (float)T_QUERY.w + 2.0f * p, (float)T_QUERY.h + p };
+        fill_round_rect(box, box.h * 0.35f, mix(C.tile, C.bg, 0.15f));
+        stroke_round_rect(box, box.h * 0.35f, SDL_max(1.0f, 1.5f * SC), with_alpha(C.accent, 0.8f));
+        tex_draw(T_QUERY, box.x + p, box.y + p * 0.5f, C.accent);
+    }
+
+    if (T_HINTS.t && !pos_is_none(C.hints_pos)) {
+        bool hint_top = pos_is_top(C.hints_pos);
+        float y = hint_top ? (HEADER_H - (float)T_HINTS.h) * 0.5f
+                           : (float)RH - FOOTER_H * 0.5f - (float)T_HINTS.h * 0.5f;
+        float x = pos_x(C.hints_pos, (float)T_HINTS.w, m);
+
+        /* both lines in the same band: keep the hints clear of the header */
+        if (T_HEADER.t && head_top == hint_top && x + (float)T_HINTS.w > head_x0 - m * 0.5f) {
+            if (head_x0 > (float)RW * 0.5f) x = SDL_min(x, head_x0 - m * 0.5f - (float)T_HINTS.w);
+            else                            x = SDL_max(x, head_x1 + m * 0.5f);
+            x = SDL_max(x, m * 0.5f);
+        }
+        tex_draw(T_HINTS, x, y, with_alpha(C.hint, 0.8f));
+    }
+}
+
+
+/* --------------------------------------------------------------- actions */
+
+static bool running = true;
+static bool dirty   = true;      /* a frame is only drawn when this is set */
+
+static void reload_model(void)
+{
+    /* remember what was selected so a reload does not lose the cursor */
+    int keep_con = -1;
+    char keep_ws[64] = {0};
+    if (NWS > 0) {
+        snprintf(keep_ws, sizeof(keep_ws), "%s", WSS[sel_ws].name);
+        Win *w = ws_sel_win(&WSS[sel_ws]);
+        if (w) keep_con = w->con_id;
+    }
+    int marks[MAX_WINDOWS];
+    int nmarks = 0;
+    for (int i = 0; i < NWIN && nmarks < MAX_WINDOWS; ++i)
+        if (WINS[i].marked) marks[nmarks++] = WINS[i].con_id;
+
+    if (!model_reload()) { running = false; return; }
+
+    for (int i = 0; i < NWIN; ++i)
+        for (int j = 0; j < nmarks; ++j)
+            if (WINS[i].con_id == marks[j]) WINS[i].marked = true;
+
+    select_current_workspace();
+
+    for (int i = 0; i < NWS; ++i) {
+        WSS[i].sel = -1;
+        if (keep_ws[0] && strcmp(WSS[i].name, keep_ws) == 0) sel_ws = i;
+        for (int j = 0; j < WSS[i].count; ++j)
+            if (WINS[WSS[i].first + j].con_id == keep_con) { sel_ws = i; WSS[i].sel = j; }
+    }
+
+    layout();
+    apply_filter();
+    rebuild_chrome();
+    hov_ws = hov_win = -1;
+}
+
+static void act_focus_window(Win *w)
+{
+    if (!w) return;
+    sway_cmd("[con_id=%d] focus", w->con_id);
+    running = false;
+}
+
+static void act_goto_workspace(const Ws *ws)
+{
+    if (!ws) return;
+    char *e = escape_arg(ws->name);
+    sway_cmd("workspace --no-auto-back-and-forth \"%s\"", e);
+    free(e);
+    running = false;
+}
+
+/* windows the next action applies to: all marked ones, else the selected one.
+ * Returns 0 when the selection is a whole workspace rather than windows. */
+static int gather_targets(int *out, int cap)
+{
+    int n = 0;
+    for (int i = 0; i < NWIN && n < cap; ++i)
+        if (WINS[i].marked) out[n++] = WINS[i].con_id;
+    if (n == 0 && NWS > 0) {
+        Win *w = ws_sel_win(&WSS[sel_ws]);
+        if (w) out[n++] = w->con_id;
+    }
+    return n;
+}
+
+static void act_move_to_workspace(int number)
+{
+    int ids[MAX_WINDOWS];
+    int n = gather_targets(ids, MAX_WINDOWS);
+
+    if (n > 0) {
+        for (int i = 0; i < n; ++i)
+            sway_cmd("[con_id=%d] move container to workspace number %d", ids[i], number);
+    } else if (NWS > 0 && WSS[sel_ws].ntop > 0) {
+        /* Whole workspace: move its top level containers, not the single
+         * windows. A container carries its own split/tab layout along, so
+         * the arrangement survives the move. */
+        Ws *ws = &WSS[sel_ws];
+        if (ws->num == number) return;
+        for (int i = 0; i < ws->ntop; ++i)
+            sway_cmd("[con_id=%d] move container to workspace number %d", ws->top[i], number);
+    } else return;
+
+    for (int i = 0; i < NWIN; ++i) WINS[i].marked = false;
+    if (C.quit_after_action) { running = false; return; }
+    reload_model();
+}
+
+static void act_close(void)
+{
+    int ids[MAX_WINDOWS];
+    int n = gather_targets(ids, MAX_WINDOWS);
+    for (int i = 0; i < n; ++i) sway_cmd("[con_id=%d] kill", ids[i]);
+    for (int i = 0; i < NWIN; ++i) WINS[i].marked = false;
+    reload_model();
+}
+
+static void act_toggle_mark(Win *w)
+{
+    if (w) w->marked = !w->marked;
+}
+
+/* ----------------------------------------------------------------- fonts */
+
+/* One fc-match run resolves the regular and the bold cut together; spawning
+ * a process twice is the most expensive thing startup would otherwise do. */
+static char *FC_REGULAR, *FC_BOLD;
+
+static void fontconfig_resolve(const char *family)
+{
+    /* the family ends up in a shell command, so keep it to harmless characters */
+    char fam[128] = "sans";
+    if (family && *family) {
+        size_t n = 0;
+        for (const char *p = family; *p && n < sizeof(fam) - 1; ++p)
+            if (isalnum((unsigned char)*p) || *p == ' ' || *p == '-' ||
+                *p == '_' || *p == '.' || *p == ',') fam[n++] = *p;
+        fam[n] = 0;
+        if (!n) snprintf(fam, sizeof(fam), "sans");
+    }
+
+    char cmd[512];
+    snprintf(cmd, sizeof(cmd),
+             "fc-match -f '%%{file}\n' '%s' 2>/dev/null; "
+             "fc-match -f '%%{file}\n' '%s:bold' 2>/dev/null", fam, fam);
+
+    FILE *p = popen(cmd, "r");
+    if (!p) return;
+    char l1[1024] = {0}, l2[1024] = {0};
+    if (fgets(l1, sizeof(l1), p)) str_trim(l1);
+    if (fgets(l2, sizeof(l2), p)) str_trim(l2);
+    pclose(p);
+
+    if (l1[0] && file_readable(l1)) FC_REGULAR = xstrdup(l1);
+    if (l2[0] && file_readable(l2)) FC_BOLD    = xstrdup(l2);
+}
+
+static char *pick_font(const char *spec, bool bold)
+{
+    if (spec && *spec && strchr(spec, '/') && file_readable(spec)) return xstrdup(spec);
+
+    const char *hit = bold ? FC_BOLD : FC_REGULAR;
+    if (hit) return xstrdup(hit);
+
+    const char *fallback[] = {
+        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+        "/usr/share/fonts/TTF/DejaVuSans.ttf",
+        "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
+        "/usr/share/fonts/liberation/LiberationSans-Regular.ttf"
+    };
+    for (size_t i = 0; i < SDL_arraysize(fallback); ++i)
+        if (file_readable(fallback[i])) return xstrdup(fallback[i]);
+    return NULL;
+}
+
+static TTF_Font *open_font(const char *path, float size, bool bold, bool synth_bold)
+{
+    TTF_Font *f = TTF_OpenFont(path, size);
+    if (!f) return NULL;
+    if (bold && synth_bold) TTF_SetFontStyle(f, TTF_STYLE_BOLD);
+    TTF_SetFontHinting(f, TTF_HINTING_LIGHT);
+    return f;
+}
+
+static void load_fonts(void)
+{
+    /* a configured path needs no fontconfig at all */
+    bool have_paths = C.font[0] && strchr(C.font, '/') &&
+                      (!C.font_bold[0] || strchr(C.font_bold, '/'));
+    if (!have_paths) fontconfig_resolve(C.font);
+
+    char *regular = pick_font(C.font, false);
+    if (!regular) die("no usable font found (install fontconfig and a TTF font)");
+    char *bold = C.font_bold[0] ? pick_font(C.font_bold, true) : pick_font(C.font, true);
+    bool synth = false;
+    if (!bold) { bold = xstrdup(regular); synth = true; }
+    else if (strcmp(bold, regular) == 0) synth = true;
+
+    float u = C.ui_scale * SC;
+    F_BADGE = open_font(bold,    (float)C.ws_px    * u, true, synth);
+    F_LABEL = open_font(bold,    (float)C.label_px * u, true, synth);
+    F_TITLE = open_font(regular, (float)C.title_px * u, false, false);
+    F_HINT  = open_font(regular, (float)C.hint_px  * u, false, false);
+    free(regular);
+    free(bold);
+    free(FC_REGULAR);
+    free(FC_BOLD);
+    FC_REGULAR = FC_BOLD = NULL;
+
+    if (!F_BADGE || !F_LABEL || !F_TITLE || !F_HINT) die("could not open fonts: %s", SDL_GetError());
+}
+
+/* ------------------------------------------------------------------ main */
+
+static SDL_Window  *WIN_HANDLE;
+static const char  *SHOT_PATH;
+static float        SHOT_MX = -1.0f, SHOT_MY = -1.0f;
+static SDL_Texture *TARGET;
+static SDL_Cursor  *CUR_ARROW, *CUR_HAND;
+static float        MOUSE_SCALE = 1.0f;
+
+static void usage(void)
+{
+    printf(
+"swov — window and workspace overview for sway\n"
+"\n"
+"usage: swov [options] [key=value ...]\n"
+"  -c, --config PATH   read this config file instead of the default\n"
+"  -n, --no-config     ignore the config file\n"
+"      --shot PATH     render one frame to a PNG and exit (handy for tuning)\n"
+"  -h, --help          this text\n"
+"\n"
+"Every config key is also a command line option, in three spellings:\n"
+"      swov --ui_scale=1.2 hl=ff8800 -s ssaa=1\n"
+"\n"
+"default config: ${XDG_CONFIG_HOME:-~/.config}/swov/config\n"
+"keys: ssaa icons icon_px shadow shadow_layers vsync ui_scale ws_px label_px\n"
+"      title_px hint_px font font_bold cols rows margin gap pad win_gap\n"
+"      screen_pad radius border show_empty all_outputs header_pos hints_pos\n"
+"      start_selection quit_on_focus_loss quit_after_action\n"
+"      colors: bg tile tile_sel tile_hover mini_bg card card_hover card_focus\n"
+"              hl text subtext dim accent hltext hint urgent outline\n"
+"\n"
+"keys:\n"
+"  enter / click       focus the window under the cursor and leave\n"
+"  click on a tile     switch to that workspace\n"
+"  arrows or hjkl      move the selection, across tiles as well\n"
+"  tab / shift+tab     previous / next workspace\n"
+"  w                   switch between window and whole-workspace selection\n"
+"  space / right click toggle the mark on a window\n"
+"  a                   mark or unmark every window of the workspace\n"
+"  c                   clear all marks\n"
+"  0-9                 move marked or selected windows to that workspace;\n"
+"                      with a whole workspace selected it moves everything\n"
+"                      there, keeping the container layout intact\n"
+"  ctrl+0-9            switch to that workspace\n"
+"  x / delete          close marked (or selected) windows\n"
+"  /                   filter by app id, title or workspace\n"
+"  r                   reload the tree\n"
+"  esc / q             quit\n");
+}
+
+static bool create_target(void)
+{
+    if (TARGET) { SDL_DestroyTexture(TARGET); TARGET = NULL; }
+
+    int pw = 0, ph = 0;
+    SDL_GetWindowSizeInPixels(WIN_HANDLE, &pw, &ph);
+    if (pw <= 0 || ph <= 0) return false;
+
+    int ssaa = C.ssaa;
+    while (ssaa > 1 && (long long)pw * ph * ssaa * ssaa > 34000000LL) ssaa--;
+
+    if (ssaa > 1) {
+        TARGET = SDL_CreateTexture(REN, SDL_PIXELFORMAT_RGBA32,
+                                   SDL_TEXTUREACCESS_TARGET, pw * ssaa, ph * ssaa);
+        if (TARGET) {
+            SDL_SetTextureScaleMode(TARGET, SDL_SCALEMODE_LINEAR);
+            SDL_SetTextureBlendMode(TARGET, SDL_BLENDMODE_BLEND);
+        }
+    }
+    SC = TARGET ? (float)ssaa : 1.0f;
+    RW = TARGET ? pw * ssaa : pw;
+    RH = TARGET ? ph * ssaa : ph;
+
+    int ww = 0, wh = 0;
+    SDL_GetWindowSize(WIN_HANDLE, &ww, &wh);
+    MOUSE_SCALE = (ww > 0) ? (float)RW / (float)ww : 1.0f;
+    return true;
+}
+
+static void goto_workspace_number(int num)
+{
+    for (int i = 0; i < NWS; ++i)
+        if (WSS[i].num == num) { act_goto_workspace(&WSS[i]); return; }
+    sway_cmd("workspace number %d", num);
+    running = false;
+}
+
+static int digit_from_scancode(SDL_Scancode sc)
+{
+    if (sc >= SDL_SCANCODE_1 && sc <= SDL_SCANCODE_9) return (int)(sc - SDL_SCANCODE_1) + 1;
+    if (sc == SDL_SCANCODE_0) return 0;
+    return -1;
+}
+
+static void handle_key(const SDL_KeyboardEvent *k)
+{
+    bool shift = (k->mod & SDL_KMOD_SHIFT) != 0;
+    bool was_active = sel_active;
+    sel_active = true;
+    SDL_Keycode key = k->key;
+
+    if (filtering) {
+        switch (key) {
+        case SDLK_ESCAPE:
+            filtering = false;
+            query[0] = 0;
+            qlen = 0;
+            apply_filter();
+            rebuild_chrome();
+            return;
+        case SDLK_RETURN:
+        case SDLK_KP_ENTER:
+            if (NWS > 0 && WSS[sel_ws].count > 0) act_focus_window(ws_sel_win(&WSS[sel_ws]));
+            else running = false;
+            return;
+        case SDLK_BACKSPACE:
+            while (qlen > 0) {                     /* drop one code point */
+                unsigned char c = (unsigned char)query[--qlen];
+                query[qlen] = 0;
+                if ((c & 0xc0) != 0x80) break;
+            }
+            apply_filter();
+            rebuild_chrome();
+            return;
+        case SDLK_TAB:   step_ws(shift ? -1 : 1); return;
+        case SDLK_LEFT:  navigate(-1, 0); return;
+        case SDLK_RIGHT: navigate( 1, 0); return;
+        case SDLK_UP:    navigate(0, -1); return;
+        case SDLK_DOWN:  navigate(0,  1); return;
+        default: return;
+        }
+    }
+
+    /* A digit only counts when it is pressed bare, or with ctrl. With shift
+     * held the layout may well be producing something else entirely — on a
+     * German keyboard shift+7 is "/" — and that has to reach text input. */
+    int digit = digit_from_scancode(k->scancode);
+    if (digit >= 0 && !shift && !(k->mod & SDL_KMOD_ALT)) {
+        if (k->mod & SDL_KMOD_CTRL) goto_workspace_number(digit);
+        else if (was_active)        act_move_to_workspace(digit);
+        return;
+    }
+    if (digit >= 0) return;                    /* shifted digit: let text input have it */
+
+    switch (key) {
+    case SDLK_ESCAPE:
+        if (drag_active) { drag_cancel(); break; }
+        running = false;
+        break;
+    case SDLK_Q:
+        running = false;
+        break;
+
+    case SDLK_RETURN:
+    case SDLK_KP_ENTER:
+        if (NWS == 0) { running = false; break; }
+        if (ws_sel_win(&WSS[sel_ws])) act_focus_window(ws_sel_win(&WSS[sel_ws]));
+        else                          act_goto_workspace(&WSS[sel_ws]);
+        break;
+
+    case SDLK_TAB:
+        step_ws(shift ? -1 : 1);
+        break;
+
+    case SDLK_W:                                   /* window level <-> workspace */
+        if (NWS > 0) {
+            Ws *ws = &WSS[sel_ws];
+            if (ws->sel >= 0) ws->sel = -1;
+            else              ws->sel = ws_first_visible(ws);
+        }
+        break;
+
+    case SDLK_LEFT:  case SDLK_H: navigate(-1, 0); break;
+    case SDLK_RIGHT: case SDLK_L: navigate( 1, 0); break;
+    case SDLK_UP:    case SDLK_K: navigate(0, -1); break;
+    case SDLK_DOWN:  case SDLK_J: navigate(0,  1); break;
+
+    case SDLK_SPACE:
+        if (NWS > 0) {
+            Ws *ws = &WSS[sel_ws];
+            if (ws->sel >= 0) act_toggle_mark(ws_sel_win(ws));
+            else for (int i = 0; i < ws->count; ++i)   /* mark the whole workspace */
+                WINS[ws->first + i].marked = true;
+        }
+        break;
+
+    case SDLK_A: {
+        if (NWS == 0) break;
+        Ws *ws = &WSS[sel_ws];
+        bool all = ws->count > 0;
+        for (int i = 0; i < ws->count; ++i) if (!WINS[ws->first + i].marked) all = false;
+        for (int i = 0; i < ws->count; ++i) WINS[ws->first + i].marked = !all;
+        break;
+    }
+
+    case SDLK_C:
+        for (int i = 0; i < NWIN; ++i) WINS[i].marked = false;
+        break;
+
+    case SDLK_X:
+    case SDLK_DELETE:
+        act_close();
+        break;
+
+    case SDLK_R:
+        reload_model();
+        break;
+
+    case SDLK_SLASH:
+        filtering = true;
+        query[0] = 0;
+        qlen = 0;
+        rebuild_chrome();
+        break;
+
+    default:
+        break;
+    }
+}
+
+static void handle_mouse_press(const SDL_MouseButtonEvent *b)
+{
+    float mx = b->x * MOUSE_SCALE, my = b->y * MOUSE_SCALE;
+    int ws_idx = -1;
+    int win_idx = hit_test(mx, my, &ws_idx);
+
+    if (ws_idx < 0) {                       /* the empty background */
+        if (b->button == SDL_BUTTON_LEFT) running = false;
+        return;
+    }
+
+    sel_active = true;
+    sel_ws = ws_idx;
+    WSS[ws_idx].sel = (win_idx >= 0) ? win_idx - WSS[ws_idx].first : -1;
+
+    switch (b->button) {
+    case SDL_BUTTON_LEFT:                   /* arm a drag, act on release */
+        press_down = true;
+        press_x = mx;
+        press_y = my;
+        press_win = win_idx;
+        press_ws  = ws_idx;
+        break;
+    case SDL_BUTTON_RIGHT:
+        if (win_idx >= 0) act_toggle_mark(&WINS[win_idx]);
+        break;
+    case SDL_BUTTON_MIDDLE:
+        if (win_idx >= 0) {
+            sway_cmd("[con_id=%d] kill", WINS[win_idx].con_id);
+            reload_model();
+        }
+        break;
+    default: break;
+    }
+}
+
+static void handle_mouse_release(const SDL_MouseButtonEvent *b)
+{
+    if (b->button != SDL_BUTTON_LEFT) return;
+
+    float mx = b->x * MOUSE_SCALE, my = b->y * MOUSE_SCALE;
+
+    if (drag_active) {
+        drag_update_target(mx, my);
+        drag_finish();
+        press_down = false;
+        return;
+    }
+    if (!press_down) return;
+    press_down = false;
+
+    /* a plain click: only acts when press and release landed on the same
+     * window, or on the same workspace */
+    int ws_idx = -1;
+    int win_idx = hit_test(mx, my, &ws_idx);
+    if (ws_idx < 0 || ws_idx != press_ws) { press_win = press_ws = -1; return; }
+
+    if (win_idx >= 0 && win_idx == press_win) act_focus_window(&WINS[win_idx]);
+    else if (win_idx < 0 && press_win < 0)    act_goto_workspace(&WSS[ws_idx]);
+
+    press_win = press_ws = -1;
+}
+
+static void handle_mouse_motion(const SDL_MouseMotionEvent *mo)
+{
+    float mx = mo->x * MOUSE_SCALE, my = mo->y * MOUSE_SCALE;
+    drag_x = mx;
+    drag_y = my;
+
+    if (press_down && !drag_active) {
+        float dx = mx - press_x, dy = my - press_y;
+        if (dx * dx + dy * dy > (7.0f * SC) * (7.0f * SC)) {
+            drag_active = true;
+            hov_ws = hov_win = -1;               /* hover is meaningless now */
+            drag_src_tile = (SDL_FRect){ 0, 0, 0, 0 };
+            if (press_win >= 0 && WINS[press_win].ws >= 0 && WINS[press_win].ws < NWS)
+                drag_src_tile = WSS[WINS[press_win].ws].tile;
+            if (press_win < 0 && press_ws >= 0 && WSS[press_ws].num >= 0)
+                drag_ws_mode = true;         /* dragging the whole workspace */
+            update_ghosts(mx, my);
+            SDL_SetCursor(CUR_HAND);
+        }
+    }
+
+    if (drag_active) {
+        drag_update_target(mx, my);
+        dirty = true;
+        return;
+    }
+
+    int ws_idx = -1;
+    int win_idx = hit_test(mx, my, &ws_idx);
+    if (ws_idx != hov_ws || win_idx != hov_win) {
+        hov_ws = ws_idx;
+        hov_win = win_idx;
+        SDL_SetCursor(ws_idx >= 0 ? CUR_HAND : CUR_ARROW);
+        dirty = true;
+    }
+}
+
+static void handle_event(const SDL_Event *e)
+{
+    /* Mouse motion fires constantly; it only earns a redraw when it changes
+     * what is under the cursor. Everything else is a real state change. */
+    if (e->type != SDL_EVENT_MOUSE_MOTION) dirty = true;
+
+    switch (e->type) {
+    case SDL_EVENT_QUIT:
+        running = false;
+        break;
+
+    case SDL_EVENT_KEY_DOWN:
+        handle_key(&e->key);
+        break;
+
+    case SDL_EVENT_TEXT_INPUT:
+        if (!filtering) {
+            if (strcmp(e->text.text, "/") == 0) {
+                filtering = true;
+                query[0] = 0;
+                qlen = 0;
+                rebuild_chrome();
+            }
+            break;
+        }
+        if (filtering) {
+            size_t add = strlen(e->text.text);
+            if (qlen + (int)add < (int)sizeof(query) - 1) {
+                memcpy(query + qlen, e->text.text, add + 1);
+                qlen += (int)add;
+                apply_filter();
+                rebuild_chrome();
+            }
+        }
+        break;
+
+    case SDL_EVENT_MOUSE_MOTION:
+        handle_mouse_motion(&e->motion);
+        break;
+
+    case SDL_EVENT_MOUSE_BUTTON_DOWN:
+        handle_mouse_press(&e->button);
+        break;
+
+    case SDL_EVENT_MOUSE_BUTTON_UP:
+        handle_mouse_release(&e->button);
+        break;
+
+    case SDL_EVENT_MOUSE_WHEEL:
+        if (e->wheel.y != 0.0f) step_ws(e->wheel.y > 0.0f ? -1 : 1);
+        break;
+
+    case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
+    case SDL_EVENT_WINDOW_RESIZED:
+        if (create_target()) { layout(); rebuild_chrome(); }
+        break;
+
+    case SDL_EVENT_WINDOW_FOCUS_LOST:
+        if (C.quit_on_focus_loss) running = false;
+        break;
+
+    default:
+        break;
+    }
+}
+
+static void present(void)
+{
+    if (TARGET) SDL_SetRenderTarget(REN, TARGET);
+    render();
+    if (TARGET) {
+        SDL_SetRenderTarget(REN, NULL);
+        SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_NONE);
+        SDL_SetRenderDrawColorFloat(REN, 0, 0, 0, 0);
+        SDL_RenderClear(REN);
+        SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_BLEND);
+        SDL_RenderTexture(REN, TARGET, NULL, NULL);
+    }
+
+    if (SHOT_PATH) {
+        SDL_Surface *shot = SDL_RenderReadPixels(REN, NULL);
+        if (shot) {
+            if (!IMG_SavePNG(shot, SHOT_PATH))
+                fprintf(stderr, "swov: cannot write %s: %s\n", SHOT_PATH, SDL_GetError());
+            SDL_DestroySurface(shot);
+        }
+        running = false;
+    }
+    SDL_RenderPresent(REN);
+}
+
+/* place the overlay on the output sway is using, fall back to the pointer */
+static void pick_bounds(SDL_Rect *out)
+{
+    *out = (SDL_Rect){ 0, 0, 1280, 720 };
+
+    int count = 0;
+    SDL_DisplayID *ids = SDL_GetDisplays(&count);
+    if (!ids || count <= 0) { SDL_free(ids); return; }
+
+    SDL_DisplayID chosen = 0;
+    if (FOCUSED_OUTPUT[0]) {
+        for (int i = 0; i < count; ++i) {
+            const char *n = SDL_GetDisplayName(ids[i]);
+            if (n && strcmp(n, FOCUSED_OUTPUT) == 0) { chosen = ids[i]; break; }
+        }
+    }
+    if (!chosen) {
+        float mx = 0.0f, my = 0.0f;
+        SDL_GetGlobalMouseState(&mx, &my);
+        chosen = ids[0];
+        for (int i = 0; i < count; ++i) {
+            SDL_Rect b;
+            if (!SDL_GetDisplayBounds(ids[i], &b)) continue;
+            if (mx >= (float)b.x && mx < (float)(b.x + b.w) &&
+                my >= (float)b.y && my < (float)(b.y + b.h)) { chosen = ids[i]; break; }
+        }
+    }
+    SDL_GetDisplayBounds(chosen, out);
+    SDL_free(ids);
+}
+
+int main(int argc, char **argv)
+{
+    C = cfg_defaults();
+
+    char *cfg_path = NULL;
+    bool  use_cfg = true;
+    const char *sets[64];
+    int nsets = 0;
+
+    for (int i = 1; i < argc; ++i) {
+        const char *a = argv[i];
+        if (!strcmp(a, "-h") || !strcmp(a, "--help")) { usage(); return 0; }
+        else if ((!strcmp(a, "-c") || !strcmp(a, "--config")) && i + 1 < argc)
+            cfg_path = expand_tilde(argv[++i]);
+        else if (!strcmp(a, "-n") || !strcmp(a, "--no-config")) use_cfg = false;
+        else if (!strcmp(a, "--shot") && i + 1 < argc) SHOT_PATH = argv[++i];
+        else if (!strcmp(a, "--mouse") && i + 1 < argc) {
+            float fx = 0.0f, fy = 0.0f;
+            if (sscanf(argv[++i], "%f,%f", &fx, &fy) == 2) { SHOT_MX = fx; SHOT_MY = fy; }
+        }
+        else if ((!strcmp(a, "-s") || !strcmp(a, "--set")) && i + 1 < argc && nsets < 64)
+            sets[nsets++] = argv[++i];
+        else if (strncmp(a, "--", 2) == 0 && strchr(a, '=')) {
+            if (nsets < 64) sets[nsets++] = a + 2;      /* --ui_scale=1.2 */
+        }
+        else if (a[0] != '-' && strchr(a, '=')) {
+            if (nsets < 64) sets[nsets++] = a;          /* ui_scale=1.2 */
+        }
+        else { fprintf(stderr, "swov: unknown argument '%s'\n", a); usage(); return 2; }
+    }
+
+    if (use_cfg) {
+        if (!cfg_path) cfg_path = default_config_path();
+        if (cfg_path && !cfg_load_file(&C, cfg_path) && argc > 1)
+            fprintf(stderr, "swov: no config at %s (using defaults)\n", cfg_path);
+    }
+    free(cfg_path);
+
+    for (int i = 0; i < nsets; ++i) {               /* -s key=value */
+        char *copy = xstrdup(sets[i]);
+        char *eq = strchr(copy, '=');
+        if (eq) { *eq = 0; cfg_set(&C, str_trim(copy), str_trim(eq + 1)); }
+        free(copy);
+    }
+
+    sway_fd = sway_connect();
+    if (sway_fd < 0) die("cannot reach sway (is SWAYSOCK set?)");
+
+    /* the focused output is needed before the window is created */
+    JV *wsr = sway_query(IPC_GET_WORKSPACES);
+    if (wsr && wsr->type == J_ARR)
+        for (int i = 0; i < wsr->count; ++i)
+            if (jbool(wsr->items[i], "focused", false))
+                snprintf(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), "%s",
+                         jstr(wsr->items[i], "output", ""));
+    jfree(wsr);
+
+    SDL_SetHint(SDL_HINT_APP_ID, APP_ID);
+    SDL_SetAppMetadata("swov", "1.0", "org.swov.overview");
+
+    if (!SDL_Init(SDL_INIT_VIDEO)) die("SDL_Init: %s", SDL_GetError());
+    if (!TTF_Init()) die("TTF_Init: %s", SDL_GetError());
+
+    SDL_Rect bounds;
+    pick_bounds(&bounds);
+
+    SDL_WindowFlags flags = SDL_WINDOW_BORDERLESS | SDL_WINDOW_TRANSPARENT |
+                            SDL_WINDOW_ALWAYS_ON_TOP | SDL_WINDOW_HIGH_PIXEL_DENSITY;
+    WIN_HANDLE = SDL_CreateWindow("swov", bounds.w, bounds.h, flags);
+    if (!WIN_HANDLE) die("SDL_CreateWindow: %s", SDL_GetError());
+    SDL_SetWindowPosition(WIN_HANDLE, bounds.x, bounds.y);
+
+    REN = SDL_CreateRenderer(WIN_HANDLE, NULL);
+    if (!REN) die("SDL_CreateRenderer: %s", SDL_GetError());
+    SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_BLEND);
+    SDL_SetRenderVSync(REN, C.vsync ? 1 : 0);
+
+    if (!create_target()) die("cannot size the window");
+    load_fonts();
+
+    CUR_ARROW = SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_DEFAULT);
+    CUR_HAND  = SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_POINTER);
+
+    sway_subscribe_events();
+    if (!model_reload()) die("could not read the sway tree");
+    select_current_workspace();
+
+    sel_active = C.start_selection != 0;
+    if (C.start_selection == 2 && NWS > 0) WSS[sel_ws].sel = ws_first_visible(&WSS[sel_ws]);
+    layout();
+    apply_filter();
+    rebuild_chrome();
+
+    if (SHOT_MX >= 0.0f) {                          /* pretend the pointer is there */
+        hov_win = hit_test(SHOT_MX * MOUSE_SCALE, SHOT_MY * MOUSE_SCALE, &hov_ws);
+    }
+
+    SDL_StartTextInput(WIN_HANDLE);
+    SDL_RaiseWindow(WIN_HANDLE);
+    present();
+
+    while (running) {
+        SDL_Event e;
+        if (anim_running()) dirty = true;
+        if (SDL_WaitEventTimeout(&e, anim_running() ? 8 : 60)) {
+            handle_event(&e);
+            while (running && SDL_PollEvent(&e)) handle_event(&e);   /* coalesce */
+        }
+
+        /* sway acknowledges a command before the layout transaction has
+         * committed, so the tree is only trustworthy once the events for it
+         * have arrived. This also keeps the overview live. */
+        if (running && !drag_active && sway_events_pending()) {
+            reload_model();
+            dirty = true;
+        }
+        if (running && dirty) { present(); dirty = false; }
+    }
+
+    /* cleanup */
+    tex_free(&T_HEADER);
+    tex_free(&T_HINTS);
+    tex_free(&T_QUERY);
+    model_free();
+    icons_free();
+
+    if (CUR_ARROW) SDL_DestroyCursor(CUR_ARROW);
+    if (CUR_HAND)  SDL_DestroyCursor(CUR_HAND);
+    if (TARGET)    SDL_DestroyTexture(TARGET);
+    if (F_BADGE)   TTF_CloseFont(F_BADGE);
+    if (F_LABEL)   TTF_CloseFont(F_LABEL);
+    if (F_TITLE)   TTF_CloseFont(F_TITLE);
+    if (F_HINT)    TTF_CloseFont(F_HINT);
+    TTF_Quit();
+    SDL_DestroyRenderer(REN);
+    SDL_DestroyWindow(WIN_HANDLE);
+    SDL_Quit();
+    if (sway_fd >= 0) close(sway_fd);
+    if (sway_evt_fd >= 0) close(sway_evt_fd);
+    return 0;
+}