Nimbin[12]?Sway / SWOv / commits / 05e32ea

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

Add multi monitor switch layout

Christian Immanuel · 2026-08-29 11:35 · 05e32ea890e6d6c2b730f5bdf176bc4e24d23a2d

 README.md         |  27 ++++++--
 config.example    |  15 +++--
 sw_config.example |  60 ++++++++++++++++++
 swov.c            | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 4 files changed, 267 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md
index c92afe4..5983dde 100644
--- a/README.md
+++ b/README.md
@@ -85,10 +85,11 @@ when there is one.
 left or right quarter of another it inserts there, pushing the occupied run up
 by one.
 
-**Ghost slots** are the free numbers 0–10. Dragging a window inside swov
-brings them out; a drag from swas does not, unless `drop_ghosts=1` — they add
-a dozen tiles to the grid, and the workspaces you are aiming at shrink to a
-third just as you start aiming. They appear when a workspace drag
+**Ghost slots** are the free numbers 0–10. They come out for a drag from swas as
+well as for one inside swov, so dropping an app on a workspace that does not
+exist yet works the same either way. `drop_ghosts=0` keeps them out of a swas
+drag, at the cost of not being able to make a workspace that way — they do add
+tiles to the grid, and the real ones shrink to fit. They appear when a workspace drag
 starts, or when a dragged window leaves its own workspace, and the tiles glide
 aside to make room. Drop on one to give a workspace that number, or to create a
 workspace from a single window.
@@ -197,6 +198,23 @@ swas uses all three: `overview=1` puts swov behind the wheel, dragging an
 app asks it where the pointer is — including which side of which window — and
 dropping starts the app exactly there.
 
+## More than one monitor
+
+With a second screen attached, the bottom left holds a small map of the
+monitors, laid out the way sway has them arranged, each with its name inside.
+It has that place to itself — the tiles are laid out in what is left over — so
+it is in the same spot whichever screen you are looking at, and it never
+collides with anything. `outputs_map_w` is how wide it is as a share of the
+tile area (0.18 by default), `outputs_map=0` turns it off.
+
+Click a monitor and the overview shows *its* workspaces instead. The one you
+are looking at is filled in, the one sway is really on keeps a ring, and while
+those differ the header says `viewing DP-1` in a highlighted box — everything
+you do from there, every key and every drop, lands on that screen. Clicking
+the one you are already looking at goes back to following sway.
+
+`swov output=DP-1` starts on a given screen.
+
 ## Workspace usage
 
 Every switch goes through swov, so it stamps the time as it goes: the workspace
@@ -274,6 +292,7 @@ lists them. The file above is read after it, so swov's own config always wins.
 | `blur` | how soft `--backdrop` is drawn, `0`–`3` |
 | `drop_ghosts` | offer the free numbers while an app is dragged over the backdrop |
 | `cpu`, `cpu_min`, `cpu_full` | the load dot, and the range it covers |
+| `outputs_map`, `outputs_map_w`, `output` | the monitor map, its width, and which screen to start on |
 | `track`, `usage_dots`, `dot_count`, `dot_px` | usage recording and its dot scale |
 | `start_selection` | `workspace`, `none` or `window` |
 | `cols`, `rows` | force the grid; default picks the largest tiles |
diff --git a/config.example b/config.example
index 3ab5ab2..37583ea 100644
--- a/config.example
+++ b/config.example
@@ -34,10 +34,17 @@ cpu_full=4         # cpu_min gets one faint dot. swbr measures it and
                    # leaves it in $XDG_RUNTIME_DIR/swbr-cpu; without swbr
                    # running there is nothing to show and nothing is drawn
 
-drop_ghosts=0      # --backdrop only: 1 = while an app is being dragged over
-                   # the overview, offer the free workspace numbers as extra
-                   # tiles. They cost the real tiles two thirds of their size
-                   # just as you start aiming, so this is off
+drop_ghosts=1      # --backdrop only: while an app is dragged over the
+                   # overview, offer the free workspace numbers as extra
+                   # tiles, the same as dragging a window inside swov. 0 =
+                   # keep the grid at full size, and no new workspaces from
+                   # a swas drag
+outputs_map=1      # with a second screen: a map of the monitors, bottom left,
+                   # in their real arrangement. Click one to look at its
+                   # workspaces. It keeps that place to itself, so it is
+                   # always in the same spot
+outputs_map_w=0.18 # how wide, as a share of the tile area
+# output=DP-1      # start on that screen instead of the one sway is on
 blur=2             # --backdrop only: how soft the overview is drawn while
                    # something else has the foreground. 0 = off, 3 = softest.
                    # It sharpens by itself while an app is being dragged over
