Nimbin[12]?Sway / SwAS / commits / 2de83fa

SwAS git · main

SwAS - app switcher for sway (SVG/PNG icons, themeable)

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

C 97.5% Markdown 1.6%
git clone https://git.christianimmanuel.de/sway/SwAS.gitwget https://git.christianimmanuel.de/sway/SwAS/archive/SwAS.tar.gz

Add drop app to swov And stuff

Christian Immanuel · 2026-08-27 22:42 · 2de83fafb8d475c2d32463eb51e2bbcb07fdb481

 Makefile       |  16 +-
 README.md      | 247 +++++++++++++++----------
 appwheel.c     | 562 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 config.example |  90 +++++++++
 sw_theme.h     | 194 ++++++++++++++++++++
 5 files changed, 992 insertions(+), 117 deletions(-)

diff --git a/Makefile b/Makefile
index 4307787..1d2cf58 100644
--- a/Makefile
+++ b/Makefile
@@ -23,6 +23,7 @@ CFLAGS      ?= -O2
 PREFIX      ?= $(HOME)/.local
 bindir      ?= $(PREFIX)/bin
 
+BUILD       := $(shell md5sum appwheel.c 2>/dev/null | cut -c1-8)
 SRC         := appwheel.c
 BIN         := appwheel
 
