Nimbin[12]?Sway / SwBr / commits / 31a043b

SwBr git · main

SwBr - status bar for sway

sway wayland c bar · first commit 2026-08-27 · last commit 2026-10-05 (4 days ago) · synced 3 days ago · upstream: github.com/nimbin2/SwBr

C 95.9% Markdown 3.7%
git clone https://git.christianimmanuel.de/sway/SwBr.gitwget https://git.christianimmanuel.de/sway/SwBr/archive/SwBr.tar.gz

Fix bar with for workspaces And maybe focus loose bug

Christian Immanuel · 2026-09-20 13:47 · 31a043b6c6b65ab0967450899df574cae156c7f0

 Makefile                |  26 +-
 README.md               |  98 +++++-
 config.advanced.example |  43 ++-
 config.example          |  19 ++
 swbr.c                  | 807 ++++++++++++++++++++++++++++++++++++++++++------
 5 files changed, 892 insertions(+), 101 deletions(-)

diff --git a/Makefile b/Makefile
index 339c434..fc1b100 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,6 @@
-PREFIX  ?= $(HOME)
+PREFIX  ?= /usr
 BINDIR  ?= $(PREFIX)/bin
+DATADIR ?= $(PREFIX)/share/swbr
 CONFDIR ?= $(HOME)/.config/swbr
 
 CC      ?= cc
@@ -26,17 +27,32 @@ debug: swbr.c stb_truetype.h
 		-DSWBR_BUILD='"$(BUILD)-dbg"' -o swbr-debug swbr.c $(LDFLAGS) $(LDLIBS)
 
 install: swbr
-	install -d $(BINDIR)
-	install -m755 swbr $(BINDIR)/swbr
+	install -Dm755 swbr $(DESTDIR)$(BINDIR)/swbr
+	install -Dm644 config.example $(DESTDIR)$(DATADIR)/config.example
+	install -Dm644 config.advanced.example $(DESTDIR)$(DATADIR)/config.advanced.example
+	@echo "installed $(DESTDIR)$(BINDIR)/swbr"
+	@echo "examples  $(DESTDIR)$(DATADIR)/"
 
+# Your own copy: edit it freely, an install never touches it.
 config:
 	install -d $(CONFDIR)
 	[ -f $(CONFDIR)/config ] || install -m644 config.example $(CONFDIR)/config
 
+# The shipped example, live: every install updates what you are running.
+# Link the advanced one with  make link EXAMPLE=config.advanced.example
+EXAMPLE ?= config.example
+link:
+	install -d $(CONFDIR)
+	ln -sfn $(DATADIR)/$(EXAMPLE) $(CONFDIR)/config
+	@echo "$(CONFDIR)/config -> $(DATADIR)/$(EXAMPLE)"
+
 uninstall:
-	rm -f $(BINDIR)/swbr
+	rm -f $(DESTDIR)$(BINDIR)/swbr
+	rm -f $(DESTDIR)$(DATADIR)/config.example
+	rm -f $(DESTDIR)$(DATADIR)/config.advanced.example
+	-rmdir $(DESTDIR)$(DATADIR) 2>/dev/null || true
 
 clean:
 	rm -f swbr swbr-debug
 
-.PHONY: all debug install config uninstall clean
+.PHONY: all debug install config link uninstall clean
diff --git a/README.md b/README.md
index ef71fea..767f1fc 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,9 @@ what is going on.
 ```sh
 sudo apt install build-essential libwayland-dev
 make
-make install          # ~/.local/bin, or PREFIX=/usr/local
+sudo make install     # /usr/bin, examples in /usr/share/swbr
 make config           # optional: config.example -> ~/.config/swbr/config
+make link             # or: config -> the installed example, kept in sync
 ```
 
 `libwayland-client` is the only library it links. The layer-shell protocol glue
@@ -110,12 +111,43 @@ already knows.
 
 `slim_align=1` (the default) gives each mark its cell's whole place, inset by
 the same padding the text had — so folding changes the height of the bar and nothing else.
-Starting folded, the open layout is worked out once with nothing drawn, so the
-marks are the right size from the first frame. `slim_align=0` packs them
+Every folded frame works the open layout out first with nothing drawn, so the
+marks are right from the first frame, `--slim` included. `slim_align=0` packs them
 against the right edge instead.
 
+`ws_slots=10` (the default) puts the numbers 1–10 in the bar whether or not
+they exist. A free number is the same button in the same place, `ws_empty_alpha`
+faint, and clicking it opens that workspace. `0` shows only open ones.
+
+Folded and lined up, the workspace marks are the open bar's own buttons — the
+same set, the same left edges, the same widths — so you click the same spot in
+either mode. Brightness says used from unused. `slim_ws_w` forces a width;
+`slim_ws_slots` only applies to the packed strip (`slim_align=0`).
+
+`slim_bar_segs=10` splits a folded gauge into ten blocks, so four lit blocks
+reads as forty percent. `slim_bar_gap` is the space between them,
+`slim_bar_pad` the space either side of the whole gauge, and `0` segments
+gives one solid bar. A cell too narrow to show the dividers halves the count;
+give it a `slim_w` instead — on a gauge that wins over the alignment and grows
+about the cell's middle, which is what a two-digit battery needs.
+
 `signals=0` leaves a plain strip.
 
+A floating bar (`min_width` > 0) sizes itself to its content. It grows at once,
+but ignores getting narrower by less than `shrink_px=24` and waits `shrink_s=30`
+before a bigger narrowing — it is centred, so every width change moves
+everything on it.
+
+## Redrawing
+
+`redraw_ms=250` holds cell updates and merges whatever lands in the same window
+into one frame — every frame is a round trip through the compositor, and ten
+cells on their own timers would otherwise wake the bar three times a second.
+
+Anything coming from sway is exempt. A workspace switch is drawn on the next
+frame, so switching back and forth stays visible instead of the two changes
+merging into one late frame.
+
 ## Hover
 
 `NAME.hover=` is what a cell shows while the pointer is on it, with the same
@@ -153,7 +185,9 @@ Monday, and `‹ ›` at either end of the title row page the month; it opens on
 the current one every time.
 
 `NAME.hover_cmd=` is the general form: any command's output, run once when the
-hover starts rather than on a timer. The panel is as tall as the output has
+hover starts rather than on a timer. It is wrapped in `timeout 2`, since it
+runs on the frame the pointer arrives and a command that blocks would block
+the whole bar. The panel is as tall as the output has
 lines either way.
 
 ```
@@ -220,10 +254,18 @@ ws_cpu=1  ws_cpu_interval=3  ws_cpu_idle=0.01  ws_cpu_min=0.25  ws_cpu_full=4
 ```
 
 The numbers go to `$XDG_RUNTIME_DIR/swbr-cpu` so swov can draw the same thing
-without measuring anything itself.
+without measuring anything itself. The last thirty-two windows to be focused
+go to `$XDG_RUNTIME_DIR/swbr-focus` in the same spirit: swov opens and closes
+in a moment and can never watch focus itself, so its `tab` key reads the order
+from here.
 
 ## Other monitors
 
+Workspaces are ordered by number, not in the order sway lists them — that is
+grouped by output, so with a second monitor the numbers come out interleaved
+and which way round depends on which cable went in first. `ws_sort=0` keeps
+sway's order.
+
 `ws_other=1` adds the workspaces from your other screens as short pills on the
 bar's bottom edge, in smaller, dimmer type. Folded they take a slot like any
 other, but never this screen's emphasis.
@@ -269,11 +311,13 @@ panels, and a `poweroff` at one percent. Either goes to
 | `min_width`, `align_x`, `side_margin` | a floating bar sizes itself to its content |
 | `radius` | corner radius; the corners at the screen edge stay square |
 | `outputs`, `ws_other` | which monitors get a bar, and whose workspaces show |
-| `ws_names`, `ws_inset`, `ws_radius` | numbers or names, pills or blocks |
+| `ws_names`, `ws_inset`, `ws_radius`, `ws_border` | numbers or names, pills or blocks, outlined or not |
+| `ws_slots`, `ws_empty_alpha` | show every number, open or not, and how faint |
+| `slim_ws_w`, `slim_bar_segs`, `slim_bar_gap` | folded slot width, gauge blocks |
 | `font`, `font_alt`, `ui_scale`, `text_px` | text |
 | `markup` | the pango subset: `<span foreground=..>`, `<b>`, `&amp;` |
 | `hide_key`, `collapsed_px`, `anim_ms` | folding |
-| `signals`, `slim_align`, `slim_ws_slots` | what the folded strip shows |
+| `signals`, `slim_align`, `slim_ws_slots`, `slim_bar_segs` | what the folded strip shows |
 | `status_command` | i3bar-style status, used when no cells are configured |
 
 Colours and fonts for swbr, swov and swas can be set once in
@@ -282,9 +326,47 @@ Colours and fonts for swbr, swov and swas can be set once in
 the table. Keys before any section go to all three, a `[swbr]` section to swbr
 only. This config is read afterwards and wins, the command line wins over that.
 
+`make install` also drops the shipped examples in `/usr/share/swbr/`.
+`make link` points your config at one of them, so the next install is the
+config you are running — handy if you want to follow the examples rather than
+keep your own copy. Edits you make there are overwritten on install.
+
 ## Notes
 
-- Talks to the sway IPC socket directly: no `swaymsg`, no `jq`.
+- Talks to the sway IPC socket directly: no `swaymsg`, no `jq`. Every reply
+  costs the compositor about twelve times its own size and never gives it
+  back, so swbr asks as rarely as it can: a workspace switch is applied from
+  the event itself rather than by fetching the list again, and only a change
+  in the set of workspaces — one created, emptied, renamed — asks for a new
+  one. The tree is
+  the expensive question, so it is asked for only when sway has said
+  something changed, never more often than `tree_min_s` (5), and shared
+  between the load sampler and every `source=window` cell. Only the changes that
+  can move a window between workspaces count — new, close, move, floating —
+  so a shell retitling itself on every prompt does not. An idle session, or a
+  busy terminal, fetches it once at startup and then not again — polling it every couple of
+  seconds made sway grow by about six times each reply and never give it
+  back, which is a compositor bug but not one worth leaning on.
+- Cell updates are held for `redraw_ms` (250, or `refresh=` in seconds) and
+  merged into one frame. Ten
+  cells on their own timers wake the bar three times a second, each with one
+  number changed, and every frame is a full round trip through the
+  compositor; a clock a quarter of a second late is not a clock anyone
+  notices. Anything moving — an animation, a hover, a scrolling cell —
+  bypasses it.
+- A frame that comes out identical to the one on screen is never sent: the
+  new buffer is compared against the old and the commit skipped. Ten cells on
+  short timers wake the bar constantly, but the volume is usually still 80%.
+- Frames are capped at one per 8 ms per bar, so nothing upstream — an
+  animation, a configure, a cell whose width wobbles — can turn into an
+  unbounded stream of requests. `SWBR_TRACE=1 swbr` prints, every five
+  seconds, how many commits, resizes, buffer pools, regions and configures
+  went to the compositor, and on a second line how many IPC queries went to
+  sway and how many kilobytes came back. `no_tree=1` stops swbr asking for the
+  tree at all, which switches off the load dots and every `source=window`
+  cell. `--minimal` goes further: workspaces only, no cells, no tree, no
+  keyboard grab, no fifo — a bare layer surface, for finding out which part
+  of swbr a compositor is unhappy about.
 - The cursor comes from `cursor-shape-v1`, so there is no cursor theme to
   load and no cursor surface to draw. On a compositor without it the pointer
   simply keeps whatever shape it had.
