aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-13-controls-panel-design.md
blob: 582ffbeea0f1b90e7415daeb0bd5df7470af1abf (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
# Controls panel design

A small panel of one-click toggles that opens from the island's status pill,
backed by the first piece of a preferences store. This is sub-project 2 of the
roadmap in `2026-10-07-island-core-design.md`. The drawer stays the place for
detail; the panel is for the toggles reached for several times a day, and each
tile links to the drawer page that holds the rest.

The model is Modesty's control center, read for ideas only (GPLv3, this repo
is GPLv2-only).

## Scope

In v1:

- Seven tiles: wifi, bluetooth, do not disturb, presentation, auto-lock,
  output mute, mic mute.
- An edit mode: drag to reorder, hide a tile, show a hidden one.
- A preferences file holding the visible tiles and their order.
- Opening on a bare SUPER press and release.

Not in v1: a settings window (other tunables move into the store when there
is a UI for them), night light (hyprsunset publishes no state the shell can
read), breaktimer and KDE Connect tiles (status displays, the drawer has them).

## Where it lives

    desktop/
      Prefs.qml              singleton: the preferences file
      island/
        Controls.js          pure: tile table, order helpers, fill
        test_controls.js     node test for Controls.js
        ControlTile.qml      one tile: live state, click, right-click, scroll
        ControlsPanel.qml    the grid, edit mode, the hidden strip

`Prefs.qml` sits in `desktop/`, not `shared/`: no other shell reads it yet.

## Preferences store

`Prefs.qml` is a `FileView` with a `JsonAdapter` on
`$XDG_STATE_HOME/quickshell/desktop.json`, falling back to
`~/.local/state/quickshell/desktop.json` when the variable is unset. State
home rather than the runtime directory, because preferences must survive a
reboot, and rather than `~/.config`, because the shell writes the file and a
hand-kept dotfile would be overwritten.

One key in v1:

    { "controls": ["wifi", "bluetooth", "dnd", "presentation", "nolock",
                   "output", "mic"] }

`controls` lists the visible tiles in display order. A tile is hidden when it
is known to `Controls.js` and absent from the list. A tile added to the code
later therefore starts hidden, and the user adds it in edit mode. Unknown ids
in the file (a tile since removed from the code) are dropped on read.

A missing file means the default: all seven, in the order above. A file that
fails to parse also reads as the default, and is not rewritten until the user
changes something, so a hand edit with a typo is not silently replaced by a
panel that was merely opened. Every edit writes the file immediately; the
directory is created on first write.

## Tiles

`Controls.js` holds the table, one entry per id: glyph, label, and the drawer
page right-click opens.

| id | state | left-click | right-click |
| --- | --- | --- | --- |
| wifi | `Networking.wifiEnabled` | flip it | `network` |
| bluetooth | `Bluetooth.defaultAdapter.enabled` | flip it | `bluetooth` |
| dnd | `Status.dnd` | `Status.toggleMode("dnd")` | `status` |
| presentation | `Status.presentation` | `Status.toggleMode("presentation")` | `status` |
| nolock | `!Status.nolock`, labelled "auto-lock" | `Status.toggleMode("nolock")` | `status` |
| output | `sound.sink.audio` | flip `muted` | `sound` |
| mic | `sound.source.audio` | flip `muted` | `sound` |

A tile is an icon over a short label. Toggle tiles fill their background when
on. The two audio tiles fill a bar from the left whose width is the volume
clamped to 1 (output goes to 1.5); muted keeps the width and draws it in
`Theme.overlay` with the muted glyph, so the level stays readable. The accent
is not used as a neutral anywhere, per the AGENTS.md note on udt snapping the
accent onto green.

Scrolling an audio tile steps its volume by 5%, through `MouseArea.onWheel`
(a `WheelHandler` never fired in the island). Output is capped at 1.5 like the
volume key, the mic at 1.0. Scroll on other tiles does nothing.

A tile whose backing object is not there yet shows dimmed and ignores input:
no bluetooth adapter, a null sink or source, and the roughly two seconds after
launch before `Quickshell.Networking` and `Quickshell.Bluetooth` populate.
Bindings repaint when they arrive.

Presentation reads as on while a game holds it (`Status.gaming`); clicking
then clears only the manual half, so the tile stays lit until the game ends.
That is the registry's existing rule and the tile does not hide it.

Icons are freedesktop names through `Quickshell.iconPath(name, true)`, the
island's rule, so an icon theme switch reaches the panel. Every name was
checked to resolve in candy-icons (which inherits breeze-dark): wifi
`network-wireless` / `network-wireless-disconnected`, bluetooth
`preferences-system-bluetooth`, DND `notifications` /
`notifications-disabled`, presentation `video-display`, auto-lock
`system-lock-screen`, output `Logic.volumeIcon`, mic
`audio-input-microphone` / `microphone-sensitivity-muted`, the opener
`preferences-system`, edit `document-edit`, hide `window-close`, show
`list-add`.

## Panel

The status pill gains one glyph that opens the panel in its capsule, the same
way the volume glyph opens the sound panel: `panel === "controls"` in
`Island.qml`. Focus grab and Escape close it as they close the sound panel.

The grid is four columns, so seven tiles make two rows. A pencil glyph in a
corner enters edit mode, where:

- each visible tile carries a small × that hides it;
- a tile dragged onto another moves to that position: one `MouseArea` per
  tile offsets it while pressed, and on release the slot under its centre
  (`slotAt()` in `Controls.js`) is where it lands;
- hidden tiles appear in a strip under the grid, each with a + that appends it
  to the visible list;
- clicks do not toggle and right-click does not open the drawer.

Edit mode ends on the pencil, Escape, or the panel closing. Hiding every tile
is allowed; the panel then shows only the strip.

The order helpers in `Controls.js` are pure: `known()`, `normalize(list)` (drop
unknown ids and duplicates), `hidden(list)`, `move(list, from, to)`,
`hide(list, id)`, `show(list, id)`, `fill(volume)`, `step(volume, delta,
max)`, `slotAt(x, y, pitch, cols, count)`. `test_controls.js` runs
them under `node`, the same way `test_logic.js` does.

## Opening

`desktop/shell.qml` gains `IpcHandler { target: "island" }` with
`controls()`, which reveals the island and toggles the panel.

Hyprland binds it on a bare SUPER press and release (the keyboard has no
right SUPER, so only `Super_L` is bound):

    hl.bind("SUPER + Super_L", ..., { release = true, ... })

calling `qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls`.
A release bind is meant to be cancelled by any key pressed in between, so
`SUPER+c` does not also open the panel. Mouse binds are the risk: older
Hyprland fired a modifier release bind after `SUPER+drag` and `SUPER+scroll`,
both of which are bound here. The build checks both, and SUPER+c, by hand. If
either mouse case opens the panel, the bind falls back to `SUPER+q`.

## Testing

- `node desktop/island/test_controls.js` for the pure helpers.
- A smoke run of `desktop/` confirming a clean load and no new warnings.
- The user checks on screen: the grid, toggles and their fill, audio fill and
  scroll, right-click to the drawer, edit mode (drag, hide, show), the file
  surviving a restart, and the keybind against the mouse cases above.