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(); } } } }