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
|
# appearance: Sunset, Idle, Icons tabs
Three tabs join Wallpaper and Theme in the `appearance/` drawer. Sunset edits
`hyprsunset.conf` the way the user's own `hyprsunset-qt` does, Idle edits the
timeouts and enables of `hypridle.conf`, and Icons switches the icon and cursor
themes with a live preview of each.
## Why
The drawer already owns "what the desktop looks like": wallpaper, accent,
scheme. These three are the same kind of choice that currently lives outside it,
in a separate PyQt app (`hyprsunset-qt`), in a text file (`hypridle.conf`), and
in `gsettings` plus half a dozen hardcoded names. Putting them behind the same
Tab key is the point. It is not a rewrite of hyprsunset-qt; it is the same
config, edited from the drawer.
## Tab bar and IPC
The bar becomes Wallpaper, Theme, Sunset, Idle, Icons. The Tab key currently
toggles between two tabs; it becomes a cycle over the five. `shell.qml` gains
`sunset`, `idle` and `icons` IPC verbs alongside `wallpaper` and `theme`, so a
keybind or a `qs ipc call` opens any tab directly. `show()` also refreshes the
new lists, the way it already refreshes schemes and wallpapers, because this
shell is autostarted and runs all session.
## Files
appearance/Hyprsunset.qml profile parse/serialize, location, apply
appearance/Hypridle.qml listener parse/serialize, apply
appearance/Icons.qml theme lists, preview resolution, apply
appearance/SunsetTab.qml tab UI
appearance/IdleTab.qml tab UI
appearance/IconsTab.qml tab UI
`AppearancePanel.qml` grows only the tab bar entries and the `Loader` switch.
The singletons follow `Udt.qml` and `Wallpapers.qml`: one `pragma Singleton`,
`Quickshell`/`Quickshell.Io`/`QtQuick`, a `Process` per job, signals for
results.
## Sunset
A faithful port of `hyprsunset-qt`'s behaviour, because that app already defines
what "correct" is for this file.
**Parse.** `~/.config/hypr/hyprsunset.conf`, `profile { ... }` blocks, the same
regexes as `sunset-qt/hyprsunset_qt/config.py`: `time`, `identity` (bool),
`temperature` (int), `gamma` (float). Serialize with the same
`# Managed by hyprsunset-qt. Edits here are overwritten on save.` header and the
same derived `# day profile -- sunrise` / `# night profile -- sunset` comments
(first block with `identity`, first with `temperature`). Writing the identical
format is what lets the Qt app and the drawer edit the same file without either
clobbering the other's meaning.
**UI.** One row per profile: a time field, an identity checkbox, a temperature
enable + field (1000..20000, default 5500), a gamma enable + field (0..2, step
0.05, default 1.0), and a remove button. An Add button appends `time = 0:00`.
**Location.** Read and written from the existing
`~/.config/hyprsunset-qt/config` (`[location]` lat/lon/auto_detect,
`[cache] path`, `[daemon] command`), so both tools agree. Detect runs `curl`
against `ip-api.com/json`; Fetch runs `curl` against
`api.sunrise-sunset.org/json?lat=..&lng=..&formatted=0` with the browser
User-Agent `sunset-qt` uses (Cloudflare 403s the default), writes the response
verbatim to the cache path, and sets the day row's time to the local sunrise and
the night row's to the local sunset. Only Fetch refreshes, matching the Qt app.
**Preview.** `hyprctl hyprsunset temperature|gamma|identity` on the night
profile (first with a temperature), else the first profile. Gamma goes as an
integer percent (`round(gamma * 100)`), the only form the IPC takes. No file
write, no restart.
**Save.** Validate every profile (time `HH:MM`, temp 1000..20000, gamma 0..2);
on failure show the offending value and stop. On success write the config, save
the location settings, then restart: `pkill -x hyprsunset` followed by a
detached `hyprsunset`. `Quickshell.execDetached` is the detached start, its
`start_new_session` equivalent.
## Idle
The commands are fixed; only each listener's timeout and whether it runs are
editable. That is deliberate: a typo in `on-timeout` can suspend the machine, and
the current commands (`notify-send`, `loginctl lock-session`, the Lua
`hl.dsp.dpms` form, `loginctl suspend`) are already correct and are not the thing
being customised.
**Parse.** Everything before the first `listener {` is kept verbatim, so the
`general` block and the long rationale comment about screensavers survive a
save. Each `listener { ... }` block becomes `{ timeout, on-timeout, on-resume,
enabled }`; the command strings are copied, never edited.
**Serialize.** The preserved prefix, then the listener blocks. A disabled
listener is written commented out, so the intent stays visible in the file
rather than vanishing.
**UI.** A row per listener: a description derived from the command ("notify
before lock", "lock session", "monitors off", "suspend"), an enable toggle, and a
seconds field shown as `m:ss`.
**Save.** Write the file, then restart `hypridle` (`pkill -x hypridle`,
detached start). `hypridle` has no reload signal on this install, so a restart is
the only way to apply, and the notice says the idle timers reset.
## Icons
Two sections, icon themes and cursor themes, each with a preview and a current
marker.
**Lists.** Icon themes are directories with an `index.theme` carrying icon
directories; cursor themes are those with a `cursors/` directory or a
hyprcursor `manifest.hl`, scanned in `~/.icons`, `~/.local/share/icons` and
`/usr/share/icons`. The current values come from `gsettings`.
**Icon preview.** `Quickshell.iconPath` cannot show a non-active theme: it reads
the platform theme or `QS_ICON_THEME`, fixed at load. So a single `python3 -c`
process runs GTK's `IconTheme.set_custom_theme(name)` plus `lookup_icon` for a
fixed sample set (`folder`, `text-x-generic`, `image-x-generic`,
`network-wireless`, `audio-x-generic`, `video-x-generic`, `battery-full`,
`printer`) over every theme, printing `theme<TAB>name<TAB>path`; the tab renders
those with `IconImage`. One process per tab open, not one per theme.
**Cursor preview.** Cursor themes here are hyprcursor: a `.hlc` shape is a zip
holding `<name>.svg`, and a shape may instead be a `<name>/` directory with the
same SVG. `unzip -p` extracts `left_ptr` (falling back through `default`,
`pointer`, `hand2`) into the per-shell cache directory and `Image` renders it.
A legacy Xcursor theme (`cursors/left_ptr` binary) goes through `xcur2png`.
Hovering a cursor theme also runs `hyprctl setcursor NAME 24` for a real live
preview, reverted to the current theme on leave and on close.
**Apply, icons.** `gsettings set org.gnome.desktop.interface icon-theme NAME`,
and rewrite `icon_theme=` in `~/.config/qt6ct/qt6ct.conf` and
`~/.config/qt5ct/qt5ct.conf` when present. Apps need restarting.
**Apply, cursor.** `hyprctl setcursor NAME 24` now, `gsettings set
org.gnome.desktop.interface cursor-theme NAME` for GTK, and rewrite
`HYPRCURSOR_THEME` / `XCURSOR_THEME` in
`~/.config/hypr/sections/environment.lua` for the next login. Newly started apps
take the env then; a relogin covers the rest.
## What was verified before writing this
Against this machine, quickshell 0.3.1:
- `hyprctl setcursor hypr_bibata-modern-amber 24` returns `ok`.
- `gsettings` reports icon-theme `Material-Black-Plum-Suru` and cursor-theme
`hypr_bibata-modern-amber`; `qt6ct.conf` and `qt5ct.conf` both carry
`icon_theme=Material-Black-Plum-Suru`.
- GTK lookup resolved all eight sample names to absolute SVG paths under
`Material-Black-Plum-Suru`, with correct size directories (status at 48, the
rest at 32).
- A hyprcursor `.hlc` is a zip whose entries are `<name>.svg` and `meta.hl`;
`unzip -p` extracts the SVG. `XCursor-Pro-Dark-Hyprcursor` uses the
directory shape instead. `xcur2png` converted a legacy
`breeze_cursors/cursors/left_ptr` into 11 PNGs.
- `setsid`, `pkill`, `pgrep`, `gsettings`, `hyprctl`, `curl`, `unzip` and
`xcur2png` are all on PATH; `gi` + `Gtk` import in the session's `python3`.
- `hypridle` carries no reload/SIGHUP handling, so a restart is required.
- `hyprsunset-qt`'s profile block format, validation ranges and restart
(`pkill` then a new session) were read from its source, as was the
location/cache INI.
- `hypridle.conf` has all its comments before the first `listener`, and its
`general` block plus four listeners.
## Traps this has to clear
Recorded in AGENTS.md and all apply: a config with no visible window exits (the
1x1 keepalive stays); QML has no `String.matchAll` (exec loops); a reused
`Process` needs `running = false` before `running = true`; `qs` is the process
name and `pkill -f` matches the caller, so `-x` only.
One is new here. Adding a component *file* does not hot-reload: the directory's
`qmldir` is regenerated only when a file that imports it reloads. The three new
tab components need the shell restarted, or `appearance/shell.qml` edited, once
during development.
A second is specific to editing these files. `hyprsunset.conf` is managed by
hyprsunset-qt and `environment.lua` is the live Hyprland config; both are
user config, not this repo, and writes are confined to the exact lines named
above. The icon theme also appears hardcoded in `wb-icon`, the udt templates,
rofi and the waybar taskbar module, so a switch here does not reach those until
they are unhardcoded, which is a follow-up, not this change.
## Checks
Each singleton gets a `selftest` reachable as
`qs -p . ipc call appearance selftest`, in the spirit of `wb-icon --selftest`:
Sunset round-trips a fixture profile block, Idle round-trips a fixture with a
disabled listener and asserts the comments before the first listener survive,
Icons asserts the eight lookups resolve and that a cursor preview file is
produced. Results are logged. `qs -p .` runs the component and the three tabs are
exercised by hand.
The real verification is a diff: copy `hyprsunset.conf`, `hypridle.conf`,
`qt6ct.conf` and `environment.lua`, save from each tab, and confirm only the
expected lines moved and the parsers reproduce them exactly. A save with no
edits must produce a byte-identical file.
## Deliberately left out
- **Unhardcoding the icon theme.** udt `templates/qt-gtk/*`, waybar-theme-udt
`bin/wb-icon` and `modules/extras/taskbar.jsonc`, rofi `config.rasi`,
`hyprqt6engine.conf`, `firefox-clean.desktop`. A README note here and a TODO in
each of those repos, per the decision.
- **Adding or removing idle listeners.** Timeout and enable only.
- **Editing idle commands.** Fixed by design.
- **A location map or manual timezone.** Lat/lon and the API's times only.
- **The Qt apps' own live reload.** Icons apply to the stored config; the notice
says what still needs restarting rather than pretending it is done.
## Done means
Tab cycles all five; each new tab opens over IPC; Sunset saves an
`hyprsunset.conf` that `hyprsunset-qt` would write and restarts the daemon; Idle
toggles and retimes listeners while keeping the file's comments; Icons previews and
switches both themes. The user judges the visual result.
|