aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
blob: cd9de42e59edf6c997aaf0d7c6ca8d1c9d4d9719 (plain)
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
# AGENTS.md

Guidance for agents working in this repository.

## What this is

Quickshell components for a Hyprland desktop, one per directory, each a
complete shell in its own right. They are not modules of a single bar: any of
them runs alone, and running one does not require the others. One of them,
`desktop/`, is itself a host for modules.

    desktop/          the drawer: sound, mail, VMs, appearance
    appearance/       wallpaper picker and colour scheme switcher
    window-switcher/  open windows as live previews in a grid, on ALT+TAB

They are started from `~/.config/hypr/sections/autostart.lua` and keep running
for the whole session.

## The rule that bites first

**A quickshell config with no visible window exits.** These components are
hidden most of the time, so each holds itself open with a 1x1 transparent
`PanelWindow` with `mask: Region {}`, which is click-through and draws nothing.

Without it the shell loads, logs `Configuration Loaded`, reports no error, and
quits. The symptom is never an error message: it is a keybind that appears to
do nothing, or a panel that never paints. This was removed once during
development because a process check was measuring the wrong thing, and two
components broke in exactly that way. Any new component needs the same window.

## Verifying, and how not to

Two traps cost real time in this repo, both about measurement rather than code.

**A detached `qs` does not survive an agent's tool call.** Starting one with
`&`, `nohup` or `setsid -f` and then checking `pgrep` in a later call reports
`DEAD` regardless of whether the config is sound. That reads exactly like the
no-visible-window exit above and sent a debugging session in the wrong
direction for a dozen calls. Start it so the harness owns the process, and
confirm with the log rather than a later `pgrep`.

**The process is called `qs`, not `quickshell`.** Both binaries ship, but `qs`
is what runs, so `pkill -x quickshell` and `pgrep -x quickshell` match nothing
and silently succeed. During development this meant every "stopped" was a lie
and every restart stacked another instance: 47 accumulated before the user
noticed. It also produced "0 processes" readings that were mistaken twice for
the shell exiting, and sent a debugging session after a bug that was not
there. Use `pkill -x qs` and `pgrep -cx qs`, and check the count after.

**`pkill -f` matches the agent's own shell.** The working directory is in the
command line, so `pkill -f qs` or `pkill -f "foo.qml"` kills the caller and
returns 144. Always `-x`, never `-f`.

For anything visual, ask. Screenshots of a transient OSD are a race, and the
user has the screen.

## Per-component notes

Each directory's README carries its own, and they are worth reading before
changing that component. The ones that generalise:

- **PipeWire nodes report their initial volume before `ready` goes true.** The
  `ready` check alone suppresses the startup values. An extra guard on top of
  it ate the user's first keypress instead.
- **`PwObjectTracker` is not optional.** Node properties only stay current
  while something binds the node; without it the volume reads once and goes
  stale.
- **playerctld publishes a duplicate of every MPRIS player** under its own bus
  name. Filter by `dbusName`, not by identity.
- **Key events reach a focused item, not a window.** Setting
  `WlrLayershell.keyboardFocus` is necessary but not sufficient:
  `Keys.onEscapePressed` on a `PanelWindow` never fires.
- **A `Row` sizes to its children, not its parent.** Fixed child widths inside
  one overflowed the panel and pushed a button past its edge.
- **QML's JS engine has no `String.matchAll`.** It throws, and inside a `try`
  that looks like a parser quietly returning nothing. Use an `exec` loop.
- **Assigning `running = true` to a `Process` that is already running does
  nothing.** Reusing one `Process` for a sequence of commands needs
  `running = false` immediately before each start.
- **`notmuch` fails two different ways and only one is detectable.** A
  rejected query prints nothing and exits 1; a query Xapian merely misparses
  returns a plausible wrong number and exits 0 (`tag:unread and ((` gave 41).
  Validate the output as an integer, which catches the first, where empty
  output would otherwise render as an empty inbox. Nothing catches the second,
  so keep such queries as fixed strings.