diff --git a/sw_config.example b/sw_config.example
new file mode 100644
index 0000000..50bde8e
--- /dev/null
+++ b/sw_config.example
@@ -0,0 +1,60 @@
+# ~/.config/sw/config — one look for swov, swas and swbr.
+#
+# Keys written here, before any [section], go to all three. Role names are
+# translated to each program's own keys; roles a program has no use for are
+# dropped. sw_theme.h holds the whole table.
+#
+# Read after the built-in defaults and before each program's own config, so
+# ~/.config/swov/config and the command line still win over anything here.
+
+# --- the palette ---------------------------------------------------------
+# role            swov            swas     swbr
+bg=0d1117cc     # bg              bg           —          the scrim
+surface=1e2733f2 # tile           ring         bg         the panel itself
+surface_alt=26313ff2 # tile_sel   ring2        —
+surface_hi=2b3644f2  # tile_hover —            ws_visible_bg
+inset=11171f9e  # mini_bg         center       —
+card=33404ff7   # card            —            ws_bg
+card_hi=46566af7 # card_hover     —            —
+card_focus=3b4a5bf7 # card_focus  —            —
+
+text=e8e8e8ff   # text            text         text
+subtext=b3c0cdff # subtext        —            ws_fg, ws_visible_fg
+dim=5a6b7aff    # dim             dim          dim
+hint=a7b5c4ff   # hint            —            —
+
+accent=89afc4ff # accent          accent       accent, msg_info
+hl=cb9b00ff     # hl              hl           hl, ws_focused_bg, mode_bg
+hltext=141414ff # hltext          hltext       ws_focused_fg, mode_fg
+current=4fb3a5ff # current        —            —
+match=b58ae0ff  # match           —            —
+warn=cb9b00ff   # —               —            msg_warn
+urgent=e0533cff # urgent          —            urgent, ws_urgent_bg, msg_error
+ok=3ddc84ff     # —               —            running
+outline=0a0e1499 # outline        —            outline, separator_color
+shadow=00000073 # shadow_color    —            —
+
+# --- everything else -----------------------------------------------------
+# font=                 leave unset and each program asks fc-match
+ui_scale=1.0
+anim_ms=160
+corner_radius=14 # swov radius, swbr radius. swas's `radius` is the size
+                 # of the wheel, so it is left alone
+ssaa=2           # swov, swas
+icons=1          # swov, swas
+icon_px=40
+
+# --- per program ---------------------------------------------------------
+# Inside a section that program's own key names work as well.
+
+[swov]
+# blur=2
+# cpu=1
+
+[swas]
+# radius=0.44
+# slots=8
+
+[swbr]
+# height=30
+# ws_cpu=1
diff --git a/swov.c b/swov.c
index cb10d0c..ff71131 100644
--- a/swov.c
+++ b/swov.c
@@ -240,6 +240,10 @@ typedef struct {
     char  hints_pos[16];
     int   show_empty;
     int   all_outputs;
+    int   outputs_map;       /* the little map of the monitors, bottom left  */
+    float outputs_map_w;     /* how wide, as a share of the tile area        */
+    char  output[64];        /* start looking at this screen, not the one
+                                sway is on                                   */
     int   show_header;
     int   show_hints;
     int   quit_on_focus_loss;
@@ -282,7 +286,7 @@ static Cfg cfg_defaults(void)
     c.back_scope = 0;        /* global: go back to where I just was */
     c.blur       = 2;
     c.cpu        = 1;
-    c.drop_ghosts = 0;
+    c.drop_ghosts = 1;
     c.cpu_idle   = 0.01f;    /* cores; under this nothing is happening */
     c.cpu_min    = 0.25f;    /* cores, not a share of the machine */
     c.cpu_full   = 4.0f;
@@ -299,6 +303,8 @@ static Cfg cfg_defaults(void)
 
     c.show_empty         = 1;
     c.all_outputs        = 0;
+    c.outputs_map        = 1;
+    c.outputs_map_w      = 0.18f;
     c.show_header        = 1;
     c.show_hints         = 1;
     c.quit_on_focus_loss = 1;
@@ -388,6 +394,9 @@ static void cfg_set(Cfg *c, const char *k, const char *v)
     else if (key_is(k,"hints_pos"))  str_set(c->hints_pos, sizeof(c->hints_pos), 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,"outputs_map"))   c->outputs_map = atoi(v) != 0;