@@ -36,19 +37,21 @@ SDL_STATIC_EXTRA := $(filter-out -lSDL3,$(shell $(PKG_CONFIG) --static --libs sd
 
 all: $(BIN)
 
-$(BIN): $(SRC) stb_truetype.h stb_image.h
-	$(CC) $(CFLAGS) $(SDL_CFLAGS) $(SRC) -o $(BIN) $(SDL_LIBS) -lm
+$(BIN): $(SRC) stb_truetype.h stb_image.h sw_theme.h
+	$(CC) $(CFLAGS) -DAPPWHEEL_BUILD='"$(BUILD)"' $(SDL_CFLAGS) $(SRC) -o $(BIN) $(SDL_LIBS) -lm
+	@./$(BIN) --version
 
 # Link libSDL3.a statically while keeping system libs dynamic.
-static: $(SRC) stb_truetype.h stb_image.h
-	$(CC) $(CFLAGS) $(SDL_CFLAGS) $(SRC) -o $(BIN) \
+static: $(SRC) stb_truetype.h stb_image.h sw_theme.h
+	$(CC) $(CFLAGS) -DAPPWHEEL_BUILD='"$(BUILD)"' $(SDL_CFLAGS) $(SRC) -o $(BIN) \
 	  $(SDL_LDPATH) -Wl,-Bstatic -lSDL3 -Wl,-Bdynamic $(SDL_STATIC_EXTRA) -lm
 	@echo "built ./$(BIN) with SDL baked in:"; \
 	 ldd $(BIN) | grep -qi SDL3 && echo "  (warning: still links libSDL3.so)" \
 	   || echo "  no libSDL3.so dependency."
 
-strict: $(SRC) stb_truetype.h stb_image.h
-	$(CC) -O2 -Wall -Wextra -Wno-misleading-indentation $(SDL_CFLAGS) $(SRC) -o $(BIN) $(SDL_LIBS) -lm
+strict: $(SRC) stb_truetype.h stb_image.h sw_theme.h
+	$(CC) -O2 -Wall -Wextra -Wno-misleading-indentation -DAPPWHEEL_BUILD='"$(BUILD)"' \
+	  $(SDL_CFLAGS) $(SRC) -o $(BIN) $(SDL_LIBS) -lm
 
 run: all
 	./$(BIN)
@@ -56,6 +59,7 @@ run: all
 install: all
 	install -Dm755 $(BIN) $(DESTDIR)$(bindir)/$(BIN)
 	@echo "installed to $(DESTDIR)$(bindir)/$(BIN)"
+	@./$(BIN) --version
 
 config: all
 	@mkdir -p $(HOME)/.config/appwheel
diff --git a/README.md b/README.md
index 58ab81a..1b3ed92 100644
--- a/README.md
+++ b/README.md
@@ -1,135 +1,194 @@
-appwheel
-========
+# appwheel
 
-A radial "weapon-wheel" application launcher for Linux (Wayland and X11),
-in a single C file. Point at a slice to pick an app, type to filter, Enter
-to launch.
+A radial app launcher, drawn with SDL3. Apps sit in wide slots across the top
+arc; point at one and click, or type to filter. Works on Wayland and X11.
 
-![AppWheel screenshot](screenshot.png)
+![appwheel](screenshot.png)
 
-This is a vibe-coded project: 100% written by Claude (Anthropic) from a chat.
-It builds clean and was tested headless, but not on real hardware — read it
-before you trust it.
+100% vibecode, but tested.
 
+## Build
 
-Requirements
-------------
-
-A C compiler, pkg-config, and the SDL3 development files.
-
-Arch:
+Debian trixie and newer:
 
 ```sh
-sudo pacman -S sdl3
+sudo apt install build-essential pkg-config libsdl3-dev
+make
+make install          # ~/.local/bin, or PREFIX=/usr/local
+make config           # optional: write a default config to ~/.config/appwheel/config
 ```
 
-Fedora:
+`make static` bakes SDL3 into the binary, so it runs without `libSDL3.so`. It
+needs `libSDL3.a`, which distro packages do not always ship.
 
-```sh
-sudo dnf install SDL3-devel
-```
+On bookworm the SDL3 package does not exist yet; build SDL 3.2+ from source and
+set `PKG_CONFIG_PATH`.
 
-Debian / Ubuntu (24.10 or newer):
+Needs any TTF font. Icons need a `.desktop` file and an icon theme.
 
-```sh
-sudo apt install libsdl3-dev
+## Use
+
+```
+bindsym $mod+space exec appwheel
+for_window [app_id="appwheel"] floating enable, border none
 ```
 
-If your distro has no SDL3 package, build it once from source:
+| key | action |
+| --- | --- |
+| move the pointer over the top arc | highlight a slot |
+| bottom left / right | page back / forward — the further out, the faster |
+| scroll | move the list under the fixed highlight |
+| type | filter by name |
+| `enter`, left click | launch |
+| `tab` / `shift+tab`, arrows | next / previous slot |
+| `esc` | clear the filter, else quit |
+| right click | quit |
+
+Apps are read from the `.desktop` files in the usual directories. The wheel
+shows the most recently opened ones first; every launch is written to
+`~/.cache/appwheel/history`.
 
 ```sh
-git clone --depth 1 https://github.com/libsdl-org/SDL
-cmake -S SDL -B SDL/build -DCMAKE_BUILD_TYPE=Release
-cmake --build SDL/build -j
-sudo cmake --install SDL/build && sudo ldconfig
+appwheel --list                       # every id, name and resolved icon
+appwheel include=firefox,code,gimp    # a curated wheel, in that order
+appwheel --dmenu < items              # a dmenu-style picker on stdin/stdout
 ```
 
+## Drop onto a workspace
 
-Build
------
-
-  Keep the vendored headers (stb_truetype.h, stb_image.h, nanosvg.h,
-  nanosvgrast.h) next to appwheel.c, then:
-
-      make
+Drag an app out of the wheel and let go over a workspace: it starts *there*,
+while you stay where you are. As you drag, the wheel shrinks and slides into
+the top-left corner, out of the way of what you are aiming at, and it stays
+open afterwards, ready for the next one.
 
-  or by hand:
+Letting go over the wheel itself cancels — nothing opens, and the workspace
+underneath it is never picked up by mistake. A ring around the wheel says so
+while you hover there. `esc` and right click cancel too.
 
-      cc appwheel.c -o appwheel $(pkg-config --cflags --libs sdl3) -lm
+`drop_corner=top-right` parks it on the other side, `none` leaves it in the
+middle.
 
+Grey outlined tiles are numbers that have no workspace yet — drop on one and
+sway makes it.
 
-Install
--------
+### With the overview behind it
 
-      make install          # copies appwheel to ~/.local/bin
+`overview=1` puts the real thing back there instead of the tiles: swov, drawn
+underneath, showing every workspace with the windows already in it.
 
-  Then bind a key to `appwheel` in your compositor, e.g.:
-
-      Hyprland   bind = SUPER, A, exec, appwheel
-      sway       bindsym $mod+a exec appwheel
-      i3         bindsym $mod+a exec appwheel
-
-
-Usage
------
-
-  Mouse over the top of the ring     highlight an app
-  Bottom-left / bottom-right         page through long lists (further = faster)
-  Scroll wheel                       move one at a time
-  Type                               filter by name
-  Enter or left-click                launch
-  Esc                                clear the filter, or quit
-  Right-click                        quit
-  Tab / Shift+Tab                    next / previous
-
-  Apps you launch are remembered and shown first next time.
+```
+bindsym $mod+space exec appwheel overview=1
 
-  As a dmenu-style picker (like dmenu/bemenu/wofi), feed items on stdin and read
-  the choice from stdout:
+no_focus [app_id="swov-backdrop"]
+for_window [app_id="swov-backdrop"] floating enable, border none
+for_window [app_id="appwheel"] floating enable, border none
+```
 
-      printf 'Log Out\nReboot\nShutdown' | appwheel --dmenu
+`no_focus` is a command in its own right and takes the criteria as its
+argument — it is *not* something you put after `for_window`. Written the wrong
+way round it fails silently at load and the backdrop takes your keyboard.
 
+The `no_focus` line matters. Without it sway hands the keyboard to the backdrop
+the moment it appears and the first letters you type are lost.
 
-Configuration
--------------
+If something looks wrong, run it from a terminal with `overview_debug=1`:
+every line between the two, and swov's own complaints, go to stderr.
 
-  Optional — appwheel works with no config. To create one:
+The backdrop maps on top of the wheel — on Wayland a window cannot lift itself
+back — so appwheel asks swov to restack it as soon as that window is up. If
+you ever end up with a wheel that ignores the mouse, that is what went wrong;
+`esc` still closes it, and `overview=0` falls back to the plain tiles.
 
-      mkdir -p ~/.config/appwheel
-      appwheel --dump-config > ~/.config/appwheel/config
+Drop it on an empty part of a workspace and it lands at the end of the layout.
+Drop it on the **left or right edge of a window already there** and it opens
+beside it, splitting horizontally; the **top or bottom edge** splits
+vertically. A bar shows the edge before you let go, the same one swov draws
+when you drag a window inside it. That way a workspace can be filled with a
+few apps in the arrangement you want without touching any of them afterwards.
 
-  Edit that file (one key=value per line). Every key also works on the
-  command line, so you can try things without editing anything:
+The app is placed before it opens, not moved after: appwheel hands swov the
+pid the moment it launches, and swov puts an `assign` rule in sway's way while
+the process is still starting. Nothing flashes onto the workspace you are on.
 
-      appwheel slots=8 ssaa=3 ui_scale=1.3
+The wheel stays open and in front through all of it, even though each new
+window takes the focus as it opens, so you can fill several workspaces in one
+go and then pick one to switch to. The one catch is dropping on the workspace
+you are already on: the app opens behind the wheel, so you will not see it
+until you close it. `drop_close_here=1` closes the wheel in that case.
 
-  The main keys:
+appwheel starts it only after its own first frame is on screen, and swov fades
+in from nothing, so picking an app straight away shows no flicker at all — you
+never see it arrive. While you drag, the wheel shrinks and the workspace under
+the pointer takes the selection cursor. Let go and the app starts there.
 
-      slots=10          apps shown around the top of the ring
-      arc=240           degrees of the ring used for apps
-      page_ms=110       paging speed (higher = slower)
-      icons=1           show .desktop icons (icon_px=46 for size)
-      ui_scale=1.0      overall text size
-      font=...          a .ttf path or a family name; default is your
-                        desktop font. Also label_px/title_px/search_px.
-      ssaa=2            edge smoothing, 1..4
-      sort=recent       recent (most-used first) or alpha
-      include=a,b,c     show only these apps, in this order
-      exclude=a,b       hide these
-      colors            bg ring ring2 hl text hltext center accent dim
-                        (as #rrggbb or #rrggbbaa; bg alpha < ff = transparent)
+The overview is drawn soft while you are still choosing an app, and sharpens
+as soon as you start dragging — set `blur` in swov's own config.
 
-  Run `appwheel --help` for the full list, and `appwheel --list` to see the
-  app ids you use with include/exclude.
+The two talk over a pipe, one line each way: appwheel sends the pointer as a
+fraction of the screen, swov answers with the workspace under it. Closing the
+wheel closes the pipe, and the backdrop fades out and leaves.
 
+This needs swov on `PATH`: appwheel asks it for the
+workspace list and hands it the launched process, which swov follows until its
+window appears and then moves. Without swov the feature is simply off. Set
+`drop_focus=1` to follow the app to its workspace instead of staying put.
 
-Running on another distro
--------------------------
+## Config
 
-  A binary built on a new distro may fail on an older one with
-  "GLIBC_2.xx not found". Build it on the older machine, or use the included
-  script to build inside an old Ubuntu container (needs Docker or Podman):
+`${XDG_CONFIG_HOME:-~/.config}/appwheel/config`, `key=value`, `#` comments.
+Every key is also a command line option:
 
-      ./build-portable.sh
+```sh
+appwheel slots=8 arc=220 icons=0
+appwheel --ui_scale=1.3 --icon_px=56
+```
 
-  The result runs on that Ubuntu release and anything newer.
+`appwheel --dump-config` prints the whole set with defaults. A `#` after
+whitespace starts a comment, so the annotated lines that command prints can be
+kept as they are. The ones worth
+knowing:
+
+| key | |
+| --- | --- |
+| `slots` | how many apps the arc holds at once |
+| `arc` | degrees of the ring used for apps; the rest is the paging zone |
+| `radius`, `y_offset` | wheel size and where it sits |
+| `ui_scale` | scales all text at once |
+| `ssaa` | supersampling 1–4 |
+| `icons`, `icon_px` | `.desktop` icons and their size |
+| `sort` | `recent` or `alpha` |
+| `include`, `exclude`, `dirs` | which apps show, and in what order |
+| `launcher`, `terminal` | how an `Exec=` line is run |
+| `bg` | the scrim over the desktop; `0d111700` for none |
+| `close_on_focus_loss` | `1` = quit when another window takes focus |
+| `drop` | drag an app onto a workspace; `0` = off |
+| `overview` | `1` = swov behind the wheel instead of the plain tiles |
+| `overview_debug` | `1` = print the backdrop conversation to stderr |
+| `drop_close_here` | close after a drop on the current workspace; `0` = stay |
+| `drop_shrink`, `drop_ms`, `drop_px` | how the wheel gets out of the way |
+| `drop_corner` | where it parks while dragging: `top-left`, `top-right`, `none` |
+| `drop_focus` | `1` = switch to the workspace the app landed on |
+| `swov` | the binary asked about workspaces |
+
+### Shared config
+
+Colours and fonts for appwheel, swov and swbr can be set once in
+`${XDG_CONFIG_HOME:-~/.config}/sw/config`, using role names (`surface`,
+`accent`, `hl`, ...) that each program maps onto its own keys. `sw_theme.h`
+lists them all.
+
+Keys written before any section go to all three programs; a `[appwheel]`
+section goes to appwheel only and takes its own key names as well. The config
+above is read afterwards, so it always wins, and the command line wins over
+that.
+
+## Notes
+
+- The window is mapped and focused before the `.desktop` scan and the font
+  load, so anything typed while it starts is queued by the compositor instead
+  of being lost.
+- "Fullscreen" is a borderless window the size of the display, not exclusive
+  fullscreen: that would make the surface opaque and flicker the mode on close.
+- Apps whose `.desktop` file says `Terminal=true` are run inside `terminal=`.
+- SVG icons are rasterised at the size they are drawn, so they stay sharp at
+  any `icon_px`.
diff --git a/appwheel.c b/appwheel.c
index 9feb280..448a0e5 100644
--- a/appwheel.c
+++ b/appwheel.c
@@ -37,6 +37,15 @@
 #define STBI_ONLY_BMP
 #include "stb_image.h"
 
+#include "sw_theme.h"
+
+#define APP_ID "appwheel"          /* wayland app_id / X11 WM_CLASS */
+
+#define APPWHEEL_VERSION "1.1"
+#ifndef APPWHEEL_BUILD             /* set by the Makefile: md5 of this file */
+#define APPWHEEL_BUILD "unknown"
+#endif
+
 /* SVG icons via nanosvg (vendored single-header). Third-party headers, so we
    quiet their warnings without affecting our own -Wall build. */
 #pragma GCC diagnostic push
@@ -63,6 +72,9 @@
 #include <sys/types.h>
 #include <sys/wait.h>
 #include <fcntl.h>
+#include <signal.h>
+#include <errno.h>
+#include <stdarg.h>
 #include <limits.h>
 
 #ifndef M_PI
@@ -123,6 +135,16 @@ typedef struct {
     char  font[PATH_MAX];
     char  sort[16], launcher[16], terminal[128];
     char  history[PATH_MAX], dirs[4096], include[8192], exclude[8192];
+    int   drop;                       /* drag an app onto a workspace: 0 = off */
+    int   overview;                   /* 1 = swov behind the wheel as the target */
+    int   overview_debug;             /* narrate the backdrop conversation      */
+    float drop_shrink;                /* how small the wheel gets while dragging */
+    int   drop_ms, drop_px, drop_focus;
+    int   drop_close_here;            /* dropped on the workspace we are on:
+                                         close, or the app opens behind us   */
+    char  drop_corner[16];            /* where the wheel goes while dragging:
+                                         top-left, top-right, or none        */
+    char  swov[PATH_MAX];             /* the binary that knows about sway */
     Col bg,ring,ring2,hl,text,hltext,center,accent,dim;
 } Config;
 
@@ -135,6 +157,10 @@ static void config_defaults(Config*c){
     c->label_px=24; c->title_px=25; c->search_px=20; c->count_px=20;
     c->ssaa=2;
     c->animate=1; c->anim_ms=90; c->recent_first=1;
+    c->drop=1; c->drop_shrink=0.55f; c->drop_ms=130; c->drop_px=10; c->drop_focus=0;
+    c->overview=0; c->overview_debug=0; c->drop_close_here=0;
+    strcpy(c->drop_corner,"top-left");
+    strcpy(c->swov,"swov");
     strcpy(c->sort,"recent"); strcpy(c->launcher,"sh");
     const char*term=getenv("TERMINAL");
     snprintf(c->terminal,sizeof c->terminal,"%s",term?term:"xterm");
@@ -173,6 +199,16 @@ static void config_set(Config*c,const char*k,const char*v){
     else if(!strcmp(k,"animate"))c->animate=atoi(v);
     else if(!strcmp(k,"anim_ms"))c->anim_ms=atoi(v);
     else if(!strcmp(k,"recent_first"))c->recent_first=atoi(v);
+    else if(!strcmp(k,"drop"))c->drop=atoi(v);
+    else if(!strcmp(k,"overview"))c->overview=atoi(v);
+    else if(!strcmp(k,"overview_debug"))c->overview_debug=atoi(v);
+    else if(!strcmp(k,"drop_close_here"))c->drop_close_here=atoi(v);
+    else if(!strcmp(k,"drop_corner"))snprintf(c->drop_corner,sizeof c->drop_corner,"%s",v);
+    else if(!strcmp(k,"drop_shrink"))c->drop_shrink=(float)atof(v);
+    else if(!strcmp(k,"drop_ms"))c->drop_ms=atoi(v);
+    else if(!strcmp(k,"drop_px"))c->drop_px=atoi(v);
+    else if(!strcmp(k,"drop_focus"))c->drop_focus=atoi(v);
+    else if(!strcmp(k,"swov"))snprintf(c->swov,sizeof c->swov,"%s",v);
     else if(!strcmp(k,"font"))snprintf(c->font,sizeof c->font,"%s",v);
     else if(!strcmp(k,"sort"))snprintf(c->sort,sizeof c->sort,"%s",v);
     else if(!strcmp(k,"launcher"))snprintf(c->launcher,sizeof c->launcher,"%s",v);
@@ -192,13 +228,33 @@ static void config_set(Config*c,const char*k,const char*v){
     else if(!strcmp(k,"dim"))parse_color(v,&c->dim);
     else fprintf(stderr,"wheel: unknown key '%s'\n",k);
 }
+/* the shared ~/.config/sw/config, translated into appwheel's own keys */
+static void config_set_shared(void*ud,const char*k,const char*v){ config_set((Config*)ud,k,v); }
+
+/* `key=value   # what it does` — the trailing comment is not part of the
+ * value. Numbers survived it by accident (atoi stops at the space) but a
+ * string key did not: `sort=recent  # ...` set sort to the whole rest of the
+ * line, which is not "recent", and the most-recently-used order quietly
+ * turned itself off. A '#' only starts a comment after whitespace, so a value
+ * may still contain one. */
+static void strip_comment(char*v){
+    char q=0;
+    for(char*p=v;*p;p++){
+        if(q){ if(*p==q) q=0; continue; }
+        if(*p=='\''||*p=='"'){ q=*p; continue; }
+        if(*p=='#'&&p>v&&isspace((unsigned char)p[-1])){ *p='\0'; return; }
+    }
+}
+
 static void config_load(Config*c,const char*path){
     FILE*f=fopen(path,"r"); if(!f) return;
     char line[8192];
     while(fgets(line,sizeof line,f)){
         char*s=trim(line); if(!*s||*s=='#'||*s==';') continue;
         char*eq=strchr(s,'='); if(!eq) continue;
-        *eq='\0'; config_set(c,trim(s),trim(eq+1));
+        *eq='\0';
+        char*v=eq+1; strip_comment(v);
+        config_set(c,trim(s),trim(v));
     }
     fclose(f);
 }
@@ -669,7 +725,203 @@ static void history_prepend(Config*c,const char*id){
     for(int i=0;i<ln;i++){ fprintf(f,"%s\n",lines[i]); free(lines[i]); }
     fclose(f);
 }
-static void launch(App*a,Config*c){
+/* ------------------------------------------------------------------ */
+/* workspaces — everything sway-shaped is asked of swov, so this file    */
+/* needs no IPC, no JSON and no knowledge of the compositor.             */
+/* ------------------------------------------------------------------ */
+typedef struct {
+    int  num;                 /* -1 for a workspace that only has a name */
+    char name[64];            /* "3" or "3:code"                         */
+    int  focused, exists;     /* exists = 0 -> a free number, a new one   */
+} Wsp;
+
+static Wsp  WSP[24];
+static int  NWSP, WSP_TRIED;
+
+static const char *wsp_label(const Wsp*w){
+    const char*colon=strchr(w->name,':');
+    return colon&&colon[1]?colon+1:"";
+}
+
+static int wsp_cmp(const void*A,const void*B){
+    const Wsp*a=A,*b=B;
+    if((a->num<0)!=(b->num<0)) return a->num<0?1:-1;   /* named-only ones last */
+    if(a->num!=b->num) return a->num-b->num;
+    return strcmp(a->name,b->name);
+}
+
+/* `swov --workspaces`, plus the free numbers 1..9 as places to make one */
+static void wsp_load(Config*c){
+    if(WSP_TRIED) return;
+    WSP_TRIED=1; NWSP=0;
+
+    char cmd[PATH_MAX+64];
+    snprintf(cmd,sizeof cmd,"%s --workspaces 2>/dev/null",c->swov);
+    FILE*f=popen(cmd,"r");
+    if(!f) return;
+
+    char line[256];
+    while(NWSP<(int)(sizeof WSP/sizeof*WSP) && fgets(line,sizeof line,f)){
+        char*num=strtok(line,"\t"); if(!num) continue;
+        char*name=strtok(NULL,"\t"); if(!name) continue;
+        strtok(NULL,"\t");                       /* output, not used here */
+        char*flags=strtok(NULL,"\t\n");
+        Wsp*w=&WSP[NWSP++];
+        memset(w,0,sizeof *w);
+        w->num=atoi(num);
+        snprintf(w->name,sizeof w->name,"%s",name);
+        w->exists=1;
+        w->focused=flags&&contains_ci(flags,"focused");
+    }
+    pclose(f);
+    if(NWSP==0) return;                          /* no swov, no dropping   */
+
+    for(int n=1;n<=9 && NWSP<(int)(sizeof WSP/sizeof*WSP);n++){
+        int taken=0;
+        for(int i=0;i<NWSP;i++) if(WSP[i].num==n){ taken=1; break; }
+        if(taken) continue;
+        Wsp*w=&WSP[NWSP++];
+        memset(w,0,sizeof *w);
+        w->num=n; snprintf(w->name,sizeof w->name,"%d",n);
+    }
+    qsort(WSP,NWSP,sizeof*WSP,wsp_cmp);
+}
+
+/* ------------------------------------------------------------------ */
+/* the overview behind the wheel                                        */
+/*                                                                      */
+/* `swov --backdrop` draws the workspaces underneath us and takes no     */
+/* input of its own: we send it the pointer, it sends back the workspace */
+/* under it. Two pipes, one line each way, no shared code.               */
+/* ------------------------------------------------------------------ */
+static int   OV_IN=-1, OV_OUT=-1;     /* our end of its stdin / stdout */
+static pid_t OV_PID=-1;
+static int   OV_READY;                /* its window is up              */
+static char  OV_TARGET[64];           /* the workspace under the cursor */
+static int   OV_HERE;                 /* ...and it is the one we are on  */
+static char  OV_BESIDE[32];           /* con_id of the window under it    */
+static char  OV_EDGE[16];             /* which side of it: left/right/... */
+static int   OV_LOG;                  /* print the conversation to stderr */
+
+static void ov_send(const char*fmt,...){
+    if(OV_IN<0) return;
+    char line[128]; va_list ap; va_start(ap,fmt);
+    int n=vsnprintf(line,sizeof line-1,fmt,ap); va_end(ap);
+    if(n<0) return;
+    line[n]='\n'; line[n+1]='\0';
+    if(OV_LOG) fprintf(stderr,"appwheel: -> %.*s\n",n,line);
+    if(write(OV_IN,line,(size_t)n+1)<0){
+        if(OV_LOG) fprintf(stderr,"appwheel: backdrop pipe closed\n");
+        close(OV_IN); OV_IN=-1;
+    }
+}
+
+static void ov_spawn(Config*c){
+    OV_LOG=c->overview_debug;
+    int to[2], from[2];
+    if(pipe(to)!=0) return;
+    if(pipe(from)!=0){ close(to[0]); close(to[1]); return; }
+
+    signal(SIGPIPE,SIG_IGN);          /* it may be gone before we notice */
+    pid_t p=fork();
+    if(p<0){ close(to[0]);close(to[1]);close(from[0]);close(from[1]); return; }
+    if(p==0){
+        dup2(to[0],0); dup2(from[1],1);
+        close(to[0]); close(to[1]); close(from[0]); close(from[1]);
+        execlp(c->swov,c->swov,c->overview_debug?"--backdrop-debug":"--backdrop",
+               (char*)NULL);
+        fprintf(stderr,"appwheel: cannot run '%s' (%s) — no overview\n",
+                c->swov,strerror(errno));
+        _exit(127);
+    }
+    close(to[0]); close(from[1]);
+    OV_PID=p; OV_IN=to[1]; OV_OUT=from[0];
+    fcntl(OV_OUT,F_SETFL,O_NONBLOCK);
+}
+
+/* whatever it has said since the last frame */
+static void ov_poll(SDL_Window*win){
+    if(OV_OUT<0) return;
+    static char buf[512]; static int len;
+
+    for(;;){
+        ssize_t n=read(OV_OUT,buf+len,sizeof buf-1-(size_t)len);
+        if(n<=0) break;
+        len+=(int)n; buf[len]='\0';
+
+        char*start=buf,*nl;
+        while((nl=strchr(start,'\n'))!=NULL){
+            *nl='\0';
+            if(OV_LOG) fprintf(stderr,"appwheel: <- %s\n",start);
+            if(!strcmp(start,"ready")){
+                OV_READY=1;
+                SDL_RaiseWindow(win);          /* enough on X11 */
+                /* On Wayland a window cannot lift itself, and the backdrop
+                   mapped on top of us. swov is talking to sway anyway. */
+                ov_send("raise " APP_ID);
+            } else if(!strncmp(start,"target ",7)){
+                /* "target 3", "target 3 current", "target 3 beside 42 left" */
+                char buf[160]; snprintf(buf,sizeof buf,"%s",start+7);
+                OV_HERE=0; OV_BESIDE[0]='\0'; OV_EDGE[0]='\0';
+                char*tok=strtok(buf," ");
+                snprintf(OV_TARGET,sizeof OV_TARGET,"%s",
+                         (tok&&strcmp(tok,"none"))?tok:"");
+                while((tok=strtok(NULL," "))!=NULL){
+                    if(!strcmp(tok,"current")) OV_HERE=1;
+                    else if(!strcmp(tok,"beside")){
+                        char*id=strtok(NULL," "), *ed=strtok(NULL," ");
+                        if(id&&ed){ snprintf(OV_BESIDE,sizeof OV_BESIDE,"%s",id);
+                                    snprintf(OV_EDGE,sizeof OV_EDGE,"%s",ed); }
+                    }
+                }
+            }
+            start=nl+1;
+        }
+        len=(int)strlen(start);
+        memmove(buf,start,(size_t)len+1);
+    }
+}
+
+static void ov_stop(void){
+    if(OV_IN>=0){ ov_send("quit"); close(OV_IN); OV_IN=-1; }
+    if(OV_OUT>=0){ close(OV_OUT); OV_OUT=-1; }
+}
+
+/* Hand the launched process to swov, which waits for its window and moves it.
+   sway cannot run something "on workspace N", so this is the way there. */
+static void adopt_to(Config*c,pid_t pid,const char*target,
+                     const char*beside,const char*edge){
+    if(pid<=0||!target||!*target) return;
+    char spid[32];
+    snprintf(spid,sizeof spid,"%d",(int)pid);
+
+    pid_t p=fork();
+    if(p<0) return;
+    if(p==0){
+        int fd=open("/dev/null",O_RDWR);
+        if(fd>=0){ dup2(fd,0); dup2(fd,1); dup2(fd,2); if(fd>2) close(fd); }
+        const char*av[12]; int n=0;
+        av[n++]=c->swov; av[n++]="--adopt"; av[n++]=spid; av[n++]=target;
+        if(beside&&*beside&&edge&&*edge){
+            av[n++]="--beside"; av[n++]=beside; av[n++]="--edge"; av[n++]=edge;
+        }
+        if(c->drop_focus)      av[n++]="--adopt-focus";
+        if(c->overview_debug)  av[n++]="--adopt-debug";
+        av[n]=NULL;
+        execvp(c->swov,(char*const*)av);
+        _exit(127);
+    }
+    waitpid(p,NULL,0);        /* swov backgrounds itself, so this is instant */
+}
+
+static void wsp_adopt(Config*c,pid_t pid,const Wsp*w){
+    char target[80];
+    if(w->num>=0) snprintf(target,sizeof target,"%d",w->num);
+    else          snprintf(target,sizeof target,"%s",w->name);
+    adopt_to(c,pid,target,NULL,NULL);
+}
+
+static pid_t launch(App*a,Config*c){
     char cmd[8192];
     if(!strcmp(c->launcher,"gtk-launch")) snprintf(cmd,sizeof cmd,"gtk-launch %s.desktop",a->id);
     else if(a->terminal) snprintf(cmd,sizeof cmd,"%s -e %s",c->terminal,a->exec);
@@ -682,12 +934,24 @@ static void launch(App*a,Config*c){
        from. We: new session (setsid) so there's no controlling tty, a second
        fork so it can never reacquire one and gets reparented to init, and
        std fds pointed at /dev/null. */
+    /* The grandchild is the one that becomes the app, and its pid is what a
+       drop needs, so it reports itself back through a pipe. */
+    int pfd[2];
+    if(pipe(pfd)!=0){ pfd[0]=pfd[1]=-1; }
+
     pid_t pid=fork();
-    if(pid<0){ fprintf(stderr,"appwheel: fork failed\n"); return; }
+    if(pid<0){ fprintf(stderr,"appwheel: fork failed\n");
+               if(pfd[0]>=0){ close(pfd[0]); close(pfd[1]); } return -1; }
     if(pid==0){
+        if(pfd[0]>=0) close(pfd[0]);
         setsid();
         pid_t p2=fork();
         if(p2>0) _exit(0);
+        if(pfd[1]>=0){
+            pid_t me=getpid();
+            if(write(pfd[1],&me,sizeof me)<0){}
+            close(pfd[1]);
+        }
         int fd=open("/dev/null",O_RDWR);
         if(fd>=0){ dup2(fd,0); dup2(fd,1); dup2(fd,2); if(fd>2) close(fd); }
         const char*home=getenv("HOME"); if(home){ if(chdir(home)!=0){} }
@@ -695,6 +959,14 @@ static void launch(App*a,Config*c){
         _exit(127);
     }
     waitpid(pid,NULL,0);   /* reap the intermediate child; grandchild -> init */
+
+    pid_t app=-1;
+    if(pfd[1]>=0) close(pfd[1]);
+    if(pfd[0]>=0){
+        if(read(pfd[0],&app,sizeof app)!=(ssize_t)sizeof app) app=-1;
+        close(pfd[0]);
+    }
+    return app;
 }
 
 /* ------------------------------------------------------------------ */
@@ -709,6 +981,15 @@ static void fill_circle(SDL_Renderer*r,float cx,float cy,float rad,Col c){
     int idx[SEG*3]; for(int i=0;i<SEG;i++){ idx[i*3]=0; idx[i*3+1]=i+1; idx[i*3+2]=i+2; }
     SDL_RenderGeometry(r,NULL,v,SEG+2,idx,SEG*3);
 }
+/* a thin outline circle: the "let go here" hint around the parked wheel */
+static void ring(SDL_Renderer*r,float cx,float cy,float rad,float thick,Col c){
+    const int SEG=96; SDL_SetRenderDrawColor(r,c.r,c.g,c.b,c.a);
+    for(float t=0;t<thick;t+=1.0f)
+        for(int i=0;i<SEG;i++){ float a=(float)(2*M_PI*i/SEG);
+            SDL_FRect d={cx+(rad+t)*cosf(a),cy+(rad+t)*sinf(a),1.5f,1.5f};
+            SDL_RenderFillRect(r,&d); }
+}
+
 static void fill_sector(SDL_Renderer*r,float cx,float cy,float r0,float r1,float a0,float a1,Col c){
     int seg=(int)((a1-a0)/0.04f)+2; if(seg<2)seg=2; if(seg>160)seg=160;
     int nv=(seg+1)*2; SDL_Vertex*v=malloc(nv*sizeof*v); SDL_FColor fc=tofc(c);
@@ -721,6 +1002,21 @@ static void fill_sector(SDL_Renderer*r,float cx,float cy,float r0,float r1,float
         idx[i*6]=b;idx[i*6+1]=b+1;idx[i*6+2]=b+2; idx[i*6+3]=b+1;idx[i*6+4]=b+3;idx[i*6+5]=b+2; }
     SDL_RenderGeometry(r,NULL,v,nv,idx,ni); free(v); free(idx);
 }
+static void fill_round_rect(SDL_Renderer*r,float x,float y,float w,float h,float rad,Col c){
+    if(rad>w/2)rad=w/2; if(rad>h/2)rad=h/2;
+    SDL_SetRenderDrawColor(r,c.r,c.g,c.b,c.a);
+    SDL_FRect mid={x,y+rad,w,h-2*rad}, top={x+rad,y,w-2*rad,rad}, bot={x+rad,y+h-rad,w-2*rad,rad};
+    SDL_RenderFillRect(r,&mid); SDL_RenderFillRect(r,&top); SDL_RenderFillRect(r,&bot);
+    fill_circle(r,x+rad,y+rad,rad,c);       fill_circle(r,x+w-rad,y+rad,rad,c);
+    fill_circle(r,x+rad,y+h-rad,rad,c);     fill_circle(r,x+w-rad,y+h-rad,rad,c);
+}
+
+static void stroke_rect(SDL_Renderer*r,float x,float y,float w,float h,float t,Col c){
+    SDL_SetRenderDrawColor(r,c.r,c.g,c.b,c.a);
+    SDL_FRect e[4]={{x,y,w,t},{x,y+h-t,w,t},{x,y,t,h},{x+w-t,y,t,h}};
+    for(int i=0;i<4;i++) SDL_RenderFillRect(r,&e[i]);
+}
+
 static void fill_tri(SDL_Renderer*r,float x0,float y0,float x1,float y1,float x2,float y2,Col c){
     SDL_FColor fc=tofc(c);
     SDL_Vertex v[3]={ {{x0,y0},fc,{0,0}}, {{x1,y1},fc,{0,0}}, {{x2,y2},fc,{0,0}} };
@@ -794,6 +1090,21 @@ static void dump_config(void){
 "# font=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\n"
 "font_px=50        # glyph atlas height; the sharpness ceiling for big text\n"
 "\n"
+"# --- dropping an app onto a workspace ---\n"
+"# Drag an app out of the wheel and let go over a workspace: it starts there,\n"
+"# and the wheel stays open. Needs swov on PATH, which is asked for the\n"
+"# workspace list and does the moving.\n"
+"drop=1           # 0 = off, the wheel then only launches\n"
+"drop_shrink=0.55 # how small the wheel goes while you drag\n"
+"drop_corner=top-left  # top-left, top-right or none. Letting go over the\n"
+"                 # wheel there cancels instead of opening anything\n"
+"drop_ms=130      # how long that takes\n"
+"drop_px=10       # movement before a press turns into a drag\n"
+"drop_focus=0     # 1 = also switch to that workspace\n"
+"drop_close_here=0  # dropping on the workspace you are on keeps the wheel\n"
+"                 # open too; 1 = close, so the new window is not behind it\n"
+"# swov=swov      # the binary to ask (a path works too)\n"
+"\n"
 "# --- launching / ordering ---\n"
 "# appwheel logs every app you launch to the history file below and shows the\n"
 "# most-recently-opened ones first. This is the default (sort=recent).\n"
@@ -824,7 +1135,7 @@ static void dump_config(void){
 
 static void usage(const char*a0){
     printf(
-"appwheel — a GTA-V-style radial (weapon-wheel) app launcher (Wayland & X11)\n"
+"appwheel " APPWHEEL_VERSION " (build " APPWHEEL_BUILD ") — a radial app launcher\n"
 "\n"
 "USAGE\n"
 "  %s [options] [key=value ...]\n"
@@ -843,6 +1154,7 @@ static void usage(const char*a0){
 "  -d, --dmenu         read newline-separated items from stdin, print the chosen\n"
 "                      one to stdout (a dmenu/bemenu/wofi-style picker)\n"
 "  -h, --help          show this help and exit\n"
+"  -v, --version       print the version and build id, and exit\n"
 "\n"
 "LAYOUT / INTERACTION\n"
 "  slots=11            wide, easy-to-hit app slots across the top arc\n"
@@ -866,6 +1178,18 @@ static void usage(const char*a0){
 "                      Default: your desktop's configured font (via fc-match).\n"
 "  font_px=50          atlas raster height; larger = crisper big text\n"
 "\n"
+"DROP ONTO A WORKSPACE\n"
+"  Drag an app out of the wheel and drop it on a workspace: it starts there\n"
+"  and the wheel stays open. Dropping it back on the wheel does nothing.\n"
+"  Needs swov on PATH — it supplies the workspace list and moves the window.\n"
+"  drop=1              0 = off\n"
+"  drop_shrink=0.55    how small the wheel goes while dragging\n"
+"  drop_corner=top-left  where it parks while dragging: top-left, top-right\n"
+"                      or none. Letting go over the wheel there cancels\n"
+"  drop_ms=130 drop_px=10\n"
+"  drop_focus=0        1 = also switch to that workspace\n"
+"  swov=swov           the binary to ask\n"
+"\n"
 "RENDERING\n"
 "  ssaa=2              full-scene supersampling 1..4 (smooths edges; alias: aa)\n"
 "  animate=1 anim_ms=90  crossfade names/icons when results change (0 = off)\n"
@@ -942,15 +1266,18 @@ int main(int argc,char**argv){
         if((!strcmp(argv[i],"-c")||!strcmp(argv[i],"--config"))&&i+1<argc)
             snprintf(cfgpath,sizeof cfgpath,"%s",argv[++i]);
         else if(!strcmp(argv[i],"-h")||!strcmp(argv[i],"--help")){ usage(argv[0]); return 0; }
+        else if(!strcmp(argv[i],"-v")||!strcmp(argv[i],"--version")){
+            printf("appwheel %s (build %s)\n",APPWHEEL_VERSION,APPWHEEL_BUILD); return 0; }
         else if(!strcmp(argv[i],"--dump-config")){ dump_config(); return 0; }
         else if(!strcmp(argv[i],"--list")) want_list=1;
         else if(!strcmp(argv[i],"--dmenu")||!strcmp(argv[i],"-d")) dmenu=1;
     }
     { char tmp[PATH_MAX]; expand_tilde(cfgpath,tmp,sizeof tmp); snprintf(cfgpath,sizeof cfgpath,"%s",tmp); }
-    config_load(&cfg,cfgpath);
+    sw_shared_apply("appwheel",config_set_shared,&cfg);   /* shared first */
+    config_load(&cfg,cfgpath);                            /* our own wins  */
     for(int i=1;i<argc;i++){
         if(!strcmp(argv[i],"-c")||!strcmp(argv[i],"--config")){ i++; continue; }
-        if(!strcmp(argv[i],"--list")||!strcmp(argv[i],"-h")||!strcmp(argv[i],"--help")||!strcmp(argv[i],"--dump-config")||!strcmp(argv[i],"--dmenu")||!strcmp(argv[i],"-d")) continue;
+        if(!strcmp(argv[i],"--list")||!strcmp(argv[i],"-h")||!strcmp(argv[i],"--help")||!strcmp(argv[i],"--dump-config")||!strcmp(argv[i],"--dmenu")||!strcmp(argv[i],"-d")||!strcmp(argv[i],"-v")||!strcmp(argv[i],"--version")) continue;
         if(!strcmp(argv[i],"--no-recent")||!strcmp(argv[i],"--all-apps")){ cfg.recent_first=0; continue; }
         char*a=argv[i]; while(*a=='-') a++;            /* accept --key=value too */
         char*eq=strchr(a,'='); if(eq){ *eq='\0'; config_set(&cfg,a,eq+1); }
@@ -971,10 +1298,10 @@ int main(int argc,char**argv){
         return 0;
     }
 
-    /* Identify as AppWheel so the compositor shows a proper name, and set the
+    /* Identify as appwheel so the compositor shows the same name everywhere,
        Wayland app_id / X11 WM_CLASS so float/center rules match. Before init. */
-    SDL_SetAppMetadata("AppWheel","1.0","org.appwheel.AppWheel");
-    SDL_SetHint(SDL_HINT_APP_ID,"appwheel");
+    SDL_SetAppMetadata(APP_ID,APPWHEEL_VERSION,"org.appwheel.appwheel");
+    SDL_SetHint(SDL_HINT_APP_ID,APP_ID);
 
     if(!SDL_Init(SDL_INIT_VIDEO)){ fprintf(stderr,"SDL_Init: %s\n",SDL_GetError()); return 1; }
 
@@ -990,7 +1317,7 @@ int main(int argc,char**argv){
     }
     SDL_WindowFlags wf=SDL_WINDOW_BORDERLESS|SDL_WINDOW_ALWAYS_ON_TOP;
     if(cfg.bg.a<255) wf|=SDL_WINDOW_TRANSPARENT;
-    SDL_Window*win=SDL_CreateWindow("AppWheel",winw,winh,wf);
+    SDL_Window*win=SDL_CreateWindow(APP_ID,winw,winh,wf);
     if(!win){ fprintf(stderr,"CreateWindow: %s\n",SDL_GetError()); return 1; }
     if(have_bounds) SDL_SetWindowPosition(win,dbounds.x,dbounds.y);
     SDL_Renderer*ren=SDL_CreateRenderer(win,NULL);
@@ -1039,10 +1366,26 @@ int main(int argc,char**argv){
         fn=build_filter(&apps,query,filt,cfg.slots,cfg.recent_first,&off,&selslot); anim0=SDL_GetTicks(); }while(0)
     REBUILD();
 
+    /* --- drag an app out of the wheel and onto a workspace ---
+       A press only arms it. If the pointer stays put the release launches, as
+       it always did; if it moves, the wheel shrinks out of the way and the
+       workspaces appear along the top. Dropping keeps the wheel open. */
+    int   dragging=0, press_down=0, press_item=-1, drop_ws=-1;
+    float press_x=0, press_y=0;
+    float wheel_scale=1.0f;               /* animated 1 -> drop_shrink */
+    float corner_t=0.0f;                  /* 0 = centred, 1 = parked in a corner */
+    Uint64 flash_at=0; int flash_ws=-1;   /* the tile that just took an app */
+    SDL_FRect wsp_rect[24];
+
     int page_dir=0; float page_speed=0;   /* for the paging indicators         */
     float mx=cfg.width/2.0f,my=cfg.height/2.0f;
     Uint64 last_page=0, blink0=SDL_GetTicks();
     int running=1, had_focus=0, page_armed=0, prev_region=0, chose=0;
+    int ov_started=0; float ov_lx=-1, ov_ly=-1;
+    /* SDL reports where the pointer already is as soon as the window is up.
+       That is not the user moving it, and it must not throw away the
+       selection we opened with. */
+    int mouse_live=0; float m0x=0, m0y=0; int mouse_seen=0;
     #define CHOOSE() do{ if(dmenu){ printf("%s\n",apps.v[filt[sel]].name); fflush(stdout); chose=1; } \
                          else launch(&apps.v[filt[sel]],&cfg); running=0; }while(0)
     float arc=cfg.arc_deg*(float)DEG; if(arc<60*DEG)arc=60*DEG; if(arc>330*DEG)arc=330*DEG;
@@ -1053,9 +1396,53 @@ int main(int argc,char**argv){
     while(running){
         int w,h; SDL_GetWindowSize(win,&w,&h);
         int ss=cfg.ssaa;
-        float mind=(w<h?w:h);
-        float cx=w/2.0f, cy=h/2.0f + cfg.y_offset*mind;
+
+        {   /* ease the wheel down to drop_shrink and into its corner, and back */
+            float want = dragging ? cfg.drop_shrink : 1.0f;
+            float step = cfg.drop_ms>0 ? 16.0f/(float)cfg.drop_ms : 1.0f;
+            if(wheel_scale<want){ wheel_scale+=step; if(wheel_scale>want)wheel_scale=want; }
+            else if(wheel_scale>want){ wheel_scale-=step; if(wheel_scale<want)wheel_scale=want; }
+
+            float cwant = dragging ? 1.0f : 0.0f;
+            if(corner_t<cwant){ corner_t+=step; if(corner_t>cwant)corner_t=cwant; }
+            else if(corner_t>cwant){ corner_t-=step; if(corner_t<cwant)corner_t=cwant; }
+        }
+
+        float mind=(w<h?w:h)*wheel_scale;
         float R=mind*cfg.radius, ri=R*0.46f, rc=ri*0.95f, rl=(R+ri)/2.0f;
+        float cx=w/2.0f, cy=h/2.0f + cfg.y_offset*mind;
+
+        /* Out of the way: parked in a corner the wheel stops covering the
+           workspaces you are aiming at, and becomes the place to drop an app
+           you have changed your mind about. */
+        int corner_left = !strcmp(cfg.drop_corner,"top-left");
+        int corner_on   = corner_left || !strcmp(cfg.drop_corner,"top-right");
+        if(corner_on && corner_t>0.0f){
+            float pad=R*0.12f+14.0f*cfg.ui_scale;
+            float tx=corner_left ? R+pad : (float)w-R-pad;
+            float ty=R+pad;
+            float e=corner_t*corner_t*(3.0f-2.0f*corner_t);   /* smoothstep */
+            cx += (tx-cx)*e;
+            cy += (ty-cy)*e;
+        }
+        int over_wheel = dragging &&
+            ((mx-cx)*(mx-cx)+(my-cy)*(my-cy)) < (R*1.12f)*(R*1.12f);
+
+        /* the workspace strip across the top, laid out every frame so it
+           follows a resize */
+        float tile_h=0, tile_y=0;
+        if(NWSP>0){
+            float margin=w*0.035f, gap=w*0.007f;
+            float avail=(float)w-2*margin-(float)(NWSP-1)*gap;
+            float tw=avail/(float)NWSP;
+            float maxw=230.0f*cfg.ui_scale;
+            if(tw>maxw){ tw=maxw;
+                margin=((float)w-(tw*(float)NWSP+(float)(NWSP-1)*gap))/2.0f; }
+            tile_h=(float)h*0.135f; if(tile_h>190.0f*cfg.ui_scale) tile_h=190.0f*cfg.ui_scale;
+            tile_y=(float)h*0.035f;
+            for(int i=0;i<NWSP;i++)
+                wsp_rect[i]=(SDL_FRect){ margin+(tw+gap)*(float)i, tile_y, tw, tile_h };
+        }
 
         int searching = query[0]!=0;
         int vis = fn? (fn<cfg.slots?fn:cfg.slots) : 0;
@@ -1076,7 +1463,7 @@ int main(int argc,char**argv){
            fires from wherever the cursor happened to open on (startup safety). --- */
         float half_arc = arc/2 + 2.0f*(float)DEG;     /* apps fill the arc; page below it */
         page_dir=0; page_speed=0;
-        if(page_armed){
+        if(page_armed && !dragging){
           float dx=mx-cx,dy=my-cy,dist=sqrtf(dx*dx+dy*dy);
           if(dist>rc){
               float pa=atan2f(dy,dx), dtop=norm_ang(pa-top);
@@ -1099,13 +1486,58 @@ int main(int argc,char**argv){
           }
         }
 
+        if(OV_OUT>=0) ov_poll(win);
+
         SDL_Event ev;
         while(SDL_PollEvent(&ev)){
             if(ev.type==SDL_EVENT_QUIT) running=0;
             else if(ev.type==SDL_EVENT_WINDOW_FOCUS_GAINED) had_focus=1;
-            else if(ev.type==SDL_EVENT_WINDOW_FOCUS_LOST){ if(cfg.close_on_focus_loss && had_focus) running=0; }
+            else if(ev.type==SDL_EVENT_WINDOW_FOCUS_LOST){
+                /* With the overview up, apps are being started behind us on
+                   purpose; each one takes the focus for a moment and that is
+                   not a reason to close. */
+                if(cfg.close_on_focus_loss && had_focus && !OV_READY) running=0;
+            }
             else if(ev.type==SDL_EVENT_MOUSE_MOTION){
                 mx=ev.motion.x; my=ev.motion.y;
+
+                if(!mouse_live){
+                    if(!mouse_seen){ mouse_seen=1; m0x=mx; m0y=my; }
+                    else if(fabsf(mx-m0x)>2.0f||fabsf(my-m0y)>2.0f) mouse_live=1;
+                    if(!mouse_live) continue;   /* the pointer has not moved yet */
+                }
+
+                if(press_down && !dragging && cfg.drop && press_item>=0){
+                    float dx=mx-press_x, dy=my-press_y;
+                    if(dx*dx+dy*dy > (float)(cfg.drop_px*cfg.drop_px)){
+                        if(OV_READY){ dragging=1; ov_send("drag on"); }
+                        else {
+                            wsp_load(&cfg);      /* first drag pays for the list */
+                            if(NWSP>0) dragging=1; else press_item=-1;
+                        }
+                    }
+                }
+                if(dragging){
+                    /* Over the wheel means "never mind": the overview is told
+                       there is no target, so a workspace underneath the wheel
+                       is not picked up by accident. */
+                    if(OV_READY){
+                        if(mx!=ov_lx||my!=ov_ly){         /* ask the overview */
+                            ov_lx=mx; ov_ly=my;
+                            if(over_wheel){ ov_send("hover -1 -1"); OV_TARGET[0]='\0'; }
+                            else ov_send("hover %.4f %.4f",mx/(float)w,my/(float)h);
+                        }
+                    } else {
+                        drop_ws=-1;
+                        if(!over_wheel)
+                            for(int i=0;i<NWSP;i++){
+                                SDL_FRect t=wsp_rect[i];
+                                if(mx>=t.x&&mx<t.x+t.w&&my>=t.y&&my<t.y+t.h){ drop_ws=i; break; }
+                            }
+                    }
+                    continue;                    /* no slot hover while dragging */
+                }
+
                 float dx=mx-cx,dy=my-cy,dist=sqrtf(dx*dx+dy*dy);
                 int region=1; float adtop=0;   /* 1=apps/center, 2=bottom off-icon, 3=on icon */
                 if(dist>rc){
@@ -1142,8 +1574,53 @@ int main(int argc,char**argv){
                 }
             }
             else if(ev.type==SDL_EVENT_MOUSE_BUTTON_DOWN){
-                if(ev.button.button==SDL_BUTTON_LEFT&&fn){ CHOOSE(); }
-                else if(ev.button.button==SDL_BUTTON_RIGHT) running=0;
+                if(ev.button.button==SDL_BUTTON_LEFT&&fn){
+                    press_down=1; press_x=ev.button.x; press_y=ev.button.y;
+                    /* the bottom zone is for paging; nothing is dragged there */
+                    press_item=(prev_region==2||prev_region==3)?-1:filt[sel];
+                }
+                else if(ev.button.button==SDL_BUTTON_RIGHT){
+                    if(dragging){ dragging=press_down=0; drop_ws=-1; press_item=-1;
+                                  if(OV_READY){ ov_send("drag off"); OV_TARGET[0]='\0'; } }
+                    else running=0;
+                }
+            }
+            else if(ev.type==SDL_EVENT_MOUSE_BUTTON_UP){
+                if(ev.button.button==SDL_BUTTON_LEFT){
+                    if(dragging){
+                        if(OV_READY){
+                            if(over_wheel) OV_TARGET[0]='\0';   /* changed my mind */
+                            if(OV_TARGET[0] && press_item>=0){
+                                pid_t p=launch(&apps.v[press_item],&cfg);
+                                /* Straight down the pipe we already have, so
+                                   sway knows where the window belongs before
+                                   the app has finished starting: it never
+                                   lands here first and nothing on this
+                                   workspace gets rearranged. */
+                                if(p>0) ov_send("assign %d %s",(int)p,OV_TARGET);
+                                adopt_to(&cfg,p,OV_TARGET,OV_BESIDE,OV_EDGE);
+                                /* the new window takes the focus as it opens;
+                                   the wheel is meant to stay in front of it */
+                                if(OV_HERE && cfg.drop_close_here) running=0;
+                                else ov_send("raise " APP_ID);
+                            }
+                            ov_send("drag off");
+                            OV_TARGET[0]='\0'; OV_HERE=0;
+                            OV_BESIDE[0]='\0'; OV_EDGE[0]='\0'; ov_lx=ov_ly=-1;
+                        } else if(!over_wheel && drop_ws>=0 && press_item>=0){
+                            pid_t p=launch(&apps.v[press_item],&cfg);
+                            wsp_adopt(&cfg,p,&WSP[drop_ws]);
+                            WSP[drop_ws].exists=1;      /* it will be there now */
+                            flash_ws=drop_ws; flash_at=SDL_GetTicks();
+                            if(WSP[drop_ws].focused && cfg.drop_close_here) running=0;
+                        }
+                        /* dropped on the wheel, or nowhere: nothing happens */
+                        dragging=0; drop_ws=-1;
+                    } else if(press_down && fn){
+                        CHOOSE();
+                    }
+                    press_down=0; press_item=-1;
+                }
             }
             else if(ev.type==SDL_EVENT_TEXT_INPUT){
                 strncat(query,ev.text.text,sizeof query-strlen(query)-1);
@@ -1151,7 +1628,12 @@ int main(int argc,char**argv){
             }
             else if(ev.type==SDL_EVENT_KEY_DOWN){
                 SDL_Keycode k=ev.key.key;
-                if(k==SDLK_ESCAPE){ if(query[0]){query[0]='\0';REBUILD();} else running=0; }
+                if(k==SDLK_ESCAPE){
+                    if(dragging){ dragging=press_down=0; drop_ws=-1; press_item=-1;
+                                  if(OV_READY){ ov_send("drag off"); OV_TARGET[0]='\0'; } }
+                    else if(query[0]){ query[0]='\0'; REBUILD(); }
+                    else running=0;
+                }
                 else if(k==SDLK_RETURN||k==SDLK_KP_ENTER){ if(fn){ CHOOSE(); } }
                 else if(k==SDLK_BACKSPACE){ int L=strlen(query);
                     if(L>0){ L--; while(L>0&&((unsigned char)query[L]&0xC0)==0x80)L--; query[L]='\0'; REBUILD(); } }
@@ -1278,6 +1760,46 @@ int main(int argc,char**argv){
             text_centered(ren,&font,cx,cy,cfg.title_px*ui,cfg.dim,"no matches");
         }
 
+        /* --- the workspaces, and the app hanging off the pointer --- */
+        if(!OV_READY && (dragging || (flash_ws>=0 && SDL_GetTicks()-flash_at<420))){
+            float rad=10.0f*ui;
+            for(int i=0;i<NWSP;i++){
+                SDL_FRect t=wsp_rect[i];
+                int hot=(i==drop_ws), lit=(i==flash_ws && SDL_GetTicks()-flash_at<420);
+                Col fill=hot||lit?cfg.hl:(WSP[i].exists?cfg.ring:cfg.bg);
+                if(!WSP[i].exists && !hot && !lit) fill.a=0;
+
+                if(fill.a) fill_round_rect(ren,t.x,t.y,t.w,t.h,rad,fill);
+                if(!WSP[i].exists && !hot && !lit)
+                    stroke_rect(ren,t.x,t.y,t.w,t.h,SDL_max(1.0f,1.5f*ui),cfg.dim);
+                else if(WSP[i].focused && !hot && !lit)
+                    stroke_rect(ren,t.x,t.y,t.w,t.h,SDL_max(1.0f,2.0f*ui),cfg.accent);
+
+                Col tc = hot||lit ? cfg.hltext : (WSP[i].exists?cfg.text:cfg.dim);
+                char num[16]; snprintf(num,sizeof num,"%d",WSP[i].num);
+                const char*lbl=wsp_label(&WSP[i]);
+                float ny = *lbl ? t.y+t.h*0.40f : t.y+t.h*0.5f;
+                text_centered(ren,&font,t.x+t.w/2,ny,cfg.title_px*ui*1.15f,tc,num);
+                if(*lbl){
+                    char cut[80]; fit_label(&font,cfg.count_px*ui,lbl,t.w*0.86f,cut,sizeof cut);
+                    text_centered(ren,&font,t.x+t.w/2,t.y+t.h*0.72f,cfg.count_px*ui,tc,cut);
+                }
+            }
+        }
+        if(over_wheel){                      /* the wheel says: let go here to stop */
+            Col o=cfg.dim; o.a=200;
+            ring(ren,cx,cy,R*1.12f,SDL_max(2.0f,2.0f*ui),o);
+        }
+        if(dragging && press_item>=0){
+            App*a=&apps.v[press_item];
+            float isz=cfg.icon_px*ui*1.15f;
+            SDL_Texture*ic=app_icon(ren,a,&cfg);
+            if(ic){ SDL_FRect d={mx-isz/2,my-isz/2,isz,isz};
+                    SDL_SetTextureAlphaMod(ic,235); SDL_RenderTexture(ren,ic,NULL,&d); }
+            char cut[160]; fit_label(&font,cfg.label_px*ui,a->name,300.0f*ui,cut,sizeof cut);
+            text_centered(ren,&font,mx,my+isz*0.72f,cfg.label_px*ui,cfg.text,cut);
+        }
+
         if(ss>1 && target){
             SDL_SetRenderScale(ren,1,1);
             SDL_SetRenderTarget(ren,NULL);
@@ -1286,8 +1808,14 @@ int main(int argc,char**argv){
             SDL_RenderTexture(ren,target,NULL,NULL);     /* fade is per-element now */
         }
         SDL_RenderPresent(ren);
+
+        if(!ov_started && cfg.overview && cfg.drop){
+            ov_started=1;             /* only now: the wheel is already up */
+            ov_spawn(&cfg);
+        }
         SDL_Delay(16);
     }
+    ov_stop();
 
     if(target)SDL_DestroyTexture(target);
     SDL_StopTextInput(win);
diff --git a/config.example b/config.example
new file mode 100644
index 0000000..0280aa1
--- /dev/null
+++ b/config.example
@@ -0,0 +1,90 @@
+# appwheel config  —  ~/.config/appwheel/config
+# One key=value per line. '#' starts a comment. Blank lines are ignored.
+# Every key here also works on the command line (key=value or --key=value);
+# command-line values win over the file. See `appwheel --help` for the full list.
+# The values below are the built-in defaults, so this file changes nothing
+# until you edit it.
+
+# --- window ---
+width=900
+height=900
+fullscreen=1     # cover the screen as a borderless overlay (0 = 900x900 window)
+close_on_focus_loss=0   # 1 = quit when the window loses focus (dmenu-style)
+
+# --- ring layout & paging ---
+slots=11          # wide, easy-to-hit slots across the TOP arc
+arc=240           # degrees of the ring used for apps (rest = paging zone)
+radius=0.44       # wheel size, as a fraction of the shorter screen side
+y_offset=0.10     # nudge the wheel down (apps sit up top, so this centers it)
+page_ms=110       # base ms per paged step at the SLOW end. Bigger = calmer
+                  # start. Paging eases in: slow where you enter the bottom
+                  # zone, fast toward straight-down.
+
+# --- rendering ---
+ssaa=2            # full-scene supersampling 1..4 (smooths edges). alias: aa
+animate=1         # subtle settle animation when the search results change
+anim_ms=90        # its duration (0 or animate=0 to turn it off)
+icons=1           # show .desktop icons if found (PNG/SVG/JPG/BMP)
+icon_px=46        # icon size (before ui_scale)
+
+# --- text ---
+ui_scale=1.0      # master text size. 1.3 = bigger, 0.85 = smaller.
+                  # aliases: font_scale, text_scale
+label_px=24       # app labels on the wheel   (all four are multiplied by
+title_px=25       # selected app name (center)  ui_scale, so tweak one or all)
+search_px=20      # the text you type (search box)
+count_px=20       # the "3 / 42" counter
+# By default appwheel uses your desktop's configured font (via fontconfig).
+# Set a .ttf path or a family name to override, e.g. font=JetBrains Mono
+# font=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
+font_px=50        # glyph atlas height; the sharpness ceiling for big text
+
+# --- dropping an app onto a workspace ---
+# Drag an app out of the wheel and let go over a workspace: it starts there,
+# and the wheel stays open. Needs swov on PATH — it supplies the workspace
+# list and moves the window once it appears.
+drop=1            # 0 = off, the wheel then only launches
+drop_shrink=0.55  # how small the wheel goes while you drag
+drop_corner=top-left   # where it parks while you drag: top-left, top-right
+                  # or none to keep it in the middle. Letting go over the
+                  # wheel there cancels, so it never fights the workspace
+                  # underneath it
+drop_ms=130       # how long that takes
+drop_px=10        # movement before a press turns into a drag
+drop_focus=0      # 1 = also switch to that workspace
+drop_close_here=0 # the wheel stays open after every drop, so you can keep
+                  # filling workspaces. 1 = close when you drop on the
+                  # workspace you are on, where the app would otherwise
+                  # open behind the wheel
+overview=0        # 1 = draw swov behind the wheel and drop onto the real
+                  # workspaces instead of the plain tiles. Needs sway rules:
+                  #   no_focus [app_id="swov-backdrop"]
+                  #   for_window [app_id="swov-backdrop"] floating enable, border none
+overview_debug=0  # 1 = print the conversation with the backdrop to stderr
+# swov=swov       # the binary to ask (a path works too)
+
+# --- launching / ordering ---
+# appwheel logs every app you launch to the history file below and shows the
+# most-recently-opened ones first. This is the default (sort=recent).
+sort=recent       # recent = most-recently-opened first (DEFAULT) | alpha = A-Z
+launcher=sh       # sh = run Exec= ; gtk-launch = launch by desktop id
+terminal=xterm    # used for Terminal=true entries when launcher=sh
+# history=~/.cache/appwheel/history   # the opened-apps log (auto-created)
+
+# which apps show / in what order (an id is the .desktop filename without
+# the extension; run `appwheel --list` to see them):
+# dirs=~/.local/share/applications:/usr/share/applications
+# include=firefox,code,gimp     # show ONLY these ids, in this exact order
+# exclude=htop,xterm            # hide these (matches id OR Name)
+
+# --- colors: #rrggbb or #rrggbbaa ---
+# bg alpha < ff => transparent window background (needs a compositor)
+bg=0d111700
+ring=1e2733f2
+ring2=26313ff2
+hl=cb9b00ff        # highlighted slot
+text=e8e8e8ff      # normal slot text
+hltext=141414ff    # text on the highlighted slot
+center=0d1117e6    # hub disc (keep some alpha so search text stays legible)
+accent=89afc4ff    # typed query text + active paging chevrons
+dim=5a6b7aff       # hints / counters / idle chevrons
diff --git a/sw_theme.h b/sw_theme.h
new file mode 100644
index 0000000..63d22c6
--- /dev/null
+++ b/sw_theme.h
@@ -0,0 +1,194 @@
+/* sw_theme.h — one look for swov, appwheel and swbr.
+ *
+ *     ${XDG_CONFIG_HOME:-~/.config}/sw/config
+ *
+ * key=value, '#' comments, same as every other config here. Keys written
+ * before any [section] go to all three programs; keys inside [swov],
+ * [appwheel] or [swbr] go to that one only.
+ *
+ * Outside a section only *role* names are understood (surface, accent, hl,
+ * ...). Each program translates them to its own keys, and roles it has no use
+ * for are dropped. Inside a section every key of that program works as well.
+ *
+ * Order: built-in defaults -> this file -> the program's own config -> the
+ * command line. The specific always wins over the shared.
+ *
+ * Drop-in header, no dependencies. Each program calls sw_shared_apply() with
+ * its own setter, right before it reads its own config file.
+ */
+#ifndef SW_THEME_H
+#define SW_THEME_H
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+#define SW_MAX_PAIRS 256
+#define SW_KEYLEN     48
+#define SW_VALLEN    256
+#define SW_MAXNATIVE   4
+
+/* ------------------------------------------------------------ role table
+ * NULL = this program has nothing for that role. Several native keys are
+ * separated by commas: swbr paints its focused workspace button with `hl`.
+ */
+typedef struct {
+    const char *role, *swov, *appwheel, *swbr;
+} SwRole;
+
+static const SwRole SW_ROLES[] = {
+    /* role          swov            appwheel   swbr                           */
+    { "bg",          "bg",           "bg",      NULL                           },
+    { "surface",     "tile",         "ring",    "bg"                           },
+    { "surface_alt", "tile_sel",     "ring2",   NULL                           },
+    { "surface_hi",  "tile_hover",   NULL,      "ws_visible_bg"                },
+    { "inset",       "mini_bg",      "center",  NULL                           },
+    { "card",        "card",         NULL,      "ws_bg"                        },
+    { "card_hi",     "card_hover",   NULL,      NULL                           },
+    { "card_focus",  "card_focus",   NULL,      NULL                           },
+    { "text",        "text",         "text",    "text"                         },
+    { "subtext",     "subtext",      NULL,      "ws_fg,ws_visible_fg"          },
+    { "dim",         "dim",          "dim",     "dim"                          },
+    { "hint",        "hint",         NULL,      NULL                           },
+    { "accent",      "accent",       "accent",  "accent,msg_info"              },
+    { "hl",          "hl",           "hl",      "hl,ws_focused_bg,mode_bg"     },
+    { "hltext",      "hltext",       "hltext",  "ws_focused_fg,mode_fg,ws_urgent_fg" },
+    { "current",     "current",      NULL,      NULL                           },
+    { "match",       "match",        NULL,      NULL                           },
+    { "warn",        NULL,           NULL,      "msg_warn"                     },
+    { "urgent",      "urgent",       NULL,      "urgent,ws_urgent_bg,msg_error" },
+    { "ok",          NULL,           NULL,      "running"                      },
+    { "outline",     "outline",      NULL,      "outline,separator_color"      },
+    { "shadow",      "shadow_color", NULL,      NULL                           },
+
+    { "font",          "font",      "font",     "font"     },
+    { "font_bold",     "font_bold", NULL,       NULL       },
+    { "ui_scale",      "ui_scale",  "ui_scale", "ui_scale" },
+    { "anim_ms",       "anim_ms",   "anim_ms",  "anim_ms"  },
+    { "corner_radius", "radius",    NULL,       "radius"   },
+    { "ssaa",          "ssaa",      "ssaa",     NULL       },
+    { "icons",         "icons",     "icons",    NULL       },
+    { "icon_px",       "icon_px",   "icon_px",  NULL       },
+};
+
+/* ------------------------------------------------------------------ path */
+
+static const char *sw_shared_path(void)
+{
+    static char path[512];
+    const char *xdg = getenv("XDG_CONFIG_HOME");
+    const char *home = getenv("HOME");
+    if (xdg && *xdg)       snprintf(path, sizeof path, "%s/sw/config", xdg);
+    else if (home && *home) snprintf(path, sizeof path, "%s/.config/sw/config", home);
+    else return NULL;
+    return path;
+}
+
+/* --------------------------------------------------------------- mapping */
+
+static int sw_ci_eq(const char *a, const char *b)
+{
+    for (; *a && *b; ++a, ++b)
+        if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return 0;
+    return *a == *b;
+}
+
+static const char *sw_role_for(const SwRole *r, const char *app)
+{
+    if (sw_ci_eq(app, "swov"))     return r->swov;
+    if (sw_ci_eq(app, "appwheel")) return r->appwheel;
+    if (sw_ci_eq(app, "swbr"))     return r->swbr;
+    return NULL;
+}
+
+/* Translate one shared key into the program's own keys.
+ * `scoped` means the key came from a [section], where the program's own key
+ * names are allowed too. Returns how many names were written. */
+static int sw_map(const char *app, const char *key, int scoped,
+                  char out[][SW_KEYLEN], int max)
+{
+    for (size_t i = 0; i < sizeof SW_ROLES / sizeof *SW_ROLES; ++i) {
+        if (!sw_ci_eq(SW_ROLES[i].role, key)) continue;
+        const char *nat = sw_role_for(&SW_ROLES[i], app);
+        if (!nat) return 0;                       /* not this program's business */
+        int n = 0;
+        while (*nat && n < max) {
+            const char *comma = strchr(nat, ',');
+            size_t len = comma ? (size_t)(comma - nat) : strlen(nat);
+            if (len >= SW_KEYLEN) len = SW_KEYLEN - 1;
+            memcpy(out[n], nat, len);
+            out[n][len] = '\0';
+            ++n;
+            if (!comma) break;
+            nat = comma + 1;
+        }
+        return n;
+    }
+    if (!scoped) return 0;                        /* global: roles only */
+    snprintf(out[0], SW_KEYLEN, "%s", key);       /* [section]: pass through */
+    return 1;
+}
+
+/* ---------------------------------------------------------------- reader */
+
+static char *sw_trim(char *s)
+{
+    while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') ++s;
+    char *e = s + strlen(s);
+    while (e > s && (e[-1] == ' ' || e[-1] == '\t' || e[-1] == '\r' || e[-1] == '\n')) --e;
+    *e = '\0';
+    return s;
+}
+
+/* Read the shared file and hand every setting that concerns `app` to `set`,
+ * in file order, so a later line overrides an earlier one. Returns 1 if the
+ * file was there. */
+static int sw_shared_apply(const char *app,
+                           void (*set)(void *ud, const char *key, const char *val),
+                           void *ud)
+{
+    const char *path = sw_shared_path();
+    if (!path) return 0;
+    FILE *f = fopen(path, "r");
+    if (!f) return 0;
+
+    char line[1024], section[SW_KEYLEN] = "";
+    int  mine = 1;                                /* before any section: all */
+
+    while (fgets(line, sizeof line, f)) {
+        char *s = sw_trim(line);
+        if (!*s || *s == '#' || *s == ';') continue;
+
+        if (*s == '[') {                          /* [swov] */
+            char *end = strchr(s, ']');
+            if (!end) continue;
+            *end = '\0';
+            snprintf(section, sizeof section, "%s", sw_trim(s + 1));
+            mine = sw_ci_eq(section, app) || sw_ci_eq(section, "all") ||
+                   sw_ci_eq(section, "global");
+            if (sw_ci_eq(section, "all") || sw_ci_eq(section, "global")) section[0] = '\0';
+            continue;
+        }
+        if (!mine) continue;
+
+        char *eq = strchr(s, '=');
+        if (!eq) continue;
+        *eq = '\0';
+        char *k = sw_trim(s), *v = sw_trim(eq + 1);
+        char *hash = strchr(v, '#');              /* trailing comment */
+        if (hash && hash > v && (hash[-1] == ' ' || hash[-1] == '\t')) {
+            *hash = '\0';
+            v = sw_trim(v);
+        }
+        if (!*k) continue;
+
+        char native[SW_MAXNATIVE][SW_KEYLEN];
+        int n = sw_map(app, k, section[0] != '\0', native, SW_MAXNATIVE);
+        for (int i = 0; i < n; ++i) set(ud, native[i], v);
+    }
+    fclose(f);
+    return 1;
+}
+
+#endif /* SW_THEME_H */