sway-groups-cli 0.2.0

Command-line tool for managing sway workspaces in named groups.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
# sway-groups (`swayg`)

Group-aware workspace management for [sway](https://swaywm.org/), with
[waybar](https://github.com/Alexays/Waybar) integration via
[waybar-dynamic](https://github.com/AriaSeitia/waybar-dynamic).

> **On "optional" bar integration.** In principle `swayg` is bar-agnostic —
> it manages state in sway, and the bar integration is a separate output
> channel. In practice waybar + waybar-dynamic is currently the only
> supported renderer, and without it there is no visible feedback on
> which groups exist or which workspaces the active group contains. So
> unless you plan to write your own bar module against the
> [waybar-dynamic IPC]https://github.com/AriaSeitia/waybar-dynamic,
> treat waybar as a required dependency.

Workspaces are organised into named **groups**. Each output has an **active
group**, and only workspaces that belong to the active group (plus globals
and user-unhidden ones) are shown to waybar and included in group-aware
navigation. Workspace state is persisted in a small SQLite DB so switching
back to a group restores its last focus.

## Key concepts

- **Workspace** — a sway workspace (`1`, `2`, `3:Firefox`, …).
- **Group** — a named collection of workspaces. Each output has one *active*
  group at a time.
- **Global workspace** — visible in all groups (e.g. a persistent notes
  workspace).
- **Hidden workspace** — a workspace marked as hidden in a specific group.
  By default hidden workspaces are invisible to waybar and skipped by
  navigation, so you can declutter the bar during presentations or deep
  work. Toggle `show_hidden_workspaces` to reveal them with a `.hidden`
  CSS class applied (combinable with `.global`, `.focused`, …).

## Requirements

- Rust toolchain (stable, edition 2024)
- sway
- waybar + [waybar-dynamic]https://github.com/AriaSeitia/waybar-dynamic — see note above

## Installation

### `cargo install --git` (recommended right now)

No crates.io publishing needed:

```sh
cargo install --git https://github.com/bschnitz/sway-groups swayg
cargo install --git https://github.com/bschnitz/sway-groups swayg-daemon
```

Both binaries land in `~/.cargo/bin/`. Make sure that's in your `PATH`.

### `cargo install --path` (from a local clone)

```sh
git clone https://github.com/bschnitz/sway-groups
cd sway-groups
cargo install --path sway-groups-cli
cargo install --path sway-groups-daemon
```

### Later: `cargo install` from crates.io

Once the crates are published (in order: `sway-groups-config` →
`sway-groups-core` → `sway-groups-cli` / `sway-groups-daemon`) it becomes:

```sh
cargo install sway-groups-cli
cargo install sway-groups-daemon
```

### systemd user unit for the daemon

`cargo install` cannot install non-binary files, so copy this unit once
into `~/.config/systemd/user/swayg-daemon.service`:

```ini
[Unit]
Description=swayg daemon - track external sway workspace events
After=graphical-session.target
PartOf=graphical-session.target

[Service]
Type=simple
ExecStart=%h/.cargo/bin/swayg-daemon
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=sway_groups_daemon=info

[Install]
WantedBy=graphical-session.target
```

```sh
mkdir -p ~/.config/systemd/user
# paste the unit above into ~/.config/systemd/user/swayg-daemon.service
systemctl --user daemon-reload
systemctl --user enable --now swayg-daemon.service
```

The unit is `WantedBy=graphical-session.target`. For sway users, make sure
the target actually gets activated — the common recipe is a small session
target that sway starts. Create
`~/.config/systemd/user/sway-session.target`:

```ini
[Unit]
Description=sway compositor session
BindsTo=graphical-session.target
```

…and in your sway `config`:

```
exec systemctl --user --no-block start sway-session.target
```

### waybar-dynamic integration

Install [waybar-dynamic](https://github.com/AriaSeitia/waybar-dynamic),
then add two modules to your waybar config:

```jsonc
"cffi/swayg_groups": {
    "module_path": "/path/to/libwaybar_dynamic.so",
    "name": "swayg_groups"
},
"cffi/swayg_workspaces": {
    "module_path": "/path/to/libwaybar_dynamic.so",
    "name": "swayg_workspaces"
}
```

`swayg` pushes widget updates to these modules automatically after every
state-changing command. For per-state CSS classes see [Bar styling](#bar-styling)
below.

## First-time setup

```sh
swayg init             # creates the DB and imports current sway state
```

This seeds the DB from sway's current workspaces, creates the default
group (`0`), and pushes initial bar widgets.

## CLI overview

Every command is documented under `--help`:

```sh
swayg --help
swayg workspace --help
swayg workspace hide --help
```

High-level tour:

```sh
# Groups
swayg group create dev
swayg group select dev               # make dev the active group on current output
swayg group next -w                  # next group (alphabetical, wrap)
swayg group prune                    # delete empty groups

# Workspace membership
swayg workspace add 3 -g dev         # add workspace "3" to dev
swayg workspace move 3 -g dev,work   # set exactly these groups
swayg workspace global 1             # workspace 1 visible in all groups
swayg workspace rename old new       # rename (merges if target exists)

# Hiding (auto-focuses away when the focused workspace becomes invisible)
swayg workspace hide                 # hide currently focused workspace in active group
swayg workspace hide 4 -g dev -t     # toggle "4" hidden in group dev
swayg workspace unhide 4 -g dev
swayg group unhide-all               # unhide everything in active group
swayg workspace show-hidden -t       # toggle the global show_hidden flag

# Navigation (group-aware — skips hidden unless show_hidden=true)
swayg nav next -w                    # next visible workspace, wrap
swayg nav go 3                       # focus workspace 3 (works even if hidden)
swayg nav back                       # previous focus

# Container moves
swayg container move 3 --switch-to-workspace

# State
swayg status
swayg sync --all --repair
swayg config dump                    # print the default config TOML

# Global flags
swayg -v ...                         # verbose
swayg --db /tmp/test.db ...          # alternate DB file
swayg --config ~/my.toml ...         # alternate config file
```

`swayg status` sample:

```
show_hidden_workspaces = false
eDP-1: active group = "dev"
  Visible:  1, 3
  Inactive: 2, 4
  Hidden:   5
  Global:   0
```

- **Visible** — in the active group (plus globals) and not user-hidden
- **Inactive** — belongs to other groups; exists in sway on this output
- **Hidden** — user-hidden in the active group (only shown if
  `show_hidden_workspaces = true`)
- **Global**`is_global = true` workspaces

## Configuration

`swayg config dump` prints the default TOML. Save to
`~/.config/swayg/config.toml` (or any path passed via `--config` or
`SWAYG_CONFIG=`) and edit.

Current sections:

- `[defaults]``default_group`, `default_workspace` (used when orphan
  workspaces need a home, e.g. after `group delete --force`)
- `[bar.workspaces]` / `[bar.groups]` — per-bar tuning: socket instance
  name, display mode (`all` | `active` | `none`), `show_global`,
  `show_empty`
- `[[assign]]` — workspace assignment rules (see below)

### Assignment rules

When the daemon sees a new workspace, it normally adds it to the active
group. Assignment rules let you override this per workspace name — useful
together with sway's `assign` / `for_window` rules:

```toml
# Exact match: put "music" in media + bg, mark global
[[assign]]
match = "music"
groups = ["media", "bg"]
global = true

# Regex match: any workspace starting with "dev_" goes to dev group
[[assign]]
match = "^dev_"
match_type = "regex"
groups = ["dev"]
```

- `match` — pattern to match against the workspace name.
- `match_type``"exact"` (default) or `"regex"`.
- `groups` — groups to add the workspace to. When set, replaces the
  default "add to active group" behaviour.
- `global` — mark the workspace as global (`true`/`false`).

If a rule sets `global = true` but specifies no `groups`, the workspace
is still added to the active group (in addition to being global).
Multiple rules can match the same workspace — their groups are merged.

Runtime DB flags (separate from the config file):

- `show_hidden_workspaces` — toggled via `swayg workspace show-hidden`

## Bar styling

![swayg bars in waybar](screenshot.png)

Widgets emitted by `swayg` carry CSS classes you can style. The available
classes are:

- **`swayg_workspaces`**: `focused` (focused on this output), `visible`
  (visible on another output), `urgent`, `global` (`is_global` flag),
  `hidden` (only sent when `show_hidden_workspaces = true`). Classes
  combine, e.g. `.focused.global`, `.hidden.global.focused`.
- **`swayg_groups`**: `active` (active group on the focused output),
  `urgent` (a workspace in the group is urgent).

### Example theme (lavender workspaces, blue groups)

This is the theme used in the screenshot above — drop it into your
`~/.config/waybar/style.css`:

```css
/* ── swayg workspaces — lavender, lime accent for globals ───────── */
#waybar-dynamic.swayg_workspaces label {
    padding: 0 5px;
    background: transparent;
    color: #C9A0F8;
    border-bottom: 3px solid rgba(184, 133, 255, 0.7);
    border-radius: 0;
    transition: background 0.15s, color 0.15s;
}
#waybar-dynamic.swayg_workspaces label.focused {
    background: rgba(184, 133, 255, 0.35);
    color: #ffffff;
    border-bottom: 3px solid #D4AAFF;
}
#waybar-dynamic.swayg_workspaces label.visible {
    color: rgba(184, 133, 255, 0.75);
}
#waybar-dynamic.swayg_workspaces label.urgent {
    background-image: linear-gradient(to top, transparent, rgba(232, 69, 60, 0.7));
    color: #ffffff;
}
/* Global workspaces: lime text + border */
#waybar-dynamic.swayg_workspaces label.global {
    color: #b8f060;
    border-bottom: 3px solid rgba(184, 240, 96, 0.75);
}
#waybar-dynamic.swayg_workspaces label.focused.global {
    background: rgba(184, 133, 255, 0.3);
    color: #b8f060;
    border-bottom: 3px solid #b8f060;
}
#waybar-dynamic.swayg_workspaces label.global.visible {
    color: rgba(184, 240, 96, 0.6);
}
#waybar-dynamic.swayg_workspaces label.hover {
    background: rgba(184, 133, 255, 0.2);
}
#waybar-dynamic.swayg_workspaces label.focused.hover {
    background: rgba(184, 133, 255, 0.5);
}
#waybar-dynamic.swayg_workspaces label.global.hover {
    background: rgba(184, 240, 96, 0.15);
}
#waybar-dynamic.swayg_workspaces label.focused.global.hover {
    background: rgba(184, 133, 255, 0.45);
}

/* Hidden (only shown when show_hidden_workspaces = true):
   faded + italic + dashed border signals "would normally be invisible". */
#waybar-dynamic.swayg_workspaces label.hidden {
    opacity: 0.45;
    border-bottom: 3px dashed rgba(184, 133, 255, 0.7);
    font-style: italic;
}
#waybar-dynamic.swayg_workspaces label.hidden.focused {
    opacity: 0.8;
    background: rgba(184, 133, 255, 0.25);
    color: #ffffff;
    border-bottom: 3px dashed #D4AAFF;
}
#waybar-dynamic.swayg_workspaces label.hidden.visible {
    opacity: 0.55;
}
#waybar-dynamic.swayg_workspaces label.hidden.global {
    opacity: 0.5;
    color: #b8f060;
    border-bottom: 3px dashed rgba(184, 240, 96, 0.75);
    font-style: italic;
}
#waybar-dynamic.swayg_workspaces label.hidden.focused.global {
    opacity: 0.85;
    background: rgba(184, 133, 255, 0.25);
    color: #b8f060;
    border-bottom: 3px dashed #b8f060;
}
#waybar-dynamic.swayg_workspaces label.hidden.global.visible {
    opacity: 0.55;
}
/* Urgent wins: full visibility, solid border, no italic */
#waybar-dynamic.swayg_workspaces label.hidden.urgent {
    opacity: 1.0;
    background-image: linear-gradient(to top, transparent, rgba(232, 69, 60, 0.7));
    color: #ffffff;
    font-style: normal;
}
#waybar-dynamic.swayg_workspaces label.hidden.hover {
    opacity: 0.7;
    background: rgba(184, 133, 255, 0.15);
}
#waybar-dynamic.swayg_workspaces label.hidden.focused.hover {
    opacity: 0.95;
    background: rgba(184, 133, 255, 0.4);
}
#waybar-dynamic.swayg_workspaces label.hidden.global.hover {
    opacity: 0.7;
    background: rgba(184, 240, 96, 0.1);
}

/* ── swayg groups — same structure, blue accent ─────────────────── */
#waybar-dynamic.swayg_groups label {
    padding: 0 5px;
    background: transparent;
    color: rgba(255, 255, 255, 0.5);
    border-bottom: 3px solid rgba(137, 180, 250, 0.3);
    border-radius: 0;
}
#waybar-dynamic.swayg_groups label.active {
    color: #ffffff;
    background: rgba(137, 180, 250, 0.15);
    border-bottom: 3px solid #89b4fa;
}
#waybar-dynamic.swayg_groups label.urgent {
    background-image: linear-gradient(to top, transparent, rgba(235, 77, 75, 0.7));
    color: #ffffff;
}
#waybar-dynamic.swayg_groups label.hover {
    background: rgba(100, 114, 125, 0.3);
}
#waybar-dynamic.swayg_groups label.active.hover {
    background: rgba(137, 180, 250, 0.3);
}
```

## Storage locations

- SQLite DB: `~/.local/share/swayg/swayg.db`
- Log files: `~/.local/share/swayg/swayg.YYYY-MM-DD` (daily rotation)
- Config (optional): `~/.config/swayg/config.toml`
- Daemon state: `/tmp/swayg-daemon-test.state` (test daemon only)

Reset all state:

```sh
rm ~/.local/share/swayg/swayg.db
swayg init
```

## Architecture

Workspace crates:

| Crate | Role |
|---|---|
| `sway-groups-config` | TOML config schema + loader |
| `sway-groups-core` | DB entities, services, sway/waybar IPC |
| `sway-groups-cli``swayg` | User-facing CLI |
| `sway-groups-daemon``swayg-daemon` | Catches sway IPC events (new/empty workspace, etc.), keeps DB + bars in sync |
| `sway-groups-dummy-window` | Wayland dummy window for tests (`publish = false`) |
| `sway-groups-tests` | Integration tests against a live sway session (`publish = false`) |

### Tables

- `workspaces`, `groups` — main entities
- `workspace_groups` — many-to-many membership
- `hidden_workspaces` — presence-based `(workspace_id, group_id)` pairs
- `outputs` — per-output state (including active group)
- `settings` — global runtime flags (key/value)
- `focus_history`, `group_state`, `pending_workspace_events` — internal
  state for nav-back and daemon coordination

## Troubleshooting

- `RUST_LOG=debug swayg <cmd>` — verbose tracing to stderr
- Log files under `~/.local/share/swayg/`
- `swayg repair` — reconcile DB with sway (removes stale workspaces etc.)
- `swayg sync --all --init-bars --init-bars-retries 20 --init-bars-delay-ms 500`
  — after `swaymsg reload`, retry pushing to waybar until its socket is
  back up

## Development

```sh
cargo build --workspace
cargo test -p sway-groups-tests -- --test-threads=1   # integration tests need a serialised sway session
cargo clippy --workspace --all-targets
```

The integration test suite spawns a test-mode daemon, temporarily stops
the production daemon, and tears everything down in `Drop`. All tests
must be able to run against a real sway socket.

### Waybar test progress

During test runs a waybar `custom` module shows which test is running
and overall progress (n/m). The test fixture writes JSON to
`/tmp/swayg-test-progress.json` which waybar polls every second.

Add the module to your waybar config (e.g. in `modules-center`):

```jsonc
"custom/swayg_tests": {
    "exec": "cat /tmp/swayg-test-progress.json 2>/dev/null || echo '{}'",
    "return-type": "json",
    "interval": 1,
    "tooltip": true
}
```

Suggested CSS (pill badge, yellow while running, green when done):

```css
#custom-swayg_tests {
    padding: 2px 12px;
    margin: 4px 0;
    background: rgba(80, 80, 100, 0.4);
    color: rgba(255, 255, 255, 0.5);
    border-radius: 12px;
    font-size: 12px;
}
#custom-swayg_tests.running {
    color: #1e1e2e;
    background: #fac850;
    font-weight: bold;
}
#custom-swayg_tests.done {
    color: #1e1e2e;
    background: #a6e3a1;
    font-weight: bold;
}
```

## License

MIT