+    else if (key_is(k,"outputs_map_w")) c->outputs_map_w = (float)atof(v);
+    else if (key_is(k,"output"))        str_set(c->output, sizeof(c->output), v);
     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;
@@ -2223,7 +2232,42 @@ 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 FOCUSED_OUTPUT[64] = {0};   /* the output whose workspaces we show */
+static char HOME_OUTPUT[64]    = {0};   /* the one sway is really on           */
+static bool PINNED_OUTPUT;              /* ...and we are looking elsewhere     */
+
+/* Every monitor, in the arrangement sway has them in, so the overview can
+ * draw a little map of the desk and let you step onto another screen. */
+typedef struct {
+    char name[64];
+    int  x, y, w, h;
+    bool focused;
+    SDL_FRect box;                      /* where it sits in the map */
+} Out;
+static Out  OUTS[8];
+static int  NOUTS;
+static SDL_FRect MAP_RECT;              /* the free corner it is drawn in */
+
+static void outputs_reload(void)
+{
+    NOUTS = 0;
+    JV *outs = sway_query(IPC_GET_OUTPUTS);
+    if (!outs || outs->type != J_ARR) { jfree(outs); return; }
+
+    for (int i = 0; i < outs->count && NOUTS < (int)SDL_arraysize(OUTS); ++i) {
+        const JV *o = outs->items[i];
+        if (!jbool(o, "active", true)) continue;
+        const JV *r = jget(o, "rect");
+        Out *d = &OUTS[NOUTS++];
+        str_set(d->name, sizeof(d->name), jstr(o, "name", ""));
+        d->x = jint(r, "x", 0);      d->y = jint(r, "y", 0);
+        d->w = jint(r, "width", 0);  d->h = jint(r, "height", 0);
+        d->focused = jbool(o, "focused", false);
+        if (d->focused) str_set(HOME_OUTPUT, sizeof(HOME_OUTPUT), d->name);
+        d->box = (SDL_FRect){ 0, 0, 0, 0 };
+    }
+    jfree(outs);
+}
 static char CUR_OUTPUT[64]     = {0};   /* output the tree walk is inside */
 
 static void model_free(void)
@@ -2395,7 +2439,9 @@ static bool model_reload(void)
 
     /* sway reports no focused workspace for a moment after a rename; keeping
      * the previous output beats falling back to "every output" */
-    if (focused[0]) str_set(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), focused);
+    if (focused[0] && !PINNED_OUTPUT)
+        str_set(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), focused);
+    outputs_reload();
 
     JV *tree = sway_query(IPC_GET_TREE);
     if (!tree) { jfree(wsr); return false; }
@@ -2917,6 +2963,32 @@ static void layout(void)
     float ah = (float)RH - HEADER_H - FOOTER_H - m;
     if (aw < 40.0f || ah < 40.0f || NWS == 0) return;
 
+    /* The monitor map keeps a fixed place in the bottom left, always the
+     * same one whichever screen is being shown. Handing it whatever cell the
+     * grid happened to leave over meant it moved about, and vanished
+     * entirely when the grid came out full. The tiles get what is left. */
+    MAP_RECT = (SDL_FRect){ 0, 0, 0, 0 };
+    if (C.outputs_map && NOUTS > 1) {
+        int minx = OUTS[0].x, miny = OUTS[0].y;
+        int maxx = OUTS[0].x + OUTS[0].w, maxy = OUTS[0].y + OUTS[0].h;
+        for (int i = 1; i < NOUTS; ++i) {
+            if (OUTS[i].x < minx) minx = OUTS[i].x;
+            if (OUTS[i].y < miny) miny = OUTS[i].y;
+            if (OUTS[i].x + OUTS[i].w > maxx) maxx = OUTS[i].x + OUTS[i].w;
+            if (OUTS[i].y + OUTS[i].h > maxy) maxy = OUTS[i].y + OUTS[i].h;
+        }
+        float dw = (float)(maxx - minx), dh = (float)(maxy - miny);
+        if (dw > 1.0f && dh > 1.0f) {
+            float mw = aw * SDL_clamp(C.outputs_map_w, 0.05f, 0.45f);
+            float mh = mw * dh / dw;
+            float cap = ah * 0.30f;
+            if (mh > cap) { mh = cap; mw = mh * dw / dh; }
+            MAP_RECT = (SDL_FRect){ ax, ay + ah - mh, mw, mh };
+            ah -= mh + gap;
+            if (ah < 60.0f) { MAP_RECT = (SDL_FRect){ 0, 0, 0, 0 }; ah = (float)RH - HEADER_H - FOOTER_H - m; }
+        }
+    }
+
     /* aspect of the screen we are mirroring */
     float aspect = 16.0f / 9.0f;
     for (int i = 0; i < NWS; ++i)
