diff --git a/.claude/skills/ponytail/SKILL.md b/.claude/skills/ponytail/SKILL.md
deleted file mode 100644
index 0e0d3be..0000000
--- a/.claude/skills/ponytail/SKILL.md
+++ /dev/null
@@ -1,100 +0,0 @@
----
-name: ponytail
-description: >
- Forces the laziest solution that actually works, simplest, shortest, most
- minimal. Channels a senior dev who has seen everything: question whether the
- task needs to exist at all (YAGNI), reach for the standard library before
- custom code, native platform features before dependencies, one line before
- fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
- the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
- "minimal solution", "yagni", "do less", or "shortest path", and whenever
- they complain about over-engineering, bloat, boilerplate, or unnecessary
- dependencies.
-license: MIT
----
-
-# Ponytail
-
-You are a lazy senior developer. Lazy means efficient, not careless. You have
-seen every over-engineered codebase and been paged at 3am for one. The best
-code is the code never written.
-
-## Persistence
-
-ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
-unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
-Switch: `/ponytail lite|full|ultra`.
-
-## The ladder
-
-Stop at the first rung that holds:
-
-1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
-2. **Stdlib does it?** Use it.
-3. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code.
-4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
-5. **Can it be one line?** One line.
-6. **Only then:** the minimum code that works.
-
-The ladder is a reflex, not a research project. Two rungs work → take the
-higher one and move on. The first lazy solution that works is the right one.
-
-## Rules
-
-- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
-- No boilerplate, no scaffolding "for later", later can scaffold for itself.
-- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
-- Fewest files possible. Shortest working diff wins.
-- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
-- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
-- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
-
-## Output
-
-Code first. Then at most three short lines: what was skipped, when to add it.
-No essays, no feature tours, no design notes. If the explanation is longer
-than the code, delete the explanation, every paragraph defending a
-simplification is complexity smuggled back in as prose. Explanation the user
-explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
-give it in full, the rule is only against unrequested prose.
-
-Pattern: `[code] → skipped: [X], add when [Y].`
-
-## Intensity
-
-| Level | What change |
-|-------|------------|
-| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
-| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
-| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
-
-Example: "Add a cache for these API responses."
-- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
-- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
-- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
-
-## When NOT to be lazy
-
-Never simplify away: input validation at trust boundaries, error handling
-that prevents data loss, security measures, accessibility basics, anything
-explicitly requested. User insists on the full version → build it, no
-re-arguing.
-
-Hardware is never the ideal on paper: a real clock drifts, a real sensor
-reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
-just less code, the physical world needs tuning a minimal model can't see.
-
-Lazy code without its check is unfinished. Non-trivial logic (a branch, a
-loop, a parser, a money/security path) leaves ONE runnable check behind, the
-smallest thing that fails if the logic breaks: an `assert`-based
-`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
-fixtures, no per-function suites unless asked. Trivial one-liners need no
-test, YAGNI applies to tests too.
-
-## Boundaries
-
-Ponytail governs what you build, not how you talk (pair with Caveman for
-terse prose). "stop ponytail" / "normal mode": revert. Level persists until
-changed or session end.
-
-The shortest path to done is the right path.
diff --git a/.mcp.json b/.mcp.json
deleted file mode 100644
index 24bc471..0000000
--- a/.mcp.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "mcpServers": {
- "nixos": {
- "type": "stdio",
- "command": "nix",
- "args": [
- "run",
- "github:utensils/mcp-nixos",
- "--"
- ],
- "env": {}
- }
- }
-}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index b3dde06..8287bfe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,11 +27,6 @@ That means evaluation is **pure**: config can never read files outside the repo
## Code Evaluation
-Before writing or changing any NixOS / Home Manager option, verify it exists and
-has the expected name and type using the `nixos` MCP server tools (`nix` /
-`nix_versions`, configured in `.mcp.json`). Don't rely on memory for option or
-package names — look them up first to avoid invented attributes that fail at eval.
-
Always validate Nix expressions with `nix eval` before committing. For example:
```bash
diff --git a/common.nix b/common.nix
index 2d07bff..48cb9c8 100644
--- a/common.nix
+++ b/common.nix
@@ -22,7 +22,7 @@
./services/qbittorrent-nox.nix
./services/nginx.nix
./services/go2rtc.nix
- # ./services/frigate.nix
+ ./services/frigate.nix
./services/sonarr.nix
./services/radarr.nix
./services/prowlarr.nix
@@ -37,14 +37,10 @@
./services/adguard.nix
./services/router.nix
./services/crowdsec.nix
- ./services/service-health.nix
./services/sabnzbd.nix
./services/forgejo-runner.nix
./services/code-server.nix
./services/memos.nix
- # ./services/neko.nix # superseded by selkies.nix (Neko can't handle GW's mouse grab)
- ./services/selkies.nix
- ./services/hardware-health.nix
];
### Make build time quicker
@@ -91,12 +87,6 @@
# Allow unfree packages
nixpkgs.config.allowUnfree = true;
- # vesktop (multiple hosts) builds with pnpm via fetchPnpmDeps, which nixpkgs
- # marks insecure (build-time only, hash-pinned FOD — not in PATH). broadcom-sta
- # is Macbook-only Wi-Fi but allowing it everywhere is harmless (absent on others).
- nixpkgs.config.allowInsecurePredicate = pkg:
- lib.any (p: lib.hasPrefix p (lib.getName pkg)) [ "broadcom-sta" "pnpm" ];
-
# Flakes — nixos-rebuild self-enables these, but plain `nix eval` /
# `nix flake check` on the hosts need them too.
nix.settings.experimental-features = [ "nix-command" "flakes" ];
diff --git a/flake.lock b/flake.lock
index bc04e91..f1d400f 100644
--- a/flake.lock
+++ b/flake.lock
@@ -71,11 +71,11 @@
"cachyos-kernel": {
"flake": false,
"locked": {
- "lastModified": 1781883168,
- "narHash": "sha256-raAojJGk0aWdscfFn/9ikZ6V5oUuAZcAz5kjAZ2QN3E=",
+ "lastModified": 1780413908,
+ "narHash": "sha256-T15bnskj20rdc4vJ55bFF2lVCVR8edilWn0hiYR7vVs=",
"owner": "CachyOS",
"repo": "linux-cachyos",
- "rev": "daed450e9b1a4fadfef68fb4fa5e2f3391fedb34",
+ "rev": "a61f943f5e94b75c5600a2968cb699d0e37945b3",
"type": "github"
},
"original": {
@@ -87,11 +87,11 @@
"cachyos-kernel-patches": {
"flake": false,
"locked": {
- "lastModified": 1782242233,
- "narHash": "sha256-AUwTZq++PBq0qjDVFKqD0AZNNwa0b1RK41bM9XMbkW8=",
+ "lastModified": 1780462466,
+ "narHash": "sha256-t6c7FTqMB0skEz+4tei5v8GEyL4fRDgx24oW3LrnYiE=",
"owner": "CachyOS",
"repo": "kernel-patches",
- "rev": "19250dcc39862169961756c733b8a6ba77754c22",
+ "rev": "bb41330bd4372672f552beda66712fb70b17f0fa",
"type": "github"
},
"original": {
@@ -211,11 +211,11 @@
]
},
"locked": {
- "lastModified": 1782704057,
- "narHash": "sha256-G1I1gd32F7mp9LAe1DaZ4ZL7NX5gyiKwdCMwro1Vrck=",
+ "lastModified": 1781184346,
+ "narHash": "sha256-cZRlW47U6A2nWvAmnZeeO6Xvq23gxYbVLel4KxqOrcQ=",
"owner": "nix-community",
"repo": "home-manager",
- "rev": "868d0a692de703c2de98fab61968e4e310b7c28e",
+ "rev": "ea6d221d7aa85652d014b6f719dddf036037515b",
"type": "github"
},
"original": {
@@ -234,11 +234,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
- "lastModified": 1782415778,
- "narHash": "sha256-Qts73QQA+lADfxWjonL3Q1JcZssVZPsQI38L3qZyS0o=",
+ "lastModified": 1780771919,
+ "narHash": "sha256-cbace1ZTWYFG0luPL7OFlUxDh/t9lmPj+Isvg9hLN0k=",
"owner": "xddxdd",
"repo": "nix-cachyos-kernel",
- "rev": "1740ec90e7b07730c212a3a1ff5e71af08a5270b",
+ "rev": "3d940a534da0ba6bce60e345ff2c9c7b062087fb",
"type": "github"
},
"original": {
@@ -250,11 +250,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1782378976,
- "narHash": "sha256-UqQgBlQATXM3aBvzTRE/1wxHrCdKg5/ePlXfG/7Eqd8=",
+ "lastModified": 1780751787,
+ "narHash": "sha256-nWR7F46SyrLvN8Ot39XJDpVCswekGakXlOD4KsTYKW0=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "5df71f3d167f0aad71658608361c1301147b9eb6",
+ "rev": "00fa9a692bafc08a86061886f888b843bf7fbdb0",
"type": "github"
},
"original": {
@@ -281,11 +281,11 @@
},
"nixpkgs_2": {
"locked": {
- "lastModified": 1782691344,
- "narHash": "sha256-i5nw9BYYsMDAaOC4J+JmTof6b2GhlyH076awYRNrTV8=",
+ "lastModified": 1780902259,
+ "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "1f01958ffb5b3545c96d9ef2f4e24c5e5e1eb846",
+ "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
"type": "github"
},
"original": {
@@ -297,11 +297,11 @@
},
"nixpkgs_3": {
"locked": {
- "lastModified": 1782723713,
- "narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
+ "lastModified": 1780749050,
+ "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
+ "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
"type": "github"
},
"original": {
@@ -357,11 +357,11 @@
"nixpkgs": "nixpkgs_3"
},
"locked": {
- "lastModified": 1782797297,
- "narHash": "sha256-mscJSstGLRHQ4shBSQOtsElODg+mnRPBDlsA3Mi2A50=",
+ "lastModified": 1781218261,
+ "narHash": "sha256-09ZzpMMfszYPp+SV+P48smeTG2cqttf9oO5BrgTRrzk=",
"owner": "powerofthe69",
"repo": "proton-cachyos-nix",
- "rev": "5cf94db45bd823e8a432ba8aae9745a43f32d24d",
+ "rev": "8c0236830281dffa4ae4236e2ea8b6361d63407f",
"type": "github"
},
"original": {
@@ -398,11 +398,11 @@
"tinted-zed": "tinted-zed"
},
"locked": {
- "lastModified": 1782770679,
- "narHash": "sha256-+8RpmHKn5n2tYmoRCwiKJ6PeU85q15qnXzGQ2WGMn9Q=",
+ "lastModified": 1780702455,
+ "narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=",
"owner": "nix-community",
"repo": "stylix",
- "rev": "3ed763829fc06d32cab3c1f31672379a1f53450e",
+ "rev": "54fa19702f4f2c7f6a981a92850678933588af9a",
"type": "github"
},
"original": {
@@ -501,11 +501,11 @@
]
},
"locked": {
- "lastModified": 1782812215,
- "narHash": "sha256-OPVK9WW9QsO2aj1R+Ln3p7fniFj5h441vb3LyrzpeE4=",
+ "lastModified": 1781173532,
+ "narHash": "sha256-MwnZpL82aQO1I15JH525vz6REI/OULEAmXDp6cIcgNg=",
"owner": "0xc000022070",
"repo": "zen-browser-flake",
- "rev": "a7c2a9a5e492e0e62072547f7e4c2abf138425c5",
+ "rev": "f13e82162fae68af7716147207fa5f868f5ca381",
"type": "github"
},
"original": {
diff --git a/hosts/hardware/FredOS-Macbook.nix b/hosts/hardware/FredOS-Macbook.nix
index 39af88d..bc8e658 100644
--- a/hosts/hardware/FredOS-Macbook.nix
+++ b/hosts/hardware/FredOS-Macbook.nix
@@ -34,17 +34,10 @@
hardware.enableRedistributableFirmware = true;
hardware.facetimehd.enable = true;
- # Pin to the 6.12 LTS kernel: the out-of-tree broadcom_sta (Wi-Fi) and
- # facetimehd (iSight) modules don't build against linuxPackages_latest
- # (7.x) — broadcom-sta's wl_cfg80211 ops no longer match the cfg80211 API.
- # 6.12 LTS is the newest kernel both modules compile against. Overrides
- # common.nix's linuxPackages_latest.
- boot.kernelPackages = lib.mkForce pkgs.linuxPackages_6_12;
-
# wait_prepare/wait_finish were removed from struct vb2_ops in Linux 6.8
nixpkgs.overlays = [
(final: prev: {
- linuxPackages_6_12 = prev.linuxPackages_6_12.extend (lpFinal: lpPrev: {
+ linuxPackages_latest = prev.linuxPackages_latest.extend (lpFinal: lpPrev: {
facetimehd = lpPrev.facetimehd.overrideAttrs (old: {
postPatch = (old.postPatch or "") + ''
sed -i '/\.wait_prepare[[:space:]]*=.*vb2_ops_wait_prepare/d' fthd_v4l2.c
@@ -55,7 +48,8 @@
})
];
- # allowInsecurePredicate (broadcom-sta + pnpm) lives in common.nix now.
+ nixpkgs.config.allowInsecurePredicate = pkg:
+ (lib.hasPrefix "broadcom-sta" (lib.getName pkg));
services.xserver.deviceSection = lib.mkDefault ''
Option "TearFree" "true"
diff --git a/ports.toml b/ports.toml
index 3cf5547..ac7f48d 100644
--- a/ports.toml
+++ b/ports.toml
@@ -45,10 +45,5 @@ name = "7DTD-coop voice/dynamic"
ports = "26911-26912"
protocol = "udp"
-[[forward]]
-name = "Neko WebRTC"
-port = 59000
-protocol = "udp"
-
# DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled.
# Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back.
diff --git a/services/arr-interconnect.nix b/services/arr-interconnect.nix
index 7e8293e..66440df 100644
--- a/services/arr-interconnect.nix
+++ b/services/arr-interconnect.nix
@@ -2,7 +2,7 @@
let
interconnectScript = pkgs.writeShellScript "arr-interconnect" ''
set -euo pipefail
- PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd pkgs.sqlite ]}:$PATH"
+ PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd ]}:$PATH"
BASE="http://127.0.0.1"
@@ -30,14 +30,6 @@ let
SABNZBD_KEY=$(grep -oP '^api_key\s*=\s*\K\S+' /var/lib/sabnzbd/sabnzbd.ini | head -n1 || true)
fi
- # Jellyfin has no config.xml api key; any AccessToken in its db works as
- # an API key. Reuse the first one (create one in the Jellyfin UI once if
- # the table is empty — same first-run caveat as SAB above).
- JELLYFIN_KEY=""
- if [ -f "/var/lib/jellyfin/data/jellyfin.db" ]; then
- JELLYFIN_KEY=$(sqlite3 /var/lib/jellyfin/data/jellyfin.db "SELECT AccessToken FROM ApiKeys LIMIT 1;" 2>/dev/null || true)
- fi
-
# --- Helpers ---
wait_for() {
local name="$1" url="$2" key="$3"
@@ -349,79 +341,6 @@ let
fi
fi
- ##########################################################################
- # Sonarr → Jellyfin (refresh library on import so new shows appear
- # without waiting for Jellyfin's flaky filesystem watcher / full scan)
- ##########################################################################
- if [ -n "$SONARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then
- if ! exists_by_name "$BASE:8989/api/v3/notification" "$SONARR_KEY" "Jellyfin"; then
- echo "Adding Jellyfin notification to Sonarr..."
- curl -sf -X POST \
- -H "Content-Type: application/json" \
- -H "X-Api-Key: $SONARR_KEY" \
- "$BASE:8989/api/v3/notification" \
- -d "$(jq -n --arg key "$JELLYFIN_KEY" '{
- name: "Jellyfin",
- implementation: "MediaBrowser",
- configContract: "MediaBrowserSettings",
- implementationName: "Emby / Jellyfin",
- onDownload: true,
- onUpgrade: true,
- onRename: true,
- onSeriesDelete: true,
- onEpisodeFileDelete: true,
- onEpisodeFileDeleteForUpgrade: true,
- fields: [
- {name: "host", value: "localhost"},
- {name: "port", value: 8096},
- {name: "useSsl", value: false},
- {name: "apiKey", value: $key},
- {name: "notify", value: false},
- {name: "updateLibrary", value: true}
- ],
- tags: []
- }')" > /dev/null && echo " done" || echo " failed"
- else
- echo "Sonarr → Jellyfin already configured"
- fi
- fi
-
- ##########################################################################
- # Radarr → Jellyfin (refresh library on import)
- ##########################################################################
- if [ -n "$RADARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then
- if ! exists_by_name "$BASE:7878/api/v3/notification" "$RADARR_KEY" "Jellyfin"; then
- echo "Adding Jellyfin notification to Radarr..."
- curl -sf -X POST \
- -H "Content-Type: application/json" \
- -H "X-Api-Key: $RADARR_KEY" \
- "$BASE:7878/api/v3/notification" \
- -d "$(jq -n --arg key "$JELLYFIN_KEY" '{
- name: "Jellyfin",
- implementation: "MediaBrowser",
- configContract: "MediaBrowserSettings",
- implementationName: "Emby / Jellyfin",
- onDownload: true,
- onUpgrade: true,
- onRename: true,
- onMovieDelete: true,
- onMovieFileDelete: true,
- onMovieFileDeleteForUpgrade: true,
- fields: [
- {name: "host", value: "localhost"},
- {name: "port", value: 8096},
- {name: "useSsl", value: false},
- {name: "apiKey", value: $key},
- {name: "notify", value: false},
- {name: "updateLibrary", value: true}
- ],
- tags: []
- }')" > /dev/null && echo " done" || echo " failed"
- else
- echo "Radarr → Jellyfin already configured"
- fi
- fi
-
##########################################################################
# Prowlarr auth — trust localhost so Authelia is the only gate. Other
# *arr apps default to this; Prowlarr does not.
diff --git a/services/authelia.nix b/services/authelia.nix
index 92b0e0d..0d3f0f7 100644
--- a/services/authelia.nix
+++ b/services/authelia.nix
@@ -41,7 +41,6 @@
{ domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; }
{ domain = "code.nordhammer.it"; policy = "one_factor"; }
{ domain = "notes.nordhammer.it"; policy = "one_factor"; }
- { domain = "selkies.nordhammer.it"; policy = "one_factor"; }
];
};
diff --git a/services/crowdsec.nix b/services/crowdsec.nix
index d35905c..355f9d9 100644
--- a/services/crowdsec.nix
+++ b/services/crowdsec.nix
@@ -9,10 +9,37 @@
# 2. Delete ../modules/crowdsec/ and the disabledModules + imports lines below
# 3. The settings/option API is the same as the PR's, so config below is forward-compatible
#
-# CrowdSec bans silently — no ntfy pushes (they were constant noise).
-# The /var/secrets/ntfy-url topic is used by services/service-health.nix instead.
+# Before first deploy, create /var/secrets/ntfy-url with your topic URL:
+# echo 'https://ntfy.sh/nordhammer-' | sudo tee /var/secrets/ntfy-url
+# sudo chmod 600 /var/secrets/ntfy-url
{ config, lib, pkgs, ... }:
let
+ # The real URL is injected at service start (see ExecStartPre below) —
+ # eval-time builtins.readFile can't see /var/secrets under pure flake
+ # evaluation, which is how the `update` alias builds.
+ ntfyUrlPlaceholder = "@NTFY_URL@";
+
+ # The module renders settings.notifications into /etc/crowdsec/notifications/
+ # as a symlink into /etc/static (the store). Re-render it from the static
+ # source with the secret substituted on every service start; nixos-rebuild
+ # restores the symlink on activation, so this never goes stale.
+ injectNtfyUrl = pkgs.writeShellScript "crowdsec-inject-ntfy-url" ''
+ set -euo pipefail
+ src=/etc/static/crowdsec/notifications/0-nixos-generated.yaml
+ dst=/etc/crowdsec/notifications/0-nixos-generated.yaml
+ secret=/var/secrets/ntfy-url
+ if [ ! -f "$secret" ]; then
+ echo "WARNING: $secret not found; ntfy notifications will not work" >&2
+ exit 0
+ fi
+ url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
+ tmp=$(${pkgs.coreutils}/bin/mktemp "$dst.XXXXXX")
+ ${pkgs.gnused}/bin/sed "s|${ntfyUrlPlaceholder}|$url|g" "$src" > "$tmp"
+ ${pkgs.coreutils}/bin/chmod 600 "$tmp"
+ ${pkgs.coreutils}/bin/chown crowdsec:crowdsec "$tmp"
+ ${pkgs.coreutils}/bin/mv "$tmp" "$dst"
+ '';
+
# nixpkgs only builds the agent + cscli; the new module also expects
# notification plugins at $out/libexec/crowdsec/plugins/. Compile them
# from the same source tree (cmd/notification-*) and move them there.
@@ -115,27 +142,52 @@ in
}
];
- # Profiles set ban duration to 4h. No ntfy notifications: a push per
- # ban was constant noise on a WAN-exposed box. ntfy is now reserved
- # for service-down alerts (see services/service-health.nix); CrowdSec
- # still bans silently.
+ # Push notifications via ntfy.sh
+ notifications = [
+ {
+ name = "ntfy_http";
+ type = "http";
+ log_level = "info";
+ url = ntfyUrlPlaceholder;
+ method = "POST";
+ headers = {
+ Title = "CrowdSec alert";
+ Priority = "high";
+ Tags = "rotating_light";
+ };
+ format = ''
+ {{range . -}}
+ {{.Scenario}} from {{.Source.IP}} ({{.Source.Cn}}) — {{len .Decisions}} decision(s) taken
+ {{end -}}
+ '';
+ }
+ ];
+
+ # Override default profiles to attach the ntfy notifier
profiles = [
{
name = "default_ip_remediation";
filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Ip'" ];
decisions = [{ type = "ban"; duration = "4h"; }];
+ notifications = [ "ntfy_http" ];
on_success = "break";
}
{
name = "default_range_remediation";
filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Range'" ];
decisions = [{ type = "ban"; duration = "4h"; }];
+ notifications = [ "ntfy_http" ];
on_success = "break";
}
];
};
};
+ # Inject the ntfy topic URL into the rendered notification config before
+ # every start. "+" runs the script with full privileges (it reads the
+ # root-owned secret and replaces a root-owned /etc symlink).
+ systemd.services.crowdsec.serviceConfig.ExecStartPre = [ "+${injectNtfyUrl}" ];
+
# Firewall bouncer enforces decisions via nftables; auto-registers with LAPI
services.crowdsec-firewall-bouncer = {
enable = true;
diff --git a/services/hardware-health.nix b/services/hardware-health.nix
deleted file mode 100644
index 6d2d525..0000000
--- a/services/hardware-health.nix
+++ /dev/null
@@ -1,35 +0,0 @@
-# services/hardware-health.nix — RAS error attribution + watchdog auto-recovery
-#
-# Context: Jun 2026 the dual Xeon E5-2697 v3 began throwing a storm of
-# *corrected* Machine Check Exceptions on both sockets (Bank 5 / Bank 20),
-# ~18k events in 36h, eventually hanging the box. Since this host is the
-# router, a hang takes the whole LAN offline until a manual power-cycle.
-#
-# This module:
-# - rasdaemon: decodes every MCE to a specific DIMM/channel/socket and
-# persists a per-component error DB, so a failing part can be named
-# (needed for the seller's warranty claim). Query with `ras-mc-ctl
-# --error-count` and `ras-mc-ctl --summary`.
-# - hardware watchdog: if userspace hangs again, systemd stops petting
-# /dev/watchdog0 and the chipset watchdog reboots the box (~30s),
-# restoring the LAN without physical access.
-
-{ config, lib, pkgs, ... }:
-{
- config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
-
- # Decode + log + persist machine-check / memory errors per component.
- hardware.rasdaemon.enable = true;
-
- # ras-mc-ctl on PATH for manual inspection.
- environment.systemPackages = [ pkgs.rasdaemon ];
-
- # Hardware watchdog: auto-reboot a hung box instead of a dead LAN.
- # systemd pets /dev/watchdog0 at half the runtime interval; if it stops
- # (hang), the chipset resets after RuntimeWatchdogSec.
- systemd.settings.Manager = {
- RuntimeWatchdogSec = "30s";
- RebootWatchdogSec = "10min";
- };
- };
-}
diff --git a/services/neko.nix b/services/neko.nix
deleted file mode 100644
index 46e04cf..0000000
--- a/services/neko.nix
+++ /dev/null
@@ -1,92 +0,0 @@
-# services/neko.nix — Guild Wars (2005) in a browser via Neko
-#
-# Streams an Xfce desktop running the Windows Guild Wars client (under Wine)
-# to a browser tab over WebRTC. Reach it at neko.nordhammer.it (Authelia-gated).
-#
-# Neko's stock images don't ship Wine, and apt installs land in /usr — which is
-# wiped whenever the container is recreated. So we bake Wine into a locally-built
-# image (FROM the upstream nvidia-xfce base) instead of relying on a volume.
-# Guild Wars' own data installs into the persistent /home/neko volume on first run.
-#
-# Rendering is software-only (no GPU): neko doesn't ship a prebuilt NVIDIA Xfce
-# desktop image, and building one from nvidia-base is a big detour for a 2005
-# game. Wine renders via llvmpipe (software OpenGL) and neko encodes via x264 —
-# both are heavily multithreaded and this box has 56 Xeon threads to spare, so
-# Guild Wars is comfortably playable this way.
-{ config, pkgs, lib, ... }:
-let
- # Wine-enabled image definition. Fed to `docker build` over stdin (see below)
- # so there's no build context — we have no COPY/ADD, and a Nix-store symlinked
- # context dir breaks BuildKit's Dockerfile resolution. Pinned to the v3.1
- # series so the NEKO_MEMBER_*/NEKO_WEBRTC_* env schema below stays valid.
- dockerfile = pkgs.writeText "neko-gw.Dockerfile" ''
- FROM ghcr.io/m1k1o/neko/xfce:3.1
- USER root
- RUN dpkg --add-architecture i386 \
- && apt-get update \
- && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
- wine wine64 wine32 winbind ca-certificates wget \
- libgl1 libgl1:i386 libglx-mesa0 libglx-mesa0:i386 \
- libgl1-mesa-dri libgl1-mesa-dri:i386 \
- && rm -rf /var/lib/apt/lists/*
- '';
-in
-{
- config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
-
- virtualisation.docker.enable = true;
-
- systemd.tmpfiles.rules = [
- "d /var/lib/neko 0755 root root -"
- # The container's neko user is uid/gid 1000 and must own its home, or the
- # X server / Xfce can't create ~/.config, ~/.cache, etc. and the desktop
- # never starts.
- "d /var/lib/neko/home 0755 1000 1000 -"
- ];
-
- systemd.services.neko = {
- description = "Neko — Guild Wars in a browser (Xfce + Wine + NVIDIA)";
- after = [ "docker.service" "network-online.target" ];
- requires = [ "docker.service" ];
- wants = [ "network-online.target" ];
- wantedBy = [ "multi-user.target" ];
-
- # First start pulls a multi-GB base image and runs apt — give it room.
- # If it fails, back off but don't crash-loop (see 7dtd veth-flood note).
- startLimitIntervalSec = 600;
- startLimitBurst = 3;
-
- serviceConfig = {
- Restart = "on-failure";
- RestartSec = "30s";
- TimeoutStartSec = "3600";
- ExecStartPre = [
- "-${pkgs.docker}/bin/docker rm -f neko"
- "${pkgs.writeShellScript "neko-build" ''
- exec ${pkgs.docker}/bin/docker build -t neko-gw:local - < ${dockerfile}
- ''}"
- ];
- # Wrapped in a shell script so the ICE-server JSON survives quoting
- # (systemd's own ExecStart parser would strip the inner double quotes).
- ExecStart = pkgs.writeShellScript "neko-run" ''
- exec ${pkgs.docker}/bin/docker run --rm --name neko \
- --shm-size=1g \
- -e LIBGL_ALWAYS_SOFTWARE=1 \
- -p 127.0.0.1:8092:8080 \
- -p 59000:59000/udp \
- -e NEKO_DESKTOP_SCREEN=1280x720@30 \
- -e NEKO_MEMBER_PROVIDER=multiuser \
- -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \
- -e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=neko-admin \
- -e NEKO_WEBRTC_UDPMUX=59000 \
- -e NEKO_WEBRTC_NAT1TO1=10.0.0.1 \
- -e 'NEKO_WEBRTC_ICESERVERS_FRONTEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \
- -e 'NEKO_WEBRTC_ICESERVERS_BACKEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \
- -v /var/lib/neko/home:/home/neko \
- neko-gw:local
- '';
- ExecStop = "${pkgs.docker}/bin/docker stop neko";
- };
- };
- };
-}
diff --git a/services/nginx.nix b/services/nginx.nix
index ac98d9e..c340533 100644
--- a/services/nginx.nix
+++ b/services/nginx.nix
@@ -123,7 +123,6 @@ in
'';
};
"notes.nordhammer.it" = protectedProxy 5230;
- "selkies.nordhammer.it" = protectedProxy 8093;
# --- Local-only: serves update history JSON to Homepage's customapi widget ---
"homepage-updates.local" = {
diff --git a/services/selkies.nix b/services/selkies.nix
deleted file mode 100644
index b8c8e03..0000000
--- a/services/selkies.nix
+++ /dev/null
@@ -1,99 +0,0 @@
-# services/selkies.nix — Guild Wars in a browser via Selkies
-#
-# Replaces the Neko attempt (services/neko.nix, now unimported): Neko's
-# absolute-pointer input model can't handle Guild Wars' exclusive mouse grab.
-# Selkies captures the mouse client-side with the browser Pointer Lock API and
-# sends *relative* movement, so the grab is a non-issue — and it uses the GPU
-# (NVENC + EGL) instead of software rendering.
-#
-# Reach it at selkies.nordhammer.it (Authelia-gated). The Wine prefix with
-# Guild Wars already installed is reused from the old Neko home, seeded into
-# /var/lib/selkies/home/.wine (see the deploy note in the repo).
-{ config, pkgs, lib, ... }:
-let
- # Selkies' NVIDIA EGL desktop (Ubuntu 24.04) plus Wine for the 32-bit GW
- # client. Built from stdin (no build context); see neko.nix for the why.
- dockerfile = pkgs.writeText "selkies-gw.Dockerfile" ''
- FROM ghcr.io/selkies-project/nvidia-egl-desktop:24.04
- USER root
- RUN add-apt-repository -y multiverse \
- && dpkg --add-architecture i386 \
- && apt-get update \
- && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
- wine wine32 wine64 winbind cabextract ca-certificates wget \
- && rm -rf /var/lib/apt/lists/*
- # `gw` launcher: GW is 32-bit, so it needs the 32-bit NVIDIA GL libs (mounted
- # at /usr/lib/i386-linux-gnu/nvidia by the service) on the loader path, and
- # VirtualGL (EGL backend) to render on the M2000. Without this GW falls back
- # to llvmpipe (software) and pegs ~18 CPU cores at <20 fps.
- RUN printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/usr/lib/i386-linux-gnu/nvidia\nexport VGL_DISPLAY=egl VGL_FPS=60\ncd "$HOME/.wine/drive_c/Program Files (x86)/Guild Wars/"\nexec vglrun wine Gw.exe "$@"\n' > /usr/local/bin/gw \
- && chmod +x /usr/local/bin/gw
- USER 1000
- '';
-in
-{
- config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
-
- virtualisation.docker.enable = true;
- # GPU into the container via CDI (nvidia.com/gpu=all). The CDI spec only
- # carries the 64-bit driver libs, so the 32-bit set (for 32-bit Wine/GW) is
- # bind-mounted separately below; enable32Bit makes them exist on the host.
- hardware.nvidia-container-toolkit.enable = true;
- hardware.graphics.enable32Bit = true;
-
- systemd.tmpfiles.rules = [
- "d /var/lib/selkies 0755 root root -"
- # Container user is uid/gid 1000 and must own its home.
- "d /var/lib/selkies/home 0755 1000 1000 -"
- ];
-
- systemd.services.selkies = {
- description = "Selkies — Guild Wars in a browser (EGL desktop + Wine + NVENC)";
- after = [ "docker.service" "network-online.target" ];
- requires = [ "docker.service" ];
- wants = [ "network-online.target" ];
- wantedBy = [ "multi-user.target" ];
-
- startLimitIntervalSec = 600;
- startLimitBurst = 3;
-
- serviceConfig = {
- Restart = "on-failure";
- RestartSec = "30s";
- TimeoutStartSec = "3600";
- ExecStartPre = [
- "-${pkgs.docker}/bin/docker rm -f selkies"
- "${pkgs.writeShellScript "selkies-build" ''
- exec ${pkgs.docker}/bin/docker build -t selkies-gw:local - < ${dockerfile}
- ''}"
- ];
- ExecStart = pkgs.writeShellScript "selkies-run" ''
- exec ${pkgs.docker}/bin/docker run --rm --name selkies \
- --device=nvidia.com/gpu=all \
- -e NVIDIA_VISIBLE_DEVICES=all \
- -e NVIDIA_DRIVER_CAPABILITIES=all \
- --shm-size=2g \
- -p 127.0.0.1:8093:8080 \
- -p 3478:3478 -p 3478:3478/udp \
- -p 65532-65535:65532-65535/udp \
- -e TZ=Europe/Stockholm \
- -e DISPLAY_SIZEW=1280 -e DISPLAY_SIZEH=720 \
- -e DISPLAY_REFRESH=60 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \
- -e PASSWD=selkies \
- -e SELKIES_ENCODER=nvh264enc \
- -e SELKIES_VIDEO_BITRATE=8000 \
- -e SELKIES_FRAMERATE=60 \
- -e SELKIES_ENABLE_BASIC_AUTH=false \
- -e SELKIES_TURN_HOST=10.0.0.1 \
- -e SELKIES_TURN_PROTOCOL=udp \
- -e SELKIES_TURN_PORT=3478 \
- -e TURN_MIN_PORT=65532 -e TURN_MAX_PORT=65535 \
- -v ${config.hardware.nvidia.package.lib32}/lib:/usr/lib/i386-linux-gnu/nvidia:ro \
- -v /var/lib/selkies/home:/home/ubuntu \
- selkies-gw:local
- '';
- ExecStop = "${pkgs.docker}/bin/docker stop selkies";
- };
- };
- };
-}
diff --git a/services/service-health.nix b/services/service-health.nix
deleted file mode 100644
index 58335d5..0000000
--- a/services/service-health.nix
+++ /dev/null
@@ -1,77 +0,0 @@
-# services/service-health.nix — ntfy alert when a watched systemd unit fails,
-# and again when it recovers. Replaces the noisy per-ban CrowdSec pushes
-# (silenced in services/crowdsec.nix); both share the /var/secrets/ntfy-url topic.
-#
-# Detection is event-driven: each watched unit gets OnFailure=notify-failure@%n.
-# OnFailure fires only once a unit truly enters "failed" state — i.e. after it
-# has exhausted its Restart= attempts — so transient restarts stay silent and
-# you're only paged when a service has genuinely given up. The handler sends a
-# "down" push, then waits for the unit to come back and sends "recovered".
-#
-# Requires /var/secrets/ntfy-url (the same topic file CrowdSec used):
-# echo 'https://ntfy.sh/your-topic' | sudo tee /var/secrets/ntfy-url
-# sudo chmod 600 /var/secrets/ntfy-url
-{ config, lib, pkgs, ... }:
-let
- # Core media + infra units to page on. All verified to exist on the box;
- # adding a name that isn't a real unit would create a stray stub service.
- watched = [
- "jellyfin" "sonarr" "radarr" "prowlarr" "bazarr"
- "qbittorrent-nox" "sabnzbd" "authelia-main" "nginx"
- "adguardhome" "crowdsec" "go2rtc"
- "homepage-dashboard" "cloudflare-dyndns" "gitea-runner-default"
- ];
-
- # Reads the topic at runtime (pure flake eval can't see /var/secrets).
- # $1 = the failed unit's full name, e.g. "jellyfin.service".
- notify = pkgs.writeShellScript "service-health-notify" ''
- set -uo pipefail
- unit="$1"
- name="''${unit%.service}"
- host="${config.networking.hostName}"
- secret=/var/secrets/ntfy-url
- if [ ! -f "$secret" ]; then
- echo "service-health: $secret missing; cannot notify" >&2
- exit 0
- fi
- url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
-
- post() { # title priority tags body
- ${pkgs.curl}/bin/curl -fsS --max-time 10 \
- -H "Title: $1" -H "Priority: $2" -H "Tags: $3" \
- -d "$4" "$url" >/dev/null 2>&1 || true
- }
-
- post "Service down" high rotating_light "$name failed on $host"
-
- # Wait for recovery: up to 2h, polling every 20s.
- for _ in $(${pkgs.coreutils}/bin/seq 1 360); do
- ${pkgs.coreutils}/bin/sleep 20
- if ${pkgs.systemd}/bin/systemctl is-active --quiet "$unit"; then
- post "Service recovered" default white_check_mark "$name is running again on $host"
- exit 0
- fi
- done
- '';
-in
-{
- config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
-
- systemd.services = lib.mkMerge [
- # Templated handler: %i is the failed unit's full name (jellyfin.service).
- {
- "notify-failure@" = {
- description = "ntfy alert: %i failed";
- serviceConfig = {
- Type = "simple";
- ExecStart = "${notify} %i";
- };
- };
- }
- # Wire OnFailure onto each watched unit (merges with its existing config).
- (lib.genAttrs watched (_: {
- unitConfig.OnFailure = [ "notify-failure@%n.service" ];
- }))
- ];
- };
-}
diff --git a/settings/hyprland.nix b/settings/hyprland.nix
index a6b608a..74a4287 100644
--- a/settings/hyprland.nix
+++ b/settings/hyprland.nix
@@ -227,32 +227,12 @@ in
hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" })
-- Window rules
- -- Don't lock/idle while any window is fullscreen (video, games).
- hl.window_rule({
- match = { class = ".*" },
- idle_inhibit = "fullscreen",
- })
-
-- Battle.net tray icon leaks as a tiny floating XWayland window.
hl.window_rule({
match = { class = "steam_app_0", title = "^$", float = true },
workspace = "special silent",
})
- -- Tiny Terraces opens floating by default; force it to tile.
- hl.window_rule({
- match = { class = "steam_app_3136330" },
- tile = true,
- })
-
- -- Battle.net (a non-Steam shortcut, so class steam_app_0) spams
- -- window-activation events that clear quickshell's focus grab and
- -- instantly close the launcher / power menu. Drop those events.
- hl.window_rule({
- match = { class = "steam_app_0" },
- suppress_event = "activate activatefocus",
- })
-
-- Binds
local mod = "SUPER"
@@ -295,10 +275,8 @@ in
end
-- Screenshots — Shift+Super+S matches GNOME binding
- -- Pin/unpin quickshell's focus grab around the region select so an
- -- open menu survives slurp's input grab (no-ops if qs isn't up).
- hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region; qs ipc call screenshot unpin'"))
- hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output"))
+ hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only"))
+ hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only"))
-- Settings shortcut — Super+I matches GNOME binding
hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol"))
@@ -378,38 +356,31 @@ in
};
};
- services.hypridle = lib.mkIf isMacbook (
- let
- # Skip the action if any MPRIS player is playing — covers windowed
- # video (Jellyfin in a browser, mpv, …) that the fullscreen
- # idle_inhibit rule misses. Browsers expose MPRIS via playerctl.
- unlessPlaying = cmd: "playerctl -a status 2>/dev/null | grep -q Playing || ${cmd}";
- in {
- enable = true;
- settings = {
- general = {
- lock_cmd = "pidof hyprlock || hyprlock";
- before_sleep_cmd = "loginctl lock-session";
- after_sleep_cmd = "hyprctl dispatch dpms on";
- };
- listener = [
- {
- timeout = 300; # 5 min — lock
- on-timeout = unlessPlaying "loginctl lock-session";
- }
- {
- timeout = 420; # 7 min — display off
- on-timeout = unlessPlaying "hyprctl dispatch dpms off";
- on-resume = "hyprctl dispatch dpms on";
- }
- {
- timeout = 600; # 10 min — suspend
- on-timeout = unlessPlaying "systemctl suspend";
- }
- ];
+ services.hypridle = lib.mkIf isMacbook {
+ enable = true;
+ settings = {
+ general = {
+ lock_cmd = "pidof hyprlock || hyprlock";
+ before_sleep_cmd = "loginctl lock-session";
+ after_sleep_cmd = "hyprctl dispatch dpms on";
};
- }
- );
+ listener = [
+ {
+ timeout = 300; # 5 min — lock
+ on-timeout = "loginctl lock-session";
+ }
+ {
+ timeout = 420; # 7 min — display off
+ on-timeout = "hyprctl dispatch dpms off";
+ on-resume = "hyprctl dispatch dpms on";
+ }
+ {
+ timeout = 600; # 10 min — suspend
+ on-timeout = "systemctl suspend";
+ }
+ ];
+ };
+ };
# Scope all HM Wayland services (hyprpaper, etc.) to the
# Hyprland session so they don't crash-loop in a GNOME session.
diff --git a/settings/quickshell.nix b/settings/quickshell.nix
index 7c98ea1..81de7de 100644
--- a/settings/quickshell.nix
+++ b/settings/quickshell.nix
@@ -153,19 +153,15 @@ in
readonly property color base03: "#${c.base03}"
readonly property color base04: "#${c.base04}"
readonly property color base05: "#${c.base05}"
+ readonly property color base06: "#${c.base06}"
+ readonly property color base07: "#${c.base07}"
readonly property color base08: "#${c.base08}"
readonly property color base0A: "#${c.base0A}"
readonly property color base0B: "#${c.base0B}"
readonly property color base0C: "#${c.base0C}"
readonly property color base0D: "#${c.base0D}"
readonly property color barBg: "#B3${c.base00}"
- // Card surfaces are translucent so the compositor's bar-layer
- // blur shows through — a lessened blur over the panel tint.
- readonly property color cardBg: "#CC${c.base01}"
- // Transparent base02: hover highlights fade to/from this so the
- // colour animation stays in-hue instead of dipping through
- // transparent *black* (which reads as an extra flash colour).
- readonly property color base02t: "#00${c.base02}"
+ readonly property color toastBg: "#E6${c.base00}"
readonly property string fontFamily: "${monoFont}"
// Ligature-based icon font: text "volume_up" renders the icon
readonly property string iconFont: "Material Symbols Rounded"
@@ -173,20 +169,6 @@ in
readonly property int borderWidth: 2
// Screen frame band; sits inside hyprland's gaps_out (12)
readonly property int frameWidth: 6
-
- // ── Layout / metric tokens (referenced, not hardcoded) ──
- // Bar band height. Drives widget heights, dropdown y-origin and
- // the shader cutout geometry — change here, not in ~10 places.
- readonly property int barHeight: 30
- readonly property int radius: 8 // cards, panels, dropdowns
- readonly property int radiusSmall: 6 // workspace dots, pill buttons
- readonly property int radiusTiny: 4 // hover rows, action chips
- readonly property int cardPad: 8 // card inner padding (inset = 2×)
-
- // ── Animation duration tokens (ms) ──
- readonly property int animMorph: 280 // panel grow / slide (OutExpo)
- readonly property int animContent: 200 // content fade / highlight move
- readonly property int animFade: 120 // hover colour transitions
}
'';
};
@@ -238,16 +220,6 @@ in
function reload(): void { Quickshell.reload(false); }
}
- // Screenshot pin: the Shift+Super+S keybind brackets hyprshot
- // with pin/unpin so the focus grab is suspended while slurp
- // grabs input — otherwise the open menu would close like any
- // click-outside. Self-heals via a watchdog if unpin is missed.
- IpcHandler {
- target: "screenshot"
- function pin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(true); }
- function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); }
- }
-
NotificationServer {
id: _notifServer
bodySupported: true
@@ -288,6 +260,8 @@ in
import Quickshell.Widgets
import Quickshell.Io
import QtQuick
+ import QtQuick.Layouts
+ import QtQuick.Shapes
import Qt5Compat.GraphicalEffects
PanelWindow {
@@ -301,7 +275,7 @@ in
// (caelestia's): the grab redirects focus to this window and
// OnDemand lets the layer surface accept it. Exclusive fights
// the grab — it self-clears and instantly closes the panel.
- WlrLayershell.keyboardFocus: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
+ WlrLayershell.keyboardFocus: sessionMenu.open || launcherPanel.open ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
anchors {
top: true
@@ -310,7 +284,7 @@ in
}
implicitHeight: bar.screen.height
- exclusiveZone: Theme.barHeight
+ exclusiveZone: 30
color: "transparent"
mask: Region {
@@ -346,7 +320,7 @@ in
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
- height: Theme.barHeight
+ height: 30
}
// Register the primary bar so shell.qml's IPC handler can
@@ -364,186 +338,6 @@ in
launcherPanel.toggle();
}
- // ── Shared base text types: default the shell's two fonts so
- // no widget repeats `font.family`. Size/colour/weight are
- // overridable per use (these are just the common defaults).
- component SText: Text {
- color: Theme.base05
- font.family: Theme.fontFamily
- font.pixelSize: 13
- }
- component SIcon: Text {
- color: Theme.base05
- font.family: Theme.iconFont
- font.pixelSize: 16
- }
-
- // ── VolIcon: a slider's volume glyph that toggles its audio
- // node's mute on click. Glyph reflects the mute state; pair it
- // with a fill that greys when `audioNode.muted`.
- component VolIcon: SIcon {
- property var audioNode: null
- text: (audioNode && audioNode.muted) ? "volume_off" : "volume_up"
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: if (audioNode) audioNode.muted = !audioNode.muted
- }
- }
-
- // ── HoverRow: a rounded clickable row that owns the shared
- // base02 hover-fade + pointer cursor. Drop content inside and
- // handle `onClicked`; override `radius` for non-radiusTiny rows.
- component HoverRow: Rectangle {
- default property alias rowData: _hrContent.data
- signal clicked()
- radius: Theme.radiusTiny
- color: _hrMa.containsMouse ? Theme.base02 : Theme.base02t
- Behavior on color { ColorAnimation { duration: Theme.animFade } }
- Item { id: _hrContent; anchors.fill: parent }
- MouseArea {
- id: _hrMa
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: parent.clicked()
- }
- }
-
- // ── Card: the rounded base01 section surface used by every
- // dropdown. Children flow into a padded auto-height column,
- // so callers just set `width` and drop content in.
- component Card: Rectangle {
- default property alias cardData: _cardCol.data
- property alias cardSpacing: _cardCol.spacing
- radius: Theme.radius
- color: Theme.cardBg
- implicitHeight: _cardCol.height + 2 * Theme.cardPad
- Column {
- id: _cardCol
- anchors.top: parent.top
- anchors.topMargin: Theme.cardPad
- anchors.horizontalCenter: parent.horizontalCenter
- width: parent.width - 2 * Theme.cardPad
- spacing: 8
- }
- }
-
- // ── PillSlider: slim rounded track + fill with a full-height
- // invisible hit area. `value` is 0..1; `moved(v)` fires on drag.
- component PillSlider: Item {
- property real value: 0
- property color fillColor: Theme.base0D
- property real trackH: 6
- signal moved(real v)
- height: 20
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: parent.width
- height: parent.trackH
- radius: parent.trackH / 2
- color: Theme.base02
- }
- Rectangle {
- anchors.verticalCenter: parent.verticalCenter
- width: parent.value > 0 ? Math.max(parent.trackH, parent.value * parent.width) : 0
- height: parent.trackH
- radius: parent.trackH / 2
- color: parent.fillColor
- Behavior on width { NumberAnimation { duration: 80 } }
- }
- MouseArea {
- anchors.fill: parent
- function set(mouse) { parent.moved(Math.max(0, Math.min(1, mouse.x / width))); }
- onPressed: (mouse) => set(mouse)
- onPositionChanged: (mouse) => { if (pressed) set(mouse); }
- }
- }
-
- // ── NotifContent: summary + body + action chips for one
- // notification, shared by the calendar list and the toast.
- // Callers supply the container, dismiss button and sizes.
- component NotifContent: Column {
- id: _nc
- property var notif
- property int summarySize: 12
- property int bodySize: 11
- property int bodyLines: 3
- property color chipBg: Theme.base01
- property color chipBgHover: Theme.base02
- property color chipBorder: Theme.base02
- signal actionInvoked()
- spacing: 2
-
- // App header: icon + name of the sending app, when known.
- Row {
- width: parent.width
- spacing: 4
- visible: _nc.notif && (_nc.notif.appName !== "" || _appIcon.source != "")
- Image {
- id: _appIcon
- width: 14; height: 14
- sourceSize.width: 14; sourceSize.height: 14
- anchors.verticalCenter: parent.verticalCenter
- source: _nc.notif && _nc.notif.appIcon ? Quickshell.iconPath(_nc.notif.appIcon, true) : ""
- visible: source != ""
- }
- SText {
- anchors.verticalCenter: parent.verticalCenter
- text: _nc.notif ? _nc.notif.appName : ""
- color: Theme.base04
- font.pixelSize: 10
- elide: Text.ElideRight
- width: parent.width - (_appIcon.visible ? 18 : 0)
- }
- }
-
- SText {
- width: parent.width
- text: _nc.notif ? (_nc.notif.summary || _nc.notif.appName) : ""
- font.pixelSize: _nc.summarySize
- font.weight: Font.Medium
- elide: Text.ElideRight
- }
- SText {
- width: parent.width
- text: _nc.notif ? (_nc.notif.body || "") : ""
- color: Theme.base04
- font.pixelSize: _nc.bodySize
- elide: Text.ElideRight
- maximumLineCount: _nc.bodyLines
- wrapMode: Text.Wrap
- // ponytail: hyprshot's body is a noisy file path; the
- // "Screenshot saved" summary + the preview say enough.
- visible: text !== "" && !(_nc.notif && _nc.notif.appName === "Hyprshot")
- }
- Row {
- spacing: 4
- visible: _nc.notif && _nc.notif.actions.length > 0
- Repeater {
- model: _nc.notif ? _nc.notif.actions : []
- Rectangle {
- required property var modelData
- width: _at.width + 12
- height: _at.height + 6
- radius: Theme.radiusTiny
- color: _ama.containsMouse ? _nc.chipBgHover : _nc.chipBg
- Behavior on color { ColorAnimation { duration: Theme.animFade } }
- border.width: 1
- border.color: _nc.chipBorder
- SText { id: _at; anchors.centerIn: parent; text: modelData.text; font.pixelSize: 10 }
- MouseArea {
- id: _ama
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: { modelData.invoke(); _nc.actionInvoked(); }
- }
- }
- }
- }
- }
-
// ── Shell chrome: bar, frame, panel and toast rendered as
// ONE signed-distance field (caelestia-style). Surfaces merge
// via circular smooth-min, and the 2px border is the distance
@@ -554,20 +348,17 @@ in
anchors.fill: parent
readonly property real panelLeft: chrome.x + 8
readonly property real panelRight: chrome.x + chrome.width + (chrome.flushRight ? 4 : -8)
- // The panel/toast centre-y uses (barHeight − 4): the
- // surface overlaps 4px up into the bar so they melt.
- readonly property real surfTopY: Theme.barHeight - 4
property vector4d cutout: Qt.vector4d(
bar.width / 2,
- (Theme.barHeight + bar.height - Theme.frameWidth) / 2,
+ (30 + bar.height - Theme.frameWidth) / 2,
bar.width / 2 - Theme.frameWidth,
- (bar.height - Theme.frameWidth - Theme.barHeight) / 2)
+ (bar.height - Theme.frameWidth - 30) / 2)
property vector4d panel: chrome.visible
- ? Qt.vector4d((panelLeft + panelRight) / 2, surfTopY + chrome.height / 2,
+ ? Qt.vector4d((panelLeft + panelRight) / 2, 26 + chrome.height / 2,
(panelRight - panelLeft) / 2, 4 + chrome.height / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d toast: toastItem.visible && _toastRect.height > 0.5
- ? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, surfTopY + _toastRect.height / 2,
+ ? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, 26 + _toastRect.height / 2,
_toastRect.width / 2, 4 + _toastRect.height / 2)
: Qt.vector4d(0, 0, 0, 0)
readonly property real sessRight: bar.width - Theme.frameWidth + 4
@@ -583,8 +374,8 @@ in
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height)
- property real cutoutR: Theme.radius
- property real panelR: Theme.radius
+ property real cutoutR: 8
+ property real panelR: 8
property real meltK: 12
property real borderW: Theme.borderWidth
fragmentShader: "file://${chromeShader}"
@@ -603,10 +394,10 @@ in
property real openW: open ? 64 : 0
property real openH: open ? sessionCard.height + 24 : 36
Behavior on openW {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on openH {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
readonly property var actions: [
@@ -629,6 +420,7 @@ in
if (open) {
selIdx = 0;
forceActiveFocus();
+ _sessionAutoClose.restart();
}
}
@@ -640,19 +432,32 @@ in
focus: open
Keys.onEscapePressed: open = false
- Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length
- Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length
- Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length
+ Keys.onUpPressed: { selIdx = (selIdx + actions.length - 1) % actions.length; _sessionAutoClose.restart(); }
+ Keys.onDownPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); }
+ Keys.onTabPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); }
Keys.onReturnPressed: activate(actions[selIdx].act)
Keys.onEnterPressed: activate(actions[selIdx].act)
+ Timer {
+ id: _sessionAutoClose
+ interval: 2500
+ onTriggered: sessionMenu.open = false
+ }
+
+ HoverHandler {
+ onHoveredChanged: {
+ if (hovered) _sessionAutoClose.stop();
+ else if (sessionMenu.open) _sessionAutoClose.restart();
+ }
+ }
+
// Content pinned to the column edge, revealed by the grow
Item {
anchors.fill: parent
clip: true
opacity: sessionMenu.open ? 1 : 0
Behavior on opacity {
- NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
+ NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
// Card backing, matching the other dropdowns
@@ -663,15 +468,15 @@ in
anchors.rightMargin: 8
width: 48
height: sessionCol.height + 8
- radius: Theme.radius
- color: Theme.cardBg
+ radius: 8
+ color: Theme.base01
// Sliding selection pill — same tech as the power
// profile selector; glides between the buttons.
Rectangle {
width: 40
height: 40
- radius: Theme.radius
+ radius: 8
color: Theme.base02
border.width: 1
border.color: Theme.base03
@@ -694,17 +499,22 @@ in
id: sessBtn
required property var modelData
required property int index
+ readonly property bool selected: sessionMenu.selIdx === index
width: 40
height: 40
- SIcon {
+ Text {
anchors.centerIn: parent
text: sessBtn.modelData.icon
- // All four buttons share the logout
- // setup: base05, FILL on selection.
- color: Theme.base05
+ // Calendar palette: base05 icons; red only when
+ // a destructive action is the armed selection
+ color: sessBtn.selected && sessBtn.modelData.danger
+ ? Theme.base08 : Theme.base05
+ Behavior on color { ColorAnimation { duration: 120 } }
+ font.family: Theme.iconFont
font.pixelSize: 20
font.weight: 600
+ font.variableAxes: { "FILL": sessBtn.selected ? 1.0 : 0.0 }
}
MouseArea {
@@ -730,16 +540,16 @@ in
property bool open: false
readonly property real panelW: 420
property real targetH: 36 + launcherList.contentHeight
- + (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24
+ + (launcherList.count > 0 ? 8 : 0) + 24
// Both axes animate: expands from a small point on the
// bottom edge (like the top dropdowns' stub seed).
property real openH: open ? targetH : 0
property real openW: open ? panelW : 80
Behavior on openH {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on openW {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
x: Math.round((bar.width - openW) / 2)
@@ -805,7 +615,7 @@ in
clip: true
opacity: launcherPanel.open ? 1 : 0
Behavior on opacity {
- NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
+ NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
Column {
@@ -815,23 +625,14 @@ in
width: launcherPanel.panelW - 24
spacing: 8
- // Results sit in a base01 card segment, like the
- // notification list and the other dropdowns.
- Rectangle {
- width: parent.width
- height: launcherList.contentHeight + 2 * Theme.cardPad
- radius: Theme.radius
- color: Theme.cardBg
- visible: launcherList.count > 0
-
- ListView {
+ ListView {
id: launcherList
- anchors.fill: parent
- anchors.margins: Theme.cardPad
+ width: parent.width
+ height: contentHeight
interactive: false
model: launcherPanel.entries
highlight: Rectangle {
- radius: Theme.radiusSmall
+ radius: 6
color: Theme.base02
}
highlightMoveDuration: 200
@@ -860,10 +661,11 @@ in
source: Quickshell.iconPath(modelData.icon, true)
}
- SText {
+ Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.base05
+ font.family: Theme.fontFamily
font.pixelSize: 13
elide: Text.ElideRight
width: 330
@@ -878,21 +680,21 @@ in
}
}
}
- }
Rectangle {
width: parent.width
height: 36
- radius: Theme.radius
- color: Theme.cardBg
+ radius: 6
+ color: Theme.base01
- SIcon {
+ Text {
id: searchIcon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "search"
color: Theme.base04
+ font.family: Theme.iconFont
font.pixelSize: 16
}
@@ -917,13 +719,14 @@ in
Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
}
- SText {
+ Text {
anchors.left: searchIcon.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === ""
text: "Search"
color: Theme.base03
+ font.family: Theme.fontFamily
font.pixelSize: 13
}
}
@@ -931,54 +734,23 @@ in
}
}
- // Click-outside dismissal for every panel — the launcher,
- // session menu AND the bar dropdowns. Clicking into another
- // window (or anywhere outside the bar) clears the grab and
- // closes whatever is open. Suspended while screenshotPinned so
- // slurp can grab input without dismissing the menu.
+ // Click-outside dismissal for the keyboard-grabbing panels
HyprlandFocusGrab {
- active: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned
+ active: sessionMenu.open || launcherPanel.open
windows: [bar]
onCleared: {
- if (bar.screenshotPinned) return;
sessionMenu.open = false;
launcherPanel.open = false;
- bar.closeAllDropdowns();
}
}
property var activeDropdown: null
- // Set by the screenshot keybind (via IPC) to hold menus open
- // while a region screenshot runs. Watchdog unpins if the
- // bracketing unpin call is ever missed.
- property bool screenshotPinned: false
- Timer {
- id: _pinWatchdog
- interval: 30000
- onTriggered: bar.screenshotPinned = false
- }
- function setScreenshotPin(v) {
- screenshotPinned = v;
- if (v) _pinWatchdog.restart(); else _pinWatchdog.stop();
- }
-
function closeAllDropdowns() {
if (activeDropdown && activeDropdown.visible) {
activeDropdown.animateClose();
}
}
-
- // Preview image for a notification: its image hint, else a
- // file-path appIcon (e.g. hyprshot's saved shot). "" = none.
- function notifPreviewSource(notif) {
- if (!notif) return "";
- if (notif.image && notif.image !== "") return notif.image;
- let a = notif.appIcon || "";
- if (a.startsWith("file://")) return a;
- if (a.startsWith("/")) return "file://" + a;
- return "";
- }
function toggleDropdown(dd, setupFn) {
if (dd.visible && !dd.closing) {
dd.animateClose();
@@ -1026,7 +798,7 @@ in
required property var modelData
visible: modelData.id > 0
width: visible ? dot.width + 6 : 0
- height: Theme.barHeight
+ height: 30
Rectangle {
id: dot
@@ -1039,7 +811,7 @@ in
Behavior on width {
NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
}
- Behavior on color { ColorAnimation { duration: Theme.animFade } }
+ Behavior on color { ColorAnimation { duration: 120 } }
}
MouseArea {
@@ -1059,13 +831,14 @@ in
precision: SystemClock.Minutes
}
- SText {
+ Text {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: barBgRect.verticalCenter
property date now: sysClock.date
text: now.toLocaleTimeString(Qt.locale(), "HH:mm")
color: Theme.base05
+ font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
@@ -1076,6 +849,7 @@ in
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); });
+ else bar.activeDropdown.resetAutoClose();
}
}
}
@@ -1097,7 +871,7 @@ in
Item {
id: volWidget
width: volRow.width
- height: Theme.barHeight
+ height: 30
property PwNode sink: Pipewire.defaultAudioSink
@@ -1125,17 +899,19 @@ in
anchors.verticalCenter: parent.verticalCenter
spacing: 3
- SIcon {
+ Text {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.volIcon
color: volWidget.muted ? Theme.base03 : Theme.base05
+ font.family: Theme.iconFont
font.pixelSize: 16
}
- SText {
+ Text {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.vol + "%"
color: volWidget.muted ? Theme.base03 : Theme.base05
+ font.family: Theme.fontFamily
font.pixelSize: 13
}
}
@@ -1155,6 +931,7 @@ in
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
+ else bar.activeDropdown.resetAutoClose();
}
}
}
@@ -1164,7 +941,7 @@ in
Item {
id: netWidget
width: 16
- height: Theme.barHeight
+ height: 30
property string netState: "disconnected"
property string netConn: ""
@@ -1255,10 +1032,11 @@ in
}
}
- SIcon {
+ Text {
anchors.centerIn: parent
text: netWidget.netIcon
color: Theme.base05
+ font.family: Theme.iconFont
font.pixelSize: 16
}
@@ -1321,6 +1099,7 @@ in
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown();
+ else bar.activeDropdown.resetAutoClose();
}
}
}
@@ -1331,7 +1110,7 @@ in
Item {
id: batteryWidget
width: batteryText.width + 4 + batteryIconText.width
- height: Theme.barHeight
+ height: 30
// Live DBus-driven properties from the UPower service —
// no polling, no /sys parsing, no subprocess spawns.
@@ -1366,23 +1145,25 @@ in
// Explicit vertical centering: Rows top-align by
// default, and the icon font's taller line metrics
// would push the text off the shared baseline.
- SText {
+ Text {
id: batteryText
anchors.verticalCenter: parent.verticalCenter
text: batteryWidget.batteryLevel + "%"
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
+ font.family: Theme.fontFamily
font.pixelSize: 13
}
- SIcon {
+ Text {
id: batteryIconText
anchors.verticalCenter: parent.verticalCenter
text: batteryWidget.batteryIcon
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
+ font.family: Theme.iconFont
font.pixelSize: 16
}
}
@@ -1401,6 +1182,7 @@ in
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown();
+ else bar.activeDropdown.resetAutoClose();
}
}
}
@@ -1411,16 +1193,22 @@ in
Row {
id: trayArea
spacing: 8
- height: Theme.barHeight
+ height: 30
anchors.verticalCenter: parent.verticalCenter
+ HoverHandler {
+ onHoveredChanged: {
+ if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose();
+ }
+ }
+
Repeater {
model: SystemTray.items
Item {
required property var modelData
width: 24
- height: Theme.barHeight
+ height: 30
Image {
id: trayIcon
@@ -1447,6 +1235,7 @@ in
acceptedButtons: Qt.NoButton
onEntered: {
if (bar.activeDropdown) {
+ bar.activeDropdown.resetAutoClose();
if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) {
if (bar.activeDropdown === contextMenu) {
// Same dropdown, just switch content
@@ -1454,6 +1243,7 @@ in
contextMenu.dropdownX = pos.x;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
+ contextMenu.resetAutoClose();
} else {
bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
@@ -1497,6 +1287,7 @@ in
property real dropdownX: 0
property real fullWidth: 200
property real fullHeight: 200
+ property int autoCloseMs: 1500
// Flush-right dropdowns merge into the screen frame's
// right column instead of centering on their widget.
property bool alignRight: false
@@ -1516,9 +1307,14 @@ in
bar.activeDropdown = null;
chrome.shrinkToButton(dropdown);
}
+ _autoClose.stop();
_closeDelay.start();
}
+ function resetAutoClose() {
+ if (visible && !closing) _autoClose.restart();
+ }
+
// Reopen a dropdown that's mid-close: the pending hide
// timer must be cancelled, otherwise it fires later and
// closes the revived dropdown (and the whole chrome).
@@ -1526,12 +1322,13 @@ in
_closeDelay.stop();
closing = false;
open = true;
+ _autoClose.restart();
}
x: alignRight
? bar.width - Theme.frameWidth - width
: Math.round(Math.min(bar.width - Theme.frameWidth - width, Math.max(Theme.frameWidth, dropdownX - width / 2)))
- y: Theme.barHeight
+ y: 30
width: fullWidth + (alignRight ? 8 : 16)
height: fullHeight + 4
visible: false
@@ -1540,18 +1337,33 @@ in
if (visible) {
closing = false;
open = true;
+ _autoClose.restart();
} else {
open = false;
closing = false;
+ _autoClose.stop();
}
}
+ Timer {
+ id: _autoClose
+ interval: dropdown.autoCloseMs
+ onTriggered: bar.closeAllDropdowns()
+ }
+
Timer {
id: _closeDelay
interval: 300
onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; }
}
+ HoverHandler {
+ onHoveredChanged: {
+ if (hovered) _autoClose.stop();
+ else _autoClose.restart();
+ }
+ }
+
// Content is clipped to the chrome's ANIMATED geometry —
// revealed as the panel slides/grows over it and wiped as
// the panel leaves, instead of popping in place. The inner
@@ -1566,7 +1378,7 @@ in
clip: true
opacity: dropdown.open ? 1 : 0
Behavior on opacity {
- NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
+ NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
Item {
@@ -1614,7 +1426,7 @@ in
}
x: tX
- y: Theme.barHeight
+ y: 30
width: tW
height: openH
visible: height > 0.5
@@ -1646,14 +1458,14 @@ in
Behavior on tX {
enabled: !chrome.snap
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on tW {
enabled: !chrome.snap
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Behavior on openH {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
}
@@ -1674,22 +1486,21 @@ in
id: menuOpener
}
- Card {
+ Column {
id: menuItems
anchors.centerIn: parent
width: 200
- cardSpacing: 0
Repeater {
model: menuOpener.children
Rectangle {
required property var modelData
- width: parent.width
+ width: 200
height: modelData.isSeparator ? 9 : 28
color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled
- ? Theme.base02 : Theme.base02t
- Behavior on color { ColorAnimation { duration: Theme.animFade } }
+ ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
radius: modelData.isSeparator ? 0 : 4
Rectangle {
@@ -1700,32 +1511,29 @@ in
color: Theme.base03
}
- Item {
+ RowLayout {
visible: !modelData.isSeparator
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
+ spacing: 8
- SText {
- id: menuCheck
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
+ Text {
+ Layout.fillWidth: true
+ text: modelData.text ?? ""
+ color: modelData.enabled ? Theme.base05 : Theme.base03
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+
+ Text {
visible: modelData.buttonType !== QsMenuButtonType.None
text: modelData.checkState === Qt.Checked ? "\u2713" : ""
color: Theme.base0D
+ font.family: Theme.fontFamily
font.pixelSize: 12
}
-
- SText {
- anchors.left: parent.left
- anchors.right: menuCheck.visible ? menuCheck.left : parent.right
- anchors.rightMargin: menuCheck.visible ? 8 : 0
- anchors.verticalCenter: parent.verticalCenter
- text: modelData.text ?? ""
- color: modelData.enabled ? Theme.base05 : Theme.base03
- font.pixelSize: 12
- elide: Text.ElideRight
- }
}
MouseArea {
@@ -1749,6 +1557,7 @@ in
alignRight: true
fullWidth: volDropdownCol.width + 28
fullHeight: volDropdownCol.height + 20
+ autoCloseMs: 3000
Column {
id: volDropdownCol
@@ -1757,140 +1566,253 @@ in
spacing: 8
// Master volume card
- Card {
+ Rectangle {
width: parent.width
+ height: masterCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- Row {
- spacing: 6
- VolIcon { anchors.verticalCenter: parent.verticalCenter; audioNode: volWidget.sink ? volWidget.sink.audio : null }
- SText {
- anchors.verticalCenter: parent.verticalCenter
- text: "Master"
- font.weight: Font.Medium
- }
- }
-
- Row {
- width: parent.width
+ Column {
+ id: masterCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
spacing: 8
- PillSlider {
- width: parent.width - masterVolLabel.width - 8
- anchors.verticalCenter: parent.verticalCenter
- value: volWidget.sink && volWidget.sink.audio ? Math.min(1, volWidget.sink.audio.volume) : 0
- fillColor: volWidget.muted ? Theme.base03 : Theme.base0D
- onMoved: (v) => { if (volWidget.sink && volWidget.sink.audio) volWidget.sink.audio.volume = v; }
- }
-
- SText {
- id: masterVolLabel
- width: 36
- text: volWidget.vol + "%"
- font.pixelSize: 11
- horizontalAlignment: Text.AlignRight
- anchors.verticalCenter: parent.verticalCenter
- }
- }
-
- // Mute button
- HoverRow {
- width: parent.width
- height: 28
- onClicked: {
- if (volWidget.sink && volWidget.sink.audio)
- volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
+ Row {
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "volume_up"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 16
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Master"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ font.weight: Font.Medium
+ }
}
Row {
- anchors.centerIn: parent
- spacing: 6
- SIcon {
+ width: parent.width
+ spacing: 8
+
+ // Slim pill slider: 6px fully-rounded track,
+ // 20px invisible hit area for comfy dragging
+ Item {
+ id: masterSliderBg
+ width: parent.width - masterVolLabel.width - 8
+ height: 20
anchors.verticalCenter: parent.verticalCenter
- text: volWidget.muted ? "volume_off" : "volume_up"
- font.pixelSize: 15
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width
+ height: 6
+ radius: 3
+ color: Theme.base02
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: {
+ let v = volWidget.sink && volWidget.sink.audio
+ ? Math.min(1, volWidget.sink.audio.volume) : 0;
+ return v > 0 ? Math.max(6, v * parent.width) : 0;
+ }
+ height: 6
+ radius: 3
+ color: volWidget.muted ? Theme.base03 : Theme.base0D
+ Behavior on width { NumberAnimation { duration: 80 } }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onPressed: (mouse) => setVolume(mouse)
+ onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); }
+ function setVolume(mouse) {
+ if (!volWidget.sink || !volWidget.sink.audio) return;
+ let v = Math.max(0, Math.min(1, mouse.x / width));
+ volWidget.sink.audio.volume = v;
+ }
+ }
}
- SText {
+
+ Text {
+ id: masterVolLabel
+ width: 36
+ text: volWidget.vol + "%"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
- text: volWidget.muted ? "Unmute" : "Mute"
- font.pixelSize: 12
+ }
+ }
+
+ // Mute button
+ Rectangle {
+ width: parent.width
+ height: 28
+ color: masterMuteMa.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ radius: 4
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: volWidget.muted ? "volume_off" : "volume_up"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 15
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: volWidget.muted ? "Unmute" : "Mute"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ }
+ }
+ MouseArea {
+ id: masterMuteMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (volWidget.sink && volWidget.sink.audio)
+ volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
+ }
}
}
}
}
// Applications card
- Card {
+ Rectangle {
visible: appStreamsCol.childrenRect.height > 0
width: parent.width
+ height: appsCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- Row {
- spacing: 6
- SIcon { anchors.verticalCenter: parent.verticalCenter; text: "graphic_eq" }
- SText {
- anchors.verticalCenter: parent.verticalCenter
- text: "Applications"
- font.weight: Font.Medium
- }
- }
-
- // Per-app streams
Column {
- id: appStreamsCol
- width: parent.width
- spacing: 6
+ id: appsCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
+ spacing: 8
- Repeater {
- id: appStreamsRepeater
- model: Pipewire.nodes
+ Row {
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "graphic_eq"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 16
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Applications"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ font.weight: Font.Medium
+ }
+ }
- Column {
- required property var modelData
- width: parent.width
- spacing: 2
- visible: modelData.isStream && modelData.audio !== null
+ // Per-app streams
+ Column {
+ id: appStreamsCol
+ width: parent.width
+ spacing: 6
- PwObjectTracker {
- objects: [modelData]
- }
+ Repeater {
+ id: appStreamsRepeater
+ model: Pipewire.nodes
- SText {
- text: modelData.properties["application.name"] || modelData.name || "Unknown"
- color: Theme.base04
- font.pixelSize: 11
- elide: Text.ElideRight
+ Column {
+ required property var modelData
width: parent.width
- }
+ spacing: 2
+ visible: modelData.isStream && modelData.audio !== null
- Row {
- width: parent.width
- spacing: 8
-
- VolIcon {
- anchors.verticalCenter: parent.verticalCenter
- width: 18
- color: Theme.base04
- font.pixelSize: 15
- audioNode: modelData.audio
+ PwObjectTracker {
+ objects: [modelData]
}
- PillSlider {
- width: parent.width - 18 - appVolLabel.width - 16
- anchors.verticalCenter: parent.verticalCenter
- height: 16
- trackH: 4
- value: modelData.audio ? Math.min(1, modelData.audio.volume) : 0
- fillColor: modelData.audio && modelData.audio.muted ? Theme.base03 : Theme.base0C
- onMoved: (v) => { if (modelData.audio) modelData.audio.volume = v; }
+ Text {
+ text: modelData.properties["application.name"] || modelData.name || "Unknown"
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ width: parent.width
}
- SText {
- id: appVolLabel
- width: 36
- text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
- color: Theme.base04
- font.pixelSize: 10
- horizontalAlignment: Text.AlignRight
- anchors.verticalCenter: parent.verticalCenter
+ Row {
+ width: parent.width
+ spacing: 8
+
+ // Slim pill slider, 16px hit area
+ Item {
+ width: parent.width - appVolLabel.width - 8
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width
+ height: 4
+ radius: 2
+ color: Theme.base02
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: {
+ let v = modelData.audio ? Math.min(1, modelData.audio.volume) : 0;
+ return v > 0 ? Math.max(4, v * parent.width) : 0;
+ }
+ height: 4
+ radius: 2
+ color: modelData.audio && modelData.audio.muted
+ ? Theme.base03 : Theme.base0C
+ Behavior on width { NumberAnimation { duration: 80 } }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onPressed: (mouse) => setVol(mouse)
+ onPositionChanged: (mouse) => { if (pressed) setVol(mouse); }
+ function setVol(mouse) {
+ if (!modelData.audio) return;
+ let v = Math.max(0, Math.min(1, mouse.x / width));
+ modelData.audio.volume = v;
+ }
+ }
+ }
+
+ Text {
+ id: appVolLabel
+ width: 36
+ text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 10
+ horizontalAlignment: Text.AlignRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
}
}
}
@@ -1914,120 +1836,170 @@ in
spacing: 8
// Connection card
- Card {
+ Rectangle {
width: parent.width
- cardSpacing: 4
- Row {
- width: parent.width
- spacing: 6
+ height: connCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- SIcon {
- anchors.verticalCenter: parent.verticalCenter
- text: netWidget.netState === "connected" ? "wifi" : "wifi_off"
- color: Theme.base05
- font.pixelSize: 16
+ Column {
+ id: connCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
+ spacing: 4
+
+ Row {
+ width: parent.width
+ spacing: 6
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: netWidget.netState === "connected" ? "wifi" : "wifi_off"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 16
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 22
+ text: netWidget.netState === "connected"
+ ? netWidget.netConn : "Not connected"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
}
- SText {
- anchors.verticalCenter: parent.verticalCenter
- width: parent.width - 22
- text: netWidget.netState === "connected"
- ? netWidget.netConn : "Not connected"
- color: Theme.base05
- font.pixelSize: 13
- font.weight: Font.Medium
- elide: Text.ElideRight
- }
- }
+ Rectangle {
+ visible: netWidget.netState === "connected"
+ width: parent.width
+ height: 28
+ color: disconnectMouse.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ radius: 4
- HoverRow {
- visible: netWidget.netState === "connected"
- width: parent.width
- height: 28
- onClicked: {
- netDisconnectProc.targetDevice = netWidget.netDevice;
- netDisconnectProc.running = true;
- netWidget.netState = "disconnected";
- netWidget.netConn = "";
- netWidget.netIcon = "wifi_off";
- bar.closeAllDropdowns();
- netRefreshDelay.start();
- }
+ Text {
+ anchors.centerIn: parent
+ text: "Disconnect"
+ color: Theme.base08
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ }
- SText {
- anchors.centerIn: parent
- text: "Disconnect"
- color: Theme.base08
- font.pixelSize: 12
+ MouseArea {
+ id: disconnectMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ netDisconnectProc.targetDevice = netWidget.netDevice;
+ netDisconnectProc.running = true;
+ netWidget.netState = "disconnected";
+ netWidget.netConn = "";
+ netWidget.netIcon = "wifi_off";
+ bar.closeAllDropdowns();
+ netRefreshDelay.start();
+ }
+ }
}
}
}
// Available networks card
- Card {
+ Rectangle {
width: parent.width
- cardSpacing: 4
- SText {
- text: "Available networks"
- color: Theme.base04
- font.pixelSize: 11
- }
+ height: netsCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- Repeater {
- model: netWidget.wifiNetworks
+ Column {
+ id: netsCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
+ spacing: 4
- HoverRow {
- required property var modelData
- width: parent.width
- height: 32
- onClicked: {
- if (!modelData.active) {
- wifiConnectProc.targetSsid = modelData.ssid;
- wifiConnectProc.running = true;
- netRefreshDelay.start();
- }
- bar.closeAllDropdowns();
- }
+ Text {
+ text: "Available networks"
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ }
- Row {
- anchors.verticalCenter: parent.verticalCenter
- anchors.left: parent.left
- anchors.leftMargin: 8
- anchors.right: parent.right
- anchors.rightMargin: 8
- spacing: 8
+ Repeater {
+ model: netWidget.wifiNetworks
- SIcon {
- text: {
- let s = modelData.signal;
- if (s >= 75) return "signal_wifi_4_bar";
- if (s >= 50) return "network_wifi_3_bar";
- if (s >= 25) return "network_wifi_2_bar";
- return "network_wifi_1_bar";
+ Rectangle {
+ required property var modelData
+ width: netsCardCol.width
+ height: 32
+ color: netItemMouse.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ radius: 4
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ spacing: 8
+
+ Text {
+ text: {
+ let s = modelData.signal;
+ if (s >= 75) return "signal_wifi_4_bar";
+ if (s >= 50) return "network_wifi_3_bar";
+ if (s >= 25) return "network_wifi_2_bar";
+ return "network_wifi_1_bar";
+ }
+ color: modelData.active ? Theme.base0B : Theme.base04
+ font.family: Theme.iconFont
+ font.pixelSize: 16
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Text {
+ text: modelData.ssid
+ color: modelData.active ? Theme.base0B : Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ width: 140
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Text {
+ visible: modelData.security !== "" && modelData.security !== "--"
+ text: "lock"
+ color: Theme.base03
+ font.family: Theme.iconFont
+ font.pixelSize: 13
+ anchors.verticalCenter: parent.verticalCenter
}
- color: modelData.active ? Theme.base0B : Theme.base04
- font.pixelSize: 16
- anchors.verticalCenter: parent.verticalCenter
}
- SText {
- text: modelData.ssid
- color: modelData.active ? Theme.base0B : Theme.base05
- font.pixelSize: 12
- elide: Text.ElideRight
- width: 140
- anchors.verticalCenter: parent.verticalCenter
- }
-
- SIcon {
- visible: modelData.security !== "" && modelData.security !== "--"
- text: "lock"
- color: Theme.base03
- font.pixelSize: 13
- anchors.verticalCenter: parent.verticalCenter
+ MouseArea {
+ id: netItemMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!modelData.active) {
+ wifiConnectProc.targetSsid = modelData.ssid;
+ wifiConnectProc.running = true;
+ netRefreshDelay.start();
+ }
+ bar.closeAllDropdowns();
+ }
}
}
-
}
}
}
@@ -2049,119 +2021,148 @@ in
spacing: 8
// Battery status card
- Card {
+ Rectangle {
width: parent.width
- Row {
- width: parent.width
- spacing: 8
+ height: battCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- SIcon {
- text: batteryWidget.batteryIcon
- color: Theme.base05
- font.pixelSize: 22
- anchors.verticalCenter: parent.verticalCenter
- }
+ Column {
+ id: battCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
- Column {
- anchors.verticalCenter: parent.verticalCenter
- SText {
- text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "")
+ Row {
+ width: parent.width
+ spacing: 8
+
+ Text {
+ text: batteryWidget.batteryIcon
color: Theme.base05
- font.pixelSize: 13
- font.weight: Font.Medium
+ font.family: Theme.iconFont
+ font.pixelSize: 22
+ anchors.verticalCenter: parent.verticalCenter
}
- SText {
- text: batteryWidget.powerDraw.toFixed(1) + " W"
- + (batteryWidget.timeRemaining !== "" ? " • " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "")
- color: Theme.base04
- font.pixelSize: 11
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ Text {
+ text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "")
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ font.weight: Font.Medium
+ }
+ Text {
+ text: batteryWidget.powerDraw.toFixed(1) + " W"
+ + (batteryWidget.timeRemaining !== "" ? " • " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "")
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ }
}
}
}
}
// Power profile card
- Card {
+ Rectangle {
width: parent.width
- cardSpacing: 6
- SText {
- text: "Power Profile"
- color: Theme.base04
- font.pixelSize: 11
- }
+ height: profCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- Item {
- width: parent.width
- height: 36
+ Column {
+ id: profCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
+ spacing: 6
- // Sliding selection pill — glides between
- // profiles instead of each button flipping.
- Rectangle {
- id: profilePill
- readonly property int selIdx:
- batteryWidget.powerProfile === "power-saver" ? 0
- : batteryWidget.powerProfile === "performance" ? 2
- : 1
- width: (parent.width - 8) / 3
- height: 36
- radius: Theme.radiusSmall
- color: Theme.base02
- border.width: 1
- border.color: Theme.base03
- x: selIdx * (width + 4)
- Behavior on x {
- NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
- }
+ Text {
+ text: "Power Profile"
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
}
- Row {
- anchors.fill: parent
- spacing: 4
+ Item {
+ width: parent.width
+ height: 36
- Repeater {
- model: [
- { name: "power-saver", profile: PowerProfile.PowerSaver, label: "energy_savings_leaf", tip: "Saver" },
- { name: "balanced", profile: PowerProfile.Balanced, label: "balance", tip: "Balanced" },
- { name: "performance", profile: PowerProfile.Performance, label: "speed", tip: "Performance" }
- ]
+ // Sliding selection pill — glides between
+ // profiles instead of each button flipping.
+ Rectangle {
+ id: profilePill
+ readonly property int selIdx:
+ batteryWidget.powerProfile === "power-saver" ? 0
+ : batteryWidget.powerProfile === "performance" ? 2
+ : 1
+ width: (parent.width - 8) / 3
+ height: 36
+ radius: 6
+ color: Theme.base02
+ border.width: 1
+ border.color: Theme.base03
+ x: selIdx * (width + 4)
+ Behavior on x {
+ NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
+ }
+ }
- Rectangle {
- required property var modelData
- width: (parent.width - 8) / 3
- height: 36
- radius: Theme.radiusSmall
- color: profMouse.containsMouse && batteryWidget.powerProfile !== modelData.name
- ? Theme.base02 : Theme.base02t
- Behavior on color { ColorAnimation { duration: Theme.animFade } }
+ Row {
+ anchors.fill: parent
+ spacing: 4
- Column {
- anchors.centerIn: parent
- spacing: 1
- SIcon {
- anchors.horizontalCenter: parent.horizontalCenter
- text: modelData.label
- // Selected = bright, unselected = grey
- color: batteryWidget.powerProfile === modelData.name
- ? Theme.base05 : Theme.base04
- Behavior on color { ColorAnimation { duration: 200 } }
- font.pixelSize: 17
+ Repeater {
+ model: [
+ { name: "power-saver", profile: PowerProfile.PowerSaver, label: "energy_savings_leaf", tip: "Saver" },
+ { name: "balanced", profile: PowerProfile.Balanced, label: "balance", tip: "Balanced" },
+ { name: "performance", profile: PowerProfile.Performance, label: "speed", tip: "Performance" }
+ ]
+
+ Rectangle {
+ required property var modelData
+ width: (parent.width - 8) / 3
+ height: 36
+ radius: 6
+ color: profMouse.containsMouse && batteryWidget.powerProfile !== modelData.name
+ ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 1
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: modelData.label
+ color: batteryWidget.powerProfile === modelData.name
+ ? Theme.base0D : Theme.base05
+ Behavior on color { ColorAnimation { duration: 200 } }
+ font.family: Theme.iconFont
+ font.pixelSize: 17
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: modelData.tip
+ color: batteryWidget.powerProfile === modelData.name
+ ? Theme.base05 : Theme.base04
+ Behavior on color { ColorAnimation { duration: 200 } }
+ font.family: Theme.fontFamily
+ font.pixelSize: 9
+ }
}
- SText {
- anchors.horizontalCenter: parent.horizontalCenter
- text: modelData.tip
- color: batteryWidget.powerProfile === modelData.name
- ? Theme.base05 : Theme.base04
- Behavior on color { ColorAnimation { duration: 200 } }
- font.pixelSize: 9
- }
- }
- MouseArea {
- id: profMouse
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: PowerProfiles.profile = modelData.profile
+ MouseArea {
+ id: profMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: PowerProfiles.profile = modelData.profile
+ }
}
}
}
@@ -2181,7 +2182,8 @@ in
// ceil: Text metrics give fractional sizes; fractional rect
// edges render as soft 2px lines
fullWidth: Math.ceil(calRow.width) + 24
- fullHeight: Math.ceil(calRow.height + 8 + notifCard.height) + 24
+ fullHeight: Math.ceil(calRow.height) + 24
+ autoCloseMs: 3000
// Month being viewed; reset to today when the popup opens
// (via the setup function passed to bar.toggleDropdown).
@@ -2231,15 +2233,12 @@ in
triggeredOnStart: true
onTriggered: weatherProc.running = true
}
- // WMO weather codes → Material Symbols. Ranges per
- // open-meteo: 0 clear, 1-2 partly, 3 overcast, 45-48 fog,
- // 51-67 drizzle/rain, 71-77 snow, 80-82 rain showers,
- // 85-86 snow showers, 95+ thunder.
function weatherGlyph(code) {
if (code === 0) return "clear_day";
if (code <= 2) return "partly_cloudy_day";
if (code === 3) return "cloud";
if (code <= 48) return "foggy";
+ if (code <= 57) return "rainy";
if (code <= 67) return "rainy";
if (code <= 77) return "cloudy_snowing";
if (code <= 82) return "rainy";
@@ -2247,27 +2246,13 @@ in
return "thunderstorm";
}
- // --- Media: one card per MPRIS player so Spotify, a
- // browser tab, etc. stay separate instead of collapsing
- // into a single combined player. ---
- property var players: Mpris.players.values.filter(
- p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing)
-
- // Best-effort link from an MPRIS player to its Pipewire
- // audio stream (matched by app name), so a card can carry a
- // volume slider via the same per-app path as the volume
- // widget. null when no stream matches.
- function streamFor(player) {
- if (!player) return null;
- let id = (player.identity || "").toLowerCase();
- let ns = Pipewire.nodes.values;
- for (let i = 0; i < ns.length; i++) {
- let n = ns[i];
- if (!n.isStream || !n.audio) continue;
- let an = (n.properties["application.name"] || "").toLowerCase();
- if (an && (an === id || an.includes(id) || id.includes(an))) return n;
+ // --- Media: prefer the actively playing MPRIS player ---
+ property var player: {
+ let ps = Mpris.players.values;
+ for (let i = 0; i < ps.length; i++) {
+ if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i];
}
- return null;
+ return ps.length > 0 ? ps[0] : null;
}
Row {
@@ -2289,95 +2274,129 @@ in
spacing: 8
// Calendar card
- Card {
+ Rectangle {
width: parent.width
- // Month header: ‹ [Month Year] › — label click jumps to today
- Item {
- width: parent.width
- height: 28
+ height: calCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- HoverRow {
- width: 28; height: 28; radius: Theme.radiusSmall
- anchors.left: parent.left
- onClicked: calPopup.shiftMonth(-1)
- SIcon {
- anchors.centerIn: parent
- text: "chevron_left"
- color: Theme.base05
- font.pixelSize: 18
- }
- }
+ Column {
+ id: calCardCol
+ anchors.top: parent.top
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 7 * 32
+ spacing: 8
- SText {
- anchors.centerIn: parent
- text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy")
- color: Theme.base05
- font.pixelSize: 14
- font.weight: Font.Medium
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: calPopup.resetView()
- }
- }
+ // Month header: ‹ [Month Year] › — label click jumps to today
+ Item {
+ width: parent.width
+ height: 28
- HoverRow {
- width: 28; height: 28; radius: Theme.radiusSmall
- anchors.right: parent.right
- onClicked: calPopup.shiftMonth(1)
- SIcon {
- anchors.centerIn: parent
- text: "chevron_right"
- color: Theme.base05
- font.pixelSize: 18
- }
- }
- }
-
- Row {
- spacing: 0
- Repeater {
- model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
- SText {
- required property var modelData
- width: 32
- horizontalAlignment: Text.AlignHCenter
- text: modelData
- color: Theme.base04
- font.pixelSize: 13
- }
- }
- }
-
- Grid {
- columns: 7
- spacing: 0
- Repeater {
- model: 42
Rectangle {
- required property int index
- property int dayNum: {
- let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1);
- let startDay = (first.getDay() + 6) % 7;
- return index - startDay + 1;
- }
- property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate()
- property bool isToday: dayNum === clockText.now.getDate()
- && calPopup.viewMonth === clockText.now.getMonth()
- && calPopup.viewYear === clockText.now.getFullYear()
- width: 32
- height: 26
- radius: Theme.radiusTiny
- color: isToday ? Theme.base03 : "transparent"
-
- SText {
+ width: 28; height: 28; radius: 6
+ anchors.left: parent.left
+ color: calPrevMa.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
anchors.centerIn: parent
- text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : ""
- color: parent.isToday ? Theme.base05 : Theme.base04
+ text: "chevron_left"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 18
+ }
+ MouseArea {
+ id: calPrevMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: calPopup.shiftMonth(-1)
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy")
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 14
+ font.weight: Font.Medium
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: calPopup.resetView()
+ }
+ }
+
+ Rectangle {
+ width: 28; height: 28; radius: 6
+ anchors.right: parent.right
+ color: calNextMa.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
+ anchors.centerIn: parent
+ text: "chevron_right"
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 18
+ }
+ MouseArea {
+ id: calNextMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: calPopup.shiftMonth(1)
+ }
+ }
+ }
+
+ Row {
+ spacing: 0
+ Repeater {
+ model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
+ Text {
+ required property var modelData
+ width: 32
+ horizontalAlignment: Text.AlignHCenter
+ text: modelData
+ color: Theme.base04
+ font.family: Theme.fontFamily
font.pixelSize: 13
}
}
}
+
+ Grid {
+ columns: 7
+ spacing: 0
+ Repeater {
+ model: 42
+ Rectangle {
+ required property int index
+ property int dayNum: {
+ let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1);
+ let startDay = (first.getDay() + 6) % 7;
+ return index - startDay + 1;
+ }
+ property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate()
+ property bool isToday: dayNum === clockText.now.getDate()
+ && calPopup.viewMonth === clockText.now.getMonth()
+ && calPopup.viewYear === clockText.now.getFullYear()
+ width: 32
+ height: 26
+ radius: 4
+ color: isToday ? Theme.base03 : "transparent"
+
+ Text {
+ anchors.centerIn: parent
+ text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : ""
+ color: parent.isToday ? Theme.base05 : Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+ }
+ }
+ }
}
}
@@ -2385,8 +2404,8 @@ in
Rectangle {
width: parent.width
height: weatherRow.height + 16
- radius: Theme.radius
- color: Theme.cardBg
+ radius: 8
+ color: Theme.base01
visible: calPopup.weatherDays.length > 0
Row {
@@ -2400,28 +2419,32 @@ in
required property var modelData
width: 32
spacing: 2
- SText {
+ Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.day
color: Theme.base04
+ font.family: Theme.fontFamily
font.pixelSize: 10
}
- SIcon {
+ Text {
anchors.horizontalCenter: parent.horizontalCenter
text: calPopup.weatherGlyph(modelData.code)
color: Theme.base0C
+ font.family: Theme.iconFont
font.pixelSize: 16
}
- SText {
+ Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.max + "°"
color: Theme.base05
+ font.family: Theme.fontFamily
font.pixelSize: 10
}
- SText {
+ Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.min + "°"
color: Theme.base03
+ font.family: Theme.fontFamily
font.pixelSize: 10
}
}
@@ -2436,254 +2459,256 @@ in
width: 300
spacing: 8
- // Media player cards — one per active MPRIS source,
- // so Spotify and a browser tab stay separate.
- Repeater {
- model: calPopup.players
+ // Media player card
+ Rectangle {
+ width: parent.width
+ height: 64
+ radius: 8
+ color: Theme.base01
+ visible: calPopup.player !== null
- Rectangle {
- id: mediaCard
- required property var modelData
- property var pwNode: calPopup.streamFor(modelData)
- width: parent.width
- height: mediaCol.height + 16
- radius: Theme.radius
- color: Theme.cardBg
+ Row {
+ anchors.fill: parent
+ anchors.margins: 8
+ spacing: 10
- PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] }
+ Rectangle {
+ width: 48; height: 48
+ radius: 6
+ anchors.verticalCenter: parent.verticalCenter
+ color: Theme.base02
+ clip: true
+ Text {
+ anchors.centerIn: parent
+ visible: albumArt.status !== Image.Ready
+ text: "music_note"
+ color: Theme.base04
+ font.family: Theme.iconFont
+ font.pixelSize: 22
+ }
+ Image {
+ id: albumArt
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectCrop
+ source: calPopup.player ? calPopup.player.trackArtUrl : ""
+ }
+ }
Column {
- id: mediaCol
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.margins: 8
- spacing: 8
-
- // Album art with title + artist to its
- // right; controls and volume below.
- Row {
+ width: parent.width - 48 - 10 - 88 - 10
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Text {
width: parent.width
- spacing: 12
-
- ClippingRectangle {
- width: 128; height: 128
- radius: Theme.radiusSmall
- anchors.verticalCenter: parent.verticalCenter
- color: Theme.base02
- SIcon {
- anchors.centerIn: parent
- visible: albumArt.status !== Image.Ready
- text: "music_note"
- color: Theme.base04
- font.pixelSize: 48
- }
- Image {
- id: albumArt
- anchors.fill: parent
- fillMode: Image.PreserveAspectCrop
- source: mediaCard.modelData.trackArtUrl
- }
- }
-
- Column {
- width: parent.width - 128 - 12
- anchors.verticalCenter: parent.verticalCenter
- spacing: 2
- SText {
- width: parent.width
- text: mediaCard.modelData.trackTitle
- color: Theme.base05
- font.pixelSize: 13
- font.weight: Font.Medium
- elide: Text.ElideRight
- }
- SText {
- width: parent.width
- text: mediaCard.modelData.trackArtist
- color: Theme.base04
- font.pixelSize: 11
- elide: Text.ElideRight
- }
- }
+ text: calPopup.player ? calPopup.player.trackTitle : ""
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ elide: Text.ElideRight
}
-
- // Transport controls, centered below.
- Item {
+ Text {
width: parent.width
- height: 36
- Row {
- anchors.centerIn: parent
- spacing: 4
- Repeater {
- model: [
- { glyph: "skip_previous", act: "prev" },
- { glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" },
- { glyph: "skip_next", act: "next" }
- ]
- HoverRow {
- id: mediaBtn
- required property var modelData
- width: 36; height: 36; radius: 18
- onClicked: {
- let p = mediaCard.modelData;
- if (!p) return;
- if (mediaBtn.modelData.act === "prev") p.previous();
- else if (mediaBtn.modelData.act === "next") p.next();
- else p.togglePlaying();
- }
- SIcon {
- anchors.centerIn: parent
- text: mediaBtn.modelData.glyph
- color: Theme.base05
- font.pixelSize: 24
- }
+ text: calPopup.player ? calPopup.player.trackArtist : ""
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ }
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Repeater {
+ model: [
+ { glyph: "skip_previous", act: "prev" },
+ { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" },
+ { glyph: "skip_next", act: "next" }
+ ]
+ Rectangle {
+ id: mediaBtn
+ required property var modelData
+ width: 28; height: 28; radius: 14
+ color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent"
+ Behavior on color { ColorAnimation { duration: 120 } }
+ Text {
+ anchors.centerIn: parent
+ text: mediaBtn.modelData.glyph
+ color: Theme.base05
+ font.family: Theme.iconFont
+ font.pixelSize: 18
+ }
+ MouseArea {
+ id: mediaBtnMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ let p = calPopup.player;
+ if (!p) return;
+ if (mediaBtn.modelData.act === "prev") p.previous();
+ else if (mediaBtn.modelData.act === "next") p.next();
+ else p.togglePlaying();
}
}
}
}
-
- // Per-source volume — same per-app path
- // as the volume widget. Shown when the
- // player's Pipewire stream is matched.
- Row {
- width: parent.width
- spacing: 8
- visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null
-
- VolIcon {
- anchors.verticalCenter: parent.verticalCenter
- width: 18
- color: Theme.base04
- font.pixelSize: 15
- audioNode: mediaCard.pwNode ? mediaCard.pwNode.audio : null
- }
-
- PillSlider {
- width: parent.width - 18 - mediaVolLabel.width - 16
- anchors.verticalCenter: parent.verticalCenter
- height: 16
- trackH: 4
- value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0
- fillColor: mediaCard.pwNode && mediaCard.pwNode.audio && mediaCard.pwNode.audio.muted ? Theme.base03 : Theme.base0C
- onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; }
- }
-
- SText {
- id: mediaVolLabel
- width: 36
- text: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.round(mediaCard.pwNode.audio.volume * 100) + "%" : "0%"
- color: Theme.base04
- font.pixelSize: 10
- horizontalAlignment: Text.AlignRight
- anchors.verticalCenter: parent.verticalCenter
- }
- }
}
}
}
- }
- }
-
- // ── Notifications: spans both columns, below the panes ──
- Card {
- id: notifCard
- anchors.top: calRow.bottom
- anchors.topMargin: 8
- anchors.horizontalCenter: parent.horizontalCenter
- width: calRow.width
- opacity: calRow.opacity
- cardSpacing: 6
-
- Item {
- width: parent.width
- height: 20
- SText {
- anchors.left: parent.left
- anchors.verticalCenter: parent.verticalCenter
- text: "Notifications"
- font.weight: Font.Medium
- }
- SText {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : ""
- color: Theme.base04
- font.pixelSize: 11
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- let notifs = bar.notifServer.trackedNotifications.values;
- for (let i = notifs.length - 1; i >= 0; i--) {
- notifs[i].dismiss();
- }
- }
- }
- }
- }
-
- SText {
- visible: bar.notifServer.trackedNotifications.values.length === 0
- text: "No notifications"
- color: Theme.base03
- font.pixelSize: 11
- anchors.horizontalCenter: parent.horizontalCenter
- }
-
- Repeater {
- model: bar.notifServer.trackedNotifications
-
+ // Notifications card
Rectangle {
- id: notifItem
- required property var modelData
- readonly property string previewSource: bar.notifPreviewSource(notifItem.modelData)
width: parent.width
- height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height) + 16
- radius: Theme.radiusSmall
- color: Theme.base02
+ height: notifCardCol.height + 16
+ radius: 8
+ color: Theme.base01
- // Image preview (album art, screenshot thumb…)
- ClippingRectangle {
- id: notifPreview
- visible: notifItem.previewSource !== ""
- anchors.left: parent.left
+ Column {
+ id: notifCardCol
anchors.top: parent.top
- anchors.margins: 8
- width: 48; height: 48
- radius: Theme.radiusTiny
- color: Theme.base01
- Image {
- anchors.fill: parent
- fillMode: Image.PreserveAspectCrop
- source: notifItem.previewSource
+ anchors.topMargin: 8
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - 16
+ spacing: 6
+
+ Item {
+ width: parent.width
+ height: 20
+
+ Text {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Notifications"
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ font.weight: Font.Medium
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : ""
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ let notifs = bar.notifServer.trackedNotifications.values;
+ for (let i = notifs.length - 1; i >= 0; i--) {
+ notifs[i].dismiss();
+ }
+ }
+ }
+ }
}
- }
- NotifContent {
- id: ncBody
- notif: notifItem.modelData
- anchors.left: notifPreview.visible ? notifPreview.right : parent.left
- anchors.right: dismissBtn.left
- anchors.top: parent.top
- anchors.margins: 8
- }
+ Text {
+ visible: bar.notifServer.trackedNotifications.values.length === 0
+ text: "No notifications"
+ color: Theme.base03
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
- SIcon {
- id: dismissBtn
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.margins: 8
- text: "close"
- color: dismissMa.containsMouse ? Theme.base05 : Theme.base03
- font.pixelSize: 15
- MouseArea {
- id: dismissMa
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: notifItem.modelData.dismiss()
+ Repeater {
+ model: bar.notifServer.trackedNotifications
+
+ Rectangle {
+ id: notifItem
+ required property var modelData
+ width: notifCardCol.width
+ height: notifCol.height + 12
+ radius: 6
+ color: Theme.base02
+
+ Column {
+ id: notifCol
+ anchors.left: parent.left
+ anchors.right: dismissBtn.left
+ anchors.top: parent.top
+ anchors.margins: 6
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: notifItem.modelData.summary || notifItem.modelData.appName
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ text: notifItem.modelData.body || ""
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 10
+ elide: Text.ElideRight
+ maximumLineCount: 2
+ wrapMode: Text.Wrap
+ visible: text !== ""
+ }
+
+ Row {
+ spacing: 4
+ visible: notifItem.modelData.actions.length > 0
+ Repeater {
+ model: notifItem.modelData.actions
+ Rectangle {
+ required property var modelData
+ width: actionText.width + 12
+ height: actionText.height + 4
+ radius: 4
+ color: actionMa.containsMouse ? Theme.base03 : Theme.base02
+ Behavior on color { ColorAnimation { duration: 120 } }
+ border.width: 1
+ border.color: Theme.base03
+ Text {
+ id: actionText
+ anchors.centerIn: parent
+ text: modelData.text
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 10
+ }
+ MouseArea {
+ id: actionMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: modelData.invoke()
+ }
+ }
+ }
+ }
+ }
+
+ Text {
+ id: dismissBtn
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 6
+ text: "close"
+ color: dismissMa.containsMouse ? Theme.base05 : Theme.base03
+ font.family: Theme.iconFont
+ font.pixelSize: 14
+ MouseArea {
+ id: dismissMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: notifItem.modelData.dismiss()
+ }
+ }
+ }
}
}
}
@@ -2701,7 +2726,7 @@ in
readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
x: Math.round(bar.width / 2 - width / 2)
- y: Theme.barHeight
+ y: 30
width: _toastRect.width + 16
height: _toastRect.height + 4
@@ -2774,69 +2799,92 @@ in
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
width: 320
- height: toastItem.toastOpen ? toastCard.height + 12 : 0
+ height: toastItem.toastOpen ? toastCol.height + 16 : 0
clip: true
Behavior on height {
- NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
+ NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
- // Notification sits in a base02 rounded card, matching
- // the calendar list. Inset 6px so the melt panel frames it.
- Rectangle {
- id: toastCard
- anchors.top: parent.top
+ Column {
+ id: toastCol
anchors.left: parent.left
+ anchors.right: toastDismiss.left
+ anchors.top: parent.top
+ anchors.margins: 8
+ spacing: 2
+
+ Text {
+ width: parent.width
+ text: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.currentNotif.appName) : ""
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ Text {
+ width: parent.width
+ text: toastItem.currentNotif ? (toastItem.currentNotif.body || "") : ""
+ color: Theme.base04
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ maximumLineCount: 3
+ wrapMode: Text.Wrap
+ visible: text !== ""
+ }
+
+ Row {
+ spacing: 4
+ visible: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0
+ Repeater {
+ model: toastItem.currentNotif ? toastItem.currentNotif.actions : []
+ Rectangle {
+ required property var modelData
+ width: toastActionText.width + 12
+ height: toastActionText.height + 6
+ radius: 4
+ color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01
+ Behavior on color { ColorAnimation { duration: 120 } }
+ border.width: 1
+ border.color: Theme.base02
+ Text {
+ id: toastActionText
+ anchors.centerIn: parent
+ text: modelData.text
+ color: Theme.base05
+ font.family: Theme.fontFamily
+ font.pixelSize: 10
+ }
+ MouseArea {
+ id: toastActionMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { modelData.invoke(); toastItem.dismiss(); }
+ }
+ }
+ }
+ }
+ }
+
+ Text {
+ id: toastDismiss
anchors.right: parent.right
- anchors.margins: 6
- height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16
- radius: Theme.radiusSmall
- color: Theme.base02
-
- property string previewSource: bar.notifPreviewSource(toastItem.currentNotif)
-
- // Image preview (screenshot thumb, album art…)
- ClippingRectangle {
- id: toastPreview
- visible: toastCard.previewSource !== ""
- anchors.left: parent.left
- anchors.top: parent.top
- anchors.margins: 8
- width: 48; height: 48
- radius: Theme.radiusTiny
- color: Theme.base01
- Image {
- anchors.fill: parent
- fillMode: Image.PreserveAspectCrop
- source: toastCard.previewSource
- }
- }
-
- NotifContent {
- id: toastCol
- notif: toastItem.currentNotif
- anchors.left: toastPreview.visible ? toastPreview.right : parent.left
- anchors.right: toastDismiss.left
- anchors.top: parent.top
- anchors.margins: 8
- onActionInvoked: toastItem.dismiss()
- }
-
- SIcon {
- id: toastDismiss
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.margins: 8
- text: "close"
- color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
- font.pixelSize: 15
- MouseArea {
- id: toastDismissMa
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); }
- }
+ anchors.top: parent.top
+ anchors.margins: 8
+ text: "close"
+ color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
+ font.family: Theme.iconFont
+ font.pixelSize: 15
+ MouseArea {
+ id: toastDismissMa
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); }
}
}
}