- **notmuch deduplicates by message id, so one message can have several
  paths.** A message that arrived at two configured addresses is counted by
  both accounts under a `path:` glob, and per-account counts then sum above the
  total. The `account-*` tag is a property of the message, so it is singular.
- **Xapian replaces files on commit.** A watch held on a filename inside the
  database directory dies with the file; watch the directory for
  `close_write,moved_to` instead, and debounce, because one commit touches
  several files.
- **An INI section body is not "everything up to the next `[`".** Values can
  themselves contain brackets: `qtmaildir.conf` has folders named
  `[Gmail]/Bozze`, which ended a section before its `label` and made three of
  five accounts silently display their raw key. Walk lines instead. A first fix
  using a lazy quantifier and a lookahead dropped every label, which is the
  argument for the boring version.
- **libvirt's own memory and disk figures are not what they look like.**
  `balloon.current` is memory allocated to the VM and reads full forever;
  `block.allocation` is qcow2 growth on the host, not usage inside the guest.
  The real numbers come from qemu-guest-agent, and the panel shows a dash
  rather than substituting the host-side ones.
- **A managed save makes `virsh start` a restore, and nothing in `domstats`
  says so.** A shut-off VM carrying a saved memory image fails to start with
  `unable to execute QEMU command 'migrate-incoming'` every time, and the
  state reads a plain `shut off`. The shut-off *reason* is no help either: it
  reads `failed`, from the failed start, not from the save. `virsh list
  --all --managed-save` prints `saved` in the state column but not with
  `--name`, which is the form the panel lists with, so detection is `virsh
  dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the
  agent rows are. It measured 6ms.
- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch
  focuswindow address:0x...` is a syntax error rather than a command, and it
  fails silently unless stderr is read. The working form is `dispatch
  hl.dsp.focus({ window = "address:0x..." })`. This is why
  `~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread
  as legacy oddity worth modernising.
- **A focus dispatched while an overlay holds the keyboard is accepted and then
  ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the
  keyboard, and the compositor will not move window focus out from under that
  grab. The dispatch reports `ok`, nothing moves, and no log line says
  otherwise. Closing in the same turn does not help either, because a `close()`
  that clears a property leaves the surface alive until the frame after. Focus
  after the overlay is really gone, which `window-switcher` does with a 60ms
  timer.
- **`HyprlandToplevel` has no `focusHistoryID` property.** It reads
  `undefined`, so a sort on it compares `NaN` and silently does nothing,
  leaving a plausible looking list in arbitrary order. The value is on
  `lastIpcObject`.
- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints
  and that every dispatch requires.
- **`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.**
- **An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**,
  not `(0, 0)`. An aspect ratio guard has to test for a positive height: the
  obvious rewrites, `!== 0` or a truthiness check, all pass on `-1` and produce
  a negative ratio.
- **A `pragma Singleton` in a subdirectory needs no `qmldir`.** A plain
  directory import resolves it, the same way quickshell resolves the
  `Theme.qml` symlink. Tested while merging the components: the control was a
  reference to an undefined type, which warns `ReferenceError: <name> is not
  defined`, and the singleton case produced no such warning.
- **`Virsh.sampling` gates the 2s stats poll, not the whole service.** The
  lifecycle event stream runs unconditionally, which is what keeps the VM list
  and the tile's dots current while the page is closed.
- **`Quickshell.Networking` and `Quickshell.Bluetooth` start empty.** Devices,
  the adapter and the wifi radio all read empty or false for roughly two
  seconds after launch, then populate. Bindings repaint when they arrive;
  nothing may cache the first paint. Both device lists are `ObjectModel`s, so
  iterate `.values`, which is a plain array.
- **`NMSettings` is not in QML scope.** `typeof NMSettings` is `undefined`, so
  a NetworkManager profile cannot be built from scratch in QML. This is why
  the network module cannot join a hidden network, and the same class of
  limit blocks enterprise 802.1x.
