dot

vizie's NixOS desktop: Hyprland, CachyOS kernel and gaming tweaks

0cf7797

Control centre: Network and Bluetooth tiles, Update NixOS

By Gustaf Granström

- Network and Bluetooth as split tiles: the round icon switches it on and
  off, the rest opens nm-connection-editor or blueman-manager. nm-applet
  is gone and blueman-applet keeps running without its tray icon.
- Sound loses the output picker; the Superlight's battery becomes a
  fourth ring next to CPU, RAM and Disk.
- An "Update NixOS" tile runs tools/nixos-update in kitty: flake update
  in a temporary worktree, build, nvd diff, then (if you say yes) push,
  nixos-rebuild boot and reboot. On failure the window waits for Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

9 files changed +277 −97

@@ -39,18 +39,49 @@
3939 font-weight: bold;
4040 }
4141
42-// ---- tiles (Bluetooth / mouse) ------------------------------------------------
42+// ---- tiles (Network / Bluetooth, Update NixOS) -----------------------------------
4343
4444 .tile {
4545 background-color: $card;
4646 border-radius: 18px;
4747 padding: 10px 12px;
48+
49+ // Split tile: the padding moves onto the two halves, so each half's hover
50+ // highlight is a rounded shape of its own inside the tile.
51+ &.split {
52+ padding: 4px;
53+ }
4854 }
4955
5056 .tile-wrap:hover .tile {
5157 background-color: $card-hover;
5258 }
5359
60+.toggle {
61+ padding: 6px;
62+ border-radius: 14px;
63+}
64+
65+.open {
66+ padding: 6px 8px 6px 6px;
67+ border-radius: 14px;
68+}
69+
70+.toggle-wrap:hover .toggle,
71+.open-wrap:hover .open {
72+ background-color: rgba(255, 255, 255, 0.06);
73+}
74+
75+.chevron {
76+ font-size: 14px;
77+ color: $dim;
78+}
79+
80+.open-wrap:hover .chevron,
81+.tile-wrap:hover .chevron {
82+ color: $fg;
83+}
84+
5485 .icon-circle {
5586 min-width: 38px;
5687 min-height: 38px;
@@ -61,6 +92,12 @@
6192 font-size: 18px;
6293 }
6394
95+ // The NixOS snowflake sits a little left of centre in the font.
96+ .nix {
97+ font-size: 19px;
98+ padding-left: 2px;
99+ }
100+
64101 &.on {
65102 background-color: $fg;
66103
@@ -111,41 +148,6 @@
111148 min-width: 0;
112149 min-height: 24px;
113150 background-color: transparent;
114-}
115-
116-// ---- output device switcher ------------------------------------------------------
117-
118-.device-row {
119- padding: 4px 6px 0 6px;
120- margin: 0 -6px;
121- border-radius: 10px;
122-
123- &:hover {
124- background-color: $card-hover;
125- }
126-}
127-
128-.device-current,
129-.chevron {
130- font-size: 12px;
131- color: $dim;
132-}
133-
134-.device-list {
135- margin-top: 4px;
136-}
137-
138-.device {
139- padding: 6px 8px;
140- border-radius: 10px;
141-
142- &:hover {
143- background-color: $card-hover;
144- }
145-
146- &.active {
147- font-weight: bold;
148- }
149151 }
150152
151153 // ---- system rings -----------------------------------------------------------------
@@ -7,13 +7,17 @@
77 ;; State
88 ;; ---------------------------------------------------------------------------
99
10-(defvar sinks_open false)
1110 (defvar confirm "none") ;; none | logout | reboot | poweroff
1211
1312 (deflisten audio
14- :initial '{"vol":0,"muted":false,"mic":0,"mic_muted":false,"sink":"","sinks":[]}'
13+ :initial '{"vol":0,"muted":false,"mic":0,"mic_muted":false}'
1514 "python3 $HOME/.config/waybar/eww/scripts/audio.py")
1615
16+(defpoll net
17+ :interval "3s"
18+ :initial '{"available":false,"on":false,"connected":false,"icon":"","label":"Unavailable"}'
19+ "bash $HOME/.config/waybar/eww/scripts/network.sh")
20+
1721 (defpoll mouse
1822 :interval "30s"
1923 :initial '{"text":"","percentage":0,"class":"","tooltip":"","label":"Not connected","connected":false}'
@@ -29,6 +33,12 @@
2933 :initial "0"
3034 "df --output=pcent / | tail -1 | tr -dc '0-9'")
3135
36+;; "26.11 · nixpkgs from 15 Oct", from nixos-version (26.11.20261015.fe13242).
37+(defpoll nixos
38+ :interval "10m"
39+ :initial ""
40+ "v=$(nixos-version | cut -d' ' -f1); date -d \"$(cut -d. -f3 <<<\"$v\")\" \"+$(cut -d. -f1-2 <<<\"$v\") · nixpkgs from %-d %b\"")
41+
3242 ;; Last notifications from dunst's history. Only polled while the panel is open;
3343 ;; scripts/toggle.sh fills it once right before opening.
3444 (defvar notifs_open false)
@@ -77,32 +87,42 @@
7787 (defwidget control-center []
7888 (box :class "cc" :orientation "v" :space-evenly false :spacing 10
7989 (box :orientation "h" :space-evenly true :spacing 10
80- (tile :icon {bt.on ? "󰂯" : "󰂲"}
81- :title "Bluetooth"
82- :sub {bt.label}
83- :active {bt.available && bt.on}
84- :onclick "bash $HOME/.config/waybar/eww/scripts/bt-toggle.sh ${bt.on ? 'off' : 'on'}")
85- (tile :icon {mouse.connected ? mouse.text : "󰍽"}
86- :title "Superlight"
87- :sub {mouse.label}
88- :active {mouse.connected && mouse.class == "charging"}
89- :onclick ":"))
90+ (split-tile :icon {net.icon}
91+ :title "Network"
92+ :sub {net.label}
93+ :active {net.available && net.connected}
94+ :ontoggle "bash $HOME/.config/waybar/eww/scripts/net-toggle.sh ${net.on ? 'off' : 'on'}"
95+ :onopen "${EWW_CMD} close control-center cc-closer; setsid -f nm-connection-editor")
96+ (split-tile :icon {bt.on ? "󰂯" : "󰂲"}
97+ :title "Bluetooth"
98+ :sub {bt.label}
99+ :active {bt.available && bt.on}
100+ :ontoggle "bash $HOME/.config/waybar/eww/scripts/bt-toggle.sh ${bt.on ? 'off' : 'on'}"
101+ :onopen "${EWW_CMD} close control-center cc-closer; setsid -f blueman-manager"))
90102 (sound)
91103 (stats)
92- (power)))
104+ (power)
105+ (update)))
93106
94107 ;; ---------------------------------------------------------------------------
95108 ;; Building blocks
96109 ;; ---------------------------------------------------------------------------
97110
98-(defwidget tile [icon title sub active onclick]
99- (eventbox :class "tile-wrap" :cursor "pointer" :timeout "2s" :onclick onclick
100- (box :class "tile" :orientation "h" :space-evenly false :spacing 10
101- (box :class "icon-circle ${active ? 'on' : ''}" :space-evenly true
102- (label :text icon))
103- (box :orientation "v" :valign "center" :halign "start" :space-evenly false
104- (label :class "tile-title" :halign "start" :limit-width 16 :text title)
105- (label :class "tile-sub" :halign "start" :limit-width 18 :text sub)))))
111+;; A tile in two halves: the round icon switches it on and off, the rest opens its
112+;; settings. The halves sit side by side rather than one inside the other, because
113+;; a click on an inner eventbox would reach the outer one too.
114+(defwidget split-tile [icon title sub active ontoggle onopen]
115+ (box :class "tile split" :orientation "h" :space-evenly false
116+ (eventbox :class "toggle-wrap" :cursor "pointer" :timeout "2s" :onclick ontoggle
117+ (box :class "toggle"
118+ (box :class "icon-circle ${active ? 'on' : ''}" :space-evenly true
119+ (label :text icon))))
120+ (eventbox :class "open-wrap" :cursor "pointer" :timeout "2s" :hexpand true :onclick onopen
121+ (box :class "open" :orientation "h" :space-evenly false
122+ (box :orientation "v" :valign "center" :halign "start" :hexpand true :space-evenly false
123+ (label :class "tile-title" :halign "start" :limit-width 12 :text title)
124+ (label :class "tile-sub" :halign "start" :limit-width 15 :text sub))
125+ (label :class "chevron" :valign "center" :text "󰅂")))))
106126
107127 (defwidget slider-row [icon muted value onmute onchange]
108128 (box :class "slider-row" :orientation "h" :space-evenly false :spacing 10
@@ -112,13 +132,14 @@
112132 :timeout "1s" :value value :onchange onchange)
113133 (label :class "slider-val" :text "${value}%")))
114134
115-(defwidget ring [value name]
135+;; `text` replaces the percentage in the middle; `hot` (red) defaults to above 85%.
136+(defwidget ring [value name ?text ?hot]
116137 (box :class "ring-wrap" :orientation "v" :space-evenly false :spacing 6 :hexpand true
117138 (overlay :halign "center"
118- (circular-progress :class "ring ${value > 85 ? 'hot' : ''}"
139+ (circular-progress :class "ring ${(hot ?: value > 85) ? 'hot' : ''}"
119140 :value value :thickness 6 :start-at 75 :clockwise true
120141 :width 60 :height 60)
121- (label :class "ring-value" :text "${round(value, 0)}%"))
142+ (label :class "ring-value" :text {text ?: "${round(value, 0)}%"}))
122143 (label :class "ring-name" :text name)))
123144
124145 (defwidget power-btn [icon label onclick]
@@ -147,27 +168,18 @@
147168 :muted {audio.mic_muted}
148169 :value {audio.mic}
149170 :onmute "pactl set-source-mute @DEFAULT_SOURCE@ toggle"
150- :onchange "pactl set-source-volume @DEFAULT_SOURCE@ {}%")
151- (eventbox :class "device-row" :cursor "pointer" :timeout "1s"
152- :onclick "${EWW_CMD} update sinks_open=${!sinks_open}"
153- (box :orientation "h" :space-evenly false
154- (label :class "device-current" :hexpand true :halign "start" :limit-width 34
155- :text "Output: ${audio.sink}")
156- (label :class "chevron" :text {sinks_open ? "󰅃" : "󰅀"})))
157- (revealer :transition "slidedown" :duration "200ms" :reveal sinks_open
158- (box :class "device-list" :orientation "v" :space-evenly false
159- (for s in {audio.sinks}
160- (eventbox :class "device ${s.active ? 'active' : ''}" :cursor "pointer" :timeout "1s"
161- :onclick "pactl set-default-sink '${s.name}'; ${EWW_CMD} update sinks_open=false"
162- (box :orientation "h" :space-evenly false
163- (label :hexpand true :halign "start" :limit-width 34 :text "${s.desc}")
164- (label :text {s.active ? "󰄬" : ""}))))))))
171+ :onchange "pactl set-source-volume @DEFAULT_SOURCE@ {}%")))
165172
166173 (defwidget stats []
167174 (box :class "card" :orientation "h" :space-evenly true
168175 (ring :value {EWW_CPU.avg} :name "CPU")
169176 (ring :value {EWW_RAM.used_mem_perc} :name "RAM")
170- (ring :value disk :name "Disk")))
177+ (ring :value disk :name "Disk")
178+ ;; The Superlight's battery; red when low rather than when full.
179+ (ring :value {mouse.connected ? mouse.percentage : 0}
180+ :text {mouse.connected ? "${mouse.percentage}%" : "–"}
181+ :hot {mouse.class == "warning" || mouse.class == "critical"}
182+ :name {mouse.class == "charging" ? "Charging" : "Mouse"})))
171183
172184 (defwidget power []
173185 (box :class "card" :orientation "v" :space-evenly false :spacing 6
@@ -192,10 +204,23 @@
192204 (box :class "btn"
193205 (label :text "Cancel")))
194206 (eventbox :class "btn-wrap" :cursor "pointer" :timeout "3s"
195- :onclick "${EWW_CMD} update confirm=none sinks_open=false; ${EWW_CMD} close control-center cc-closer; ${confirm == 'reboot' ? 'systemctl reboot' : (confirm == 'poweroff' ? 'systemctl poweroff' : 'hyprctl dispatch exit')}"
207+ :onclick "${EWW_CMD} update confirm=none; ${EWW_CMD} close control-center cc-closer; ${confirm == 'reboot' ? 'systemctl reboot' : (confirm == 'poweroff' ? 'systemctl poweroff' : 'hyprctl dispatch exit')}"
196208 (box :class "btn danger"
197209 (label :text "Confirm")))))))
198210
211+;; Updates and reboots in a kitty window (tools/nixos-update), asking before it
212+;; installs anything.
213+(defwidget update []
214+ (eventbox :class "tile-wrap" :cursor "pointer" :timeout "3s"
215+ :onclick "${EWW_CMD} close control-center cc-closer; setsid -f kitty --title 'NixOS update' nixos-update"
216+ (box :class "tile" :orientation "h" :space-evenly false :spacing 10
217+ (box :class "icon-circle" :space-evenly true
218+ (label :class "nix" :text ""))
219+ (box :orientation "v" :valign "center" :halign "start" :hexpand true :space-evenly false
220+ (label :class "tile-title" :halign "start" :text "Update NixOS")
221+ (label :class "tile-sub" :halign "start" :limit-width 34 :text nixos))
222+ (label :class "chevron" :valign "center" :text "󰅂"))))
223+
199224 ;; ---------------------------------------------------------------------------
200225 ;; Notifications panel
201226 ;; ---------------------------------------------------------------------------
@@ -41,15 +41,6 @@
4141 "muted": bool(sink and sink.get("mute")),
4242 "mic": percent(src) if src else 0,
4343 "mic_muted": bool(src and src.get("mute")),
44- "sink": (sink or {}).get("description", "No output"),
45- "sinks": [
46- {
47- "name": s.get("name", ""),
48- "desc": s.get("description") or s.get("name", ""),
49- "active": s.get("name") == default_sink,
50- }
51- for s in sinks
52- ],
5344 }
5445
5546
@@ -1,0 +1,6 @@
1+#!/usr/bin/env bash
2+# Usage: net-toggle.sh on|off -- switches networking and refreshes the tile right away.
3+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4+nmcli networking "$1" >/dev/null 2>&1
5+sleep 0.4
6+eww --config "$DIR" update net="$(bash "$DIR/scripts/network.sh")" >/dev/null 2>&1
@@ -1,0 +1,36 @@
1+#!/usr/bin/env bash
2+# Network state as one line of JSON for the Eww control center. Wired wins over
3+# Wi-Fi when both are up.
4+ICON_WIRED=$'\U000F0200' # nf-md-ethernet
5+ICON_WIFI=$'\U000F05A9' # nf-md-wifi
6+ICON_OFF=$'\U000F0202' # nf-md-ethernet_off
7+
8+state() { # available on connected icon label
9+ printf '{"available":%s,"on":%s,"connected":%s,"icon":"%s","label":"%s"}\n' "$@"
10+}
11+
12+command -v nmcli >/dev/null 2>&1 || { state false false false "$ICON_OFF" "Unavailable"; exit 0; }
13+networking=$(timeout 2 nmcli networking 2>/dev/null) || { state false false false "$ICON_OFF" "Unavailable"; exit 0; }
14+[ "$networking" = enabled ] || { state true false false "$ICON_OFF" "Off"; exit 0; }
15+
16+# DEVICE:TYPE:STATE:CONNECTION
17+devices=$(timeout 2 nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device 2>/dev/null)
18+wired=$(grep -m1 -E '^[^:]+:ethernet:connected:' <<<"$devices")
19+wifi=$(grep -m1 -E '^[^:]+:wifi:connected:' <<<"$devices")
20+
21+if [ -n "$wired" ]; then
22+ dev=${wired%%:*}
23+ speed=$(cat "/sys/class/net/$dev/speed" 2>/dev/null)
24+ label="Wired"
25+ if [ "${speed:-0}" -ge 1000 ] 2>/dev/null; then
26+ label+=" · $(awk "BEGIN { print $speed / 1000 }") Gb/s"
27+ elif [ "${speed:-0}" -gt 0 ] 2>/dev/null; then
28+ label+=" · $speed Mb/s"
29+ fi
30+ state true true true "$ICON_WIRED" "$label"
31+elif [ -n "$wifi" ]; then
32+ name=$(cut -d: -f4- <<<"$wifi" | tr -d '"\\')
33+ state true true true "$ICON_WIFI" "${name:-Wi-Fi}"
34+else
35+ state true true false "$ICON_OFF" "Disconnected"
36+fi
@@ -35,7 +35,7 @@
3535 is_open "$w" && open+=("$w")
3636 done
3737 [ ${#open[@]} -gt 0 ] && "${EWW[@]}" close "${open[@]}"
38- "${EWW[@]}" update sinks_open=false confirm=none notifs_open=false
38+ "${EWW[@]}" update confirm=none notifs_open=false
3939 }
4040
4141 # Clicking the button of the panel that is open (or asking to close) just closes it;
@@ -1,8 +1,24 @@
11 # Programs your configs and scripts call, and your own tools.
2-{ inputs, pkgs, ... }:
2+{
3+ config,
4+ inputs,
5+ pkgs,
6+ ...
7+}:
38 let
49 system = pkgs.stdenv.hostPlatform.system;
510
11+ # The control centre's "Update NixOS" tile (tools/nixos-update).
12+ nixos-update = pkgs.writeShellApplication {
13+ name = "nixos-update";
14+ runtimeInputs = with pkgs; [
15+ git
16+ nvd
17+ ];
18+ runtimeEnv.DOT_REPO = config.dot.repoPath;
19+ text = builtins.readFile ../../tools/nixos-update;
20+ };
21+
622 # Philly's webview (WebKitGTK) closes at once on the NVIDIA driver under
723 # Wayland, with "Error 71 (Protocol error) dispatching to Wayland display",
824 # unless its DMA-BUF renderer is off. Set on the program itself, so it holds
@@ -37,6 +53,8 @@
3753 # Called by the control centre and tray scripts.
3854 pulseaudio # pactl
3955 bluez # bluetoothctl
56+ networkmanagerapplet # nm-connection-editor, the Network tile's settings
57+ nixos-update
4058 solaar # mouse battery fallback (mouse-battery.sh)
4159
4260 # Screenshots and screen recording (imsh-shot, imsh-cast; Super+Shift+S/F/R).
@@ -92,12 +92,6 @@
9292 slice = "session-graphical.slice";
9393 };
9494
95- nm-applet = graphical {
96- description = "NetworkManager tray icon";
97- exec = "${pkgs.networkmanagerapplet}/bin/nm-applet --indicator";
98- after = [ "waybar.service" ];
99- };
100-
10195 # Puts Wine/Proton tray icons (Battle.net) into Waybar's tray, so it needs
10296 # the tray to be there first.
10397 xembedsniproxy = graphical {
@@ -142,9 +136,14 @@
142136 };
143137 };
144138
145- # The Bluetooth tray icon (blueman-applet) comes from the autostart entry
146- # services.blueman installs, which uwsm also runs as a systemd unit
147- # (app-blueman@autostart.service).
139+ # Network and Bluetooth live in the control centre, not the tray. blueman-applet
140+ # still runs, from the autostart entry services.blueman installs (uwsm runs it
141+ # as app-blueman@autostart.service), for pairing prompts and notifications;
142+ # only its tray icon is turned off (ShowConnected needs that icon).
143+ dconf.settings."org/blueman/general".plugin-list = [
144+ "!StatusIcon"
145+ "!ShowConnected"
146+ ];
148147
149148 # Bitwarden at login. This one stays an XDG autostart entry, as on CachyOS
150149 # (uwsm runs those as systemd units too: app-bitwarden@autostart.service); the
@@ -1,0 +1,103 @@
1+#!/usr/bin/env bash
2+# Updates NixOS from a throwaway worktree of this repo, so the checkout you edit in
3+# is left alone until the update is done. Started from the control centre's
4+# "Update NixOS" tile in a kitty window (installed as `nixos-update`,
5+# modules/home/packages.nix, which sets DOT_REPO).
6+#
7+# 1. a temporary worktree of main
8+# 2. nix flake update, committed there
9+# 3. nixos-rebuild build, then nvd's list of what changed against the running system
10+# 4. asks before going on; then pushes the commit, installs it as the next boot
11+# entry (nixos-rebuild boot), brings your checkout's main up to date and reboots
12+#
13+# If anything fails, the window stays open until you press Enter, and nothing is
14+# rebooted.
15+set -E
16+
17+repo=${DOT_REPO:-$HOME/git/dot}
18+tmp=""
19+
20+step() { printf '\n\e[1;34m==> %s\e[0m\n' "$*"; }
21+note() { printf '\e[1;33m%s\e[0m\n' "$*"; }
22+wait_close() { read -rp "Press Enter to close. " _ || true; }
23+
24+die() {
25+ trap - ERR
26+ printf '\n\e[1;31m%s\e[0m\n' "$*"
27+ wait_close
28+ exit 1
29+}
30+
31+cleanup() {
32+ if [ -n "$tmp" ]; then
33+ git -C "$repo" worktree remove --force "$tmp" 2>/dev/null || true
34+ rm -rf "$tmp"
35+ tmp=""
36+ fi
37+}
38+
39+trap 'die "The update failed (see above). Nothing was rebooted."' ERR
40+trap cleanup EXIT
41+
42+step "Fetching $repo"
43+git -C "$repo" worktree prune
44+git -C "$repo" fetch origin main
45+git -C "$repo" merge-base --is-ancestor origin/main main ||
46+ die "main in $repo is behind origin/main (or has diverged); pull first."
47+base=$(git -C "$repo" rev-parse main)
48+ahead=$(git -C "$repo" rev-list --count origin/main..main)
49+dirty=$(git -C "$repo" status --porcelain)
50+
51+tmp=$(mktemp -d -t dot-update.XXXXXX)
52+step "Creating a worktree of main in $tmp"
53+git -C "$repo" worktree add --detach "$tmp" main
54+cd "$tmp" || die "Could not enter $tmp."
55+
56+step "Updating flake inputs"
57+nix flake update --commit-lock-file
58+if [ "$(git rev-parse HEAD)" = "$base" ]; then
59+ printf '\nEverything is already up to date.\n'
60+ wait_close
61+ exit 0
62+fi
63+
64+step "Building"
65+nixos-rebuild build --flake "$tmp"
66+
67+step "What changes"
68+git --no-pager log -1 --format=%b
69+nvd diff /run/current-system "$tmp/result"
70+
71+echo
72+[ -z "$dirty" ] || note "Uncommitted changes in $repo are not part of this update."
73+[ "$ahead" -eq 0 ] || note "main has $ahead commit(s) not on GitHub yet; they are pushed too."
74+read -rp "Commit, push, install and reboot? [y/N] " answer || answer=""
75+case $answer in
76+ [yY]*) ;;
77+ *)
78+ echo "Nothing was changed."
79+ sleep 1
80+ exit 0
81+ ;;
82+esac
83+
84+step "Pushing"
85+git push origin HEAD:main
86+
87+step "Installing as the next boot entry"
88+nixos-rebuild boot --sudo --flake "$tmp"
89+
90+step "Updating main in $repo"
91+commit=$(git rev-parse HEAD)
92+if [ "$(git -C "$repo" symbolic-ref --quiet --short HEAD)" = main ]; then
93+ git -C "$repo" merge --ff-only "$commit"
94+else
95+ git -C "$repo" update-ref refs/heads/main "$commit" "$base"
96+fi
97+cd "$repo"
98+cleanup
99+
100+trap 'printf "\nReboot cancelled. The update boots next time.\n"; wait_close; exit 0' INT
101+step "Rebooting in 5 seconds (Ctrl+C to cancel)"
102+sleep 5
103+systemctl reboot