diff --git a/config.advanced.example b/config.advanced.example
index 95871e5..8412abc 100644
--- a/config.advanced.example
+++ b/config.advanced.example
@@ -24,6 +24,10 @@ min_width=800      # 0 = span the whole output. Otherwise the bar floats and
                    # sizes itself to its content: never narrower than this,
                    # never wider than the screen. Cells appearing (charging,
                    # docker) grow it instead of being cut off
+shrink_px=24       # it grows at once but ignores getting narrower by less than
+                   # this, so 99% -> 100% or a temperature losing a digit does
+                   # not move the whole (centred) bar
+shrink_s=30        # a bigger narrowing has to hold this long first
 align_x=center     # where the floating bar sits: center | left | right
 radius=10          # corner radius. The two corners that touch the screen edge
                    # stay square, so the bar sits flush against it
@@ -38,6 +42,14 @@ ws_inset=0         # gap above and below the buttons. 0 = the button spans
                    # the full bar height, top to bottom
 ws_radius=0        # 0 = square corners. -1 = follow radius, or a full pill
                    # when ws_inset > 0
+ws_border=0        # outline thickness in px around every button. 0 = none,
+                   # which is the plain look: the gap between buttons is the
+                   # only division
+ws_slots=10        # always show the numbers 1..10, open or not. A free number
+                   # is the same button in the same place, just faint, and
+                   # clicking one opens that workspace. It also means the bar
+                   # is the same width folded and open. 0 = only what is open
+ws_empty_alpha=35  # how visible a free number is, percent
 ws_click=1
 scroll_workspace=0
 mode_show=1        # show the binding mode (resize, ..)
@@ -108,6 +120,14 @@ separator_color=3a4654ff
 # While a message is up it takes over the cmus cell, so the title turns into
 # the warning and turns back into the title afterwards. Click it to dismiss.
 msg_target=cmus
+redraw_ms=250      # or refresh=0.25, the same thing in seconds. Try 1000 if
+                   # you would rather the bar redrew once a second.
+                   # Cell updates are held this long and merged into one
+                   # frame. Every frame is a round trip through the
+                   # compositor, and ten cells on their own timers otherwise
+                   # wake the bar three times a second. Anything from sway --
+                   # a workspace switch, urgency -- skips the hold and is
+                   # drawn on the next frame
 msg_timeout=8      # seconds; 0 = stays until cleared
 msg_flash=1        # folded bar turns fully into the message colour
 msg_info=89afc4
@@ -164,6 +184,9 @@ volume.cmd=alsa_control volume get
 volume.interval=2
 volume.button2=alsa_control mute toggle
 volume.slim=bar
+volume.slim_w=56   # folded: a gauge needs room for its ten blocks, and this
+                   # cell is only two characters wide. Wins over slim_align,
+                   # growing about the middle so it stays where the text was
 volume.slim_color=a9d8f5   # light baby blue, its own thing on the strip
 
 # --- docker and mpv share one group: docker.sep=0 joins them ---------------
@@ -259,6 +282,7 @@ battery.color=e1eeff
 battery.warn=<30:cecb00
 battery.crit=<12:ff2222
 battery.slim=bar
+battery.slim_w=56  # same again: wide enough to count the blocks
 
 # --- shouting when it is going flat -----------------------------------------
 battery.alert=30,20,10,5   # percentages. Each fires once on the way down and
@@ -292,7 +316,19 @@ signals=1          # the folded strip still says something: every workspace
                    # keeps a fixed slot on the left (slot 3 is always
                    # workspace 3), the clock becomes twelve dots, and cells
                    # with a percentage become little gauges
-slim_ws_slots=10   # at most this many workspace slots
+slim_ws_slots=10   # only for slim_align=0, the packed strip: this many
+                   # slots. Lined up, the strip is exactly the open bar's own
+                   # buttons — same set, same place, same width — so set
+                   # ws_slots above to choose how many
+slim_bar_segs=10   # folded gauges are split into this many blocks, so four
+                   # lit blocks reads as forty percent without measuring it.
+                   # 0 = one solid bar. Halves itself in a cell too narrow to
+                   # show the dividers — give that cell a slim_w instead
+slim_bar_gap=2     # px between those blocks
+slim_bar_pad=3     # px either side of a folded gauge. A gauge takes its
+                   # whole cell, so without this it runs edge to edge
+slim_ws_w=0        # folded slot width in px. 0 = each slot copies its own
+                   # button in the open bar, so nothing moves or resizes
 start_collapsed=0  # 1 = start folded. Command line: swbr --slim
 # Without the mouse. Bind either one to a key in your sway config:
 #   bindsym $mod+b exec swbr --toggle      (also --fold / --unfold)
@@ -325,6 +361,8 @@ dim=5a6b7aff
 accent=89afc4ff     # visible-but-not-focused workspace tick
 hl=cb9b00ff         # focused workspace tick in the folded strip
 urgent=e0533cff
+ws_sort=1          # order them by number. sway lists them grouped by output,
+                   # so two monitors otherwise give 1 2 5 8 3 10
 ws_other=1         # 1 = also show the workspaces of your other monitors, as
                    # short pills on the baseline so they are clearly not this
                    # screen's
@@ -332,6 +370,9 @@ ws_cpu=1           # a dot on each workspace button showing how much
                    # processor time the windows on it are using. Folded, the
                    # workspace slot fills from the bottom instead
 ws_cpu_interval=3  # seconds between samples
+# tree_min_s=5     # the load dots and source=window cells need sway's tree.
+                   # It is fetched only when sway reports a change, and never
+                   # more often than this. no_tree=1 stops asking entirely
 ws_cpu_idle=0.01   # in cores. Under this a workspace counts as doing
                    # nothing and stays blank. Above it, but under ws_cpu_min,
                    # one faint dot: a player, a polling script, something
diff --git a/config.example b/config.example
index 4a50c38..b39d2fd 100644
--- a/config.example
+++ b/config.example
@@ -23,15 +23,34 @@ accent=89afc4
 hl=cb9b00
 urgent=e0533c
 
+refresh=0.25       # how often the bar may redraw, in seconds. Cell updates
+                   # inside one window are merged into a single frame; 1 is
+                   # fine for a bar of clocks and gauges. Animations and
+                   # hovers ignore it
+
 # --- workspaces -------------------------------------------------------------
 ws_names=1         # 1 = names, 0 = numbers only
 ws_other=1         # also the workspaces of your other monitors, as short pills
 ws_cpu=1           # a column of dots: how busy each workspace is, in cores
+ws_border=0        # 1 = a thin outline around every button. 0 = none, just
+                   # the gap between them
+ws_slots=10        # always show the numbers 1..10, open or not; a free one
+                   # is faint and clicking it opens that workspace. 0 = only
+                   # the ones that are open. The folded strip follows this
+ws_empty_alpha=35  # how visible a free number is, percent
 
 # --- folding ----------------------------------------------------------------
 hide_key=space     # hover the bar and press this to fold it to a thin strip
 collapsed_px=5
 slim_align=1       # folded, each mark keeps its cell's place and width
+slim_ws_slots=10   # only with slim_align=0: slots in the packed strip
+slim_bar_segs=10   # folded gauges are split into ten blocks, so four lit
+                   # blocks reads as forty percent. 0 = one solid bar
+slim_bar_gap=2     # px between those blocks
+slim_bar_pad=3     # px either side of a folded gauge, so it does not sit
+                   # flush against its neighbours
+slim_ws_w=0        # folded slot width in px. 0 = each slot copies its own
+                   # button in the open bar, so nothing moves or resizes
 
 # --- a plain cell: a command, every few seconds ------------------------------
 cell=clock
diff --git a/swbr.c b/swbr.c
index 77a4f71..095c5b7 100644
--- a/swbr.c
+++ b/swbr.c
@@ -670,10 +670,82 @@ static char *sway_request(uint32_t type, const char *payload)
     return body;
 }
 