- **Quickshell has no BlueZ pairing agent and no generic D-Bus module.** A
  device needing a passkey or PIN confirmed cannot be paired from QML;
  `bluetoothctl` registers its own agent, so pairing is the one shell-out for a
  device action in the bluetooth module.
- **`WifiNetwork.signalStrength` is `0..1`, not a percentage.** The known
  networks a scan has not refreshed report a cached `1`.

## Theme

No component defines a palette. `udt-accent`, in the
`unified-desktop-theme` repo alongside this one, writes
`~/.cache/wal/udt-palette.qml` from its `palette.rasi`, carrying the whole
Catppuccin Macchiato palette plus the accent snapped from the current
wallpaper. `Theme.qml` parses and watches that file, so a palette edit
recolours a running shell with no restart.

What is left in `Theme.qml` is a fallback for before the file is read, and for
a machine without unified-desktop-theme. Do not grow it into a second palette:
that duplication is what generating the file removed.

The file is parsed rather than imported because a generated QML singleton
cannot be imported without a `qmldir` beside it, and the wal cache has no
reason to carry one.

There is one `Theme.qml`, in `shared/`, and each component holds a symlink to
it. It was four copies that had already drifted: `vm-manager` was missing
`surfaceAlt` and `volume-osd` was missing `green`, `yellow` and `surfaceAlt`,
so the shared file is the superset and the two thin ones gained properties
they never had. A symlink rather than a shared import path because a singleton
outside the config directory needs a `qmldir`, which is the same friction that
keeps the palette parsed rather than imported; quickshell follows the link and
resolves the singleton with no qmldir and no consumer change. Editing any
component's `Theme.qml` edits all three. Do not replace a link with a copy.

Glyphs are Nerd Font private-use codepoints and render with `Theme.iconFamily`,
pinned to Inconsolata Nerd Font, not `Theme.fontFamily` ("Noto Sans" has no
glyphs). Without the pin, fontconfig falls back per codepoint and the same
codepoint can be a different glyph in a different Nerd Font: the ethernet
codepoint `\uf6ff` rendered as a gamepad (`gamepad-variant` in JetBrains Mono
Nerd Font, absent from Inconsolata, whose ethernet is `\uef44`). A new glyph
must exist in Inconsolata Nerd Font and mean what it says; check the font cmap
rather than trusting the codepoint.

## Blur

Translucency is set in QML; the frosting is the compositor's. Hyprland blurs a
layer surface only when a rule names it, matched on the namespace the window
sets, in `~/.config/hypr/sections/decorations.lua`. A new component that wants
frosting needs its own `hl.layer_rule` and a distinct
`WlrLayershell.namespace`. Without the rule it still works, rendering flat
translucent.

A panel that should sit below waybar rather than over it wants
`exclusionMode: ExclusionMode.Normal` on its window, which respects waybar's
exclusive zone without the component knowing the bar's height. Measured with
`hyprctl layers`: waybar at `y=-540 h=42`, a `Normal` overlay on the same
screen at `y=-498 h=1038`, starting exactly where the bar ends, so the
backdrop never dims it. The `desktop` drawer does this; components that cover
the whole screen use `ExclusionMode.Ignore`.

`window-switcher` has namespace `quickshell-window-switcher` and a
`blur-window-switcher` rule of its own.

## Reloading

| | How |
| --- | --- |
| a component's QML | quickshell hot-reloads on save; no restart |
| palette | regenerate with `udt-accent <wallpaper>`; watched, no restart |
| Hyprland binds, layer rules, autostart | `hyprctl reload` |

Hot reload covers adding and removing windows too: the keepalive window above
was added to a running shell and took effect on save.

`qs -p <dir>` runs one directly. `qs -p <dir> ipc call <target> <fn>` reaches a
running one, which is how `SUPER+v` opens the VM drawer.

## Conventions

- GPLv2 only, with the header notice in every source file.
- Commits carry the reasoning, not just the change: several of the notes above
  exist because a commit message explained why something was the way it was.
- No home paths in committed files. A gitleaks hook blocks them and has been
  right every time; `~` in documentation, absolute paths only in the live
  Hyprland config, which is not in this repo.