@@ -3808,7 +3880,10 @@ static void rebuild_chrome(void)
 
     if (C.show_header) {
         char buf[256];
-        snprintf(buf, sizeof(buf), "%.63s   %d window%s on %d workspace%s",
+        /* Looking at a screen you are not on is worth saying outright: every
+         * key and every drop from here lands over there. */
+        snprintf(buf, sizeof(buf), "%s%.63s   %d window%s on %d workspace%s",
+                 PINNED_OUTPUT ? "viewing " : "",
                  FOCUSED_OUTPUT[0] ? FOCUSED_OUTPUT : "sway",
                  NWIN, NWIN == 1 ? "" : "s", NWS, NWS == 1 ? "" : "s");
         T_HEADER = text_make(F_HINT, buf);
@@ -4299,6 +4374,59 @@ static void draw_drop_indicator(void)
     }
 }
 
+/* The monitors as they sit on the desk, scaled into whatever corner of the
+ * grid was left over. Click one to look at its workspaces instead; the one
+ * you are looking at is filled, the one sway is really on keeps a ring. */
+static void draw_outputs_map(void)
+{
+    if (MAP_RECT.w < 16.0f || NOUTS < 2) return;
+
+    int minx = OUTS[0].x, miny = OUTS[0].y;
+    int maxx = OUTS[0].x + OUTS[0].w, maxy = OUTS[0].y + OUTS[0].h;
+    for (int i = 1; i < NOUTS; ++i) {
+        if (OUTS[i].x < minx) minx = OUTS[i].x;
+        if (OUTS[i].y < miny) miny = OUTS[i].y;
+        if (OUTS[i].x + OUTS[i].w > maxx) maxx = OUTS[i].x + OUTS[i].w;
+        if (OUTS[i].y + OUTS[i].h > maxy) maxy = OUTS[i].y + OUTS[i].h;
+    }
+    float dw = (float)(maxx - minx), dh = (float)(maxy - miny);
+    if (dw < 1.0f || dh < 1.0f) return;
+
+    /* fit the whole desk inside its box, aspect kept */
+    float pad = C.pad * SC * 0.5f;
+    float aw = MAP_RECT.w - 2.0f * pad, ah = MAP_RECT.h - 2.0f * pad;
+    float sc = SDL_min(aw / dw, ah / dh);
+    float bw = dw * sc, bh = dh * sc;
+    float bx = MAP_RECT.x + (MAP_RECT.w - bw) * 0.5f;
+    float by = MAP_RECT.y + (MAP_RECT.h - bh) * 0.5f;
+
+    float rad = SDL_max(2.0f, 4.0f * SC);
+    for (int i = 0; i < NOUTS; ++i) {
+        Out *d = &OUTS[i];
+        SDL_FRect r = { bx + (float)(d->x - minx) * sc + 1.0f * SC,
+                        by + (float)(d->y - miny) * sc + 1.0f * SC,
+                        (float)d->w * sc - 2.0f * SC,
+                        (float)d->h * sc - 2.0f * SC };
+        d->box = r;
+        if (r.w < 4.0f || r.h < 4.0f) continue;
+
+        bool showing = strcmp(d->name, FOCUSED_OUTPUT) == 0;
+        fill_round_rect(r, rad, showing ? mix(C.tile_sel, C.hl, 0.25f)
+                                        : with_alpha(C.tile, 0.75f));
+        if (d->focused)
+            stroke_round_rect(r, rad, SDL_max(1.0f, 2.0f * SC),
+                              with_alpha(C.current, 0.9f));
+
+        Tex t = text_make_fit(F_HINT, d->name, (int)(r.w - 4.0f * SC));
+        if (t.t) {
+            tex_draw(t, r.x + (r.w - (float)t.w) * 0.5f,
+                     r.y + (r.h - (float)t.h) * 0.5f,
+                     showing ? C.hltext : C.subtext);
+            tex_free(&t);
+        }
+    }
+}
+
 static void render(void)
 {
     SDL_SetRenderDrawBlendMode(REN, SDL_BLENDMODE_NONE);
@@ -4362,6 +4490,8 @@ static void render(void)
 
     if (drag_active) draw_drop_indicator();
 
+    draw_outputs_map();
+
     float m = C.margin * SC;
     float head_x0 = (float)RW, head_x1 = 0.0f;
     bool  head_top = pos_is_top(C.header_pos);
@@ -4371,7 +4501,15 @@ static void render(void)
                            : (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 (PINNED_OUTPUT) {                       /* not the screen you are on */
+            float p = 6.0f * SC;
+            SDL_FRect box = { head_x0 - p, y - p * 0.5f,
+                              (float)T_HEADER.w + 2.0f * p, (float)T_HEADER.h + p };
+            fill_round_rect(box, box.h * 0.35f, with_alpha(C.hl, 0.22f));
+            stroke_round_rect(box, box.h * 0.35f, SDL_max(1.0f, 1.5f * SC),
+                              with_alpha(C.hl, 0.85f));
+        }
+        tex_draw(T_HEADER, head_x0, y, PINNED_OUTPUT ? C.hl : C.hint);
     }
 
     if (T_QUERY.t) {
@@ -5071,6 +5209,25 @@ static void handle_mouse_press(const SDL_MouseButtonEvent *b)
 
     if (editing) end_edit(true);                   /* a click elsewhere commits */
 
+    /* the map of the monitors: step onto one and look at its workspaces */
+    if (b->button == SDL_BUTTON_LEFT && MAP_RECT.w > 0.0f) {
+        for (int i = 0; i < NOUTS; ++i) {
+            SDL_FRect r = OUTS[i].box;
+            if (r.w <= 0.0f || mx < r.x || mx >= r.x + r.w ||
+                my < r.y || my >= r.y + r.h) continue;
+            if (strcmp(OUTS[i].name, FOCUSED_OUTPUT) == 0) {
+                PINNED_OUTPUT = false;             /* back to following sway */
+                str_set(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), HOME_OUTPUT);
+            } else {
+                PINNED_OUTPUT = strcmp(OUTS[i].name, HOME_OUTPUT) != 0;
+                str_set(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), OUTS[i].name);
+            }
+            reload_model();
+            dirty = true;
+            return;
+        }
+    }
+
     if (b->button == SDL_BUTTON_LEFT && ws_idx >= 0 && win_idx < 0) {
         SDL_FRect tb = WSS[ws_idx].title_hit;
         if (mx >= tb.x && mx < tb.x + tb.w && my >= tb.y && my < tb.y + tb.h) {
@@ -5398,9 +5555,11 @@ static bool backdrop_command(char *line)
         return true;
     }
     if (!strcmp(line, "drag on")) {
-        /* Offering every free number adds a dozen tiles to the grid, and the
-         * workspaces you are actually aiming at shrink to a third of their
-         * size just as you start aiming. Off unless asked for. */
+        /* The free numbers become ghost tiles to drop on, exactly as they do
+         * when a window is dragged inside swov — dropping an app on a
+         * workspace that does not exist yet has to work the same either way.
+         * They do cost the real tiles some size; drop_ghosts=0 keeps them
+         * out of a drag that comes from swas. */
         if (C.drop_ghosts) set_ghosts(0, 10);
         BLUR_WANT = 0.0f;
         return true;
@@ -5956,7 +6115,13 @@ int main(int argc, char **argv)
 
     usage_sync();
     sway_subscribe_events();
+    if (C.output[0]) {                 /* asked to look at another screen */
+        str_set(FOCUSED_OUTPUT, sizeof(FOCUSED_OUTPUT), C.output);
+        PINNED_OUTPUT = true;
+    }
     if (!model_reload()) die("could not read the sway tree");
+    if (C.output[0] && HOME_OUTPUT[0] && !strcmp(C.output, HOME_OUTPUT))
+        PINNED_OUTPUT = false;
     mark("sway tree");
     cpu_load();                  /* whatever swbr last measured */
     select_current_workspace();