SwBr git · main
SwBr - status bar for sway
C 95.9% Markdown 3.7%git clone https://git.christianimmanuel.de/sway/SwBr.gitwget https://git.christianimmanuel.de/sway/SwBr/archive/SwBr.tar.gzAdd fancy process load display in slim And stuff
README.md | 397 ++++++++++-------------- sw_theme.h | 194 ++++++++++++ swbr.c | 850 ++++++++++++++++++++++++++++++++++++++++++++++++++-- swbr_config.example | 66 +++- 4 files changed, 1236 insertions(+), 271 deletions(-) diff --git a/README.md b/README.md index 7c67e52..179962c 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,220 @@ -# SwBr +# swbr -**100% Vibecode but tested.** +A floating bar for [Sway](https://swaywm.org), on the layer shell. It sits on +top of the tiled windows at the screen edge instead of taking space from them. +Hover it and press space and it folds into a thin strip that still tells you +what is going on. -A floating status bar for Sway. Replaces `swaybar`. Binary: `swbr`. + -It sits on the layer shell above the tiled windows, flush with the top edge. -Hover it, press space, and it rolls up into a 5px signal strip. - - - -Folded: workspace slots, twelve clock dots, gauges — still readable, still -clickable. - - - -Version: **0.8.1** - -## Why not swaybar - -- Floats above the windows instead of taking their space (`exclusive=0`). -- Folds away on a key, without re-tiling anything. -- Bar squared off against the screen edge, rounded on the free side. The - focused workspace is a full-height block in the appwheel orange. -- Same look and same `key=value` config as `swov` and `appwheel`. +100% vibecode, but tested. ## Build ```sh +sudo apt install build-essential libwayland-dev make +make install # ~/.local/bin, or PREFIX=/usr/local +make config # optional: swbr_config.example -> ~/.config/swbr/config ``` -or by hand: +`libwayland-client` is the only library it links. The wlr-layer-shell protocol +glue is vendored, so there is no `wayland-scanner` step. Fonts come from +`stb_truetype.h`, also vendored. -```sh -cc -std=c11 -O2 -Wall -Wextra -o swbr swbr.c -lwayland-client -lm -``` +Needs a running sway session (`$SWAYSOCK`) and any TTF font. `fc-match` is used +to pick one if it is there. -`stb_truetype.h` sits next to the source, same as for `appwheel`. -The layer-shell protocol glue is vendored inside `swbr.c`, so there is no -`wayland-scanner` step. +## Use -Needs: `libwayland-client`, a running Sway session (`$SWAYSOCK`). -Optional: `fc-match` to pick up the desktop font. +``` +exec_always swbr --replace +``` -## Install +`--replace` terminates a bar that is already running and waits for it to go, +which is what you want on a sway reload. Do not use `pkill swbr` on a separate +line for this: sway forks each `exec_always` without waiting for it, so the +kill and the new bar race each other and the kill usually wins — which looks +exactly like the bar refusing to start. -```sh -make install # -> ~/bin/swbr, override with PREFIX= or BINDIR= -make config # -> ~/.config/swbr/config, never overwrites -``` +| | | +| --- | --- | +| left click a workspace | switch to it | +| hover the bar, press `space` | fold it into the signal strip, and back | +| click a cell | run its `button1=` command | +| `pkill -USR1 swbr` | fold or unfold every bar | -**Delete the `bar { ... }` block from your sway config.** If you leave it, -swaybar keeps running underneath and you see its text peeking out below SwBr. -Then add: +The right hand side is made of **cells**: one command each, run on an interval +or kept running and read line by line. ``` -exec_always pkill -x swobar; exec_always ~/bin/swbr -``` +cell=clock +clock.cmd=date '+%H:%M' +clock.interval=20 -Your old `status_command` script keeps working — point the config at it: +cell=vol +vol.cmd=pactl get-sink-volume @DEFAULT_SINK@ | grep -o '[0-9]*%' | head -1 +vol.interval=2 +vol.button1=pavucontrol -``` -status_command=~/bin/sway_bar_status.sh +bar={workspaces||vol,clock} ``` -## Cells +`bar=` is one line that places everything: `||` splits left / center / right, +`,` separates cells, `(a,b)` glues two together. Keep it last in the config. -The right hand side is a row of cells. One command per cell, its own -interval, its own colour. No status script in between. +## Folded -``` -cell=cpu # order of the cell= lines = order on the bar -cpu.cmd=cpu_in_percent -cpu.fmt=%s% -cpu.interval=2 -cpu.warn=>70:cecb00 # recolour at or above 70 -cpu.crit=>80:cc0403 -cpu.min_w=52 # never jitter when the number gets shorter -cpu.sep=0 # no separator: glue the next cell to this one -cpu.button1=foot -e htop # click it -``` +`space` over the bar folds it to a few pixels. The strip keeps saying +something. + + -| Field | Does | +| `NAME.slim=` | on the strip | | --- | --- | -| `cmd` | shell command, its output is the text | -| `interval` | seconds; `0` = once, `-1` = stream every line | -| `fmt` | `%s` is the output; no printf escaping | -| `color` `bg` | fixed colours | -| `warn` `crit` | `>70:cecb00` / `<12:ff2222` on the first number | -| `min_w` `max_w` `align` | width and placement of the text | -| `sep` | `0` combines this cell with the next | -| `hide_empty` | prints nothing = cell and separator disappear | -| `empty` | text drawn instead of hiding, so the area stays clickable | -| `gap` | space after the cell; `0` glues it to the next one | -| `pad` | padding inside the cell | -| `slim` | folded strip: `auto`, `tick`, `bar`, `clock`, `off` | -| `slim_min` `slim_max` | value range a gauge maps | -| `slim_w` | width in the folded strip | -| `markup` | `<span foreground="#rrggbb">` inside the output | -| `button1..9` | per-cell click commands | -| `pos` | `left`, `center` or `right` group | -| `scroll` | fixed slot, `..` when too long, scrolls on hover | - -`swbr_config.example` ships a full set that reproduces a typical swaybar -script: cmus, volume, docker, mpv, clock, cpu, temperature, battery. - -With no cells configured, `status_command` still works the old way. +| `tick` | a block, on while the cell has output | +| `bar` | a gauge, `slim_min`–`slim_max` | +| `clock` | twelve bars, lit up to the hour — two at 02:00, still two at 02:55 | +| `presence` | a block in the `running` colour, there only while the program is | +| `media` | two full-height bars while playing, the same two short and dim when paused, nothing when stopped | +| `auto` | pick from what the cell prints | +| `off` | nothing | + +A cell keeps its own colour when folded, markup included, so a green "playing" +glyph stays green down there. `slim_color` overrides it, `slim_color2` is the +second one a clock uses for its minutes. `slim_on=` says which output counts as +playing for `slim=media` — it has to match what your command prints. Without +it swbr guesses from the usual glyphs and the words "playing" and "paused", +which only helps if your player happens to print one of them. + +For cmus there is no guessing: -## Messages +``` +cell=music +music.source=cmus +``` -Anything on the system can shout at the bar through a fifo -(`$XDG_RUNTIME_DIR/swbr.fifo`, override with `msg_fifo=`): +Two cells, if you want the icon separate from the title: -```sh -swbr --msg "warn: backup running" -swbr --msg "error: disk full" -swbr --msg clear -echo "info: done" > $XDG_RUNTIME_DIR/swbr.fifo +``` +cell=cmusui +cmusui.source=cmus +cmusui.cmus_fmt=%i # just ▶ or ⏸ + +cell=cmus +cmus.source=cmus +cmus.cmus_fmt=%n # just Artist — Title +cmus.slim=off ``` -`info:` / `warn:` / `error:` pick the colour. `msg_timeout=8` seconds, `0` -keeps it until cleared, and clicking it dismisses it. - -`msg_target=cmus` hands a cell over to the message while one is up — the -music title becomes the warning, then goes back to being the title. With no -target the message is drawn centred on its own. Folded, `msg_flash=1` paints -the whole strip in the message colour, so an error is visible at 4px. +`cmus_fmt` understands `%i` (the icon, which follows the real state), `%n` +(artist — title, or just the title), `%a`, `%t` and `%s` (the word playing or +paused). The default is `%i %n`. -## Layout +That is the whole config, and `source=cmus` sets the command itself — a +`music.cmd=` from an older config would leave swbr parsing a script's output +instead of cmus's own, so setting one switches the cell back to a plain +command. swbr runs `cmus-remote -Q` and reads the `status` line, so playing and paused are facts, not a guess at what a script +printed. The bar shows `▶ Artist — Title` or `⏸ Artist — Title`, the icon +following the actual state, and the cell disappears when cmus is stopped or +not running. Left click toggles pause, middle skips forward, right goes back; +`music.cmd=`, `music.interval=` and the binds override any of it. -One line places everything: +`swbr --probe` prints what it decided for every cell, and the text it decided +it from: ``` -bar={workspaces,mode||cmus,volume,(docker,mpv),clock,(cpu,temp),(power,battery)} +cell music iv=2 min_w=0 sep=1 '♪ Boards of Canada — Roygbiv' + slim=media playing=yes slim_on unset, guessing from the text ``` -- `||` splits groups — one part is left, two are left and right, three are - left, centre and right. -- `,` separates items, `(a,b)` glues them with no separator between. -- Names are your cell names plus the built-ins `workspaces` and `mode`. +`signals=0` turns the whole thing off and leaves a plain strip. -It assigns group, order and separators, so keep it last in the config. The -left group reserves its width first, then the right one is placed against it, -so nothing ever overlaps. +## Load per workspace -`min_width=800` makes the bar a floating pill instead of a full-width strip, -and it **sizes itself to its content**: never narrower than `min_width`, -never wider than the screen. A cell appearing — charging, a docker list, a -longer clock — grows the bar instead of being cut off. `min_width=0` goes -back to spanning the whole output. `align_x=center|left|right` places it. +A moon in the corner of each workspace button says how much processor time the +windows on that workspace are using: a sliver when it is idling, whole when something +is working. It is drawn held back towards the bar's own `dim`, since it is +secondary to the name. Folded, the workspace slot fills from the bottom +instead, because a moon that size would be invisible. -## Buttons that stay put +swbr asks sway which window belongs to which workspace, reads `/proc`, and +credits every process to the nearest ancestor that owns a window — so a build +running in a terminal counts towards the workspace that terminal is on. Each +process is measured by its own cpu time *plus* the time of the children it has +already reaped, which is what makes a build show up at all: every compiler +process lives for a moment and is gone long before the next sample, and its +time only survives in its parent's totals. +Anything with no window above it, a daemon or the session itself, belongs to +no workspace and is left out. The colour runs from `running` through +`slim_warm` into `urgent`. -A cell that prints nothing disappears — fine for docker, wrong for a media -control you want to click. Two fields fix it: - -- `empty=♪` draws a placeholder instead of hiding, so the area and its click - bindings survive. -- `gap=0` glues a cell to the next one; with a `bg` on both, they render as - one continuous box. +``` +ws_cpu=1 ws_cpu_interval=3 ws_cpu_min=4 ws_cpu_full=60 +``` -The shipped config uses this for cmus: the title is a scrolling text cell, -the transport controls are their own cell right next to it. The controls can -never be truncated away by a long title, and when cmus is not running the -title vanishes while the button stays and starts it. +The numbers are written to `$XDG_RUNTIME_DIR/swbr-cpu` so swov can show the +same thing on its tiles without measuring anything itself — a rate needs two +samples seconds apart, and swov is only on screen for a moment. `swbr --probe` +prints what it last measured, and how many windows it found in sway's tree to +attribute work to — if that count is zero, nothing can ever show. -## Text cells +## Messages -`NAME.scroll=1` with a `min_w` gives a cell a fixed slot. Longer text is cut -with `..`, and scrolls through while the pointer sits on it. When the bar -runs out of room, these are the cells that give up width — nothing else -moves. +Other programs can shout at the bar, which shows the text in place of a cell: -## Use +```sh +swbr --msg 'warn: battery at 9%' +swbr --msg clear +echo 'info: build done' > $XDG_RUNTIME_DIR/swbr.fifo +``` -| Action | Result | -| --- | --- | -| Left click a workspace | switch to it | -| Hover + `space` | fold / unfold the bar | -| `pkill -USR1 swbr` | fold / unfold without the mouse | -| Click a message | dismiss it | -| Mouse buttons | run `button1`..`button9` commands | - -## The folded strip - -Four pixels tall and still readable: - -- **Workspaces** keep a fixed slot each, so slot 3 is always workspace 3 — - missing ones stay as faint placeholders and nothing shifts around. -- **A clock cell** (`slim=clock`) becomes twelve dots: hours up to now are - lit, the current hour grows with the minutes. -- **Anything with a percentage, or with warn/crit thresholds**, becomes a - gauge: a visible track filled to its value, in that cell's current colour. - Battery red still reads as red. `slim_min`/`slim_max` map any range, so a - temperature cell can be a gauge over 40..100 C. -- **A message** takes the whole strip in its colour. - -Per cell: `slim=auto|tick|bar|clock|off`, plus `slim_min`/`slim_max`. -Height is `collapsed_px` (5 by default). - -The strip stays **clickable**: workspace slots still switch workspaces, and a -cell's button bindings still fire. Give a media cell `slim=tick` (not `off`) -and `slim_w=26`, and it stays on the strip as a play/pause button you can -find and hit — the screen edge makes a 5px target easy. - -For something you must be able to reach without aiming, use the bar-wide -`button2=` / `button6=` / `button7=` bindings: they fire anywhere no cell -sits, folded or not. Start folded with `swbr --slim`. - -Elements sit in the same order as the expanded bar, and cells glued in the -layout stay glued here too. `swbr --probe` prints the whole strip left to -right with each element's mode, colour and fill, which is the fastest way to -work out which bar is which. - -## Screen modes - -Sizes are logical pixels, multiplied by the output scale — the bar looks the -same on a HiDPI screen. `height=0` derives the height from the font, and -`ui_scale` scales all text at once. One bar per output, each with its own -workspaces. +`msg_target=` says which cell is taken over, `msg_timeout=` for how long. ## Config -`~/.config/swbr/config`, `key=value`, one per line. -Every key also works on the command line: `swbr ui_scale=1.2 exclusive=1`. +`${XDG_CONFIG_HOME:-~/.config}/swbr/config`, `key=value`, `#` comments. Every +key is also a command line option: ```sh -swbr --help # all keys -swbr --dump-config # a config file with every default -swbr --probe # outputs, sizes, font metrics, live cell values -swbr --msg TEXT # send a message to the running bar -swbr --slim # start folded -swbr --version # SwBr 0.8.1 (build eaebdc09) <- md5 of swbr.c +swbr position=bottom height=32 +swbr --dump-config > ~/.config/swbr/config +swbr --probe # outputs, sizes, font metrics and what each cell prints ``` -See `swbr_config.example` for the commented version. +`swbr_config.example` lists everything with defaults. The ones worth knowing: + +| key | | +| --- | --- | +| `position`, `layer`, `height` | where the bar sits and how tall it is | +| `exclusive` | `1` = reserve the space, `0` = float above the windows | +| `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` | which monitors get a bar | +| `ws_names`, `ws_inset`, `ws_radius` | workspace buttons: numbers or names, pills or blocks | +| `font`, `font_alt`, `ui_scale`, `text_px` | text | +| `markup` | the pango subset: `<span foreground=..>`, `<b>`, `&` | +| `hide_key`, `collapsed_px`, `anim_ms` | folding | +| `signals`, `slim_ws_slots`, `slim_warm` | what the folded strip shows | +| `status_command` | i3bar-style status, used when no cells are configured | + +### Shared config + +Colours and fonts for swbr, swov and appwheel can be set once in +`${XDG_CONFIG_HOME:-~/.config}/sw/config`, using role names (`surface`, +`accent`, `hl`, ...) that each program maps onto its own keys. `sw_theme.h` +lists them all. + +Keys written before any section go to all three programs; a `[swbr]` section +goes to swbr only and takes its own key names as well. The config above is read +afterwards, so it always wins, and the command line wins over that. ## Notes -- `hover_keys=1` grabs the keyboard while the pointer is over the bar. That is - what lets `space` work without a click first. Keystrokes typed while the - pointer rests on the bar go to the bar, not to your window. Set - `hover_keys=0` and use `SIGUSR1` if that bothers you. -- `hide_key` matches the physical key, not the layout. -- Status markup: `<span foreground=..>`, `<span background=..>`, `<b>`, `<i>` - and the XML entities. The i3bar JSON protocol is not supported. -- No system tray. - -## Tested - -- Builds clean with `-Wall -Wextra`. `--version` carries the md5 of the - source, so you always know which build is running. -- Config parser: inline comments stripped, `#` inside quoted command text - kept, verified against the shipped example. -- Vertical centering checked at scale 1 and 2: digits sit equally far from - both edges. -- Merged backgrounds verified pixel by pixel: no bare bar shows between two - glued cells, so the pair reads as one panel. -- Media glyphs checked against the font before shipping them: `U+23F8` and - the rest of that block are missing from DejaVu, so the config uses glyphs - that exist. -- The folded paint path is now the same code the compositor gets, run - headless in tests: the music button lands at 582..608 in its own colour - with its binding attached, and a middle click anywhere else falls through - to the bar-wide binding. -- Self-sizing checked across states: 800px idle, 1217px with charging, temp - and a docker list, and capped at the 1920px screen when content overflows. -- Folded strip rendered and sampled pixel by pixel: workspace slots, clock - dots, and gauge fills matching their values (47%, 63%, 60%, 88%) against a - track that is actually visible. -- cmus split checked both ways: idle leaves only the button and its click - binding, playing keeps the controls at full width beside a truncated title. -- Focused workspace checked pixel by pixel: solid `cb9b00` from the top row - to the bottom row, square corners, and the baseline lift is exactly 2px. -- Layout DSL parsed and rendered: groups, glued items, hit boxes measured on - an 800px bar — workspaces and cells no longer overlap, the last cell lands - exactly on the right padding, and a hovered text cell scrolls. -- Messages tested end to end: fifo round trip, level parsing, cell takeover - and hand-back, and the centred-group layout landing on the bar's midpoint. -- Markup parser, glyph cache, text metrics and the rounded-rect rasterizer - run under ASan/UBSan against a real status line: no leaks, no overruns, - correct premultiplied output, square top edge, rounded bottom corners. -- `--dump-config` output re-parses without warnings. -- Cells tested end to end: config parse, process spawn and reap, thresholds, - hidden cells dropping their separators, `sep=0` grouping, `min_w`, and the - resulting layout — under ASan/UBSan. -- `make`, `make debug` and `make clean` run clean. -- Not yet run against a live compositor — that part is yours. +- Talks to the sway IPC socket directly: no `swaymsg`, no `jq`. +- One bar per output, each with its own surface and buffer. +- Text is rasterised in software into an ARGB32 buffer — no GPU, no SDL. +- `hover_keys=1` grabs the keyboard only while the pointer is over the bar, + which is what makes `hide_key` work without clicking first. +- A cell with `interval=-1` keeps its command running and takes every line it + prints, so a script can push updates the moment they happen. diff --git a/sw_theme.h b/sw_theme.h new file mode 100644 index 0000000..63d22c6 --- /dev/null +++ b/sw_theme.h @@ -0,0 +1,194 @@ +/* sw_theme.h — one look for swov, appwheel and swbr. + * + * ${XDG_CONFIG_HOME:-~/.config}/sw/config + * + * key=value, '#' comments, same as every other config here. Keys written + * before any [section] go to all three programs; keys inside [swov], + * [appwheel] or [swbr] go to that one only. + * + * Outside a section only *role* names are understood (surface, accent, hl, + * ...). Each program translates them to its own keys, and roles it has no use + * for are dropped. Inside a section every key of that program works as well. + * + * Order: built-in defaults -> this file -> the program's own config -> the + * command line. The specific always wins over the shared. + * + * Drop-in header, no dependencies. Each program calls sw_shared_apply() with + * its own setter, right before it reads its own config file. + */ +#ifndef SW_THEME_H +#define SW_THEME_H + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <ctype.h> + +#define SW_MAX_PAIRS 256 +#define SW_KEYLEN 48 +#define SW_VALLEN 256 +#define SW_MAXNATIVE 4 + +/* ------------------------------------------------------------ role table + * NULL = this program has nothing for that role. Several native keys are + * separated by commas: swbr paints its focused workspace button with `hl`. + */ +typedef struct { + const char *role, *swov, *appwheel, *swbr; +} SwRole; + +static const SwRole SW_ROLES[] = { + /* role swov appwheel swbr */ + { "bg", "bg", "bg", NULL }, + { "surface", "tile", "ring", "bg" }, + { "surface_alt", "tile_sel", "ring2", NULL }, + { "surface_hi", "tile_hover", NULL, "ws_visible_bg" }, + { "inset", "mini_bg", "center", NULL }, + { "card", "card", NULL, "ws_bg" }, + { "card_hi", "card_hover", NULL, NULL }, + { "card_focus", "card_focus", NULL, NULL }, + { "text", "text", "text", "text" }, + { "subtext", "subtext", NULL, "ws_fg,ws_visible_fg" }, + { "dim", "dim", "dim", "dim" }, + { "hint", "hint", NULL, NULL }, + { "accent", "accent", "accent", "accent,msg_info" }, + { "hl", "hl", "hl", "hl,ws_focused_bg,mode_bg" }, + { "hltext", "hltext", "hltext", "ws_focused_fg,mode_fg,ws_urgent_fg" }, + { "current", "current", NULL, NULL }, + { "match", "match", NULL, NULL }, + { "warn", NULL, NULL, "msg_warn" }, + { "urgent", "urgent", NULL, "urgent,ws_urgent_bg,msg_error" }, + { "ok", NULL, NULL, "running" }, + { "outline", "outline", NULL, "outline,separator_color" }, + { "shadow", "shadow_color", NULL, NULL }, + + { "font", "font", "font", "font" }, + { "font_bold", "font_bold", NULL, NULL }, + { "ui_scale", "ui_scale", "ui_scale", "ui_scale" }, + { "anim_ms", "anim_ms", "anim_ms", "anim_ms" }, + { "corner_radius", "radius", NULL, "radius" }, + { "ssaa", "ssaa", "ssaa", NULL }, + { "icons", "icons", "icons", NULL }, + { "icon_px", "icon_px", "icon_px", NULL }, +}; + +/* ------------------------------------------------------------------ path */ + +static const char *sw_shared_path(void) +{ + static char path[512]; + const char *xdg = getenv("XDG_CONFIG_HOME"); + const char *home = getenv("HOME"); + if (xdg && *xdg) snprintf(path, sizeof path, "%s/sw/config", xdg); + else if (home && *home) snprintf(path, sizeof path, "%s/.config/sw/config", home); + else return NULL; + return path; +} + +/* --------------------------------------------------------------- mapping */ + +static int sw_ci_eq(const char *a, const char *b) +{ + for (; *a && *b; ++a, ++b) + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return 0; + return *a == *b; +} + +static const char *sw_role_for(const SwRole *r, const char *app) +{ + if (sw_ci_eq(app, "swov")) return r->swov; + if (sw_ci_eq(app, "appwheel")) return r->appwheel; + if (sw_ci_eq(app, "swbr")) return r->swbr; + return NULL; +} + +/* Translate one shared key into the program's own keys. + * `scoped` means the key came from a [section], where the program's own key + * names are allowed too. Returns how many names were written. */ +static int sw_map(const char *app, const char *key, int scoped, + char out[][SW_KEYLEN], int max) +{ + for (size_t i = 0; i < sizeof SW_ROLES / sizeof *SW_ROLES; ++i) { + if (!sw_ci_eq(SW_ROLES[i].role, key)) continue; + const char *nat = sw_role_for(&SW_ROLES[i], app); + if (!nat) return 0; /* not this program's business */ + int n = 0; + while (*nat && n < max) { + const char *comma = strchr(nat, ','); + size_t len = comma ? (size_t)(comma - nat) : strlen(nat); + if (len >= SW_KEYLEN) len = SW_KEYLEN - 1; + memcpy(out[n], nat, len); + out[n][len] = '\0'; + ++n; + if (!comma) break; + nat = comma + 1; + } + return n; + } + if (!scoped) return 0; /* global: roles only */ + snprintf(out[0], SW_KEYLEN, "%s", key); /* [section]: pass through */ + return 1; +} + +/* ---------------------------------------------------------------- reader */ + +static char *sw_trim(char *s) +{ + while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') ++s; + char *e = s + strlen(s); + while (e > s && (e[-1] == ' ' || e[-1] == '\t' || e[-1] == '\r' || e[-1] == '\n')) --e; + *e = '\0'; + return s; +} + +/* Read the shared file and hand every setting that concerns `app` to `set`, + * in file order, so a later line overrides an earlier one. Returns 1 if the + * file was there. */ +static int sw_shared_apply(const char *app, + void (*set)(void *ud, const char *key, const char *val), + void *ud) +{ + const char *path = sw_shared_path(); + if (!path) return 0; + FILE *f = fopen(path, "r"); + if (!f) return 0; + + char line[1024], section[SW_KEYLEN] = ""; + int mine = 1; /* before any section: all */ + + while (fgets(line, sizeof line, f)) { + char *s = sw_trim(line); + if (!*s || *s == '#' || *s == ';') continue; + + if (*s == '[') { /* [swov] */ + char *end = strchr(s, ']'); + if (!end) continue; + *end = '\0'; + snprintf(section, sizeof section, "%s", sw_trim(s + 1)); + mine = sw_ci_eq(section, app) || sw_ci_eq(section, "all") || + sw_ci_eq(section, "global"); + if (sw_ci_eq(section, "all") || sw_ci_eq(section, "global")) section[0] = '\0'; + continue; + } + if (!mine) continue; + + char *eq = strchr(s, '='); + if (!eq) continue; + *eq = '\0'; + char *k = sw_trim(s), *v = sw_trim(eq + 1); + char *hash = strchr(v, '#'); /* trailing comment */ + if (hash && hash > v && (hash[-1] == ' ' || hash[-1] == '\t')) { + *hash = '\0'; + v = sw_trim(v); + } + if (!*k) continue; + + char native[SW_MAXNATIVE][SW_KEYLEN]; + int n = sw_map(app, k, section[0] != '\0', native, SW_MAXNATIVE); + for (int i = 0; i < n; ++i) set(ud, native[i], v); + } + fclose(f); + return 1; +} + +#endif /* SW_THEME_H */ diff --git a/swbr.c b/swbr.c index 1857648..de0d7b3 100644 --- a/swbr.c +++ b/swbr.c @@ -46,6 +46,7 @@ #include <ctype.h> #include <errno.h> +#include <dirent.h> #include <fcntl.h> #include <limits.h> #include <math.h> @@ -67,8 +68,10 @@ #include <time.h> #include <unistd.h> +#include "sw_theme.h" + #define APP_ID "swbr" -#define SWBR_VERSION "0.8.1" +#define SWBR_VERSION "0.11.0" #ifndef SWBR_BUILD /* set by the Makefile: md5 of this file */ #define SWBR_BUILD "unknown" #endif @@ -78,7 +81,8 @@ #define MAX_BINDS 10 #define MAX_CELLS 24 -enum { SLIM_AUTO, SLIM_TICK, SLIM_BAR, SLIM_CLOCK, SLIM_OFF }; +enum { SLIM_AUTO, SLIM_TICK, SLIM_BAR, SLIM_CLOCK, SLIM_PRESENCE, + SLIM_MEDIA, SLIM_OFF }; /* ------------------------------------------------------------------ util */ @@ -526,7 +530,8 @@ enum { IPC_RUN_COMMAND = 0, IPC_GET_WORKSPACES = 1, IPC_SUBSCRIBE = 2, - IPC_GET_OUTPUTS = 3 + IPC_GET_OUTPUTS = 3, + IPC_GET_TREE = 4 }; static int sway_fd = -1; /* request socket */ @@ -765,21 +770,268 @@ static void ws_reload(bool full_names) jfree(r); } +/* ------------------------------------------------------ cpu per workspace + * + * Who is working, and where. sway knows which pid each window belongs to and + * which workspace that window is on; /proc knows how much processor time each + * process has used. Put the two together and a workspace has a load. + * + * A process is credited to the nearest ancestor that owns a window, so a + * compiler started in a terminal counts towards the workspace that terminal + * is on. Anything with no window above it — daemons, the session itself — + * belongs to no workspace and is left out. + * + * swbr is the one that measures, because a rate needs two samples a few + * seconds apart and only a program that stays running has those. It writes + * the answer where swov can read it, so swov does not have to measure + * anything to draw the same numbers. + */ + +typedef struct { int pid; int ws; } PidWs; /* window pid -> ws_list index */ + +static PidWs cpu_win[512]; +static int cpu_nwin; +static double cpu_ws_pct[MAX_WORKSPACES]; /* by ws_list index */ +static char cpu_ws_name[MAX_WORKSPACES][128]; +static int cpu_ws_n; +static double cpu_last_at; + +typedef struct { + int pid, ppid, ws; + unsigned long long own, chld; +} ProcSample; +static ProcSample *cpu_prev; +static int cpu_prev_n; + +typedef struct { int pid, ws; unsigned long long delta; } CpuTop; +static CpuTop cpu_top[5]; /* the busiest of the last sample, for --probe */ +static int cpu_top_n; + +/* own = this process's own cpu time, chld = the time of the children it has + * already reaped. The second one is what makes a build visible: every cc1 + * lives for a moment and is gone long before the next sample, and its time + * lands in make's cutime rather than anywhere we could see it directly. */ +static bool proc_stat_of(int pid, int *ppid, + unsigned long long *own, unsigned long long *chld) +{ + char path[64], buf[1024]; + snprintf(path, sizeof(path), "/proc/%d/stat", pid); + FILE *f = fopen(path, "r"); + if (!f) return false; + size_t got = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[got] = 0; + + char *close = strrchr(buf, ')'); /* comm can hold anything */ + if (!close) return false; + + unsigned long long ut = 0, st = 0; + long long cut = 0, cst = 0; + int pp = 0; + /* after "): state ppid pgrp session tty tpgid flags min_flt cmin_flt + maj_flt cmaj_flt utime stime cutime cstime" */ + if (sscanf(close + 1, " %*c %d %*d %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu %lld %lld", + &pp, &ut, &st, &cut, &cst) != 5) return false; + if (cut < 0) cut = 0; + if (cst < 0) cst = 0; + *ppid = pp; + *own = ut + st; + if (chld) *chld = (unsigned long long)(cut + cst); + return true; +} + +static int cpu_ws_of_pid(int pid) +{ + for (int hop = 0; hop < 12 && pid > 1; ++hop) { + for (int i = 0; i < cpu_nwin; ++i) + if (cpu_win[i].pid == pid) return cpu_win[i].ws; + int ppid; unsigned long long own, chld; + if (!proc_stat_of(pid, &ppid, &own, &chld)) return -1; + pid = ppid; + } + return -1; +} + +/* every view in sway's tree, as pid -> the workspace it sits on */ +static void cpu_collect_windows(const JV *node, int ws_idx) +{ + if (!node || node->type != J_OBJ) return; + + const char *type = jstr(node, "type", ""); + if (!strcmp(type, "workspace")) { + const char *name = jstr(node, "name", ""); + ws_idx = -1; + for (int i = 0; i < ws_count; ++i) + if (!strcmp(ws_list[i].name, name)) { ws_idx = i; break; } + } + int pid = jint(node, "pid", -1); + if (pid > 0 && ws_idx >= 0 && cpu_nwin < (int)(sizeof cpu_win / sizeof *cpu_win)) { + cpu_win[cpu_nwin].pid = pid; + cpu_win[cpu_nwin].ws = ws_idx; + cpu_nwin++; + } + for (int i = 0; i < node->count; ++i) { + JV *kid = node->items[i]; + if (!kid) continue; + if (kid->type == J_ARR) + for (int j = 0; j < kid->count; ++j) cpu_collect_windows(kid->items[j], ws_idx); + else if (kid->type == J_OBJ) + cpu_collect_windows(kid, ws_idx); + } +} + +static void cpu_write_cache(void) +{ + const char *dir = getenv("XDG_RUNTIME_DIR"); + char path[512], tmp[540]; + if (dir && *dir) snprintf(path, sizeof(path), "%s/swbr-cpu", dir); + else { + const char *home = getenv("HOME"); + if (!home) return; + snprintf(path, sizeof(path), "%s/.cache/swbr-cpu", home); + } + snprintf(tmp, sizeof(tmp), "%s.new", path); + + FILE *f = fopen(tmp, "w"); + if (!f) return; + for (int i = 0; i < cpu_ws_n; ++i) + fprintf(f, "%s\t%.1f\n", cpu_ws_name[i], cpu_ws_pct[i]); + fclose(f); + if (rename(tmp, path) != 0) unlink(tmp); +} + +/* one sample; the first call only records, the ones after it measure */ +static void cpu_sample(void) +{ + JV *tree = sway_query(IPC_GET_TREE); + if (!tree) return; + cpu_nwin = 0; + cpu_collect_windows(tree, -1); + jfree(tree); + + DIR *d = opendir("/proc"); + if (!d) return; + + int cap = 1024, n = 0; + ProcSample *now = (ProcSample *)xmalloc((size_t)cap * sizeof *now); + struct dirent *e; + while ((e = readdir(d)) != NULL) { + const char *p = e->d_name; + bool digits = *p != 0; + for (const char *q = p; *q; ++q) if (*q < '0' || *q > '9') { digits = false; break; } + if (!digits) continue; + if (n == cap) { cap *= 2; now = (ProcSample *)xrealloc(now, (size_t)cap * sizeof *now); } + int pid = atoi(p), ppid; unsigned long long own, chld; + if (!proc_stat_of(pid, &ppid, &own, &chld)) continue; + now[n].pid = pid; now[n].ppid = ppid; + now[n].own = own; now[n].chld = chld; now[n].ws = -1; + n++; + } + closedir(d); + + double at = now_ms() / 1000.0; + double dt = at - cpu_last_at; + + if (cpu_prev && dt > 0.2) { + long hz = sysconf(_SC_CLK_TCK); + if (hz <= 0) hz = 100; + long ncpu = sysconf(_SC_NPROCESSORS_ONLN); + if (ncpu <= 0) ncpu = 1; + + double used[MAX_WORKSPACES] = { 0 }; + cpu_top_n = 0; + for (int i = 0; i < n; ++i) { + const ProcSample *was = NULL; + for (int j = 0; j < cpu_prev_n; ++j) /* same order, mostly */ + if (cpu_prev[j].pid == now[i].pid) { was = &cpu_prev[j]; break; } + + unsigned long long total = now[i].own + now[i].chld, before = 0; + if (was && was->own + was->chld <= total) before = was->own + was->chld; + /* no `was` means it was born inside this window, so all of its + time belongs to this window */ + unsigned long long delta = total - before; + + int ws = cpu_ws_of_pid(now[i].pid); + now[i].ws = ws; + if (!delta) continue; + if (ws >= 0 && ws < MAX_WORKSPACES) + used[ws] += (double)delta / (double)hz; + + /* keep the five busiest, wherever they went */ + int slot = cpu_top_n < 5 ? cpu_top_n : -1; + if (slot < 0) { + unsigned long long worst = delta; + for (int j = 0; j < 5; ++j) + if (cpu_top[j].delta < worst) { worst = cpu_top[j].delta; slot = j; } + } else { + cpu_top_n++; + } + if (slot >= 0) { + cpu_top[slot].pid = now[i].pid; + cpu_top[slot].ws = ws; + cpu_top[slot].delta = delta; + } + } + /* A process that died since the last sample had its whole lifetime + * added to its parent's reaped-children total, and we already counted + * the part of it that ran before the last sample. Take that back, or + * every exit is counted twice. */ + for (int j = 0; j < cpu_prev_n; ++j) { + bool alive = false; + for (int i = 0; i < n; ++i) + if (now[i].pid == cpu_prev[j].pid) { alive = true; break; } + if (alive) continue; + int ws = cpu_prev[j].ws; + if (ws >= 0 && ws < MAX_WORKSPACES) + used[ws] -= (double)(cpu_prev[j].own + cpu_prev[j].chld) / (double)hz; + } + + cpu_ws_n = 0; + for (int i = 0; i < ws_count && i < MAX_WORKSPACES; ++i) { + if (used[i] < 0) used[i] = 0; + double pct = used[i] / dt / (double)ncpu * 100.0; + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + cpu_ws_pct[i] = pct; + str_set(cpu_ws_name[cpu_ws_n], sizeof(cpu_ws_name[0]), ws_list[i].name); + cpu_ws_n++; + } + cpu_write_cache(); + } + + free(cpu_prev); + cpu_prev = now; + cpu_prev_n = n; + cpu_last_at = at; +} + /* ---------------------------------------------------------------- config */ /* A cell is one command whose output is drawn on the right hand side. - * Declared with `cell=NAME`, configured with `NAME.field=value`. */ + * Declared with `cell=NAME`, configured with `NAME.field=value`. + * + * `NAME.source=cmus` is the exception: swbr runs cmus-remote itself and reads + * the answer, so whether something is playing is a fact rather than a guess + * at what a script happened to print. */ +enum { SRC_CMD, SRC_CMUS }; +enum { PS_UNKNOWN = -1, PS_STOPPED = 0, PS_PAUSED = 1, PS_PLAYING = 2 }; typedef struct { char name[32]; char cmd[2048]; int interval; /* seconds; 0 = run once; -1 = keep streaming */ char fmt[128]; /* %s is replaced by the output */ char empty[128]; /* drawn when the command prints nothing */ + char slim_on[64]; /* output containing this = the "on" state */ + int source; /* SRC_CMD, or a player swbr knows itself */ + char src_fmt[128]; /* how a known player is laid out */ + int state; /* what that player is doing: PS_* */ int gap; /* -1 = cell_gap */ int pad; /* inner padding, left and right */ int slim; /* how it shows in the folded strip */ float slim_min, slim_max; /* value range a gauge maps, default 0..100 */ int slim_w; /* custom width in the folded strip */ + Col slim_color, slim_color2; /* strip colour, and the minute colour */ + bool has_slim_color, has_slim_color2; Col color, bg; bool has_color, has_bg; Col warn_col, crit_col; @@ -845,7 +1097,11 @@ typedef struct { char bind[MAX_BINDS][512]; /* colors */ - Col bg, text, dim, accent, hl, urgent, outline; + Col bg, text, dim, accent, hl, urgent, outline, running, slim_warm; + int ws_cpu; /* the load dot on a workspace button */ + int ws_cpu_interval; /* seconds between samples */ + float ws_cpu_min, ws_cpu_full; /* below min nothing shows; full = the + load that paints it urgent */ 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; @@ -911,9 +1167,15 @@ static void config_defaults(Config *c) c->accent = (Col){ 0x89, 0xaf, 0xc4, 0xff }; c->hl = (Col){ 0xcb, 0x9b, 0x00, 0xff }; c->urgent = (Col){ 0xe0, 0x53, 0x3c, 0xff }; + c->running = (Col){ 0x3d, 0xdc, 0x84, 0xff }; + 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_fg = (Col){ 0xb3, 0xc0, 0xcd, 0xff }; + c->ws_cpu = 1; + c->ws_cpu_interval = 3; + c->ws_cpu_min = 4.0f; + c->ws_cpu_full = 60.0f; c->ws_focused_bg = (Col){ 0xcb, 0x9b, 0x00, 0xff }; c->ws_focused_fg = (Col){ 0x14, 0x14, 0x14, 0xff }; c->ws_visible_bg = (Col){ 0x3c, 0x4a, 0x5c, 0xff }; @@ -971,19 +1233,55 @@ static void cell_set(Config *c, const char *name, const char *k, const char *v) { Cell *e = cell_add(c, name); if (!e) return; - if (!strcmp(k, "cmd") || !strcmp(k, "command")) str_set(e->cmd, sizeof(e->cmd), v); + if (!strcmp(k, "cmd") || !strcmp(k, "command")) { + str_set(e->cmd, sizeof(e->cmd), v); + e->source = SRC_CMD; /* your command prints, swbr shows it */ + } else if (!strcmp(k, "interval")) e->interval = atoi(v); else if (!strcmp(k, "fmt") || !strcmp(k, "format")) str_set(e->fmt, sizeof(e->fmt), v); else if (!strcmp(k, "empty")) str_set(e->empty, sizeof(e->empty), v); + else if (!strcmp(k, "slim_on")) str_set(e->slim_on, sizeof(e->slim_on), v); + else if (!strcmp(k, "src_fmt") || !strcmp(k, "cmus_fmt")) + str_set(e->src_fmt, sizeof(e->src_fmt), v); + else if (!strcmp(k, "source")) { + if (!strcasecmp(v, "cmus")) { + e->source = SRC_CMUS; + /* everything a cmus cell needs, so the config line is enough. + * The command is not optional: swbr parses cmus-remote's own + * output, so a leftover cmd from a script would leave the cell + * with nothing it can read. */ + str_set(e->cmd, sizeof(e->cmd), "cmus-remote -Q 2>/dev/null"); + if (!e->interval) e->interval = 2; + if (e->slim == SLIM_AUTO) e->slim = SLIM_MEDIA; + if (!*e->bind[1]) str_set(e->bind[1], sizeof(e->bind[1]), "cmus-remote -u"); + if (!*e->bind[2]) str_set(e->bind[2], sizeof(e->bind[2]), "cmus-remote -n"); + if (!*e->bind[3]) str_set(e->bind[3], sizeof(e->bind[3]), "cmus-remote -r"); + } else { + e->source = SRC_CMD; + } + } else if (!strcmp(k, "gap")) e->gap = atoi(v); else if (!strcmp(k, "pad")) e->pad = atoi(v); else if (!strcmp(k, "slim_min")) e->slim_min = (float)atof(v); else if (!strcmp(k, "slim_max")) e->slim_max = (float)atof(v); else if (!strcmp(k, "slim_w")) e->slim_w = atoi(v); + else if (!strcmp(k, "slim_color")) { + e->slim_color.a = 255; + parse_color(v, &e->slim_color); + e->has_slim_color = true; + } + else if (!strcmp(k, "slim_color2")) { + e->slim_color2.a = 255; + parse_color(v, &e->slim_color2); + e->has_slim_color2 = true; + } else if (!strcmp(k, "slim")) { if (!strcasecmp(v, "bar")) e->slim = SLIM_BAR; else if (!strcasecmp(v, "clock")) e->slim = SLIM_CLOCK; else if (!strcasecmp(v, "tick")) e->slim = SLIM_TICK; + else if (!strcasecmp(v, "presence") || !strcasecmp(v, "running") || + !strcasecmp(v, "app")) e->slim = SLIM_PRESENCE; + else if (!strcasecmp(v, "media") || !strcasecmp(v, "play")) e->slim = SLIM_MEDIA; else if (!strcasecmp(v, "off") || !strcmp(v, "0")) e->slim = SLIM_OFF; else e->slim = SLIM_AUTO; } @@ -1086,9 +1384,15 @@ static void config_set(Config *c, const char *k, const char *v) else if (!strcmp(k, "accent")) parse_color(v, &c->accent); else if (!strcmp(k, "hl")) parse_color(v, &c->hl); else if (!strcmp(k, "urgent")) parse_color(v, &c->urgent); + else if (!strcmp(k, "running")) parse_color(v, &c->running); + 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_fg")) parse_color(v, &c->ws_fg); + 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_min")) c->ws_cpu_min = (float)atof(v); + else if (!strcmp(k, "ws_cpu_full")) c->ws_cpu_full = (float)atof(v); else if (!strcmp(k, "ws_focused_bg")) parse_color(v, &c->ws_focused_bg); else if (!strcmp(k, "ws_focused_fg")) parse_color(v, &c->ws_focused_fg); else if (!strcmp(k, "ws_visible_bg")) parse_color(v, &c->ws_visible_bg); @@ -1114,6 +1418,12 @@ static void strip_comment(char *v) } } +/* the shared ~/.config/sw/config, translated into swbr's own keys */ +static void config_set_shared(void *ud, const char *k, const char *v) +{ + config_set((Config *)ud, k, v); +} + static void config_load(Config *c, const char *path) { FILE *f = fopen(path, "r"); @@ -1884,8 +2194,22 @@ static Col msg_color(void) return msg_level >= 2 ? cfg.msg_error : (msg_level == 1 ? cfg.msg_warn : cfg.msg_info); } +static int ctrl_req = 0; /* 1 toggle, 2 fold, 3 unfold */ + +static bool msg_control(const char *t) +{ + if (*t != ':') return false; + t++; + if (!strcasecmp(t, "toggle")) ctrl_req = 1; + else if (!strcasecmp(t, "fold") || !strcasecmp(t, "slim")) ctrl_req = 2; + else if (!strcasecmp(t, "unfold") || !strcasecmp(t, "full")) ctrl_req = 3; + else fprintf(stderr, "swbr: unknown control ':%s'\n", t); + return true; +} + static void msg_set(const char *line) { + if (msg_control(line)) return; const char *t = line; int lvl = 0; if (!strncasecmp(t, "error:", 6)) { lvl = 2; t += 6; } @@ -1905,6 +2229,61 @@ static void msg_set(const char *line) msg_until = cfg.msg_timeout > 0 ? now_ms() + (uint32_t)cfg.msg_timeout * 1000u : 0; } +/* Take over from a bar that is already running. + * + * `pkill swbr` in the sway config does not work reliably: sway forks each + * exec_always without waiting for it, so the kill and the new bar race, and + * the kill often wins — it terminates the instance that was just started. A + * bar that clears the way for itself has nothing to race against. + */ +static void replace_running(void) +{ + DIR *d = opendir("/proc"); + if (!d) return; + + pid_t me = getpid(), victims[32]; + int n = 0; + struct dirent *e; + while ((e = readdir(d)) && n < (int)(sizeof victims / sizeof *victims)) { + bool digits = e->d_name[0] != 0; + for (const char *q = e->d_name; *q; ++q) + if (*q < '0' || *q > '9') { digits = false; break; } + if (!digits) continue; + + pid_t pid = (pid_t)atoi(e->d_name); + if (pid == me || pid <= 1) continue; + + char path[64], comm[64] = ""; + snprintf(path, sizeof(path), "/proc/%d/comm", (int)pid); + FILE *f = fopen(path, "r"); + if (!f) continue; /* gone, or not ours to read */ + if (fgets(comm, sizeof(comm), f)) { + char *nl = strchr(comm, '\n'); + if (nl) *nl = 0; + } + fclose(f); + if (strcmp(comm, APP_ID)) continue; + + struct stat st; + snprintf(path, sizeof(path), "/proc/%d", (int)pid); + if (stat(path, &st) != 0 || st.st_uid != getuid()) continue; + + victims[n++] = pid; + } + closedir(d); + + for (int i = 0; i < n; ++i) kill(victims[i], SIGTERM); + + for (int wait = 0; wait < 40 && n; ++wait) { /* up to two seconds */ + usleep(50000); + int left = 0; + for (int i = 0; i < n; ++i) + if (kill(victims[i], 0) == 0) victims[left++] = victims[i]; + n = left; + } + for (int i = 0; i < n; ++i) kill(victims[i], SIGKILL); /* stubborn */ +} + static void msg_open(void) { msg_resolve_path(); @@ -1990,9 +2369,91 @@ static void cell_spawn(Cell *e) e->len = 0; } +/* `cmus-remote -Q` prints one key per line: + * status playing + * file /music/x.flac + * tag artist Boards of Canada + * tag title Roygbiv + * Nothing at all means cmus is not running. */ +static void cmus_parse(Cell *e) +{ + char status[32] = "", artist[256] = "", title[256] = "", file[512] = ""; + + for (char *line = e->buf; line && *line; ) { + char *nl = strchr(line, '\n'); + if (nl) *nl = 0; + char *v; + if (!strncmp(line, "status ", 7)) + str_set(status, sizeof(status), trim(line + 7)); + else if (!strncmp(line, "file ", 5)) + str_set(file, sizeof(file), trim(line + 5)); + else if (!strncmp(line, "tag artist ", 11)) + str_set(artist, sizeof(artist), trim(line + 11)); + else if (!strncmp(line, "tag title ", 10)) + str_set(title, sizeof(title), trim(line + 10)); + else if (!strncmp(line, "tag albumartist ", 16) && !*artist) + str_set(artist, sizeof(artist), trim(line + 16)); + (void)v; + line = nl ? nl + 1 : NULL; + } + + e->state = !*status ? PS_UNKNOWN + : !strcmp(status, "playing") ? PS_PLAYING + : !strcmp(status, "paused") ? PS_PAUSED + : PS_STOPPED; + + if (e->state == PS_UNKNOWN || e->state == PS_STOPPED) { e->out[0] = 0; return; } + + if (!*title && *file) { /* fall back to the name */ + const char *base = strrchr(file, '/'); + str_set(title, sizeof(title), base ? base + 1 : file); + char *dot = strrchr(title, '.'); + if (dot && dot != title) *dot = 0; + } + + /* ▶ while playing, ⏸ while paused — the icon is the state, so it is right + * whether or not the player prints one */ + const char *icon = e->state == PS_PLAYING ? "\xe2\x96\xb6" : "\xe2\x8f\xb8"; + + char name[600]; + if (*artist && *title) snprintf(name, sizeof(name), "%s — %s", artist, title); + else snprintf(name, sizeof(name), "%s", title); + + /* %i icon %n artist — title %a artist %t title %s playing|paused + * so one cell can be just the icon and another just the name */ + const char *f = *e->src_fmt ? e->src_fmt : "%i %n"; + size_t o = 0; + for (const char *p = f; *p && o < sizeof(e->out) - 1; ++p) { + const char *ins = NULL; + if (*p == '%' && p[1]) { + switch (p[1]) { + case 'i': ins = icon; break; + case 'n': ins = name; break; + case 'a': ins = artist; break; + case 't': ins = title; break; + case 's': ins = e->state == PS_PLAYING ? "playing" : "paused"; break; + case '%': ins = "%"; break; + default: break; + } + } + if (!ins) { e->out[o++] = *p; continue; } + ++p; + size_t l = strlen(ins); + if (o + l >= sizeof(e->out)) l = sizeof(e->out) - 1 - o; + memcpy(e->out + o, ins, l); + o += l; + } + e->out[o] = 0; + char *t = trim(e->out); + if (t != e->out) memmove(e->out, t, strlen(t) + 1); +} + static void cell_finish(Cell *e) { e->buf[e->len] = 0; + + if (e->source == SRC_CMUS) { cmus_parse(e); e->len = 0; return; } + char *t = trim(e->buf); for (char *q = t; *q; ++q) if (*q == '\n' || *q == '\r' || *q == '\t') *q = ' '; str_set(e->out, sizeof(e->out), t); @@ -2070,7 +2531,14 @@ static void cell_text(Cell *e, char *out, size_t cap) if (!*e->fmt) { str_set(out, cap, e->out); return; } const char *pc = strstr(e->fmt, "%s"); if (!pc) { str_set(out, cap, e->fmt); return; } - snprintf(out, cap, "%.*s%s%s", (int)(pc - e->fmt), e->fmt, e->out, pc + 2); + size_t head = (size_t)(pc - e->fmt); + if (head >= cap) head = cap - 1; + memcpy(out, e->fmt, head); + out[head] = 0; + size_t n = strlen(out); + if (n < cap - 1) str_set(out + n, cap - n, e->out); + n = strlen(out); + if (n < cap - 1) str_set(out + n, cap - n, pc + 2); } /* the first number in the output, ignoring markup tags */ @@ -2301,6 +2769,17 @@ static float baseline_for(Font *f, float y0, float vis, float s) return y0 + vis * 0.5f + (f->ascent - f->lineh * 0.5f) + cfg.text_y * s; } +static Col col_mix(Col a, Col b, float t) +{ + t = clampf(t, 0.0f, 1.0f); + Col o; + o.r = (uint8_t)((float)a.r + ((float)b.r - (float)a.r) * t); + o.g = (uint8_t)((float)a.g + ((float)b.g - (float)a.g) * t); + o.b = (uint8_t)((float)a.b + ((float)b.b - (float)a.b) * t); + o.a = (uint8_t)((float)a.a + ((float)b.a - (float)a.a) * t); + return o; +} + static Col col_scale_alpha(Col c, float f) { c.a = (uint8_t)clampi((int)lroundf((float)c.a * clampf(f, 0.0f, 1.0f)), 0, 255); @@ -2341,29 +2820,117 @@ static float slim_width_of(Cell *e, int mode, float s) switch (mode) { case SLIM_CLOCK: return 12.0f * 3.0f * s + 11.0f * 2.0f * s; case SLIM_BAR: return 40.0f * s; + case SLIM_PRESENCE: return 10.0f * s; + case SLIM_MEDIA: return 26.0f * s; case SLIM_OFF: return 0.0f; default: return 12.0f * s; } } +/* The colour a cell shows on the strip: its own slim_color, else the colour + * it painted its own text with, so folding never changes what a thing looks + * like. A cyan clock stays cyan. */ +static Col slim_color_of(Cell *e) +{ + if (e->has_slim_color) return e->slim_color; + char txt[1024]; + Col base; + cell_display(e, txt, sizeof(txt), &base); + if (e->markup) { + Runs rs; + int saved = cfg.markup; + cfg.markup = 1; + markup_parse(txt, &rs, base); + cfg.markup = saved; + for (int i = 0; i < rs.n; ++i) + if (rs.v[i].has_fg) return rs.v[i].fg; + } + return base; +} + +/* +, an up arrow or a bolt mean charging; -, a down arrow mean draining; + * =, a tick mean full. Anything else: no direction shown. */ +static int slim_direction(const char *s) +{ + if (strchr(s, '+') || strstr(s, "\xe2\x86\x91") || strstr(s, "\xe2\x9a\xa1")) return 1; + if (strchr(s, '=') || strstr(s, "\xe2\x9c\x93")) return 0; + if (strchr(s, '-') || strstr(s, "\xe2\x86\x93")) return -1; + return 2; +} + +/* 1 = playing, 0 = not, -1 = the cell has said nothing at all yet. + * + * `slim_on` is the reliable way: give it a word your command prints only while + * something is playing. Without it we guess from the usual glyphs, which only + * works if your player happens to print one of them. `swbr --probe` prints + * what this decided for every cell, next to the text it decided it from. */ +static int media_state(const Cell *e) +{ + /* A player swbr talks to itself has already said which it is. Paused is + * not playing, and that is the distinction the strip is there to show. */ + if (e->source != SRC_CMD) { + if (e->state == PS_UNKNOWN) return -1; + return e->state == PS_PLAYING ? 1 : 0; + } + if (!e->out[0]) return -1; + if (*e->slim_on) return strstr(e->out, e->slim_on) != NULL; + + /* Paused first: a bar showing "⏸ Artist — Title" is not playing, and + * looking for the play glyph first would call it playing anyway. */ + static const char *PAUSED[] = { + "\xe2\x8f\xb8", /* ⏸ */ + "\xe2\x9d\x9a", /* ❚ */ + "paused", + }; + for (size_t i = 0; i < sizeof PAUSED / sizeof *PAUSED; ++i) + if (strcasestr(e->out, PAUSED[i])) return 0; + + static const char *PLAYING[] = { + "\xe2\x96\xb6", /* ▶ */ + "\xf0\x9f\x8e\xb5", /* 🎵 */ + "\xe2\x99\xaa", /* ♪ */ + "playing", + }; + for (size_t i = 0; i < sizeof PLAYING / sizeof *PLAYING; ++i) + if (strcasestr(e->out, PLAYING[i])) return 1; + return 0; +} + static void slim_draw_one(Canvas *cv, Cell *e, int mode, float x, float y, float w, float h, float s) { - Col c = cell_color(e); + Col c = slim_color_of(e); + if (mode == SLIM_PRESENCE) { + /* the cell only exists while its program does, so a block here means + * "that one is up" — its own colour, not shared with any gauge */ + if (!e->has_slim_color) c = cfg.running; + fill_rect(cv, x, y, w, h, c); + return; + } if (mode == SLIM_CLOCK) { time_t t = time(NULL); struct tm tmv; localtime_r(&t, &tmv); - int hour = tmv.tm_hour % 12; + int hour = (tmv.tm_hour + 11) % 12 + 1; /* 1..12, 0:xx reads as 12 */ float dot = 3.0f * s, gap = 2.0f * s; + /* every dot up to the hour, so you count bars instead of finding the + * one lit position: 2 bars at 02:00, still 2 at 02:55. Morning uses + * the second colour, afternoon the main one, so 1:00 and 13:00 differ. */ + Col hc = tmv.tm_hour >= 12 ? c + : (e->has_slim_color2 ? e->slim_color2 : cfg.accent); for (int i = 0; i < 12; ++i) { Col dc = cfg.dim; - float dw = dot; - if (i < hour) dc = cfg.accent; - else if (i == hour) { dc = cfg.hl; dw = dot * (0.35f + 0.65f * (float)tmv.tm_min / 59.0f); } - fill_rect(cv, x + (float)i * (dot + gap), y, dw, h, dc); + dc.a = 90; + if (i < hour) dc = hc; + fill_rect(cv, x + (float)i * (dot + gap), y, dot, h, dc); } + /* the minutes run along the bottom half, in the second colour */ + Col mc = e->has_slim_color2 ? e->slim_color2 : cfg.accent; + mc.a = 200; + float span = 12.0f * dot + 11.0f * gap; + float half = h * 0.5f; + fill_rect(cv, x, y + h - half, span * (float)tmv.tm_min / 60.0f, half, mc); return; } if (mode == SLIM_BAR) { @@ -2372,16 +2939,94 @@ static void slim_draw_one(Canvas *cv, Cell *e, int mode, float x, float y, float lo = e->slim_min, hi = e->slim_max; float f = (hi > lo) ? (v - lo) / (hi - lo) : 0.0f; f = clampf(f, 0.0f, 1.0f); + int dir = slim_direction(e->out); + Col fillc = c; /* the fill keeps whatever + * warn/crit decided */ 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, c); + 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 + * it drains, warm into green while it fills. Direction and roughly + * how hard it is working, in three dots. */ + Col far = dir > 0 ? cfg.running : (dir == 0 ? cfg.running : cfg.urgent); + float edge = x + w * f, pip = 2.0f * s, step = 3.0f * s; + for (int i = 0; i < 3; ++i) { + /* Start most of the way to the far colour, not at the warm end: + * ramping from 0 left the brightest pip orange and the green one + * dim, so charging and draining looked much the same. */ + Col pc = col_mix(cfg.slim_warm, far, 0.45f + (float)i * 0.275f); + pc.a = (uint8_t)(245 - i * 35); + float px_ = dir > 0 ? edge + (float)i * step + : dir < 0 ? edge - (float)(i + 1) * step + : x + w - (float)(3 - i) * step; + if (px_ < x || px_ + pip > x + w) continue; + fill_rect(cv, px_, y, pip, h, pc); + } return; } + if (mode == SLIM_MEDIA) { + /* Playing: two full-height bars, the pause glyph at five pixels, in + * the cell's own colour. Not playing: one short stub at a third of + * the height, well under half as bright. Same shape at two sizes read + * as the same thing, so the two states differ in height as well. */ + int st = media_state(e); + if (st > 0) { /* playing: two full bars */ + float bar = w * 0.38f, gapw = w - 2.0f * bar; + fill_rect(cv, x, y, bar, h, c); + fill_rect(cv, x + bar + gapw, y, bar, h, c); + } else if (st == 0) { /* paused: the same two, + short and dim */ + Col d = c; + d.a = 110; + float bar = w * 0.38f, gapw = w - 2.0f * bar; + fill_rect(cv, x, y + h * 0.34f, bar, h * 0.32f, d); + fill_rect(cv, x + bar + gapw, y + h * 0.34f, bar, h * 0.32f, d); + } + return; /* st < 0: nothing to show */ + } fill_rect(cv, x, y, w, h, c); } +/* A disc filled from the bottom: empty at rest, full when it is working hard. + * `frac` 0..1 is how much of it is lit. */ +static void fill_moon(Canvas *cv, float cx, float cy, float r, float frac, Col c) +{ + if (r < 0.5f) return; + float top = cy + r - 2.0f * r * clampf(frac, 0.0f, 1.0f); + for (float dy = -r; dy <= r; dy += 1.0f) { + float y = cy + dy; + if (y < top) continue; + float half = sqrtf(r * r - dy * dy); + fill_rect(cv, cx - half, y, half * 2.0f, 1.0f, c); + } +} + +/* How busy, as a colour: quiet green through the accent into urgent. Nothing + * at all below ws_cpu_min, so an idle bar stays clean. */ +static bool cpu_load_col(int ws_idx, Col *out, float *frac) +{ + if (!cfg.ws_cpu || ws_idx < 0 || ws_idx >= cpu_ws_n) return false; + double pct = cpu_ws_pct[ws_idx]; + if (pct < cfg.ws_cpu_min) return false; + + float f = cfg.ws_cpu_full > cfg.ws_cpu_min + ? (float)(pct - cfg.ws_cpu_min) / (cfg.ws_cpu_full - cfg.ws_cpu_min) + : 1.0f; + f = clampf(f, 0.0f, 1.0f); + *frac = f; + Col hot = f < 0.5f ? col_mix(cfg.accent, cfg.slim_warm, f * 2.0f) + : col_mix(cfg.slim_warm, cfg.urgent, (f - 0.5f) * 2.0f); + /* held back towards the bar's own dim, and never fully opaque: it should + * be readable out of the corner of an eye, not competing with the name */ + *out = col_mix(cfg.dim, hot, 0.6f + 0.4f * f); + out->a = (uint8_t)(190 + 65 * f); + return true; +} + static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs) { if (!cfg.signals) return; @@ -2394,12 +3039,13 @@ static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs } /* one fixed slot per workspace number, so slot 3 is always workspace 3 */ - int slots = 0; - for (int i = 0; i < ws_count; ++i) - if (ws_on_bar(b, &ws_list[i]) && ws_list[i].num > slots) slots = ws_list[i].num; - if (slots <= 0) - for (int i = 0; i < ws_count; ++i) if (ws_on_bar(b, &ws_list[i])) slots++; - if (cfg.slim_ws_slots > 0 && slots > cfg.slim_ws_slots) slots = cfg.slim_ws_slots; + /* 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]) && ws_list[i].num > slots) slots = ws_list[i].num; + } float tick = 14.0f * s; b->hits = 0; @@ -2415,6 +3061,17 @@ static void draw_signals(Canvas *cv, Bar *b, float y, float h, float s, Runs *rs else { c = cfg.dim; c.a = 170; } } fill_rect(cv, x, y, tick, h, c); + if (w) { + int wi = (int)(w - ws_list); + Col lc; float lf; + if (cpu_load_col(wi, &lc, &lf)) { + /* the slot is only a few pixels tall, so the load is drawn as + * a lit portion of it rising from the bottom rather than as a + * dot nobody would see */ + float lh = clampf(h * (0.35f + 0.65f * lf), 1.0f, h); + fill_rect(cv, x, y + h - lh, tick, lh, lc); + } + } if (w && b->hits < MAX_WORKSPACES) { /* still switchable when folded */ b->hit[b->hits].x0 = x / s; b->hit[b->hits].x1 = (x + tick) / s; @@ -2534,6 +3191,22 @@ static void ws_block_draw(Canvas *cv, Bar *b, Font *f, float x, float y0, pill(cv, x, y0, vis, bwid, s, rad, col_scale_alpha(bg, fade)); text_draw(cv, f, x + (bwid - tw) * 0.5f, base, col_scale_alpha(fg, fade), w->label); + Col lc; float lf; + if (cpu_load_col(i, &lc, &lf)) { + /* Top right of the button, big enough to actually see: a dim + * whole disc for the outline, then the lit part rising through + * it. The pill underneath can be bright orange, so the rim keeps + * a dark ring around it. */ + float r = clampf(vis * 0.17f, 3.0f * s, 6.0f * s); + float mx = x + bwid - r - 3.0f * s, my = y0 + r + 3.0f * s; + + Col ring = cfg.bg; ring.a = 200; + fill_moon(cv, mx, my, r + 1.0f * s, 1.0f, col_scale_alpha(ring, fade)); + Col rim = lc; rim.a = 90; + fill_moon(cv, mx, my, r, 1.0f, col_scale_alpha(rim, fade)); + fill_moon(cv, mx, my, r, 0.2f + 0.8f * lf, col_scale_alpha(lc, fade)); + } + if (b->hits < MAX_WORKSPACES) { b->hit[b->hits].x0 = x / s; b->hit[b->hits].x1 = (x + bwid) / s; @@ -3406,8 +4079,14 @@ static void usage(void) "\n" " --config PATH read this file instead of the default\n" " --dump-config print a config file with all defaults, then exit\n" +" --replace terminate any swbr already running, then start. Use\n" +" 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" " --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" +" bindsym $mod+b exec swbr --toggle\n" " --msg TEXT send a message to the running bar, then exit\n" " 'warn: ..', 'error: ..', 'info: ..', or 'clear'\n" " --help, --version\n" @@ -3474,10 +4153,27 @@ static void usage(void) " NAME.sep=1 draw the separator after this cell\n" " NAME.hide_empty=1 no output = the cell and its separator disappear\n" " NAME.empty=TEXT drawn instead of hiding, so the area stays clickable\n" +" NAME.slim_on=TEXT output containing this counts as the 'on' state for\n" +" slim=media, e.g. the icon your player prints\n" " NAME.gap= space after this cell; 0 glues it to the next one and\n" " equal background colours merge into a single box\n" " NAME.pad=0 padding inside the cell, left and right\n" -" NAME.slim=auto folded strip: auto | tick | bar | clock | off\n" +" NAME.slim=auto folded strip: auto | tick | bar | clock | presence\n" +" | media | off. presence = a block in the running\n" +" colour, there only while the program is. media = two\n" +" bars while playing, one dim one when not\n" +" NAME.slim_color= override this cell's colour on the strip. Without it\n" +" the strip reuses the colour the cell's own text has\n" +" NAME.source=cmus swbr runs cmus-remote itself: playing and paused are\n" +" read from it rather than guessed from the text, and\n" +" the command, interval, slim=media and the mouse binds\n" +" are filled in for you\n" +" NAME.cmus_fmt=%i %n how it is laid out: %i icon, %n artist — title, %a,\n" +" %t, %s playing|paused. One cell can be just the icon\n" +" and another just the name\n" +" NAME.slim_color2= second colour: the minutes of a clock cell, and its\n" +" hour bars before noon. clock lights every bar up to\n" +" the hour, so 02:00 and 02:55 both show two\n" " NAME.slim_min=0 NAME.slim_max=100 the range a gauge maps\n" " NAME.slim_w=0 width in the folded strip, 0 = the mode's default\n" " NAME.markup=1 parse pango markup in this cell's output\n" @@ -3506,7 +4202,7 @@ 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 at most this many workspace slots\n" +" slim_ws_slots=10 always show this many workspace slots\n" " start_collapsed=0\n" " SIGUSR1 folds or unfolds every bar: pkill -USR1 swbr\n" "\n" @@ -3517,7 +4213,10 @@ static void usage(void) "colors (#rrggbb or #rrggbbaa)\n" " bg text dim accent hl urgent outline\n" " ws_bg ws_fg ws_focused_bg ws_focused_fg ws_visible_bg ws_visible_fg\n" -" ws_urgent_bg ws_urgent_fg mode_bg mode_fg\n", stdout); +" ws_urgent_bg ws_urgent_fg mode_bg mode_fg\n" +" running the folded strip's 'this program is up' colour\n" +" slim_warm where a gauge's direction pips start before they ramp\n" +" into running (charging) or urgent (draining)\n", stdout); } static void print_color(const char *k, Col c) @@ -3576,6 +4275,8 @@ static void dump_config(void) print_color("accent", c.accent); print_color("hl", c.hl); print_color("urgent", c.urgent); + print_color("running", c.running); + print_color("slim_warm", c.slim_warm); print_color("outline", c.outline); print_color("ws_bg", c.ws_bg); print_color("ws_fg", c.ws_fg); @@ -3592,6 +4293,7 @@ static void dump_config(void) /* ------------------------------------------------------------------ main */ static int probe = 0; +static int do_replace = 0; static int cfg_start_collapsed_arg = 0; static void probe_report(void) @@ -3631,10 +4333,64 @@ static void probe_report(void) f->px, (double)f->ascent, (double)f->lineh, (double)baseline_for(f, 0, (float)b->bh, (float)b->scale), b->bh); } + if (cfg.ws_cpu) { + printf("cpu per workspace, sampled every %ds (min %g%%, full %g%%)\n", + cfg.ws_cpu_interval, (double)cfg.ws_cpu_min, (double)cfg.ws_cpu_full); + printf(" %d window(s) in sway's tree to attribute work to\n", + cpu_nwin); + for (int i = 0; i < cpu_nwin && i < 8; ++i) + printf(" pid %-7d -> %s\n", cpu_win[i].pid, + cpu_win[i].ws >= 0 && cpu_win[i].ws < ws_count + ? ws_list[cpu_win[i].ws].name : "(no workspace)"); + + printf(" busiest processes in that sample:\n"); + for (int i = 0; i < cpu_top_n; ++i) { + char path[64], comm[64] = "?"; + snprintf(path, sizeof(path), "/proc/%d/comm", cpu_top[i].pid); + FILE *cf = fopen(path, "r"); + if (cf) { + if (fgets(comm, sizeof(comm), cf)) { + char *nl = strchr(comm, '\n'); + if (nl) *nl = 0; + } + fclose(cf); + } + printf(" %-16s pid %-7d -> %s\n", comm, cpu_top[i].pid, + cpu_top[i].ws >= 0 && cpu_top[i].ws < ws_count + ? ws_list[cpu_top[i].ws].name : "no workspace (not under a window)"); + } + if (!cpu_ws_n) printf(" no sample yet\n"); + for (int i = 0; i < cpu_ws_n; ++i) + printf(" %-12s %5.1f%%\n", cpu_ws_name[i], cpu_ws_pct[i]); + } + static const char *SLIM_NAME[] = { "auto", "tick", "bar", "clock", + "presence", "media", "off" }; for (int i = 0; i < cfg.cell_count; ++i) { Cell *e = &cfg.cell[i]; printf("cell %-10s iv=%-3d min_w=%-4d sep=%d '%s'\n", e->name, e->interval, e->min_w, e->sep, e->out); + + int mode = slim_mode(e); + printf(" slim=%s", SLIM_NAME[mode]); + if (e->slim == SLIM_AUTO && mode != SLIM_AUTO) printf(" (auto)"); + if (mode == SLIM_MEDIA) { + int st = media_state(e); + printf(" playing=%s", st > 0 ? "yes" : st == 0 ? "no" : "(no output yet)"); + if (e->source == SRC_CMUS) + printf(" from cmus-remote (%s)", + e->state == PS_PLAYING ? "playing" : + e->state == PS_PAUSED ? "paused" : + e->state == PS_STOPPED ? "stopped" : "not running"); + else if (*e->slim_on) + printf(" slim_on='%s'%s", e->slim_on, + strstr(e->out, e->slim_on) ? " found" : " NOT in the output"); + else printf(" slim_on unset, guessing from the text"); + } else if (mode == SLIM_BAR) { + float v = 0; + printf(" value=%s range %g..%g", cell_number(e->out, &v) ? "yes" : "no", + (double)e->slim_min, (double)e->slim_max); + } + printf("\n"); } printf("folded strip (%d px), right side left to right:\n", cfg.collapsed_px); for (int i = 0; i < layout_n[G_RIGHT]; ++i) { @@ -3647,9 +4403,18 @@ static void probe_report(void) float v = 0; bool have = cell_number(e->out, &v); const char *mn = m == SLIM_BAR ? "gauge" : m == SLIM_CLOCK ? "clock" - : m == SLIM_OFF ? "off" : "tick"; + : m == SLIM_OFF ? "off" + : m == SLIM_PRESENCE ? "up" + : m == SLIM_MEDIA ? "media" : "tick"; printf(" %-10s %-5s %3.0fpx %02x%02x%02x", e->name, mn, (double)slim_width_of(e, m, 1.0f), c.r, c.g, c.b); + if (m == SLIM_MEDIA) { + bool on = *e->slim_on ? strstr(e->out, e->slim_on) != NULL + : (strstr(e->out, "\xe2\x9d\x9a") != NULL || + strcasestr(e->out, "playing") != NULL); + printf(" %s (slim_on='%s')", on ? "PLAYING: two bars" : "not playing: one block", + *e->slim_on ? e->slim_on : "(guessing from the icon)"); + } if (m == SLIM_BAR && have) printf(" %.1f of %g..%g = %.0f%% full", (double)v, (double)e->slim_min, (double)e->slim_max, @@ -3692,24 +4457,32 @@ int main(int argc, char **argv) printf("SwBr %s (build %s)\n", SWBR_VERSION, SWBR_BUILD); return 0; } 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], "--slim") || !strcmp(argv[i], "--collapsed")) cfg_start_collapsed_arg = 1; } + if (do_replace) replace_running(); /* clear the way before asking for a surface */ + config_defaults(&cfg); - config_load(&cfg, cfgpath); + sw_shared_apply("swbr", config_set_shared, &cfg); /* shared first */ + config_load(&cfg, cfgpath); /* our own wins */ - for (int i = 1; i < argc; ++i) /* talk to a running bar */ + for (int i = 1; i < argc; ++i) { /* talk to a running bar */ if (!strcmp(argv[i], "--msg") || !strcmp(argv[i], "-m")) { if (i + 1 >= argc) die("--msg needs a text ('warn: ..', 'clear')"); return msg_send(argv[i + 1]); } + if (!strcmp(argv[i], "--toggle")) return msg_send(":toggle"); + if (!strcmp(argv[i], "--fold")) return msg_send(":fold"); + if (!strcmp(argv[i], "--unfold")) return msg_send(":unfold"); + } for (int i = 1; i < argc; ++i) { /* command line wins */ const char *a = argv[i]; if (!strcmp(a, "--config")) { i++; continue; } if (!strcmp(a, "--probe") || !strcmp(a, "--slim") || - !strcmp(a, "--collapsed")) continue; + !strcmp(a, "--replace") || !strcmp(a, "--collapsed")) continue; if (!strcmp(a, "-s") && i + 1 < argc) a = argv[++i]; else if (!strncmp(a, "--", 2)) a += 2; char buf[4096]; @@ -3753,10 +4526,12 @@ int main(int argc, char **argv) msg_open(); if (probe) { + if (cfg.ws_cpu) cpu_sample(); /* the first of the two */ for (int i = 0; i < 60; ++i) { /* give the cells a moment */ cells_read(); usleep(20000); } + if (cfg.ws_cpu) cpu_sample(); /* and the one that measures */ wl_display_roundtrip(dpy); probe_report(); cells_stop(); @@ -3782,8 +4557,26 @@ int main(int argc, char **argv) cells_tick(); if (cells_read()) redraw = true; if (msg_read()) redraw = true; + if (ctrl_req) { + for (int i = 0; i < output_count; ++i) { + Bar *b = outputs[i].bar; + if (!b) continue; + bar_set_collapsed(b, ctrl_req == 1 ? !b->collapsed : ctrl_req == 2); + } + ctrl_req = 0; + redraw = true; + } if (msg_expiry_due()) redraw = true; + 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(); + cpu_next = now_ms() + (uint32_t)cfg.ws_cpu_interval * 1000u; + redraw = true; + } + } + if (status_fd < 0 && *cfg.status_command && status_next_spawn && (int32_t)(now_ms() - status_next_spawn) >= 0) { status_next_spawn = 0; @@ -3821,7 +4614,8 @@ int main(int argc, char **argv) int timeout = -1; if (animating || scroll_running) timeout = 16; - else if (cfg.cell_count || msg_until) timeout = 100; + 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; diff --git a/swbr_config.example b/swbr_config.example index 2ed7013..f8b1f48 100644 --- a/swbr_config.example +++ b/swbr_config.example @@ -80,7 +80,13 @@ scroll_pause=900 # ms held at each end before scrolling # NAME.gap= space after this cell, overrides cell_gap. 0 glues it # to the next one: equal bg colours merge into one box # NAME.pad=0 padding inside the cell, left and right -# NAME.slim=auto folded strip: auto | tick | bar | clock | off +# NAME.slim=auto folded strip: auto | tick | bar | clock | presence | +# off. presence = a plain block in the running colour, +# present only while the program is +# NAME.slim_color= override this cell's colour on the strip. Without it +# the strip reuses the colour the cell's own text has, +# so a cyan clock stays cyan when folded +# NAME.slim_color2= second colour: the minutes of a clock cell # NAME.slim_min=0 the value range a gauge maps, so a temperature or a # NAME.slim_max=100 load average can be a gauge as well as a percent # NAME.markup=1 the output may use <span foreground="#rrggbb">..</span> @@ -132,17 +138,22 @@ cmus.button4=cmus_control prev cmus.button5=cmus_control next cell=cmusui -cmusui.cmd=pgrep -x cmus >/dev/null || exit 0; case "$(cmus_control status 2>/dev/null)" in playing) printf '❚❚';; paused) printf '▶';; stopped) printf '■';; *) printf '▶';; esac +cmusui.cmd=pgrep -x cmus >/dev/null || exit 0; case "$(cmus_control status 2>/dev/null)" in playing) printf '<span foreground="#2fbf71">❚❚</span>';; paused) printf '<span foreground="#e8b81a">▶</span>';; *) printf '<span foreground="#5a6b7a">■</span>';; esac cmusui.interval=2 cmusui.empty=♪ # cmus not running: still a button, click it to start cmusui.min_w=38 # never shrinks, never truncates cmusui.align=center cmusui.pad=4 cmusui.bg=ffffff14 -cmusui.color=e8b81a # a touch brighter than cb9b00: small glyph on a dark - # panel reads darker than a big filled workspace block -cmusui.slim=tick # stays on the folded strip as a button you can click. - # If yours says slim=off you get no music block there. +cmusui.color=e8b81a # fallback; the command colours itself per state, and + # the folded strip picks that colour up, so a green + # block means playing and a grey one means stopped +cmusui.slim=media # folded: TWO bars while playing, one dim block when not. + # A shape, not another coloured block among gauges. +cmusui.slim_on=❚ # THIS is what tells it "playing": the output must contain + # this text. It matches the ❚❚ printed above. If you change + # the command to print your own icon, change this too, or + # the strip will always show the idle block. cmusui.slim_w=26 # wider than the other ticks, so it is easy to find and hit cmusui.button1=pgrep -x cmus >/dev/null && cmus_control toggle || foot -e cmus cmusui.button2=cmus_control toggle @@ -155,6 +166,7 @@ volume.cmd=alsa_control volume get volume.interval=2 volume.button2=alsa_control mute toggle volume.slim=bar +volume.slim_color=a9d8f5 # light baby blue, its own thing on the strip # --- docker and mpv share one group: docker.sep=0 joins them --------------- cell=docker @@ -162,16 +174,34 @@ docker.cmd=pgrep -x dockerd >/dev/null || exit 0; docker ps --format '{{.Image}} docker.interval=10 docker.color=19cb00 docker.sep=0 +docker.slim=presence # folded: a block in the running colour, there only + # while dockerd is, so a glance says it is up cell=mpv mpv.cmd=pgrep -x mpv >/dev/null || exit 0; ws=$(swaymsg -t get_workspaces | jq -r '.[] | select(.representation != null) | select(.representation | test("mpv")) | .name' | paste -sd'|'); [ -n "$ws" ] && printf '<span foreground="#19cb00">mpv </span><span foreground="#3E98E7">[%s]</span>' "$ws" || printf '<span foreground="#cecb00">mpv</span>' mpv.interval=3 +mpv.slim=presence # same for mpv: started something in the background? +mpv.slim_color=3ea6ff # its own colour, so you can tell the two apart + +# --- cmus, without a script ------------------------------------------------ +# swbr runs cmus-remote itself, so playing and paused are facts. It fills in +# the command, the interval, slim=media and the mouse binds; override any of +# them below if you want something else. +# cell=music +# music.source=cmus +# music.cmus_fmt=%i %n # %i icon, %n artist — title, %a, %t, %s playing|paused +# # two cells work too: one with %i, one with %n # --- clock ----------------------------------------------------------------- cell=clock clock.cmd=date '+<span foreground="#00ffff">%H:%M</span> %a %d.%m' clock.interval=5 -clock.slim=clock # twelve dots: hours lit, the current one grows with minutes +clock.slim=clock # twelve dots for the hour, in the same cyan the clock + # uses in the full bar (taken from its own markup) +clock.slim_color2=2f7f9f # the minutes bar, and the morning hours. Every dot + # up to the hour is lit, so you count bars: 2 at 02:00 and + # still 2 at 02:55. Main colour after noon, this one + # before, so 13:00 and 1:00 differ # --- cpu load and temperature, joined -------------------------------------- cell=cpu @@ -199,11 +229,14 @@ cell=power power.cmd=b=/sys/class/power_supply/BAT0; grep -q Discharging $b/status || exit 0; awk -v e="$(cat $b/energy_now)" -v p="$(cat $b/power_now)" 'BEGIN{if(p>0)printf "%.2fW %.1fh", p/1000000, e/p}' power.interval=10 power.color=b3c0cd +power.slim=off # watts and hours say nothing as a bar power.sep=0 +# The marker at the end tells the folded strip which way it is going: +# + charging, - discharging, = full. In the full bar it reads as ⚡ or nothing. cell=battery -battery.cmd=cat /sys/class/power_supply/BAT0/capacity -battery.fmt=%s% +battery.cmd=b=/sys/class/power_supply/BAT0; c=$(cat $b/capacity); case "$(cat $b/status)" in Charging) printf '%s%%+' "$c";; Full) printf '%s%%=' "$c";; *) printf '%s%%-' "$c";; esac +battery.markup=1 battery.interval=10 battery.color=e1eeff battery.warn=<30:cecb00 @@ -224,7 +257,9 @@ signals=1 # the folded strip still says something: every workspace # with a percentage become little gauges slim_ws_slots=10 # at most this many workspace slots start_collapsed=0 # 1 = start folded. Command line: swbr --slim -# folds or unfolds every bar without the mouse: pkill -USR1 swbr +# Without the mouse. Bind either one to a key in your sway config: +# bindsym $mod+b exec swbr --toggle (also --fold / --unfold) +# bindsym $mod+b exec pkill -USR1 swbr # --- mouse: anywhere on the bar, folded or not ------------------------------ # Cell bindings win where a cell is; these catch everywhere else, which is the @@ -253,6 +288,17 @@ dim=5a6b7aff accent=89afc4ff # visible-but-not-focused workspace tick hl=cb9b00ff # focused workspace tick in the folded strip urgent=e0533cff +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 +ws_cpu_min=4 # below this much load nothing is drawn +ws_cpu_full=60 # this much load paints it fully hot + +running=3ddc84ff # folded strip: this program is up, and where a charging + # gauge's pips ramp to +slim_warm=e8963cff # where the direction pips start: orange into red while + # draining, orange into green while charging ws_bg=00000000 # inactive button: nothing, the bar shows through ws_fg=b3c0cdff ws_focused_bg=cb9b00ff # the appwheel orange