+/* Everything swbr asks the compositor to do, counted. Sway allocates for
+ * each of these, so if its heap is growing while swbr runs, the answer is in
+ * these numbers. SWBR_TRACE=1 prints them every five seconds. */
+static struct {
+    unsigned commit, resize, pool, region, ack;
+    unsigned ipc_tree, ipc_ws, ipc_other;
+    unsigned long ipc_bytes;
+} wl_count;
+static bool wl_trace;
+
+static void wl_trace_tick(void)
+{
+    if (!wl_trace) return;
+    static uint32_t next;
+    uint32_t now = now_ms();
+    if (next && (int32_t)(now - next) < 0) return;
+    next = now + 5000;
+    fprintf(stderr, "swbr: per 5s — commits %u, resizes %u, pools %u, "
+                    "regions %u, configures %u\n"
+                    "swbr:           ipc: %u tree, %u workspaces, %u other, "
+                    "%lu KiB back\n",
+            wl_count.commit, wl_count.resize, wl_count.pool,
+            wl_count.region, wl_count.ack,
+            wl_count.ipc_tree, wl_count.ipc_ws, wl_count.ipc_other,
+            wl_count.ipc_bytes / 1024);
+    memset(&wl_count, 0, sizeof(wl_count));
+}
+
+static JV *sway_query(uint32_t type);
+
+/* The whole tree is the most expensive thing swbr asks sway for — it builds
+ * and serialises the lot every time. The cpu sampler wants it, and so does
+ * every window cell, so they share one for a couple of seconds rather than
+ * each asking. Fewer, larger allocations on the compositor's side, and less
+ * of swbr's own time spent parsing the same JSON twice. */
+static int      no_tree_flag;      /* mirrors cfg.no_tree, set once config
+                                     parsing is done                       */
+static int      tree_min_ms = 5000;   /* never more often than this */
+static JV      *tree_cache;
+static uint32_t tree_cache_at;
+
+/* Polling this every couple of seconds made sway grow by roughly six times
+ * the size of each reply and never give it back — a compositor bug, but one
+ * swbr was leaning on twenty thousand times a night. It is asked for only
+ * when sway has said something changed, and never more often than
+ * tree_min_s. An idle session fetches it once, at startup, and then not
+ * again. */
+static bool     tree_dirty = true;
+
+static const JV *sway_tree_cached(void)
+{
+    if (no_tree_flag) return NULL;     /* for finding out whether it is us */
+
+    uint32_t now = now_ms();
+    if (tree_cache && !tree_dirty) return tree_cache;
+    if (tree_cache && (int32_t)(now - tree_cache_at) < tree_min_ms) return tree_cache;
+
+    jfree(tree_cache);
+    tree_cache = sway_query(IPC_GET_TREE);
+    tree_cache_at = now;
+    tree_dirty = false;
+    return tree_cache;
+}
+
+static void sway_tree_forget(void) { tree_dirty = true; }
+
 static JV *sway_query(uint32_t type)
 {
     char *body = sway_request(type, NULL);
     if (!body) return NULL;
+
+    if (type == IPC_GET_TREE)            wl_count.ipc_tree++;
+    else if (type == IPC_GET_WORKSPACES) wl_count.ipc_ws++;
+    else                                 wl_count.ipc_other++;
+    wl_count.ipc_bytes += strlen(body);
+
     JV *v = jparse(body);
     free(body);
     return v;
@@ -713,7 +785,7 @@ static bool sway_subscribe_events(void)
     sway_evt_fd = sway_connect();
     if (sway_evt_fd < 0) return false;
 
-    const char *payload = "[\"workspace\",\"mode\"]";
+    const char *payload = "[\"workspace\",\"mode\",\"window\"]";
     char hdr[14];
     memcpy(hdr, "i3-ipc", 6);
     uint32_t l = (uint32_t)strlen(payload), t = IPC_SUBSCRIBE;
@@ -746,6 +818,41 @@ static bool sway_subscribe_events(void)
  * name is picked out of "mode" events on the way past. */
 static char sway_mode[128] = "default";
 
+static bool ws_stale;            /* an event changed the set, not just the focus */
+static bool ws_apply_event(const JV *cur);
+
+/* The focus order, most recent first, left where swov can find it. Small
+ * enough that writing it on every focus change costs nothing worth counting;
+ * it is one line per window and never more than a few dozen. */
+#define FOCUS_MAX 32
+static int focus_ring[FOCUS_MAX];
+static int focus_n;
+
+static void focus_seen(int con_id)
+{
+    for (int i = 0; i < focus_n; ++i)             /* already known: move up */
+        if (focus_ring[i] == con_id) {
+            memmove(focus_ring + 1, focus_ring, (size_t)i * sizeof(int));
+            focus_ring[0] = con_id;
+            goto write;
+        }
+    if (focus_n < FOCUS_MAX) focus_n++;
+    memmove(focus_ring + 1, focus_ring, (size_t)(focus_n - 1) * sizeof(int));
+    focus_ring[0] = con_id;
+
+write:;
+    const char *rt = getenv("XDG_RUNTIME_DIR");
+    if (!rt || !*rt) return;
+    char path[512], tmp[540];
+    snprintf(path, sizeof(path), "%s/swbr-focus", rt);
+    snprintf(tmp, sizeof(tmp), "%s.tmp", path);
+    FILE *f = fopen(tmp, "w");
+    if (!f) return;
+    for (int i = 0; i < focus_n; ++i) fprintf(f, "%d\n", focus_ring[i]);
+    fclose(f);
+    if (rename(tmp, path) != 0) unlink(tmp);
+}
+
 static bool sway_events_pending(void)
 {
     if (sway_evt_fd < 0) return false;
@@ -767,14 +874,59 @@ static bool sway_events_pending(void)
         if (rlen && !read_all(sway_evt_fd, body, rlen)) { free(body); break; }
         body[rlen] = 0;
 
-        /* 0x80000002 = mode, 0x80000000 = workspace */
+        /* 0x80000002 = mode, 0x80000000 = workspace, 0x80000003 = window */
         if (rtype == 0x80000002u) {
             JV *v = jparse(body);
             if (v) str_set(sway_mode, sizeof(sway_mode), jstr(v, "change", "default"));
             jfree(v);
         }
+
+        if (rtype == 0x80000000u) {                       /* workspace */
+            tree_dirty = true;
+            JV *v = jparse(body);
+            const char *ch = v ? jstr(v, "change", "") : "";
+            /* A switch only moves the highlight, so it can be applied from
+             * the event. Anything else changes the set of workspaces and
+             * needs a fresh list — and once one event in a batch has said
+             * so, a later focus must not talk us out of it. Switching to a
+             * brand new workspace arrives as init and then focus: believing
+             * the focus left the new workspace out of the list entirely and
+             * lit up whichever one happened to match. */
+            if (!(!strcmp(ch, "focus") || !strcmp(ch, "urgent")) ||
+                !ws_apply_event(jget(v, "current")))
+                ws_stale = true;
+            jfree(v);
+        }
+
+        if (rtype == 0x80000003u) {
+            /* Which window was last focused, and the one before that. swbr
+             * is the only thing here that watches all day, so it is the only
+             * thing that can know; swov reads the list to offer "the app you
+             * were just in". */
+            JV *fv = jparse(body);
+            if (fv && !strcmp(jstr(fv, "change", ""), "focus")) {
+                int id = jint(jget(fv, "container"), "id", 0);
+                if (id > 0) focus_seen(id);
+            }
+            jfree(fv);
+        }
+
+        if (rtype == 0x80000003u) {
+            /* Only the changes that move a window between workspaces matter
+             * here. A title change does not — and a terminal retitling
+             * itself on every prompt would otherwise keep asking sway for
+             * the tree, which is the thing worth not doing. */
+            JV *v = jparse(body);
+            const char *ch = v ? jstr(v, "change", "") : "";
+            if (!strcmp(ch, "new") || !strcmp(ch, "close") ||
+                !strcmp(ch, "move") || !strcmp(ch, "floating"))
+                tree_dirty = true;
+            jfree(v);
+        }
+
+        /* a window event on its own does not redraw the workspace buttons */
+        if (rtype != 0x80000003u) any = true;
         free(body);
-        any = true;
     }
     return any;
 }
@@ -791,6 +943,7 @@ typedef struct {
 
 static Ws  ws_list[MAX_WORKSPACES];
 static int ws_count = 0;
+static int ws_sort_flag = 1;    /* mirrors cfg.ws_sort, which is declared later */
 
 /* "3:web" -> "3" ; a name without a leading number keeps the name */
 static void ws_make_label(Ws *w, bool full_names)
@@ -808,6 +961,34 @@ static void ws_make_label(Ws *w, bool full_names)
     else             str_set(w->label, sizeof(w->label), w->name);
 }
 
+/* Every IPC reply costs the compositor roughly twelve times its own size,
+ * and never gives it back. A workspace switch does not need a new list: the
+ * event says which one is current, and that is enough to move the focus and
+ * the visible flag about. Only a change in the set of workspaces — one
+ * created, emptied, renamed — needs asking again. */
+static bool ws_apply_event(const JV *cur)
+{
+    if (!cur || cur->type != J_OBJ || !ws_count) return false;
+
+    const char *name = jstr(cur, "name", "");
+    const char *out  = jstr(cur, "output", "");
+    if (!*name) return false;
+
+    int hit = -1;
+    for (int i = 0; i < ws_count; ++i)
+        if (!strcmp(ws_list[i].name, name)) { hit = i; break; }
+    if (hit < 0) return false;                   /* new to us: ask properly */
+
+    if (!*out) out = ws_list[hit].output;
+
+    for (int i = 0; i < ws_count; ++i) {
+        ws_list[i].focused = (i == hit);
+        if (!strcmp(ws_list[i].output, out)) ws_list[i].visible = (i == hit);
+    }
+    ws_list[hit].urgent = jbool(cur, "urgent", false);
+    return true;
+}
+
 static void ws_reload(bool full_names)
 {
     JV *r = sway_query(IPC_GET_WORKSPACES);
@@ -827,6 +1008,20 @@ static void ws_reload(bool full_names)
         ws_make_label(w, full_names);
     }
     jfree(r);
