SWOv git · main
SWOv - window overview for sway
C 91.5% Markdown 7.6%git clone https://git.christianimmanuel.de/sway/SWOv.gitwget https://git.christianimmanuel.de/sway/SWOv/archive/SWOv.tar.gzAdd memorize workspace movement
README.md | 37 +++- config.example | 9 + swov.c | 614 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 628 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ad5b8a1..8bd224b 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,15 @@ for_window [app_id="swov"] floating enable, border none | `c` | clear all marks | | `x`, `del` | close marked or selected windows, `enter` confirms | | middle click | close that window straight away | -| `s` | search app ids and titles, highlighting the hits | +| `f` | find windows by app id, title or workspace name, highlighting the hits | | `/` | filter: same search, but hides everything else | | `r` | reload | | `esc` | cancel a drag, else quit | Mouse and keyboard share one cursor: pointing at a window selects it, so `space`, `x`, `ctrl`+digit and the rest act on whatever is under the pointer. +Click a workspace name in the header to rename it; `enter` keeps it, `esc` +drops it. The name is what `f` and `/` search, next to app ids and titles. swov opens with the current workspace selected as a whole, no window picked. Orange is only ever the selection cursor; the workspace sway is showing and the window it has focused are marked in teal (`current`), search hits in violet @@ -79,6 +81,36 @@ starts, or when a dragged window leaves its own workspace, and the tiles glide aside to make room. Drop on one to give a workspace that number, or to create a workspace from a single window. +## Without opening the overview + +```sh +swov -g 3 # switch to workspace 3 and exit +swov -g 3:code # by name works too +swov -b # switch to the workspace you came from +``` + +Both talk to the IPC socket and quit — no window, no font, about 5 ms. Good for +keybindings. + +## Workspace usage + +Every switch goes through swov, so it stamps the time as it goes: the workspace +you leave is credited with the seconds since the last switch, and the new one is +noted in `~/.cache/swov/usage`. No background process. + +The overview draws it as a dot scale down the left edge of each tile — fourteen +dots filling from the bottom, relative to the busiest workspace — and prints the time +next to the window count. A workspace that falls empty is forgotten and starts +from zero. + +```sh +swov --usage # where you are, and how long each workspace has had you +swov --info # every path and setting in use: binary, config, usage file, + # sway socket, fonts, icon theme, colours +``` + +`track=0` in the config turns the recording off. + ## Config `${XDG_CONFIG_HOME:-~/.config}/swov/config`, `key=value`, `#` comments. Every @@ -101,6 +133,7 @@ swov --shot /tmp/o.png # one frame to a PNG, for tuning colours | `win_gap`, `screen_pad` | space between windows, and around them | | `header_pos`, `hints_pos` | `none`, or `top`/`bottom` + `left`/`center`/`right` | | `anim_ms` | tile glide duration; `0` disables | +| `track`, `usage_dots`, `dot_count`, `dot_px` | usage recording and its dot scale | | `start_selection` | `workspace`, `none` or `window` | | `cols`, `rows` | force the grid; default picks the largest tiles | @@ -115,5 +148,7 @@ swov --shot /tmp/o.png # one frame to a PNG, for tuning colours - Reordering workspaces renames them, which is all sway offers. Swaps go through a temporary name. - Scratchpad windows are not shown. +- Tabbed and stacked containers are drawn the way sway draws them: a tab strip + across the top, the visible window's contents below. Each tab is clickable. - Only the name plate of a floating or fullscreen window takes the mouse; clicks on its body go to whatever is beneath it. diff --git a/config.example b/config.example index 9a6e4cd..6c2a686 100644 --- a/config.example +++ b/config.example @@ -12,6 +12,15 @@ icons=1 # show .desktop icons (PNG/SVG/XPM); 0 = text only icon_px=40 # icon edge length; shrinks by itself on small cards shadow=1 # drop shadows under tiles and floating windows shadow_layers=3 # fewer layers = cheaper, harder edged +track=1 # remember how long each workspace is used. swov performs + # every switch itself, so it simply stamps the time on each + # one — no background process. 0 = do not record. +usage_dots=1 # dot scale down the left edge of a tile: how much that + # workspace is used, relative to the busiest one. Fills from + # the bottom. alias: usage_bar +dot_count=14 # how many dots the scale has; fewer are drawn if the tile + # is too short for them +dot_px=5 # dot diameter anim_ms=160 # how long tiles take to glide when the ghost slots come # and go, in and out. 0 turns the animation off vsync=1 diff --git a/swov.c b/swov.c index 7e62349..5037f19 100644 --- a/swov.c +++ b/swov.c @@ -22,6 +22,7 @@ #include <dirent.h> #include <errno.h> #include <stdarg.h> +#include <time.h> #include <stdbool.h> #include <stdint.h> #include <stdio.h> @@ -29,12 +30,18 @@ #include <string.h> #include <fcntl.h> #include <poll.h> +#include <sys/file.h> +#ifdef __linux__ +#include <sys/prctl.h> +#endif #include <sys/socket.h> #include <sys/stat.h> #include <sys/un.h> +#include <limits.h> #include <unistd.h> #define APP_ID "swov" +#define SWOV_VERSION "1.0" #define MAX_WINDOWS 512 #define MAX_WORKSPACES 64 #define MAX_DESKTOPS 4096 @@ -202,6 +209,10 @@ typedef struct { float float_alpha; /* opacity of floating / fullscreen cards */ float screen_pad; /* border between mini screen and the cards */ int shadow_layers; + int usage_dots; /* dot scale down the left edge of a tile */ + int track; /* record how long each workspace is used */ + int dot_count; /* how many dots the scale has */ + float dot_px; /* dot diameter */ float anim_ms; /* tile glide duration, 0 disables */ int start_selection; /* 0 none, 1 workspace, 2 first window */ char header_pos[16]; /* none|top-left|top-center|...|bottom-right */ @@ -245,6 +256,10 @@ static Cfg cfg_defaults(void) c.float_alpha = 0.62f; c.screen_pad = 6.0f; c.shadow_layers = 3; + c.usage_dots = 1; + c.track = 1; + c.dot_count = 14; + c.dot_px = 5.0f; c.anim_ms = 160.0f; c.start_selection = 1; snprintf(c.header_pos, sizeof(c.header_pos), "%s", "top-right"); @@ -322,6 +337,11 @@ static void cfg_set(Cfg *c, const char *k, const char *v) else if (key_is(k,"float_alpha") || key_is(k,"floating_alpha")) c->float_alpha = (float)atof(v); else if (key_is(k,"shadow_layers")) c->shadow_layers = atoi(v); + else if (key_is(k,"usage_dots") || key_is(k,"usage_bar")) + c->usage_dots = atoi(v) != 0; + else if (key_is(k,"dot_count")) c->dot_count = atoi(v); + else if (key_is(k,"dot_px")) c->dot_px = (float)atof(v); + else if (key_is(k,"track")) c->track = atoi(v) != 0; else if (key_is(k,"anim_ms") || key_is(k,"animation")) c->anim_ms = (float)atof(v); else if (key_is(k,"start_selection")) { c->start_selection = key_is(v,"none") ? 0 : key_is(v,"window") ? 2 : 1; @@ -389,6 +409,9 @@ static bool cfg_load_file(Cfg *c, const char *path) return true; } +static char CFG_PATH[512]; +static bool CFG_LOADED; + static char *default_config_path(void) { const char *xdg = getenv("XDG_CONFIG_HOME"); @@ -838,6 +861,147 @@ static bool sway_events_pending(void) return any; } +/* ------------------------------------------------------------ usage store + * How long the user has had each workspace in front of them. Every workspace + * switch goes through swov, so no daemon is needed: on each switch we credit + * the workspace we are leaving with the time since the last switch, and write + * down where we are going and when. A workspace that falls empty loses its + * count — the number describes the workspace as it is now. + * + * File: one "> <epoch> <name>" line saying where we are, then "<seconds> + * <name>" per workspace. + */ + +typedef struct { char name[64]; double secs; } Usage; + +static Usage USAGE[128]; +static int NUSAGE; +static double USAGE_MAX = 0.0; /* the busiest workspace, for the scale */ +static char USAGE_CUR[64]; /* the workspace we are on */ +static double USAGE_SINCE; /* ... since this moment (epoch seconds) */ + +static double now_secs(void) +{ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +static char *usage_path(void) +{ + const char *xdg = getenv("XDG_CACHE_HOME"); + if (xdg && *xdg) return fmt_alloc("%s/swov/usage", xdg); + const char *home = getenv("HOME"); + return home ? fmt_alloc("%s/.cache/swov/usage", home) : NULL; +} + +static Usage *usage_find(const char *name, bool create) +{ + for (int i = 0; i < NUSAGE; ++i) + if (strcmp(USAGE[i].name, name) == 0) return &USAGE[i]; + if (!create || NUSAGE >= (int)SDL_arraysize(USAGE)) return NULL; + + Usage *u = &USAGE[NUSAGE++]; + snprintf(u->name, sizeof(u->name), "%s", name); + u->secs = 0.0; + return u; +} + +static void usage_load(void) +{ + NUSAGE = 0; + char *path = usage_path(); + if (!path) return; + + FILE *f = fopen(path, "r"); + free(path); + if (!f) return; + + USAGE_CUR[0] = 0; + USAGE_SINCE = 0.0; + + char line[256]; + while (fgets(line, sizeof(line), f) && NUSAGE < (int)SDL_arraysize(USAGE)) { + double v = 0.0; + char name[64] = {0}; + if (line[0] == '>') { + if (sscanf(line + 1, "%lf %63[^\n]", &v, name) == 2) { + USAGE_SINCE = v; + snprintf(USAGE_CUR, sizeof(USAGE_CUR), "%s", name); + } + } else if (sscanf(line, "%lf %63[^\n]", &v, name) == 2 && name[0]) { + Usage *u = usage_find(name, true); + if (u) u->secs = v; + } + } + fclose(f); +} + +/* time spent on the workspace we are on but not yet written down */ +static double usage_pending(void) +{ + if (!USAGE_CUR[0] || USAGE_SINCE <= 0.0) return 0.0; + double d = now_secs() - USAGE_SINCE; + if (d < 0.0 || d > 12.0 * 3600.0) return 0.0; /* clock jump, or a nap */ + return d; +} + +static void usage_save(void) +{ + char *path = usage_path(); + if (!path) return; + + char *slash = strrchr(path, '/'); + if (slash) { /* mkdir -p on the parent, one level */ + *slash = 0; + char *up = strrchr(path, '/'); + if (up) { *up = 0; mkdir(path, 0755); *up = '/'; } + mkdir(path, 0755); + *slash = '/'; + } + + char *tmp = fmt_alloc("%s.tmp", path); + FILE *f = fopen(tmp, "w"); + if (f) { + if (USAGE_CUR[0]) fprintf(f, "> %.0f %s\n", USAGE_SINCE, USAGE_CUR); + for (int i = 0; i < NUSAGE; ++i) + if (USAGE[i].secs > 0.0) fprintf(f, "%.0f %s\n", USAGE[i].secs, USAGE[i].name); + fclose(f); + rename(tmp, path); + } + free(tmp); + free(path); +} + +/* Credit the workspace we are leaving, then note where we are going. This is + * the whole of the time tracking: swov performs every switch itself. */ +static void usage_switch(const char *to) +{ + if (!to || !*to) return; + usage_load(); + + double pending = usage_pending(); + if (pending > 0.0 && USAGE_CUR[0]) { + Usage *u = usage_find(USAGE_CUR, true); + if (u) u->secs += pending; + } + snprintf(USAGE_CUR, sizeof(USAGE_CUR), "%s", to); + USAGE_SINCE = now_secs(); + usage_save(); +} + +/* the workspace sway is showing at this moment */ +static void focused_workspace(char *out, size_t cap) +{ + out[0] = 0; + JV *r = sway_query(IPC_GET_WORKSPACES); + if (r && r->type == J_ARR) + for (int i = 0; i < r->count; ++i) + if (jbool(r->items[i], "focused", false)) + snprintf(out, cap, "%s", jstr(r->items[i], "name", "")); + jfree(r); +} + /* --------------------------------------------------------------- globals */ static Cfg C; @@ -1533,6 +1697,9 @@ typedef struct { bool floating, focused, urgent, fullscreen, sticky; bool marked; /* multi-selection */ bool match; /* passes the current filter */ + bool visible; /* the one on top of its tabbed container */ + SDL_FRect tab; /* its slot in the container's tab strip */ + bool has_tab; int ws; /* index into WSS */ SDL_FRect card; /* where it is drawn (render pixels) */ @@ -1549,8 +1716,10 @@ typedef struct { } Win; typedef struct { - char name[64]; + char name[64]; /* what sway calls it: "3" or "3:code" */ + char label[64]; /* just the name part, "" when it is only a number */ char output[64]; + double usage; /* seconds spent here, from the tracker */ int num; bool focused, visible, urgent; int rx, ry, rw, rh; /* workspace rect */ @@ -1562,8 +1731,9 @@ typedef struct { int ntop; SDL_FRect tile, screen; /* layout (render pixels) */ + SDL_FRect title_box; /* the clickable name area in the header */ SDL_FRect tile_from; /* where this tile animates from */ - Tex badge, sub; + Tex badge, sub, title; } Ws; static Win WINS[MAX_WINDOWS]; @@ -1577,7 +1747,11 @@ static char CUR_OUTPUT[64] = {0}; /* output the tree walk is inside */ static void model_free(void) { for (int i = 0; i < NWIN; ++i) { tex_free(&WINS[i].label); tex_free(&WINS[i].subtitle); } - for (int i = 0; i < NWS; ++i) { tex_free(&WSS[i].badge); tex_free(&WSS[i].sub); } + for (int i = 0; i < NWS; ++i) { + tex_free(&WSS[i].badge); + tex_free(&WSS[i].sub); + tex_free(&WSS[i].title); + } NWIN = NWS = 0; } @@ -1610,6 +1784,7 @@ static void collect_views(const JV *node, Ws *ws, bool floating) w->con_id = jint(node, "id", 0); w->pid = jint(node, "pid", 0); w->focused = jbool(node, "focused", false); + w->visible = jbool(node, "visible", true); w->urgent = jbool(node, "urgent", false); w->sticky = jbool(node, "sticky", false); w->floating = floating; @@ -1665,6 +1840,10 @@ static void walk_outputs(const JV *node) snprintf(ws->name, sizeof(ws->name), "%s", name); snprintf(ws->output, sizeof(ws->output), "%s", CUR_OUTPUT); ws->num = jint(node, "num", str_all_digits(name) ? atoi(name) : -1); + + const char *colon = strchr(name, ':'); /* "3:code" -> "code" */ + if (colon) snprintf(ws->label, sizeof(ws->label), "%s", colon + 1); + else if (!str_all_digits(name)) snprintf(ws->label, sizeof(ws->label), "%s", name); ws->focused = jbool(node, "focused", false); ws->urgent = jbool(node, "urgent", false); @@ -1774,6 +1953,29 @@ static bool model_reload(void) for (int i = 0; i < NWS; ++i) for (int j = 0; j < WSS[i].count; ++j) WINS[WSS[i].first + j].ws = i; + usage_load(); + double pending = usage_pending(); + bool changed = false; + + for (int i = 0; i < NUSAGE; ) { /* forget what is gone */ + bool alive = false; + for (int j = 0; j < NWS; ++j) + if (strcmp(WSS[j].name, USAGE[i].name) == 0 && WSS[j].count > 0) alive = true; + if (alive) { ++i; continue; } + USAGE[i] = USAGE[--NUSAGE]; /* an empty workspace resets */ + changed = true; + } + if (changed) usage_save(); + + USAGE_MAX = 0.0; + for (int i = 0; i < NWS; ++i) { + Usage *u = usage_find(WSS[i].name, false); + WSS[i].usage = u ? u->secs : 0.0; + if (strcmp(WSS[i].name, USAGE_CUR) == 0) WSS[i].usage += pending; + if (WSS[i].count == 0) WSS[i].usage = 0.0; /* empty means start over */ + if (WSS[i].usage > USAGE_MAX) USAGE_MAX = WSS[i].usage; + } + for (int i = 0; i < NWIN; ++i) { Win *w = &WINS[i]; if (app_id_is_useless(w->app_id)) { /* ask the process instead */ @@ -1879,6 +2081,12 @@ static bool query_active(void) { return filtering || searching; } static bool swallow_next_text = false; static bool confirm_kill = false; +/* renaming a workspace in place, from a click on its title */ +static bool editing = false; +static int edit_ws = -1; +static char edit_buf[64]; +static int edit_len = 0; + /* NULL means "the workspace itself is selected, not a window in it" */ static Win *ws_sel_win(Ws *w) { @@ -1930,7 +2138,10 @@ static void layout_cards(Ws *ws) * visible and clickable. */ bool done[MAX_WINDOWS]; memset(done, 0, sizeof(bool) * (size_t)ws->count); - for (int i = 0; i < ws->count; ++i) WINS[ws->first + i].group = -1; + for (int i = 0; i < ws->count; ++i) { + WINS[ws->first + i].group = -1; + WINS[ws->first + i].has_tab = false; + } for (int i = 0; i < ws->count; ++i) { if (done[i]) continue; @@ -1952,18 +2163,48 @@ static void layout_cards(Ws *ws) SDL_FRect base = WINS[ws->first + i].card; base.x += ge; base.y += ge; base.w -= 2.0f * ge; base.h -= 2.0f * ge; if (base.w < 4.0f || base.h < 4.0f) base = WINS[ws->first + i].card; - bool horiz = base.w >= base.h; for (int k = 0; k < n; ++k) WINS[ws->first + group[k]].group = i; + + /* Which child is on top: sway marks it visible, otherwise the focused + * one, otherwise the first. */ + int active = 0; for (int k = 0; k < n; ++k) { - SDL_FRect r = base; - if (horiz) { - r.w = (base.w - (float)(n - 1) * inset * 2.0f) / (float)n; - r.x = base.x + (float)k * (r.w + inset * 2.0f); - } else { - r.h = (base.h - (float)(n - 1) * inset * 2.0f) / (float)n; - r.y = base.y + (float)k * (r.h + inset * 2.0f); + Win *cw = &WINS[ws->first + group[k]]; + if (cw->focused) { active = k; break; } + if (cw->visible) active = k; + } + + float strip = SDL_min(32.0f * SC, base.h * 0.36f); + bool tabs_fit = strip >= 16.0f * SC && base.w / (float)n >= 26.0f * SC; + + if (tabs_fit) { + /* a tab per window across the top, the active one showing its + * contents underneath — the same shape sway draws */ + float tw = base.w / (float)n; + for (int k = 0; k < n; ++k) { + Win *cw = &WINS[ws->first + group[k]]; + cw->tab = (SDL_FRect){ base.x + (float)k * tw + inset * 0.5f, base.y, + tw - inset, strip }; + cw->has_tab = true; + cw->card = (k == active) + ? (SDL_FRect){ base.x, base.y + strip + inset * 0.5f, + base.w, base.h - strip - inset * 0.5f } + : cw->tab; + } + } else { + bool horiz = base.w >= base.h; /* too small: plain slices */ + for (int k = 0; k < n; ++k) { + SDL_FRect r = base; + if (horiz) { + r.w = (base.w - (float)(n - 1) * inset * 2.0f) / (float)n; + r.x = base.x + (float)k * (r.w + inset * 2.0f); + } else { + r.h = (base.h - (float)(n - 1) * inset * 2.0f) / (float)n; + r.y = base.y + (float)k * (r.h + inset * 2.0f); + } + WINS[ws->first + group[k]].card = r; + WINS[ws->first + group[k]].has_tab = false; } - WINS[ws->first + group[k]].card = r; } } @@ -1979,13 +2220,19 @@ static void layout_cards(Ws *ws) } } +/* shallow cards (a tab, say) cannot spare 7px top and bottom */ +static float card_pad(const Win *w) +{ + return SDL_min(7.0f * SC, w->card.h * 0.13f); +} + enum { CL_ICON, CL_STACK, CL_ROW, CL_TEXT }; /* Decide how much fits on a card: icon + app name + title, icon + name, * a row (icon left, text right) on wide flat cards, or just an icon. */ static void compute_card_layout(Win *w) { - float pad = 7.0f * SC; + float pad = card_pad(w); float availw = w->card.w - 2.0f * pad; float availh = w->card.h - 2.0f * pad; float lab_h = (float)TTF_GetFontHeight(F_LABEL); @@ -2066,7 +2313,7 @@ static bool overlay_plate(const Win *w, SDL_FRect r, SDL_FRect *plate, float *ic static float card_text_width(const Win *w) { - float pad = 7.0f * SC; + float pad = card_pad(w); float availw = w->card.w - 2.0f * pad; if (w->lay_mode == CL_ROW && w->lay_icon > 0.0f) availw -= w->lay_icon + 6.0f * SC; return availw; @@ -2120,12 +2367,24 @@ static void build_texts(void) tex_free(&ws->badge); tex_free(&ws->sub); - ws->badge = text_make(F_BADGE, ws->name); + char num[16]; + if (ws->num >= 0) snprintf(num, sizeof(num), "%d", ws->num); + else snprintf(num, sizeof(num), "%s", ws->name); + ws->badge = text_make(F_BADGE, num); + ws->title = ws->label[0] ? text_make(F_LABEL, ws->label) : (Tex){0}; char sub[192]; if (ws->count == 0) snprintf(sub, sizeof(sub), "empty"); else if (ws->count == 1) snprintf(sub, sizeof(sub), "1 window"); else snprintf(sub, sizeof(sub), "%d windows", ws->count); + + if (C.usage_dots && ws->usage >= 60.0) { /* say what the dots mean */ + int mins = (int)(ws->usage / 60.0); + char tmp[224]; + if (mins < 60) snprintf(tmp, sizeof(tmp), "%s \xc2\xb7 %dm", sub, mins); + else snprintf(tmp, sizeof(tmp), "%s \xc2\xb7 %dh %dm", sub, mins / 60, mins % 60); + snprintf(sub, sizeof(sub), "%.191s", tmp); + } if (C.all_outputs && ws->output[0]) { char tmp[256]; snprintf(tmp, sizeof(tmp), "%s \xc2\xb7 %s", ws->output, sub); @@ -2287,9 +2546,10 @@ static void layout(void) ws->tile = SLOTS[sidx].tile; float p = C.pad * SC; - SDL_FRect body = { ws->tile.x + p, + float gutter = C.usage_dots ? C.dot_px * SC * 2.0f : 0.0f; + SDL_FRect body = { ws->tile.x + p + gutter, ws->tile.y + head_h, - ws->tile.w - 2.0f * p, + ws->tile.w - 2.0f * p - gutter, ws->tile.h - head_h - p }; if (body.h < 8.0f) body.h = 8.0f; @@ -2353,6 +2613,17 @@ static void layout(void) laid_out_once = true; build_texts(); + + /* the clickable name area, between the count and the number */ + for (int i = 0; i < NWS; ++i) { + Ws *ws = &WSS[i]; + float p = C.pad * SC; + float head_h = (float)C.ws_px * SC * 1.55f; + float left = ws->tile.x + p + (ws->sub.t ? (float)ws->sub.w : 0.0f) + p; + float right = ws->tile.x + ws->tile.w - p - + (ws->badge.t ? (float)ws->badge.w : 0.0f) - p; + ws->title_box = (SDL_FRect){ left, ws->tile.y, SDL_max(right - left, 0.0f), head_h }; + } } /* ------------------------------------------------------------- navigation */ @@ -2852,7 +3123,7 @@ static void rebuild_chrome(void) T_HINTS = text_make_fit(F_HINT, "\xe2\x86\xb5 focus drag to move tab workspace 0-9 go to " - "ctrl+0-9 move space mark x close s search / filter " + "ctrl+0-9 move space mark x close f find / filter " "esc quit", avail); } @@ -2984,7 +3255,7 @@ static void draw_card(Win *w, bool tile_selected) fill_round_rect(dot, d * 0.5f, C.hl); } - float pad = 7.0f * SC; + float pad = card_pad(w); float a = dimmed ? 0.45f : 1.0f; SDL_FColor lab_col = (selected || is_hovered) ? C.text : C.subtext; if (w->urgent) lab_col = C.urgent; @@ -3097,7 +3368,7 @@ static void draw_workspace(int idx) else bw = SDL_max(1.0f, C.border * SC * 0.45f); stroke_round_rect(tile, rad, bw, bc); - /* ---- tile header: window count left, workspace number right ---- */ + /* ---- tile header: count left, name centred, number right ---- */ float p = C.pad * SC; float top = tile.y + C.badge_top * SC; @@ -3109,12 +3380,66 @@ static void draw_workspace(int idx) tex_draw(ws->badge, tile.x + tile.w - p - (float)ws->badge.w, top, num_col); + float count_right = tile.x + p; if (ws->sub.t) { - /* the count sits on the same optical line as the number */ float base = top + ((float)ws->badge.h - (float)ws->sub.h) * 0.62f; SDL_FColor col = ws->count ? mix(C.dim, C.text, selected ? 0.45f : 0.25f) : with_alpha(C.dim, 0.85f); tex_draw(ws->sub, tile.x + p, base, col); + count_right += (float)ws->sub.w; + } + + /* the name sits between the two; clicking it starts a rename */ + (void)count_right; + SDL_FRect tb = xf(ws->title_box); + float tb_x = tb.x, tb_w = tb.w; + + bool editing_this = editing && edit_ws == idx; + if (editing_this) { + SDL_FRect box = { tb_x, top - p * 0.35f, SDL_max(tb_w, 40.0f * SC), + (float)ws->badge.h + p * 0.7f }; + fill_round_rect(box, box.h * 0.3f, mix(C.tile, C.bg, 0.2f)); + stroke_round_rect(box, box.h * 0.3f, SDL_max(1.0f, 1.5f * SC), C.hl); + + char buf[80]; + snprintf(buf, sizeof(buf), "%s\xe2\x96\x8f", edit_buf); + Tex t = text_make_fit(F_LABEL, buf[0] ? buf : "\xe2\x96\x8f", (int)(box.w - p)); + tex_draw(t, box.x + p * 0.5f, box.y + (box.h - (float)t.h) * 0.5f, C.text); + tex_free(&t); + } else if (ws->title.t && tb_w > 20.0f * SC) { + Tex t = ws->title; + float x = tb_x + (tb_w - (float)t.w) * 0.5f; + if ((float)t.w > tb_w) x = tb_x; + SDL_FColor col = selected ? C.text : mix(C.subtext, C.tile, 0.15f); + tex_draw(t, x, top + ((float)ws->badge.h - (float)t.h) * 0.55f, col); + } + + /* How much this workspace gets used, as a column of dots down the left + * edge: the more of them are lit, the more time is spent here. The scale + * is relative to the busiest workspace. */ + if (C.usage_dots && C.dot_count > 0) { + float d = C.dot_px * SC; + float gap = d * 0.62f; + + SDL_FRect screen_now = xf(ws->screen); + int fits = (int)((screen_now.h + gap) / (d + gap)); + int n = SDL_clamp(SDL_min(C.dot_count, fits), 0, 40); + if (n < 2) n = 0; + float total = (float)n * d + (float)(n - 1) * gap; + float cx = tile.x + C.pad * SC + d * 0.55f; + float y0 = screen_now.y + (screen_now.h - total) * 0.5f; + + float frac = (USAGE_MAX > 0.0) ? (float)(ws->usage / USAGE_MAX) : 0.0f; + int lit = (ws->usage > 0.0) ? (int)SDL_ceilf(frac * (float)n) : 0; + lit = SDL_clamp(lit, 0, n); + + for (int i = 0; i < n; ++i) { + /* fills from the bottom like a level gauge */ + bool on = (n - i) <= lit; + SDL_FRect dot = { cx - d * 0.5f, y0 + (float)i * (d + gap), d, d }; + fill_round_rect(dot, d * 0.5f, + on ? C.current : with_alpha(C.dim, 0.38f)); + } } /* ---- the mini screen ---- */ @@ -3148,6 +3473,15 @@ static void draw_workspace(int idx) SDL_FRect box = { u.x - e, u.y - e, u.w + 2.0f * e, u.h + 2.0f * e }; stroke_round_rect(box, C.radius * SC * 0.7f, SDL_max(1.0f, 1.6f * SC), with_alpha(C.accent, 0.5f)); + + if (WINS[ws->first + i].has_tab) { /* the strip the tabs sit on */ + SDL_FRect strip = xf(WINS[ws->first + i].tab); + strip.x = box.x; + strip.w = box.w; + strip.y -= e * 0.5f; + strip.h += e; + fill_round_rect(strip, C.radius * SC * 0.5f, with_alpha(C.mini_bg, 0.8f)); + } } for (int i = 0; i < ws->count; ++i) { @@ -3387,6 +3721,7 @@ static void act_goto_workspace(const Ws *ws) char *e = escape_arg(ws->name); sway_cmd("workspace --no-auto-back-and-forth \"%s\"", e); free(e); + if (C.track) usage_switch(ws->name); running = false; } @@ -3561,6 +3896,10 @@ static void usage(void) "swov — window and workspace overview for sway\n" "\n" "usage: swov [options] [key=value ...]\n" +" -g, --go N|NAME switch to that workspace and exit, without a window\n" +" -b, --back switch to the previously used workspace and exit\n" +" --usage print how long each workspace has been used, and exit\n" +" --info print every path and setting swov is using, and exit\n" " -c, --config PATH read this config file instead of the default\n" " -n, --no-config ignore the config file\n" " --shot PATH render one frame to a PNG and exit (handy for tuning)\n" @@ -3594,7 +3933,7 @@ static void usage(void) " there, keeping the container layout intact\n" " x / delete close marked or selected windows, enter confirms\n" -" s search app ids and titles, highlighting the hits\n" +" f find windows by app id, title or workspace name\n" " / filter: same search, but hides everything else\n" " r reload the tree\n" " esc / q quit\n"); @@ -3633,7 +3972,13 @@ static void goto_workspace_number(int num) { for (int i = 0; i < NWS; ++i) if (WSS[i].num == num) { act_goto_workspace(&WSS[i]); return; } - sway_cmd("workspace number %d", num); + + sway_cmd("workspace number %d", num); /* it does not exist yet */ + if (C.track) { + char name[16]; + snprintf(name, sizeof(name), "%d", num); + usage_switch(name); + } running = false; } @@ -3644,6 +3989,9 @@ static int digit_from_scancode(SDL_Scancode sc) return -1; } +static void begin_edit(int idx); +static void end_edit(bool commit); + static void handle_key(const SDL_KeyboardEvent *k) { bool shift = (k->mod & SDL_KMOD_SHIFT) != 0; @@ -3651,6 +3999,38 @@ static void handle_key(const SDL_KeyboardEvent *k) sel_active = true; SDL_Keycode key = k->key; + if (editing) { + switch (key) { + case SDLK_RETURN: + case SDLK_KP_ENTER: end_edit(true); reload_model(); return; + case SDLK_ESCAPE: end_edit(false); return; + case SDLK_BACKSPACE: + while (edit_len > 0) { + unsigned char c = (unsigned char)edit_buf[--edit_len]; + edit_buf[edit_len] = 0; + if ((c & 0xc0) != 0x80) break; + } + return; + default: return; + } + } + + if (editing) { + switch (key) { + case SDLK_RETURN: + case SDLK_KP_ENTER: end_edit(true); reload_model(); return; + case SDLK_ESCAPE: end_edit(false); return; + case SDLK_BACKSPACE: + while (edit_len > 0) { + unsigned char c = (unsigned char)edit_buf[--edit_len]; + edit_buf[edit_len] = 0; + if ((c & 0xc0) != 0x80) break; + } + return; + default: return; + } + } + if (confirm_kill) { /* enter confirms, anything * else calls it off */ confirm_kill = false; @@ -3783,7 +4163,7 @@ static void handle_key(const SDL_KeyboardEvent *k) rebuild_chrome(); break; - case SDLK_S: /* search, without hiding */ + case SDLK_F: /* find, without hiding */ searching = true; filtering = false; query[0] = 0; @@ -3797,12 +4177,51 @@ static void handle_key(const SDL_KeyboardEvent *k) } } +static void begin_edit(int idx) +{ + editing = true; + edit_ws = idx; + snprintf(edit_buf, sizeof(edit_buf), "%s", WSS[idx].label); + edit_len = (int)strlen(edit_buf); + swallow_next_text = false; +} + +static void end_edit(bool commit) +{ + if (commit && edit_ws >= 0 && edit_ws < NWS) { + Ws *ws = &WSS[edit_ws]; + char *to = edit_buf[0] ? (ws->num >= 0 ? fmt_alloc("%d:%s", ws->num, edit_buf) + : xstrdup(edit_buf)) + : (ws->num >= 0 ? fmt_alloc("%d", ws->num) + : xstrdup(ws->name)); + if (strcmp(to, ws->name) != 0) ws_rename(ws, to); + free(to); + } + editing = false; + edit_ws = -1; + edit_buf[0] = 0; + edit_len = 0; +} + static void handle_mouse_press(const SDL_MouseButtonEvent *b) { float mx = b->x * MOUSE_SCALE, my = b->y * MOUSE_SCALE; int ws_idx = -1; int win_idx = hit_test(mx, my, &ws_idx); + if (editing) end_edit(true); /* a click elsewhere commits */ + + if (b->button == SDL_BUTTON_LEFT && ws_idx >= 0 && win_idx < 0) { + SDL_FRect tb = WSS[ws_idx].title_box; + if (mx >= tb.x && mx < tb.x + tb.w && my >= tb.y && my < tb.y + tb.h) { + sel_active = true; + sel_ws = ws_idx; + WSS[ws_idx].sel = -1; + begin_edit(ws_idx); + return; + } + } + if (ws_idx < 0) { /* the empty background */ if (b->button == SDL_BUTTON_LEFT) running = false; return; @@ -3922,7 +4341,19 @@ static void handle_event(const SDL_Event *e) break; case SDL_EVENT_TEXT_INPUT: - if (!query_active()) { + /* the key that opened a mode sends its own character right after */ + if (swallow_next_text) { swallow_next_text = false; break; } + + if (editing) { /* renaming a workspace */ + size_t add = strlen(e->text.text); + if (edit_len + (int)add < (int)sizeof(edit_buf) - 1) { + memcpy(edit_buf + edit_len, e->text.text, add + 1); + edit_len += (int)add; + } + break; + } + + if (!query_active()) { /* "/" opens the filter */ if (strcmp(e->text.text, "/") == 0) { filtering = true; query[0] = 0; @@ -3931,8 +4362,8 @@ static void handle_event(const SDL_Event *e) } break; } - if (swallow_next_text) { swallow_next_text = false; break; } - if (query_active()) { + + { size_t add = strlen(e->text.text); if (qlen + (int)add < (int)sizeof(query) - 1) { memcpy(query + qlen, e->text.text, add + 1); @@ -4035,12 +4466,20 @@ int main(int argc, char **argv) char *cfg_path = NULL; bool use_cfg = true; + const char *go_name = NULL; + bool go_back = false; + bool show_usage_stats = false; + bool show_info = false; const char *sets[64]; int nsets = 0; for (int i = 1; i < argc; ++i) { const char *a = argv[i]; if (!strcmp(a, "-h") || !strcmp(a, "--help")) { usage(); return 0; } + else if ((!strcmp(a, "-g") || !strcmp(a, "--go")) && i + 1 < argc) go_name = argv[++i]; + else if (!strcmp(a, "-b") || !strcmp(a, "--back")) go_back = true; + else if (!strcmp(a, "--usage")) show_usage_stats = true; + else if (!strcmp(a, "--info")) show_info = true; else if ((!strcmp(a, "-c") || !strcmp(a, "--config")) && i + 1 < argc) cfg_path = expand_tilde(argv[++i]); else if (!strcmp(a, "-n") || !strcmp(a, "--no-config")) use_cfg = false; @@ -4061,9 +4500,13 @@ int main(int argc, char **argv) } if (use_cfg) { + bool named = cfg_path != NULL; if (!cfg_path) cfg_path = default_config_path(); - if (cfg_path && !cfg_load_file(&C, cfg_path) && argc > 1) - fprintf(stderr, "swov: no config at %s (using defaults)\n", cfg_path); + if (cfg_path) { + snprintf(CFG_PATH, sizeof(CFG_PATH), "%s", cfg_path); + CFG_LOADED = cfg_load_file(&C, cfg_path); + if (!CFG_LOADED && named) fprintf(stderr, "swov: no config at %s\n", cfg_path); + } } free(cfg_path); @@ -4074,9 +4517,118 @@ int main(int argc, char **argv) free(copy); } + if (show_info) { + char exe[PATH_MAX] = {0}; + ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (n > 0) exe[n] = 0; + + char *upath = usage_path(); + usage_load(); + + printf("swov %s\n\n", SWOV_VERSION); + printf("binary %s\n", exe[0] ? exe : "(unknown)"); + printf("config %s%s\n", CFG_PATH[0] ? CFG_PATH : "(none)", + CFG_PATH[0] ? (CFG_LOADED ? " [loaded]" : " [missing, defaults in use]") : ""); + printf("usage file %s\n", upath ? upath : "(none)"); + printf("sway socket %s\n", getenv("SWAYSOCK") ? getenv("SWAYSOCK") : "(unset)"); + + char *theme = icon_theme_name(); + printf("icon theme %s\n", theme); + free(theme); + + fontconfig_resolve(C.font); + printf("font %s\n", FC_REGULAR ? FC_REGULAR : (C.font[0] ? C.font : "(none)")); + printf("font bold %s\n", FC_BOLD ? FC_BOLD : "(synthetic)"); + free(FC_REGULAR); + free(FC_BOLD); + FC_REGULAR = FC_BOLD = NULL; + + printf("\nrecorded usage\n"); + if (USAGE_CUR[0]) + printf(" on %s, for %.0fs\n", USAGE_CUR, usage_pending()); + if (NUSAGE == 0) printf(" (nothing yet)\n"); + for (int i = 0; i < NUSAGE; ++i) { + double secs = USAGE[i].secs + + (strcmp(USAGE[i].name, USAGE_CUR) == 0 ? usage_pending() : 0.0); + printf(" %-20s %7.0fs\n", USAGE[i].name, secs); + } + + printf("\nsettings\n"); + printf(" ssaa %d icons %d icon_px %d ui_scale %.2f\n", + C.ssaa, C.icons, C.icon_px, (double)C.ui_scale); + printf(" ws_px %d label_px %d title_px %d hint_px %d\n", + C.ws_px, C.label_px, C.title_px, C.hint_px); + printf(" cols %d rows %d margin %.0f gap %.0f pad %.0f\n", + C.cols, C.rows, (double)C.margin, (double)C.gap, (double)C.pad); + printf(" win_gap %.0f screen_pad %.0f radius %.0f border %.0f\n", + (double)C.win_gap, (double)C.screen_pad, (double)C.radius, (double)C.border); + printf(" show_empty %d all_outputs %d start_selection %d\n", + C.show_empty, C.all_outputs, C.start_selection); + printf(" header_pos %s hints_pos %s\n", C.header_pos, C.hints_pos); + printf(" track %d usage_dots %d dot_count %d dot_px %.0f\n", + C.track, C.usage_dots, C.dot_count, (double)C.dot_px); + printf(" anim_ms %.0f shadow %d float_alpha %.2f vsync %d\n", + (double)C.anim_ms, C.shadow, (double)C.float_alpha, C.vsync); + + printf("\ncolours\n"); + struct { const char *k; SDL_FColor c; } cols[] = { + { "bg", C.bg }, { "tile", C.tile }, { "tile_sel", C.tile_sel }, + { "mini_bg", C.mini_bg }, { "card", C.card }, { "card_hover", C.card_hover }, + { "card_focus", C.card_focus }, { "hl", C.hl }, { "current", C.current }, + { "match", C.match }, { "text", C.text }, { "subtext", C.subtext }, + { "dim", C.dim }, { "accent", C.accent }, { "hint", C.hint }, + { "hltext", C.hltext }, { "urgent", C.urgent }, { "outline", C.outline } + }; + for (size_t i = 0; i < SDL_arraysize(cols); ++i) + printf(" %-12s %02x%02x%02x%02x\n", cols[i].k, + (unsigned)(cols[i].c.r * 255.0f + 0.5f), (unsigned)(cols[i].c.g * 255.0f + 0.5f), + (unsigned)(cols[i].c.b * 255.0f + 0.5f), (unsigned)(cols[i].c.a * 255.0f + 0.5f)); + + free(upath); + return 0; + } + + if (show_usage_stats) { + char *path = usage_path(); + usage_load(); + printf("usage file: %s\n", path ? path : "(none)"); + if (USAGE_CUR[0]) printf("on: %s, for %.0fs\n", USAGE_CUR, usage_pending()); + else printf("on: nothing recorded yet\n"); + for (int i = 0; i < NUSAGE; ++i) { + double secs = USAGE[i].secs + + (strcmp(USAGE[i].name, USAGE_CUR) == 0 ? usage_pending() : 0.0); + int mins = (int)(secs / 60.0); + printf(" %-20s %6.0fs (%dh %02dm)\n", USAGE[i].name, secs, mins / 60, mins % 60); + } + free(path); + return 0; + } + sway_fd = sway_connect(); if (sway_fd < 0) die("cannot reach sway (is SWAYSOCK set?)"); + /* Switching does not need a window, a renderer or a font: connect, say it, + * leave. This is the path a keybinding should use. */ + if (go_back || go_name) { + bool ok; + if (go_back) { + ok = sway_cmd("workspace back_and_forth"); + } else if (str_all_digits(go_name)) { + ok = sway_cmd("workspace number %d", atoi(go_name)); + } else { + char *e = escape_arg(go_name); /* a name, "code" or "3:code" */ + ok = sway_cmd("workspace --no-auto-back-and-forth \"%s\"", e); + free(e); + } + if (ok && C.track) { /* stamp the new workspace */ + char now[64]; + focused_workspace(now, sizeof(now)); + usage_switch(now); + } + close(sway_fd); + return ok ? 0 : 1; + } + /* the focused output is needed before the window is created */ JV *wsr = sway_query(IPC_GET_WORKSPACES); if (wsr && wsr->type == J_ARR) @@ -4087,7 +4639,7 @@ int main(int argc, char **argv) jfree(wsr); SDL_SetHint(SDL_HINT_APP_ID, APP_ID); - SDL_SetAppMetadata("swov", "1.0", "org.swov.overview"); + SDL_SetAppMetadata("swov", SWOV_VERSION, "org.swov.overview"); if (!SDL_Init(SDL_INIT_VIDEO)) die("SDL_Init: %s", SDL_GetError()); if (!TTF_Init()) die("TTF_Init: %s", SDL_GetError());