+
+    /* sway lists them by output, so with a second monitor the numbers come
+     * out interleaved — 1 2 5 on one, 3 4 on the other — and which order
+     * depends on which cable went in first. Sort by number, so 3 is always
+     * after 2 wherever it lives. */
+    if (ws_sort_flag)
+        for (int i = 1; i < ws_count; ++i)
+            for (int j = i; j > 0; --j) {
+                Ws *a = &ws_list[j - 1], *b = &ws_list[j];
+                bool swap = a->num > b->num ||
+                            (a->num == b->num && strcmp(a->name, b->name) > 0);
+                if (!swap) break;
+                Ws t = *a; *a = *b; *b = t;
+            }
 }
 
 /* ------------------------------------------------------ cpu per workspace
@@ -994,11 +1189,10 @@ static void cpu_write_cache(void)
 /* one sample; the first call only records, the ones after it measure */
 static void cpu_sample(void)
 {
-    JV *tree = sway_query(IPC_GET_TREE);
+    const JV *tree = sway_tree_cached();
     if (!tree) return;
     cpu_nwin = 0;
     cpu_collect_windows(tree, -1);
-    jfree(tree);
 
     DIR *d = opendir("/proc");
     if (!d) return;
@@ -1185,6 +1379,14 @@ typedef struct {
 
     /* workspaces */
     int  ws_names, ws_pad, ws_gap, ws_min_w, ws_click, scroll_workspace, ws_inset;
+    int  ws_border;              /* outline thickness in px, 0 = none        */
+    int  ws_slots;               /* show this many numbers, used or not      */
+    int  ws_empty_alpha;         /* how visible a free number is, percent    */
+    int  slim_bar_segs;          /* folded gauges: this many segments, 0 = solid */
+    int  slim_bar_gap;           /* px between those segments                */
+    int  slim_bar_pad;           /* px either side of a folded gauge         */
+    int  slim_ws_w;              /* folded slot width, 0 = the widest button */
+    int  shrink_px, shrink_s;    /* when a floating bar may get narrower    */
     float ws_radius;
     int  mode_show;
 
@@ -1202,6 +1404,8 @@ typedef struct {
     int  cell_count;
     char msg_fifo[PATH_MAX], msg_target[32];
     int  msg_timeout, msg_flash, msg_panel;
+    int  redraw_ms;              /* cell updates are held this long and
+                                    merged into one frame                 */
     Col  msg_info, msg_warn, msg_error;
     int  cell_gap, cell_inset, slim_ws_slots;
     float cell_radius;
@@ -1218,15 +1422,18 @@ typedef struct {
     /* colors */
     Col bg, text, dim, accent, hl, urgent, outline, running, slim_warm;
     int ws_other;                /* show other monitors' workspaces too  */
+    int ws_sort;                 /* by number, not in sway's output order */
     int slim_align;              /* folded marks sit under their full-bar
                                     positions, rather than packed right   */
+    int no_tree;                 /* never ask sway for the whole tree    */
+    int tree_min_s;              /* and never more often than this       */
     int ws_cpu;                  /* the load dot on a workspace button   */
     int ws_cpu_interval;         /* seconds between samples              */
     float ws_cpu_idle;           /* under this, nothing is happening at all */
     float ws_cpu_min, ws_cpu_full;  /* the scale, in cores                  */
     Col ws_bg, ws_fg, ws_focused_bg, ws_focused_fg;
     Col ws_visible_bg, ws_visible_fg, ws_urgent_bg, ws_urgent_fg;
-    Col mode_bg, mode_fg;
+    Col mode_bg, mode_fg, ws_border_col;
 } Config;
 
 static void config_defaults(Config *c)
@@ -1246,6 +1453,15 @@ static void config_defaults(Config *c)
     c->ws_pad = 11;
     c->ws_gap = 4;
     c->ws_inset = 0;
+    c->ws_border = 0;            /* off: the bar looked busier, not clearer */
+    c->ws_slots = 10;
+    c->ws_empty_alpha = 35;
+    c->slim_bar_segs = 10;
+    c->slim_bar_gap = 2;
+    c->slim_bar_pad = 3;
+    c->slim_ws_w = 0;
+    c->shrink_px = 24;
+    c->shrink_s = 30;
     c->ws_min_w = 0;
     c->ws_click = 1;
     c->scroll_workspace = 0;
@@ -1270,6 +1486,7 @@ static void config_defaults(Config *c)
     c->msg_timeout = 8;
     c->msg_flash = 1;
     c->msg_panel = 3;
+    c->redraw_ms = 250;
     c->msg_info  = (Col){ 0x89, 0xaf, 0xc4, 0xff };
     c->msg_warn  = (Col){ 0xcb, 0x9b, 0x00, 0xff };
     c->msg_error = (Col){ 0xe0, 0x53, 0x3c, 0xff };
@@ -1294,9 +1511,12 @@ static void config_defaults(Config *c)
     c->slim_warm     = (Col){ 0xe8, 0x96, 0x3c, 0xff };
     c->outline       = (Col){ 0x0a, 0x0e, 0x14, 0x99 };
     c->ws_bg         = (Col){ 0x1e, 0x27, 0x33, 0x00 };
+    c->ws_border_col = (Col){ 0x4a, 0x58, 0x6a, 0x9a };
     c->ws_fg         = (Col){ 0xb3, 0xc0, 0xcd, 0xff };
     c->ws_other        = 0;
+    c->ws_sort         = 1;
     c->slim_align      = 1;
+    c->tree_min_s      = 5;
     c->ws_cpu          = 1;
     c->ws_cpu_interval = 3;
     c->ws_cpu_idle     = 0.01f;   /* one percent of one core */
@@ -1497,6 +1717,12 @@ static void config_set(Config *c, const char *k, const char *v)
     if (!strcmp(k, "cell_gap")) { c->cell_gap = atoi(v); return; }
     if (!strcmp(k, "cell_inset")) { c->cell_inset = atoi(v); return; }
     if (!strcmp(k, "slim_ws_slots")) { c->slim_ws_slots = atoi(v); return; }
+    if (!strcmp(k, "slim_bar_segs")) { c->slim_bar_segs = atoi(v); return; }
+    if (!strcmp(k, "slim_bar_gap")) { c->slim_bar_gap = atoi(v); return; }
+    if (!strcmp(k, "slim_bar_pad")) { c->slim_bar_pad = atoi(v); return; }
+    if (!strcmp(k, "slim_ws_w")) { c->slim_ws_w = atoi(v); return; }
+    if (!strcmp(k, "shrink_px")) { c->shrink_px = atoi(v); return; }
+    if (!strcmp(k, "shrink_s")) { c->shrink_s = atoi(v); return; }
     if (!strcmp(k, "cell_radius")) { c->cell_radius = (float)atof(v); return; }
     if (!strcmp(k, "separator")) { str_set(c->separator, sizeof(c->separator), v); return; }
     if (!strcmp(k, "separator_color")) { parse_color(v, &c->separator_color); return; }
@@ -1517,6 +1743,9 @@ static void config_set(Config *c, const char *k, const char *v)
     else if (!strcmp(k, "ws_gap")) c->ws_gap = atoi(v);
     else if (!strcmp(k, "ws_min_w")) c->ws_min_w = atoi(v);
     else if (!strcmp(k, "ws_inset")) c->ws_inset = atoi(v);
+    else if (!strcmp(k, "ws_border")) c->ws_border = atoi(v);
+    else if (!strcmp(k, "ws_slots")) c->ws_slots = atoi(v);
+    else if (!strcmp(k, "ws_empty_alpha")) c->ws_empty_alpha = atoi(v);
     else if (!strcmp(k, "ws_click")) c->ws_click = atoi(v);
     else if (!strcmp(k, "ws_radius")) c->ws_radius = (float)atof(v);
     else if (!strcmp(k, "scroll_workspace")) c->scroll_workspace = atoi(v);
@@ -1542,6 +1771,8 @@ static void config_set(Config *c, const char *k, const char *v)
     else if (!strcmp(k, "msg_timeout")) c->msg_timeout = atoi(v);
     else if (!strcmp(k, "msg_flash")) c->msg_flash = atoi(v);
     else if (!strcmp(k, "msg_panel")) c->msg_panel = atoi(v);
+    else if (!strcmp(k, "redraw_ms")) c->redraw_ms = atoi(v);
+    else if (!strcmp(k, "refresh")) c->redraw_ms = (int)(atof(v) * 1000.0);
     else if (!strcmp(k, "msg_info")) parse_color(v, &c->msg_info);
     else if (!strcmp(k, "msg_warn")) parse_color(v, &c->msg_warn);
     else if (!strcmp(k, "msg_error")) parse_color(v, &c->msg_error);
@@ -1566,9 +1797,13 @@ static void config_set(Config *c, const char *k, const char *v)
     else if (!strcmp(k, "slim_warm")) parse_color(v, &c->slim_warm);
     else if (!strcmp(k, "outline")) parse_color(v, &c->outline);
     else if (!strcmp(k, "ws_bg")) parse_color(v, &c->ws_bg);
+    else if (!strcmp(k, "ws_border_col")) parse_color(v, &c->ws_border_col);
     else if (!strcmp(k, "ws_fg")) parse_color(v, &c->ws_fg);
     else if (!strcmp(k, "ws_other")) c->ws_other = atoi(v);
+    else if (!strcmp(k, "ws_sort")) c->ws_sort = atoi(v);
     else if (!strcmp(k, "slim_align")) c->slim_align = atoi(v);
+    else if (!strcmp(k, "no_tree")) c->no_tree = atoi(v);
+    else if (!strcmp(k, "tree_min_s")) c->tree_min_s = atoi(v);
     else if (!strcmp(k, "ws_cpu")) c->ws_cpu = atoi(v);
     else if (!strcmp(k, "ws_cpu_interval")) c->ws_cpu_interval = atoi(v);
     else if (!strcmp(k, "ws_cpu_idle")) c->ws_cpu_idle = (float)atof(v);
@@ -1959,6 +2194,36 @@ static void fill_round(Canvas *cv, float x, float y, float w, float h,
     }
 }
 
+/* The same distance field kept to a ring: an outline that follows the corner
+ * radius and stays inside the box, so a button with no fill still has an edge. */
+static void stroke_round(Canvas *cv, float x, float y, float w, float h,
+                         float rtl, float rtr, float rbr, float rbl,
+                         float t, Col c)
+{
+    if (w <= 0 || h <= 0 || t <= 0 || !c.a) return;
+    float hw = w * 0.5f, hh = h * 0.5f;
+    float ccx = x + hw, ccy = y + hh;
+    float cap = fminf(hw, hh);
+    rtl = clampf(rtl, 0, cap); rtr = clampf(rtr, 0, cap);
+    rbr = clampf(rbr, 0, cap); rbl = clampf(rbl, 0, cap);
+
+    int x0 = (int)floorf(x) - 1, y0 = (int)floorf(y) - 1;
+    int x1 = (int)ceilf(x + w) + 1, y1 = (int)ceilf(y + h) + 1;
+    for (int yy = y0; yy < y1; ++yy) {
+        if (yy < cv->cy0 || yy >= cv->cy1) continue;
+        for (int xx = x0; xx < x1; ++xx) {
+            if (xx < cv->cx0 || xx >= cv->cx1) continue;
+            float px = (float)xx + 0.5f - ccx, py = (float)yy + 0.5f - ccy;
+            float r = px < 0 ? (py < 0 ? rtl : rbl) : (py < 0 ? rtr : rbr);
+            float qx = fabsf(px) - (hw - r), qy = fabsf(py) - (hh - r);
+            float mx = fmaxf(qx, 0.0f), my = fmaxf(qy, 0.0f);
+            float d = sqrtf(mx * mx + my * my) + fminf(fmaxf(qx, qy), 0.0f) - r;
+            float cov = clampf(0.5f - fabsf(d + t * 0.5f) + t * 0.5f, 0.0f, 1.0f);
+            if (cov > 0) blend(cv, xx, yy, c, cov);
+        }
+    }
+}
+
 static void text_draw(Canvas *cv, Font *f, float x, float baseline, Col c, const char *s)
 {
     const char *p = s;
@@ -2690,13 +2955,12 @@ static void window_read(Cell *e)
     e->state = PS_STOPPED;
     if (!*e->src_path) return;                   /* nothing to look for */
 
-    JV *tree = sway_query(IPC_GET_TREE);
+    const JV *tree = sway_tree_cached();
     if (!tree) return;
 
     char list[256] = "";
     int  found = 0;
     window_scan(tree, e->src_path, NULL, list, sizeof(list), &found);
-    jfree(tree);
 
     if (!found) return;                          /* not running: no cell */
     e->state = PS_PLAYING;
@@ -3127,6 +3391,8 @@ struct Bar {
     int reg_w, reg_y, reg_h;    /* the input region as the compositor has it */
     int tip_edge;               /* -1 the panel is flush left, +1 right, 0 no */
     uint32_t last_fit;          /* when we last asked for a new size         */
+    uint32_t last_commit;       /* ...and when we last committed a frame     */
+    int  last_slot;             /* the buffer currently on screen            */
     float cal_x0, cal_x1, cal_x2, cal_x3, cal_y0, cal_y1;  /* the ‹ › boxes */
     int  tip_h;                 /* extra surface for the hover text, logical */
     int  tip_cell;              /* which cell it belongs to, or -1          */
@@ -3134,6 +3400,13 @@ struct Bar {
                                                     full bar, so the folded
                                                     strip can line up with it */
     float wsx0[MAX_WORKSPACES], wsx1[MAX_WORKSPACES];   /* and each workspace */
+    float wsbx0, wsbx1, wsbw;    /* the block: left, right, widest button  */
+    float wsnx0[MAX_WORKSPACES + 1], wsnx1[MAX_WORKSPACES + 1];
+                                 /* and by number, ghosts included       */
+    struct { float x0, x1; int num; char name[128]; } wsbtn[MAX_WORKSPACES * 2];
+    int   wsbtn_n;               /* every button, in the order drawn     */
+    bool  soon;                  /* this frame skips the redraw_ms hold    */
+    uint32_t shrink_since;       /* content has been narrower since then   */
     struct { float x0, x1; int cell; } chit[MAX_CELLS];
     int chits;
 };
@@ -3153,14 +3426,27 @@ static Bar   *pointer_bar = NULL;
 static bool   running = true;
 
 static void bar_render(Bar *b);
+static bool bar_animating(Bar *b);
 static void bar_damage(Bar *b) { if (b) { b->dirty = true; } }
 
+/* Some frames must not wait for redraw_ms. A clock a quarter of a second late
+ * is nothing; the workspace you just switched to lighting up a quarter of a
+ * second late is the bar feeling broken, and switching back inside that window
+ * showed nothing at all because the two frames merged into one. */
+static void bar_damage_now(Bar *b) { if (b) { b->dirty = true; b->soon = true; } }
+
 static void damage_all(void)
 {
     for (int i = 0; i < output_count; ++i)
         if (outputs[i].bar) bar_damage(outputs[i].bar);
 }
 
+static void damage_all_now(void)
+{
+    for (int i = 0; i < output_count; ++i)
+        if (outputs[i].bar) bar_damage_now(outputs[i].bar);
+}
+
 static bool output_wanted(const char *name)
 {
     if (!*cfg.outputs) return true;
@@ -3230,6 +3516,7 @@ static bool bar_alloc(Bar *b)
     if (b->pool && b->bw == bw && b->bh == bh) return true;
 
     bar_free_buffers(b);
+    b->last_slot = -1;
     b->bw = bw;
     b->bh = bh;
     b->stride = bw * 4;
@@ -3243,6 +3530,7 @@ static bool bar_alloc(Bar *b)
 
     b->pool_data = mem;
     b->pool_size = total;
+    wl_count.pool++;
     b->pool = wl_shm_create_pool(shm, fd, (int32_t)total);
     close(fd);
     for (int i = 0; i < 2; ++i) {
@@ -3454,8 +3742,31 @@ static void slim_draw_one(Canvas *cv, Cell *e, int mode, float x, float y,
         Col track = cfg.dim;                       /* visible, or a part filled
                                                     * gauge just looks short */
         track.a = 110;
-        fill_rect(cv, x, y, w, h, track);
-        if (have) fill_rect(cv, x, y, w * f, h, fillc);
+        /* Split into ten, and the gauge can be read as a number: four lit
+         * blocks is forty percent without measuring it against anything. A
+         * solid bar only ever said "about half". */
+        int segs = cfg.slim_bar_segs;
+        float sgap = fmaxf(1.0f, (float)cfg.slim_bar_gap * s);
+        /* Ten blocks in a narrow cell came out under two pixels each and the
+         * dividers vanished, so it read as one solid bar again. Halve the
+         * count instead of giving up on it. */
+        while (segs > 2 && (w - (float)(segs - 1) * sgap) / (float)segs < 2.0f * s)
+            segs /= 2;
+        float sw = segs > 1 ? (w - (float)(segs - 1) * sgap) / (float)segs : 0;
+        if (segs > 1 && sw < 1.5f) segs = 0;       /* too narrow to read */
+        if (segs > 1) {
+            for (int i = 0; i < segs; ++i) {
+                float bx = x + (float)i * (sw + sgap);
+                fill_rect(cv, bx, y, sw, h, track);
+                if (!have) continue;
+                float f0 = (float)i / (float)segs, f1 = (float)(i + 1) / (float)segs;
+                if (f >= f1)     fill_rect(cv, bx, y, sw, h, fillc);
+                else if (f > f0) fill_rect(cv, bx, y, sw * (f - f0) * (float)segs, h, fillc);
+            }
+        } else {
+            fill_rect(cv, x, y, w, h, track);
+            if (have) fill_rect(cv, x, y, w * f, h, fillc);
+        }
         if (dir == 2) return;
 
         /* The pips run a colour ramp away from the fill: warm into red while
@@ -3588,48 +3899,74 @@ static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs
                 (0.5f - 0.5f * cosf(2.0f * (float)M_PI * (float)ph / 900.0f));
     }
 
-    /* one fixed slot per workspace number, so slot 3 is always workspace 3 */
-    /* always the full set, so an empty workspace still holds its place and
-     * the lit ones never move */
-    int slots = cfg.slim_ws_slots;
-    if (slots <= 0) {
-        for (int i = 0; i < ws_count; ++i)
-            if ((ws_on_bar(b, &ws_list[i]) || cfg.ws_other) &&
-                ws_list[i].num > slots) slots = ws_list[i].num;
-    }
-
     float tick = 14.0f * s;
     b->hits = 0;
     b->chits = 0;
-    /* Lined up, a workspace keeps the place its button has in the full bar,
-     * so folding does not shuffle them either. Numbers with no workspace have
-     * no button to line up with, so they are simply not there. */
-    bool ws_aligned = cfg.slim_align != 0;
-    if (ws_aligned) {
-        bool any = false;
-        for (int i = 0; i < ws_count && i < MAX_WORKSPACES; ++i)
-            if (b->wsx1[i] > b->wsx0[i]) { any = true; break; }
-        ws_aligned = any;
-    }
-
-    for (int i = 1; i <= slots; ++i) {
-        Ws *w = ws_by_num(b, i);
-        float sx = x, sw = tick;
-        if (ws_aligned) {
-            if (!w) continue;                      /* nothing to sit under */
-            int wi = (int)(w - ws_list);
-            if (wi < 0 || wi >= MAX_WORKSPACES || b->wsx1[wi] <= b->wsx0[wi]) continue;
-            sx = b->wsx0[wi] * s;
-            sw = (b->wsx1[wi] - b->wsx0[wi]) * s - gap * 0.5f;
+
+    /* The workspace marks. Lined up (slim_align=1) they are the open bar's own
+     * buttons, one for one — the same set, the same left edges, the same
+     * widths — taken from the layout pass that ran just before this. So a
+     * mark is exactly where its button is, and you click the same spot folded
+     * or open. Letting the strip pick its own count (slim_ws_slots) could
+     * never match: ten marks against five buttons had to land somewhere the
+     * buttons were not. That count is only for the packed layout now.
+     *
+     * Packed (slim_align=0), one fixed slot per number, slot 3 always
+     * workspace 3. */
+    typedef struct { float x, w; Ws *ws; int num; const char *name; } Mark;
+    Mark mk[MAX_WORKSPACES * 2];
+    int nmk = 0;
+    char ghostname[MAX_WORKSPACES * 2][8];
+
+    if (cfg.slim_align && b->wsbtn_n > 0) {
+        for (int k = 0; k < b->wsbtn_n && nmk < MAX_WORKSPACES * 2; ++k) {
+            Mark *m = &mk[nmk];
+            m->x = b->wsbtn[k].x0 * s;
+            m->w = (b->wsbtn[k].x1 - b->wsbtn[k].x0) * s;
+            if (cfg.slim_ws_w > 0) m->w = (float)cfg.slim_ws_w * s;
+            m->num = b->wsbtn[k].num;
+            m->ws = m->num >= 1 ? ws_by_num(b, m->num) : NULL;
+            if (!m->ws)                            /* a named one: by name */
+                for (int i = 0; i < ws_count; ++i)
+                    if (!strcmp(ws_list[i].name, b->wsbtn[k].name)) { m->ws = &ws_list[i]; break; }
+            snprintf(ghostname[nmk], sizeof ghostname[nmk], "%d", m->num);
+            m->name = m->ws ? m->ws->name : ghostname[nmk];
+            nmk++;
         }
+    } else {
+        int slots = cfg.slim_ws_slots;
+        if (slots <= 0)
+            for (int i = 0; i < ws_count; ++i)
+                if ((ws_on_bar(b, &ws_list[i]) || cfg.ws_other) &&
+                    ws_list[i].num > slots) slots = ws_list[i].num;
+        if (slots > MAX_WORKSPACES) slots = MAX_WORKSPACES;
+        float sw0 = cfg.slim_ws_w > 0 ? (float)cfg.slim_ws_w * s : tick;
+        for (int i = 1; i <= slots; ++i) {
+            Mark *m = &mk[nmk];
+            m->x = x + (float)(i - 1) * (sw0 + gap);
+            m->w = sw0;
+            m->num = i;
+            m->ws = ws_by_num(b, i);
+            snprintf(ghostname[nmk], sizeof ghostname[nmk], "%d", i);
+            m->name = m->ws ? m->ws->name : ghostname[nmk];
+            nmk++;
+        }
+    }
+
+    for (int k = 0; k < nmk; ++k) {
+        Ws *w = mk[k].ws;
+        float sx = mk[k].x, sw = mk[k].w;
+        if (sw <= 0.0f) continue;
+        /* Brightness says used from unused: a free number is barely a shadow,
+         * one with a workspace on it is solid. */
         Col c = cfg.dim;
-        c.a = 60;                                  /* empty slot: a faint place holder */
+        c.a = 40;
         bool mine = w ? ws_on_bar(b, w) : true;
         if (w) {
             if (w->urgent) c = cfg.urgent;
             else if (w->focused) c = cfg.hl;
             else if (w->visible) c = cfg.accent;
-            else { c = cfg.dim; c.a = 170; }
+            else { c = cfg.dim; c.a = 205; }
 
             /* Another screen's workspace is shown, but never with this
              * screen's emphasis: focused there is not focused here. */
@@ -3649,14 +3986,16 @@ static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs
                            faint ? 0.001f : lf, lc);
             }
         }
-        if (w && b->hits < MAX_WORKSPACES) {      /* still switchable when folded */
+        /* Every mark switches, a free one included — clicking it opens that
+         * workspace, the same as its button does open. */
+        if (b->hits < MAX_WORKSPACES) {
             b->hit[b->hits].x0 = sx / s;
             b->hit[b->hits].x1 = (sx + sw) / s;
-            str_set(b->hit[b->hits].name, sizeof(b->hit[b->hits].name), w->name);
+            str_set(b->hit[b->hits].name, sizeof(b->hit[b->hits].name), mk[k].name);
             b->hits++;
         }
-        if (!ws_aligned) x += tick + gap;
     }
+    if (nmk > 0) x = mk[nmk - 1].x + mk[nmk - 1].w + gap;
 
     if (!cfg.cell_count) {                         /* status_command fallback */
         float rx = (float)cv->w - 4.0f * s;
@@ -3706,11 +4045,48 @@ static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs
             if (c0 < x + gap) c0 = x + gap;
             if (c1 > (float)cv->w - 3.0f * s) c1 = (float)cv->w - 3.0f * s;
             /* inset by the same padding the text has, so the mark lines up
-               with the words that were there a moment ago */
-            float cpad = (float)cfg.cell[idx[k]].pad * s;
+               with the words that were there a moment ago. A gauge is the
+               exception: it has ten segments to fit and a short cell like a
+               battery percentage left it too cramped to read, so it takes the
+               whole cell. */
+            float cpad = mode[k] == SLIM_BAR ? (float)cfg.slim_bar_pad * s
+                                             : (float)cfg.cell[idx[k]].pad * s;
             if (c1 - c0 - 2.0f * cpad < 4.0f * s) { aligned = false; break; }
             px[k] = c0 + cpad;
             pw[k] = c1 - c0 - 2.0f * cpad;
+            /* A short cell — a two digit battery — leaves a gauge too small to
+             * read ten blocks in. slim_w wins over the alignment when it is
+             * set, growing about the cell's middle so the mark stays where the
+             * text was. */
+            if (cfg.cell[idx[k]].slim_w > 0) {
+                float want = (float)cfg.cell[idx[k]].slim_w * s
+                           - (mode[k] == SLIM_BAR ? 2.0f * cpad : 0.0f);
+                if (want > pw[k]) {
+                    px[k] -= (want - pw[k]) * 0.5f;
+                    pw[k]  = want;
+                    if (px[k] < x + gap) px[k] = x + gap;
+                    if (px[k] + pw[k] > (float)cv->w - 3.0f * s)
+                        px[k] = (float)cv->w - 3.0f * s - pw[k];
+                }
+            }
+        }
+    }
+
+    /* A mark grown by slim_w can reach into the one beside it — the battery
+     * sat right up against the temperature. Trim it back so there is always
+     * a clear gap of slim_bar_pad on each side between two marks. */
+    if (aligned) {
+        float sep = fmaxf(2.0f * (float)cfg.slim_bar_pad * s, 2.0f * s);
+        for (int k = 0; k < n; ++k) {
+            if (k > 0) {
+                float lim = px[k - 1] + pw[k - 1] + sep;
+                if (px[k] < lim) { pw[k] -= lim - px[k]; px[k] = lim; }
+            }
+            if (k + 1 < n) {
+                float lim = px[k + 1] - sep;
+                if (px[k] + pw[k] > lim) pw[k] = lim - px[k];
+            }
+            if (pw[k] < 2.0f * s) pw[k] = 2.0f * s;
         }
     }
 
@@ -3752,17 +4128,49 @@ static float ws_button_w(Font *f, Ws *w, float s)
                  (float)cfg.ws_min_w * s);
 }
 
-static float ws_block_w(Font *f, Bar *b, float s)
+/* The row the bar draws: with ws_slots set it is the numbers 1..n whether or
+ * not they exist, then anything else that is open — a workspace numbered past
+ * the end, or a named one. A free number is a button like any other, so it is
+ * measured, placed and clicked like one; switching to it is what creates it.
+ * Both the measuring and the drawing pass walk this, or the block comes out a
+ * different width than what goes in it. */
+typedef struct { Ws *w; bool ghost; } WsSlot;
+
+static Ws ws_ghost[MAX_WORKSPACES];
+
+static int ws_order(Bar *b, WsSlot *out, int max)
 {
-    float w = 0;
-    int n = 0;
-    for (int i = 0; i < ws_count; ++i) {
-        /* the same set the drawing walks, or the block is measured shorter
-         * than it is drawn and the next cell lands on top of it */
-        if (!ws_on_bar(b, &ws_list[i]) && !cfg.ws_other) continue;
-        w += ws_button_w(ws_font(b, &ws_list[i], f), &ws_list[i], s);
+    int n = 0, lim = cfg.ws_slots;
+    if (lim > MAX_WORKSPACES) lim = MAX_WORKSPACES;
+    for (int k = 1; k <= lim && n < max; ++k) {
+        Ws *w = ws_by_num(b, k);
+        if (w) { out[n].w = w; out[n].ghost = false; }
+        else {
+            Ws *g = &ws_ghost[k - 1];
+            memset(g, 0, sizeof(*g));
+            g->num = k;
+            snprintf(g->name, sizeof(g->name), "%d", k);
+            str_set(g->label, sizeof(g->label), g->name);
+            out[n].w = g; out[n].ghost = true;
+        }
         n++;
     }
+    for (int i = 0; i < ws_count && n < max; ++i) {
+        Ws *w = &ws_list[i];
+        if (!ws_on_bar(b, w) && !cfg.ws_other) continue;
+        if (w->num >= 1 && w->num <= lim) continue;   /* already placed above */
+        out[n].w = w; out[n].ghost = false; n++;
+    }
+    return n;
+}
+
+static float ws_block_w(Font *f, Bar *b, float s)
+{
+    WsSlot sl[MAX_WORKSPACES * 2];
+    float w = 0;
+    int n = ws_order(b, sl, MAX_WORKSPACES * 2);
+    for (int i = 0; i < n; ++i)
+        w += ws_button_w(ws_font(b, sl[i].w, f), sl[i].w, s);
     if (n > 1) w += (float)(n - 1) * (float)cfg.ws_gap * s;
     return w;
 }
@@ -3794,10 +4202,16 @@ static void ws_block_draw(Canvas *cv, Bar *b, Font *f, float x, float y0,
 {
     float rad = ws_radius_px(vis, s);
     float base = baseline_for(f, y0, vis, s);
-    for (int i = 0; i < ws_count; ++i) {
-        Ws *w = &ws_list[i];
+    WsSlot sl[MAX_WORKSPACES * 2];
+    int nsl = ws_order(b, sl, MAX_WORKSPACES * 2);
+    b->wsbx0 = x / s;
+    b->wsbw  = 0;
+    for (int k = 0; k <= MAX_WORKSPACES; ++k) b->wsnx0[k] = b->wsnx1[k] = 0;
+    b->wsbtn_n = 0;
+    for (int k = 0; k < nsl; ++k) {
+        Ws *w = sl[k].w;
+        int i = sl[k].ghost ? -1 : (int)(w - ws_list);
         bool mine = ws_on_bar(b, w);
-        if (!mine && !cfg.ws_other) continue;
 
         Font *wf = ws_font(b, w, f);
         float bwid = ws_button_w(wf, w, s);
@@ -3808,6 +4222,14 @@ static void ws_block_draw(Canvas *cv, Bar *b, Font *f, float x, float y0,
         else if (w->focused) { bg = cfg.ws_focused_bg; fg = cfg.ws_focused_fg; }
         else if (w->visible) { bg = cfg.ws_visible_bg; fg = cfg.ws_visible_fg; }
 
+        /* A number nobody is using: the same button in the same place, just
+         * faint, so the row is a fixed ten and clicking one opens it. */
+        float ga = clampf((float)cfg.ws_empty_alpha / 100.0f, 0.0f, 1.0f);
+        if (sl[k].ghost) {
+            bg = col_scale_alpha(bg, ga);
+            fg = col_scale_alpha(fg, ga);
+        }
+
         /* A workspace on another monitor is drawn as a short pill sitting on
          * the baseline: still readable, still clickable, and obviously not
          * one of this screen's. */
@@ -3817,6 +4239,19 @@ static void ws_block_draw(Canvas *cv, Bar *b, Font *f, float x, float y0,
 
         pill(cv, x, by, bh, bwid, s, mine ? rad : rad * 0.6f,
              col_scale_alpha(bg, bfade));
+        /* With ws_bg transparent an unused workspace was a bare number with no
+         * edge, so the row read as loose text rather than buttons. The outline
+         * gives every one the same cell, filled or not. */
+        if (cfg.ws_border > 0) {
+            float br = mine ? rad : rad * 0.6f, in = (float)cfg.ws_inset * s;
+            float oy = by, oh = bh, t0 = br, t1 = br, b0 = br, b1 = br;
+            if (in > 0) { oy += in; oh -= 2.0f * in; }
+            else if (strcmp(cfg.position, "bottom") != 0) t0 = t1 = 0;
+            else                                         b0 = b1 = 0;
+            stroke_round(cv, x, oy, bwid, oh, t0, t1, b1, b0,
+                         (float)cfg.ws_border * s,
+                         col_scale_alpha(cfg.ws_border_col, bfade));
+        }
         if (mine)
             text_draw(cv, f, x + (bwid - tw) * 0.5f, base,
                       col_scale_alpha(fg, fade), w->label);
@@ -3845,11 +4280,27 @@ static void ws_block_draw(Canvas *cv, Bar *b, Font *f, float x, float y0,
             str_set(b->hit[b->hits].name, sizeof(b->hit[b->hits].name), w->name);
             b->hits++;
         }
-        if (i < MAX_WORKSPACES) {                 /* for the folded strip */
+        if (i >= 0 && i < MAX_WORKSPACES) {       /* for the folded strip */
             b->wsx0[i] = x / s;
             b->wsx1[i] = (x + bwid) / s;
         }
+        /* By number as well, ghosts included: that is what the folded strip
+         * copies, so a slot is exactly its own button and not the widest
+         * one. */
+        if (w->num >= 1 && w->num <= MAX_WORKSPACES) {
+            b->wsnx0[w->num] = x / s;
+            b->wsnx1[w->num] = (x + bwid) / s;
+        }
+        if (b->wsbtn_n < MAX_WORKSPACES * 2) {
+            int q = b->wsbtn_n++;
+            b->wsbtn[q].x0 = x / s;
+            b->wsbtn[q].x1 = (x + bwid) / s;
+            b->wsbtn[q].num = w->num;
+            str_set(b->wsbtn[q].name, sizeof(b->wsbtn[q].name), w->name);
+        }
+        if (bwid > b->wsbw) b->wsbw = bwid / s;
         x += bwid + (float)cfg.ws_gap * s;
+        b->wsbx1 = (x - (float)cfg.ws_gap * s) / s;
     }
 }
 
@@ -4176,6 +4627,20 @@ static int bar_natural_width(Bar *b, Canvas *cv, Font *f, float vis, float s)
     if (groups > 1) total += (float)(groups - 1) * gap * 2.0f;
     total += (float)cfg.pad_x * s + (float)(cfg.pad_right >= 0 ? cfg.pad_right : cfg.pad_x) * s;
 
+    /* The folded strip can need more room than the open bar — ten workspace
+     * slots against however many are actually open. The surface is sized once
+     * for both, so folding never has to ask the compositor for a new width
+     * (and could not: the strip is drawn after the fitting). */
+    /* Only the packed strip picks its own count; lined up it is the open
+     * bar's buttons and needs no more room than they do. */
+    if (cfg.signals && !cfg.slim_align && cfg.slim_ws_slots > 0 && b->wsbw > 0) {
+        int n = cfg.slim_ws_slots;
+        float strip = (float)n * (cfg.slim_ws_w > 0 ? (float)cfg.slim_ws_w : b->wsbw)
+                    + (float)(n - 1) * (float)cfg.ws_gap;
+        float have = b->wsbx1 - b->wsbx0;
+        if (strip > have) total += (strip - have) * s;
+    }
+
     int want = (int)ceilf(total / s);
     if (want < cfg.min_width) want = cfg.min_width;
 
@@ -4217,6 +4682,11 @@ static void bar_fit_height(Bar *b)
      * real width — and want_w is still zero until the width has been fitted
      * once, which since it stopped fitting during animations is exactly when
      * a fold would ask for a new height. */
+    uint32_t now = now_ms();
+    if (b->last_fit && (int32_t)(now - b->last_fit) < 100) return;
+    b->last_fit = now;
+    wl_count.resize++;
+
     uint32_t w = 0;
     if (cfg.min_width > 0) {
         w = (uint32_t)(b->want_w > 0 ? b->want_w : b->w);
@@ -4284,10 +4754,18 @@ static void hover_cal_build(Cell *e)
 /* `cal`, or anything else whose output is the panel. Run once when the hover
  * starts and kept until it ends, so resting on the clock does not spawn a
  * process every frame. */
+/* Run it, but never wait on it for long: this happens on the frame the
+ * pointer arrives, so a command that blocks blocks the whole bar — which is
+ * what a frozen bar turns out to be. */
 static void hover_cmd_run(Cell *e)
 {
     e->out_hover[0] = 0;
-    FILE *p = popen(e->hover_cmd, "r");
+
+    char guarded[320];
+    snprintf(guarded, sizeof(guarded), "timeout 2 sh -c %c%s%c",
+             '\'', e->hover_cmd, '\'');
+
+    FILE *p = popen(guarded, "r");
     if (!p) return;
     size_t n = fread(e->out_hover, 1, sizeof(e->out_hover) - 1, p);
     pclose(p);
@@ -4389,10 +4867,24 @@ static void bar_fit_width(Bar *b, Canvas *cv, Font *f, float vis, float s)
     if (b->last_fit && (int32_t)(now - b->last_fit) < 250) return;
 
     int want = bar_natural_width(b, cv, f, vis, s);
+
+    /* Growing happens at once — clipped text is worse than a bar that moves.
+     * Shrinking waits. A floating bar is centred, so every change of width
+     * moves everything on it, and most of them undo themselves: the battery
+     * going from 100 to 99, a temperature losing a digit, a cell that blinks
+     * empty for a second. A shrink smaller than shrink_px is ignored outright,
+     * and a larger one has to hold for shrink_s before the bar follows. */
+    if (want < b->w) {
+        if (b->w - want < cfg.shrink_px) { b->shrink_since = 0; return; }
+        if (!b->shrink_since) { b->shrink_since = now ? now : 1; return; }
+        if ((int32_t)(now - b->shrink_since) < cfg.shrink_s * 1000) return;
+    }
+    b->shrink_since = 0;
     if (want == b->want_w || abs(want - b->w) < 4) return;
 
     b->want_w  = want;
     b->last_fit = now;
+    wl_count.resize++;
     ls_set_size(b->ls, (uint32_t)want, (uint32_t)b->h);
     wl_surface_commit(b->surf);
 }
@@ -4589,7 +5081,13 @@ static void bar_paint(Bar *b, Canvas *cv, float visL, float s)
          * positions to line up with. Run the full layout with the canvas
          * clipped to nothing: it draws no pixels and records where every cell
          * and workspace would sit. */
-        if (!b->measured_full) {
+        /* Every folded frame lays the open bar out first, with the canvas
+         * clipped to nothing: no pixels, just where every button and cell
+         * would be right now. Doing it only once went stale — a workspace
+         * opened while folded had no button to line up with, and started
+         * folded the surface was not even its real width yet. The strip is
+         * redrawn rarely, so the pass costs next to nothing. */
+        {
             int sx0 = cv->cx0, sy0 = cv->cy0, sx1 = cv->cx1, sy1 = cv->cy1;
             canvas_clip(cv, 0, 0, 0, 0);
             bar_measure_layout(cv, b, f, y0, vis, s, bw);
@@ -4597,6 +5095,7 @@ static void bar_paint(Bar *b, Canvas *cv, float visL, float s)
             b->measured_full = true;
             b->hits = 0;
             b->chits = 0;
+            bar_fit_width(b, cv, f, vis, s);
         }
         draw_signals(cv, b, y0, vis, s, &rs);
         return;
@@ -4639,6 +5138,27 @@ static void bar_paint(Bar *b, Canvas *cv, float visL, float s)
 static void bar_render(Bar *b)
 {
     if (!b->configured || b->closed) return;
+
+    /* A hard ceiling on how often the compositor is asked to do anything.
+     * Whatever decides a frame is needed — an animation, a configure, a cell
+     * whose width wobbles — it cannot turn into an unbounded stream of
+     * commits, and sway cannot be made to allocate faster than it can free.
+     * The frame is not lost, only deferred: dirty stays set. */
+    /* Two floors on how often a frame goes out.
+     *
+     * The 8 ms one is a hard ceiling of ~120 fps for anything animating. The
+     * other is for content: ten cells on their own timers wake the bar three
+     * times a second, each with one number changed, and every one of those is
+     * a full round trip through the compositor. Holding them for redraw_ms
+     * merges whatever arrives in the same window into a single frame. A clock
+     * a quarter of a second late is not a clock anyone notices. */
+    uint32_t now = now_ms();
+    if (b->last_commit && (int32_t)(now - b->last_commit) < 8) return;
+
+    bool moving = bar_animating(b) || b->hovered || scroll_running || b->soon;
+    if (!moving && cfg.redraw_ms > 0 && b->last_commit &&
+        (int32_t)(now - b->last_commit) < cfg.redraw_ms) return;
+
     if (!bar_alloc(b)) return;
 
     int slot = -1;
@@ -4651,20 +5171,27 @@ static void bar_render(Bar *b)
     Canvas cv = { (uint32_t *)b->bufmem[slot], b->bw, b->bh, 0, 0, b->bw, b->bh };
     bar_paint(b, &cv, visL, s);
 
-    /* hand the buffer over */
-    b->busy[slot] = true;
-    wl_surface_set_buffer_scale(b->surf, b->scale);
-    wl_surface_attach(b->surf, b->buf[slot], 0, 0);
-    wl_surface_damage_buffer(b->surf, 0, 0, b->bw, b->bh);
-
+    /* Nothing to say? Then say nothing.
+     *
+     * Ten cells on two-to-ten second timers wake the bar a couple of times a
+     * second, but most of those produce the same picture — the volume is
+     * still 80%, the clock is still 12:41. Comparing the frame we just drew
+     * against the one on screen costs a memcmp of the buffer and saves the
+     * whole round trip: attach, damage, commit, and everything the
+     * compositor does with it. */
     /* only the visible strip takes clicks — the rest of the surface is
      * transparent and must not swallow them */
     /* the bar, plus the panel while one is open: the pointer has to be able
      * to rest on the panel without the hover ending under it. Sent only when
-     * it changes — the compositor allocates a region for every one of these. */
+     * it changes — the compositor allocates a region for every one of these —
+     * but ahead of the skip below: a frame with nothing new to show is still a
+     * frame whose region may have changed, and going without one is a bar the
+     * pointer falls through. */
     int rv = (int)ceilf(visL) + (b->tip_h > 0 ? b->tip_h : 0);
     int ry = top ? 0 : b->h - rv;
-    if (rv != b->reg_h || ry != b->reg_y || b->w != b->reg_w) {
+    bool new_region = rv != b->reg_h || ry != b->reg_y || b->w != b->reg_w;
+    if (new_region) {
+        wl_count.region++;
         struct wl_region *reg = wl_compositor_create_region(compositor);
         wl_region_add(reg, 0, ry, b->w, rv);
         wl_surface_set_input_region(b->surf, reg);
@@ -4672,12 +5199,33 @@ static void bar_render(Bar *b)
         b->reg_h = rv; b->reg_y = ry; b->reg_w = b->w;
     }
 
+    if (b->last_slot >= 0 && b->last_slot != slot &&
+        memcmp(b->bufmem[slot], b->bufmem[b->last_slot],
+               (size_t)b->stride * (size_t)b->bh) == 0) {
+        /* Nothing new to show, but a region that was just set is only state
+         * until something commits it. */
+        if (new_region) wl_surface_commit(b->surf);
+        b->dirty = false;
+        b->soon = false;
+        return;
+    }
+
+    /* hand the buffer over */
+    b->last_slot = slot;
+    b->busy[slot] = true;
+    wl_surface_set_buffer_scale(b->surf, b->scale);
+    wl_surface_attach(b->surf, b->buf[slot], 0, 0);
+    wl_surface_damage_buffer(b->surf, 0, 0, b->bw, b->bh);
+
     if (cfg.exclusive)                       /* the panel must not reserve
                                                 space: it is transient */
         ls_set_exclusive_zone(b->ls, (int)ceilf(visL) + cfg.margin);
 
+    wl_count.commit++;
+    b->last_commit = now_ms();
     wl_surface_commit(b->surf);
     b->dirty = false;
+    b->soon = false;
 }
 /* ------------------------------------------------------------- animation */
 
@@ -4710,9 +5258,16 @@ static void bar_anim_step(Bar *b, uint32_t t)
     b->dirty = true;
 }
 
+/* Taking the grab is always re-sent, never skipped because we think we still
+ * have it. The compositor can take it back without telling us — a leave that
+ * never arrives because the surface moved or was remapped leaves kbd_grab
+ * saying true while the keyboard is long gone, and every later enter then does
+ * nothing, so keys go to the window underneath until the bar is restarted.
+ * Dropping it is still only sent on a change; that one cannot go stale. */
 static void bar_set_grab(Bar *b, bool on)
 {
-    if (!cfg.hover_keys || b->kbd_grab == on || !b->ls) return;
+    if (!cfg.hover_keys || !b->ls) return;
+    if (!on && !b->kbd_grab) return;
     b->kbd_grab = on;
     ls_set_keyboard(b->ls, on ? ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE
                               : ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_NONE);
@@ -4725,10 +5280,23 @@ static void ls_configure(void *data, struct zwlr_layer_surface_v1 *s,
                          uint32_t serial, uint32_t w, uint32_t h)
 {
     Bar *b = (Bar *)data;
+    wl_count.ack++;
     ls_ack_configure(s, serial);
+
+    bool changed = (w && (int)w != b->w) || (h && (int)h != b->h) || !b->configured;
     if (w) b->w = (int)w;
     if (h) b->h = (int)h;
     b->configured = true;
+    if (!changed) return;            /* nothing to redraw, and no ping-pong */
+
+    /* A different surface needs everything measured against it again, a fresh
+     * input region, and the keyboard asked for again — none of that carries
+     * over, and a stale input region is a bar the pointer goes straight
+     * through. */
+    b->measured_full = false;
+    b->reg_w = -1;
+    if (b->kbd_grab && cfg.hover_keys)
+        ls_set_keyboard(b->ls, ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_EXCLUSIVE);
     b->dirty = true;
     bar_render(b);
 }
@@ -4755,6 +5323,7 @@ static void bar_create(Output *o)
     b->w = 0;
     b->h = bar_wanted_h(b);
     b->tip_pinned = -1;
+    b->last_slot = -1;
     b->vis = cfg.start_collapsed ? 0.0f : 1.0f;
     b->anim_to = b->vis;
     b->collapsed = cfg.start_collapsed != 0;
@@ -4929,6 +5498,14 @@ static void ptr_leave(void *d, struct wl_pointer *p, uint32_t serial, struct wl_
     (void)d; (void)p; (void)serial;
     Bar *b = bar_of_surface(surf);
     if (!b) return;
+    if (pointer_bar && pointer_bar != b) {
+        /* a leave for the bar we already left, arriving after the enter for
+         * the one we are on: dropping the grab here would take it off the
+         * wrong surface and lose the pointer with it */
+        b->hovered = false;
+        bar_set_grab(b, false);
+        return;
+    }
     b->hovered = false;
     b->tip_pinned = -1;
     b->dirty = true;
@@ -5236,6 +5813,12 @@ static void usage(void)
 "                      this rather than `pkill swbr` in the sway config: sway\n"
 "                      forks each exec_always without waiting, so a kill on\n"
 "                      one line races the bar started on the next\n"
+"  no_tree=0           1 = never ask sway for the tree (no load dots, no\n"
+"                      source=window cells). tree_min_s=5 is how often it\n"
+"                      may be asked for at most, and only after a change\n"
+"  --minimal           a bare bar: workspaces only, no cells, no tree, no\n"
+"                      keyboard grab, no fifo. For finding out which part of\n"
+"                      swbr a compositor is unhappy about\n"
 "  --probe             print outputs, sizes, font metrics and cell values\n"
 "  --slim              start folded (same as start_collapsed=1)\n"
 "  --toggle            fold or unfold the running bar. Also --fold/--unfold\n"
@@ -5263,7 +5846,7 @@ static void usage(void)
 "\n"
 "workspaces\n"
 "  ws_names=0          0 = number only, 1 = the full workspace name\n"
-"  ws_pad=11 ws_gap=4 ws_min_w=0\n"
+"  ws_pad=11 ws_gap=4 ws_min_w=0 ws_border=0\n""  ws_slots=10         show the numbers 1..n whether or not they exist\n""  ws_empty_alpha=35   how visible a free number is, percent\n"
 "  ws_inset=0          gap above and below the buttons; >0 makes them pills\n"
 "  ws_radius=0         0 = square, the button spans the whole bar height\n"
 "                      -1 = a full pill when inset, else follows radius\n"
@@ -5364,6 +5947,10 @@ static void usage(void)
 "  msg_fifo=           default $XDG_RUNTIME_DIR/swbr.fifo\n"
 "  msg_target=         name of the cell a message takes over while it lasts\n"
 "  msg_timeout=8       seconds, 0 = until cleared. Click it to dismiss\n"
+"  redraw_ms=250       cell updates are held this long and merged into one\n"
+"                      frame; refresh=1 says the same in seconds. Anything\n"
+"                      moving — an animation, a hover, a scrolling cell —\n"
+"                      ignores it\n"
 "  msg_flash=1         pulse the folded strip in the message colour\n"
 "  msg_panel=3         seconds the message also drops into the panel\n"
 "  msg_info= msg_warn= msg_error=\n"
@@ -5377,7 +5964,8 @@ static void usage(void)
 "  signals=1           the folded strip keeps saying something: a fixed slot\n"
 "                      per workspace, twelve dots for a clock cell, a gauge\n"
 "                      for anything with a percentage\n"
-"  slim_ws_slots=10    always show this many workspace slots\n"
+"  slim_ws_slots=10    slots in the packed strip (slim_align=0); lined up, the\n"
+"                      strip is the open bar's own buttons\n""  slim_bar_segs=10    folded gauges are split into this many blocks\n""  slim_bar_gap=2      px between those blocks\n""  slim_bar_pad=3      px either side of a folded gauge\n""  slim_ws_w=0         folded slot width, 0 = its own button\n""  shrink_px=24 shrink_s=30   a floating bar ignores narrowing by less than\n""                      shrink_px, and waits shrink_s before a bigger one\n"
 "  start_collapsed=0\n"
 "  SIGUSR1 folds or unfolds every bar:  pkill -USR1 swbr\n"
 "\n"
@@ -5413,8 +6001,10 @@ static void dump_config(void)
            c.min_width, c.align_x, (double)c.radius, c.outputs);
     printf("\n# --- workspaces ---\n");
     printf("ws_names=%d\nws_pad=%d\nws_gap=%d\nws_min_w=%d\nws_inset=%d\nws_radius=%g\n"
+           "ws_border=%d\nws_slots=%d\nws_empty_alpha=%d\n"
            "ws_click=%d\nscroll_workspace=%d\nmode_show=%d\n",
            c.ws_names, c.ws_pad, c.ws_gap, c.ws_min_w, c.ws_inset, (double)c.ws_radius,
+           c.ws_border, c.ws_slots, c.ws_empty_alpha,
            c.ws_click, c.scroll_workspace, c.mode_show);
     printf("\n# --- text ---\n");
     printf("# font=\n# font_alt=\nui_scale=%g\ntext_px=%g\nws_px=%g\ntext_y=%g\n"
@@ -5427,6 +6017,7 @@ static void dump_config(void)
     print_color("separator_color", c.separator_color);
     printf("# cell=clock\n# clock.cmd=date '+%%H:%%M'\n# clock.interval=10\n");
     printf("\n# --- messages ---\n");
+    printf("redraw_ms=%d\n", c.redraw_ms);
     printf("# msg_fifo=\n# msg_target=cmus\nmsg_timeout=%d\nmsg_flash=%d\n",
            c.msg_timeout, c.msg_flash);
     print_color("msg_info", c.msg_info);
@@ -5438,10 +6029,13 @@ static void dump_config(void)
     printf("# status_command=~/bin/sway_bar_status.sh\ninterval=%d\n", c.interval);
     printf("\n# --- folding ---\n");
     printf("hide_key=%s\nhover_keys=%d\ncollapsed_px=%d\nanim_ms=%d\nsignals=%d\n"
-           "slim_ws_slots=%d\n"
+           "slim_ws_slots=%d\nslim_bar_segs=%d\nslim_bar_gap=%d\nslim_bar_pad=%d\n"
+           "slim_ws_w=%d\nshrink_px=%d\nshrink_s=%d\n"
            "start_collapsed=%d\n",
            c.hide_key, c.hover_keys, c.collapsed_px, c.anim_ms, c.signals,
-           c.slim_ws_slots, c.start_collapsed);
+           c.slim_ws_slots, c.slim_bar_segs, c.slim_bar_gap, c.slim_bar_pad,
+           c.slim_ws_w, c.shrink_px, c.shrink_s,
+           c.start_collapsed);
     printf("\n# --- mouse ---\n# button2=cmus_control toggle\n");
     printf("\n# --- colors ---\n");
     print_color("bg", c.bg);
@@ -5453,6 +6047,7 @@ static void dump_config(void)
     print_color("running", c.running);
     print_color("slim_warm", c.slim_warm);
     print_color("outline", c.outline);
+    print_color("ws_border_col", c.ws_border_col);
     print_color("ws_bg", c.ws_bg);
     print_color("ws_fg", c.ws_fg);
     print_color("ws_focused_bg", c.ws_focused_bg);
@@ -5469,6 +6064,7 @@ static void dump_config(void)
 
 static int  probe = 0;
 static int  do_replace = 0;
+static int  minimal = 0;
 static int  cfg_start_collapsed_arg = 0;
 
 static void probe_report(void)
@@ -5663,10 +6259,12 @@ int main(int argc, char **argv)
         } else if (!strcmp(argv[i], "--dump-config")) { dump_config(); return 0; }
         else if (!strcmp(argv[i], "--probe")) probe = 1;
         else if (!strcmp(argv[i], "--replace")) do_replace = 1;
+        else if (!strcmp(argv[i], "--minimal")) minimal = 1;
         else if (!strcmp(argv[i], "--slim") || !strcmp(argv[i], "--collapsed"))
             cfg_start_collapsed_arg = 1;
     }
 
+    wl_trace = getenv("SWBR_TRACE") != NULL;
     if (do_replace) replace_running();   /* clear the way before asking for a surface */
 
     config_defaults(&cfg);
@@ -5687,13 +6285,20 @@ int main(int argc, char **argv)
         const char *a = argv[i];
         if (!strcmp(a, "--config")) { i++; continue; }
         if (!strcmp(a, "--probe") || !strcmp(a, "--slim") ||
-            !strcmp(a, "--replace") || !strcmp(a, "--collapsed")) continue;
+            !strcmp(a, "--replace") || !strcmp(a, "--minimal") ||
+            !strcmp(a, "--collapsed")) continue;
         if (!strcmp(a, "-s") && i + 1 < argc) a = argv[++i];
         else if (!strncmp(a, "--", 2)) a += 2;
         char buf[4096];
         str_set(buf, sizeof(buf), a);
         char *eq = strchr(buf, '=');
-        if (!eq) continue;
+        if (!eq) {
+            /* A mistyped flag used to be swallowed in silence, so the test
+             * you thought you were running was the one you were not. */
+            if (!strncmp(argv[i], "-", 1))
+                fprintf(stderr, "swbr: unknown option '%s' (see --help)\n", argv[i]);
+            continue;
+        }
         *eq = 0;
         config_set(&cfg, trim(buf), trim(eq + 1));
     }
@@ -5723,12 +6328,29 @@ int main(int argc, char **argv)
     if (!compositor || !shm) die("compositor is missing wl_compositor/wl_shm");
     if (!layer_shell) die("compositor does not support wlr-layer-shell");
 
+    no_tree_flag = cfg.no_tree;      /* the tree reader reads this, not cfg */
+    ws_sort_flag = cfg.ws_sort;
+    tree_min_ms  = cfg.tree_min_s > 0 ? cfg.tree_min_s * 1000 : 5000;
+
+    if (minimal) {                   /* a bare bar, for bisecting */
+        cfg.cell_count = 0;
+        cfg.ws_cpu = 0;
+        cfg.signals = 0;
+        cfg.hover_keys = 0;
+        cfg.msg_flash = 0;
+        cfg.msg_panel = 0;
+        cfg.status_command[0] = 0;
+        no_tree_flag = 1;
+        fprintf(stderr, "swbr: minimal — workspaces only, no cells, no tree,\n"
+                        "swbr:           no keyboard grab, no message fifo\n");
+    }
+
     for (int i = 0; i < output_count; ++i)
         if (outputs[i].alive && !outputs[i].bar) bar_create(&outputs[i]);
 
     if (*cfg.status_command && !cfg.cell_count) status_spawn();
     cells_tick();
-    msg_open();
+    if (!minimal) msg_open();
 
     if (probe) {
         if (cfg.ws_cpu) cpu_sample();           /* the first of the two */
@@ -5757,7 +6379,14 @@ int main(int argc, char **argv)
             redraw = true;
         }
 
-        if (sway_events_pending()) { ws_reload(cfg.ws_names != 0); redraw = true; }
+        bool redraw_now = false;
+        if (sway_events_pending()) {          /* something moved: the cached
+                                                 tree is stale */
+            sway_tree_forget();
+            if (ws_stale) ws_reload(cfg.ws_names != 0);   /* else: from the event */
+            ws_stale = false;
+            redraw_now = true;
+        }
         if (status_read()) redraw = true;
         cells_tick();
         if (cells_read()) redraw = true;
@@ -5774,7 +6403,9 @@ int main(int argc, char **argv)
         }
         if (msg_expiry_due()) redraw = true;
 
-        if (cfg.ws_cpu && cfg.ws_cpu_interval > 0) {
+        wl_trace_tick();
+
+    if (cfg.ws_cpu && cfg.ws_cpu_interval > 0) {
             static uint32_t cpu_next;
             if (!cpu_next || (int32_t)(now_ms() - cpu_next) >= 0) {
                 cpu_sample();
@@ -5797,7 +6428,8 @@ int main(int argc, char **argv)
             bar_anim_step(b, t);
             if (bar_animating(b)) animating = true;
         }
-        if (redraw) damage_all();
+        if (redraw_now) damage_all_now();
+        else if (redraw) damage_all();
 
         scroll_running = false;
         for (int i = 0; i < output_count; ++i) {
@@ -5818,17 +6450,18 @@ int main(int argc, char **argv)
         for (int i = 0; i < cfg.cell_count; ++i)
             if (cfg.cell[i].fd >= 0) { pfd[n].fd = cfg.cell[i].fd; pfd[n].events = POLLIN; n++; }
 
+        bool any_dirty = false;
+        for (int i = 0; i < output_count; ++i)
+            if (outputs[i].bar && outputs[i].bar->dirty) any_dirty = true;
+
         int timeout = -1;
-        if (animating || scroll_running) timeout = 16;
+        /* A frame held back by redraw_ms has to be woken to go out. Waiting on
+         * the 100 ms cell tick put it a further tenth of a second behind
+         * whatever it was already late for. */
+        if (animating || scroll_running || any_dirty) timeout = 16;
         else if (cfg.cell_count || msg_until || (cfg.ws_cpu && cfg.ws_cpu_interval > 0))
             timeout = 100;
         else if (status_fd < 0 && *cfg.status_command) timeout = 100;
-        else {
-            bool any_dirty = false;
-            for (int i = 0; i < output_count; ++i)
-                if (outputs[i].bar && outputs[i].bar->dirty) any_dirty = true;
-            if (any_dirty) timeout = 16;
-        }
 
         int pr = poll(pfd, (nfds_t)n, timeout);
         if (pr < 0 && errno != EINTR) { wl_display_cancel_read(dpy); break; }