diff --git a/.gitignore b/.gitignore index 8b188ad1..04f73c31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ result/ *.pyc .direnv/ *report.json + +# Wallpapers kept locally but not shipped. The shipped set is the two entries in +# modules/features/desktop/wallpaper.nix and WallpaperService.wallpapers; adding +# an image here means adding it to both, not just dropping it in this directory. +shell/extras/wallpapers/waifu-* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..be01b304 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,119 @@ +# Changelog + +All notable changes to ctOS are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Fixed + +#### Major Bug Fixes + +- **Radial menu geometry was completely inverted** — Trees grew sideways across their own segment instead of outward along the segment axis. Fixed by correcting the layout frame from `(sin, -cos)` to `(cos, sin)` so `dir 0` maps to `+x` (outward). (commit `1162e81`) + +- **Radial tree branching was dendrite instead of columns** — Each fork was inherited by the subtree, causing ever-widening spread. Now fork angle is local to the generation; descendants return to the axis, producing parallel columns. (commit `be5bda0`) + +- **Preview trunk was 2× too long** — `r1Point` 300 → 242, matching the reference proportion (0.35× ring radius). The drawn trunk went from 99px to 41px. (commit `53b39d8`) + +- **Mic volume slider was completely broken** — Node declared `minValue`/`maxValue`/`stepSize` but slider reads `minVal`/`maxVal`/`step`. It inherited defaults `0/100/1` against a 0–1 gain, making it off-or-full with nothing between. Also added missing `root.revision++` for repaint. (commit `21a006f`) + +- **Wallpaper selection reverted after reboot** — Three stacked faults: + 1. `ctos-wallpaper.service` applied v1 unconditionally, never reading `settings.json` + 2. Fallback could destroy the record: a premature scan saw the default `wallpaper-v1.png`, which wasn't in the directory, so it wrote the first image over the user's choice and saved it + 3. Stale-result guard compared against the *intended* directory, so an old scan's files were adopted as the current directory's — producing fallbacks to images from the wrong directory + (commit `5c0f726`) + +- **Boot flash of wallpaper v1** — `ctos-wallpaper.service` hardcoded v1. Now the unit reads `settings.json` and applies the recorded choice, falling back to v1 only when the setting is missing/unreadable/invalid. Removed dead code `defaultWallpaper`. (commit `7dccfff`) + +- **Network/Bluetooth popups were dead code but live** — 1682 lines deleted. Notch and IPC rewired to Command Center cards. Verified zero ReferenceErrors on boot. (commit `f557517`) + +- **Mic volume slider was inverted contract** — Node used `minValue`/`maxValue`/`stepSize` but slider reads `minVal`/`maxVal`/`step`. Added missing `root.revision++` for repaint. (commit `21a006f`) + +#### Minor Bug Fixes + +- **Wallpaper grid only showed 2 rows** — Footer filler `Item { Layout.fillHeight: true }` competed with browser control box for leftover space. Filler now yields when browser is active: 2 → 5 rows. (commit `58ee861`) + +- **Wallpaper list never refreshed** — Scanned once at boot. Now rescans when browser becomes visible. (commit `58ee861`) + +- **Stale scan results from wrong directory** — `_scannedDir` recorded intent, not actual scan directory. Scan output now self-describes its directory; guard compares against that. (commit `5c0f726`) + +- **Preview trunk drawn 2× too long** — `r1Point` 300 → 242 (reference proportion 0.35× ring radius). Visible run: 99px → 41px. (commit `53b39d8`) + +- **Radial preview trunk was 2× too long** — `r1Point` 300 → 242 (reference proportion 0.35× ring radius). Visible run: 99px → 41px. (commit `53b39d8`) + +- **Radial trees grew sideways** — Local layout frame was `(sin, -cos)` so `dir 0` = up. Changed to `(cos, sin)` so `dir 0` = +x (outward). (commit `1162e81`) + +- **Radial trees were dendrites, not columns** — Fork angle was inherited by subtree. Now fork is local; descendants return to axis. (commit `be5bda0`) + +- **Network/Bluetooth popups showed wrong glyph** — Notch always showed wifi glyph even on ethernet. Now conditional: wifi glyph on WiFi, ethernet glyph on wired. (commit `bfcac30`) + +- **Volume/battery glyphs too small** — `opticalScale`: volume 0.85→0.90, battery 0.73→0.85 (now matches wifi scale). (commit `bfcac30`) + +- **DevShell `CTOS_INPUT` flag was broken** — `${CTOS_INPUT: -0}` inside indented string was parsed as Nix interpolation. Fixed to `''${CTOS_INPUT:-0}`. (commit `b04f5f5`) + +- **`defaultWallpaper` dead code removed** — Defined but never referenced. (commit `7dccfff`) + +### Added + +- **Wallpaper grid scroll indicator** — Hand-rolled track+thumb (no Controls dependency). Thumb proportional to visible fraction with 24px floor. Travel arithmetic verified: thumb lands exactly on track bottom at max scroll. (commit `8cac07e`) + +- **Conditional network glyph in notch** — WiFi glyph on WiFi, ethernet (router) glyph on wired. Detail shows signal% on WiFi, "LAN" on Ethernet. (commit `bfcac30`) + +- **Ethernet glyph added** — Material Symbols U+E328 (router) added to `GlyphIcon.qml`. (commit `bfcac30`) + +- **Volume/battery glyph size parity** — Volume `opticalScale` 0.85→0.90, battery 0.73→0.85 (matches wifi scale). (commit `bfcac30`) + +- **Wallpaper unit reads recorded choice** — `ctos-wallpaper.service` now reads `settings.json` and applies recorded choice; falls back to v1 only when unreadable/absent/invalid. (commit `7dccfff`) + +- **Wallpaper grid scroll indicator** — Hand-rolled track+thumb with 24px floor. (commit `8cac07e`) + +- **Wallpaper rescans on browser open** — List was frozen at boot; now rescans when browser becomes visible. (commit `58ee861`) + +- **Stale scan guard now self-describing** — Scan output declares its directory; guard compares against that, not against overwritten `_scannedDir`. (commit `5c0f726`) + +- **Mic volume slider contract fix** — Node used `minValue`/`maxValue`/`stepSize`; slider reads `minVal`/`maxVal`/`step`. Added missing `root.revision++`. (commit `21a006f`) + +- **DevShell `CTOS_INPUT` escaping fix** — `${CTOS_INPUT: -0}` → `''${CTOS_INPUT:-0}`. (commit `b04f5f5`) + +### Changed + +- **Radial trees now grow outward** — Local frame `(cos, sin)` so `dir 0` = +x (outward). `projectPoint` maps this to outward radial for every segment. (commit `1162e81`) + +- **Radial fork angle is now local** — Descendants return to axis, producing parallel columns instead of widening dendrite. (commit `be5bda0`) + +- **Preview trunk shortened to reference proportion** — `r1Point` 300 → 242 (0.35× ring radius). (commit `53b39d8`) + +- **Wallpaper unit now reads settings** — Applies recorded choice; falls back to v1 only on error. (commit `7dccfff`) + +- **Network/Bluetooth popups retired** — Replaced by Command Center cards. Notch and IPC rewired to CCC submenus. (commit `f557517`) + +- **Volume/battery glyph sizes increased** — Volume `opticalScale` 0.85→0.90, battery 0.73→0.85. (commit `bfcac30`) + +- **Conditional network glyph in notch** — WiFi glyph on WiFi, ethernet glyph on wired. Detail shows signal% or "LAN". (commit `bfcac30`) + +- **Ethernet glyph added** — Material Symbols U+E328 (router). (commit `bfcac30`) + +- **DevShell `CTOS_INPUT` escaping fixed** — `${CTOS_INPUT: -0}` → `''${CTOS_INPUT:-0}`. (commit `b04f5f5`) + +- **`defaultWallpaper` dead code removed** (commit `7dccfff`) + +### Removed + +- **NetworkPopup.qml** (910 lines) — Retired, replaced by CCC WiFi card. (commit `f557517`) + +- **BluetoothPopup.qml** (772 lines) — Retired, replaced by CCC Bluetooth card. (commit `f557517`) + +- **`defaultWallpaper`** dead code in `wallpaper.nix` (commit `7dccfff`) + +- **`toggleNetwork`/`toggleBluetooth` helpers in `shell.qml`** — Rewired to CCC submenus. (commit `f557517`) + +- **`bluetoothVisible`/`networkVisible` properties** — No longer needed. (commit `f557517`) + +- **Per-screen liveness checks for popups** — No longer needed. (commit `f557517`) + +--- + +## [0.1.0] - 2026-10-05 + +Initial release baseline. \ No newline at end of file diff --git a/README.md b/README.md index b49b82d3..bf78ca8f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Instead of relying on bloated desktop environments or a mishmash of uncoordinate ### // KEY FEATURES -- `[+]` **Custom Quickshell Desktop** — Includes a massive `SystemRail` for hardware/session controls, an `AmbientBar`, and an instantaneous `CommandDeck` runner. +- `[+]` **Custom Quickshell Desktop** — A single unified Living Notch that breathes open on hover and unfolds into an Adaptive Command & Control Center, plus an instantaneous `CommandDeck` runner. - `[+]` **Immersive Greetd Login** — A fully custom graphical login screen featuring a hacker boot sequence and glitch shaders. - `[+]` **Wayland Compositors** — Highly-tuned, modular integrations for both **Hyprland** and **Niri**. - `[+]` **Performance Optimized** — The QML shell is heavily optimized: surfaces are kept in RAM, Javascript search models are pre-computed, and aggressive background logging is disabled for a snappy UX. @@ -77,13 +77,20 @@ ctOS/ │ └── terminals/ # Kitty, Ghostty │ ├── shell/ # The ctOS Quickshell Desktop Environment -│ ├── desktop/ # QML Surfaces (CommandDeck, SystemRail, AmbientBar) -│ ├── greeter/ # Custom Greetd login screen -│ └── common/ # Shared QML singletons, loggers, and services +│ ├── shell.qml # Desktop entry point; owns every PanelWindow +│ ├── desktop/ # QML: core (tokens, overlay, settings), services, +│ │ # adapters (hyprland), surfaces (notch, CCC) +│ ├── greeter/ # Custom Greetd login screen (separate boundary) +│ ├── docs/target/ # Authoritative design spec + reference images +│ └── nix/ # package.nix + Home Manager module +│ +├── tests/ # QML e2e harnesses (not yet in `nix flake check`) │ └── assets/ # GRUB themes, Plymouth splashes, SVG iconography ``` +See [`shell/docs/target/`](shell/docs/target/) for the shell design specification. + --- ## // COMPOSABLE PROFILES diff --git a/flake.lock b/flake.lock index 72fea5cc..05fee97c 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ ] }, "locked": { - "lastModified": 1789630322, - "narHash": "sha256-jiUl3+k9K+BASO4hvKFhlCMUpHPPoy2q5uZMl3pzOF0=", + "lastModified": 1791041429, + "narHash": "sha256-Y/Q4JtMo6nHB6XS1LPSfK7Y4AnuNl9jci/5sCps1MRc=", "owner": "hyprwm", "repo": "aquamarine", - "rev": "f31c47a1b9d300847d8fc3108ad959448103dfc1", + "rev": "52ea11f06dd492f34b3cc0b39a9c805beecafcff", "type": "github" }, "original": { @@ -81,12 +81,12 @@ ] }, "locked": { - "lastModified": 1790881202, - "narHash": "sha256-aCop8S9239RsGBJSuDF0JI1ErrI98amwYB1mCQEeKwk=", - "rev": "68e51a34285ceb664e74d078bcd46a15f984dfe4", - "revCount": 439, + "lastModified": 1791250419, + "narHash": "sha256-vU0YD5Y5Ji2TtIoMpF+cf/FF+loK7YuuHGyNK5FPwlY=", + "rev": "27b1d95e5fe09d94b5e3a130db3c9a6af1f28a08", + "revCount": 440, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/determinate/3.23.0/01a0f8da-0547-7233-b055-c6224e6d79f3/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/determinate/3.23.1/01a10edb-9034-7171-81fd-c25275e39c48/source.tar.gz" }, "original": { "type": "tarball", @@ -96,37 +96,37 @@ "determinate-nixd-aarch64-darwin": { "flake": false, "locked": { - "narHash": "sha256-c6tUs06EeKcTCpBn8QjUO66JUbdwn+nC6kWKR+8Mako=", + "narHash": "sha256-21m2/tBfv0Zof5NNvi5s8vd1Obr6YP3liMlwy6Oq7hw=", "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/macOS" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/macOS" }, "original": { "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/macOS" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/macOS" } }, "determinate-nixd-aarch64-linux": { "flake": false, "locked": { - "narHash": "sha256-o93Pjfqh3/A0bgAFi2BWEMKs6uU/98gmW5zVTLDkLU4=", + "narHash": "sha256-oXVuzjjdqd2x+efSQXM/MpVLXrxnvBc/vO4sWDTEBhQ=", "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/aarch64-linux" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/aarch64-linux" }, "original": { "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/aarch64-linux" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/aarch64-linux" } }, "determinate-nixd-x86_64-linux": { "flake": false, "locked": { - "narHash": "sha256-UYBRo5g1XZQr0DFNE6C+OVgnK5/PK2p7YaQ2dU2VLN8=", + "narHash": "sha256-bbjsVkZJrIewacdFtAKsaS/+AvCC5J8Qwjpzd9Au2lk=", "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/x86_64-linux" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/x86_64-linux" }, "original": { "type": "file", - "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.0/x86_64-linux" + "url": "https://install.determinate.systems/determinate-nixd/tag/v3.23.1/x86_64-linux" } }, "disko": { @@ -225,11 +225,11 @@ ] }, "locked": { - "lastModified": 1788450739, - "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", + "lastModified": 1790898470, + "narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", + "rev": "024633cd702b10285db5cb19b40ad48d2399ba60", "type": "github" }, "original": { @@ -267,11 +267,11 @@ ] }, "locked": { - "lastModified": 1790819692, - "narHash": "sha256-rvubAPbh3QCliic3sAK+CXTrPCGgJstY52OTs/5H1J4=", + "lastModified": 1791306979, + "narHash": "sha256-KuLePuhwJM9NLt/gTLHzXaOoSZKGoB3m7NfXjiQ98HY=", "owner": "nix-community", "repo": "home-manager", - "rev": "c8ecc29e5175452bee4a2aa1ba2383856d795338", + "rev": "fae6e9e42c3b762ab47635cddcfaf6f52374a61b", "type": "github" }, "original": { @@ -325,6 +325,38 @@ "systems" ] }, + "locked": { + "lastModified": 1791066858, + "narHash": "sha256-hLh6ALHrGpgYm8viWCo1pO+ErC22YDGdAvVPcfYsXA8=", + "owner": "hyprwm", + "repo": "hyprgraphics", + "rev": "80c7d00f2236732853e390019ff8d79d17fa3612", + "type": "github" + }, + "original": { + "owner": "hyprwm", + "repo": "hyprgraphics", + "type": "github" + } + }, + "hyprgraphics_2": { + "inputs": { + "hyprutils": [ + "hyprland", + "xdph", + "hyprutils" + ], + "nixpkgs": [ + "hyprland", + "xdph", + "nixpkgs" + ], + "systems": [ + "hyprland", + "xdph", + "systems" + ] + }, "locked": { "lastModified": 1786464367, "narHash": "sha256-k58p4wbzIXWyRWrW84pP8tD+iaZSSYiiM+fr0Auk4oU=", @@ -347,6 +379,7 @@ "hyprland-guiutils": "hyprland-guiutils", "hyprland-protocols": "hyprland-protocols", "hyprlang": "hyprlang", + "hyprtoolkit": "hyprtoolkit", "hyprutils": "hyprutils", "hyprwayland-scanner": "hyprwayland-scanner", "hyprwire": "hyprwire", @@ -356,11 +389,11 @@ "xdph": "xdph" }, "locked": { - "lastModified": 1790877618, - "narHash": "sha256-wQ/sKmnjmLrKkglG8QzTGI+/D28o9z+Zh6il1Mfo/SM=", + "lastModified": 1791292844, + "narHash": "sha256-5u+cuVSt6NrZWXhMEo2pN24ZuobaKHDF27Ag9iPPPyI=", "ref": "refs/heads/main", - "rev": "a3aee8276b0b6126a597f48092c441d12ed08952", - "revCount": 7860, + "rev": "5a78b5e927345860a27e2893bf894f97ee620c48", + "revCount": 7899, "submodules": true, "type": "git", "url": "https://github.com/hyprwm/Hyprland" @@ -385,7 +418,10 @@ "hyprland", "hyprlang" ], - "hyprtoolkit": "hyprtoolkit", + "hyprtoolkit": [ + "hyprland", + "hyprtoolkit" + ], "hyprutils": [ "hyprland", "hyprutils" @@ -475,46 +511,39 @@ "inputs": { "aquamarine": [ "hyprland", - "hyprland-guiutils", "aquamarine" ], "hyprgraphics": [ "hyprland", - "hyprland-guiutils", "hyprgraphics" ], "hyprlang": [ "hyprland", - "hyprland-guiutils", "hyprlang" ], "hyprutils": [ "hyprland", - "hyprland-guiutils", "hyprutils" ], "hyprwayland-scanner": [ "hyprland", - "hyprland-guiutils", "hyprwayland-scanner" ], "nixpkgs": [ "hyprland", - "hyprland-guiutils", "nixpkgs" ], "systems": [ "hyprland", - "hyprland-guiutils", "systems" ] }, "locked": { - "lastModified": 1785930473, - "narHash": "sha256-DitTu625BhEYpZjtjxtGpjrEJwPwW+X/+jJvhSZNSJM=", + "lastModified": 1789562890, + "narHash": "sha256-LFzeVJkDMQLjCpi55S9oc3N/wMrW2RCS1ShPL+1HB78=", "owner": "hyprwm", "repo": "hyprtoolkit", - "rev": "af515b69dfbe366dc7873aa1475cb2f4db3ebad7", + "rev": "b3f2d0e725e4eaf804af7e529cb69bbed43d1a04", "type": "github" }, "original": { @@ -535,11 +564,11 @@ ] }, "locked": { - "lastModified": 1788619883, - "narHash": "sha256-dpmeFq5vPvSOsi30ZchOLjltAZEuVBucDvGKs3IZYf4=", + "lastModified": 1789968180, + "narHash": "sha256-YGRfhw+fJW7oKW3IspoceD34jeokN1KXNxKU6APlFH4=", "owner": "hyprwm", "repo": "hyprutils", - "rev": "a21e87b878e72ee5dfd375899e8b2ad0d0c4c0e1", + "rev": "95983ee836ff205e615bba67d1f67098e1231941", "type": "github" }, "original": { @@ -687,12 +716,12 @@ "nixpkgs-regression": "nixpkgs-regression" }, "locked": { - "lastModified": 1790875667, - "narHash": "sha256-ldqXsv3UaPHCvvH0nlDvjNF7zM4wJmMjuyRy91/pVFM=", - "rev": "8bb6b35147841a45b6dead93fc98339798d586f5", - "revCount": 27572, + "lastModified": 1791243198, + "narHash": "sha256-fuoqgE/DZMDByEYZyoOBNUxbf/JUMy2VLT8mFcYe0Fc=", + "rev": "1ef903401b3426fbca74a692e158cca82d7c98ab", + "revCount": 27640, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nix-src/3.23.0/01a0f8be-adf7-7fa0-94ab-eaa73eddb9de/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nix-src/3.23.1/01a10eb8-4756-7cb0-bafb-48caf3e3a116/source.tar.gz" }, "original": { "type": "tarball", @@ -706,11 +735,11 @@ ] }, "locked": { - "lastModified": 1790499302, - "narHash": "sha256-Gbf/U9ptJhQh0qnDnJ/+a8MAcq1HXunbArUTHR2wNYk=", + "lastModified": 1791105314, + "narHash": "sha256-oTM7QFgRQ+vxBpMBMc7hEYpqBtpXAzXu2xuB/tru84U=", "owner": "nix-community", "repo": "nix-index-database", - "rev": "161d7c91accd93034bb3c295224d9d895a778573", + "rev": "e740bc4e9d8918d7819a51486d5099930c65c72e", "type": "github" }, "original": { @@ -767,11 +796,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1789684949, - "narHash": "sha256-ZKhUe/2IJUq1JhKxKMu8rbkgSGmPP2ZCqlIPn40aGCM=", + "lastModified": 1791048980, + "narHash": "sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e554fab72f81915600f3f449b786fd9af40439a5", + "rev": "a7868a727837f3c09cee2ce0ca671c76b1589fed", "type": "github" }, "original": { @@ -783,11 +812,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1790822859, - "narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=", + "lastModified": 1791260994, + "narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa", + "rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4", "type": "github" }, "original": { @@ -806,11 +835,11 @@ ] }, "locked": { - "lastModified": 1790797021, - "narHash": "sha256-H2vA5yUaS58Wtdp46piMbRqSQe3Qn716i/oZPYoyhp8=", + "lastModified": 1791215772, + "narHash": "sha256-GBC/HfZM1Bh5dM/B0csBr8YoVGk3DTmCF/Qx646kThE=", "owner": "notashelf", "repo": "nvf", - "rev": "2477cb0a5f394df36ddc72867c2657225d93d157", + "rev": "b5a8011df7e743d5bd992d25983d548f99e9ff83", "type": "github" }, "original": { @@ -828,11 +857,11 @@ ] }, "locked": { - "lastModified": 1789514889, - "narHash": "sha256-PFD1nxptCXJyJoiYO1gf/5Vv43yqGpHtMC6LUhl9/pQ=", + "lastModified": 1790500375, + "narHash": "sha256-XN3sDtn8TU9hAc9xqZ+SqRB/HHm2wjxM6aSWYLJU+oo=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "59f4ca0d063a1a3ec722c88b51a33004862e5379", + "rev": "a0e4241b51206fbcbf52fd322eb5f0cd80f153c4", "type": "github" }, "original": { @@ -865,11 +894,11 @@ ] }, "locked": { - "lastModified": 1790498116, - "narHash": "sha256-rs9meAYxW3zzrh43yaW7htrqCD+X9+pupDPHN86fumI=", + "lastModified": 1791103873, + "narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=", "owner": "Mic92", "repo": "sops-nix", - "rev": "5efb5a6f4f5ab192817d28557dd4d650fa14d866", + "rev": "dcd241ba97088c22569d1573286e1b9daad340c0", "type": "github" }, "original": { @@ -915,6 +944,11 @@ }, "xdph": { "inputs": { + "aquamarine": [ + "hyprland", + "aquamarine" + ], + "hyprgraphics": "hyprgraphics_2", "hyprland-protocols": [ "hyprland", "hyprland-protocols" @@ -923,6 +957,10 @@ "hyprland", "hyprlang" ], + "hyprtoolkit": [ + "hyprland", + "hyprtoolkit" + ], "hyprutils": [ "hyprland", "hyprutils" @@ -941,11 +979,11 @@ ] }, "locked": { - "lastModified": 1788025068, - "narHash": "sha256-TBqronrrc/F2Ry/E37d/1TLldDLJDFNSwvJjgk+cXzU=", + "lastModified": 1790080407, + "narHash": "sha256-uKM+FAvcllYFcHoh4LEmyB0CGLaT3dMOjK4dxXyIIgc=", "owner": "hyprwm", "repo": "xdg-desktop-portal-hyprland", - "rev": "ba31964ee42b56bcb0d3b78a64ead5d8a1c3c6f6", + "rev": "e87ae7823e7bf0601385220c69b5a3b245123fc5", "type": "github" }, "original": { @@ -997,11 +1035,11 @@ ] }, "locked": { - "lastModified": 1790843055, - "narHash": "sha256-rTDYMyiKgVNiL7sAc2LMr7zw3n3eC5YR+IGblzDdQQk=", + "lastModified": 1791435089, + "narHash": "sha256-BpS+S99I5/oUAelESZqO8cSK7JyZ3C1SL7EsIv3icvI=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "a2cb59515204ebe895f73cce387b811a78b3c032", + "rev": "be226e4c3efaaa2a6ae2d8852839f93d31e6ece9", "type": "github" }, "original": { diff --git a/flake/checks.nix b/flake/checks.nix index 706b2b0a..56a72477 100644 --- a/flake/checks.nix +++ b/flake/checks.nix @@ -77,6 +77,88 @@ touch $out ''; + # Gate QML on hard errors. + # + # qmllint cannot be judged by exit code: it exits 0 even on a syntax + # error, and only goes non-zero under --max-warnings. So diagnostics are + # parsed from --json instead, and a file fails only on the categories + # that mean "will not compile": + # + # id == "syntax" a parse error + # type == "error" anything qmllint itself escalated + # + # Everything else is deliberately non-fatal. The tree carries a large + # backlog of style and layout advice and failing on it would make the + # gate useless as a regression signal. + qml-lint = + pkgs.runCommand "check-qml-lint" + { + nativeBuildInputs = [ + pkgs.qt6.qtdeclarative + pkgs.qt6.qtshadertools + pkgs.quickshell + pkgs.jq + ]; + QML2_IMPORT_PATH = lib.concatStringsSep ":" [ + "${pkgs.qt6.qtdeclarative}/lib/qt-6/qml" + "${pkgs.qt6.qtshadertools}/lib/qt-6/qml" + "${pkgs.quickshell}/lib/qt-6/qml" + ]; + } + '' + cp -R ${../shell} ./shell-src + chmod -R u+w ./shell-src + + export QML_IMPORT_PATH="$QML2_IMPORT_PATH" + export QML2_IMPORT_PATH + export QT_QPA_PLATFORM=offscreen + export QT_QUICK_BACKEND=software + + echo "=== Linting QML ===" + + mapfile -t FILES < <(find ./shell-src/desktop ./shell-src/greeter \ + ./shell-src/shell.qml ./shell-src/greeter.qml \ + -name '*.qml' | sort) + + echo "checking ''${#FILES[@]} files" + + qmllint --bare --ignore-settings --json report.json -I ./shell-src \ + "''${FILES[@]}" > /dev/null 2>&1 || true + + if [ ! -s report.json ]; then + echo "FAIL: qmllint produced no report" + exit 1 + fi + + # Surface every fatal diagnostic with its location. + jq -r ' + .files[] as $f + | $f.warnings[]? + | select(.id == "syntax" or .type == "error") + | " \($f.filename):\(.line):\(.column) [\(.id)] \(.message)" + ' report.json | while IFS= read -r line; do echo "$line"; done > fatal.txt + + FAILED=$(wc -l < fatal.txt) + if [ "$FAILED" -ne 0 ]; then + echo "FAIL: $FAILED fatal diagnostic(s)" + cat fatal.txt + exit 1 + fi + + # Advisory only. Type-level diagnostics are NOT available here: + # QtQuick's qmldir resolves through `linktarget Qt6::qtquick2plugin`, + # which qmllint cannot satisfy from a Nix sandbox, so every file + # also carries import-resolution warnings. Those are expected and + # are not a signal about the code. Parse checking still works, and + # that is what this gate relies on. + TOTAL=$(jq '[.files[].warnings[]?] | length' report.json) + SYNTAX=$(jq '[.files[].warnings[]? | select(.id == "syntax")] | length' report.json) + echo " $TOTAL diagnostic(s), $SYNTAX of them parse errors" + + echo "=== QML lint passed (parse-level; type resolution unavailable in sandbox) ===" + touch $out + ''; + # Verify the installer package builds }; }; diff --git a/flake/devshell.nix b/flake/devshell.nix index fefeeec5..ca669625 100644 --- a/flake/devshell.nix +++ b/flake/devshell.nix @@ -1,9 +1,52 @@ # Flake-parts module: development shell for contributors -{ inputs, ... }: +{ + inputs, + lib, + ... +}: { perSystem = { pkgs, system, ... }: + let + # Qt modules are pinned to the same Qt the shell builds against. Mixing + # these with the host's Qt produces a plugin layout where qmllint and + # qmltestrunner start, exit 0, and silently load nothing. + qt6 = pkgs.qt6; + + qmlImports = lib.concatStringsSep ":" [ + "${qt6.qtdeclarative}/lib/qt-6/qml" + "${qt6.qtshadertools}/lib/qt-6/qml" + "${pkgs.quickshell}/lib/qt-6/qml" + ]; + + qtEnv = '' + export QML2_IMPORT_PATH="${qmlImports}" + export QT_PLUGIN_PATH="${qt6.qtbase}/lib/qt-6/plugins" + export QT_QPA_PLATFORM=offscreen + export QT_QUICK_BACKEND=software + ''; + + # Headless layer-shell rendering. + # + # The shell is wlr-layer-shell only, so verifying it visually needs a real + # compositor -- Qt's offscreen platform cannot present a layer surface. + # sway with WLR_BACKENDS=headless and the pixman renderer gives one with + # no GPU and no X server, which is what makes this usable over SSH and in + # CI. See shell/tools/README.md. + headlessEnv = '' + export WLR_BACKENDS=headless + export WLR_RENDERER=pixman + + if [ "''${CTOS_INPUT:-0}" = "1" ]; then + unset WLR_LIBINPUT_NO_DEVICES + else + export WLR_LIBINPUT_NO_DEVICES=1 + fi + + export LIBGL_ALWAYS_SOFTWARE=1 + ''; + in { devShells.default = pkgs.mkShell { name = "ctos-dev"; @@ -17,14 +60,123 @@ # General git jq + + # QML tooling. qtdeclarative provides qml, qmlscene, qmllint and + # qmltestrunner; qttools provides none of them. qtshadertools carries + # the Qt Quick shader modules the software renderer needs. + qt6.qtdeclarative + qt6.qtshadertools + quickshell + + # Headless compositor for layer-shell rendering, plus capture. + sway + grim + slurp + wayland-utils + xwayland-satellite + # swrast/llvmpipe, used when pixman is unavailable or when a shell + # surface needs real GL. + mesa + + # Image inspection: sample exact pixel values out of a render and + # diff two of them. Pillow is what makes "is that actually #EB4ADF" + # a checkable question. + # + # fontTools reads font internals directly -- cmap lookups, glyph + # counts, family names. Determining which codepoint an icon font uses + # for a given glyph otherwise means hand-parsing the cmap tables and + # matching rendered bitmaps against upstream artwork, which is how four + # near-identical neighbours got mistaken for the right glyph. + python3 + python3Packages.pillow + python3Packages.fonttools + imagemagick + + # Synthetic input. + # + # The headless seat has no pointer device (see WLR_LIBINPUT_NO_DEVICES + # in ctos-shot.sh), so swaymsg can move the cursor in sway's model but + # no motion or button event ever reaches a client. Every hover and click + # path therefore has to be faked -- by forcing _isHovered, or by calling + # handlers directly -- and a real interaction bug can survive that. Two + # did: a click gate unreachable from the state the pointer puts the + # notch in, and icons sitting 2px below centre. + # + # ydotool creates a uinput device, which libinput picks up as a real + # pointer, so events travel the same path as a physical mouse -- + # including the Region input mask, which is otherwise unverifiable. + # wtype does the same for the keyboard, so Escape-to-dismiss can be + # exercised rather than reasoned about. + # + # Both need write access to /dev/uinput, which is root:root 0600 by + # default. See the note the shell prints below. + ydotool + wtype + + # Fonts. Theme.fontFamily is "Maple Mono"; without the same faces the + # render does not match what the host draws. + fontconfig + maple-mono.truetype + nerd-fonts.jetbrains-mono + nerd-fonts.monaspace + nerd-fonts.caskaydia-cove + nerd-fonts.symbols-only + + # Session bus. Several services degrade to available=false without + # one, which is worth exercising deliberately but makes unrelated + # failures noisier. + dbus + + opencode ]; shellHook = '' + ${qtEnv} + ${headlessEnv} + + export PATH="$PWD/shell/tools:$PATH" + + ctos-qmllint() { + local fail=0 + while IFS= read -r f; do + qmllint --bare --ignore-settings -I shell "$f" || fail=1 + done < <(find shell/desktop shell/greeter shell/shell.qml shell/greeter.qml -name '*.qml' | sort) + return $fail + } + + ctos-shot() { "$PWD/shell/tools/ctos-shot.sh" "$@"; } + ctos-pick() { python3 "$PWD/shell/tools/ctos-pick.py" "$@"; } + echo "❄️ ctOS development shell" echo "" echo " nix flake check --impure — run all checks" echo " nix fmt — format all Nix files" + echo " ctos-qmllint — lint a QML file" + echo " ctos-shot — render the shell headless and screenshot it" + echo " ctos-pick out.png — dominant colours in a render" + echo " ctos-pick out.png --at X,Y — exact pixel value" + echo "" + echo " Synthetic input (needs writable /dev/uinput):" + echo " CTOS_INPUT=1 ctos-shot out.png — keep input devices live" + echo " CTOS_KEEP=1 ctos-shot out.png — leave the compositor up" + echo " then: swaymsg seat - cursor set X Y / ydotool click 0xC0" echo "" + echo " Headless compositor env is preset (sway + pixman)." + echo "" + + # ydotool and wtype are installed, but they are useless without write + # access to /dev/uinput, which is root:root 0600 on NixOS by default. + # Say so once, with the fix, rather than letting a synthetic-input test + # fail with a bare permission error. + if [ -e /dev/uinput ] && ! ( : > /dev/uinput ) 2>/dev/null; then + echo " Synthetic input: /dev/uinput is present but not writable." + echo " CTOS_INPUT=1 plus ydotool/wtype will fail until you add:" + echo "" + echo " services.udev.extraRules = \"SUBSYSTEM==\\\"misc\\\", KERNEL==\\\"uinput\\\", MODE=\\\"0660\\\", GROUP=\\\"input\\\", OPTIONS+=\\\"static_node=uinput\\\"\";" + echo "" + echo " (reload with: sudo nixos-rebuild switch)" + echo "" + fi ''; }; }; diff --git a/hosts/Makima/default.nix b/hosts/Makima/default.nix index b05beadc..c6c3d383 100644 --- a/hosts/Makima/default.nix +++ b/hosts/Makima/default.nix @@ -32,11 +32,14 @@ "libvirtd" "docker" "wireshark" + "input" ]; shell = pkgs.zsh; hashedPassword = "$6$7uVH9VA23imtOFPs$Rx7oc7xoN5gxBqdB6pg1ZG7xqAeX4LIzLuKjPExFOySTdfmVGdDbCD.4K/dtLLbUbdpcNJ8W5OYpeknaij6mM."; }; + services.udev.extraRules = "SUBSYSTEM==\"misc\", KERNEL==\"uinput\", MODE=\"0660\", GROUP=\"input\", OPTIONS+=\"static_node=uinput\""; + # ctOS profiles ctos.profiles = { desktop.enable = true; @@ -63,6 +66,12 @@ programs.wireshark.enable = true; programs.wireshark.package = pkgs.wireshark-cli; + # Verbose logging for the Hyprland login failure. The desktop launcher dumps + # its full environment to /tmp/ctos-desktop-session.log before exec'ing + # start-hyprland, and cage runs with -D -d, so a login attempt leaves enough + # to see what the session actually got. Turn this off once Hyprland is up. + ctos.debug.enable = false; + networking.hostName = "Makima"; system.stateVersion = "26.11"; } diff --git a/modules/features/core/fonts.nix b/modules/features/core/fonts.nix index 3eae6530..facc6356 100644 --- a/modules/features/core/fonts.nix +++ b/modules/features/core/fonts.nix @@ -13,6 +13,16 @@ in config = lib.mkIf cfg.enable { fonts.packages = with pkgs; [ inter + # Functional glyphs for the shell, referenced by family name as + # "Material Symbols Outlined" (see Theme.fontFamilyMaterialIcons). + # Apache-2.0, as is the rest of this list. + # + # Material Symbols rather than the older Material Icons: it carries the + # same codepoints for the E-range the shell already uses, so switching + # did not disturb a single existing glyph, and roughly triples the set + # (4372 vs 1372). That is what makes glyphs like "hexagon" available at + # all -- the classic face has no hexagon. + material-symbols noto-fonts-cjk-sans source-han-sans source-han-serif diff --git a/modules/features/core/packages.nix b/modules/features/core/packages.nix index 0bd37925..f0fa620e 100644 --- a/modules/features/core/packages.nix +++ b/modules/features/core/packages.nix @@ -19,13 +19,29 @@ in eza fd fzf + fuzzel jq + ydotool fastfetch ripgrep + superfile tmux unzip wget zoxide ]; + + systemd.services.ydotoold = { + description = "ydotool daemon"; + + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.ydotool}/bin/ydotoold --socket-own=1000:100"; + Restart = "always"; + RestartSec = 2; + }; + }; }; } diff --git a/modules/features/desktop/desktopPackages.nix b/modules/features/desktop/desktopPackages.nix index 941e908a..9d4af123 100644 --- a/modules/features/desktop/desktopPackages.nix +++ b/modules/features/desktop/desktopPackages.nix @@ -23,10 +23,6 @@ in hyprcursor hypridle hyprlock - hyprpolkitagent - kdePackages.dolphin - kdePackages.dolphin-plugins - kdePackages.okular libnotify mpv obsidian @@ -37,7 +33,11 @@ in slurp wl-clipboard zathura + superfile inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.ctos-shell + + # Agent tooling for working on this configuration. + opencode ]; }; } diff --git a/modules/features/desktop/greeter.nix b/modules/features/desktop/greeter.nix index 74b6c730..ea1915e4 100644 --- a/modules/features/desktop/greeter.nix +++ b/modules/features/desktop/greeter.nix @@ -8,22 +8,6 @@ let cfg = config.ctos.features.greeter; ctosPackage = pkgs.callPackage ../../../shell/nix/package.nix { }; - desktopCommand = pkgs.writeShellScript "ctos-start-hyprland" '' - set -u - - ${lib.optionalString config.ctos.debug.enable '' - log=/tmp/ctos-desktop-session.log - exec >>"$log" 2>&1 - echo "ctOS desktop launcher: $(${pkgs.coreutils}/bin/date --iso-8601=seconds)" - ${pkgs.coreutils}/bin/env - ''} - - ${lib.optionalString (!config.ctos.debug.enable) '' - exec >/dev/null 2>&1 - ''} - - exec ${config.programs.hyprland.package}/bin/start-hyprland - ''; greeterCommand = pkgs.writeShellScript "ctos-greeter-launch" '' set -u @@ -40,7 +24,6 @@ let ''} export CTOS_MODE=greetd - export CTOS_LAUNCH_COMMAND=${desktopCommand} export QT_QPA_PLATFORM=wayland export XDG_SESSION_TYPE=wayland export XCURSOR_THEME=Bibata-Modern-Classic @@ -86,7 +69,7 @@ in animations = "all"; monitor = ""; exitOverride = [ ]; - launchOverride = [ "${desktopCommand}" ]; + launchOverride = [ ]; modes = { greetd = { animations = "all"; diff --git a/modules/features/desktop/gtk.nix b/modules/features/desktop/gtk.nix new file mode 100644 index 00000000..85afbd95 --- /dev/null +++ b/modules/features/desktop/gtk.nix @@ -0,0 +1,61 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.ctos.features.gtk; + + hmGtkModule = + { + config, + lib, + pkgs, + ... + }: + { + config = { + gtk = { + enable = true; + + theme = { + name = cfg.theme; + package = pkgs.yaru-theme; + }; + + iconTheme = { + name = "Yaru"; + package = pkgs.yaru-theme; + }; + }; + + dconf.settings = { + "org/gnome/desktop/interface" = { + gtk-theme = cfg.theme; + icon-theme = "Yaru"; + }; + }; + + home.packages = [ + pkgs.nautilus + ]; + }; + }; +in +{ + options.ctos.features.gtk = { + enable = lib.mkEnableOption "GTK theming and file manager"; + + theme = lib.mkOption { + type = lib.types.str; + default = "Yaru"; + description = "GTK theme name"; + }; + }; + + config = lib.mkIf cfg.enable { + home-manager.sharedModules = [ hmGtkModule ]; + }; +} diff --git a/modules/features/desktop/hyprland.nix b/modules/features/desktop/hyprland.nix index ad8ea276..e6dac2d9 100644 --- a/modules/features/desktop/hyprland.nix +++ b/modules/features/desktop/hyprland.nix @@ -7,6 +7,7 @@ }: let cfg = config.ctos.features.hyprland; + debugEnabled = config.ctos.debug.enable; hmHyprlandModule = { @@ -33,8 +34,8 @@ let }) local terminal = "kitty" - local fileManager = "kitty -e yazi" - local menu = "ctos-shell-msg toggleCommandDeck" + local fileManager = "kitty -e superfile" + local menu = "fuzzel" local mainMod = "SUPER" hl.env("XCURSOR_THEME", "Bibata-Modern-Classic") @@ -42,12 +43,70 @@ let hl.env("HYPRCURSOR_THEME", "Bibata-Modern-Classic") hl.env("HYPRCURSOR_SIZE", "20") + -- Hyprland 0.56 renders through aquamarine, not wlroots. + -- + -- From the aquamarine log of a failing greeter start-up: + -- drm: gpu /dev/dri/card1 becomes primary drm + -- drm: Starting backend for /dev/dri/card0, with driver nvidia-drm + -- with primary /dev/dri/card1 + -- Couldn't open a GBM device at fd 40 + -- Cannot create a GBM Allocator: gbm failed to create a device. + -- CRIT: Cannot open backend: no allocator available + -- + -- So DRM itself opens fine and card1 (the AMD APU) is already + -- primary, but aquamarine still brings the backend up on card0 with + -- nvidia-drm, and gbm_create_device() fails on the NVIDIA node + -- because the NVIDIA GBM userspace is not on the session's library + -- path. The result is no allocator and an abort. + -- + -- AQ_DRM_DEVICES is aquamarine's own override. Restricting it to the + -- AMD APU keeps the backend off the NVIDIA card entirely, which is + -- what Niri already does successfully on this machine. This is an + -- Optimus laptop (GeForce GTX 1650 Ti + Renoir APU) and the internal + -- panel is driven by the APU. + -- + -- Note /dev/dri numbering is not what it looks like: card1 is the AMD + -- APU (PCI 05:00.0) while renderD128 is *also* the APU and renderD129 + -- is the NVIDIA card. + hl.env("AQ_DRM_DEVICES", "/dev/dri/card1") + + ${lib.optionalString debugEnabled '' + -- 0.56 defaults debug.disable_logs to true, so Hyprland prints + -- nothing at all unless logs are explicitly re-enabled. Combined + -- with the launcher redirecting stderr, a failing start-up used to + -- produce a completely empty log. + hl.env("HYPRLAND_TRACE", "1") + hl.env("AQ_TRACE", "1") + ''} + hl.on("hyprland.start", function() - hl.exec_cmd("dbus-update-activation-environment --systemd DISPLAY HYPRLAND_INSTANCE_SIGNATURE WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE && systemctl --user stop hyprland-session.target && systemctl --user start hyprland-session.target") - hl.exec_cmd("systemctl --user start hyprpolkitagent") + -- nixos-fake-graphical-session.target, the same target niri + -- starts. + -- + -- This used to start and stop hyprland-session.target, but the + -- nixpkgs Hyprland package installs no systemd user units, so + -- that target does not exist and systemctl fails with "Unit + -- hyprland-session.target not found". Nothing then pulled in + -- graphical-session.target, which is where ctos.service, + -- ctos-awww-daemon.service and ctos-wallpaper.service live -- + -- hence a Hyprland session with no shell, no awww and no + -- wallpaper. + hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP DISPLAY GTK_USE_PORTAL HYPRLAND_INSTANCE_SIGNATURE XDG_SESSION_TYPE && systemctl --user start nixos-fake-graphical-session.target") + hl.exec_cmd("${pkgs.polkit_gnome}/libexec/polkit-gnome-authentication-agent-1") end) hl.config({ + ${lib.optionalString debugEnabled '' + -- enable_stdout_logs is only honoured when disable_logs is false, + -- so both are needed to get anything on stdout. + debug = { + disable_logs = false, + enable_stdout_logs = true, + disable_time = false, + suppress_errors = false, + error_limit = 20, + }, + ''} general = { gaps_in = 3, gaps_out = 5, @@ -210,7 +269,28 @@ in programs.hyprland = { enable = true; - package = inputs.hyprland.packages.${pkgs.stdenv.hostPlatform.system}.hyprland; + + # From the system nixpkgs rather than the Hyprland git flake input. + # + # The flake input carries its own nixpkgs, so Hyprland and aquamarine + # were linked against glibc-2.42 while the system's Mesa is built + # against glibc-2.44. Hyprland therefore starts with glibc 2.42 mapped, + # and when GBM tries to dlopen the driver's dri_gbm.so it pulls in + # mesa's libgallium, which requires GLIBC_2.43: + # + # MESA-LOADER: failed to open dri: + # .../glibc-2.42-84/lib/libm.so.6: version `GLIBC_2.43' not found + # (required by .../mesa-26.2.3/lib/libgallium-26.2.3.so) + # + # gbm_create_device() then returns NULL and start-up dies with + # "Cannot create a GBM Allocator" / "no allocator available". Niri is + # unaffected because it comes from the system nixpkgs and so runs + # against glibc 2.44. + # + # No environment variable can paper over this: glibc resolves its + # dlopen search path once at startup, and the GLIBC_2.43 requirement is + # absolute. The two closures have to be built against the same glibc. + package = pkgs.hyprland; xwayland.enable = true; }; diff --git a/modules/features/desktop/niri.nix b/modules/features/desktop/niri.nix index aa063243..8221315b 100644 --- a/modules/features/desktop/niri.nix +++ b/modules/features/desktop/niri.nix @@ -179,8 +179,8 @@ let # Application Launchers "Mod+Return".action = actions.spawn "kitty"; "Mod+Shift+Return".action = actions.spawn "ghostty"; - "Mod+D".action = actions.spawn "ctos-shell-msg" "toggleCommandDeck"; - "Mod+E".action = actions.spawn "kitty" "-e" "yazi"; + "Mod+D".action = actions.spawn "fuzzel"; + "Mod+E".action = actions.spawn "kitty" "-e" "superfile"; "Mod+B".action = actions.spawn "zen"; "Mod+Shift+Slash".action = actions.show-hotkey-overlay; diff --git a/modules/features/desktop/polkit.nix b/modules/features/desktop/polkit.nix new file mode 100644 index 00000000..82292870 --- /dev/null +++ b/modules/features/desktop/polkit.nix @@ -0,0 +1,34 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.ctos.features.polkit; + + hmPolkitModule = + { + config, + lib, + pkgs, + ... + }: + { + config = { + home.packages = [ + pkgs.polkit_gnome + ]; + }; + }; +in +{ + options.ctos.features.polkit.enable = lib.mkEnableOption "Polkit authentication agent"; + + config = lib.mkIf cfg.enable { + security.polkit.enable = true; + + home-manager.sharedModules = [ hmPolkitModule ]; + }; +} diff --git a/modules/features/desktop/toolkit.nix b/modules/features/desktop/toolkit.nix new file mode 100644 index 00000000..7a69cda1 --- /dev/null +++ b/modules/features/desktop/toolkit.nix @@ -0,0 +1,98 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.ctos.features.toolkit; + + # Grouped to match ToolkitService.groups in the shell, with one important + # asymmetry: this list is nixpkgs *package* names and the shell's is bare + # *command* names on PATH, and those are not the same set. `bind` provides + # dig, host and delv; `nettools` provides other legacy net tools. So a group + # here and its counterpart there are deliberately not identical, and the + # mismatch is written down rather than papered over -- the shell reports what it + # can actually execute, which is the only question a readout can answer. + groups = { + recon = [ + "nmap" + "masscan" + "whois" + "bind" # provides dig, host, delv + "nettools" + ]; + capture = [ + "tshark" + "tcpdump" + "socat" + ]; + wireless = [ + "aircrack-ng" + ]; + credentials = [ + "john" + "hashcat" + "hydra" + ]; + web = [ + "sqlmap" + ]; + forensics = [ + "binwalk" + "exiftool" + "file" + ]; + diagnostics = [ + "iperf3" + "mtr" + "traceroute" + "jq" + ]; + }; + + # Two mappings to keep flat, because attrset keys cannot collide silently -- + # a name appearing under two groups would just drop one of them. + nameToGroup = lib.listToAttrs ( + lib.concatMap ( + group: + map (name: { + name = name; + value = group; + }) groups.${group} + ) (lib.attrNames groups) + ); + duplicates = lib.filter ( + name: lib.length (lib.filter (g: g == nameToGroup.${name}) (lib.attrNames groups)) > 1 + ) (lib.attrNames nameToGroup); +in +{ + options.ctos.features.toolkit.enable = lib.mkEnableOption "ctOS security and diagnostic toolkit"; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = duplicates == [ ]; + message = '' + ctOS toolkit: these tools are listed under more than one group: ${toString (lib.concatStringsSep ", " duplicates)}. + Each tool belongs to exactly one group, or ToolkitService would count it once + and the group tallies would not add up. + ''; + } + ]; + + home-manager.sharedModules = [ + ( + { ... }: + { + # Only the nixpkgs names, not pkgs. -- the shell resolves tools by + # bare command name on PATH, and the home-manager profile is what puts + # them there. Deriving both halves from `groups` keeps the module and + # ToolkitService in step without duplicating the list. + home.packages = map (name: pkgs.${name}) (lib.attrNames nameToGroup); + } + ) + ]; + }; +} diff --git a/modules/features/desktop/wallpaper.nix b/modules/features/desktop/wallpaper.nix index 40b88753..710c5d7b 100644 --- a/modules/features/desktop/wallpaper.nix +++ b/modules/features/desktop/wallpaper.nix @@ -7,16 +7,80 @@ let cfg = config.ctos.features.wallpaper; - wallpaper = ../../../shell/extras/wallpapers/wallpaper-v1.png; + + # The authored set. + # + # Each entry names its own file rather than deriving one from the id, so a + # non-PNG or irregularly-named wallpaper cannot silently fail to install. + # + # This list and WallpaperService.wallpapers are two halves of one contract. + # They must agree on every id and every installed filename: an id installed + # here but absent there can never be selected, and a file shipped here but + # named differently there selects a path that does not exist. + # + # Only wallpapers that belong in the repository are listed. Adding one is an + # entry here and one there. + # + # The install below is written out longhand rather than folded over this list, + # and that is deliberate. Generating `home.file` entries from interpolated path + # expressions -- `../../../shell/extras/wallpapers/${w.file}` -- produces a + # value that reports itself as a path and resolves to the right file in + # isolation, but makes `system.build.toplevel` fail to evaluate with + # + # syntax error, unexpected invalid token + # at shell/extras/wallpapers/wallpaper-v1.png:1:1 + # + # i.e. Nix parses the PNG as an expression. So the set is still data, but the + # one place a path is built for home-manager spells it as a literal. Two + # entries is a small enough list that being explicit costs nothing, and being + # explicit is what the previous working version of this module did. + wallpaperFiles = [ + { + id = "v1"; + file = "wallpaper-v1.png"; + } + { + id = "v2"; + file = "wallpaper-v2.png"; + } + ]; + wallpaperPath = ".local/share/ctos/wallpapers/wallpaper-v1.png"; + + wallpaperV1 = ../../../shell/extras/wallpapers/wallpaper-v1.png; + wallpaperV2 = ../../../shell/extras/wallpapers/wallpaper-v2.png; + applyWallpaper = pkgs.writeShellScript "ctos-apply-wallpaper" '' set -eu + # Apply the recorded choice, not a hardcoded one. + # + # This unit runs at graphical-session.target and WallpaperService applies the + # same value a moment later. While the unit applied v1 outright, every boot + # showed v1 fading out and the real wallpaper fading in. The shell being the + # authority fixed what ended up on screen; this stops the wrong image being + # painted first. + # + # Any of settings.json being unreadable, absent, lacking the key, or naming a + # file that is not there is an ordinary condition on a first boot or after a + # wallpaper has been deleted, and all of them fall back to v1. + settings="''${XDG_CONFIG_HOME:-$HOME/.config}/ctos/settings.json" + target="$HOME/${wallpaperPath}" + + if [ -r "$settings" ]; then + name=$(${pkgs.jq}/bin/jq -r '.wallpaper // empty' "$settings" 2>/dev/null) || name="" + dir=$(${pkgs.jq}/bin/jq -r '.wallpaperDir // empty' "$settings" 2>/dev/null) || dir="" + [ -n "$dir" ] || dir="$HOME/.local/share/ctos/wallpapers" + if [ -n "$name" ] && [ -f "$dir/$name" ]; then + target="$dir/$name" + fi + fi + # The daemon socket can appear shortly after the graphical session target. # Retry briefly so startup ordering does not make the wallpaper disappear. for attempt in $(seq 1 20); do if ${pkgs.awww}/bin/awww img \ - "$HOME/${wallpaperPath}" \ + "$target" \ --transition-type fade \ --transition-duration 1; then exit 0 @@ -38,7 +102,12 @@ in { home.packages = [ pkgs.awww ]; - home.file.${wallpaperPath}.source = wallpaper; + # Every authored wallpaper, not just the default -- so there is + # something to switch between. home.file merges attribute names rather + # than replacing, so this composes with any other module that wants to + # drop something else in that directory. + home.file.".local/share/ctos/wallpapers/wallpaper-v1.png".source = wallpaperV1; + home.file.".local/share/ctos/wallpapers/wallpaper-v2.png".source = wallpaperV2; systemd.user.services.ctos-awww-daemon = { Unit = { diff --git a/modules/features/desktop/xdg.nix b/modules/features/desktop/xdg.nix index e0cf60db..fd85dd65 100644 --- a/modules/features/desktop/xdg.nix +++ b/modules/features/desktop/xdg.nix @@ -7,229 +7,19 @@ let cfg = config.ctos.features.xdg; - - hmXdgModule = - { - config, - lib, - pkgs, - ... - }: - { - config = { - xdg.mimeApps = { - enable = true; - associations.removed = { - "text/html" = [ "firefox.desktop" ]; - "x-scheme-handler/http" = [ "firefox.desktop" ]; - "x-scheme-handler/https" = [ "firefox.desktop" ]; - "x-scheme-handler/about" = [ "firefox.desktop" ]; - "x-scheme-handler/unknown" = [ "firefox.desktop" ]; - }; - associations.added = { - "text/html" = [ "zen-beta.desktop" ]; - "x-scheme-handler/http" = [ "zen-beta.desktop" ]; - "x-scheme-handler/https" = [ "zen-beta.desktop" ]; - }; - defaultApplications = { - # File manager / Directories - "inode/directory" = [ "org.kde.dolphin.desktop" ]; - - # Web / URLs - "text/html" = [ "zen-beta.desktop" ]; - "x-scheme-handler/http" = [ "zen-beta.desktop" ]; - "x-scheme-handler/https" = [ "zen-beta.desktop" ]; - "x-scheme-handler/about" = [ "zen-beta.desktop" ]; - "x-scheme-handler/unknown" = [ "zen-beta.desktop" ]; - - # Images (qView primary) - "image/png" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - "org.kde.dolphin.desktop" - ]; - "image/jpeg" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - "org.kde.dolphin.desktop" - ]; - "image/webp" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - "org.kde.dolphin.desktop" - ]; - "image/gif" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - "org.kde.dolphin.desktop" - ]; - "image/svg+xml" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - "zen-beta.desktop" - ]; - "image/bmp" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - ]; - "image/tiff" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - ]; - "image/avif" = [ - "com.interversehq.qView.desktop" - "org.gnome.Loupe.desktop" - "org.kde.gwenview.desktop" - ]; - - # Video & Audio (MPV) - "video/mp4" = [ "mpv.desktop" ]; - "video/mkv" = [ "mpv.desktop" ]; - "video/webm" = [ "mpv.desktop" ]; - "video/x-matroska" = [ "mpv.desktop" ]; - "video/avi" = [ "mpv.desktop" ]; - "video/quicktime" = [ "mpv.desktop" ]; - "audio/mpeg" = [ "mpv.desktop" ]; - "audio/flac" = [ "mpv.desktop" ]; - "audio/wav" = [ "mpv.desktop" ]; - "audio/ogg" = [ "mpv.desktop" ]; - - # PDF & Documents (Okular primary, Zathura terminal fallback, Zen) - "application/pdf" = [ - "org.kde.okular.desktop" - "org.pwmt.zathura.desktop" - "zen-beta.desktop" - ]; - "application/epub+zip" = [ - "org.kde.okular.desktop" - "org.pwmt.zathura.desktop" - ]; - "application/postscript" = [ - "org.kde.okular.desktop" - "org.pwmt.zathura.desktop" - ]; - - # Archives (Ark / Dolphin) - "application/zip" = [ - "org.kde.ark.desktop" - "org.kde.dolphin.desktop" - ]; - "application/x-tar" = [ - "org.kde.ark.desktop" - "org.kde.dolphin.desktop" - ]; - "application/x-7z-compressed" = [ - "org.kde.ark.desktop" - "org.kde.dolphin.desktop" - ]; - "application/x-compressed-tar" = [ - "org.kde.ark.desktop" - "org.kde.dolphin.desktop" - ]; - - # Plain Text, Markdown & Code (Emacs / Neovim) - "text/plain" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "text/markdown" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "text/x-nix" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "text/css" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "text/x-python" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "application/json" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "application/yaml" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "application/toml" = [ - "emacs.desktop" - "nvim.desktop" - ]; - "application/xml" = [ - "emacs.desktop" - "nvim.desktop" - ]; - }; - }; - - xdg.configFile."menus/applications.menu".text = '' - - - Applications - - - - - ''; - - home.sessionVariables = { - GTK_USE_PORTAL = "1"; - }; - }; - }; in { - options.ctos.features.xdg.enable = - lib.mkEnableOption "XDG desktop portals and MIME default application associations"; + options.ctos.features.xdg.enable = lib.mkEnableOption "XDG desktop portals"; config = lib.mkIf cfg.enable { xdg.portal = { enable = true; - extraPortals = with pkgs; [ - kdePackages.xdg-desktop-portal-kde - xdg-desktop-portal-gtk + + extraPortals = [ + pkgs.xdg-desktop-portal-gtk ]; - config = { - common = { - default = [ - "kde" - "gtk" - ]; - "org.freedesktop.impl.portal.FileChooser" = [ "kde" ]; - }; - niri = { - default = [ - "kde" - "gtk" - ]; - "org.freedesktop.impl.portal.FileChooser" = [ "kde" ]; - }; - hyprland = { - default = [ - "hyprland" - "kde" - "gtk" - ]; - "org.freedesktop.impl.portal.FileChooser" = [ "kde" ]; - }; - }; }; - home-manager.sharedModules = [ hmXdgModule ]; - environment.sessionVariables = { GTK_USE_PORTAL = "1"; }; diff --git a/modules/profiles/desktop.nix b/modules/profiles/desktop.nix index 60fe8ea9..9ca2f196 100644 --- a/modules/profiles/desktop.nix +++ b/modules/profiles/desktop.nix @@ -10,12 +10,15 @@ let "display" "firefox" "ghostty" + "gtk" "hyprland" "hyprlock" "kitty" "niri" "power" + "polkit" "udiskie" + "toolkit" "wallpaper" "xdg" "zen-browser" diff --git a/modules/profiles/workstation.nix b/modules/profiles/workstation.nix index 41921f1c..be275fb8 100644 --- a/modules/profiles/workstation.nix +++ b/modules/profiles/workstation.nix @@ -15,7 +15,6 @@ let "starship" "tmux" "virtualization" - "yazi" "zoxide" "zsh" ]; diff --git a/shell/desktop/core/ActionRegistry.qml b/shell/desktop/core/ActionRegistry.qml index 8f1a2ba2..7ae2f5b8 100644 --- a/shell/desktop/core/ActionRegistry.qml +++ b/shell/desktop/core/ActionRegistry.qml @@ -245,7 +245,6 @@ Singleton { execute: function () { OverlayController.close(); SessionService.lock(); - // Quickshell.execDetached(["loginctl", "lock-session"]); } }, { diff --git a/shell/desktop/core/NewColorWidgetPoC.qml b/shell/desktop/core/NewColorWidgetPoC.qml deleted file mode 100644 index f3417609..00000000 --- a/shell/desktop/core/NewColorWidgetPoC.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import QtQuick.Controls - -Rectangle { - id: root - width: 200 - height: 100 - color: Theme.background - radius: Theme.radiusMedium - border.color: Theme.border - border.width: Theme.borderWidth - - Column { - anchors.centerIn: parent - spacing: Theme.spacingMedium - - Rectangle { - width: 150 - height: 30 - color: Theme.pastelBlue - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: "New Color A" - color: Theme.textInverse - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeBody - } - } - - Rectangle { - width: 150 - height: 30 - color: Theme.pastelOrange - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: "New Color B" - color: Theme.textInverse - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeBody - } - } - } -} diff --git a/shell/desktop/core/OverlayController.qml b/shell/desktop/core/OverlayController.qml index dfd3fcc9..57265c4c 100644 --- a/shell/desktop/core/OverlayController.qml +++ b/shell/desktop/core/OverlayController.qml @@ -41,6 +41,20 @@ Singleton { // Explicit constants for zero-ambiguity access readonly property int surfaceNone: 0 + // Output the active overlay belongs to, captured when it opens. + // + // Per-output surfaces need this to decide whether they are the host. The CCC + // used to live in its own PanelWindow in shell.qml which read overlayHost's + // screen directly; now it is a child of the notch's window, and each output + // has its own notch, so exactly one of them must claim it. + property var hostScreen: null + + readonly property string hostScreenName: (hostScreen && hostScreen.name) ? hostScreen.name : "" + + function setHostScreen(screen: var): void { + root.hostScreen = screen; + } + property string pendingSessionAction: "" property string pendingRailView: "" property alias pendingRailSubmenu: root.pendingRailView @@ -87,6 +101,7 @@ Singleton { overlayOpened(targetSurface); requestFocus(targetSurface); } else { + hostScreen = null; releaseFocus(); } diff --git a/shell/desktop/core/Settings.qml b/shell/desktop/core/Settings.qml index 6d94af9b..3163c73e 100644 --- a/shell/desktop/core/Settings.qml +++ b/shell/desktop/core/Settings.qml @@ -7,7 +7,7 @@ import Quickshell.Io Singleton { id: root - property int barHeight: 32 + property int barHeight: 30 property string compositor: "hyprland" // ========================================================================= @@ -17,9 +17,10 @@ Singleton { // User-configurable path pointing to user settings file. // Defaults to "~/.config/ctos/settings.json". Never hardcodes /etc/ctos. property string configPath: "~/.config/ctos/settings.json" - readonly property int defaultBarHeight: 32 + readonly property int defaultBarHeight: 30 readonly property string defaultCompositor: "hyprland" readonly property bool defaultFeaturesCommandDeck: true + readonly property bool defaultFeaturesCommandCenter: true readonly property bool defaultFeaturesNotifications: true // ========================================================================= @@ -27,8 +28,34 @@ Singleton { // ========================================================================= readonly property bool defaultReducedMotion: false - readonly property string defaultTheme: "ctos-dark" - readonly property string defaultWallpaper: "" + // ctos-pine, not ctos-dark, despite the latter's name. Pine is the palette + // every token was designed against and the one the shell ships as; acid is + // the alternative you opt into from the radial's appearance picker. + // + // This is the value Theme renders before the config file has been read, so + // it also decides the colour of the first frame. A user who has chosen acid + // therefore sees one frame of pine before the switch, which is why the + // picker is the thing that writes this key rather than anything that reads + // it at startup. + readonly property string defaultTheme: "ctos-pine" + + // A filename, not a path, and not an id. + // + // The wallpaper switcher scans a user-settable directory, so the selection + // has to be meaningful in whichever directory is current. Storing a bare + // filename and resolving it against wallpaperDir is what lets the shipped + // set and the user's own images be the same kind of thing. + // + // A real value, not "". Empty would read as "unset", leaving the browser + // with nothing highlighted and making the first selection indistinguishable + // from no selection. + readonly property string defaultWallpaper: "wallpaper-v1.png" + + // Where the switcher looks. Defaults to where wallpaper.nix installs the + // shipped pair, so a fresh install has something to show before the user has + // configured anything. + readonly property string defaultWallpaperDir: + (Quickshell.env("HOME") || "") + "/.local/share/ctos/wallpapers" readonly property bool defaultWidgetCpuHexGridVisible: true readonly property bool defaultWidgetNetworkFlowVisible: true readonly property bool defaultWidgetRamBlockBarVisible: true @@ -36,6 +63,7 @@ Singleton { readonly property bool defaultWidgetAudioSurveillanceVisible: true readonly property bool defaultWidgetTargetProfilerVisible: true property bool featuresCommandDeck: true + property bool featuresCommandCenter: true property bool featuresNotifications: true property int notificationCooldownSeconds: 30 readonly property int defaultNotificationCooldownSeconds: 30 @@ -70,8 +98,9 @@ Singleton { } return target; } - property string theme: "ctos-dark" - property string wallpaper: "" + property string theme: "ctos-pine" + property string wallpaper: "wallpaper-v1.png" + property string wallpaperDir: (Quickshell.env("HOME") || "") + "/.local/share/ctos/wallpapers" signal settingsLoadFailed(int error) signal settingsSaved @@ -114,6 +143,15 @@ Singleton { featuresCommandDeck = defaultFeaturesCommandDeck; } + // featuresCommandCenter: supports flat key or nested features.commandCenter + if (typeof data.featuresCommandCenter === "boolean") { + featuresCommandCenter = data.featuresCommandCenter; + } else if (data.features && typeof data.features.commandCenter === "boolean") { + featuresCommandCenter = data.features.commandCenter; + } else { + featuresCommandCenter = defaultFeaturesCommandCenter; + } + // featuresNotifications: supports flat key or nested features.notifications if (typeof data.featuresNotifications === "boolean") { featuresNotifications = data.featuresNotifications; @@ -147,13 +185,20 @@ Singleton { theme = defaultTheme; } - // wallpaper: string - if (typeof data.wallpaper === "string") { + // wallpaper: string -- a filename, resolved against wallpaperDir + if (typeof data.wallpaper === "string" && data.wallpaper.length > 0) { wallpaper = data.wallpaper; } else { wallpaper = defaultWallpaper; } + // wallpaperDir: string -- the directory the switcher scans + if (typeof data.wallpaperDir === "string" && data.wallpaperDir.length > 0) { + wallpaperDir = data.wallpaperDir; + } else { + wallpaperDir = defaultWallpaperDir; + } + // compositor: string if (typeof data.compositor === "string" && data.compositor.length > 0) { compositor = data.compositor; @@ -308,11 +353,13 @@ Singleton { data.reducedMotion = root.reducedMotion; data.featuresCommandDeck = root.featuresCommandDeck; + data.featuresCommandCenter = root.featuresCommandCenter; data.featuresNotifications = root.featuresNotifications; data.notificationCooldownSeconds = root.notificationCooldownSeconds; data.barHeight = root.barHeight; data.theme = root.theme; data.wallpaper = root.wallpaper; + data.wallpaperDir = root.wallpaperDir; data.compositor = root.compositor; data.widgetCpuHexGridVisible = root.widgetCpuHexGridVisible; @@ -508,6 +555,7 @@ Singleton { function resetToDefaults(): void { reducedMotion = defaultReducedMotion; featuresCommandDeck = defaultFeaturesCommandDeck; + featuresCommandCenter = defaultFeaturesCommandCenter; featuresNotifications = defaultFeaturesNotifications; notificationCooldownSeconds = defaultNotificationCooldownSeconds; barHeight = defaultBarHeight; diff --git a/shell/desktop/core/Theme.qml b/shell/desktop/core/Theme.qml index 9521701c..80167032 100644 --- a/shell/desktop/core/Theme.qml +++ b/shell/desktop/core/Theme.qml @@ -2,144 +2,522 @@ pragma Singleton import QtQuick import Quickshell +import "./palettes/PinePalette.js" as PinePalette +import "./palettes/AcidPalette.js" as AcidPalette +// Design tokens for the Living Notch / command centre. +// +// The rule this file exists to enforce: colour is referenced by role, never +// inlined. There are three layers, and only the bottom one holds values: +// +// palettes/*.js 22 named slots per palette. The only place a hex lives. +// pal* below one token per slot, wrapped in Qt.color. +// everything else aliases those, so the ~90 call-facing tokens are a naming +// layer and never a source of colour. +// +// Two palettes ship: ctos-pine, the default, and ctos-dark, the acid one. Both +// are dark; see AcidPalette.js on why "dark" names the acid one. +// +// Role assignments, and why they are not simply "the nearest colour": +// +// The accent slot is active, selected, focused. +// The love slot is health-failing: destructive, error, danger. +// The status slot is health: connected, charging, positive. +// The cool slot is the cool secondary: informational, hover borders. +// The warning slot is the warm notice: warnings and amber states. +// +// These are different intentions and must not be interchanged. Note that +// ctos-dark sets both `accent` and `status` to #1BFD9C, which collapses that +// distinction for that palette only -- the slots stay separate so that setting +// them apart later is a one-value change. Singleton { id: root - // SECTION Accent & State Tokens + // ========================================================================= + // Palette selection + // + // Read once from Settings and validated, rather than assumed. Every token + // below resolves through root.palette, so switching Settings.theme at + // runtime repaints the shell without a restart -- which is what the radial's + // appearance picker writes. + // ========================================================================= - readonly property color accent: root.acidGreen - readonly property color accentGreen: root.acidGreen - readonly property color accentRed: root.warningRed - readonly property color pastelBlue: Qt.rgba(119/255, 181/255, 254/255, 1.0) - readonly property color pastelOrange: Qt.rgba(255/255, 150/255, 79/255, 1.0) + // Where an unusable configuration lands. Pine, not acid: it is the default + // the shell ships as, so falling back to it is invisible, and it is the + // palette every token was designed against. + readonly property string _fallbackTheme: "ctos-pine" - // SECTION Accent Primitives + readonly property var _registry: ({ + "ctos-pine": PinePalette.slots(), + "ctos-dark": AcidPalette.slots() + }) - readonly property color acidGreen: "#1BFD9C" - readonly property color active: root.acidGreen + // The 22 slots a palette must define. + // + // Checked rather than assumed because a missing slot does not fail. It + // resolves to undefined, Qt.color(undefined) is transparent black, and the + // result is an invisible border or unreadable text with nothing in the log. + // That is precisely how the Canvas widgets stayed green-on-grey through the + // last rebrand: they read hex strings that nothing validated. + readonly property var _requiredSlots: [ + "bg", "surface", "raised", "hover", "active", "selected", + "border", "borderMuted", "divider", + "text", "textDim", "textMuted", "textDisabled", "textInverse", + "accent", "status", "destructive", "warning", "cool", "love", + "destructiveDim", "radialBackdrop" + ] - // SECTION Experimental / New Colors - - // Proposing placeholder values for the "two new colors". - // These can be updated once the exact hex codes are provided. + readonly property string themeName: { + const requested = Settings.theme; - // SECTION Surface & Background Tokens + if (typeof requested !== "string" || requested.length === 0) { + console.warn("Theme: no theme set in Settings; using " + root._fallbackTheme + "."); + return root._fallbackTheme; + } - readonly property color background: root.gray800 - readonly property color backgroundBase: root.gray800 - readonly property color backgroundBright: root.gray700 - readonly property color backgroundDark: root.gray900 - readonly property color backgroundElevated: root.gray700 + if (!root._registry.hasOwnProperty(requested)) { + console.error("Theme: theme '" + requested + "' is not a known palette (have: " + + Object.keys(root._registry).join(", ") + "); falling back to " + root._fallbackTheme + "."); + return root._fallbackTheme; + } - // SECTION Dimensions & Layout Constants + const missing = []; + for (let i = 0; i < root._requiredSlots.length; ++i) { + const slot = root._requiredSlots[i]; + const v = root._registry[requested][slot]; + if (typeof v !== "string" || v.length === 0) { + missing.push(slot); + } + } + if (missing.length > 0) { + console.error("Theme: palette '" + requested + "' is missing " + missing.length + + " required slot(s) [" + missing.join(", ") + "]; falling back to " + root._fallbackTheme + "."); + return root._fallbackTheme; + } - readonly property int barHeight: 36 - readonly property int barPaddingHorizontal: 8 - readonly property int barPaddingVertical: 0 - readonly property color border: root.gray200 - readonly property color borderActive: root.acidGreen - readonly property color borderMuted: root.gray500 - readonly property int borderWidth: 1 - readonly property color connected: root.acidGreen - readonly property int cornerBracketArmLength: 7 - readonly property int cornerBracketMargin: 4 - readonly property int cornerBracketThickness: 1 - readonly property color ctosGray: root.gray200 - readonly property color destructive: root.warningRed - readonly property color divider: root.gray500 - readonly property int dividerBottomMargin: 1 - readonly property int dividerTopMargin: 1 - readonly property int dividerWidth: 1 - readonly property int durationDismiss: 0 - readonly property int durationFast: 50 - readonly property int durationNormal: 150 + return requested; + } - // Motion & Transitions (ms) - readonly property int durationScan: 100 - readonly property int durationSlow: 300 - readonly property color error: root.warningRed - readonly property var fontFamilies: ["Maple Mono", "JetBrainsMono Nerd Font", "JetBrains Mono", "Monaspace Neon", "CaskaydiaCove Nerd Font", "monospace"] + readonly property var palette: root._registry[root.themeName] + + // ========================================================================= + // Palette roots -- one token per slot, and the only colours defined here + // ========================================================================= + + readonly property color palBase: Qt.color(root.palette.bg) + readonly property color palSurface: Qt.color(root.palette.surface) + readonly property color palOverlay: Qt.color(root.palette.raised) + readonly property color palMuted: Qt.color(root.palette.borderMuted) + readonly property color palSubtle: Qt.color(root.palette.textMuted) + readonly property color palText: Qt.color(root.palette.text) + readonly property color palLove: Qt.color(root.palette.love) + readonly property color palDestructive: Qt.color(root.palette.destructive) + readonly property color palGold: Qt.color(root.palette.warning) + readonly property color palPine: Qt.color(root.palette.status) + readonly property color palFoam: Qt.color(root.palette.cool) + readonly property color palIris: Qt.color(root.palette.accent) + readonly property color palHighlightLow: Qt.color(root.palette.hover) + readonly property color palHighlightMed: Qt.color(root.palette.active) + readonly property color palSelected: Qt.color(root.palette.selected) + readonly property color palRadialBackdrop: Qt.color(root.palette.radialBackdrop) + + // Retired, and deliberately not re-added as slots: palRose (#EBBCBA) and + // palHighlightHigh (#5D5B68) had no call sites anywhere in the tree. Two + // fewer slots to keep honest. + + // Legacy raw names, kept because the role tokens below are defined in terms + // of them and because a handful of call sites outside this file still reach + // for navyDeep, navyBorder and textDim directly. New code should use a + // semantic token. + readonly property color blue: root.palFoam + readonly property color violet: root.palIris + readonly property color magenta: root.palIris + readonly property color green: root.palPine + + // The destructive slot, not the love one. + // + // These two are the same colour in ctos-pine (#EB6F92) and different in + // ctos-dark (#FC3E38 against #1BFD9C), which is what makes this worth being + // explicit about. Aliasing `red` to the love root -- as it was -- left + // destructive, error, danger and warningRed rendering accent green under + // acid, which is precisely the failure the palette split was supposed to + // rule out. `love` below reads the love slot directly instead. + readonly property color red: root.palDestructive + + // Surface ramp, darkest to lightest. + readonly property color navyDeep: root.palBase + readonly property color navySurface: root.palSurface + readonly property color navyCard: root.palSurface + readonly property color navyBorder: root.palHighlightMed + readonly property color navyElevated: root.palOverlay + readonly property color navyHover: root.palHighlightLow + readonly property color navyActive: root.palHighlightMed + readonly property color navySelected: root.palSelected + readonly property color textCool: root.palText + + // Named textDim, but it is the *border*-muted slot, not the text-muted one. + // + // The slots distinguish borderMuted (#6E6A86 in pine) from textMuted + // (#908CAA). Before the split, Theme.textDim and Theme.textMuted were both + // #6E6A86 -- the tree conflated the two roles. Mapping these tokens to the + // same-named slots would have quietly lightened every muted label in pine, + // so they keep the value they have always rendered with and the slots stay + // honest about which role they carry. Concretely, under ctos-pine: + // + // Theme.textDim -> palette.borderMuted (#6E6A86) + // Theme.textMuted -> palette.borderMuted (#6E6A86) + // Theme.textPrimaryDim -> palette.textDim (#C8C5DC) + // Theme.textPrimaryDimmer -> palette.textMuted (#908CAA) + // + // The naming is a wart and the obvious fix is to rename the slots to match + // the tokens rather than the reverse. That is a breaking change for + // Settings-adjacent config and was not done as part of the extraction. + readonly property color textDim: root.palMuted + + // ========================================================================= + // Accent and status roles + // + // Iris is the accent: active, selected, focused, today. + // Love is the red: destructive, error, danger. + // Pine is health: connected, charging, positive. + // Foam is the cool informational secondary. + // ========================================================================= + + readonly property color accentMagenta: root.magenta + readonly property color accentBlue: root.blue + readonly property color accentViolet: root.violet + readonly property color statusGreen: root.green + + readonly property color accent: root.accentMagenta + readonly property color active: root.accentMagenta + readonly property color textAccent: root.accentMagenta + readonly property color borderActive: root.accentMagenta + + // Retained name. Historically "acid green", but at every call site in the + // desktop tree it carried the generic accent meaning rather than a status + // one, so it resolves to the accent. Genuine status call sites use + // statusGreen instead. + readonly property color acidGreen: root.accentMagenta + readonly property color accentGreen: root.accentMagenta + + readonly property color connected: root.statusGreen + readonly property color success: root.statusGreen + readonly property color available: root.statusGreen + + readonly property color destructive: root.red + readonly property color error: root.red + readonly property color danger: root.red + readonly property color warningRed: root.red - // SECTION Monospace Typography + // Warm notice, distinct from the red above. Rosé Pine has both, and using + // love for warnings as well would collapse caution and failure into one + // signal. + readonly property color warning: root.palGold + // The identity dot inside the notch's hexagon. Same colour as destructive, + // deliberately: the palette's love is its warm pink, and spending it on the + // shell's mark rather than reusing a token whose meaning is "this failed" + // keeps the file's role vocabulary honest. + readonly property color love: root.palLove + + // Workspace selection, in pine. + // + // Deliberately not statusGreen even though both resolve to the same value. + // statusGreen means connected/charging/positive, and a selected workspace + // means none of those; reusing it would blur exactly the distinction this + // file exists to keep. + readonly property color workspaceActive: root.green + + + readonly property color accentRed: root.red + readonly property color pastelBlue: root.palFoam + readonly property color pastelOrange: root.palGold + + // ========================================================================= + // Surfaces + // ========================================================================= + + readonly property color pageBackground: root.navyDeep + readonly property color surfaceDeep: root.navyCard + readonly property color borderSubtle: root.navyBorder + + // Translucent panel surfaces. The desktop telemetry widgets float over the + // wallpaper, so their background is a scrim rather than an opaque fill. + readonly property color surfaceScrim: Qt.rgba(root.navySurface.r, root.navySurface.g, root.navySurface.b, 0.85) + + // String forms, for Canvas. + // + // Canvas drawing APIs (ctx.fillStyle / strokeStyle) take CSS colour strings, + // not QML colours, so these cannot be the colour tokens above. Each is a + // direct read of the slot its colour-token namesake resolves to -- they are + // the same value by construction, not by convention. + // + // CpuHexGrid and NetworkFlowMatrix are the reason this block exists at all. + // They draw with Canvas and used to hardcode the palette inline, which is + // why they stayed green-on-grey through the last rebrand while everything + // around them changed: nothing outside those two files referenced Theme. + readonly property string accentMagentaHex: root.palette.accent + readonly property string statusGreenHex: root.palette.status + readonly property string dangerHex: root.palette.destructive + readonly property string dangerDimHex: root.palette.destructiveDim + readonly property string textDimHex: root.palette.borderMuted + readonly property string gray300Hex: root.palette.textMuted + readonly property string gray700Hex: root.palette.hover + + // A CSS rgba() string for a colour at a given alpha. + // + // Canvas cannot be handed a QML colour, so every translucent draw has to + // become a string. These call sites used to spell the rgba() out by hand, + // which is how six of them ended up still carrying the pre-rebrand acid + // green: a hand-written rgba(27, 253, 156, ...) is invisible to grep for + // palette drift, because it never mentions a token at all. Routing them + // through here means an alpha draw follows the palette like any other. + function withAlpha(c, a) { + return "rgba(" + Math.round(c.r * 255) + ", " + + Math.round(c.g * 255) + ", " + + Math.round(c.b * 255) + ", " + a + ")"; + } + + // The notch body. Slightly translucent so the wallpaper reads through. + readonly property color notchSurface: Qt.rgba(root.navyDeep.r, root.navyDeep.g, root.navyDeep.b, 0.94) + + readonly property color background: root.navyDeep + readonly property color backgroundBase: root.navyDeep + readonly property color backgroundDark: root.navyDeep + readonly property color backgroundBright: root.navySurface + readonly property color backgroundElevated: root.navyElevated + + readonly property color surface: root.navySurface + readonly property color surfaceElevated: root.navyElevated + readonly property color surfaceHover: root.navyHover + readonly property color surfaceActive: root.navyActive + readonly property color surfaceSelected: root.navySelected + + // Borders and dividers. + // + // `border` and `divider` are separate slots that currently hold the same + // value in both palettes. They are kept apart because they mean different + // things -- a divider is a separator between rows, a border is the edge of a + // control -- and because the acid palette is where they would diverge if + // they were going to. + readonly property color border: Qt.color(root.palette.border) + readonly property color hairline: Qt.color(root.palette.border) + readonly property color divider: Qt.color(root.palette.divider) + readonly property color ctosGray: Qt.color(root.palette.border) + readonly property color borderMuted: Qt.color(root.palette.borderMuted) + + // ========================================================================= + // Typography + // ========================================================================= + + readonly property color textPrimary: root.textCool + // Neither palette has a second and third text step of its own, so these are + // text stepped toward muted rather than invented hues. See the note on + // root.textDim for why the two muted slots resolve as they do. + readonly property color textPrimaryDim: Qt.color(root.palette.textDim) + readonly property color textPrimaryDimmer: root.palSubtle + readonly property color textSecondary: root.textDim + readonly property color textMuted: root.textDim + readonly property color textDisabled: Qt.color(root.palette.textDisabled) + readonly property color textInverse: Qt.color(root.palette.textInverse) + readonly property color unavailable: root.textDim + + readonly property var fontFamilies: ["Maple Mono", "JetBrainsMono Nerd Font", "JetBrains Mono", "Monaspace Neon", "CaskaydiaCove Nerd Font", "monospace"] readonly property string fontFamily: "Maple Mono" readonly property string fontFamilyFallback: "monospace" readonly property string fontFamilyMonospace: "Maple Mono" - readonly property int fontSizeBody: 14 - // Typography Scale (pixel sizes) + // UI text is set in a proportional sans; the mono face is for data only + // (clocks, counters, throughput, timestamps). + // + // The design uses a proportional face for anything a person reads as a + // label and reserves the mono face for numbers that must line up in a + // column. Setting labels in mono is legible but reads as a terminal, which + // is the difference between the panel looking finished and looking like + // diagnostics output. + readonly property string fontFamilySans: "Inter" + + // Monospaced digits, so a percentage ticking from 9% to 10% does not shift + // the ring's label sideways every second. + readonly property string fontFamilyMonoNumeric: "Maple Mono" + + // Material Symbols, Outlined. Supersedes the classic Material Icons face, + // which had no hexagon to replace the shell's identity mark with. + // + // Outlined because the glyphs sit on a dark translucent panel at 16-20px, + // where the filled faces read as solid blocks. The Outlined, Rounded and + // Sharp faces are separate families, so the shape has to be named. + // + // This is a variable font (FILL, GRAD, opsz, wght). The defaults -- FILL 0, + // GRAD 0, wght 400 -- are what Outlined means, so no variation settings are + // needed to get the intended weight. + readonly property string fontFamilyMaterialIcons: "Material Symbols Outlined" + + readonly property int fontSizeBody: 14 readonly property int fontSizeCaption: 11 + readonly property int fontSizeMicro: 9 readonly property int fontSizeDisplay: 36 readonly property int fontSizeLarge: 18 readonly property int fontSizeQuery: 48 readonly property int fontSizeSegment: 16 readonly property int fontSizeSmall: 12 readonly property int fontSizeTitle: 22 + readonly property int fontWeightBold: 700 readonly property int fontWeightDemiBold: 600 readonly property int fontWeightLight: 300 readonly property int fontWeightMedium: 500 readonly property int fontWeightNormal: 400 - readonly property color gray100: "#CACACA" - readonly property color gray200: "#D9D9D9" - readonly property color gray300: "#C3C3C3" - readonly property color gray400: "#9E9E9E" - // SECTION Color Primitives (Grayscale Spectrum) + // ========================================================================= + // Neutral ramp + // + // An alias layer, not a palette root. These are the most widely used tokens + // in the tree and the worst-named: gray700 is a hover surface, gray900 is + // the page background, gray600 is a border, and only gray50/100/200 are + // actually text steps. They survive because call sites are everywhere, but + // each resolves to the slot that means the same thing -- which is what lets + // the neutral ladder follow the active palette instead of pinning it. + // ========================================================================= - readonly property color gray50: "#FFFFFF" - readonly property color gray500: "#7A7A7A" - readonly property color gray600: "#4A4A4A" - readonly property color gray700: "#202020" - readonly property color gray800: "#0E0E0E" - readonly property color gray900: "#080808" + readonly property color gray50: root.palText + readonly property color gray100: Qt.color(root.palette.textDim) + readonly property color gray200: root.textCool + readonly property color gray300: root.palSubtle + readonly property color gray400: root.palMuted + readonly property color gray500: root.textDim + readonly property color gray600: root.navyBorder + readonly property color gray700: root.navyHover + readonly property color gray800: root.navySurface + readonly property color gray900: root.navyDeep - // SECTION Borders, Dividers & Hairlines + // ========================================================================= + // Spacing + // ========================================================================= - readonly property color hairline: root.gray200 - readonly property int padding2Xl: 24 - readonly property int paddingLarge: 12 - readonly property int paddingMedium: 8 + readonly property int spacingNone: 0 + readonly property int spacingXs: 2 + readonly property int spacingSmall: 4 + readonly property int spacingMedium: 8 + readonly property int spacingLarge: 12 + readonly property int spacingXl: 16 + readonly property int spacing2Xl: 24 + + readonly property int paddingXs: 2 readonly property int paddingSmall: 4 + readonly property int paddingMedium: 8 + readonly property int paddingLarge: 12 readonly property int paddingXl: 16 + readonly property int padding2Xl: 24 - // Padding Scale - readonly property int paddingXs: 2 + // ========================================================================= + // Radii, borders, corners + // ========================================================================= - // Geometry - readonly property int radiusMedium: 8 readonly property int radiusNone: 0 - readonly property int radiusPill: 9999 readonly property int radiusSmall: 2 - readonly property color secondary: root.gray500 - readonly property int spacing2Xl: 24 - readonly property int spacingLarge: 12 - readonly property int spacingMedium: 8 + readonly property int radiusMedium: 8 + readonly property int radiusLarge: 12 + readonly property int radiusPill: 9999 - // Spacing Scale - readonly property int spacingNone: 0 - readonly property int spacingSmall: 4 - readonly property int spacingXl: 16 - readonly property int spacingXs: 2 - readonly property color success: root.acidGreen - readonly property color surface: root.gray700 - readonly property color surfaceActive: "#333333" - readonly property color surfaceElevated: root.gray700 - readonly property color surfaceHover: "#2A2A2A" - readonly property color surfaceSelected: "#1A2E24" - readonly property color textAccent: root.acidGreen - readonly property color textDisabled: root.gray600 - readonly property color textInverse: root.gray800 - readonly property color textMuted: root.gray500 - - // SECTION Text Tokens - - readonly property color textPrimary: root.gray50 - readonly property color textPrimaryDim: root.gray100 - readonly property color textPrimaryDimmer: root.gray300 - readonly property color textSecondary: root.gray500 - readonly property color unavailable: root.gray500 - readonly property color warning: root.warningRed - readonly property color warningRed: "#FC3E38" + readonly property int borderWidth: 1 + readonly property int borderWidthAccent: 2 + + readonly property int cornerBracketArmLength: 7 + readonly property int cornerBracketMargin: 4 + readonly property int cornerBracketThickness: 1 + + readonly property int dividerWidth: 1 + readonly property int dividerTopMargin: 1 + readonly property int dividerBottomMargin: 1 + + // ========================================================================= + // Notch geometry support + // ========================================================================= + + // Vertical breathing room around the notch inside its host window. The host + // window is anchored with a top margin, so this padding is what separates + // the notch's bottom edge from any surface positioned below it. + readonly property int notchHostPadding: 6 + + // The notch's resting height is a design decision, not a user setting, so it + // lives here rather than in Settings. It used to read Settings.barHeight, + // which coupled the pill's proportions to a slider in the radial settings + // (range 28-48) and to whatever barHeight the user's config last persisted. + // Settings.barHeight still governs the flat bar and the desktop widgets; the + // notch no longer follows it. + // + // This is only a floor, not the resting width. The pill measures itself from + // its content now that the clock is centred on it -- see LivingNotch's + // compactWidth, which sizes to the wider of the two side groups plus the + // clock band. The fixed 360 this replaces left dead space at both ends once + // the clock moved out of the row's flow. The floor stops the pill collapsing + // to nothing when a session has very few workspaces or a very short battery + // string, which would otherwise make the notch visibly twitch as that content + // changed. + readonly property int notchWidthCompactMin: 260 + + // Heights and widths are set as a pair against the target's proportions: + // 8.5:1 idle and 6.6:1 expanded. At the old 470x30 and 620x60 the pill was + // 15.7:1 and 10.3:1, a letterbox rather than a capsule. + readonly property int notchHeightCompact: 42 + readonly property int notchHeightExpanded: 72 + + // CCC accordion header height. Deliberately independent of the notch + // height: an accordion header is not a bar. + readonly property int accordionHeaderHeight: 36 + + // CCC body. Width is clamped per-output at runtime. + readonly property int commandCenterWidth: 680 + + // Below this the two columns stop being readable, so the clamp in + // AmbientBar floors here rather than letting the gutter eat the content. + readonly property int commandCenterMinWidth: 420 + + // The CCC is a dropdown off the notch, so it cannot run to the bottom of + // the screen. Content past this scrolls instead. + // + // Raised from 620 to give the left column room to breathe. It had almost no + // slack left: adding a top gap to card content pushed the column past this + // cap and sliced System Status in half, and the only ways to avoid that were + // to give the space back or take it from somewhere else. At 700 the panel + // still clears the bottom of a 900px-tall screen by a wide margin, with the + // notch above it -- this is a fixed constant rather than a fraction of the + // screen, so a much shorter display would want it derived instead. + readonly property int commandCenterMaxHeight: 700 + + readonly property int commandCenterColumnGutter: 12 + + // Card chrome. The section radius was defined here from the start and then + // never applied -- the CCC was still drawing its cards at radiusSmall. + readonly property int commandCenterSectionRadius: 12 + readonly property int cardPadding: 12 + readonly property int cardIconTile: 24 + readonly property int cardGap: 10 + + // Hovered card border. Cards lift by their border rather than by their fill, + // so the hover reads without the tile shifting colour under the text. + readonly property color borderHover: root.accentBlue + + readonly property int calendarWidth: 1080 + readonly property int calendarHeight: 600 + + readonly property int barPaddingHorizontal: 8 + readonly property int barPaddingVertical: 0 + + // ========================================================================= + // Motion + // ========================================================================= + + readonly property int durationDismiss: 0 + readonly property int durationFast: 50 + readonly property int durationNormal: 150 + readonly property int durationScan: 100 + readonly property int durationSlow: 300 + + readonly property real springStiffness: 4.0 + readonly property real springDamping: 0.3 + readonly property real springMass: 0.8 + + readonly property color secondary: root.gray500 } diff --git a/shell/desktop/core/palettes/AcidPalette.js b/shell/desktop/core/palettes/AcidPalette.js new file mode 100644 index 00000000..3424f168 --- /dev/null +++ b/shell/desktop/core/palettes/AcidPalette.js @@ -0,0 +1,84 @@ +.pragma library + +// ============================================================================= +// ctos-dark -- the acid palette. +// +// The wire name is "ctos-dark" and it is a historical accident: this palette +// predates the navy rebrand, when it was the only one and "dark" meant "the +// dark theme". It is not the darker of the two -- ctos-pine's base is +// #13111E against this one's #0E0E0E, which is very slightly lighter, and pine +// is the one built from a named upstream palette. Both are dark. The name is +// kept because Settings.theme and the persisted config already use it, and +// renaming it would strand every existing settings file. +// +// Accent is #1BFD9C. Note that Theme.acidGreen resolves to this palette's +// `accent` slot under both palettes, which is why that token's name is a +// misnomer in ctos-pine -- where it resolves to lavender. The token is kept +// because it has many call sites; the lie is in the name, not the value. +// ============================================================================= + +function slots() { + return { + // Surfaces, deepest to lightest. Pure neutral greys, unlike pine's + // blue-shifted ladder. + "bg": "#0E0E0E", + "surface": "#202020", + "raised": "#333333", + "hover": "#2A2A2A", + "active": "#333333", + + // The one slot that is not a grey: a desaturated green tint, so a + // selected row reads as selected rather than merely brighter. Pine + // cannot afford this because its `selected` is also its `raised`, and + // tinting it would drag every raised surface green with it. + "selected": "#1A2E24", + + // Lines. border and divider were the deliberate fix here: both were a + // dark value that vanished against the old near-black surfaces, and both + // landed on #3F3D4A. + "border": "#3F3D4A", + "borderMuted": "#7A7A7A", + "divider": "#3F3D4A", + + // Text. + "text": "#FFFFFF", + "textDim": "#CACACA", + "textMuted": "#9E9E9E", + "textDisabled": "#4A4A4A", + "textInverse": "#0E0E0E", + + // Accent and status. + // + // accent and status are both #1BFD9C and deliberately so: in this + // palette the accent *is* the status colour, which is where the token + // named acidGreen stopped meaning anything status-related. + "accent": "#1BFD9C", + "status": "#1BFD9C", + "destructive": "#FC3E38", + "warning": "#FF964F", + "cool": "#66B2B2", + "love": "#1BFD9C", + + // #FC3E38 mixed 40% toward bg. + // + // No acid-era equivalent existed for this -- destructiveDim was + // introduced with the navy rebrand, where love already had a dimmed + // partner. It is derived rather than picked so that it tracks + // destructive and bg: change either and this follows by the same ratio. + "destructiveDim": "#6D211F", + + // The radial settings surface's backdrop. Deeper and more saturated than + // pine's bronze relative to their `bg`, because this palette's bg is + // already near-black (#0E0E0E) and a tint that reads as bronze against + // pine's blue-black would disappear here. Same job as the slot above: + // dark enough for light text and for the grid to sit over. + "radialBackdrop": "#EAF6F2" + }; +} + +function notes() { + return { + "selected": "desaturated green tint, not a grey -- the only tinted surface in this palette", + "destructiveDim": "#FC3E38 at 40% toward #0E0E0E", + }; +} diff --git a/shell/desktop/core/palettes/PinePalette.js b/shell/desktop/core/palettes/PinePalette.js new file mode 100644 index 00000000..989b64d5 --- /dev/null +++ b/shell/desktop/core/palettes/PinePalette.js @@ -0,0 +1,108 @@ +.pragma library + +// ============================================================================= +// ctos-pine -- the default palette. +// +// Provenance: a Rosé Pine derivative taken from the nvchad/base46 theme +// (M.base_30 and M.base_16), not the official Rosé Pine release. It reads deeper +// and more muted than official Main -- its base is #13111e against Main's +// #191724 -- which is the "darker" look this was after. +// +// Note the official variants, for reference: Main is the darkest of the three +// (base relative luminance 0.0095, versus Moon's 0.0171). Moon is lighter, not +// darker. So this palette is not "Rosé Pine Moon"; it is a third, darker thing, +// and the values come from base46. +// +// Every value here is verbatim from the single hardcoded palette this file +// replaces, so selecting ctos-pine must render pixel-identically to the build +// before Theme gained a palette. That constraint is why several Theme tokens +// resolve to a slot whose name does not match theirs -- see the note on +// Theme.textMuted. +// ============================================================================= + +// The 21 slots. Values are strings, not colours: Theme.qml wraps them in +// Qt.color, and Canvas drawing APIs need the string form anyway. +// +// Two pairs are deliberately equal rather than accidentally so. `active` and +// `raised` are both #262431 here but differ in acid (#333333 each, but selected +// is #1A2E24); `border`, `divider` and `textDisabled` are all #3F3D4A in both +// palettes. They are separate slots because they are separate roles that +// happened to agree, and the acid palette is where they start to diverge. +function slots() { + return { + // Surfaces, deepest to lightest. + "bg": "#13111E", + "surface": "#191724", + "raised": "#262431", + "hover": "#2E2C39", + "active": "#3F3D4A", + "selected": "#262431", + + // Lines. + "border": "#3F3D4A", + "borderMuted": "#6E6A86", + "divider": "#3F3D4A", + + // Text. textDim/textMuted are stepped toward muted rather than invented + // hues: Rosé Pine has no second and third text steps. + "text": "#E0DEF4", + "textDim": "#C8C5DC", + "textMuted": "#908CAA", + "textDisabled": "#3F3D4A", + "textInverse": "#13111E", + + // Accent and status. + // + // Iris is the accent: active, selected, focused. Spending the palette's + // `love` on the accent would leave destructive actions with nothing to + // distinguish them -- the Poweroff button and the focus ring would be + // the same colour. Love is health-failing instead, Pine is health, Foam + // is the cool informational secondary, Gold is the warm notice. These + // are different intentions and must not be interchanged. + "accent": "#C4A7E7", + "status": "#31748F", + "destructive": "#EB6F92", + "warning": "#F6C177", + "cool": "#8BBEC7", + "love": "#EB6F92", + + // Love dimmed toward base, for the muted half of a destructive pair. + "destructiveDim": "#5E3246", + + // The radial settings surface's own backdrop, which is not the page + // background: the radial covers the screen while it is open and reads as + // its own place, so it gets a palette-specific floor rather than sharing + // `bg` with the desktop underneath. + // + // A deep bronze, not literal #F6C177. The panel and all its text sit on + // this, and the grid is drawn over it, so the value has to stay dark + // enough for light text -- roughly 12:1 against `text`. + "radialBackdrop": "#EFF1F5" + }; +} + +// Provenance for slots that are not a flat reading of base46, kept here so the +// reasoning travels with the value. +function notes() { + return { + "surface": "base46 black / base00", + "raised": "base46 one_bg", + "hover": "base46 line", + "active": "base46 grey", + "borderMuted": "base03", + "textMuted": "base04", + "text": "base46 white / base05", + "destructive": "base46 red / base08", + "warning": "base46 yellow / base09", + "status": "base0B", + "cool": "base46 blue", + "accent": "base46 purple / base0D", + + // Deliberately not from base46's base_30: green (#ABE9B3) and + // vibrant_green (#b5f3bd). Those are nvim-tree highlight colours; at + // 95%-in-a-ring sizes they are far too bright for a status readout, so + // status stays on base0B. Also unused: baby_pink, pink, sun, teal, cyan, + // nord_blue, statusline_bg, one_bg2, one_bg3, grey_fg, grey_fg2, + // pmenu_bg, folder_bg -- several exist only to serve neovim's own UI. + }; +} diff --git a/shell/desktop/services/CalendarService.qml b/shell/desktop/services/CalendarService.qml new file mode 100644 index 00000000..2f38f596 --- /dev/null +++ b/shell/desktop/services/CalendarService.qml @@ -0,0 +1,417 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import "../core" + +// Read-only calendar feed built from .ics files in a local directory. +// +// Deliberately not a CalDAV client. The source is a plain directory of iCalendar +// files, which keeps parsing and the card's data shape independent of whichever +// sync mechanism eventually fills that directory -- vdirsyncer, khard, a +// Thunderbird profile, or files dropped in by hand all produce the same thing. +// +// Nothing is written back. The card is an agenda view, not an editor. +// +// Files are read with Process rather than FileView. FileView cannot enumerate a +// directory, so it would need one instance per file created from a delegate, and +// a FileView in an Instantiator delegate never fired onLoaded here despite being +// handed a valid path -- the scan reported the file as found while the card stayed +// empty. Reading through a shell loop sidesteps that entirely, and comparing +// mtimes gives change detection that FileView's watchChanges would have provided. +Singleton { + id: root + + // ========================================================================= + // Public Interface + // ========================================================================= + + // Where the .ics files live. XDG_DATA_HOME/calendar is where vdirsyncer and + // khard put their collections by default, so a synced setup needs no config. + readonly property string calendarDir: { + const base = Quickshell.env("XDG_DATA_HOME"); + return (base && base.length > 0 ? base : Quickshell.env("HOME") + "/.local/share") + "/calendar"; + } + + // Parsed events, sorted by start time then summary. Rebuilt wholesale on + // every read rather than mutated in place: consumers bind to this array, and + // reassigning is what makes them re-evaluate. + readonly property var events: _sorted + + // True once at least one listing has completed, so the card can tell + // "no events yet" apart from "not looked yet". + readonly property bool loaded: _scanned + + // Shown in the card when there is nothing to list. + readonly property string emptyReason: { + if (!root.loaded) + return qsTr("Reading calendar…"); + if (root._fileCount === 0) + return qsTr("No .ics files in ") + root.calendarDir; + return qsTr("No events on this day"); + } + + // Number of .ics files currently contributing events. + readonly property int calendarCount: _fileCount + + // Bumped whenever events change, for views that would rather depend on a + // version than on the array's identity. + readonly property int revision: _revision + + // ========================================================================= + // Queries + // ========================================================================= + + // Events overlapping the given day, in start order. + // + // Overlap rather than "starts on the day": a multi-day event, or one that + // begins at 23:00, still belongs on the timeline of the day it runs through, + // which is what every calendar UI shows. + function eventsForDay(date: var): var { + const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime(); + + return _sorted.filter(ev => ev.end > dayStart && ev.start < dayEnd); + } + + // Day-of-month -> true, for the month grid's event dots. + // + // Built per call because the grid only redraws the month it displays, and a + // cached month map would need invalidating on every navigation. + function eventDaysInMonth(year: int, month: int): var { + const marks = {}; + const monthStart = new Date(year, month, 1).getTime(); + const monthEnd = new Date(year, month + 1, 1).getTime(); + + for (let i = 0; i < _sorted.length; ++i) { + const ev = _sorted[i]; + if (ev.end <= monthStart || ev.start >= monthEnd) + continue; + + // Mark every day the event touches, so a multi-day entry dots each + // date rather than only its first. + const cursor = new Date(Math.max(ev.start, monthStart)); + cursor.setHours(0, 0, 0, 0); + while (cursor.getTime() < Math.min(ev.end, monthEnd)) { + if (cursor.getMonth() === month && cursor.getFullYear() === year) + marks[cursor.getDate()] = true; + cursor.setDate(cursor.getDate() + 1); + } + } + return marks; + } + + // The next event starting at or after the reference time, or null. + function nextEvent(from: var): var { + const ref = from instanceof Date ? from.getTime() : Date.now(); + for (let i = 0; i < _sorted.length; ++i) { + if (_sorted[i].start >= ref) + return _sorted[i]; + } + return null; + } + + // ========================================================================= + // Internal State + // ========================================================================= + + readonly property var _sorted: _rebuild() + property int _revision: 0 + property bool _scanned: false + + // path -> array of events parsed from that file. + property var _byFile: ({}) + + // Derived from the buckets rather than tracked separately, so the count can + // never disagree with what was actually parsed. + readonly property int _fileCount: Object.keys(_byFile).length + + // ========================================================================= + // Reading + // + // One shell loop over every .ics file in the directory, rather than one + // process per file or a FileView per file. Each file is introduced by a + // marker so the combined output splits back into per-file buckets. + // + // The directory is re-read on every tick instead of being watched. Deriving a + // change signal from mtimes was tried first and rejected: `ls -1 + // --time-style=+%s` prints only the name, because --time-style has no effect + // unless a time field is actually displayed, so every entry looked malformed + // and nothing was ever read. Re-reading a handful of small files every 30s + // costs a few milliseconds and needs no such subtlety. + // ========================================================================= + + Process { + id: readProc + command: [ + "sh", "-c", + 'for f in "$1"/*.ics; do [ -f "$f" ] || continue; printf "%s\\n" "###CTOSICS:$f"; cat "$f"; printf "\\n"; done', + "ctos-calendar-read", root.calendarDir + ] + workingDirectory: "/" + running: false + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + root._scanned = true; + + const raw = text; + const buckets = {}; + const parts = raw.split("###CTOSICS:"); + + // parts[0] is whatever preceded the first marker; the rest are + // "path\n" pairs. + for (let i = 1; i < parts.length; ++i) { + const chunk = parts[i]; + const newline = chunk.indexOf("\n"); + if (newline < 0) + continue; + const path = chunk.substring(0, newline).trim(); + buckets[path] = root._parseIcs(chunk.substring(newline + 1)); + } + + root._byFile = buckets; + root._touch(); + } + } + } + + Timer { + id: readTimer + // No filesystem watch, so this is the refresh. Frequent enough to feel + // live after a sync, cheap enough to ignore. + interval: 30000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + // Still waiting on the previous run: skip rather than pile up forks. + if (!readProc.running) + readProc.running = true; + } + } + + // Force the derived arrays to be recomputed. + function _touch(): void { + _revision++; + } + + function _rebuild(): var { + const all = []; + for (const path in _byFile) { + const list = _byFile[path]; + for (let i = 0; i < list.length; ++i) + all.push(list[i]); + } + all.sort((a, b) => (a.start - b.start) || a.summary.localeCompare(b.summary)); + return all; + } + + // ========================================================================= + // iCalendar Parsing + // + // Scope is deliberately narrow: the properties the card renders, from VEVENT + // blocks only. VTIMEZONE components are skipped rather than interpreted -- + // TZID names are resolved by the system zone database instead, which is + // correct for the overwhelming majority of real-world zones and avoids + // shipping a timezone compiler. + // ========================================================================= + + function _parseIcs(raw: string): var { + if (!raw || raw.length === 0) + return []; + + // RFC 5545 folds long lines: a newline followed by a single space or tab + // continues the previous line. Unfolding first keeps the property parsing + // below to one line per property. + const text = raw.replace(/\r\n/g, "\n").replace(/\n[ \t]/g, ""); + const lines = text.split("\n"); + + const out = []; + let current = null; + let inEvent = false; + + for (let i = 0; i < lines.length; ++i) { + const line = lines[i]; + if (line.length === 0) + continue; + + const colon = line.indexOf(":"); + if (colon < 0) + continue; + + const rawName = line.substring(0, colon); + const value = line.substring(colon + 1); + + // "DTSTART;TZID=Europe/Berlin:20261001T090000" -> name + params. + const semi = rawName.indexOf(";"); + const name = (semi < 0 ? rawName : rawName.substring(0, semi)).toUpperCase(); + const params = semi < 0 ? "" : rawName.substring(semi + 1); + + if (name === "BEGIN" && value.trim().toUpperCase() === "VEVENT") { + inEvent = true; + current = { summary: "", location: "", description: "", categories: "", uid: "" }; + continue; + } + if (name === "END" && value.trim().toUpperCase() === "VEVENT") { + if (inEvent && current) { + const built = _finishEvent(current); + if (built !== null) + out.push(built); + } + inEvent = false; + current = null; + continue; + } + if (!inEvent || !current) + continue; + + switch (name) { + case "SUMMARY": + current.summary = _unescape(value); + break; + case "LOCATION": + current.location = _unescape(value); + break; + case "DESCRIPTION": + current.description = _unescape(value); + break; + case "CATEGORIES": + current.categories = _unescape(value); + break; + case "UID": + current.uid = value.trim(); + break; + case "DTSTART": + current.rawStart = value.trim(); + current.startParams = params; + break; + case "DTEND": + current.rawEnd = value.trim(); + current.endParams = params; + break; + case "DURATION": + // Only honoured when there is no DTEND; RFC 5545 says DTEND wins. + current.rawDuration = value.trim(); + break; + } + } + + return out; + } + + function _parseDuration(value: string): var { + // P[n]W and P[n]DT[n]H[n]M[n]S. + const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/ + .exec(value.trim().toUpperCase()); + if (!m) + return 0; + const sign = m[1] === "-" ? -1 : 1; + const weeks = m[2] ? +m[2] : 0; + const days = m[3] ? +m[3] : 0; + const hours = m[4] ? +m[4] : 0; + const minutes = m[5] ? +m[5] : 0; + const seconds = m[6] ? +m[6] : 0; + return sign * (((weeks * 7 + days) * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000; + } + + function _finishEvent(ev: var): var { + if (!ev.rawStart) + return null; // An event with no start cannot be placed on a timeline. + + const start = _parseDateTime(ev.rawStart, ev.startParams); + if (start === null) + return null; + + let end = null; + if (ev.rawEnd) + end = _parseDateTime(ev.rawEnd, ev.endParams); + else if (ev.rawDuration) { + // _parseDuration is annotated `: var`, not `: number`, despite always + // returning a Number. Qt 6.9's strict checking treats a `number` return + // annotation on a QML method as void and logs "3600000 should be + // coerced to void because the function called is insufficiently + // annotated" at the call site. The value is retained today, so this is + // cosmetic, but it is emitted as an error and will not be in future + // versions. + const durationMs = _parseDuration(ev.rawDuration); + end = new Date(start.getTime() + durationMs); + } + else + end = new Date(start.getTime()); // DTSTART-only: instantaneous. + + return { + uid: ev.uid, + summary: ev.summary.length > 0 ? ev.summary : qsTr("(No title)"), + location: ev.location, + description: ev.description, + category: ev.categories.split(",")[0].trim(), + start: start.getTime(), + end: end.getTime(), + allDay: _isAllDay(ev.rawStart, ev.startParams) + }; + } + + // DTSTART;VALUE=DATE:20261001 -> all-day, local midnight + // DTSTART:20261001T090000Z -> UTC + // DTSTART;TZID=Asia/Kolkata:20261001T090000 -> local time in that zone + // DTSTART:20261001T090000 -> floating, treated as local + function _parseDateTime(value: string, params: string): var { + if (value.length < 8) + return null; + + const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(value); + if (dateOnly) { + const d = new Date(+dateOnly[1], +dateOnly[2] - 1, +dateOnly[3], 0, 0, 0, 0); + return isNaN(d.getTime()) ? null : d; + } + + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(value); + if (!m) + return null; + + const year = +m[1], month = +m[2] - 1, day = +m[3]; + const hour = +m[4], minute = +m[5], second = +m[6]; + + // Trailing Z is UTC. Constructed through Date.UTC so the local zone of + // the machine running the shell does not shift the instant. + if (m[7] === "Z") { + const utc = new Date(Date.UTC(year, month, day, hour, minute, second)); + return isNaN(utc.getTime()) ? null : utc; + } + + // A TZID names a zone this service does not carry a database for. Falling + // back to local time is the least surprising outcome for the common case + // where the shell runs in the user's own zone, and the alternative -- + // shipping a full tz database -- is a large dependency for a margin of + // correctness on events in other zones. + return new Date(year, month, day, hour, minute, second); + } + + function _isAllDay(value: string, params: string): bool { + if (/VALUE=DATE/i.test(params || "")) + return true; + // A bare YYYYMMDD with no time component is an all-day date. + return /^\d{8}$/.test(value); + } + + // RFC 5545 TEXT escaping: \n \N \, \; \\. + function _unescape(value: string): string { + let out = ""; + for (let i = 0; i < value.length; ++i) { + const ch = value[i]; + if (ch !== "\\" || i + 1 >= value.length) { + out += ch; + continue; + } + const next = value[++i]; + if (next === "n" || next === "N") + out += "\n"; + else + out += next; + } + return out.trim(); + } +} \ No newline at end of file diff --git a/shell/desktop/services/CompositorService.qml b/shell/desktop/services/CompositorService.qml index 75b9c287..90e89991 100644 --- a/shell/desktop/services/CompositorService.qml +++ b/shell/desktop/services/CompositorService.qml @@ -33,6 +33,56 @@ Singleton { // Internal focused workspace tracking for fallback and Niri operation property int _localFocusedWorkspaceId: 1 + // Niri's real workspace ids, from `niri msg -j workspaces`. + property var _niriWorkspaceIds: [] + property string _niriJsonBuffer: "" + + function _applyNiriWorkspaces(text) { + let parsed; + try { + parsed = JSON.parse(text); + } catch (e) { + // A partial read, or niri not up yet. The next poll replaces it. + return false; + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + return true; + } + + const ids = []; + let focused = -1; + + for (let i = 0; i < parsed.length; ++i) { + const ws = parsed[i]; + if (!ws) { + continue; + } + if (typeof ws.id === "number") { + ids.push(ws.id); + } + if (ws.is_focused === true) { + focused = ws.id; + } + } + + if (ids.length > 0) { + ids.sort(function (a, b) { return a - b; }); + root._niriWorkspaceIds = ids; + } + + // Only trust the shell's own optimistic update once niri has told us + // something different. Without this the notch kept showing the workspace + // last clicked in the notch, and ignored every switch made with a + // keybinding or a gesture. + if (focused > 0 && focused !== root._localFocusedWorkspaceId) { + root._localFocusedWorkspaceId = focused; + root.workspaceChanged(focused); + } + + return true; + } + // Focused workspace ID (defaults to 1 if available, or -1 when unavailable) readonly property int focusedWorkspaceId: { if (root._adapter && root._adapter.available && !root.isNiri) { @@ -44,8 +94,16 @@ Singleton { } // Reactive list of workspace objects or IDs - // When _adapter is unavailable or when running under Niri, provides a deterministic stable list of 5 workspaces (ids 1..5) + // + // Under Niri this used to be a hardcoded [1,2,3,4,5], which is a guess: niri + // creates workspaces on demand and their ids are not contiguous, so the + // notch drew five pills for a session that had two. The real list comes from + // `niri msg -j workspaces` below, with the fixed list only as a fallback for + // before the first poll lands. readonly property var workspaces: { + if (root.isNiri && root._niriWorkspaceIds.length > 0) { + return root._niriWorkspaceIds; + } if (root._adapter && root._adapter.available && !root.isNiri) { const list = root._adapter.workspaces; if (list && list.length > 0) { @@ -82,6 +140,46 @@ Singleton { running: false } + // Niri has no D-Bus or socket the shell can subscribe to for workspace + // changes, so the state is polled. Cheap: one `niri msg -j workspaces` per + // second, and only while niri is the compositor. + Process { + id: niriWorkspacePoll + command: ["niri", "msg", "-j", "workspaces"] + running: false + + // niri emits compact JSON on a single line, so one chunk is normally the + // whole document. Parsed opportunistically: a partial read fails to parse + // and the buffer keeps filling, and the poll clears it either way. + // + // No stream-finished signal to hang this off -- SplitParser in + // Quickshell 0.3.1 only has onRead, and assigning onStreamFinished fails + // the whole load with "Cannot assign to non-existent property", which + // takes every service that imports this one down with it. + stdout: SplitParser { + onRead: data => { + root._niriJsonBuffer += data; + if (root._applyNiriWorkspaces(root._niriJsonBuffer)) { + root._niriJsonBuffer = ""; + } + } + } + } + + Timer { + interval: 1000 + repeat: true + running: root.isNiri + triggeredOnStart: true + + onTriggered: { + if (!niriWorkspacePoll.running) { + root._niriJsonBuffer = ""; + niriWorkspacePoll.running = true; + } + } + } + // ========================================================================= // Public Methods // ========================================================================= diff --git a/shell/desktop/services/NetworkService.qml b/shell/desktop/services/NetworkService.qml index fc638e44..896b93db 100644 --- a/shell/desktop/services/NetworkService.qml +++ b/shell/desktop/services/NetworkService.qml @@ -31,6 +31,16 @@ Singleton { readonly property bool isEthernet: connectionType === "ethernet" readonly property bool isWifi: connectionType === "wifi" + // Wireless-scoped state, computed independently of the wired-first priority + // above. connectionType answers "which transport is serving traffic right + // now", which is the right question for a generic status indicator but the + // wrong one for a card titled "Wi-Fi" whose body lists wireless networks: + // with enp2s0 up, connectionType resolves to "ethernet" and a Wi-Fi card + // reading it would announce "Ethernet" above a list of Wi-Fi networks. + // Callers that care about the radio specifically must use these instead. + property bool wifiConnected: false + property string wifiNetworkName: "" + // Active network identifier (SSID or interface ID); explicit "--N/A--" when offline property string networkName: "--N/A--" @@ -80,7 +90,67 @@ Singleton { // Reactive State Evaluation // ========================================================================= + // Wireless-only evaluation. Scans for an associated Wi-Fi interface and its + // active network, deliberately ignoring wired devices, so the answer stays + // correct on a machine that is also plugged in. Kept separate from + // _evaluateNetworkState rather than folded into it so the wired-first + // transport ladder below is not perturbed. + function _evaluateWifiState(): void { + if (!root.available || !Networking.devices) { + _applyWifiState(false, ""); + return; + } + + const devices = Networking.devices.values; + if (!devices || devices.length === 0) { + _applyWifiState(false, ""); + return; + } + + for (let i = 0; i < devices.length; i++) { + const dev = devices[i]; + if (!dev || dev.type !== DeviceType.Wifi || !dev.connected) { + continue; + } + + let activeSsid = ""; + if (dev.networks && dev.networks.values) { + for (let j = 0; j < dev.networks.values.length; j++) { + const net = dev.networks.values[j]; + if (net && net.connected) { + activeSsid = net.name; + break; + } + } + } + if (!activeSsid && dev.name) { + activeSsid = dev.name; + } + + _applyWifiState(true, activeSsid); + return; + } + + _applyWifiState(false, ""); + } + + function _applyWifiState(connected: bool, ssid: string): void { + // sanitizeName maps empty input to "--N/A--", which would then be + // rendered as a literal network name. An associated interface with no + // resolvable SSID stays empty here so the card can fall back to a + // generic "Connected" rather than displaying the placeholder. + const clean = connected && ssid !== "" ? root.sanitizeName(ssid) : ""; + if (root.wifiConnected !== connected || root.wifiNetworkName !== clean) { + root.wifiConnected = connected; + root.wifiNetworkName = clean; + } + } + function _evaluateNetworkState(): void { + // Refresh the wireless-scoped view first so it is never stale by the + // time the transport ladder below returns. + _evaluateWifiState(); + if (!root.available || !Networking.devices) { _applyDisconnectedState(); return; @@ -238,8 +308,100 @@ Singleton { command: ["nmcli", "dev", "wifi", "rescan"] running: false - onExited: function(exitCode) { + stdout: StdioCollector { id: rescanOut; waitForEnd: true } + stderr: StdioCollector { id: rescanErr; waitForEnd: true } + + onExited: function (exitCode) { root.isScanning = false; + if (exitCode !== 0) { + const msg = (rescanErr.text || "").trim(); + root.lastError = msg.length > 0 ? msg : ("Network scan failed (nmcli exit " + exitCode + ")"); + console.warn("[NetworkService] rescan failed:", root.lastError); + } + root._evaluateNetworkState(); + root._scheduleRebuild(); + } + } + + // Joining a network NetworkManager has not seen yet. + // + // Quickshell's native API can only carry a PSK for a Network object it + // already knows about, so a first-time join has to go through nmcli. The + // secret is delivered on stdin via `nmcli --ask`, which prompts for the + // missing psk, rather than being placed in argv where any process could + // read it from /proc//cmdline for the lifetime of the call. + // + // stderr is captured so a failed join is observable; the previous + // execDetached form discarded it and left `isConnecting` set forever. + Process { + id: joinProcess + + property string pendingSecret: "" + property string pendingSsid: "" + + command: ["nmcli", "--ask", "--colors", "no", "device", "wifi", "connect", root._joinTargetSsid] + stdinEnabled: true + running: false + + stdout: StdioCollector { id: joinOut; waitForEnd: true } + stderr: StdioCollector { id: joinErr; waitForEnd: true } + + onStarted: { + if (pendingSecret.length > 0) { + write(pendingSecret + "\n"); + } + } + + onExited: function (exitCode) { + if (exitCode !== 0) { + connectionTimeoutTimer.stop(); + root.connectingSsid = ""; + const err = (joinErr.text || "").trim(); + root.lastError = err.length > 0 + ? err + : ("Failed to connect to " + pendingSsid + " (nmcli exit " + exitCode + ")"); + console.warn("[NetworkService] join failed:", root.lastError); + } else { + const out = (joinOut.text || "").trim(); + if (out) { + console.log("[NetworkService]", out); + } + } + pendingSecret = ""; + pendingSsid = ""; + root._evaluateNetworkState(); + root._scheduleRebuild(); + } + } + + property string _joinTargetSsid: "" + + function _joinViaNmcli(ssid: string, secret: string): void { + if (joinProcess.running) { + root.lastError = "A connection attempt is already in progress"; + return; + } + root._joinTargetSsid = ssid; + joinProcess.pendingSsid = ssid; + joinProcess.pendingSecret = secret; + joinProcess.running = true; + } + + Process { + id: deleteProcess + property string pendingSsid: "" + + command: ["nmcli", "--colors", "no", "connection", "delete", deleteProcess.pendingSsid] + running: false + + stderr: StdioCollector { id: deleteErr; waitForEnd: true } + onExited: function (exitCode) { + if (exitCode !== 0) { + const err = (deleteErr.text || "").trim(); + console.warn("[NetworkService] forget failed for", pendingSsid, ":", + err.length > 0 ? err : ("nmcli exit " + exitCode)); + } + pendingSsid = ""; root._evaluateNetworkState(); root._scheduleRebuild(); } @@ -410,11 +572,13 @@ Singleton { } } } else { - // Fallback via discrete allowlisted argument array + // First-time join: NetworkManager has no Network object for this + // SSID yet, so the native API cannot carry the PSK. Fall back to + // nmcli with the secret on stdin. if (hasKey) { - Quickshell.execDetached(["nmcli", "dev", "wifi", "connect", ssid, "password", secret]); + root._joinViaNmcli(ssid, secret); } else { - Quickshell.execDetached(["nmcli", "dev", "wifi", "connect", ssid]); + root._joinViaNmcli(ssid, ""); } } } @@ -451,7 +615,8 @@ Singleton { } } } - Quickshell.execDetached(["nmcli", "connection", "delete", ssid]); + deleteProcess.pendingSsid = ssid; + deleteProcess.running = true; } // ========================================================================= diff --git a/shell/desktop/services/NotificationService.qml b/shell/desktop/services/NotificationService.qml index c61c4e52..59a77314 100644 --- a/shell/desktop/services/NotificationService.qml +++ b/shell/desktop/services/NotificationService.qml @@ -221,7 +221,18 @@ Singleton { } historyModel.insert(0, record); - if (!root.doNotDisturb && !isDuplicate) { + // Suppressed whenever an overlay is already up. + // + // A toast is the shell telling you about something you have not seen. + // With the command centre open, the same notification is sitting in the + // list the user is looking at, so the toast is not information -- it is + // a second window drawn over the panel that is answering the same + // question. Radial settings and the command deck are included for the + // same reason: any of them means the user is mid-task. + // + // The toast is still recorded in history either way, so opening the + // panel afterwards still shows it. + if (!root.doNotDisturb && !isDuplicate && !OverlayController.isOverlayActive) { for (let t = activeToastsModel.count - 1; t >= 0; --t) { const existingToast = activeToastsModel.get(t); if (existingToast && (existingToast.notifId === notifId || existingToast.id === notifId)) { @@ -315,6 +326,17 @@ Singleton { } // Clears all stored notifications from history + // Drops any toasts currently on screen without touching history. + // + // Called when an overlay opens: a toast raised before the panel was opened + // would otherwise sit on top of it for the remainder of its timeout, which + // is long enough to be in the way of reading what is underneath. + function dismissToasts(): void { + while (activeToastsModel.count > 0) { + activeToastsModel.remove(0); + } + } + function clearAll(): void { historyModel.clear(); for (let id in root._notificationObjects) { diff --git a/shell/desktop/services/SystemMonitorService.qml b/shell/desktop/services/SystemMonitorService.qml index 66f8a74a..d726b593 100644 --- a/shell/desktop/services/SystemMonitorService.qml +++ b/shell/desktop/services/SystemMonitorService.qml @@ -11,8 +11,18 @@ Singleton { // Public Interface Contract // ========================================================================= - // Backend health indicator (true when procfs files are accessible) - property bool available: true + // Backend health indicator (true when procfs files are accessible). + // + // Derived from consecutive read failures rather than assigned directly: the + // sampler is gated on this property, so setting it false on the first + // failure stopped the timer that would have produced the recovery, and the + // shell stayed permanently offline after one transient error. + readonly property bool available: _consecutiveFailures < maxConsecutiveFailures + + // Tolerance for transient procfs read errors before reporting offline. + readonly property int maxConsecutiveFailures: 3 + property int _consecutiveFailures: 0 + property bool _warnedUnavailable: false // CPU Telemetry: per-thread utilization [0.0, 1.0] and aggregate [0.0, 1.0] property list cpuThreadLoads: [] @@ -24,6 +34,27 @@ Singleton { property real swapUsedBytes: 0.0 property real swapTotalBytes: 0.0 + // Disk capacity for the filesystem holding $HOME, in bytes. + // + // procfs cannot answer this: /proc/diskstats reports I/O counters, not how + // full a filesystem is, and no procfs file carries filesystem capacity. So + // this one shells out to df. + // + // Only the filesystem containing $HOME is reported. Separate partitions + // mounted under / are real, but a single ring cannot honestly represent + // them, and summing across mount points double-counts on btrfs and on any + // system that mounts /home off the root device. + property real diskTotalBytes: 0.0 + property real diskUsedBytes: 0.0 + readonly property real diskFraction: diskTotalBytes > 0 + ? Math.min(1.0, diskUsedBytes / diskTotalBytes) + : 0.0 + + // Disk capacity changes over minutes, not seconds. Sampling it on the 1s + // CPU/net cadence would fork a process every second for a number that is + // effectively static. + readonly property int diskRefreshInterval: 15000 + // Network Telemetry: bytes per second rates property real netRxBytesPerSec: 0.0 property real netTxBytesPerSec: 0.0 @@ -57,11 +88,39 @@ Singleton { Timer { id: sampleTimer interval: root.refreshInterval - running: root.available + // Runs regardless of availability: the sampler is what re-probes, so + // gating it on the health flag it maintains is circular. + running: true repeat: true onTriggered: root.refresh() } + Timer { + id: diskTimer + interval: root.diskRefreshInterval + running: true + repeat: true + onTriggered: { + // Still waiting on the previous run: skip rather than pile up forks. + if (diskProc.running) + return; + diskProc.running = true; + } + } + + Process { + id: diskProc + // -B1 forces 1-byte blocks so the numbers need no unit guessing. + command: ["df", "-B1", "--output=size,used", (Quickshell.env("HOME") || "/")] + workingDirectory: "/" + running: false + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root._parseDiskDf(text) + } + } + // ========================================================================= // Virtual Procfs File Loaders // ========================================================================= @@ -70,36 +129,54 @@ Singleton { id: statFile path: "/proc/stat" printErrors: false - onLoaded: root._parseCpuStat(statFile.text()) - onLoadFailed: function (error) { - root.available = false; + onLoaded: { + root._recordSuccess(); + root._parseCpuStat(statFile.text()); } + onLoadFailed: root._recordFailure() } FileView { id: memFile path: "/proc/meminfo" printErrors: false - onLoaded: root._parseMemInfo(memFile.text()) - onLoadFailed: function (error) { - root.available = false; + onLoaded: { + root._recordSuccess(); + root._parseMemInfo(memFile.text()); } + onLoadFailed: root._recordFailure() } FileView { id: netFile path: "/proc/net/dev" printErrors: false - onLoaded: root._parseNetDev(netFile.text()) - onLoadFailed: function (error) { - root.available = false; + onLoaded: { + root._recordSuccess(); + root._parseNetDev(netFile.text()); } + onLoadFailed: root._recordFailure() } // ========================================================================= // Public Methods // ========================================================================= + function _recordFailure(): void { + _consecutiveFailures++; + if (!root.available && !root._warnedUnavailable) { + _warnedUnavailable = true; + console.warn("[SystemMonitorService] procfs unreadable after " + + root._consecutiveFailures + " consecutive attempts; telemetry offline. " + + "Last known values are retained."); + } + } + + function _recordSuccess(): void { + _consecutiveFailures = 0; + _warnedUnavailable = false; + } + function refresh(): void { statFile.reload(); memFile.reload(); @@ -201,6 +278,36 @@ Singleton { root.telemetryUpdated(); } + // df --output=size,used emits a column-name header, then one + // " " row for the requested path. Read by position from the + // last line that parses: df right-aligns the columns in a variable-width + // field, so this is more reliable than splitting on whitespace and + // indexing, and skipping unparseable rows also skips the header. + function _parseDiskDf(rawContent: string): void { + if (!rawContent || rawContent.length === 0) { + return; + } + + const lines = rawContent.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const parts = lines[i].trim().split(/\s+/); + if (parts.length < 2) { + continue; + } + + const total = parseInt(parts[0], 10); + const used = parseInt(parts[1], 10); + if (isNaN(total) || isNaN(used)) { + continue; + } + + root.diskTotalBytes = total; + root.diskUsedBytes = used; + root.telemetryUpdated(); + return; + } + } + function _parseMemInfo(rawContent: string): void { if (!rawContent || rawContent.length === 0) { return; diff --git a/shell/desktop/services/ToolkitService.qml b/shell/desktop/services/ToolkitService.qml new file mode 100644 index 00000000..97097c74 --- /dev/null +++ b/shell/desktop/services/ToolkitService.qml @@ -0,0 +1,185 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import "../core" + +Singleton { + id: root + + // ========================================================================= + // Public Interface Contract + // ========================================================================= + + // The declared toolkit, grouped. This is the shell's half of a contract + // whose other half is modules/features/desktop/toolkit.nix, which installs + // the packages. + // + // Nothing here is enabled at runtime. Nix decides what exists; this reports + // what turned up. That is the whole point -- a radial node per tool that + // merely asserted the tool was present would be exactly the kind of + // confident fiction Phase 3 removed from this tree. + // + // Three groups, not seven. + // + // These used to be seven (recon, capture, wireless, credentials, web, + // forensics, diagnostics) and sec-toolkit fanned all seven off one node. + // That breaks the tree layout: a node's children are spread by how many + // there are, and past three there is no reference to copy the angles from, + // so seven branches is where "tree" stops being the right word. + // + // So they are bucketed by what you would be *doing*, which is also what + // makes the split defensible rather than arbitrary: + // + // RECON find what is there discovery, before touching anything + // CAPTURE watch what is happening network observation + // ANALYSIS what did this thing leave offline inspection of artifacts + // + // The bucket names are mine; the previous seven are recoverable from each + // tool's origin below, so re-bucketing is a data edit and not a rewrite. + readonly property var groups: [ + { + id: "recon", + label: "RECON", + icon: "globe", + tools: ["nmap", "masscan", "whois", "dig", "host"] + }, + { + id: "capture", + label: "CAPTURE", + icon: "storage", + // capture + wireless + diagnostics: all three observe a live + // network rather than a file on disk. + tools: ["tshark", "tcpdump", "socat", "aircrack-ng", + "iperf3", "mtr", "traceroute"] + }, + { + id: "analysis", + label: "ANALYSIS", + icon: "layers", + // credentials + web + forensics: everything here reads an artifact + // rather than a socket. Called ANALYSIS rather than VULNERABILITY + // because binwalk, exiftool and file are not attacks. + // + // hydra-server, not hydra: the package installs no binary called + // `hydra` and leaves meta.mainProgram unset, so probing "hydra" + // reported this group as 2/3 while all three were installed. Probing + // a name that cannot exist is how a readout ends up lying about the + // thing it is measuring. + tools: ["john", "hashcat", "hydra-server", "sqlmap", + "binwalk", "exiftool", "file"] + } + ] + + // Flat tool list derived from the groups, so a tool appears once. + readonly property var tools: { + const out = []; + for (let i = 0; i < root.groups.length; ++i) { + const g = root.groups[i]; + for (let k = 0; k < g.tools.length; ++k) out.push({ id: g.tools[k], group: g.id }); + } + return out; + } + + // Present only once the first scan has finished. Distinguishes "none of this + // group is installed" from "has not looked yet", which is the difference + // between a truthful zero and an uninformative one. + readonly property bool scanned: _scanned + + readonly property int installedCount: _installed + + readonly property int totalCount: tools.length + + function groupInstalled(groupId) { + const g = root.groupFor(groupId); + if (!g) return 0; + let n = 0; + for (let i = 0; i < g.tools.length; ++i) { + if (_present[g.tools[i]] === true) n += 1; + } + return n; + } + + function groupFor(groupId) { + for (let i = 0; i < root.groups.length; ++i) { + if (root.groups[i].id === groupId) return root.groups[i]; + } + return null; + } + + function isInstalled(toolId) { + return _present[toolId] === true; + } + + // ========================================================================= + // Scanning + // + // One shell loop, same pattern as WallpaperService and CalendarService: + // Quickshell 0.3.1 has no way to ask about PATH from QML, so `command -v` + // answers it. + // + // The tool list is interpolated into the script as a fixed literal rather + // than passed as an argument, so there is nothing to quote and nothing for a + // caller to inject. The ids are compile-time constants in this file. + // ========================================================================= + + property var _present: ({}) + property var _versions: ({}) + property bool _scanned: false + property int _installed: 0 + + function rescan() { + _scanned = false; + const names = []; + for (let i = 0; i < root.tools.length; ++i) names.push(root.tools[i].id); + _scanProc.command = [ + "sh", "-c", + 'for t in ' + names.join(" ") + '; do p=$(command -v "$t" 2>/dev/null) || continue; ' + + 'v=$("$t" --version 2>&1 | head -n 1 | cut -c1-40); ' + + // A tool that has no --version answers with its usage or an + // "illegal option" complaint instead of a version. Left in, that + // string is displayed as the tool's version, which is worse than + // showing none: BSD `host` is one such tool. Blank it when the + // output is obviously the tool complaining. + 'case "$v" in *"illegal option"*|*"unknown option"*|*"invalid option"*|usage:*|Usage:*) v="" ;; esac; ' + + 'printf "###CTOSTK:%s:%s\\n" "$t" "$v"; done; printf "###CTOSTKEND\\n"', + "ctos-toolkit-scan" + ]; + _scanProc.running = true; + } + + Process { + id: _scanProc + workingDirectory: "/" + running: false + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + const present = {}; + const versions = {}; + const lines = (text || "").split("\n"); + + for (let i = 0; i < lines.length; ++i) { + const line = lines[i].trim(); + if (line.indexOf("###CTOSTK:") !== 0) continue; + + const rest = line.slice("###CTOSTK:".length); + const sep = rest.indexOf(":"); + if (sep < 0) continue; + + present[rest.slice(0, sep)] = true; + versions[rest.slice(0, sep)] = rest.slice(sep + 1).trim(); + } + + root._present = present; + root._versions = versions; + root._installed = Object.keys(present).length; + root._scanned = true; + } + } + } + + Component.onCompleted: root.rescan() +} \ No newline at end of file diff --git a/shell/desktop/services/WallpaperService.qml b/shell/desktop/services/WallpaperService.qml new file mode 100644 index 00000000..c268116f --- /dev/null +++ b/shell/desktop/services/WallpaperService.qml @@ -0,0 +1,364 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import "../core" + +Singleton { + id: root + + // ========================================================================= + // Public Interface Contract + // ========================================================================= + + // The directory being scanned. Mirrors Settings so the browser's field and + // the service can never disagree about which directory is current. + readonly property string directory: Settings.wallpaperDir + + // What the scan found: [{ name, path }], sorted, current selection flagged. + readonly property var wallpapers: _entries + + readonly property bool scanning: _scanProc.running + + // True once a scan has completed for the current directory. Distinguishes + // "no wallpapers here" from "has not looked yet", which the browser needs so + // an empty directory does not read as an error. + readonly property bool scanned: _scanned + + readonly property int count: _entries.length + + // The selected filename, or "" when the directory holds nothing selected. + readonly property string currentName: Settings.wallpaper + + readonly property bool currentPresent: entryFor(root.currentName) !== null + + // Backend health, derived from consecutive failures rather than assigned. + // + // Same reasoning as SystemMonitorService.available: anything that gates work + // on this property must not have that work be what sets it, or one failure + // disables the retry that would have recovered. + readonly property bool available: _consecutiveFailures < maxConsecutiveFailures + readonly property int maxConsecutiveFailures: 3 + + readonly property bool applying: _awwwProcess.running + + // Last failure, for the radial's error state and the startup diagnostic the + // engineering guidelines require of every optional integration. + readonly property string lastError: _lastError + + // The awww daemon's control socket. awww derives the name from the + // compositor it is serving, so it is not a fixed path. + readonly property string _socketPath: + (Quickshell.env("XDG_RUNTIME_DIR") || "") + "/" + + (Quickshell.env("WAYLAND_DISPLAY") || "") + "-awww-daemon.sock" + + // ========================================================================= + // Scanning + // + // Quickshell 0.3.1 has no DirectoryView -- the Io module ships FileView, + // Process, StdioCollector, Socket and the rest, but nothing that enumerates a + // directory. So this shells out, on the CalendarService pattern: one `sh` + // loop with a marker per line, output split back apart in JS. + // + // Re-scanned whenever the directory changes rather than watched. A wallpaper + // directory is not a hot path, and a watch would mean holding a descriptor + // open on a path the user can change to anything at all. + // + // Results are capped and extension-filtered. The directory is user-supplied, + // so it may hold a photo dump, and decoding every one of those into the + // browser's grid would be the shell's fault, not the user's. + // ========================================================================= + + readonly property int maxWallpapers: 60 + + property var _entries: [] + property bool _scanned: false + property string _scannedDir: "" + property int _consecutiveFailures: 0 + property string _lastError: "" + property string _lastApplied: "" + + // Whether the recorded selection can be trusted as the user's actual choice. + // True once Settings has either parsed the file or reported that it could + // not; see the deferred fallback in the scan handler for why isLoaded alone + // is not enough. + property bool _settleSeen: false + readonly property bool _settingsSettled: Settings.isLoaded || root._settleSeen + + // Hand a wallpaper to awww at most once per distinct name. + // + // This is what makes the shell the authority on the desktop backdrop. + // ctos-wallpaper.service applies wallpaper-v1.png unconditionally at + // graphical-session.target -- it does not read settings.json -- so without + // this the unit won every boot: the desktop came up on v1 while the setting + // and the browser showed whatever was chosen. The comment on the session + // start block below already claimed the shell re-applies the recorded choice + // and made itself authoritative; it did not, because apply() was only reached + // from the fallback branch below, which runs precisely when the recorded name + // is *missing*. + function _applyTarget(name) { + if (name === "" || name === root._lastApplied) return; + root._lastApplied = name; + root.apply(name); + } + + function entryFor(name) { + for (var i = 0; i < root._entries.length; ++i) { + if (root._entries[i].name === name) return root._entries[i]; + } + return null; + } + + function isImage(name) { + var lower = name.toLowerCase(); + return lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") + || lower.endsWith(".webp") || lower.endsWith(".bmp"); + } + + // Point the switcher at a different directory and look again. + // + // The selection is cleared when it does not survive the move, because + // leaving Settings.wallpaper pointing into a directory the user just left + // would make every chip unhighlighted and the next boot try to apply a file + // that is not there. + function setDirectory(dir) { + const trimmed = (dir || "").trim(); + if (trimmed.length === 0) return; + if (trimmed === root.directory) { + root.rescan(); + return; + } + Settings.wallpaperDir = trimmed; + Settings.save(); + root.rescan(); + } + + function rescan() { + _scanned = false; + _scannedDir = root.directory; + _scanProc.command = [ + "sh", "-c", + 'printf "%s\\n" "###CTOSWP:$1"; if [ ! -d "$1" ]; then printf "%s\\n" "###CTOSWPERR"; exit 0; fi; for f in "$1"/*; do [ -f "$f" ] || continue; printf "%s\\n" "$f"; done', + "ctos-wallpaper-scan", root.directory + ]; + _scanProc.running = true; + } + + Process { + id: _scanProc + workingDirectory: "/" + running: false + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + // The first line names the directory this output describes, and + // the guard compares against that rather than against _scannedDir. + // + // _scannedDir records where a scan was *aimed*, and it is + // overwritten by the next rescan() while earlier scans are still in + // flight. StdioCollector does not say which process a given + // onStreamFinished belongs to, so an older scan's output would + // pass a _scannedDir check that a newer rescan had already + // satisfied, and its files would be adopted as if they described + // the current directory. That is how a scan of one directory + // produced a fallback to an image from another one. + // + // Letting the output declare its own directory makes the check + // independent of arrival order. + const raw = text || ""; + const lines = raw.split("\n"); + + if (lines.length === 0 || lines[0].indexOf("###CTOSWP:") !== 0) return; + const scanDir = lines[0].slice(10); + if (scanDir !== root.directory) return; + lines.shift(); + + // A sentinel line rather than an exit code: StdioCollector + // exposes only `text`, so there is no exitCode to read here, and + // this is the same marker technique CalendarService uses to split + // its combined output. + if (lines.indexOf("###CTOSWPERR") >= 0) { + root._entries = []; + root._scanned = true; + root._lastError = "not a directory: " + root.directory; + console.warn("[WallpaperService] " + root._lastError); + return; + } + + const found = []; + for (let i = 0; i < lines.length; ++i) { + const path = lines[i].trim(); + if (path.length === 0) continue; + + const slash = path.lastIndexOf("/"); + const name = slash >= 0 ? path.slice(slash + 1) : path; + if (!root.isImage(name)) continue; + + found.push({ name: name, path: path }); + if (found.length >= root.maxWallpapers) break; + } + + found.sort(function (a, b) { return a.name.localeCompare(b.name); }); + + root._entries = found; + root._scanned = true; + root._lastError = ""; + + if (found.length === 0) return; + + if (root.entryFor(root.currentName) !== null) { + root._applyTarget(root.currentName); + return; + } + + // Falling back overwrites the recorded choice, so it must not run + // on a scan that finished before the settings were read. On such a + // scan the "missing" selection is Settings' built-in default rather + // than the user's choice, and writing the first image over it + // destroys the record permanently -- the user came back after a + // reboot to a wallpaper they had never chosen. + // + // Settings.isLoaded cannot be the only test: resetToDefaults sets it + // back to false, so it is also false after a failed parse, and + // neither of the two signals fires in that case either. That is + // harmless here, because a failed parse leaves the default in place + // and the default is what the session unit already applied. + if (!root._settingsSettled) { + console.warn("[WallpaperService] scan finished before settings" + + " settled; deferring the fallback decision"); + return; + } + + console.warn("[WallpaperService] selection '" + root.currentName + + "' is not in " + root.directory + "; falling back to '" + + found[0].name + "'"); + Settings.wallpaper = found[0].name; + Settings.save(); + root._applyTarget(found[0].name); + } + } + } + + // ========================================================================= + // Actions + // ========================================================================= + + // Apply a wallpaper by filename within the current directory. + // + // The filename is checked against the scan rather than trusted: a selection + // can be stale (directory changed, file deleted), and handing awww a path + // that does not exist would blank the desktop with no way back from the UI. + function apply(name) { + const entry = root.entryFor(name); + if (!entry) { + _lastError = "no such wallpaper in " + root.directory + ": " + name; + console.warn("[WallpaperService] " + _lastError); + return false; + } + if (_awwwProcess.running) return false; + + _pendingPath = entry.path; + _attempt = 0; + _lastError = ""; + _launch(); + return true; + } + + function _launch() { + _awwwProcess.command = [ + "awww", "img", _pendingPath, + "--transition-type", "fade", + "--transition-duration", "1" + ]; + _awwwProcess.running = true; + } + + // ========================================================================= + // Retry + // + // The daemon socket can appear a little after the graphical session target + // does, so a first failure is a startup race rather than a real one. Retry + // briefly, exactly as the session-start unit does, and only then believe it. + // + // 20 attempts at 250ms is ~5s, which covers the race without turning a + // genuinely missing daemon into a five-second hang on every selection. + // ========================================================================= + + readonly property int maxAttempts: 20 + readonly property int retryDelayMs: 250 + + property string _pendingPath: "" + property int _attempt: 0 + + Process { + id: _awwwProcess + + onExited: function(exitCode) { + if (exitCode === 0) { + _consecutiveFailures = 0; + _lastError = ""; + return; + } + + _attempt += 1; + if (_attempt < root.maxAttempts) { + _retryTimer.restart(); + return; + } + + _consecutiveFailures += 1; + _lastError = "awww img exited " + exitCode + " after " + root.maxAttempts + " attempts"; + console.warn("[WallpaperService] " + _lastError + + " (socket: " + root._socketPath + ")"); + } + } + + Timer { + id: _retryTimer + interval: root.retryDelayMs + onTriggered: root._launch() + } + + // ========================================================================= + // Session start + // + // The session-start systemd unit applies a wallpaper too, and that is + // deliberate: it is the fallback for a session where the shell never starts. + // Re-applying the same image is idempotent, so applying the recorded choice + // here as well is what makes the shell authoritative after a shell restart. + // ========================================================================= + + Component.onCompleted: { + root.rescan(); + } + + // Re-scan when the directory changes from anywhere, including a hand-edited + // settings file. Settings.wallpaperDir is a plain property, so this is the + // only thing keeping the browser in step with it. + Connections { + target: Settings + function onWallpaperDirChanged() { + root.rescan(); + } + + // The scan started in Component.onCompleted above races the settings file + // being read off disk. Whichever lands first, on a machine that is not + // already correct the first scan would settle on Settings' built-in + // default rather than the recorded choice -- and then nothing would look + // again. Look again once the settings have settled. + // + // Both outcomes are hooked rather than Settings.isLoaded: resetToDefaults + // sets isLoaded back to false, so that flag is also false after a failed + // parse and gating on it would never settle at all. + function onSettingsLoaded() { + root._settleSeen = true; + root.rescan(); + } + function onSettingsLoadFailed() { + root._settleSeen = true; + root.rescan(); + } + } +} \ No newline at end of file diff --git a/shell/desktop/services/qmldir b/shell/desktop/services/qmldir index 02f785ed..0c2e6dd9 100644 --- a/shell/desktop/services/qmldir +++ b/shell/desktop/services/qmldir @@ -8,3 +8,6 @@ singleton NotificationService 1.0 NotificationService.qml singleton BluetoothService 1.0 BluetoothService.qml singleton SessionService 1.0 SessionService.qml singleton PacketAnalyzerService 1.0 PacketAnalyzerService.qml +singleton CalendarService 1.0 CalendarService.qml +singleton WallpaperService 1.0 WallpaperService.qml +singleton ToolkitService 1.0 ToolkitService.qml diff --git a/shell/desktop/surfaces/AmbientBar.qml b/shell/desktop/surfaces/AmbientBar.qml index 38587712..fb3dea2e 100644 --- a/shell/desktop/surfaces/AmbientBar.qml +++ b/shell/desktop/surfaces/AmbientBar.qml @@ -1,9 +1,7 @@ import QtQuick -import QtQuick.Layouts import Quickshell import Quickshell.Wayland import "../core" -import "../services" import "./components" PanelWindow { @@ -25,45 +23,124 @@ PanelWindow { color: "transparent" - property bool toggleMask: false + // The CCC lives inside this window rather than in its own. Two + // layer-shell surfaces cannot read as one: the compositor separates them, + // so the notch's border and glow cannot wrap continuously across the join + // and there is a visible seam. Sharing the window is what makes the CCC + // look like the notch unfolding. + // + // Every output has its own AmbientBar, so exactly one of them claims the + // overlay by matching the screen it was opened on. + readonly property bool isCommandCenterHost: + Settings.featuresCommandCenter + && OverlayController.activeSurface === OverlayController.Surface.CommandCenter + && OverlayController.hostScreenName !== "" + && root.screen !== null + && OverlayController.hostScreenName === root.screen.name + + // Input region: the pill, plus the CCC while it is open. + // + // WindowInterface.mask is a single Region, not a list. Multiple rectangles + // go in as nested Regions inside it -- assigning a JS array to mask fails + // with "Cannot assign QJSValue to PendingRegion*", because array elements + // arrive as QJSValue rather than Region pointers. + // + // Two sub-regions rather than one union rectangle: a single region spanning + // the column would make the empty gaps beside the pill and beside the CCC + // swallow clicks meant for the desktop. + // + // Declared inline rather than built with Component.createObject. The + // created-object form had no access to commandCenterHost, which is declared + // later in the file, and it could not stay bound as the host animated. + Region { + id: windowRegion + + // Four sub-regions covering everything outside the panel, so a click there + // reaches us and can dismiss. While the CCC is open the window covers the + // screen, so these have room to exist; while it is closed they are all + // zero-sized and only the notch and CCC regions remain. + // + // Without any region out here the compositor has nothing to deliver an + // outside click to, so it goes to whatever is underneath and there is no + // way for us to see it. + Region { + x: 0 + y: 0 + width: root.dismissing ? root.dismissLeft : 0 + height: root.dismissing ? root.height : 0 + } + Region { + x: root.dismissRight + y: 0 + width: root.dismissing ? Math.max(0, root.width - root.dismissRight) : 0 + height: root.dismissing ? root.height : 0 + } + Region { + x: root.dismissLeft + y: 0 + width: root.dismissing ? Math.max(0, root.dismissRight - root.dismissLeft) : 0 + height: root.dismissing ? root.dismissTop : 0 + } + Region { + x: root.dismissLeft + y: root.dismissBottom + width: root.dismissing ? Math.max(0, root.dismissRight - root.dismissLeft) : 0 + height: root.dismissing ? Math.max(0, root.height - root.dismissBottom) : 0 + } - Component { - id: maskComponent Region { - Region { - x: livingNotch.x - y: livingNotch.y - width: livingNotch.width - height: livingNotch.height - } + id: notchRegion + x: livingNotch.x + y: livingNotch.y + width: livingNotch.width + height: livingNotch.height + } + + Region { + id: cccRegion + x: commandCenterHost.x + y: commandCenterHost.y + width: commandCenterHost.width + height: commandCenterHost.height } } - property var maskA: maskComponent.createObject(root) - property var maskB: maskComponent.createObject(root) + mask: windowRegion - mask: toggleMask ? maskA : maskB - function flushWaylandMask() { - toggleMask = !toggleMask; - } Connections { target: livingNotch - function onWidthChanged() { root.flushWaylandMask(); } - function onHeightChanged() { root.flushWaylandMask(); } - function onXChanged() { root.flushWaylandMask(); } - function onYChanged() { root.flushWaylandMask(); } } - Component.onCompleted: { - root.flushWaylandMask(); + // The CCC animating open and closed changes the input region too. + Connections { + target: commandCenterHost } + WlrLayershell.namespace: "ctos-bar" WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - implicitHeight: Math.max(Theme.barHeight, livingNotch.currentHeight + 6) + // OnDemand only while the CCC is open. This surface otherwise takes no + // keyboard input at all, and claiming focus unconditionally would steal + // keystrokes from the desktop. + WlrLayershell.keyboardFocus: root.isCommandCenterHost + ? WlrKeyboardFocus.OnDemand + : WlrKeyboardFocus.None + // Height of the bar on its own: the notch, its padding, and the CCC when + // joined underneath it. + readonly property real collapsedHeight: + Math.max(Settings.barHeight, livingNotch.currentHeight) + + Theme.notchHostPadding + + (root.isCommandCenterHost ? commandCenterHost.height : 0) + + // While the CCC is open the surface covers the screen instead, so that the + // backdrop region above has somewhere to live and outside clicks are ours to + // receive. Anchored to the top with left/right/top set, so this is the full + // screen minus the 3px top margin. + implicitHeight: root.isCommandCenterHost && root.screen !== null + ? root.screen.height + : root.collapsedHeight exclusionMode: ExclusionMode.Ignore exclusiveZone: 0 @@ -82,6 +159,60 @@ PanelWindow { // Active Living Notch Component // ========================================================================= + // The notch and the CCC together occupy one axis-aligned box. The dismiss + // target is everything outside it, so the catcher never overlaps a control. + // + // A single full-screen MouseArea with z: -1 was tried first and does not + // work: the catcher stayed the topmost item under the cursor even at z: -1, + // so every toggle, list row and power button in the panel was swallowed and + // the click just closed the panel. z is not a reliable way to put a + // screen-sized item behind a subtree here, so the geometry is used instead -- + // the catcher simply does not cover the panel. + readonly property real dismissLeft: + Math.max(0, Math.min(livingNotch.x, commandCenterHost.x)) + readonly property real dismissRight: + Math.min(root.width, Math.max(livingNotch.x + livingNotch.width, + commandCenterHost.x + commandCenterHost.width)) + readonly property real dismissTop: + Math.max(0, Math.min(livingNotch.y, commandCenterHost.y)) + readonly property real dismissBottom: + Math.min(root.height, Math.max(livingNotch.y + livingNotch.height, + commandCenterHost.y + commandCenterHost.height)) + + readonly property bool dismissing: root.isCommandCenterHost + + component DismissArea: MouseArea { + visible: root.dismissing + onClicked: OverlayController.handleBackdropClick() + } + + // Left, right, above and below the panel. Zero-sized while closed, so the + // desktop is clickable exactly as before. + DismissArea { + x: 0 + y: 0 + width: root.dismissLeft + height: root.height + } + DismissArea { + x: root.dismissRight + y: 0 + width: Math.max(0, root.width - root.dismissRight) + height: root.height + } + DismissArea { + x: root.dismissLeft + y: 0 + width: Math.max(0, root.dismissRight - root.dismissLeft) + height: root.dismissTop + } + DismissArea { + x: root.dismissLeft + y: root.dismissBottom + width: Math.max(0, root.dismissRight - root.dismissLeft) + height: Math.max(0, root.height - root.dismissBottom) + } + LivingNotch { id: livingNotch @@ -101,401 +232,56 @@ PanelWindow { } // ========================================================================= - // Backwards Compatibility Scaffolding - // Preserves legacy element IDs, signal bindings, and structural contracts - // for test suite compatibility without visual or Wayland input interference. + // Adaptive Command & Control Center + // + // Hosted here rather than in a PanelWindow of its own. Two layer-shell + // surfaces cannot read as one: the compositor separates them, so the + // notch's border and glow cannot wrap continuously across the join and + // there is a visible seam between them. Sharing this window is what makes + // the CCC look like the notch unfolding rather than a panel appearing. + // + // Every output has its own AmbientBar, so exactly one of them claims the + // overlay by matching the screen it was opened on. // ========================================================================= - Item { - id: legacyCompatibilityLayer - - visible: false - width: 0 - height: 0 - enabled: false - - // Legacy Left Island - Rectangle { - id: leftIsland - - anchors.left: parent.left - anchors.leftMargin: Theme.barPaddingHorizontal - anchors.top: parent.top - anchors.topMargin: (Theme.barHeight - height) / 2 - height: Theme.barHeight - 6 - width: leftSections.width - radius: Theme.radiusPill - color: "transparent" - - RowLayout { - id: leftSections - anchors.fill: parent - spacing: Theme.spacingMedium - - // Section 1: Blume Logo - Rectangle { - id: logoSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: height - height: Theme.barHeight - 6 - width: height - implicitHeight: height - implicitWidth: width - - color: logoMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: logoMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - Image { - id: logoIcon - - anchors.centerIn: parent - fillMode: Image.PreserveAspectFit - height: 22 - source: "components/os-icon.svg" - sourceSize.height: 22 - sourceSize.width: 22 - width: 22 - } - - MouseArea { - id: logoMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: OverlayController.openCommandDeck() - } - } - - // Section 2: Workspaces - Rectangle { - id: workspacesSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: workspacesWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: workspacesWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: Theme.background - radius: Theme.radiusMedium - border.color: Theme.borderMuted - border.width: Theme.borderWidth - - WorkspacesWidget { - id: workspacesWidget - - anchors.centerIn: parent - monitorName: (root.screen && root.screen.name) ? root.screen.name : "" - } - } - - // Section 3: Window Title - Rectangle { - id: windowTitleSection - - Layout.alignment: Qt.AlignVCenter - Layout.maximumWidth: 380 - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: Math.min(windowTitleWidget.implicitWidth + Theme.paddingMedium * 2, 380) - height: Theme.barHeight - 6 - width: Math.min(windowTitleWidget.implicitWidth + Theme.paddingMedium * 2, 380) - implicitHeight: height - implicitWidth: width - - color: windowTitleMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: windowTitleMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - WindowTitleWidget { - id: windowTitleWidget - - anchors.centerIn: parent - width: parent.width - Theme.paddingMedium * 2 - } - - MouseArea { - id: windowTitleMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - } - } - } - } + // The panel starts exactly where the notch ends: no tuck, no overlap. +// +// It used to overlap by the notch's own radius, on the theory that the two +// should read as one continuous shape -- the notch unfolding rather than a pill +// sitting on a panel. Hiding the notch's rounded bottom corners behind the +// opaque panel did produce one shape, but it read as a panel sliding underneath +// the pill rather than as a single surface, because the notch's own rounded +// corners were doing the hiding. Aligning the two edges instead makes the join +// explicit: the notch's bottom edge is the panel's top edge, and each keeps its +// own corners. +// +// Nothing needs a seam marker here. The divider that used to mark the notch's +// lower edge only made sense while the panel's top was hidden behind the notch; +// the panel's own border is the line now. - // Legacy Center Section: Dynamic Island - DynamicIsland { - id: centerSection + Item { + id: commandCenterHost - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: (Theme.barHeight - (Theme.barHeight - 6)) / 2 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: livingNotch.bottom + width: Math.max(Theme.commandCenterMinWidth, + Math.min(Theme.commandCenterWidth, root.width - Theme.spacing2Xl * 4)) + height: root.isCommandCenterHost ? ccc.implicitHeight : 0 + visible: root.isCommandCenterHost + clip: false + + Behavior on height { + NumberAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationSlow + easing.type: Easing.InOutCubic + } } - // Legacy Right Island - Rectangle { - id: rightIsland - - anchors.right: parent.right - anchors.rightMargin: Theme.barPaddingHorizontal - anchors.top: parent.top - anchors.topMargin: (Theme.barHeight - height) / 2 - height: Theme.barHeight - 6 - width: rightSections.width - radius: Theme.radiusPill - color: "transparent" - - RowLayout { - id: rightSections - anchors.fill: parent - spacing: Theme.spacingMedium - - // Section 4: Network - Rectangle { - id: networkSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: networkWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: networkWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: networkMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: networkMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - NetworkWidget { - id: networkWidget - - anchors.centerIn: parent - isHovered: networkMouseArea.containsMouse - } - - MouseArea { - id: networkMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - - onClicked: (mouse) => { - if (mouse && mouse.button === Qt.RightButton) { - NetworkService.toggleWifi(); - } else { - root.toggleNetwork(); - } - } - } - } - - // Section 5: Volume - Rectangle { - id: volumeSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: volumeWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: volumeWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: volumeMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: volumeMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - VolumeWidget { - id: volumeWidget - - anchors.centerIn: parent - isHovered: volumeMouseArea.containsMouse - } - - MouseArea { - id: volumeMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: AudioService.toggleMute() - - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Section 6: Battery - Rectangle { - id: batterySection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: batteryWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: batteryWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: batteryMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: batteryMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - visible: batteryWidget.visible - - BatteryWidget { - id: batteryWidget - - anchors.centerIn: parent - isHovered: batteryMouseArea.containsMouse - } - - MouseArea { - id: batteryMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: OverlayController.toggleCommandCenter() - } - } - - // Section 7: Bluetooth - Rectangle { - id: bluetoothSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: bluetoothWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: bluetoothWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: bluetoothMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: bluetoothMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - visible: BluetoothService.available - - BluetoothWidget { - id: bluetoothWidget - - anchors.centerIn: parent - isHovered: bluetoothMouseArea.containsMouse - } - - MouseArea { - id: bluetoothMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - - onClicked: (mouse) => { - if (mouse && mouse.button === Qt.RightButton) { - BluetoothService.togglePower(); - } else { - root.toggleBluetooth(); - } - } - } - } - - // Section 8: Clock / Calendar Trigger - Rectangle { - id: clockSection - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: clockWidget.implicitWidth + Theme.paddingMedium * 2 - height: Theme.barHeight - 6 - width: clockWidget.implicitWidth + Theme.paddingMedium * 2 - implicitHeight: height - implicitWidth: width - - color: clockMouseArea.containsMouse ? Theme.surfaceHover : Theme.background - radius: Theme.radiusMedium - border.color: clockMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - ClockWidget { - id: clockWidget - - anchors.centerIn: parent - - onToggleCalendar: root.toggleCalendar() - } - - MouseArea { - id: clockMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: root.toggleCalendar() - } - } - - // Section 9: System Rail Toggle Button - Rectangle { - id: railSection - - readonly property bool isRailOpen: OverlayController.activeSurface === OverlayController.Surface.CommandCenter - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: Theme.barHeight - 6 - Layout.preferredWidth: height - height: Theme.barHeight - 6 - width: height - implicitHeight: height - implicitWidth: width - - color: isRailOpen ? Theme.surfaceSelected : (railMouseArea.containsMouse ? Theme.surfaceHover : Theme.background) - radius: Theme.radiusMedium - border.color: (isRailOpen || railMouseArea.containsMouse) ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - - Text { - anchors.centerIn: parent - color: (railSection.isRailOpen || railMouseArea.containsMouse) ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "=" - } - - MouseArea { - id: railMouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: OverlayController.toggleCommandCenter() - } - } - } + CommandCenter { + id: ccc + width: parent.width + visible: root.isCommandCenterHost } } + } diff --git a/shell/desktop/surfaces/CommandCenter.qml b/shell/desktop/surfaces/CommandCenter.qml index 517c51ce..f935020b 100644 --- a/shell/desktop/surfaces/CommandCenter.qml +++ b/shell/desktop/surfaces/CommandCenter.qml @@ -1,77 +1,46 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Io import Quickshell.Services.Mpris import "../core" import "../services" import "./components" -import "./widgets" +// Command centre: a two-column grid of cards. +// +// Rebuilt on Card rather than on the accordion the previous version used. An +// accordion is a disclosure control -- it hides its contents behind a click -- +// which is the right model for a list you might not care about and the wrong one +// for a panel whose cards are all meant to be read at once. The design shows +// every card open. +// +// Cards that hold inputs stay collapsible, because a Wi-Fi password prompt and a +// notification history list do not both belong on screen at their natural size. +// System Status is fixed open: it reports, and there is nothing to disclose. FocusScope { id: root - implicitWidth: 360 - width: 360 - focus: true - - implicitHeight: panelContainer.implicitHeight - height: panelContainer.height + implicitWidth: Theme.commandCenterWidth + width: implicitWidth - readonly property bool isOpen: OverlayController.activeSurface === OverlayController.Surface.CommandCenter - - Keys.onEscapePressed: function (event) { - if (root.confirmingForgetSsid !== "") { - root.confirmingForgetSsid = ""; - } else if (root.isConfirming) { - root.cancelConfirmation(); - } else if (root.selectedSsid !== "") { - root.selectedSsid = ""; - } else { - OverlayController.close(); - } - if (event) { - event.accepted = true; - } - } + readonly property bool isOpen: + OverlayController.activeSurface === OverlayController.Surface.CommandCenter - Connections { - target: OverlayController - function onOverlayOpened(surface: int): void { - if (surface === OverlayController.Surface.CommandCenter) { - root.forceActiveFocus(); - if (OverlayController.pendingSessionAction !== "") { - root.confirmationAction = OverlayController.pendingSessionAction; - OverlayController.pendingSessionAction = ""; - panelContainer.activeAccordion = "power"; - } else if (OverlayController.pendingRailView !== "") { - panelContainer.activeAccordion = OverlayController.pendingRailView; - OverlayController.pendingRailView = ""; - } else if (panelContainer.activeAccordion === "") { - panelContainer.activeAccordion = "notifications"; - } - } - } - function onOverlayClosed(surface: int): void { - if (surface === OverlayController.Surface.CommandCenter) { - root.confirmationAction = ""; - root.selectedSsid = ""; - root.confirmingForgetSsid = ""; - } - } - } + // Which card a sub-surface request asked for ("power", "wifi"). Applied on + // open and then cleared, so it does not keep overriding the user's own + // expand and collapse afterwards. + property string requestedCard: "" - Component.onCompleted: { - if (OverlayController.activeSurface === OverlayController.Surface.CommandCenter) { - root.forceActiveFocus(); - if (panelContainer.activeAccordion === "") { - panelContainer.activeAccordion = "notifications"; - } - } - } + // Cards default to open: the design shows the whole panel at once, and these + // bodies are sized to fit. Assignment rather than binding, because Card + // owns its own expanded state. + // ===================================================================== + // Media Player Selection + // ===================================================================== + // Reading Mpris.players.values is not reactive to a player's own property + // changes, so activePlayer needs an explicit trigger to re-evaluate when a + // track changes or playback state flips. The watcher below bumps it. property int _mprisTrigger: 0 Repeater { @@ -84,2517 +53,1730 @@ FocusScope { Connections { target: mprisWatcher.modelData - function onPlaybackStateChanged(): void { - root._mprisTrigger++; - } - function onTrackTitleChanged(): void { - root._mprisTrigger++; - } - function onTrackArtistChanged(): void { - root._mprisTrigger++; - } + function onPlaybackStateChanged(): void { root._mprisTrigger++; } + function onTrackTitleChanged(): void { root._mprisTrigger++; } + function onTrackArtistChanged(): void { root._mprisTrigger++; } } } } + // Preference order: something actually playing, then something paused that + // has a title (so the panel does not jump to a bare idle player), then any + // paused player, then whatever is there. readonly property var activePlayer: { const trigger = root._mprisTrigger; const list = Mpris.players.values; - if (!list || list.length === 0) return null; + if (!list || list.length === 0) + return null; for (let i = 0; i < list.length; i++) { const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Playing) { + if (p && p.playbackState === MprisPlaybackState.Playing) return p; - } } - for (let i = 0; i < list.length; i++) { const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Paused && p.trackTitle && p.trackTitle.trim() !== "") { + if (p && p.playbackState === MprisPlaybackState.Paused + && p.trackTitle && p.trackTitle.trim() !== "") return p; - } } - for (let i = 0; i < list.length; i++) { const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Paused) { + if (p && p.playbackState === MprisPlaybackState.Paused) return p; - } } - return list[0] || null; } - readonly property bool isPlaying: activePlayer !== null && activePlayer.playbackState === MprisPlaybackState.Playing - - property real visualizerPhase: 0.0 - - NumberAnimation { - id: visualizerAnim - target: root - property: "visualizerPhase" - from: 0.0 - to: 6.283185307179586 - duration: 1200 - loops: Animation.Infinite - running: root.isOpen && panelContainer.expandedAccordion === "audio" && root.isPlaying && !Settings.reducedMotion - } + readonly property bool isPlaying: + activePlayer !== null && activePlayer.playbackState === MprisPlaybackState.Playing - Process { - id: lockProcess - command: ["loginctl", "lock-session"] - running: false - } + function applyRequestedCard(): void { + if (requestedCard === "") + return; - Process { - id: logoutProcess - command: ["hyprctl", "dispatch", "exit"] - running: false - } + if (requestedCard === "notifications") cardNotifications.expanded = true; + else if (requestedCard === "wifi") cardWifi.expanded = true; + else if (requestedCard === "bluetooth") cardBluetooth.expanded = true; + else if (requestedCard === "audio") cardAudio.expanded = true; + else if (requestedCard === "power") cardPower.expanded = true; + else if (requestedCard === "calendar") cardCalendar.expanded = true; - Process { - id: rebootProcess - command: ["systemctl", "reboot"] - running: false + requestedCard = ""; } - Process { - id: poweroffProcess - command: ["systemctl", "poweroff"] - running: false - } + implicitHeight: Math.min(Theme.commandCenterMaxHeight, + Theme.paddingXl * 2 + root.gridHeight) + height: implicitHeight + readonly property real gridHeight: Math.max(leftColumn.implicitHeight, + rightColumn.implicitHeight) - property string confirmationAction: "" - readonly property bool isConfirming: confirmationAction !== "" + focus: true - function triggerConfirmation(action: string): void { - confirmationAction = action; - root.forceActiveFocus(); + Keys.onEscapePressed: function (event) { + event.accepted = true; + OverlayController.close(); } - function cancelConfirmation(): void { - confirmationAction = ""; - } + Connections { + target: OverlayController + function onOverlayOpened(surface: int): void { + if (surface !== OverlayController.Surface.CommandCenter) + return; - function executeConfirmation(): void { - const action = confirmationAction; - confirmationAction = ""; + root.forceActiveFocus(); - if (action === "reboot") { - SessionService.reboot(); - } else if (action === "poweroff") { - SessionService.poweroff(); - } else if (action === "logout") { - SessionService.logout(); + // A sub-surface request (power menu, wifi rail) opens the panel with + // the matching card already expanded. + // + // Assignment comes before applyRequestedCard(), not after. The call + // used to sit above this block, so it read requestedCard while that + // was still "" -- the only two writers are the two lines below -- + // and returned on the `requestedCard === ""` guard every time. + // + // That reads like a harmless no-op, because Card.qml:45 defaults + // every card to expanded and nothing here ever collapses one. It is + // not: the only writer of expanded=false is the chevron at + // Card.qml:219, so this function is the *re*-expand path for a card + // the user had collapsed. With the call above the assignment, the + // deck's Logout/Reboot/Poweroff routing opened the CCC onto a power + // card the user had collapsed -- the confirmation it routed to, + // hidden behind the very chevron that collapsed it. + if (OverlayController.pendingSessionAction !== "") { + root.requestedCard = "power"; + OverlayController.pendingSessionAction = ""; + } else if (OverlayController.pendingRailView !== "") { + root.requestedCard = OverlayController.pendingRailView; + OverlayController.pendingRailView = ""; + } + root.applyRequestedCard(); } } - property string selectedSsid: "" - property string confirmingForgetSsid: "" - - MouseArea { - id: canvasDismissArea - anchors.fill: parent - onClicked: OverlayController.close() - } + // ------------------------------------------------------------------ panel Rectangle { - id: panelContainer - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - implicitHeight: height - radius: root.isOpen ? Theme.radiusMedium : 17 + anchors.fill: parent + radius: Theme.commandCenterSectionRadius color: Theme.background - border.color: root.isConfirming ? Theme.destructive : (root.isOpen ? Theme.accent : "black") border.width: Theme.borderWidth + border.color: Theme.border clip: true + } - states: [ - State { - name: "open" - when: root.isOpen - PropertyChanges { - target: panelContainer - width: 360 - height: mainLayout.implicitHeight + Theme.paddingXl * 2 - } - }, - State { - name: "closed" - when: !root.isOpen - PropertyChanges { - target: panelContainer - width: 120 - height: 34 - } - } - ] - - transitions: [ - Transition { - from: "closed"; to: "open" - NumberAnimation { properties: "width"; duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.InOutQuad } - }, - Transition { - from: "open"; to: "closed" - NumberAnimation { properties: "width"; duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.InOutQuad } - } - ] + // --------------------------------------------------------------- contents - Behavior on height { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } + Flickable { + id: scroller + anchors.fill: parent + anchors.margins: Theme.paddingXl + contentWidth: width + contentHeight: root.gridHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Row { + id: grid + width: scroller.width + spacing: Theme.commandCenterColumnGutter + + // ------------------------------------------------- left column + + Column { + id: leftColumn + width: (grid.width - Theme.commandCenterColumnGutter) / 2 + spacing: Theme.cardGap + + Card { + id: cardNotifications + width: parent.width + title: NotificationService.history.count > 0 + ? qsTr("Notifications (%1)").arg(NotificationService.history.count) + : qsTr("Notifications") + icon: "bell" + accent: Theme.accentBlue + collapsible: true + + action: Component { + ToggleSwitch { + checked: !NotificationService.doNotDisturb + onToggled: NotificationService.toggleDnd() + } + } - Behavior on radius { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } + contentComponent: Component { + Item { + id: notifBodyRoot + width: parent ? parent.width : undefined + + // Stated explicitly rather than read back from the + // Column below. + // + // Deriving this from notifColumn.implicitHeight + // evaluates once, while the Column's children are + // still being constructed, and then sticks at 0 -- + // the card silently renders with no body at all. + // For a fixed set of three known children, summing + // their heights here is both reliable and clearer + // than asking a positioner to measure itself. + readonly property bool hasAny: + NotificationService.history.count > 0 + + implicitHeight: (hasAny + ? Math.min(208, notifList.contentHeight) + : 0) + + (hasAny ? 22 : 0) // clear-all row + + (hasAny ? 0 : 52) // empty state + + Theme.spacingSmall * 2 + height: implicitHeight + + Column { + id: notifColumn + width: parent.width + spacing: Theme.spacingSmall - MouseArea { - id: insideClickConsumer - anchors.fill: parent - hoverEnabled: true - preventStealing: true - onClicked: function (mouse) { - mouse.accepted = true; - } - } + // Capped so a burst of notifications cannot + // push the other cards off the panel. The list + // scrolls past the cap on its own. + ListView { + id: notifList + width: parent.width + // Column measures its children's + // implicitHeight, not their height, so the + // list has to declare both or the card + // measures as empty. + implicitHeight: Math.min(contentHeight, 208) + height: implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: Theme.spacingSmall + model: NotificationService.history + visible: count > 0 + + delegate: Rectangle { + id: notifRow + required property int index + required property string appName + required property string summary + required property string body + required property int urgency + required property string timestamp + + width: notifList.width + height: notifBody.implicitHeight + Theme.spacingMedium * 2 + radius: Theme.radiusMedium + color: notifHover.containsMouse ? Theme.surfaceHover : Theme.surfaceElevated + border.width: Theme.borderWidth + border.color: Theme.border + + Behavior on color { + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } + } - CornerBrackets { - id: cornerBrackets - bracketColor: root.isConfirming ? Theme.destructive : Theme.acidGreen - margin: Theme.cornerBracketMargin - armLength: Theme.cornerBracketArmLength - thickness: Theme.cornerBracketThickness - z: 10 - opacity: root.isOpen ? 1.0 : 0.0 - visible: opacity > 0.0 - - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - } + // Critical is red, normal is structural blue, + // low is dim. The previous mapping used + // acidGreen for normal, which is a magenta + // alias and made every routine + // notification look like an alert. + readonly property color urgencyColor: + urgency === 2 ? Theme.destructive + : urgency === 0 ? Theme.textDisabled + : Theme.accentBlue + + MouseArea { + id: notifHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } - property string activeAccordion: "" - readonly property alias expandedAccordion: panelContainer.activeAccordion + // Urgency stripe down the leading edge. + Rectangle { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + width: 2 + height: parent.height - Theme.spacingMedium + radius: 1 + color: notifRow.urgencyColor + } - function toggleAccordion(id: string): void { - activeAccordion = (activeAccordion === id ? "" : id); - } + // App badge. Reference shows a per-app + // mark; we have no app icon pipeline here, + // so the initial stands in. + Rectangle { + id: notifBadge + anchors.left: parent.left + anchors.leftMargin: Theme.spacingMedium + anchors.top: parent.top + anchors.topMargin: Theme.spacingMedium + width: 18 + height: 18 + radius: Theme.radiusSmall + color: notifRow.urgencyColor + + Text { + anchors.centerIn: parent + text: notifRow.appName.length > 0 + ? notifRow.appName.charAt(0).toUpperCase() : "?" + color: Theme.navyDeep + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightBold + } + } - Component { - id: dndHeaderAction - - Rectangle { - id: dndButton - width: dndLabel.implicitWidth + Theme.paddingMedium * 2 - height: 24 - border.color: NotificationService.doNotDisturb ? Theme.warningRed : (dndMouseArea.containsMouse ? Theme.accent : Theme.borderMuted) - border.width: Theme.borderWidth - color: NotificationService.doNotDisturb ? Theme.surfaceSelected : (dndMouseArea.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - - Text { - id: dndLabel - anchors.centerIn: parent - color: NotificationService.doNotDisturb ? Theme.warningRed : (dndMouseArea.containsMouse ? Theme.accent : Theme.textSecondary) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: NotificationService.doNotDisturb ? "[DND]" : "[DND OFF]" - } + Column { + id: notifBody + anchors.left: notifBadge.right + anchors.leftMargin: Theme.spacingSmall + anchors.right: dismissButton.left + anchors.rightMargin: Theme.spacingSmall + anchors.top: parent.top + anchors.topMargin: Theme.spacingSmall + spacing: 2 + + Text { + width: parent.width + text: notifRow.summary + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight + } - MouseArea { - id: dndMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - preventStealing: true - onClicked: function (mouse) { - mouse.accepted = true; - NotificationService.toggleDnd(); - } - } - } - } + Text { + width: parent.width + text: notifRow.appName + " " + notifRow.timestamp + color: Theme.textSecondary + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + elide: Text.ElideRight + } - Component { - id: wifiHeaderAction - - Rectangle { - id: wifiToggleBtn - width: 60 - height: 24 - color: NetworkService.wifiEnabled ? Theme.surfaceSelected : (wifiToggleMouse.containsMouse ? Theme.surfaceHover : "transparent") - border.color: NetworkService.wifiEnabled ? Theme.acidGreen : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: NetworkService.wifiEnabled ? "ON" : "OFF" - color: NetworkService.wifiEnabled ? Theme.acidGreen : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } + Text { + width: parent.width + visible: notifRow.body.length > 0 + text: notifRow.body + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + maximumLineCount: 2 + elide: Text.ElideRight + wrapMode: Text.WordWrap + } + } - MouseArea { - id: wifiToggleMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - preventStealing: true - onClicked: function (mouse) { - mouse.accepted = true; - NetworkService.toggleWifi(); - } - } - } - } + Text { + id: dismissButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.top: parent.top + anchors.topMargin: Theme.spacingSmall + width: 16 + height: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: "\u2715" + color: notifHover.containsMouse ? Theme.textPrimary : Theme.textDisabled + font.pixelSize: Theme.fontSizeSmall - Component { - id: btHeaderAction - - Rectangle { - id: btToggleBtn - width: 60 - height: 24 - color: BluetoothService.powered ? Theme.surfaceSelected : (btToggleMouse.containsMouse ? Theme.surfaceHover : "transparent") - border.color: BluetoothService.powered ? Theme.acidGreen : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: BluetoothService.powered ? "ON" : "OFF" - color: BluetoothService.powered ? Theme.acidGreen : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissHistoryItem(notifRow.index) + } + } + } + } - MouseArea { - id: btToggleMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - preventStealing: true - onClicked: function (mouse) { - mouse.accepted = true; - BluetoothService.togglePower(); - } - } - } - } + // Empty state. + Item { + width: parent.width + implicitHeight: visible ? 52 : 0 + height: implicitHeight + visible: NotificationService.history.count === 0 - Component { - id: audioHeaderAction - - Rectangle { - id: audioToggleBtn - width: 70 - height: 24 - color: AudioService.muted ? Theme.surfaceSelected : (audioToggleMouse.containsMouse ? Theme.surfaceHover : "transparent") - border.color: AudioService.muted ? Theme.warningRed : Theme.acidGreen - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: AudioService.muted ? "MUTED" : "UNMUTE" - color: AudioService.muted ? Theme.destructive : Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } + Text { + anchors.centerIn: parent + text: NotificationService.doNotDisturb + ? qsTr("Do not disturb is on") + : qsTr("Nothing new") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + } + } + + // Clear all, only when there is something to + // clear -- an always-present button on an empty + // list is a control that can do nothing. + Item { + width: parent.width + implicitHeight: visible ? 22 : 0 + height: implicitHeight + visible: NotificationService.history.count > 0 - MouseArea { - id: audioToggleMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - preventStealing: true - onClicked: function (mouse) { - mouse.accepted = true; - AudioService.toggleMute(); + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Clear all") + color: clearHover.containsMouse ? Theme.textPrimary : Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + + MouseArea { + id: clearHover + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.clearAll() + } + } + } + } + } } } - } - } - Column { - id: mainLayout - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Theme.paddingXl - spacing: Theme.spacingMedium - opacity: root.isOpen ? 1.0 : 0.0 - visible: opacity > 0.0 - - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } + Card { + id: cardWifi + width: parent.width + title: qsTr("Wi-Fi") + // Reports the wireless link only. This card's body lists + // wireless networks, so a header driven by the global + // wired-first connectionType made it announce "Ethernet" + // above a list of Wi-Fi networks. + subtitle: NetworkService.wifiConnected + ? (NetworkService.wifiNetworkName !== "" + ? NetworkService.wifiNetworkName + : qsTr("Connected")) + : (NetworkService.wifiEnabled ? "" : qsTr("Off")) + icon: "wifi" + accent: Theme.accentBlue + collapsible: true + // Collapsed by default, like the design: the header already + // says which network you are on, and the rest of the list is + // one click away. Expanded by default this card listed five + // networks and pushed Bluetooth and Audio off the panel. + expanded: false + + action: Component { + ToggleSwitch { + checked: NetworkService.wifiEnabled + onToggled: NetworkService.toggleWifi() + } + } - move: Transition { - NumberAnimation { - properties: "y" - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } + // Selection state for the inline flows. Per-card rather than + // global so closing and reopening the panel starts clean. + property string selectedSsid: "" + property string confirmingForgetSsid: "" + + readonly property bool passwordPromptOpen: + selectedSsid !== "" && wifiNeedsPassword(selectedSsid) - // 1. Calendar Accordion - AccordionSection { - id: secCalendar - width: parent ? parent.width : undefined - title: "// CALENDAR" - icon: "timer" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "calendar" - onHeaderClicked: panelContainer.toggleAccordion("calendar") - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: calPopup.implicitHeight - height: calPopup.implicitHeight - - CalendarPopup { - id: calPopup - anchors.horizontalCenter: parent.horizontalCenter - width: parent ? Math.min(300, parent.width) : 300 - onCloseRequested: panelContainer.toggleAccordion("calendar") + function wifiNeedsPassword(ssid: string): bool { + const list = NetworkService.availableNetworks || []; + for (let i = 0; i < list.length; i++) { + if (list[i].ssid === ssid) + return list[i].requiresPassword && !list[i].known; } + return false; } - } - } - // 2. Notifications Accordion - AccordionSection { - id: secNotifications - width: parent ? parent.width : undefined - title: "// EVENT LOG" - icon: "warning" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "notifications" - onHeaderClicked: panelContainer.toggleAccordion("notifications") - headerAction: dndHeaderAction - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: 320 - height: 320 - - ColumnLayout { - anchors.fill: parent - spacing: Theme.spacingSmall + // Closes the password prompt without attempting a join. + // Clearing selectedSsid also clears the row's selected + // highlight, which is what the prompt's visibility is keyed + // off, so there is no second piece of state to reset. + function dismissPasswordPrompt(): void { + selectedSsid = ""; + } - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ListView { - id: historyListView - anchors.fill: parent - clip: true - boundsBehavior: Flickable.StopAtBounds - spacing: Theme.spacingSmall - model: NotificationService.history - visible: NotificationService.history.count > 0 - - delegate: Rectangle { - id: cardItem - required property int index - required property var notifId - required property string appName - required property string summary - required property string body - required property int urgency - required property string timestamp - - width: historyListView.width - implicitHeight: cardLayout.implicitHeight + Theme.paddingMedium * 2 - color: Theme.gray800 - border.color: Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - readonly property color urgencyColor: { - if (cardItem.urgency === 2) { - return Theme.warningRed; - } - if (cardItem.urgency === 0) { - return Theme.textMuted; - } - return Theme.acidGreen; + function signalFor(ssid: string): real { + const list = NetworkService.availableNetworks || []; + for (let i = 0; i < list.length; i++) { + if (list[i].ssid === ssid) + return list[i].signalStrength; } + return 0; + } - // Urgency accent stripe on left edge - Rectangle { - id: urgencyStripe - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: cardItem.urgencyColor - radius: Theme.radiusSmall - width: 3 + // Choose what clicking a network does. Selecting a secured + // unknown network opens the prompt instead of connecting, + // because there is no key to connect with. + function activate(ssid: string, known: bool, connected: bool): void { + if (connected) + return; + if (known) { + NetworkService.connectToNetwork(ssid); + return; } + if (wifiNeedsPassword(ssid)) { + selectedSsid = cardWifi.selectedSsid === ssid ? "" : ssid; + confirmingForgetSsid = ""; + } else { + NetworkService.connectToNetwork(ssid); + } + } - ColumnLayout { - id: cardLayout - anchors.left: urgencyStripe.right - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Theme.paddingMedium - spacing: Theme.spacingSmall - - // Header line: Urgency Dot, App Name (left), Timestamp (right) - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: cardItem.urgencyColor - radius: 3 + contentComponent: Component { + Item { + id: wifiBody + width: parent ? parent.width : undefined + + readonly property var nets: NetworkService.availableNetworks || [] + readonly property int netCount: nets.length + + // A dense urban environment lists twenty-odd + // networks, and rendering every one of them gave the + // Wi-Fi card the entire column. Five is enough to + // find a network by; the rest are reachable by + // scanning once the ones you care about are + // remembered as known and sort to the top. + readonly property int connectedCount: { + let n = 0; + for (let i = 0; i < nets.length; i++) { + if (nets[i].connected) + n++; } + return n; + } - Text { - Layout.fillWidth: true - color: Theme.textSecondary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: cardItem.appName.toUpperCase() - } + // Collapsed, only the connected network is listed -- + // the one fact the header cannot show. Expanded, the + // full list up to the cap. + readonly property int visibleCount: cardWifi.expanded + ? Math.min(netCount, 5) + : Math.min(netCount, connectedCount) + + readonly property bool hasOverflow: + cardWifi.expanded && netCount > visibleCount + + // 22 for the scan row; 34 per network; the password prompt's + // own promptHeight; 26 for a forget + // confirmation; 18 for the overflow note. The + // trailing term is the Column's own spacing between + // its children, which is not part of any child's + // height and was being left out -- the last row and + // the overflow note were clipped by the card. It + // counts visibleCount + 1 + overflow because the + // prompt and the forget confirmation are mutually + // exclusive, so exactly one of them supplies the + // second "+1". + implicitHeight: NetworkService.wifiEnabled && NetworkService.available + ? 22 + + visibleCount * 34 + + (cardWifi.passwordPromptOpen ? pwPrompt.promptHeight : 0) + + (cardWifi.confirmingForgetSsid !== "" ? 26 : 0) + + (hasOverflow ? 18 : 0) + + Theme.spacingSmall + * (cardWifi.expanded + ? (visibleCount + 1 + (hasOverflow ? 1 : 0)) + : 0) + : 0 + height: implicitHeight + visible: height > 0 - Text { - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: cardItem.timestamp + Text { + anchors.left: parent.left + anchors.top: parent.top + height: visible ? 22 : 0 + visible: cardWifi.expanded + verticalAlignment: Text.AlignVCenter + text: NetworkService.isScanning + ? qsTr("Scanning...") : qsTr("Scan for networks") + color: scanHover.containsMouse + ? Theme.textPrimary : Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + + MouseArea { + id: scanHover + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NetworkService.toggleScan() } } - // Summary text Text { - Layout.fillWidth: true - color: Theme.textPrimary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace + anchors.left: parent.left + anchors.right: parent.right + anchors.top: scanRowProxy.bottom + anchors.topMargin: Theme.spacingSmall + height: visible ? 30 : 0 + visible: NetworkService.available && wifiBody.netCount === 0 + verticalAlignment: Text.AlignVCenter + text: NetworkService.isConnecting + ? qsTr("Connecting...") : qsTr("No networks found") + color: Theme.textSecondary + font.family: Theme.fontFamilySans font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: cardItem.summary } - // Body text (word wrap, max 3 lines, elide) - Text { - Layout.fillWidth: true - color: Theme.textSecondary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - maximumLineCount: 3 - text: cardItem.body - visible: cardItem.body !== "" - wrapMode: Text.WordWrap + Item { + id: scanRowProxy + anchors.left: parent.left + anchors.top: parent.top + width: 1 + height: cardWifi.expanded ? 22 : 0 } - // Dismiss action button - RowLayout { - Layout.fillWidth: true - - Item { - Layout.fillWidth: true - } - - Rectangle { - id: dismissBtn - Layout.preferredHeight: 20 - Layout.preferredWidth: dismissText.implicitWidth + Theme.paddingSmall * 2 - border.color: dismissMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: dismissMouseArea.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: dismissText - anchors.centerIn: parent - color: dismissMouseArea.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: "[DISMISS]" - } + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: scanRowProxy.bottom + anchors.topMargin: Theme.spacingSmall + spacing: Theme.spacingSmall - MouseArea { - id: dismissMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: NotificationService.dismissHistoryItem(cardItem.index) - } - } - } - } - } - } + Repeater { + // Index into the full list rather than a + // modelData delegate, so the visible rows can + // be a prefix of a longer array. + model: NetworkService.wifiEnabled && NetworkService.available + ? wifiBody.visibleCount : 0 - // Scrollbar track & thumb - Rectangle { - id: scrollTrack - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.top: parent.top - color: Theme.gray800 - radius: 2 - visible: historyListView.visible && (historyListView.height < historyListView.contentHeight) - width: 4 - - Rectangle { - id: scrollThumb - color: Theme.acidGreen - height: Math.max(16, historyListView.visibleArea.heightRatio * historyListView.height) - radius: 2 - width: 4 - y: historyListView.visibleArea.yPosition * historyListView.height - } - } + delegate: Item { + id: netRow + required property int index - // Empty state container - Item { - id: emptyState - anchors.fill: parent - visible: NotificationService.history.count === 0 - - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingMedium - - Rectangle { - Layout.alignment: Qt.AlignHCenter - Layout.preferredHeight: 8 - Layout.preferredWidth: 8 - color: Theme.textMuted - radius: 4 - } + readonly property var net: wifiBody.nets[index] + readonly property string ssid: net.ssid + readonly property bool connected: net.connected + readonly property bool known: net.known + readonly property bool selected: + cardWifi.selectedSsid === ssid - Text { - Layout.alignment: Qt.AlignHCenter - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: "NO NOTIFICATIONS // STANDBY" - } - } - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Theme.borderWidth - color: Theme.borderMuted - } + width: wifiBody.width + height: 34 - // Footer Action Row - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingMedium - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: NotificationService.history.count > 0 - ? NotificationService.history.count + " LOGGED" - : "0 LOGGED" - } + Rectangle { + anchors.fill: parent + radius: Theme.radiusMedium + color: netRow.connected || netRowHover.containsMouse + ? Theme.surfaceHover : Theme.surfaceElevated + border.width: Theme.borderWidth + border.color: netRow.selected + ? Theme.borderHover + : (netRow.connected ? Theme.statusGreen : Theme.border) - Rectangle { - id: clearAllBtn - Layout.preferredHeight: 26 - Layout.preferredWidth: clearAllText.implicitWidth + Theme.paddingMedium * 2 - border.color: NotificationService.history.count === 0 - ? Theme.borderMuted - : (clearMouseArea.containsMouse ? Theme.destructive : Theme.borderMuted) - border.width: Theme.borderWidth - color: NotificationService.history.count === 0 - ? "transparent" - : (clearMouseArea.containsMouse ? Theme.surfaceHover : "transparent") - opacity: NotificationService.history.count === 0 ? 0.4 : 1.0 - radius: Theme.radiusSmall - - Text { - id: clearAllText - anchors.centerIn: parent - color: NotificationService.history.count === 0 - ? Theme.textMuted - : (clearMouseArea.containsMouse ? Theme.destructive : Theme.textPrimary) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "[CLEAR ALL]" - } + Behavior on color { + ColorAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + } + } + } - MouseArea { - id: clearMouseArea - anchors.fill: parent - cursorShape: NotificationService.history.count > 0 ? Qt.PointingHandCursor : Qt.ArrowCursor - enabled: NotificationService.history.count > 0 - hoverEnabled: NotificationService.history.count > 0 - onClicked: NotificationService.clearAll() - } - } - } + MouseArea { + id: netRowHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cardWifi.activate( + netRow.ssid, netRow.known, netRow.connected) + } - } - } - } - } - } + GlyphIcon { + id: netGlyph + anchors.left: parent.left + anchors.leftMargin: Theme.spacingMedium + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + glyph: "wifi" + color: netRow.connected ? Theme.statusGreen + : (netRow.known ? Theme.textSecondary : Theme.textDisabled) + } - // 3. Network/Wi-Fi Accordion - AccordionSection { - id: secWifi - width: parent ? parent.width : undefined - title: NetworkService.isEthernet ? "// NETWORK (ETHERNET)" : "// WI-FI" - icon: NetworkService.isEthernet ? "globe" : "wifi" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "wifi" - onHeaderClicked: panelContainer.toggleAccordion("wifi") - headerAction: wifiHeaderAction - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: 350 - height: 350 - - ColumnLayout { - anchors.fill: parent - - // Status Subheader - RowLayout { - Layout.fillWidth: true + Text { + anchors.left: netGlyph.right + anchors.leftMargin: Theme.spacingSmall + anchors.right: strengthText.left + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + text: netRow.ssid + color: netRow.connected || netRow.known + ? Theme.textPrimary : Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + font.weight: netRow.connected + ? Theme.fontWeightDemiBold : Theme.fontWeightNormal + elide: Text.ElideRight + } - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: NetworkService.isEthernet ? "// WIRED CONNECTION ACTIVE" : (NetworkService.wifiEnabled ? "// AVAILABLE NETWORKS" : "// WI-FI ADAPTER OFF") + Text { + id: strengthText + anchors.right: parent.right + anchors.rightMargin: Theme.spacingMedium + anchors.verticalCenter: parent.verticalCenter + text: Math.round(netRow.net.signalStrength * 100) + "%" + color: Theme.textSecondary + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + } + } } Text { - color: NetworkService.isConnecting ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace + anchors.left: parent.left + anchors.right: parent.right + height: visible ? 18 : 0 + visible: wifiBody.hasOverflow + verticalAlignment: Text.AlignVCenter + text: qsTr("+ %1 more").arg(wifiBody.netCount - wifiBody.visibleCount) + color: Theme.textSecondary + font.family: Theme.fontFamilySans font.pixelSize: Theme.fontSizeCaption - font.weight: NetworkService.isConnecting ? Theme.fontWeightBold : Theme.fontWeightNormal - text: { - if (NetworkService.isEthernet) return "CONNECTED"; - if (!NetworkService.wifiEnabled) return "STANDBY"; - if (NetworkService.isConnecting) return "CONNECTING..."; - return NetworkService.availableNetworks ? NetworkService.availableNetworks.length + " FOUND" : "SCANNING..."; - } } - } - // Ethernet Active Connection Box - Rectangle { - id: ethernetActiveBox - visible: NetworkService.isEthernet - Layout.fillWidth: true - Layout.preferredHeight: 36 - border.color: Theme.acidGreen - border.width: Theme.borderWidth - color: Theme.surfaceSelected - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - - CtosIcon { - active: true - name: "globe" - size: 14 - color: Theme.acidGreen - } + // Password prompt, for a secured network with no + // stored profile. +Rectangle { + id: pwPrompt + anchors.left: parent.left + anchors.right: parent.right + visible: cardWifi.passwordPromptOpen + radius: Theme.radiusMedium + color: Theme.surfaceDeep + border.width: Theme.borderWidth + border.color: Theme.border + + // Field and button heights are declared here + // and consumed below so the prompt can never be + // shorter than the stack it contains. + readonly property int fieldHeight: 28 + readonly property int buttonHeight: 26 + + // Single source of truth for the prompt's + // height: the Column's implicitHeight far below + // adds this same value, and the two drifting + // apart is what clipped the overflow note + // before. Label height is measured rather than + // guessed so a font change cannot desync it. + readonly property int promptHeight: + Theme.spacingSmall * 2 + + pwLabel.implicitHeight + + Theme.spacingSmall + + fieldHeight + + Theme.spacingSmall + + buttonHeight + + height: visible ? promptHeight : 0 Text { - Layout.fillWidth: true - color: Theme.acidGreen + id: pwLabel + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.top: parent.top + anchors.topMargin: Theme.spacingSmall + text: qsTr("Password for %1").arg(cardWifi.selectedSsid) + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: NetworkService.networkName !== "--N/A--" && NetworkService.networkName !== "" ? NetworkService.networkName.toUpperCase() : "ETHERNET" + width: parent.width - Theme.spacingSmall * 2 } - Text { - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "[CONNECTED]" - } - } - } + // Field background drawn as a sibling rather + // than a `background:` property, which is a + // QtQuick.Controls TextField feature -- and + // this is a core TextInput, so the whole card + // failed to load with "non-existent property". + Item { + id: pwField + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.top: pwLabel.bottom + anchors.topMargin: Theme.spacingSmall + height: pwPrompt.fieldHeight - // Active Connection Status Banner (Tier 2 Dedicated Banner) - Rectangle { - id: connectingBanner - visible: NetworkService.isConnecting - Layout.fillWidth: true - Layout.preferredHeight: 28 - color: Theme.surfaceSelected - border.color: Theme.accent - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingMedium - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 8 - Layout.preferredWidth: 8 - color: Theme.accent - radius: 4 - } + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: Theme.background + border.width: Theme.borderWidth + border.color: pwInput.activeFocus + ? Theme.borderHover : Theme.border + } - Text { - Layout.fillWidth: true - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "CONNECTING TO " + (NetworkService.connectingSsid !== "" ? NetworkService.connectingSsid.toUpperCase() : "NETWORK") + "..." - elide: Text.ElideRight - } - } - } + TextInput { + id: pwInput + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + color: Theme.textPrimary + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeSmall + echoMode: TextInput.Password + selectionColor: Theme.accentBlue + selectedTextColor: Theme.navyDeep + onAccepted: submitHover.triggered() + + Text { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + visible: pwInput.text === "" + text: qsTr("Network password") + color: Theme.textDisabled + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + } + } + } - // Connection Error Notice Banner - Rectangle { - id: connectionErrorBanner - visible: !NetworkService.isConnecting && NetworkService.lastError !== "" - Layout.fillWidth: true - Layout.preferredHeight: 28 - color: Theme.surface - border.color: Theme.warningRed - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingMedium - spacing: Theme.spacingSmall - - CtosIcon { - name: "warning" - size: 14 - color: Theme.warningRed - } - - Text { - Layout.fillWidth: true - color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "FAILED: " + NetworkService.lastError.toUpperCase() - elide: Text.ElideRight - } - } - } - - // Scrollable List of Available SSIDs - Rectangle { - id: networkListContainer - Layout.fillWidth: true - Layout.fillHeight: true - color: Theme.gray800 // Near-black - border.color: Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - clip: true - - // Placeholder when disabled or empty - Text { - anchors.centerIn: parent - visible: !NetworkService.wifiEnabled || !NetworkService.availableNetworks || NetworkService.availableNetworks.length === 0 - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - horizontalAlignment: Text.AlignHCenter - text: NetworkService.isEthernet - ? "// ETHERNET CONNECTED\n" + (!NetworkService.wifiEnabled ? "WI-FI IS DISABLED" : "NO WI-FI NETWORKS FOUND") - : (!NetworkService.wifiEnabled ? "WI-FI IS DISABLED\nUSE [ENABLE WI-FI] ABOVE" : "SCANNING NETWORKS...") - } - - ListView { - id: networkListView - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - clip: true - model: NetworkService.wifiEnabled ? NetworkService.availableNetworks : [] - - delegate: ColumnLayout { - id: networkItemDelegate - required property var modelData - width: networkListView.width - spacing: 2 - - readonly property var netData: modelData - readonly property string itemSsid: (netData && netData.ssid) ? netData.ssid : "" - readonly property real itemStrength: (netData && typeof netData.signalStrength === "number") ? netData.signalStrength : 0.0 - readonly property bool itemIsKnown: Boolean(netData && (netData.known || netData.isKnown || netData.saved)) - readonly property bool itemIsConnected: Boolean(netData && (netData.connected || netData.isConnected)) || (NetworkService.networkName === itemSsid && NetworkService.networkName !== "--N/A--" && NetworkService.networkName !== "") - readonly property bool itemIsConnecting: Boolean(NetworkService.connectingSsid !== "" && (NetworkService.connectingSsid === itemSsid || (netData && NetworkService.connectingSsid === netData.rawSsid))) - readonly property bool isSelected: root.selectedSsid === itemSsid - readonly property bool showPasswordPrompt: isSelected && !itemIsKnown && !itemIsConnected - readonly property bool showConnectedActions: isSelected && itemIsConnected - readonly property bool showKnownActions: isSelected && itemIsKnown && !itemIsConnected - readonly property bool isConfirmingForget: root.confirmingForgetSsid === itemSsid - readonly property bool isExpanded: showPasswordPrompt - - // Main Network Entry Tile - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 36 - border.color: itemIsConnected ? Theme.acidGreen : (itemIsConnecting ? Theme.accent : (itemMouseArea.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: itemIsConnected ? Theme.surfaceSelected : (itemIsConnecting ? Theme.surfaceSelected : (itemMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface)) - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - - CtosIcon { - active: itemIsConnected || itemIsConnecting - name: "wifi" - size: 14 - color: (itemIsConnected || itemIsConnecting) ? Theme.acidGreen : (itemMouseArea.containsMouse ? Theme.accent : Theme.textSecondary) - } - - Text { - Layout.fillWidth: true - color: (itemIsConnected || itemIsConnecting) ? Theme.acidGreen : Theme.textPrimary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: (itemIsConnected || itemIsConnecting) ? Theme.fontWeightBold : Theme.fontWeightNormal - text: itemSsid !== "" ? itemSsid : "[HIDDEN NETWORK]" - } - - // Saved Badge - Rectangle { - visible: itemIsKnown && !itemIsConnected && !itemIsConnecting - Layout.preferredHeight: 16 - Layout.preferredWidth: 46 - color: "transparent" - border.color: Theme.accent - border.width: 1 - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "SAVED" - } - } - - // Connecting Badge (Tier 3) - Rectangle { - visible: itemIsConnecting - Layout.preferredHeight: 16 - Layout.preferredWidth: 88 - color: "transparent" - border.color: Theme.accent - border.width: 1 - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "[CONNECTING...]" - } - } - - // Signal Strength Percentage - Text { - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: Math.round(itemStrength <= 1.0 ? itemStrength * 100 : itemStrength) + "%" - } - - // Connected Badge - Text { - visible: itemIsConnected - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "[CONNECTED]" - } - } - - MouseArea { - id: itemMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: { - if (root.selectedSsid === itemSsid) { - root.selectedSsid = ""; - root.confirmingForgetSsid = ""; - } else { - root.selectedSsid = itemSsid; - root.confirmingForgetSsid = ""; - } - } - } - } - - // Connected Network Action Drawer - Rectangle { - id: connectedActionsBox - visible: showConnectedActions - Layout.fillWidth: true - Layout.preferredHeight: 36 - color: Theme.gray900 - border.color: Theme.warningRed - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingMedium - spacing: Theme.spacingSmall - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: "// ACTIVE CONNECTION" - } - - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: 84 - border.color: Theme.warningRed - border.width: Theme.borderWidth - color: disconnectMouseArea.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "DISCONNECT" - } - - MouseArea { - id: disconnectMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - NetworkService.disconnectCurrentNetwork(); - root.selectedSsid = ""; - } - } - } - } - } - - // Saved/Known Network Management Drawer - Rectangle { - id: knownActionsBox - visible: showKnownActions - Layout.fillWidth: true - Layout.preferredHeight: 36 - color: Theme.gray900 - border.color: isConfirmingForget ? Theme.warningRed : Theme.accent - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - // Normal Actions: [CONNECT] and [FORGET] - RowLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingMedium - spacing: Theme.spacingSmall - visible: !isConfirmingForget - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: "// SAVED PROFILE" - } - - // Connect Button - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: itemIsConnecting ? 92 : 68 - border.color: itemIsConnecting ? Theme.accent : Theme.acidGreen - border.width: Theme.borderWidth - color: itemIsConnecting ? Theme.surfaceSelected : (knownConnectMouseArea.containsMouse ? Theme.surfaceSelected : "transparent") - radius: Theme.radiusSmall - opacity: (NetworkService.isConnecting && !itemIsConnecting) ? 0.5 : 1.0 - - Text { - anchors.centerIn: parent - color: itemIsConnecting ? Theme.accent : Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: itemIsConnecting ? "CONNECTING..." : "CONNECT" - } - - MouseArea { - id: knownConnectMouseArea - anchors.fill: parent - cursorShape: itemIsConnecting ? Qt.ArrowCursor : Qt.PointingHandCursor - hoverEnabled: !itemIsConnecting - enabled: !NetworkService.isConnecting - onClicked: { - NetworkService.connectToNetwork(itemSsid); - root.selectedSsid = ""; - } - } - } - - // Forget Button - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: 62 - border.color: Theme.warningRed - border.width: Theme.borderWidth - color: forgetMouseArea.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "FORGET" - } - - MouseArea { - id: forgetMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - root.confirmingForgetSsid = itemSsid; - } - } - } - } - - // Inline Confirmation Prompt: "FORGET ? [YES] [NO]" - RowLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingMedium - spacing: Theme.spacingSmall - visible: isConfirmingForget - - Text { - Layout.fillWidth: true - color: Theme.warningRed - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "FORGET " + itemSsid + "?" - } - - // YES Button - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: 42 - border.color: Theme.warningRed - border.width: Theme.borderWidth - color: forgetYesMouseArea.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall + // Cancel, left of Join and the same size. The prompt was + // dismissable only by clicking the + // selected network row again, which toggles + // selectedSsid -- true, but nothing on screen + // said so, so a prompt opened by mistake + // looked like it could not be closed. + Rectangle { + id: pwCancelBtn + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.top: pwField.bottom + anchors.topMargin: Theme.spacingSmall + // Half the panel's inner width: two margins, + // the field's, plus the gap between them. + // Join copies this value, so the two are + // equal by construction rather than by two + // independent calculations agreeing. + width: Math.floor((pwPrompt.width + - Theme.spacingSmall * 3) / 2) + height: pwPrompt.buttonHeight + radius: Theme.radiusSmall + color: pwCancelHover.containsMouse ? Theme.surfaceHover : "transparent" + border.width: Theme.borderWidth + border.color: Theme.border - Text { - anchors.centerIn: parent - color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "YES" - } + Text { + id: pwCancelLabel + anchors.centerIn: parent + text: qsTr("Cancel") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + } - MouseArea { - id: forgetYesMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - NetworkService.forgetNetwork(itemSsid); - root.confirmingForgetSsid = ""; - root.selectedSsid = ""; + MouseArea { + id: pwCancelHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cardWifi.dismissPasswordPrompt() + } } - } - } - - // NO Button - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: 42 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: forgetNoMouseArea.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "NO" - } - MouseArea { - id: forgetNoMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - root.confirmingForgetSsid = ""; - } - } - } - } - } + Row { + id: pwSubmit + anchors.left: pwCancelBtn.right + anchors.leftMargin: Theme.spacingSmall + anchors.top: pwCancelBtn.top + width: pwCancelBtn.width + height: pwPrompt.buttonHeight + property bool clicked: false - // Inline Password Prompt (Terminal Prompt directly in the Rail) - Rectangle { - id: passwordPromptBox - visible: isExpanded - Layout.fillWidth: true - Layout.preferredHeight: 64 - color: Theme.gray900 - border.color: Theme.acidGreen - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Text { - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "PASSWORD >_" - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 22 - color: Theme.gray800 - border.color: pwInput.activeFocus ? Theme.acidGreen : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - TextInput { - id: pwInput - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - verticalAlignment: TextInput.AlignVCenter - color: Theme.textPrimary - echoMode: TextInput.Password - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - focus: isExpanded - selectByMouse: true + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: submitHover.containsMouse ? Theme.accent : Theme.surfaceSelected + border.width: Theme.borderWidth + border.color: Theme.accent + } - onAccepted: { - if (pwInput.text.trim() === "" || pwInput.text.length === 0) { - return; - } - NetworkService.connectToNetwork(itemSsid, pwInput.text); - root.selectedSsid = ""; - pwInput.text = ""; + Text { + id: pwSubmitLabel + anchors.centerIn: parent + text: qsTr("Join") + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold } - Keys.onEscapePressed: function (event) { - root.selectedSsid = ""; - pwInput.text = ""; - event.accepted = true; + MouseArea { + id: submitHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // Submit is shared between the button and + // Enter in the field. + function triggered(): void { + if (pwInput.text === "") + return; + NetworkService.connectToNetwork( + cardWifi.selectedSsid, pwInput.text); + pwInput.text = ""; + cardWifi.selectedSsid = ""; + } + onClicked: { + if (pwInput.text === "") + return; + NetworkService.connectToNetwork( + cardWifi.selectedSsid, pwInput.text); + pwInput.text = ""; + cardWifi.selectedSsid = ""; + } } } } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - text: "[ENTER] Connect [ESC] Cancel" - } + // Forget confirmation. Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: itemIsConnecting ? 92 : 62 - border.color: itemIsConnecting ? Theme.accent : Theme.acidGreen - border.width: Theme.borderWidth - color: itemIsConnecting ? Theme.surfaceSelected : (pwConnectArea.containsMouse ? Theme.surfaceSelected : "transparent") + anchors.left: parent.left + anchors.right: parent.right + height: visible ? 26 : 0 + visible: cardWifi.confirmingForgetSsid !== "" radius: Theme.radiusSmall - opacity: (NetworkService.isConnecting && !itemIsConnecting) ? 0.5 : 1.0 + color: Theme.surfaceDeep + border.width: Theme.borderWidth + border.color: Theme.destructive Text { - anchors.centerIn: parent - color: itemIsConnecting ? Theme.accent : Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: itemIsConnecting ? "CONNECTING..." : "CONNECT" - } - - MouseArea { - id: pwConnectArea - anchors.fill: parent - cursorShape: itemIsConnecting ? Qt.ArrowCursor : Qt.PointingHandCursor - hoverEnabled: !itemIsConnecting - enabled: !NetworkService.isConnecting - onClicked: { - if (pwInput.text.trim() === "" || pwInput.text.length === 0) { - return; - } - NetworkService.connectToNetwork(itemSsid, pwInput.text); - root.selectedSsid = ""; - pwInput.text = ""; - } - } - } - } - } - } - } - } - } - - } - } - } - } - - // 4. Bluetooth Accordion - AccordionSection { - id: secBluetooth - width: parent ? parent.width : undefined - title: "// BLUETOOTH" - icon: "bluetooth" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "bluetooth" - onHeaderClicked: panelContainer.toggleAccordion("bluetooth") - headerAction: btHeaderAction - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: 320 - height: 320 - - ColumnLayout { - anchors.fill: parent - spacing: Theme.spacingSmall - - // Status Subheader with Scan Button - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: !BluetoothService.powered - ? "// BLUETOOTH ADAPTER OFF" - : (BluetoothService.isScanning ? "// SCANNING FOR DEVICES..." : "// BLUETOOTH DEVICES") - } - - Rectangle { - id: btScanBtn - Layout.preferredHeight: 22 - Layout.preferredWidth: btScanLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: !BluetoothService.powered ? Theme.borderMuted : (BluetoothService.isScanning ? Theme.accent : (btScanMouse.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: BluetoothService.isScanning ? Theme.surfaceSelected : (btScanMouse.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - opacity: BluetoothService.powered ? 1.0 : 0.4 - - Text { - id: btScanLabel - anchors.centerIn: parent - color: !BluetoothService.powered ? Theme.textMuted : (BluetoothService.isScanning ? Theme.accent : (btScanMouse.containsMouse ? Theme.accent : Theme.textSecondary)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: BluetoothService.isScanning ? "[SCANNING]" : "[SCAN]" - } - - MouseArea { - id: btScanMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: BluetoothService.powered - onClicked: BluetoothService.toggleScan() - } - } - } - - // Scrollable Device List Container - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Theme.gray800 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - clip: true - - // Empty State: Powered Off - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: !BluetoothService.powered - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "bluetooth-slash" - color: Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: "// BLUETOOTH RADIO OFF\nUSE [ON] TOGGLE ABOVE" - } - } - - // Empty State: Powered On, No Devices - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: { - const devCount = BluetoothService.devices ? (BluetoothService.devices.count || BluetoothService.devices.length || 0) : 0; - return BluetoothService.powered && devCount === 0; - } - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "bluetooth" - color: BluetoothService.isScanning ? Theme.acidGreen : Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: BluetoothService.isScanning ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: BluetoothService.isScanning ? "// SCANNING FOR NEARBY DEVICES..." : "// NO DEVICES FOUND\nCLICK [SCAN] TO DISCOVER" - } - } - - // Scrollable list when devices present - Flickable { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - clip: true - boundsBehavior: Flickable.StopAtBounds - contentHeight: btDeviceListColumn.implicitHeight - contentWidth: width - visible: { - const devCount = BluetoothService.devices ? (BluetoothService.devices.count || BluetoothService.devices.length || 0) : 0; - return BluetoothService.powered && devCount > 0; - } - - ColumnLayout { - id: btDeviceListColumn - width: parent.width - spacing: Theme.spacingSmall - - // Paired Devices - Repeater { - model: BluetoothService.pairedDevices - - delegate: Rectangle { - id: btPairedTile - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 38 - color: btPairedMouse.containsMouse ? Theme.surfaceHover : Theme.surface - border.color: Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "bluetooth" - color: btPairedTile.modelData.connected ? Theme.acidGreen : Theme.textSecondary - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 1 - - Text { - Layout.fillWidth: true - color: btPairedTile.modelData.connected ? Theme.acidGreen : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: btPairedTile.modelData.name || "Device" - elide: Text.ElideRight - } - - Text { - Layout.fillWidth: true - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - text: btPairedTile.modelData.mac + (btPairedTile.modelData.connected ? " // CONNECTED" : " // PAIRED") - } - } - - Rectangle { - Layout.preferredHeight: 20 - Layout.preferredWidth: btActionText.implicitWidth + Theme.paddingSmall * 2 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: btActionMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: btActionText - anchors.centerIn: parent - color: btActionMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: btPairedTile.modelData.connected ? "[DISCONNECT]" : "[CONNECT]" - } - - MouseArea { - id: btActionMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - if (btPairedTile.modelData.connected) { - BluetoothService.disconnectDevice(btPairedTile.modelData.mac); - } else { - BluetoothService.connectDevice(btPairedTile.modelData.mac); - } - } - } - } - } - - MouseArea { - id: btPairedMouse - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Forget %1?").arg(cardWifi.confirmingForgetSsid) + color: Theme.destructive + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + elide: Text.ElideRight + width: parent.width - 120 } - // Available Devices - Repeater { - model: BluetoothService.availableDevices - - delegate: Rectangle { - id: btAvailTile - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 38 - color: btAvailMouse.containsMouse ? Theme.surfaceHover : Theme.surface - border.color: Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "bluetooth" - color: Theme.textSecondary - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 1 - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: btAvailTile.modelData.name || "Device" - elide: Text.ElideRight - } - - Text { - Layout.fillWidth: true - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - text: btAvailTile.modelData.mac - } - } - - Rectangle { - Layout.preferredHeight: 20 - Layout.preferredWidth: btPairText.implicitWidth + Theme.paddingSmall * 2 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: btPairMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: btPairText - anchors.centerIn: parent - color: btPairMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - font.weight: Theme.fontWeightBold - text: "[PAIR]" - } - - MouseArea { - id: btPairMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: BluetoothService.pairDevice(btAvailTile.modelData.mac) - } - } - } - - MouseArea { - id: btAvailMouse - anchors.fill: parent - z: -1 - hoverEnabled: true + Text { + id: forgetYes + anchors.right: forgetNo.left + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Yes") + color: Theme.destructive + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: { + NetworkService.forgetNetwork( + cardWifi.confirmingForgetSsid); + cardWifi.confirmingForgetSsid = ""; } } } - } - } - } - } - } - } - } - - // 5. Audio Accordion - AccordionSection { - id: secAudio - width: parent ? parent.width : undefined - title: "// AUDIO & MEDIA" - icon: "volume" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "audio" - onHeaderClicked: panelContainer.toggleAccordion("audio") - headerAction: audioHeaderAction - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: 360 - height: 360 - - Flickable { - anchors.fill: parent - clip: true - boundsBehavior: Flickable.StopAtBounds - contentHeight: audioColumn.implicitHeight - contentWidth: width - - ColumnLayout { - id: audioColumn - width: parent.width - spacing: Theme.spacingMedium - - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - RowLayout { - Layout.fillWidth: true - - CtosIcon { - active: !AudioService.muted && AudioService.available - destructive: AudioService.muted - name: AudioService.muted ? "volume-mute" : "volume" - size: 16 - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: "OUTPUT AUDIO" - } - - Text { - color: AudioService.muted ? Theme.destructive : (AudioService.available ? Theme.accent : Theme.unavailable) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: !AudioService.available ? "--N/A--" : (AudioService.muted ? "[MUTED]" : Math.round(AudioService.volume * 100) + "%") - } - } - - // Output Volume Slider & Mute Row - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingMedium - - Rectangle { - id: volumeTrack - Layout.fillWidth: true - Layout.preferredHeight: 12 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: Theme.gray700 - radius: Theme.radiusSmall - - Rectangle { - id: volumeFill - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: AudioService.muted ? Theme.destructive : Theme.acidGreen - radius: Theme.radiusSmall - width: Math.max(0, Math.min(parent.width, parent.width * AudioService.volume)) - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - function updateFromMouse(mouseX) { - const ratio = mouseX / volumeTrack.width; - AudioService.setVolume(ratio); - } - - onPressed: function (mouse) { - updateFromMouse(mouse.x); - } - onPositionChanged: function (mouse) { - if (pressed) { - updateFromMouse(mouse.x); - } - } - onWheel: function (wheel) { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - Rectangle { - Layout.preferredHeight: 24 - Layout.preferredWidth: 60 - border.color: volMuteArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: AudioService.muted ? Theme.surfaceSelected : (volMuteArea.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: AudioService.muted ? Theme.destructive : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: AudioService.muted ? "UNMUTE" : "MUTE" - } - - MouseArea { - id: volMuteArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: AudioService.toggleMute() - } - } - } - } - - // Section: Hardware Microphone (Feature 9) - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - RowLayout { - Layout.fillWidth: true - - CtosIcon { - active: !AudioService.micMuted && AudioService.micAvailable - destructive: AudioService.micMuted - name: AudioService.micMuted ? "microphone-slash" : "microphone" - size: 16 - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: "MICROPHONE" - } - - Text { - color: AudioService.micMuted ? Theme.destructive : (AudioService.micAvailable ? Theme.accent : Theme.unavailable) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: !AudioService.micAvailable ? "--N/A--" : (AudioService.micMuted ? "[MUTED]" : Math.round(AudioService.micVolume * 100) + "%") - } - } - - // Mic Slider & Mute Row - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingMedium - - Rectangle { - id: micTrack - Layout.fillWidth: true - Layout.preferredHeight: 12 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: Theme.gray700 - radius: Theme.radiusSmall - - Rectangle { - id: micFill - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: AudioService.micMuted ? Theme.destructive : Theme.acidGreen - radius: Theme.radiusSmall - width: Math.max(0, Math.min(parent.width, parent.width * AudioService.micVolume)) - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - function updateFromMouse(mouseX) { - const ratio = mouseX / micTrack.width; - AudioService.setMicVolume(ratio); - } - - onPressed: function (mouse) { - updateFromMouse(mouse.x); - } - onPositionChanged: function (mouse) { - if (pressed) { - updateFromMouse(mouse.x); - } - } - onWheel: function (wheel) { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepMicVolume(delta); - } - } - } - - Rectangle { - Layout.preferredHeight: 24 - Layout.preferredWidth: 60 - border.color: micMuteArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: AudioService.micMuted ? Theme.surfaceSelected : (micMuteArea.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: AudioService.micMuted ? Theme.destructive : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: AudioService.micMuted ? "UNMUTE" : "MUTE" - } - - MouseArea { - id: micMuteArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: AudioService.toggleMicMute() - } - } - } - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 140 - color: Theme.gray800 - border.color: root.isPlaying ? Theme.acidGreen : Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingMedium - spacing: Theme.spacingSmall - - RowLayout { - Layout.fillWidth: true - - Text { - text: "MEDIA STREAM" - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - Item { - Layout.fillWidth: true - } - - Text { - text: root.isPlaying ? "STREAMING" : (root.activePlayer ? "PAUSED" : "OFFLINE") - color: root.isPlaying ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - - // Track Title - Text { - Layout.fillWidth: true - text: (root.activePlayer && root.activePlayer.trackTitle && root.activePlayer.trackTitle.trim() !== "") - ? root.activePlayer.trackTitle.trim() - : "NO ACTIVE MEDIA STREAM" - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - elide: Text.ElideRight - } + Text { + id: forgetNo + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + text: qsTr("No") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption - // Artist & Source - Text { - Layout.fillWidth: true - text: { - const artist = (root.activePlayer && root.activePlayer.trackArtist) ? root.activePlayer.trackArtist.trim() : ""; - const identity = (root.activePlayer && root.activePlayer.identity) ? root.activePlayer.identity.trim() : ""; - if (artist !== "" && identity !== "") return artist + " // " + identity; - if (artist !== "") return artist; - if (identity !== "") return identity; - return "AWAITING MPRIS TRANSMISSION"; + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: cardWifi.confirmingForgetSsid = "" + } + } + } } - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - elide: Text.ElideRight } + } + } - // Control Buttons: Prev, Play/Pause, Next - RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: Theme.spacingMedium + Card { + id: cardBluetooth + width: parent.width + title: qsTr("Bluetooth") + subtitle: BluetoothService.powered + ? (BluetoothService.isConnected && BluetoothService.deviceName !== "" + ? BluetoothService.deviceName + : qsTr("%1 paired").arg(BluetoothService.pairedDevices.count)) + : qsTr("Off") + icon: "bluetooth" + accent: Theme.accentBlue + collapsible: true + + action: Component { + ToggleSwitch { + checked: BluetoothService.powered + onToggled: BluetoothService.togglePower() + } + } - // Previous Button - Rectangle { - Layout.preferredHeight: 28 - Layout.preferredWidth: 44 - color: prevMouse.containsMouse ? Theme.surfaceHover : "transparent" - border.color: prevMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall + contentComponent: Component { + Item { + id: btBody + width: parent ? parent.width : undefined + + readonly property int rowCount: + BluetoothService.powered + ? BluetoothService.deviceModel.count : 0 + + // Devices are 34 tall each, the scan row is 22, the + // empty message 34, and the trailing term is the + // Column's spacing between its children. + implicitHeight: BluetoothService.powered + ? 22 + (rowCount === 0 ? 34 : 0) + + rowCount * 34 + + Theme.spacingSmall * (1 + rowCount) + : 0 + height: implicitHeight + visible: BluetoothService.powered + + Row { + id: scanRow + anchors.left: parent.left + anchors.top: parent.top + height: 22 + spacing: Theme.spacingSmall Text { - anchors.centerIn: parent - text: "|<<" - color: prevMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace + anchors.verticalCenter: parent.verticalCenter + text: BluetoothService.isScanning + ? qsTr("Scanning...") : qsTr("Scan for devices") + color: scanHover.containsMouse + ? Theme.textPrimary : Theme.textSecondary + font.family: Theme.fontFamilySans font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } + font.weight: Theme.fontWeightDemiBold - MouseArea { - id: prevMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - if (root.activePlayer && typeof root.activePlayer.previous === "function") { - root.activePlayer.previous(); - } + MouseArea { + id: scanHover + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: BluetoothService.toggleScan() } } } - // Play / Pause Button - Rectangle { - Layout.preferredHeight: 28 - Layout.preferredWidth: 80 - color: root.isPlaying ? Theme.surfaceSelected : (playMouse.containsMouse ? Theme.surfaceHover : "transparent") - border.color: root.isPlaying ? Theme.acidGreen : (playMouse.containsMouse ? Theme.accent : Theme.borderMuted) - border.width: Theme.borderWidth - radius: Theme.radiusSmall + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: scanRow.bottom + anchors.topMargin: Theme.spacingSmall + spacing: Theme.spacingSmall Text { - anchors.centerIn: parent - text: root.isPlaying ? "PAUSE" : "PLAY" - color: root.isPlaying ? Theme.acidGreen : (playMouse.containsMouse ? Theme.accent : Theme.textSecondary) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold + width: parent.width + height: visible ? 34 : 0 + visible: btBody.rowCount === 0 + verticalAlignment: Text.AlignVCenter + text: qsTr("No paired devices") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall } - MouseArea { - id: playMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - if (root.activePlayer && typeof root.activePlayer.togglePlaying === "function") { - root.activePlayer.togglePlaying(); + Repeater { + model: BluetoothService.powered + ? BluetoothService.deviceModel : null + + delegate: Rectangle { + id: btRow + required property string mac + required property string name + required property bool connected + required property bool paired + + width: btBody.width + height: 34 + radius: Theme.radiusMedium + color: rowHover.containsMouse ? Theme.surfaceHover : Theme.surfaceElevated + border.width: Theme.borderWidth + border.color: Theme.border + + Behavior on color { + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } - } - } - } - // Next Button - Rectangle { - Layout.preferredHeight: 28 - Layout.preferredWidth: 44 - color: nextMouse.containsMouse ? Theme.surfaceHover : "transparent" - border.color: nextMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - text: ">>|" - color: nextMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } + MouseArea { + id: rowHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } - MouseArea { - id: nextMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - if (root.activePlayer && typeof root.activePlayer.next === "function") { - root.activePlayer.next(); + GlyphIcon { + id: btGlyph + anchors.left: parent.left + anchors.leftMargin: Theme.spacingMedium + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + glyph: "bluetooth" + color: btRow.connected ? Theme.statusGreen : Theme.textSecondary } - } - } - } - } - // Equalizer bars row - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 16 - spacing: 3 + Column { + anchors.left: btGlyph.right + anchors.leftMargin: Theme.spacingSmall + anchors.right: actionBtn.left + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Text { + width: parent.width + text: btRow.name.length > 0 ? btRow.name : btRow.mac + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight + } - Repeater { - model: 12 + Text { + width: parent.width + text: btRow.connected ? qsTr("Connected") + : btRow.paired ? qsTr("Paired") + : qsTr("Available") + color: btRow.connected ? Theme.statusGreen : Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + elide: Text.ElideRight + } + } - Rectangle { - id: matrixEqBar - required property int index - Layout.fillWidth: true - Layout.alignment: Qt.AlignBottom - Layout.preferredHeight: root.isPlaying - ? Math.max(3, Math.round((Math.sin((matrixEqBar.index * 0.8) + root.visualizerPhase) * 0.4 + 0.5) * 14)) - : 2 - color: root.isPlaying ? Theme.acidGreen : Theme.gray600 - radius: 1 + // One button, whose action follows the + // device's state rather than four + // controls per row. + Rectangle { + id: actionBtn + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + width: actionLabel.implicitWidth + Theme.spacingMedium * 2 + height: 24 + radius: Theme.radiusSmall + color: actionHover.containsMouse ? Theme.surfaceHover : "transparent" + border.width: Theme.borderWidth + border.color: btRow.connected ? Theme.destructive : Theme.border + + Text { + id: actionLabel + anchors.centerIn: parent + text: btRow.connected ? qsTr("Disconnect") + : btRow.paired ? qsTr("Connect") + : qsTr("Pair") + color: btRow.connected + ? Theme.destructive : Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + } + + MouseArea { + id: actionHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (btRow.connected) + BluetoothService.disconnectDevice(btRow.mac); + else if (btRow.paired) + BluetoothService.connectDevice(btRow.mac); + else + BluetoothService.pairDevice(btRow.mac); + } + } + } + } } } } } } - } + Card { + id: cardAudio + width: parent.width + title: qsTr("Audio & Media") + subtitle: AudioService.available && AudioService.sinkName !== "" + ? AudioService.sinkName : "" + icon: "speaker" + accent: Theme.accentBlue + collapsible: true + + action: Component { + ToggleSwitch { + checked: !AudioService.muted + onToggled: AudioService.toggleMute() } } - } - } - - // 6. Power Accordion - AccordionSection { - id: secPower - width: parent ? parent.width : undefined - title: "// POWER & SESSION" - icon: "power" - autoToggle: false - isExpanded: panelContainer.activeAccordion === "power" - onHeaderClicked: panelContainer.toggleAccordion("power") - - contentComponent: Component { - Item { - width: parent ? parent.width : undefined - implicitHeight: root.isConfirming ? 250 : 210 - height: root.isConfirming ? 250 : 210 - - Behavior on height { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - // Normal state: Session Actions + Battery - ColumnLayout { - anchors.fill: parent - spacing: Theme.spacingSmall - visible: !root.isConfirming + contentComponent: Component { + Item { + id: audioBody + width: parent ? parent.width : undefined + // The transport is the only thing left in this card, so + // the height no longer varies with whether a microphone + // exists. That conditional was the 24px of slack for the + // capture row; with both level rows gone it is dead, and + // the audio card is the same height whether or not the + // machine has an input device. + implicitHeight: 52 + height: implicitHeight + + // Output and capture levels are not here. Both moved to + // the radial (AUD.03 output, AUD.04 mic volume, AUD.05 mic + // mute) because a slider in a card that also has transport + // controls is a second place to look for the same setting, + // and the two could disagree. Nothing is lost: the radial + // drives the same AudioService calls. + // + // The transport block below used to be positioned against + // outputRow.bottom; it is anchored to the top instead. + + // Transport. + Rectangle { + id: transport + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 52 + radius: Theme.radiusMedium + color: Theme.surfaceElevated + border.width: Theme.borderWidth + border.color: Theme.border - // Battery status (visible when battery present) - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: PowerService.available && PowerService.isBatteryPresent + // Clipped by a Rectangle rather than a rounded + // Image: Image has no radius of its own, and an + // opacity mask for one 40px thumbnail is not + // worth the extra render pass. + Rectangle { + id: art + anchors.left: parent.left + anchors.leftMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + radius: Theme.radiusSmall + clip: true + color: Theme.surfaceHover + border.width: Theme.borderWidth + border.color: Theme.border - RowLayout { - Layout.fillWidth: true + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + visible: status === Image.Ready + source: root.activePlayer !== null + && root.activePlayer.trackArtUrl !== "" + ? root.activePlayer.trackArtUrl : "" + } - CtosIcon { - active: PowerService.isCharging - destructive: !PowerService.isCharging && PowerService.percentage <= 20 - name: PowerService.isCharging ? "battery-charging" : (PowerService.percentage <= 20 ? "battery-low" : "battery") - size: 16 + // Stands in when there is no art, so the row + // keeps its rhythm. + GlyphIcon { + anchors.centerIn: parent + width: 18 + height: 18 + visible: art.children.length === 1 + || (art.children[0].visible === false) + glyph: "speaker" + color: Theme.textDisabled } + } + + Column { + anchors.left: art.right + anchors.leftMargin: Theme.spacingSmall + anchors.right: transportButtons.left + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + spacing: 1 Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace + width: parent.width + text: root.activePlayer !== null + && root.activePlayer.trackTitle !== "" + ? root.activePlayer.trackTitle.trim() + : qsTr("Nothing playing") + color: root.activePlayer !== null + ? Theme.textPrimary : Theme.textSecondary + font.family: Theme.fontFamilySans font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: "POWER STATUS" + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight } Text { - color: PowerService.percentage <= 20 ? Theme.destructive : (PowerService.isCharging ? Theme.accent : Theme.textPrimary) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: Math.round(PowerService.percentage) + "% // " + PowerService.stateText + width: parent.width + text: root.activePlayer !== null + && root.activePlayer.trackArtist !== "" + ? root.activePlayer.trackArtist.trim() + : (root.activePlayer !== null + ? root.activePlayer.identity : "") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + elide: Text.ElideRight } } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 8 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: Theme.gray700 - radius: Theme.radiusSmall + Row { + id: transportButtons + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXs - Rectangle { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.top: parent.top - color: PowerService.percentage <= 20 ? Theme.destructive : Theme.acidGreen - radius: Theme.radiusSmall - width: Math.max(0, Math.min(parent.width, parent.width * (PowerService.percentage / 100.0))) + // Disabled rather than hidden when the player + // cannot do it, so the row does not reflow as + // capabilities change. + Repeater { + model: [ + { act: "previous", glyph: "skipPrevious", rot: 0, enabled: root.activePlayer !== null && root.activePlayer.canGoPrevious }, + { act: "toggle", glyph: "play", rot: 0, enabled: root.activePlayer !== null && root.activePlayer.canControl }, + { act: "next", glyph: "skipNext", rot: 0, enabled: root.activePlayer !== null && root.activePlayer.canGoNext } + ] + delegate: Item { + id: btn + required property var modelData + width: 28 + height: 28 + + readonly property bool actionEnabled: + modelData.enabled && !SessionService.isBusy + + GlyphIcon { + anchors.centerIn: parent + width: 16 + height: 16 + glyph: btn.modelData.act === "toggle" + ? (root.isPlaying ? "pause" : "play") + : btn.modelData.glyph + rotation: btn.modelData.rot + color: btn.actionEnabled + ? (btnHover.containsMouse ? Theme.textPrimary : Theme.textSecondary) + : Theme.textDisabled + } + + MouseArea { + id: btnHover + anchors.fill: parent + hoverEnabled: true + enabled: btn.actionEnabled + cursorShape: Qt.PointingHandCursor + onClicked: { + const p = root.activePlayer; + if (p === null) + return; + if (btn.modelData.act === "previous") + p.previous(); + else if (btn.modelData.act === "next") + p.next(); + else + p.togglePlaying(); + } + } + } } } } + } + } + } + Card { + id: cardSystemStatus + width: parent.width + title: qsTr("System Status") + icon: "sliders" + accent: Theme.accentBlue + // Fixed open: read-only telemetry has nothing to disclose. + collapsible: false + + contentComponent: Component { + Item { + width: parent ? parent.width : undefined + // A StatRing with a caption is 78 tall, so 76 clipped + // the bottom of the rings by 2px. + implicitHeight: 78 + height: 78 + + Row { + id: metrics + anchors.left: parent.left + anchors.top: parent.top + spacing: Theme.spacingLarge + + StatRing { + fraction: SystemMonitorService.cpuTotal + ringColor: Theme.statusGreen + valueText: Math.round(SystemMonitorService.cpuTotal * 100) + "%" + caption: "CPU" + } - Text { - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "// SESSION ACTIONS" - } - - // Immediate Lock Session Action - Rectangle { - id: btnLockSession - Layout.fillWidth: true - Layout.preferredHeight: 34 - border.color: lockMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: lockMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface - radius: Theme.radiusSmall - - RowLayout { - anchors.centerIn: parent - spacing: Theme.spacingXs - - CtosIcon { - name: "lock" - size: 14 - } - - Text { - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: "LOCK SESSION" - } + StatRing { + fraction: SystemMonitorService.memTotalBytes > 0 + ? SystemMonitorService.memUsedBytes / SystemMonitorService.memTotalBytes + : 0 + ringColor: Theme.accentBlue + valueText: SystemMonitorService.memTotalBytes > 0 + ? Math.round(SystemMonitorService.memUsedBytes / SystemMonitorService.memTotalBytes * 100) + "%" + : "--" + caption: "MEMORY" } - MouseArea { - id: lockMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: lockProcess.running = true + StatRing { + fraction: SystemMonitorService.diskFraction + ringColor: Theme.accentMagenta + valueText: SystemMonitorService.diskTotalBytes > 0 + ? Math.round(SystemMonitorService.diskFraction * 100) + "%" + : "--" + caption: "DISK" } } - // Destructive Session Actions Row - RowLayout { - Layout.fillWidth: true + // Throughput, right-aligned so the arrows line up on + // their trailing edge regardless of digit count. + Column { + anchors.right: parent.right + anchors.top: parent.top spacing: Theme.spacingSmall - // Logout - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 34 - border.color: logoutMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: logoutMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface - radius: Theme.radiusSmall - - RowLayout { - anchors.centerIn: parent - spacing: Theme.spacingXs + Repeater { + model: 2 + delegate: Row { + required property int index + spacing: Theme.spacingSmall - CtosIcon { - name: "logout" - size: 14 + Text { + text: index === 0 ? "↑" : "↓" + color: index === 0 ? Theme.accentBlue : Theme.accentMagenta + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeBody } - Text { + text: SystemMonitorService.formatBytes( + index === 0 ? SystemMonitorService.netRxBytesPerSec + : SystemMonitorService.netTxBytesPerSec) + "/s" color: Theme.textPrimary font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: "LOGOUT" + font.pixelSize: Theme.fontSizeSmall } } - - MouseArea { - id: logoutMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: root.triggerConfirmation("logout") - } } - // Reboot - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 34 - border.color: rebootMouseArea.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: rebootMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface - radius: Theme.radiusSmall + Text { + anchors.right: parent.right + text: "NETWORK" + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + } + } + } + } + } + } - RowLayout { - anchors.centerIn: parent - spacing: Theme.spacingXs + // ------------------------------------------------ right column + + Column { + id: rightColumn + width: (grid.width - Theme.commandCenterColumnGutter) / 2 + spacing: Theme.cardGap + + Card { + id: cardPower + width: parent.width + title: qsTr("Power & Session") + subtitle: PowerService.isBatteryPresent + ? Math.round(PowerService.percentage) + "% " + + (PowerService.isCharging ? qsTr("Charging") : PowerService.stateText) + : "" + icon: "power" + accent: Theme.accentMagenta + collapsible: true + + // Lock runs immediately -- it is reversible. The other three + // end the session, so they ask first. + readonly property var actions: [ + { key: "lock", label: qsTr("Lock"), glyph: "lock", danger: false }, + { key: "logout", label: qsTr("Logout"), glyph: "logout", danger: false }, + { key: "reboot", label: qsTr("Reboot"), glyph: "reboot", danger: false }, + { key: "poweroff", label: qsTr("Poweroff"), glyph: "power", danger: true } + ] + + property string pendingAction: "" + + readonly property bool isConfirming: pendingAction !== "" + + function requestAction(key: string): void { + if (key === "lock") { + SessionService.executeAction(key); + } else { + // `pendingAction` is declared on this card, so write + // it through the card's own id. These four sites said + // `root.pendingAction`, and `root` is the command + // centre, which has no such property -- the assignment + // threw "Cannot assign to non-existent property" and + // aborted the function, so the confirmation never + // armed and Logout, Reboot and Poweroff did nothing. + // + // Unqualified does not work either: `isConfirming` reads + // `pendingAction` fine because a binding is evaluated in + // the object's scope, but a bare assignment inside a + // function body is plain JavaScript and binds a global. + cardPower.pendingAction = key; + } + } - CtosIcon { - name: "reboot" - size: 14 - } + // Label for the pending action, for the confirmation + // prompt. + // + // Written as a lookup rather than inline + // `actions.filter(...)[0]?.label`: QML's JavaScript engine + // does not support optional chaining, and the failure is + // silent -- the binding just evaluates to nothing and the + // prompt renders with no question in it. + function actionLabel(key: string): string { + for (let i = 0; i < cardPower.actions.length; ++i) { + if (cardPower.actions[i].key === key) + return cardPower.actions[i].label; + } + return key; + } - Text { - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: "REBOOT" - } - } + function cancelConfirmation(): void { + cardPower.pendingAction = ""; + } - MouseArea { - id: rebootMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: root.triggerConfirmation("reboot") - } - } + function confirmAction(): void { + const action = cardPower.pendingAction; + cardPower.pendingAction = ""; + if (action !== "") + SessionService.executeAction(action); + } - // Power Off - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 34 - border.color: powerMouseArea.containsMouse ? Theme.destructive : Theme.borderMuted - border.width: Theme.borderWidth - color: powerMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface - radius: Theme.radiusSmall + Keys.onEscapePressed: function (event) { + if (cardPower.isConfirming) { + event.accepted = true; + cardPower.cancelConfirmation(); + } + } - RowLayout { - anchors.centerIn: parent - spacing: Theme.spacingXs + contentComponent: Component { + Item { + width: parent ? parent.width : undefined + // The tiles are 64 tall, so reserving 76 left 12px of dead + // space under them -- the other half of the bottom-heavy + // gap the card's own padding was only part of. + implicitHeight: 64 + height: 64 + + // Confirmation replaces the tiles rather than + // stacking below them, so asking to power off does not + // push the layout around underneath the question. + Row { + id: tileRow + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.spacingSmall + visible: !cardPower.isConfirming + + Repeater { + model: cardPower.actions + delegate: Rectangle { + id: tile + required property var modelData + width: (tileRow.width - Theme.spacingSmall * 3) / 4 + height: 64 + radius: Theme.radiusMedium + + readonly property color tileColor: + modelData.danger ? Theme.destructive : Theme.surfaceElevated + + color: modelData.danger + ? tileColor + : (tileHover.containsMouse ? Theme.surfaceHover : tileColor) + border.width: Theme.borderWidth + border.color: modelData.danger + ? tileColor + : (tileHover.containsMouse ? Theme.borderHover : Theme.border) + + Behavior on color { + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } + } - CtosIcon { - destructive: powerMouseArea.containsMouse - name: "power" - size: 14 + // Busy state: whichever action is running + // shows as disabled rather than allowing a + // second session command to be queued. + readonly property bool busy: + (modelData.key === "lock" && SessionService.isLocking) + || (modelData.key === "logout" && SessionService.isLoggingOut) + || (modelData.key === "reboot" && SessionService.isRebooting) + || (modelData.key === "poweroff" && SessionService.isPoweringOff) + + opacity: tile.busy ? 0.5 : 1.0 + + GlyphIcon { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacingMedium + width: 20 + height: 20 + glyph: tile.modelData.glyph + color: tile.modelData.danger + ? Theme.textPrimary + : (tileHover.containsMouse ? Theme.textPrimary : Theme.textSecondary) } Text { - color: powerMouseArea.containsMouse ? Theme.destructive : Theme.textPrimary - font.family: Theme.fontFamilyMonospace + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingSmall + text: tile.modelData.label + color: tile.modelData.danger + ? Theme.textPrimary + : (tileHover.containsMouse ? Theme.textPrimary : Theme.textSecondary) + font.family: Theme.fontFamilySans font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightMedium - text: "POWER" + font.weight: Theme.fontWeightDemiBold } - } - MouseArea { - id: powerMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: root.triggerConfirmation("poweroff") + MouseArea { + id: tileHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !tile.busy + onClicked: cardPower.requestAction(tile.modelData.key) + } } } } - // Floating Radial Settings Node Access - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 34 - color: radialMouse.containsMouse ? Theme.surfaceHover : Theme.surface - border.color: radialMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - RowLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - - CtosIcon { - name: "gear" - size: 14 - color: Theme.textSecondary - } - - Text { - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "OPEN RADIAL SETTINGS // TELEMETRY" - } - } + // Confirmation row. The question is anchored left + // and the buttons right, rather than sharing a Row, + // so the label can take whatever width is left over + // instead of guessing the buttons' width. + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 64 + visible: cardPower.isConfirming - MouseArea { - id: radialMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: { - OverlayController.openRadialSettings(); - } + Text { + id: confirmLabel + anchors.left: parent.left + anchors.right: confirmButtons.left + anchors.rightMargin: Theme.spacingMedium + anchors.verticalCenter: parent.verticalCenter + text: cardPower.actionLabel(cardPower.pendingAction) + "?" + color: Theme.destructive + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeBody + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight } - } - } - - // Confirmation state - ColumnLayout { - id: confirmationLayout - anchors.fill: parent - anchors.margins: Theme.paddingLarge - spacing: Theme.spacingMedium - visible: root.isConfirming - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - destructive: true - name: "warning" - size: 32 - } - Text { - Layout.fillWidth: true - color: Theme.destructive - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeLarge - font.weight: Theme.fontWeightBold - horizontalAlignment: Text.AlignHCenter - text: "CRITICAL // CONFIRM " + root.confirmationAction.toUpperCase() - wrapMode: Text.WordWrap - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: "WARNING: SYSTEM WILL TERMINATE ALL ACTIVE PROCESSES. ANY UNSAVED DATA WILL BE LOST." - wrapMode: Text.WordWrap - } - - // Explicit Action Buttons - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingMedium + Row { + id: confirmButtons + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingSmall - // Cancel Button - Rectangle { - id: btnCancel - Layout.fillWidth: true - Layout.preferredHeight: 36 - border.color: cancelMouseArea.containsMouse ? Theme.textPrimary : Theme.borderMuted - border.width: Theme.borderWidth - color: cancelMouseArea.containsMouse ? Theme.surfaceHover : Theme.surface - radius: Theme.radiusSmall + Rectangle { + width: 84 + height: 40 + radius: Theme.radiusMedium + color: cancelHover.containsMouse ? Theme.surfaceHover : "transparent" + border.width: Theme.borderWidth + border.color: Theme.border - Text { - anchors.centerIn: parent - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "[ ESC ] CANCEL" - } + Text { + anchors.centerIn: parent + text: qsTr("Cancel") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + } - MouseArea { - id: cancelMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: root.cancelConfirmation() + MouseArea { + id: cancelHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cardPower.cancelConfirmation() + } } - } - // Confirm Button - Rectangle { - id: btnConfirm - Layout.fillWidth: true - Layout.preferredHeight: 36 - color: confirmMouseArea.containsMouse ? Theme.surfaceHover : Theme.destructive - radius: Theme.radiusSmall + Rectangle { + width: 84 + height: 40 + radius: Theme.radiusMedium + color: confirmHover.containsMouse + ? Qt.lighter(Theme.destructive, 1.15) + : Theme.destructive + border.width: Theme.borderWidth + border.color: Theme.destructive - Text { - anchors.centerIn: parent - color: Theme.gray900 - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "CONFIRM // EXECUTE" - } + Text { + anchors.centerIn: parent + text: qsTr("Confirm") + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeSmall + font.weight: Theme.fontWeightDemiBold + } - MouseArea { - id: confirmMouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onClicked: root.executeConfirmation() + MouseArea { + id: confirmHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cardPower.confirmAction() + } } } } } } } + +Card { + id: cardCalendar + width: parent.width + title: qsTr("Calendar & Events") + // Count of calendars actually contributing, so an empty + // title bar is distinguishable from "nothing scheduled". + subtitle: CalendarService.calendarCount > 0 + ? qsTr("%1 file(s)").arg(CalendarService.calendarCount) + : "" + icon: "calendar" + accent: Theme.accentBlue + collapsible: true + + contentComponent: CalendarAgenda {} + } } } } -} +} \ No newline at end of file diff --git a/shell/desktop/surfaces/FloatingRadialSettingsNode.qml b/shell/desktop/surfaces/FloatingRadialSettingsNode.qml deleted file mode 100644 index 2e7ffe81..00000000 --- a/shell/desktop/surfaces/FloatingRadialSettingsNode.qml +++ /dev/null @@ -1,277 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import "../core" -import "../services" -import "./components" - -FocusScope { - id: root - implicitWidth: 360 - width: 360 - implicitHeight: 380 - height: 380 - - // Rate formatting helper for telemetry - function formatBytesRate(bytes: real): string { - if (bytes >= 1048576) { - return (bytes / 1048576).toFixed(1) + " MB/s"; - } - if (bytes >= 1024) { - return (bytes / 1024).toFixed(1) + " KB/s"; - } - return Math.round(bytes) + " B/s"; - } - - Rectangle { - anchors.fill: parent - radius: Theme.radiusMedium - color: Theme.background - border.color: Theme.accent - border.width: Theme.borderWidth - clip: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingXl - spacing: Theme.spacingMedium - - Text { - text: "// SYSTEM TELEMETRY" - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSegment - font.weight: Theme.fontWeightBold - } - - // Card 1: CPU Telemetry - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 80 - color: Theme.gray800 - border.color: Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingMedium - spacing: 4 - - RowLayout { - Layout.fillWidth: true - - Text { - text: "CPU TOTAL LOAD" - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - Item { - Layout.fillWidth: true - } - - Text { - text: Math.round(SystemMonitorService.cpuTotal * 100) + "%" - color: SystemMonitorService.cpuTotal > 0.85 ? Theme.warningRed : Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - - // CPU Meter Bar - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 8 - color: Theme.gray700 - radius: 4 - - Rectangle { - width: Math.max(0, Math.min(parent.width, parent.width * SystemMonitorService.cpuTotal)) - height: parent.height - radius: 4 - color: SystemMonitorService.cpuTotal > 0.85 ? Theme.warningRed : Theme.acidGreen - } - } - - Text { - text: SystemMonitorService.cpuThreadLoads.length > 0 - ? (SystemMonitorService.cpuThreadLoads.length + " THREADS MONITORED // PROCFS ACTIVE") - : "SYSTEM CORES ONLINE" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - } - } - } - - // Card 2: Memory Allocation - Rectangle { - id: memCard - Layout.fillWidth: true - Layout.preferredHeight: 80 - color: Theme.gray800 - border.color: Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - readonly property real memPercent: SystemMonitorService.memTotalBytes > 0 - ? (SystemMonitorService.memUsedBytes / SystemMonitorService.memTotalBytes) - : 0.0 - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingMedium - spacing: 4 - - RowLayout { - Layout.fillWidth: true - - Text { - text: "MEMORY USAGE" - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - Item { - Layout.fillWidth: true - } - - Text { - text: Math.round(memCard.memPercent * 100) + "%" - color: memCard.memPercent > 0.9 ? Theme.warningRed : Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - - // Memory Meter Bar - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 8 - color: Theme.gray700 - radius: 4 - - Rectangle { - width: Math.max(0, Math.min(parent.width, parent.width * memCard.memPercent)) - height: parent.height - radius: 4 - color: memCard.memPercent > 0.9 ? Theme.warningRed : Theme.acidGreen - } - } - - Text { - text: (SystemMonitorService.memUsedBytes / 1073741824).toFixed(1) + " GB / " + - (SystemMonitorService.memTotalBytes / 1073741824).toFixed(1) + " GB (" + - (SystemMonitorService.swapUsedBytes / 1073741824).toFixed(1) + " GB SWAP)" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - } - } - } - - // Card 3: Network Bandwidth Flow - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 88 - color: Theme.gray800 - border.color: Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.margins: Theme.paddingMedium - spacing: 4 - - RowLayout { - Layout.fillWidth: true - - Text { - text: "NETWORK FLOW" - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - Item { - Layout.fillWidth: true - } - - Text { - text: NetworkService.connectionType.toUpperCase() - color: NetworkService.isConnected ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - - RowLayout { - Layout.fillWidth: true - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Text { - text: "RX FLOW" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - } - - Text { - text: root.formatBytesRate(SystemMonitorService.netRxBytesPerSec) - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Text { - text: "TX FLOW" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - } - - Text { - text: root.formatBytesRate(SystemMonitorService.netTxBytesPerSec) - color: Theme.pastelBlue - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - } - } - - Text { - text: "INTERFACE: " + NetworkService.networkName - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - - } - } -} diff --git a/shell/desktop/surfaces/components/AccordionSection.qml b/shell/desktop/surfaces/components/AccordionSection.qml deleted file mode 100644 index 737096c6..00000000 --- a/shell/desktop/surfaces/components/AccordionSection.qml +++ /dev/null @@ -1,186 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import "../../core" - -Item { - id: root - - property string title: "" - property string icon: "" - property bool isExpanded: false - property bool autoToggle: true - property bool animateHeight: true - property Component contentComponent: null - property Component headerAction: null - property Item headerActionItem: null - property Component headerComponent: null - - signal headerClicked() - - function toggle(): void { - root.isExpanded = !root.isExpanded; - } - - Layout.fillWidth: true - width: parent ? parent.width : undefined - implicitWidth: parent ? parent.width : undefined - readonly property real _contentTargetHeight: contentLoader.item ? (contentLoader.item.implicitHeight > 0 ? contentLoader.item.implicitHeight : (contentLoader.item.height > 0 ? contentLoader.item.height : 0)) : 0 - - implicitHeight: headerLoader.height + (root.isExpanded ? _contentTargetHeight + Theme.spacingMedium : 0) - - Loader { - id: headerLoader - width: parent ? parent.width : undefined - anchors.top: parent.top - sourceComponent: root.headerComponent !== null ? root.headerComponent : defaultHeaderComponent - } - - Item { - id: contentContainer - width: parent ? parent.width : undefined - anchors.top: headerLoader.bottom - anchors.topMargin: root.isExpanded ? Theme.spacingMedium : 0 - clip: true - height: root.isExpanded ? _contentTargetHeight : 0 - visible: height > 0 || root.isExpanded - - Behavior on anchors.topMargin { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - - Behavior on height { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - - Loader { - id: contentLoader - width: parent ? parent.width : undefined - sourceComponent: root.contentComponent - } - } - - Component { - id: defaultHeaderComponent - - Rectangle { - id: headerBg - width: parent ? parent.width : undefined - height: Theme.barHeight > 0 ? Theme.barHeight : 36 - color: headerMouseArea.containsMouse ? Theme.surfaceHover : (root.isExpanded ? Theme.surfaceActive : Theme.surface) - border.color: root.isExpanded ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - readonly property bool hasAction: (actionLoader.item !== null) || (root.headerActionItem !== null) - - Row { - id: titleRow - anchors.left: parent.left - anchors.leftMargin: Theme.paddingMedium - anchors.right: headerBg.hasAction ? actionContainer.left : parent.right - anchors.rightMargin: headerBg.hasAction ? Theme.paddingSmall : Theme.paddingMedium - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingMedium - clip: true - - Text { - id: chevronText - text: root.isExpanded ? "▼" : "▶" - color: root.isExpanded ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - anchors.verticalCenter: parent.verticalCenter - } - - Loader { - id: iconLoader - visible: root.icon !== "" - width: visible ? 16 : 0 - height: 16 - anchors.verticalCenter: parent.verticalCenter - sourceComponent: root.icon !== "" ? ((root.icon.indexOf("/") !== -1 || root.icon.indexOf(".") !== -1) ? fileIconComponent : ctosIconComponent) : null - } - - Text { - id: titleText - text: root.title - color: root.isExpanded ? Theme.accent : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - } - } - - Item { - id: actionContainer - anchors.right: parent.right - anchors.rightMargin: Theme.paddingMedium - anchors.verticalCenter: parent.verticalCenter - width: actionLoader.item ? actionLoader.item.width : (root.headerActionItem ? root.headerActionItem.width : 0) - height: actionLoader.item ? actionLoader.item.height : (root.headerActionItem ? root.headerActionItem.height : 0) - z: 2 - - Loader { - id: actionLoader - anchors.centerIn: parent - sourceComponent: root.headerAction - } - - Binding { - target: root.headerActionItem - property: "parent" - value: actionContainer - when: root.headerActionItem !== null - } - } - - MouseArea { - id: headerMouseArea - anchors.left: parent.left - anchors.right: headerBg.hasAction ? actionContainer.left : parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - root.headerClicked(); - if (root.autoToggle) { - root.isExpanded = !root.isExpanded; - } - } - } - } - } - - Component { - id: fileIconComponent - - Image { - source: root.icon - width: 16 - height: 16 - fillMode: Image.PreserveAspectFit - } - } - - Component { - id: ctosIconComponent - - CtosIcon { - name: root.icon - size: 16 - color: root.isExpanded ? Theme.accent : Theme.textPrimary - } - } -} diff --git a/shell/desktop/surfaces/components/BatteryWidget.qml b/shell/desktop/surfaces/components/BatteryWidget.qml deleted file mode 100644 index d1b08980..00000000 --- a/shell/desktop/surfaces/components/BatteryWidget.qml +++ /dev/null @@ -1,69 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../core" -import "../../services" - -Item { - id: root - - property bool isHovered: false - readonly property real batteryPercent: PowerService.percentage - readonly property bool isCharging: PowerService.isCharging - readonly property bool isFull: PowerService.isFull - readonly property bool isLow: !isCharging && batteryPercent <= 20.0 - readonly property string percentText: { - if (!PowerService.isBatteryPresent) { - return "--N/A--"; - } - return Math.round(batteryPercent).toString() + "%"; - } - - implicitHeight: visible ? layout.implicitHeight : 0 - implicitWidth: visible ? layout.implicitWidth : 0 - visible: PowerService.available && PowerService.isBatteryPresent - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingXs - - CtosIcon { - Layout.alignment: Qt.AlignVCenter - size: 14 - name: { - if (root.isCharging) return "battery-charging"; - if (root.isLow) return "battery-low"; - return "battery"; - } - active: root.isCharging - destructive: root.isLow - color: { - if (root.isLow) return Theme.destructive; - if (root.isCharging) return Theme.acidGreen; - if (root.isHovered) return Theme.accent; - return Theme.textSecondary; - } - } - - Text { - Layout.alignment: Qt.AlignVCenter - color: { - if (root.isLow) { - return Theme.accentRed; - } - if (root.isCharging) { - return Theme.accent; - } - return Theme.textPrimary; - } - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - maximumLineCount: 1 - text: root.percentText - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/BluetoothPopup.qml b/shell/desktop/surfaces/components/BluetoothPopup.qml deleted file mode 100644 index a50097e1..00000000 --- a/shell/desktop/surfaces/components/BluetoothPopup.qml +++ /dev/null @@ -1,772 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import "../../core" -import "../../services" -import "../widgets" - -Rectangle { - id: root - - // ========================================================================= - // Public Interface & Signals - // ========================================================================= - - signal closeRequested - - // ========================================================================= - // Geometry & Theme Styling - // ========================================================================= - - implicitWidth: 320 - width: 320 - implicitHeight: mainColumn.implicitHeight + Theme.paddingLarge * 2 - color: Theme.gray900 - radius: Theme.radiusSmall - border.color: Theme.borderMuted - border.width: Theme.borderWidth - - // Consume all clicks inside popup so backdrop does not dismiss - MouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - onClicked: mouse => mouse.accepted = true - } - - // Cyberpunk Corner Brackets - CornerBrackets { - id: cornerBrackets - bracketColor: Theme.acidGreen - z: 10 - } - - // ========================================================================= - // Content Layout - // ========================================================================= - - ColumnLayout { - id: mainColumn - - anchors.fill: parent - anchors.margins: Theme.paddingLarge - spacing: Theme.spacingMedium - - // Header: [PWR] Title [SCAN] [x] - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - // Power Toggle Button - Rectangle { - id: powerBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: powerLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: !BluetoothService.available ? Theme.textDisabled : (BluetoothService.powered ? Theme.accent : (powerMouse.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: !BluetoothService.available ? "transparent" : (BluetoothService.powered ? Theme.surfaceSelected : (powerMouse.containsMouse ? Theme.surfaceHover : "transparent")) - radius: Theme.radiusSmall - - Text { - id: powerLabel - - anchors.centerIn: parent - color: !BluetoothService.available ? Theme.textDisabled : (BluetoothService.powered ? Theme.accent : (powerMouse.containsMouse ? Theme.accent : Theme.textSecondary)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: BluetoothService.powered ? "[PWR ON]" : "[PWR OFF]" - } - - MouseArea { - id: powerMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: BluetoothService.available - - onClicked: BluetoothService.togglePower() - } - } - - // Title - Text { - id: titleText - - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - horizontalAlignment: Text.AlignHCenter - text: "BLUETOOTH // RADIO" - elide: Text.ElideRight - } - - // Scan Button - Rectangle { - id: scanBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: scanLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: !BluetoothService.powered ? Theme.borderMuted : (BluetoothService.isScanning ? Theme.accent : (scanMouse.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: BluetoothService.isScanning ? Theme.surfaceSelected : (scanMouse.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - opacity: BluetoothService.powered ? 1.0 : 0.4 - - Text { - id: scanLabel - - anchors.centerIn: parent - color: !BluetoothService.powered ? Theme.textMuted : (BluetoothService.isScanning ? Theme.accent : (scanMouse.containsMouse ? Theme.accent : Theme.textSecondary)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: BluetoothService.isScanning ? "[SCANNING]" : "[SCAN]" - } - - MouseArea { - id: scanMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: BluetoothService.powered - - onClicked: BluetoothService.toggleScan() - } - } - - // Close Button - Rectangle { - id: closeBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: 24 - border.color: closeMouse.containsMouse ? Theme.destructive : Theme.borderMuted - border.width: Theme.borderWidth - color: closeMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: closeMouse.containsMouse ? Theme.destructive : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "x" - } - - MouseArea { - id: closeMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: root.closeRequested() - } - } - } - - // Header Divider - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Theme.borderWidth - color: Theme.divider - } - - // Body Content: Empty State OR Scrollable Device List - Item { - id: bodyContainer - - Layout.fillWidth: true - Layout.preferredHeight: { - const devCount = BluetoothService.devices ? (BluetoothService.devices.count || BluetoothService.devices.length || 0) : 0; - if (!BluetoothService.powered || devCount === 0) { - return 140; - } - return Math.min(320, Math.max(140, deviceListColumn.implicitHeight)); - } - - // Empty State: Powered Off - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: !BluetoothService.powered - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "bluetooth-slash" - color: Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: "// BLUETOOTH RADIO OFF\nUSE [PWR ON] TO ACTIVATE" - } - } - - // Empty State: Powered On, No Devices - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: { - const devCount = BluetoothService.devices ? (BluetoothService.devices.count || BluetoothService.devices.length || 0) : 0; - return BluetoothService.powered && devCount === 0; - } - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "bluetooth" - color: BluetoothService.isScanning ? Theme.acidGreen : Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: BluetoothService.isScanning ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: BluetoothService.isScanning ? "// SCANNING FOR NEARBY DEVICES..." : "// NO DEVICES FOUND\nCLICK [SCAN] TO DISCOVER" - } - } - - // Scrollable Device List - Flickable { - id: deviceFlickable - - anchors.fill: parent - clip: true - boundsBehavior: Flickable.StopAtBounds - contentHeight: deviceListColumn.implicitHeight - contentWidth: width - visible: { - const devCount = BluetoothService.devices ? (BluetoothService.devices.count || BluetoothService.devices.length || 0) : 0; - return BluetoothService.powered && devCount > 0; - } - - ColumnLayout { - id: deviceListColumn - - width: deviceFlickable.width - spacing: Theme.spacingMedium - - // ========================================================= - // Section 1: Connected Devices - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = BluetoothService.connectedDevices ? (BluetoothService.connectedDevices.count || BluetoothService.connectedDevices.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.acidGreen - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = BluetoothService.connectedDevices ? (BluetoothService.connectedDevices.count || BluetoothService.connectedDevices.length || 0) : 0; - return "// CONNECTED (" + count + ")"; - } - } - } - - Repeater { - model: BluetoothService.connectedDevices - - delegate: Rectangle { - id: connectedTile - - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 46 - color: tileMouseArea.containsMouse ? Theme.surfaceHover : Theme.surfaceSelected - border.color: Theme.acidGreen - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: 3 - color: Theme.acidGreen - radius: Theme.radiusSmall - } - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: 2 - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "bluetooth" - color: Theme.acidGreen - } - - Text { - Layout.fillWidth: true - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: connectedTile.modelData.name || "Device" - elide: Text.ElideRight - } - - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: disconnectLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: disconnectMouse.containsMouse ? Theme.warningRed : Theme.borderMuted - border.width: Theme.borderWidth - color: disconnectMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: disconnectLabel - - anchors.centerIn: parent - color: disconnectMouse.containsMouse ? Theme.warningRed : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: (BluetoothService.isActionPending && BluetoothService.actionTargetMac === connectedTile.modelData.mac) ? "[WAIT...]" : "[DISCONNECT]" - } - - MouseArea { - id: disconnectMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: !BluetoothService.isActionPending - - onClicked: BluetoothService.disconnectDevice(connectedTile.modelData.mac) - } - } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: connectedTile.modelData.mac - } - } - } - - MouseArea { - id: tileMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } - } - } - - // ========================================================= - // Section 2: Paired Devices - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = BluetoothService.pairedDevices ? (BluetoothService.pairedDevices.count || BluetoothService.pairedDevices.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.textSecondary - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = BluetoothService.pairedDevices ? (BluetoothService.pairedDevices.count || BluetoothService.pairedDevices.length || 0) : 0; - return "// PAIRED (" + count + ")"; - } - } - } - - Repeater { - model: BluetoothService.pairedDevices - - delegate: Rectangle { - id: pairedTile - - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 46 - color: pairedMouseArea.containsMouse ? Theme.surfaceHover : Theme.gray800 - border.color: pairedMouseArea.containsMouse ? Theme.accent : Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: 2 - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "bluetooth" - color: Theme.textSecondary - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: pairedTile.modelData.name || "Device" - elide: Text.ElideRight - } - - // Connect Button - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: connLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: connMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: connMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: connLabel - - anchors.centerIn: parent - color: connMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: (BluetoothService.isActionPending && BluetoothService.actionTargetMac === pairedTile.modelData.mac) ? (BluetoothService.actionType === "connect" ? "[CONN...]" : "[WAIT...]") : "[CONNECT]" - } - - MouseArea { - id: connMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: !BluetoothService.isActionPending - - onClicked: BluetoothService.connectDevice(pairedTile.modelData.mac) - } - } - - // Forget Button - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: forgetLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: forgetMouse.containsMouse ? Theme.destructive : Theme.borderMuted - border.width: Theme.borderWidth - color: forgetMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: forgetLabel - - anchors.centerIn: parent - color: forgetMouse.containsMouse ? Theme.destructive : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: "x" - } - - MouseArea { - id: forgetMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: !BluetoothService.isActionPending - - onClicked: BluetoothService.forgetDevice(pairedTile.modelData.mac) - } - } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: pairedTile.modelData.mac - } - } - } - - MouseArea { - id: pairedMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } - } - } - - // ========================================================= - // Section 3: Available / Discovered Devices - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = BluetoothService.availableDevices ? (BluetoothService.availableDevices.count || BluetoothService.availableDevices.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.accent - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = BluetoothService.availableDevices ? (BluetoothService.availableDevices.count || BluetoothService.availableDevices.length || 0) : 0; - return "// DISCOVERED (" + count + ")"; - } - } - } - - Repeater { - model: BluetoothService.availableDevices - - delegate: Rectangle { - id: availTile - - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 46 - color: availMouseArea.containsMouse ? Theme.surfaceHover : Theme.gray800 - border.color: availMouseArea.containsMouse ? Theme.accent : Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: 2 - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "bluetooth" - color: Theme.textSecondary - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: availTile.modelData.name || "Device" - elide: Text.ElideRight - } - - // Pair Button - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: pairLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: pairMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: pairMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: pairLabel - - anchors.centerIn: parent - color: pairMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: (BluetoothService.isActionPending && BluetoothService.actionTargetMac === availTile.modelData.mac) ? (BluetoothService.actionType === "pair" ? "[PAIRING...]" : "[WAIT...]") : "[PAIR]" - } - - MouseArea { - id: pairMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: !BluetoothService.isActionPending - - onClicked: BluetoothService.pairDevice(availTile.modelData.mac) - } - } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: availTile.modelData.mac - } - } - } - - MouseArea { - id: availMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } - } - } - } - } - } - - // Footer Divider - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Theme.borderWidth - color: Theme.divider - } - - // Footer Row - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Text { - Layout.fillWidth: true - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: { - if (!BluetoothService.powered) return "// STATUS: OFF"; - if (BluetoothService.isScanning) return "// STATUS: SCANNING..."; - if (BluetoothService.isConnected) return "// STATUS: CONNECTED"; - return "// STATUS: STANDBY"; - } - elide: Text.ElideRight - } - - Rectangle { - id: refreshBtn - - Layout.preferredHeight: 18 - Layout.preferredWidth: refreshLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: refreshMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: refreshMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: refreshLabel - - anchors.centerIn: parent - color: refreshMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: "[REFRESH]" - } - - MouseArea { - id: refreshMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: BluetoothService.refresh() - } - } - } - } -} diff --git a/shell/desktop/surfaces/components/BluetoothWidget.qml b/shell/desktop/surfaces/components/BluetoothWidget.qml deleted file mode 100644 index cfed0e15..00000000 --- a/shell/desktop/surfaces/components/BluetoothWidget.qml +++ /dev/null @@ -1,82 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../core" -import "../../services" - -Item { - id: root - - property bool isHovered: false - - readonly property bool available: BluetoothService.available - readonly property bool powered: BluetoothService.powered - readonly property bool isConnected: BluetoothService.isConnected - readonly property string deviceName: BluetoothService.deviceName - - readonly property string stateText: { - if (!root.available) { - return "--N/A--"; - } - if (!root.powered) { - return "OFF"; - } - if (root.isConnected) { - return root.deviceName !== "" ? root.deviceName : "CONNECTED"; - } - return "ON"; - } - - implicitHeight: visible ? layout.implicitHeight : 0 - implicitWidth: visible ? layout.implicitWidth : 0 - visible: root.available - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingXs - - CtosIcon { - Layout.alignment: Qt.AlignVCenter - size: 14 - name: root.powered ? "bluetooth" : "bluetooth-slash" - active: root.isConnected - color: { - if (!root.available) { - return Theme.textMuted; - } - if (root.isConnected) { - return Theme.acidGreen; - } - if (root.powered) { - return root.isHovered ? Theme.accent : Theme.textPrimary; - } - return Theme.textSecondary; - } - } - - Text { - Layout.alignment: Qt.AlignVCenter - Layout.maximumWidth: 120 - color: { - if (!root.available) { - return Theme.textMuted; - } - if (root.isConnected) { - return Theme.textPrimary; - } - if (root.powered) { - return root.isHovered ? Theme.accent : Theme.textPrimary; - } - return Theme.textSecondary; - } - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - maximumLineCount: 1 - text: root.stateText - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/CalendarAgenda.qml b/shell/desktop/surfaces/components/CalendarAgenda.qml new file mode 100644 index 00000000..fa4ef296 --- /dev/null +++ b/shell/desktop/surfaces/components/CalendarAgenda.qml @@ -0,0 +1,489 @@ +import QtQuick +import Quickshell +import "../../core" +import "../../services" + +// Month grid over a day timeline, the agenda half of the calendar card. +// +// The design puts these side by side, but the card lives in the CCC's right +// column at roughly 318px wide, which leaves about 40px per weekday once the +// card's own padding is subtracted. Side by side would give the timeline about +// 150px -- too narrow for a time range and a title on one row. Stacked, the grid +// gets full width and the timeline reads as a list underneath it. +// +// The month shown and the day selected are local state rather than anything +// CalendarService owns: the service answers "what events exist", this decides +// "which month am I looking at". +Item { + id: root + + // ========================================================================= + // View State + // ========================================================================= + + property var today: new Date() + property int viewYear: today.getFullYear() + property int viewMonth: today.getMonth() + + // Selected day. Defaults to today, so the card opens on something useful + // instead of an empty grid. + property var selected: new Date(today.getFullYear(), today.getMonth(), today.getDate()) + + // Bumping this re-reads CalendarService's derived queries. Bound to + // revision because the service rebuilds its arrays wholesale on reload, and + // the grid dots and timeline both derive from those. + property int calRevision: CalendarService.revision + + // Routed through functions that take the revision as an argument, so these + // bindings actually depend on it. Calling CalendarService directly would + // depend only on `selected`, and since the service rebuilds its arrays + // wholesale on reload rather than mutating them, nothing would re-evaluate + // when new events arrived -- the card would stay empty until the month + // changed. + readonly property var monthMarks: root._marksForRevision(viewYear, viewMonth, calRevision) + readonly property var dayEvents: root._eventsForRevision(selected, calRevision) + + function _marksForRevision(year: int, month: int, rev: int): var { + return CalendarService.eventDaysInMonth(year, month); + } + + function _eventsForRevision(date: var, rev: int): var { + return CalendarService.eventsForDay(date); + } + + readonly property string monthLabel: + Qt.locale().standaloneMonthName(viewMonth) + " " + viewYear + + readonly property string selectedLabel: { + const d = Qt.locale().standaloneDayName(selected) + + " " + Qt.locale().standaloneMonthName(selected.getMonth()) + + " " + String(selected.getDate()).padStart(2, "0") + + " " + selected.getFullYear(); + return d; + } + + function _isSameDay(a: var, b: var): bool { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); + } + + function _shiftMonth(delta: int): void { + const shifted = new Date(viewYear, viewMonth + delta, 1); + viewYear = shifted.getFullYear(); + viewMonth = shifted.getMonth(); + } + + // Stable colour per event, derived from the summary. + // + // .ics has a CATEGORIES field but it is inconsistently used, and the design's + // coloured bars are per-event rather than per-calendar. Hashing the summary + // gives the same event the same bar across reloads, which is what makes the + // timeline scannable, without inventing a colour field. + // Accent hues only. statusGreen and the reds are deliberately excluded: Theme + // documents those as status and destructive semantics that "must not be + // interchanged", and a decorative category bar is neither. Note also that + // Theme.accentGreen resolves to magenta, so it would have silently collapsed + // to the same colour as the first entry. + readonly property var _barPalette: [ + Theme.accentMagenta, Theme.accentBlue, Theme.accentViolet, Theme.pastelOrange + ] + + function _colorFor(summary: string): color { + let hash = 0; + const text = summary || ""; + for (let i = 0; i < text.length; ++i) + hash = (hash * 31 + text.charCodeAt(i)) | 0; + const index = (hash < 0 ? -hash : hash) % _barPalette.length; + return _barPalette[index]; + } + + // ========================================================================= + // Geometry + // ========================================================================= + + readonly property int gridCols: 7 + readonly property int headerHeight: 24 + readonly property int dayCellHeight: 26 + // Six rows always, so the grid does not change height between months. + readonly property int gridRows: 6 + readonly property int gridHeight: headerHeight + gridRows * dayCellHeight + readonly property int cellWidth: (width - Theme.spacingSmall * 2) / gridCols + + readonly property int timelineHeaderHeight: 22 + // Six rows is enough to show a full day without turning the card into a + // scroll area inside a scroll area. + readonly property int maxVisibleEvents: 6 + readonly property int eventRowHeight: 34 + + implicitHeight: headerHeight + gridHeight + + Theme.spacingMedium + + timelineHeaderHeight + + Math.min(dayEvents.length, maxVisibleEvents) * eventRowHeight + + Theme.spacingSmall + + // ========================================================================= + // Month Navigation + // ========================================================================= + + Row { + id: monthBar + width: parent.width + height: root.headerHeight + + NavButton { + id: prevMonth + anchors.verticalCenter: parent.verticalCenter + label: "‹" + onClicked: root._shiftMonth(-1) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + x: prevMonth.x + prevMonth.width + Theme.spacingMedium + text: root.monthLabel + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeBody + font.weight: Theme.fontWeightDemiBold + } + + NavButton { + id: nextMonth + anchors.verticalCenter: parent.verticalCenter + x: monthBar.width - width + label: "›" + onClicked: root._shiftMonth(1) + } + } + + // ========================================================================= + // Month Grid + // ========================================================================= + + Column { + id: gridColumn + y: monthBar.height + width: parent.width + spacing: Theme.spacingSmall + + // Weekday initials, Monday first. + Row { + width: parent.width + height: root.headerHeight + + Repeater { + model: [ "M", "T", "W", "T", "F", "S", "S" ] + delegate: Text { + required property string modelData + width: root.cellWidth + height: root.headerHeight + text: modelData + color: Theme.textMuted + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + + // Day cells. + // + // A Grid, not a bare Repeater: a Repeater contributes children to its + // parent, and a Column stacks its children vertically. Without the Grid + // the 42 cells fall into one column and the month reads as a list. + Grid { + columns: root.gridCols + + Repeater { + model: root._cells + delegate: Item { + required property var modelData + width: root.cellWidth + height: root.dayCellHeight + + readonly property bool inMonth: modelData.inMonth + readonly property bool isSelected: root._isSameDay(modelData.date, root.selected) + readonly property bool isToday: root._isSameDay(modelData.date, root.today) + readonly property bool hasEvents: root.monthMarks[modelData.date.getDate()] === true + && modelData.inMonth + + // Selected day gets the filled accent circle from the design; + // today, when not selected, gets a ring instead so "today" stays + // findable after the user browses into another month. + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) - 2 + height: width + radius: width / 2 + visible: parent.isSelected + color: Theme.accentMagenta + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) - 4 + height: width + radius: width / 2 + visible: !parent.isSelected && parent.isToday + color: "transparent" + border.width: Theme.borderWidth + border.color: Theme.accentMagenta + } + + Text { + anchors.centerIn: parent + text: modelData.date.getDate() + color: parent.inMonth + ? (parent.isSelected ? Theme.navyDeep : Theme.textPrimary) + : Theme.textDim + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: parent.isToday || parent.isSelected + ? Theme.fontWeightDemiBold + : Theme.fontWeightNormal + } + + // Event indicator. Dots rather than a count: at this cell size a + // number is unreadable, and presence is what the grid is for. + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + y: parent.height - 3 + width: 3 + height: 3 + radius: 1.5 + visible: parent.hasEvents + // On the selected day the dot would sit on the filled accent + // circle, so it inverts to the circle's own dark fill instead + // of matching it and disappearing. + color: parent.isSelected ? Theme.navyDeep : Theme.accentBlue + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + root.selected = modelData.date; + // Clicking a trailing day from the next month should move + // the view too, otherwise the selection is off-screen. + if (!modelData.inMonth) + root._shiftMonth(modelData.date.getMonth() > root.viewMonth ? 1 : -1); + } + } + } + } + } + } + + // ========================================================================= + // Day Timeline + // ========================================================================= + + Rectangle { + id: divider + y: gridColumn.y + gridColumn.height + Theme.spacingMedium + width: parent.width + height: Theme.borderWidth + color: Theme.border + } + + Text { + id: timelineHeader + y: divider.y + divider.height + Theme.spacingMedium + width: parent.width + height: root.timelineHeaderHeight + text: root.selectedLabel + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // Empty state. Covers all three cases the service distinguishes, so the card + // never renders as a blank box with no explanation. + Column { + anchors.top: timelineHeader.bottom + anchors.topMargin: Theme.spacingMedium + width: parent.width + spacing: Theme.spacingSmall + visible: root.dayEvents.length === 0 + + Text { + width: parent.width + text: CalendarService.emptyReason + color: Theme.textMuted + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + wrapMode: Text.WordWrap + } + } + + Column { + anchors.top: timelineHeader.bottom + anchors.topMargin: Theme.spacingSmall + width: parent.width + spacing: Theme.spacingXs + + Repeater { + model: root.dayEvents.length > root.maxVisibleEvents + ? root.dayEvents.slice(0, root.maxVisibleEvents) + : root.dayEvents + + delegate: Item { + required property var modelData + required property int index + + width: parent.width + height: root.eventRowHeight + + readonly property bool allDay: modelData.allDay + // start/end are epoch milliseconds, so they have to be wrapped + // before Qt.formatTime will render them -- handing it the raw + // number yields an empty string rather than an error. + readonly property string startText: modelData.allDay + ? "All day" + : Qt.formatTime(new Date(modelData.start), "HH:mm") + readonly property string endText: modelData.allDay + ? "" + : Qt.formatTime(new Date(modelData.end), "HH:mm") + + // Stacked start/end, as in the design's 09:00 / 10:00 column. + Column { + id: timeColumn + x: 0 + anchors.verticalCenter: parent.verticalCenter + width: 40 + spacing: 1 + + Text { + text: parent.parent.startText + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro + } + Text { + text: parent.parent.endText + visible: text.length > 0 + color: Theme.textDim + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro + } + } + + // Category colour bar. Hue is derived from the summary so an + // event keeps a stable colour between reloads, without needing a + // colour field in the .ics. + Rectangle { + id: categoryBar + x: timeColumn.width + Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + width: 2 + height: parent.height - Theme.spacingMedium + radius: 1 + color: root._colorFor(modelData.summary) + } + + Column { + anchors.left: categoryBar.right + anchors.leftMargin: Theme.spacingMedium + anchors.right: parent.right + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + width: parent.width + text: modelData.summary + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight + } + Text { + width: parent.width + visible: text.length > 0 + // Location is the subtitle in the design; fall back to the + // .ics category when there is no location. + text: modelData.location.length > 0 + ? modelData.location + : modelData.category + color: Theme.textDim + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro + elide: Text.ElideRight + } + } + } + } + } + + // "+ N more" when the day is busier than the card shows. + Text { + y: timelineHeader.height + root.timelineHeaderHeight + Theme.spacingSmall + + Math.min(root.dayEvents.length, root.maxVisibleEvents) * root.eventRowHeight + width: parent.width + height: Theme.fontSizeCaption + 2 + visible: root.dayEvents.length > root.maxVisibleEvents + text: qsTr("+%1 more").arg(root.dayEvents.length - root.maxVisibleEvents) + color: Theme.textMuted + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro + } + + // ========================================================================= + // Derived + // ========================================================================= + + // 42 cells covering the displayed month, padded with the neighbouring + // months' days so the grid is always six rows. + readonly property var _cells: { + const out = []; + const first = new Date(viewYear, viewMonth, 1); + const offset = (first.getDay() + 6) % 7; // Monday-first + const cursor = new Date(viewYear, viewMonth, 1 - offset); + + for (let i = 0; i < gridRows * gridCols; ++i) { + out.push({ + date: new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()), + inMonth: cursor.getMonth() === viewMonth + }); + cursor.setDate(cursor.getDate() + 1); + } + return out; + } + + // Small square nav button. Local rather than in components/ because the CCC + // already has its own chrome and this is only used by the calendar grid. + component NavButton: Item { + id: nav + property string label: "" + signal clicked() + width: 22 + height: 22 + + Rectangle { + anchors.fill: parent + radius: Theme.radiusMedium + color: hover.hovered ? Theme.surfaceHover : "transparent" + } + Text { + anchors.centerIn: parent + text: nav.label + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeLarge + } + MouseArea { + id: hover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: nav.clicked() + } + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/CalendarPopup.qml b/shell/desktop/surfaces/components/CalendarPopup.qml index 6d124ca8..b810b994 100644 --- a/shell/desktop/surfaces/components/CalendarPopup.qml +++ b/shell/desktop/surfaces/components/CalendarPopup.qml @@ -51,7 +51,14 @@ Rectangle { SystemClock { id: systemClock - precision: SystemClock.Minute + // Plural, no Precision suffix. Qt documents this enum as + // SecondPrecision / MinutePrecision / HourPrecision, but on Qt 6.11.2 + // with Quickshell 0.3.1 those resolve to undefined and the assignment + // fails with "Unable to assign [undefined] to SystemClock::Enum". + // The names that actually exist here are Seconds and Minutes, verified + // by rendering a probe. Do not "correct" this to the documented form -- + // that silently breaks the clock. + precision: SystemClock.Minutes } property int todayYear: systemClock.date.getFullYear() diff --git a/shell/desktop/surfaces/components/Card.qml b/shell/desktop/surfaces/components/Card.qml new file mode 100644 index 00000000..30ffd8ed --- /dev/null +++ b/shell/desktop/surfaces/components/Card.qml @@ -0,0 +1,290 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import "../../core" + +// Card: a rounded tile with a header bar and a content area. +// +// Replaces the accordion the CCC was built from. An accordion is a disclosure +// control -- it hides its contents behind a click, which is the right model for +// a list you might not care about and the wrong model for a panel whose cards +// are all meant to be read at once. The design shows every card open. +// +// The header carries an icon in a tinted tile, a title, and one trailing +// control: a toggle, a button, or a disclosure chevron. + +Item { + id: root + + property string title: "" + + // Optional dim second line in the header, for the card's headline state + // rather than a value that belongs in the body. + property string subtitle: "" + + // Semantic glyph name, resolved by GlyphIcon. Empty hides the tile. + property string icon: "" + + // Tints the icon tile and the chevron. Per-card so a card can signal what it + // is without each one reaching for a colour directly. + property color accent: Theme.accentBlue + + // Trailing interactive control. A Component so a card can pass any widget; + // sized from the control's own implicit size so callers cannot desync the + // layout from the content by guessing a width. + property Component action: null + + property Component contentComponent: null + + // Disclosure state. A card owns this and toggles it itself on a header + // click; callers should not bind it, because a binding here is silently + // destroyed by that first click. To open a card programmatically, assign to + // it after construction rather than binding. + property bool collapsible: false + property bool expanded: true + + signal headerClicked() + + // Header height tracks the icon tile plus padding, so raising the tile size + // raises the header rather than clipping it. + readonly property int headerHeight: Theme.cardIconTile + Theme.spacingSmall * 2 +// Vertical insets, inside the card. Every card uses these two values and no + // others, so a collapsed card and an expanded one are padded identically, + // and the card is symmetric top to bottom. + // + // The card had none at all above the header and twelve below the content, so + // the heading sat against the top border while the body ended in a pool of + // slack -- top-tight and bottom-heavy. The top edge is now open and the + // bottom trimmed back to match it. + // + // Eight and eight is also the sixteen the card already reserved, so this is + // a redistribution rather than a resize: every card keeps its exact height + // and the panel does not move. That mattered because the left column had only + // ~3px of slack against the panel cap, and an earlier attempt that added 8 + // above while leaving 12 below pushed System Status past the cap and sliced + // its rings in half. + readonly property int topInset: Theme.spacingMedium + readonly property int contentPadding: Theme.spacingMedium + + readonly property real contentHeight: contentLoader.item + ? (contentLoader.item.implicitHeight > 0 + ? contentLoader.item.implicitHeight + : contentLoader.item.height) + : 0 + + implicitWidth: parent ? parent.width : undefined + // Both insets apply whether or not there is content, so a collapsed card is + // padded exactly like an expanded one. The old ternary substituted topInset + // for the bottom gap when empty, which made collapsed cards 12/12 and + // expanded ones 12/4 -- the inconsistency this removes. + implicitHeight: topInset + headerHeight + + (contentHeight > 0 ? contentHeight : 0) + + contentPadding + width: parent ? parent.width : undefined + height: implicitHeight + + // Tile fill and border. The card sits a step above the panel behind it, so + // it reads as a surface rather than as an outline drawn on the background. + Rectangle { + id: tile + anchors.fill: parent + radius: Theme.commandCenterSectionRadius + color: Theme.surface + border.width: Theme.borderWidth + border.color: hoverArea.containsMouse ? Theme.borderHover : Theme.border + visible: true + + Behavior on border.color { + ColorAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + } + } + } + + // ---------------------------------------------------------------- header + + Item { + id: header + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + // The inset lives here: between the card's top edge and the heading. + anchors.topMargin: root.topInset + height: root.headerHeight + + // Icon tile: a tinted rounded square, which is what gives the card its + // identity at a glance. + Rectangle { + id: iconTile + anchors.left: parent.left + anchors.leftMargin: Theme.cardPadding + anchors.verticalCenter: parent.verticalCenter + width: root.icon !== "" ? Theme.cardIconTile : 0 + height: width + radius: Theme.radiusMedium + color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.16) + visible: width > 0 + + GlyphIcon { + anchors.centerIn: parent + width: Theme.cardIconTile - Theme.spacingSmall * 2 + height: width + glyph: root.icon + color: root.accent + } + } + + // Title, with an optional dim second line for state ("85% - Charging"). + Column { + id: titleStack + anchors.left: iconTile.right + anchors.leftMargin: root.icon !== "" ? Theme.spacingMedium : Theme.cardPadding + anchors.right: trailing.left + anchors.rightMargin: Theme.spacingSmall + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + id: titleText + width: parent.width + text: root.title + color: Theme.textPrimary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeBody + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: root.subtitle !== "" + text: root.subtitle + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + elide: Text.ElideRight + } + } + + // Trailing slot: the chevron when collapsible, otherwise whatever the + // card passed as `action`. + Item { + id: trailing + anchors.right: parent.right + anchors.rightMargin: Theme.cardPadding + anchors.verticalCenter: parent.verticalCenter + width: trailingContent.width + height: trailingContent.height + + Row { + id: trailingContent + anchors.centerIn: parent + spacing: Theme.spacingSmall + +GlyphIcon { + id: chevronIcon + visible: root.collapsible + width: Theme.spacing2Xl - Theme.spacingSmall + height: width + glyph: "chevron" + color: Theme.textSecondary + // Glyph draws pointing down. Expanded shows it unrotated + // (further down the card is visible), collapsed flips it up + // to indicate the card opens downward. + rotation: root.expanded ? 0 : 180 + + Behavior on rotation { + NumberAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + easing.type: Easing.OutCubic + } + } + + // Lives inside the icon rather than in the header beside it. + // Anchors only resolve against a parent or a sibling, and the + // header MouseArea is a sibling of `trailing` -- the icon is + // three levels below that, inside trailingContent. Anchoring + // across that gap is silently discarded: the area kept its + // size but fell back to the header's origin, so the top-left + // corner of every card toggled it. Filling the parent needs + // no anchor at all, so there is nothing to get wrong. + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: root.collapsible + acceptedButtons: root.collapsible ? Qt.LeftButton : Qt.NoButton + cursorShape: Qt.PointingHandCursor + onClicked: { + root.expanded = !root.expanded; + root.headerClicked(); + } + } + } + + Loader { + visible: root.action !== null + width: visible ? implicitWidth : 0 + height: visible ? implicitHeight : 0 + sourceComponent: root.action + } + } + } + +MouseArea { + id: hoverArea + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + // Stops short of the trailing slot rather than filling the header. + // That slot holds the chevron and any `action`, which for the Wi-Fi + // and Bluetooth cards is the on/off ToggleSwitch. Filling the header + // swallowed clicks on those switches: the click collapsed the card + // instead of toggling Wi-Fi or Bluetooth, so every header control in + // the panel looked dead. + anchors.right: trailing.left + anchors.rightMargin: Theme.spacingSmall + hoverEnabled: true + enabled: root.collapsible + acceptedButtons: root.collapsible ? Qt.LeftButton : Qt.NoButton +onClicked: { + root.expanded = !root.expanded; + root.headerClicked(); + } + } + } + + // -------------------------------------------------------------- content + + Item { + id: contentArea + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + // Flush under the heading: the gap between the card's top edge and the + // heading is topInset's job, and doubling it up here is what left the + // content marooned in the middle of the card. + anchors.leftMargin: Theme.cardPadding + anchors.rightMargin: Theme.cardPadding + // Collapsed cards keep their header height, so the content is hidden + // rather than the card collapsing to nothing. + height: root.expanded && root.contentHeight > 0 + ? root.contentHeight + : 0 + clip: true + visible: height > 0 + + Behavior on height { + NumberAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + easing.type: Easing.OutCubic + } + } + + Loader { + id: contentLoader + width: parent ? parent.width : undefined + sourceComponent: root.expanded ? root.contentComponent : null + } + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/ClockWidget.qml b/shell/desktop/surfaces/components/ClockWidget.qml deleted file mode 100644 index 71de5930..00000000 --- a/shell/desktop/surfaces/components/ClockWidget.qml +++ /dev/null @@ -1,59 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import "../../core" - -Item { - id: root - - signal toggleCalendar - - readonly property date currentDate: systemClock.date - readonly property string dateString: Qt.formatDateTime(systemClock.date, "yyyy-MM-dd") - readonly property string timeString: Qt.formatDateTime(systemClock.date, "HH:mm") - - implicitHeight: 36 - implicitWidth: layout.implicitWidth - - MouseArea { - anchors.fill: parent - onClicked: root.toggleCalendar() - } - - SystemClock { - id: systemClock - - precision: SystemClock.Seconds - } - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingSmall - - Text { - Layout.alignment: Qt.AlignVCenter - color: Theme.textSecondary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightNormal - maximumLineCount: 1 - text: root.dateString - wrapMode: Text.NoWrap - } - - Text { - Layout.alignment: Qt.AlignVCenter - color: Theme.textPrimary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightDemiBold - maximumLineCount: 1 - text: root.timeString - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/DynamicIsland.qml b/shell/desktop/surfaces/components/DynamicIsland.qml deleted file mode 100644 index a31783f0..00000000 --- a/shell/desktop/surfaces/components/DynamicIsland.qml +++ /dev/null @@ -1,472 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Mpris -import "../../core" -import "../../services" - -Item { - id: root - - // ========================================================================= - // Dynamic Island State & Dimensions - // ========================================================================= - - readonly property real compactWidth: 120 - readonly property real mediaWidth: 240 - readonly property real expandedWidth: 300 - - // States: "compact", "media", "notification" - property string state: "compact" - - // Writable isExpanded for backward compatibility with tests. - // Tests directly set isExpanded = true/false, so we keep it writable - // and sync it with the state property. - property bool isExpanded: false - - property string latestAppName: "" - property string latestSummary: "" - property int latestUrgency: 1 - readonly property bool isCommandCenterOpen: OverlayController.activeSurface === OverlayController.Surface.CommandCenter - - // ========================================================================= - // MPRIS Active Player Tracking & Equalizer State - // ========================================================================= - - property int _mprisTrigger: 0 - - Repeater { - model: Mpris.players.values - - Item { - id: mprisWatcher - required property var modelData - - Connections { - target: mprisWatcher.modelData - - function onPlaybackStateChanged(): void { - root._mprisTrigger++; - } - function onTrackTitleChanged(): void { - root._mprisTrigger++; - } - function onTrackArtistChanged(): void { - root._mprisTrigger++; - } - } - } - } - - readonly property var activePlayer: { - const trigger = root._mprisTrigger; - const list = Mpris.players.values; - if (!list || list.length === 0) return null; - - for (let i = 0; i < list.length; i++) { - const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Playing) { - return p; - } - } - - for (let i = 0; i < list.length; i++) { - const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Paused && p.trackTitle && p.trackTitle.trim() !== "") { - return p; - } - } - - for (let i = 0; i < list.length; i++) { - const p = list[i]; - if (p && p.playbackState === MprisPlaybackState.Paused) { - return p; - } - } - - return list[0] || null; - } - - readonly property bool hasMedia: activePlayer !== null && ( - activePlayer.playbackState === MprisPlaybackState.Playing || - (activePlayer.trackTitle !== undefined && activePlayer.trackTitle !== null && activePlayer.trackTitle.trim() !== "") - ) - readonly property bool isPlaying: hasMedia && activePlayer.playbackState === MprisPlaybackState.Playing - - readonly property string mediaTitle: (activePlayer && activePlayer.trackTitle && activePlayer.trackTitle.trim() !== "") ? activePlayer.trackTitle.trim() : "--UNTITLED--" - readonly property string mediaArtist: (activePlayer && activePlayer.trackArtist && activePlayer.trackArtist.trim() !== "") ? activePlayer.trackArtist.trim() : "" - - property real visualizerPhase: 0.0 - - NumberAnimation { - id: visualizerAnim - target: root - property: "visualizerPhase" - from: 0.0 - to: 6.283185307179586 - duration: 1200 - loops: Animation.Infinite - running: root.isPlaying && !Settings.reducedMotion - } - - // ========================================================================= - // State Synchronization - // ========================================================================= - - // Sync isExpanded writes -> state - onIsExpandedChanged: { - if (root.isExpanded && root.state !== "notification") { - root.state = "notification"; - } else if (!root.isExpanded && root.state === "notification") { - root.state = root.hasMedia ? "media" : "compact"; - } - } - - // Sync state -> isExpanded (for notification state changes from showNotification) - onStateChanged: { - if (root.state === "notification" && !root.isExpanded) { - root.isExpanded = true; - } else if (root.state !== "notification" && root.isExpanded) { - root.isExpanded = false; - } - } - - // Sync media presence -> state (when not displaying notification) - onHasMediaChanged: { - if (!root.isExpanded) { - root.state = root.hasMedia ? "media" : "compact"; - } - } - - // Centralized layout function per dynamic-radial-geometry skill: - // Synchronously compute width/height based on state to avoid desync. - property real targetW: { - const isExp = root.isExpanded || root.state === "notification"; - if (isExp) { - return root.expandedWidth; - } else if (root.hasMedia) { - return root.mediaWidth; - } else if (mouseArea.containsMouse) { - return root.compactWidth + 12; - } - return root.compactWidth; - } - - property real targetH: Theme.barHeight - 6 - - // Fallback for tests: width: isExpanded ? expandedWidth : compactWidth - width: targetW - implicitWidth: targetW - height: targetH - implicitHeight: targetH - - property alias radius: visualBg.radius - property alias color: visualBg.color - - Rectangle { - id: visualBg - anchors.centerIn: parent - width: root.targetW - height: root.targetH - - color: Theme.background - radius: Theme.radiusPill - border.color: (mouseArea.containsMouse || root.isExpanded) ? Theme.accent : "black" - border.width: Theme.borderWidth - clip: true - - opacity: root.isCommandCenterOpen ? 0.0 : 1.0 - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - - Behavior on width { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } - } // end visualBg - - // ========================================================================= - // Auto Collapse Timer - // ========================================================================= - Timer { - id: collapseTimer - interval: 4000 - repeat: false - onTriggered: { - root.isExpanded = false; - } - } - - // Public method to trigger notification expansion - function showNotification(appName: string, summary: string, urgency: int): void { - root.latestAppName = appName || "System"; - root.latestSummary = summary || ""; - root.latestUrgency = (urgency !== undefined) ? urgency : 1; - root.isExpanded = true; - collapseTimer.restart(); - } - - // ========================================================================= - // NotificationService Connection - // ========================================================================= - - Connections { - target: NotificationService - - function onNotificationReceived(notification): void { - if (!NotificationService.doNotDisturb && notification) { - root.showNotification(notification.appName, notification.summary, notification.urgency); - } - } - } - - // Fallback: watch history insertions in case notificationReceived signal isn't emitted - Connections { - target: NotificationService.history - - function onRowsInserted(parentIndex, first, last): void { - if (!root.isExpanded && !NotificationService.doNotDisturb && first === 0 && NotificationService.history.count > 0) { - const item = NotificationService.history.get(0); - if (item) { - root.showNotification(item.appName, item.summary, item.urgency); - } - } - } - } - - // ========================================================================= - // Compact View (Idle / DND / Unread) - // ========================================================================= - - RowLayout { - id: compactContent - anchors.centerIn: parent - opacity: (!root.isExpanded && !root.hasMedia) ? 1.0 : 0.0 - visible: opacity > 0.0 - spacing: Theme.spacingSmall - - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationFast - } - } - - // State 1: DND active - Text { - id: dndIndicator - visible: NotificationService.doNotDisturb - text: "[DND]" - color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - // State 2: Active unread notifications (when not DND) - Rectangle { - id: unreadDot - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount > 0 - width: 6 - height: 6 - radius: 3 - color: Theme.acidGreen - Layout.alignment: Qt.AlignVCenter - } - - Text { - id: unreadText - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount > 0 - text: NotificationService.unreadCount + " NOTIF" - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightNormal - } - - // State 3: Idle standby (no notifications and not DND) - Rectangle { - id: idleDot - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount === 0 - width: 6 - height: 6 - radius: 3 - color: Theme.acidGreen - Layout.alignment: Qt.AlignVCenter - } - - Text { - id: idleText - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount === 0 - text: "// IDLE" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightNormal - } - } - - // ========================================================================= - // Media Pill / Live Activity View (240x34) - // ========================================================================= - - RowLayout { - id: mediaContent - anchors.fill: parent - anchors.leftMargin: Theme.paddingLarge - anchors.rightMargin: Theme.paddingLarge - opacity: (!root.isExpanded && root.hasMedia) ? 1.0 : 0.0 - visible: opacity > 0.0 - spacing: Theme.spacingSmall - - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationFast - } - } - - Text { - Layout.alignment: Qt.AlignVCenter - text: root.isPlaying ? "▶" : "⏸" - color: root.isPlaying ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - Text { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: root.mediaArtist !== "" ? (root.mediaTitle + " // " + root.mediaArtist) : root.mediaTitle - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightNormal - elide: Text.ElideRight - maximumLineCount: 1 - } - - // Animated Equalizer Bars - RowLayout { - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: 14 - spacing: 2 - - Repeater { - model: 4 - - Rectangle { - id: eqBar - required property int index - Layout.alignment: Qt.AlignBottom - Layout.preferredWidth: 3 - Layout.preferredHeight: root.isPlaying - ? Math.max(3, Math.round((Math.sin((eqBar.index * 1.1) + root.visualizerPhase) * 0.4 + 0.5) * 14)) - : 3 - color: root.isPlaying ? Theme.acidGreen : Theme.gray600 - radius: 1 - } - } - } - } - - // ========================================================================= - // Expanded View (Active Notification Banner) - // ========================================================================= - - RowLayout { - id: expandedContent - anchors.fill: parent - anchors.leftMargin: Theme.paddingLarge - anchors.rightMargin: Theme.paddingLarge - opacity: (root.isExpanded || root.state === "notification") ? 1.0 : 0.0 - visible: opacity > 0.0 - spacing: Theme.spacingSmall - - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationFast - } - } - - Rectangle { - Layout.alignment: Qt.AlignVCenter - width: 6 - height: 6 - radius: 3 - color: root.latestUrgency === 2 ? Theme.warningRed : Theme.acidGreen - } - - Text { - Layout.alignment: Qt.AlignVCenter - text: root.latestAppName - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - elide: Text.ElideRight - maximumLineCount: 1 - } - - Text { - Layout.alignment: Qt.AlignVCenter - text: "»" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - } - - Text { - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - text: root.latestSummary - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightNormal - elide: Text.ElideRight - maximumLineCount: 1 - wrapMode: Text.NoWrap - } - } - - // ========================================================================= - // Mouse Interaction - // ========================================================================= - - MouseArea { - id: mouseArea - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - NotificationService.toggleDnd(); - } else { - if (root.isCommandCenterOpen) { - OverlayController.close(); - } else { - collapseTimer.stop(); - root.isExpanded = false; - root.state = root.hasMedia ? "media" : "compact"; - OverlayController.openCommandCenter(); - } - } - } - - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } -} - diff --git a/shell/desktop/surfaces/components/GlyphIcon.qml b/shell/desktop/surfaces/components/GlyphIcon.qml new file mode 100644 index 00000000..02f06d16 --- /dev/null +++ b/shell/desktop/surfaces/components/GlyphIcon.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../core" + +// Functional glyphs, set in Google's Material Icons (Outlined). +// +// Why this exists rather than reusing CtosIcon: CtosIcon answers "which app is +// this?", mapping application names onto brand marks (kitty, zed, obsidian). The +// CCC was passing it category words -- "timer", "power", "volume", "bluetooth" -- +// for which it has no entries, which is why those header icons were blank or +// resolved to whatever matched first. An icon slot wants functional glyphs. +// +// These were hand-drawn on a 24x24 grid until the set grew past what is worth +// drawing by hand. Material Icons is Apache-2.0, ships a coherent family, and +// recolours and rescales like any Text, so a 539-line file of Shape paths +// collapses to one Text and a lookup. +// +// Codepoints are Google's own, taken from font/MaterialIcons-Regular.codepoints +// in the material-design-icons repository, not from memory: the pixel-matching +// route that got us here picked the wrong neighbour for lock, expand_more, +// chevron_right and calendar_today, all of which have near-identical siblings in +// the font. Every codepoint below was checked to exist in the Outlined face's +// cmap -- logout, for one, is absent there, so "logout" maps to exit_to_app +// (U+E879), which is in the Outlined face and means the same thing to a user. +// +// Positioning is by alignment within the item, not by TextMetrics. These fonts +// set a tall line box and hang the glyph high on it, so the line box cannot be +// centred blindly and the ink cannot be measured reliably -- see renderType +// below. Measured against a reference box, this lands the ink within 1px of +// dead centre at 16, 18 and 20px. + +Item { + id: root + + // Semantic name -> Material Icons codepoint. The names are the shell's own + // vocabulary rather than Google's, because these are referenced from cards + // that also choose behaviour by name. + // + // Named `glyph` rather than `name`: a property called `name` on a reusable + // component invites collision with the ambient identifiers every QML caller + // has in scope, and assigning it from the outside had no effect. + property string glyph: "" + + property color color: Theme.textPrimary + + // Optical size correction, for glyphs whose ink fills the 24dp em. + // + // font.pixelSize is derived from this item's height, which sizes the em box + // and not the drawing inside it. Most Material glyphs are inset from the em, + // so one slot size makes them look alike -- but some are not. `wifi` fills + // the em almost edge to edge, so at the same slot it draws about 1.27x the ink + // of `speaker` and overruns its slot by roughly 2px a side, which is what + // clipped its arcs against the clock. + // + // Correcting per glyph keeps the comparison honest. A uniform shrink would + // have made every other icon in the tree smaller to accommodate one glyph. + property real opticalScale: 1.0 + + implicitWidth: 20 + implicitHeight: implicitWidth + + readonly property string _codepoint: { + switch (root.glyph) { + case "bell": return "\uE7F4"; // notifications + case "wifi": return "\uE63E"; // wifi + case "bluetooth": return "\uE1A7"; // bluetooth + case "speaker": return "\uE050"; // volume_up + case "mic": return "\uE029"; // mic + case "calendar": return "\uE878"; // event + case "sliders": return "\uE429"; // tune + case "power": return "\uE8AC"; // power_settings_new + case "lock": return "\uE897"; // lock + case "logout": return "\uE879"; // exit_to_app + case "reboot": return "\uE863"; // autorenew + case "ethernet": return "\uE328"; // router (wired connection) + // battery is battery_std (U+E1A5) rather than battery_full (U+E1A4). The + // filled face is solid inside its outline, so at 16-20px it reads as a + // featureless block, and it asserts "full" at every charge level -- a + // second answer next to the percentage readout, contradicting it. The + // hollow one still reads as a battery at 14px and claims nothing. + case "battery": return "\uE1A5"; // battery_std, not battery_full + case "chevron": return "\uE5CF"; // expand_more + case "play": return "\uE037"; // play_arrow + case "pause": return "\uE034"; // pause + case "skipPrevious": return "\uE045"; // skip_previous + case "skipNext": return "\uE044"; // skip_next + // The shell's identity mark, replacing the hand-drawn HexMark. + // + // Material Symbols only -- the classic Material Icons Outlined face has + // no hexagon at all, which is why the identity mark had to be drawn as + // Shape paths until now. + // + // U+EB39, read out of the font's cmap rather than its glyph order. The + // glyph-order index for "hexagon" is 0x2B9, which looks plausible and is + // not a codepoint: U+02B9 is absent from the cmap, so Qt fell back to + // another font and drew U+02B9 -- MODIFIER LETTER EXTRA-HIGH TONE BAR -- + // which is a stray vertical stroke where the logo should be. + case "hexagon": return "\uEB39"; // hexagon + } + return ""; + } + + Text { + id: label + + anchors.fill: parent + visible: root._codepoint !== "" + text: root._codepoint + color: root.color + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + // The 24dp design grid maps to the pixel size, so a 16px slot wants + // pixelSize 16. The 1.18 overshoot is deliberate and measured: at 1.00 + // the icons read small next to the label beside them, and at 1.18 the + // wifi's strokes hold together at 16px. It does mean the ink overflows + // the slot -- the wifi is 22px wide in a 16px box -- so the glyphs need + // the surrounding spacing to breathe, not tight neighbours. + font.family: Theme.fontFamilyMaterialIcons + font.pixelSize: Math.round(root.height * 1.18 * root.opticalScale) + + // The font engine's hinting, not Qt Quick's distance-field path, which + // is tuned for body text and blurs small glyphs. QtRendering measures + // fractionally better but adds subpixel colour fringes that show up as + // cyan and orange edges against the dark panel. + // + // Also why centring is alignment and not TextMetrics: NativeRendering + // bypasses Qt Quick's text layout when it paints, so the metrics + // describe a box the glyph is not drawn into, and centring the ink by + // them put every icon 1-2px below centre. + renderType: Text.NativeRendering + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/LevelSlider.qml b/shell/desktop/surfaces/components/LevelSlider.qml new file mode 100644 index 00000000..9e34b891 --- /dev/null +++ b/shell/desktop/surfaces/components/LevelSlider.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../core" + +// Horizontal level slider: a filled track, a knob, drag and wheel to set. +// +// Exists because the CCC had two of these inline -- output and microphone -- +// which meant the drag handling, the fill maths and the keyboard behaviour all +// existed twice. Behaviour is also easier to reason about in one place than to +// keep consistent across two copies. + +Item { + id: root + + // Level in [0.0, 1.0]. Clamped on the way in and on the way out, because the + // value can come from a service that reports out-of-range, and an unclamped + // fraction draws a fill past the end of the track. + property real value: 0.0 + readonly property real clamped: Math.max(0, Math.min(1, value)) + + property real minimum: 0.0 + property real maximum: 1.0 + readonly property real span: Math.max(0.0001, maximum - minimum) + + property color fillColor: Theme.accentViolet + property color trackColor: Theme.navyBorder + property color knobColor: Theme.textPrimary + + property real knobDiameter: 12 + property real trackHeight: 5 + + // Emitted while dragging as well as on release, so the caller can push the + // value to the service continuously rather than only at the end. + signal moved(real level) + signal committed(real level) + + implicitHeight: Math.max(knobDiameter, trackHeight) + implicitWidth: 160 + + function _levelFromX(x: real): real { + const usable = Math.max(1, width - knobDiameter); + const t = Math.max(0, Math.min(1, (x - knobDiameter / 2) / usable)); + return minimum + t * span; + } + + Rectangle { + id: track + // Inset by the knob radius so the fill starts under the knob's leading + // edge rather than under its centre. Margins belong to the anchors + // group, not to the item. + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: root.knobDiameter / 2 + rightMargin: root.knobDiameter / 2 + } + height: root.trackHeight + radius: height / 2 + color: root.trackColor + } + + Rectangle { + id: fill + anchors.left: track.left + anchors.verticalCenter: parent.verticalCenter + height: root.trackHeight + radius: height / 2 + color: root.fillColor + width: Math.max(0, (track.width) * ((root.clamped - root.minimum) / root.span)) + + Behavior on width { + NumberAnimation { + // Only animate programmatic changes; a drag is already smooth + // and animating it here would lag the knob behind the pointer. + duration: root.pressed ? 0 : (Settings.reducedMotion ? 0 : Theme.durationFast) + } + } + } + + Rectangle { + id: knob + width: root.knobDiameter + height: width + radius: width / 2 + color: root.knobColor + y: (root.height - height) / 2 + x: (Math.max(1, root.width - root.knobDiameter)) * ((root.clamped - root.minimum) / root.span) + + Behavior on x { + NumberAnimation { + duration: root.pressed ? 0 : (Settings.reducedMotion ? 0 : Theme.durationFast) + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + id: area + anchors.fill: parent + anchors.margins: -Theme.spacingSmall + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + property bool pressed: false + + onPressed: function (mouse) { + area.pressed = true; + const v = root._levelFromX(mouse.x); + root.value = v; + root.moved(v); + } + onPositionChanged: function (mouse) { + if (!area.pressed) + return; + const v = root._levelFromX(mouse.x); + root.value = v; + root.moved(v); + } + onReleased: function (mouse) { + area.pressed = false; + root.committed(root.clamped); + } + + onWheel: function (wheel) { + const delta = (wheel.angleDelta.y !== 0 ? wheel.angleDelta.y : wheel.angleDelta.x) > 0 + ? root.span * 0.05 : -root.span * 0.05; + const v = Math.max(root.minimum, Math.min(root.maximum, root.value + delta)); + root.value = v; + root.moved(v); + root.committed(v); + } + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/LivingNotch.qml b/shell/desktop/surfaces/components/LivingNotch.qml index 2565a4c0..0b3dc650 100644 --- a/shell/desktop/surfaces/components/LivingNotch.qml +++ b/shell/desktop/surfaces/components/LivingNotch.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import QtQuick.Shapes import Quickshell import Quickshell.Services.Mpris import "../../adapters/hyprland" @@ -15,36 +16,79 @@ Item { // Public Contract // ========================================================================= property string monitorName: "" + // Detail level. Idle is one line: clock, glyphs, no numbers. The design's + // expanded state "breathes open with more details" -- a date line, seconds + // on the clock, and a percentage under each indicator glyph. Carrying all of + // that in the idle bar too is part of why it needed 470px to stop wrapping. + readonly property bool showDetail: + root.isCommandCenterOpen || root.notchState === "hover" + readonly property bool isCommandCenterOpen: OverlayController.activeSurface === OverlayController.Surface.CommandCenter readonly property real currentWidth: root.width readonly property real currentHeight: root.height readonly property string notchState: root._resolvedState // Dynamic Island & Test Compatibility Properties - property bool isExpanded: root._notificationActive + // Test-compatibility alias. Read-only on purpose: it used to be a writable + // binding onto _notificationActive while also being assigned alongside it, + // which broke the binding and left two bools that had to be kept in sync by + // hand. notificationActive is the only writable state. + readonly property bool isExpanded: root.notificationActive property string state: root.notchState - property bool calendarOpen: root._isCalendarOpen + // Read-only alias for the same reason as isExpanded above. + readonly property bool _isCalendarOpen: root.calendarOpen // State Tracking Flags property bool _isHovered: false - property bool _isCalendarOpen: false - property bool _notificationActive: false + property bool notificationActive: false + + // Whether the inline calendar grid is open. + // + // This was never declared, and the file both reads and writes it: the + // _isCalendarOpen alias, _resolvedState, the Loader's active binding, and + // toggleCalendar()/closeCalendar(). QML silently creates a dynamic property + // on the first assignment, so it appeared to work, but every read before + // that returned undefined -- which is what produced + // "Unable to assign [undefined] to bool" here and at the Loader. + property bool calendarOpen: false property string latestAppName: "System" property string latestSummary: "" property int latestUrgency: 1 // State Dimensions - readonly property real compactWidth: 220 - readonly property real hoverWidth: 380 - readonly property real expandedWidth: 380 + // Derived from the content rather than the old fixed 360. + // + // The clock is centred on the notch, so the pill needs half its width free on + // each side of it. Whichever side group is wider sets that half, and the + // clock band sits between them: + // + // |<- max(left, right) ->|<- clock band ->|<- max(left, right) ->| + // + // At 360 this left dead space at both ends once the clock moved out of the + // flow, which is the whole reason the pill looked over-wide. + readonly property real compactWidth: Math.max( + Theme.notchWidthCompactMin, + Math.max(root.leftContentWidth, root.rightContentWidth) * 2 + + root.clockBand + Theme.paddingLarge * 2) + + readonly property real leftContentWidth: + compactLogo.width + Theme.spacingSmall + workspaceStrip.implicitWidth + readonly property real rightContentWidth: indicatorCluster.implicitWidth + // Hover adds the date line and the indicator percentages on top of the idle + // row, so it needs more room, but not twice as much. The hover content + // measures ~411px, so 480 keeps a margin without stretching the pill into + // the letterbox the 620px width produced. + readonly property real hoverWidth: 480 readonly property real mediaWidth: 280 readonly property real notificationWidth: 320 readonly property real calendarWidth: 360 - readonly property real compactHeight: Theme.barHeight - 6 // 30px - readonly property real hoverHeight: 60 - readonly property real expandedHeight: 60 + // The notch's own token, not Settings.barHeight: see Theme.notchWidthCompactMin + // for why the pill's proportions must not follow a settings slider. + readonly property real compactHeight: Theme.notchHeightCompact + readonly property real hoverHeight: Theme.notchHeightExpanded + readonly property real expandedHeight: Theme.notchHeightExpanded readonly property real calendarHeight: 250 // Public Signals @@ -140,14 +184,21 @@ Item { // State Priority & Geometry Computations // ========================================================================= readonly property string _resolvedState: { - if ((root._isCalendarOpen || root.calendarOpen) && !root.isCommandCenterOpen) return "calendar"; - if ((root._notificationActive || root.isExpanded) && !NotificationService.doNotDisturb) return "notification"; + if (root.calendarOpen && !root.isCommandCenterOpen) return "calendar"; + if (root.notificationActive && !NotificationService.doNotDisturb) return "notification"; if (root._isHovered && !root.isCommandCenterOpen) return "hover"; if (root.hasMedia) return "media"; return "compact"; } readonly property real targetWidth: { + // While the command centre is open, this bar is the panel's header, + // and the design draws that header as wide as the panel. At the 220px + // compact width there is nowhere to put the date, the indicators and + // the collapse control. + if (root.isCommandCenterOpen) + return Theme.commandCenterWidth; + switch (root.notchState) { case "calendar": return root.calendarWidth; case "hover": return root.hoverWidth; @@ -169,19 +220,41 @@ Item { } } - readonly property real targetRadius: { - if (root.notchState === "calendar" || root.notchState === "hover") return Theme.radiusMedium; - return Theme.radiusPill; - } + // A two-line date and clock, plus rows of indicators, need more than the + // 30px bar height while the panel is open. + readonly property real currentTargetHeight: + root.isCommandCenterOpen ? Theme.notchHeightExpanded : root.targetHeight + + // Fully rounded ends at every size, per the target design. The previous + // version fell back to an 8px radius for the hover and calendar states, + // which drew a rectangle where a stadium was specified. + // Corner radius. A stadium at every size, per the design -- except while the + // command centre is open, where the header is only as tall as the notch and a + // height/2 radius would be 36px of it. Squaring that off lets the panel tuck + // up under the header by the same amount and hide those corners, which is + // what makes the two read as one surface. A radius that large would instead + // cover the bottom half of the header's own content. + // Rounded rectangle rather than a stadium. + // + // radius: height / 2 gives fully rounded ends, which read as a pill. The + // design calls for a square with rounded corners, so the resting radius is a + // fixed value instead of tracking the height -- at 42px tall a 21px radius is + // half the height, which is the pill shape, not this one. + // + // The command centre's 18 is kept: that is the join radius, where the panel + // tucks under the notch and the two rounded corners have to meet. + readonly property real targetRadius: root.isCommandCenterOpen + ? 18 + : Theme.radiusLarge // ========================================================================= // Geometry & Spring Physics Animation Declarations // ========================================================================= clip: false width: targetWidth - height: targetHeight + height: currentTargetHeight implicitWidth: targetWidth - implicitHeight: targetHeight + implicitHeight: currentTargetHeight Behavior on width { enabled: !Settings.reducedMotion @@ -203,14 +276,14 @@ Item { } } - // Opacity Handoff with CommandCenter - opacity: root.isCommandCenterOpen ? 0.0 : 1.0 - Behavior on opacity { - NumberAnimation { - duration: Settings.reducedMotion ? 0 : Theme.durationSlow - easing.type: Easing.InOutQuad - } - } + // The notch stays visible while the CCC is open. + // + // It used to fade to zero here, which was correct when the CCC was a + // separate PanelWindow that *replaced* the bar. Now the CCC is a child of + // this window unfolding downward, and the design keeps the notch header + // pinned at the top of it -- that continuity is the whole point. Fading it + // out left the CCC floating with nothing attached above it. + opacity: 1.0 // ========================================================================= // Timers & Methods @@ -220,8 +293,7 @@ Item { interval: 4000 repeat: false onTriggered: { - root._notificationActive = false; - root.isExpanded = false; + root.notificationActive = false; } } @@ -240,16 +312,6 @@ Item { } } - onIsExpandedChanged: { - if (root.isExpanded && !root._notificationActive) { - root._notificationActive = true; - collapseTimer.restart(); - } else if (!root.isExpanded && root._notificationActive) { - root._notificationActive = false; - collapseTimer.stop(); - } - } - function isInsidePill(mx: real, my: real, w: real, h: real, r: real): bool { if (mx < 0 || mx > w || my < 0 || my > h) return false; if (mx >= r && mx <= w - r) return true; @@ -265,8 +327,7 @@ Item { root.latestAppName = appName || "System"; root.latestSummary = summary || ""; root.latestUrgency = (urgency !== undefined) ? urgency : 1; - root._notificationActive = true; - root.isExpanded = true; + root.notificationActive = true; collapseTimer.restart(); } @@ -275,14 +336,12 @@ Item { OverlayController.close(); } root.calendarOpen = !root.calendarOpen; - root._isCalendarOpen = root.calendarOpen; - root.calendarToggled(root._isCalendarOpen); + root.calendarToggled(root.calendarOpen); } function closeCalendar(): void { - if (root._isCalendarOpen || root.calendarOpen) { + if (root.calendarOpen) { root.calendarOpen = false; - root._isCalendarOpen = false; root.calendarToggled(false); root.closeCalendarRequested(); } @@ -301,10 +360,9 @@ Item { } function onDoNotDisturbChanged(): void { - if (NotificationService.doNotDisturb && root._notificationActive) { + if (NotificationService.doNotDisturb && root.notificationActive) { collapseTimer.stop(); - root._notificationActive = false; - root.isExpanded = false; + root.notificationActive = false; } } } @@ -347,21 +405,59 @@ Item { // ========================================================================= SystemClock { id: systemClock + // Plural form, not Qt's documented "SecondPrecision": the documented + // names resolve to undefined on this Qt. See CalendarPopup.qml. precision: SystemClock.Seconds } + // ------------------------------------------------------------------------- + // Border ring and surface. + // + // Rectangle.border takes a flat colour, so the three-stop gradient is a + // separate rounded rectangle sitting behind the surface, and the surface is + // inset by the border width. That yields a uniform ring because the inner + // radius is the outer radius minus the inset. + // ------------------------------------------------------------------------- + readonly property color ringStart: root.latestUrgency === 2 && root.notchState === "notification" + ? Theme.danger + : Theme.accentBlue + readonly property color ringMid: Theme.accentViolet + readonly property color ringEnd: root.latestUrgency === 2 && root.notchState === "notification" + ? Theme.danger + : Theme.accentMagenta + Rectangle { - id: visualBg + id: borderRing + anchors.fill: parent - color: Theme.background radius: root.targetRadius - border.width: Theme.borderWidth - border.color: { - if (root.notchState === "notification") return root.latestUrgency === 2 ? Theme.warningRed : Theme.accent; - if (root.notchState === "hover" || root.notchState === "calendar") return Theme.accent; - if (root._isHovered) return Theme.accent; - return Theme.borderMuted; + antialiasing: true + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.00; color: root.ringStart } + GradientStop { position: 0.50; color: root.ringMid } + GradientStop { position: 1.00; color: root.ringEnd } + } + + Behavior on opacity { + NumberAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationNormal + easing.type: Easing.InOutQuad + } } + } + + Rectangle { + id: visualBg + + anchors.fill: parent + anchors.margins: Theme.borderWidthAccent + radius: Math.max(0, root.targetRadius - Theme.borderWidthAccent) + antialiasing: true + + // Slightly translucent so the wallpaper reads through, per the design. + color: Theme.notchSurface clip: true Behavior on radius { @@ -375,146 +471,433 @@ Item { // ========================================================================= // View 1: Compact State (Workspaces, Monospace Clock, Net Dot, Status) // ========================================================================= - RowLayout { - id: compactView - anchors.fill: parent - anchors.leftMargin: Theme.paddingLarge - anchors.rightMargin: Theme.paddingLarge - spacing: Theme.spacingSmall - opacity: (root.notchState === "compact") ? 1.0 : 0.0 +RowLayout { + id: compactView + anchors.fill: parent + anchors.leftMargin: Theme.paddingLarge + anchors.rightMargin: Theme.paddingLarge + spacing: Theme.spacingSmall + // Serves both the idle and the hover state. + // + // There was a second, separate hover view with its own copies of the + // system bar and the system tray -- about 300 lines duplicating content + // that had already drifted out of sync with the compact view. Hover is + // the same row with more detail, which is what showDetail expresses, so + // it is the same view. + opacity: (root.notchState === "compact" || root.notchState === "hover") ? 1.0 : 0.0 visible: opacity > 0.0 Behavior on opacity { NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } - // Workspace Indicator Dots (5 dots) + // App Logo / CommandDeck entry point. + // The target design places this first in the idle state; it was only + // present in the hover row, so the resting notch had no identity and no + // CommandDeck target. + Rectangle { + id: compactLogo + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + radius: Theme.radiusSmall + color: compactLogoMouse.containsMouse ? Theme.surfaceHover : "transparent" + Layout.alignment: Qt.AlignVCenter + + Item { + id: logoMark + anchors.centerIn: parent + width: 18 + height: 18 + + // Rose fill, with the glyph's outline kept as the edge. + // + // hexagon.fill is not usable as a codepoint: Material Symbols + // ships it as the FILL axis of the variable font rather than as + // a separate glyph, and QML's Text cannot set variation axes. + // So the fill is drawn here and the glyph is kept for its edges + // alone. + // + // Orientation is taken from the glyph rather than assumed. Its + // ink measures 22x16 device px inside this 18px slot and is + // widest at the vertical middle, so it is a flat-top hexagon with + // vertices left and right -- not the pointy-top one the old + // hand-drawn mark used. Hence vertices from 0 degrees. + readonly property real fillInset: 2.6 + readonly property var fillVerts: _hexVerts( + Math.min(width, height) / 2 - fillInset) + + function _hexVerts(r: real): var { + const cx = width / 2; + const cy = height / 2; + const out = []; + for (let i = 0; i < 6; ++i) { + const a = (i * 60) * Math.PI / 180; + out.push(cx + r * Math.cos(a)); + out.push(cy + r * Math.sin(a)); + } + return out; + } + + Shape { + anchors.fill: parent + preferredRendererType: Shape.GeometryRenderer + antialiasing: true + + // love, the palette's warm pink, is the identity fill. + // It shares a value with destructive, deliberately and with + // a separate name: destructive means something has failed, + // and borrowing it for the shell's mark would say the mark + // itself was an error. + ShapePath { + fillColor: Theme.love + startX: logoMark.fillVerts[0] + startY: logoMark.fillVerts[1] + PathLine { x: logoMark.fillVerts[2]; y: logoMark.fillVerts[3] } + PathLine { x: logoMark.fillVerts[4]; y: logoMark.fillVerts[5] } + PathLine { x: logoMark.fillVerts[6]; y: logoMark.fillVerts[7] } + PathLine { x: logoMark.fillVerts[8]; y: logoMark.fillVerts[9] } + PathLine { x: logoMark.fillVerts[10]; y: logoMark.fillVerts[11] } + } + } + + GlyphIcon { + anchors.fill: parent + // Deliberately larger than the clock. Everything else on + // the line is matched to the clock's ink height, but the + // hexagon is the identity mark and reads as an afterthought + // at that size -- it needs to hold its own against the bold + // clock digits rather than match them. 0.94 puts it back + // around its original presence. + // + // The 18px slot stays regardless: it is also the hover + // target, which should not change with the mark's size. + opticalScale: 0.94 + glyph: "hexagon" + color: Theme.textPrimary + } + } + + MouseArea { + id: compactLogoMouse + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.openCommandDeckRequested() + } + } + + // Workspaces as numbered pills. + // + // The design numbers them and marks the active one with a filled magenta + // circle. The previous five-dot row could not say which workspace was + // which, and at 6px a dot has no room to carry a number at all. + RowLayout { + id: workspaceStrip + Layout.alignment: Qt.AlignVCenter + + Repeater { + model: root.workspaceList + + Item { + id: wsCell + required property var modelData + + readonly property int wsId: (typeof modelData === "object" && modelData !== null) ? Number(modelData.id) : Number(modelData) + readonly property bool isActive: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.active || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) + readonly property bool isFocused: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.focused || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) + readonly property bool isUrgent: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.urgent) : false + + Layout.preferredWidth: isFocused ? 18 : wsLabel.implicitWidth + 6 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: wsCell.isFocused ? Theme.workspaceActive + : (wsCell.isUrgent ? Qt.rgba(1, 1, 1, 0.06) : "transparent") + border.width: Theme.borderWidth + border.color: wsCell.isUrgent ? Theme.destructive : "transparent" + + Behavior on color { + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } + } + } + + Text { + id: wsLabel + anchors.centerIn: parent + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: wsCell.wsId + color: wsCell.isFocused ? Theme.navyDeep + : (wsCell.isActive ? Theme.textPrimary : Theme.textSecondary) + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + } + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: CompositorService.switchToWorkspace(wsCell.wsId) + } + } + } + } + + // Indicator cluster: glyph over a value, values in the status colour. + + Item { Layout.fillWidth: true } + // Everything after this is pinned to the trailing edge. + // + // Replaces one connected/not-connected dot and an "// IDLE" string. The + // dot said whether the link was up; it could not say how good the link + // was, and it left the rest of the bar's right-hand side empty. RowLayout { - spacing: Theme.spacingSmall + id: indicatorCluster + Layout.maximumWidth: root.sideColumnMax + clip: true Layout.alignment: Qt.AlignVCenter + spacing: Theme.spacingMedium - Repeater { - model: root.workspaceList - - Item { - id: wsCell - required property var modelData - - readonly property int wsId: (typeof modelData === "object" && modelData !== null) ? Number(modelData.id) : Number(modelData) - readonly property bool isActive: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.active || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isFocused: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.focused || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isUrgent: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.urgent) : false + // Collapse control. Only while the panel is open: an idle notch has + // no room for it and nothing to collapse. + GlyphIcon { + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + visible: root.isCommandCenterOpen + opticalScale: 0.71 + glyph: "chevron" + // Glyph points down; collapsing means going back up. + rotation: 180 + color: Theme.textSecondary + } - Layout.preferredWidth: isFocused ? 14 : 6 - Layout.preferredHeight: 6 - Layout.alignment: Qt.AlignVCenter + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 1 + visible: NetworkService.isConnected + + GlyphIcon { + Layout.alignment: Qt.AlignHCenter + // Layout.preferred*, not width/height. These sit in a + // ColumnLayout, which owns the child's height and discards a + // direct assignment -- an earlier width/height: 14 here had no + // effect at all, and GlyphIcon sizes its font from height. + Layout.preferredWidth: 15 + Layout.preferredHeight: 15 + // 0.78 brings the wifi's ink to the volume's, measured at + // 1.27x, and stops it overrunning the slot. + opticalScale: NetworkService.isWifi ? 0.90 : 0.85 + glyph: NetworkService.isWifi ? "wifi" : "ethernet" + color: Theme.textSecondary + } - Rectangle { - anchors.fill: parent - radius: 3 - color: wsCell.isUrgent ? Theme.warningRed : (wsCell.isFocused ? Theme.acidGreen : (wsCell.isActive ? Theme.textPrimaryDim : Theme.gray600)) + Text { + Layout.alignment: Qt.AlignHCenter + visible: root.showDetail + text: NetworkService.signalStrength > 0 + ? Math.round(NetworkService.signalStrength * 100) + "%" + : (NetworkService.isEthernet ? "LAN" : "") + color: Theme.statusGreen + font.family: Theme.fontFamilyMonoNumeric + font.pixelSize: Theme.fontSizeMicro + font.weight: Theme.fontWeightDemiBold + } + } - Behavior on width { - NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } - } - } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 1 + visible: AudioService.available + + GlyphIcon { + Layout.alignment: Qt.AlignHCenter + // Layout.preferred*, not width/height. These sit in a + // ColumnLayout, which owns the child's height and discards a + // direct assignment -- an earlier width/height: 14 here had no + // effect at all, and GlyphIcon sizes its font from height. + Layout.preferredWidth: 15 + Layout.preferredHeight: 15 + opticalScale: 0.90 + glyph: "speaker" + color: Theme.textSecondary + } - MouseArea { - anchors.fill: parent - anchors.margins: -4 - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: CompositorService.switchToWorkspace(wsCell.wsId) - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } + Text { + Layout.alignment: Qt.AlignHCenter + visible: root.showDetail + text: AudioService.muted ? "M" : Math.round(AudioService.volume * 100) + "%" + color: Theme.statusGreen + font.family: Theme.fontFamilyMonoNumeric + font.pixelSize: Theme.fontSizeMicro + font.weight: Theme.fontWeightDemiBold } } - } - Item { Layout.fillWidth: true } + ColumnLayout { + Layout.alignment: Qt.AlignVCenter + spacing: 1 + visible: PowerService.isBatteryPresent + + GlyphIcon { + Layout.alignment: Qt.AlignHCenter + // Layout.preferred*, not width/height. These sit in a + // ColumnLayout, which owns the child's height and discards a + // direct assignment -- an earlier width/height: 14 here had no + // effect at all, and GlyphIcon sizes its font from height. + Layout.preferredWidth: 15 + Layout.preferredHeight: 15 + opticalScale: 0.85 + glyph: "battery" + color: PowerService.isCharging ? Theme.statusGreen : Theme.textSecondary + } - // Monospace Clock (Click opens calendar) - Item { - Layout.preferredWidth: compactClockText.implicitWidth - Layout.preferredHeight: compactClockText.implicitHeight - Layout.alignment: Qt.AlignVCenter + Text { + Layout.alignment: Qt.AlignHCenter + visible: root.showDetail + text: Math.round(PowerService.percentage) + "%" + color: Theme.statusGreen + font.family: Theme.fontFamilyMonoNumeric + font.pixelSize: Theme.fontSizeMicro + font.weight: Theme.fontWeightDemiBold + } + } + // Status word, which ends the bar. The design shows one in both the + // idle and expanded states: IDLE with nothing pending, otherwise + // what is pending. Text { - id: compactClockText - anchors.centerIn: parent - text: Qt.formatDateTime(systemClock.date, "HH:mm") - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall + Layout.alignment: Qt.AlignVCenter + visible: !NotificationService.doNotDisturb + text: NotificationService.unreadCount > 0 + ? NotificationService.unreadCount + " NEW" + : (root.hasMedia ? (root.isPlaying ? "PLAYING" : "PAUSED") : "IDLE") + color: Theme.textSecondary + font.family: Theme.fontFamilySans + // Was fontSizeMicro, whose 9px face draws about 8px of ink -- + // half the clock's height beside it. The status word is the same + // tier as the glyphs next to it, so it takes the body size. + font.pixelSize: Theme.fontSizeBody font.weight: Theme.fontWeightDemiBold } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: root.toggleCalendar() - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Network Connected Indicator Dot (visible only when connected) - Rectangle { - Layout.alignment: Qt.AlignVCenter - width: 5 - height: 5 - radius: 2.5 - color: Theme.acidGreen - visible: NetworkService.available && NetworkService.isConnected - } - - Item { Layout.fillWidth: true } - - // Dynamic Island Status Text - RowLayout { - Layout.alignment: Qt.AlignVCenter - spacing: 4 - + // DND is kept: it is state the user set and needs to see at a glance. Text { + Layout.alignment: Qt.AlignVCenter visible: NotificationService.doNotDisturb - text: "[DND]" + text: "DND" color: Theme.warningRed - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro font.weight: Theme.fontWeightBold } - - Text { - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount > 0 - text: NotificationService.unreadCount + " NOTIF" - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightNormal - } - - Text { - visible: !NotificationService.doNotDisturb && NotificationService.unreadCount === 0 - text: "// IDLE" - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - } } } + // ========================================================================= - // View 2: Media State (Play/Pause, Title // Artist, Equalizer, Clock) + // Centred clock + // + // Sits on the notch's centre line rather than in the compact row's flow. In + // the row it came after the workspace pills, so the clock drifted right as + // the pill count grew and the indicator cluster was pushed to the trailing + // edge by a single flexible spacer -- the two ends drifted apart and the + // middle never lined up with anything. + // + // centring it in the row would not have fixed that either. With one flexible + // spacer on each side of the clock, the clock's centre lands at + // (notchWidth + leftContent - rightContent) / 2, so it is only truly centred + // when the left and right groups happen to be the same width. The workspace + // strip and the indicator cluster are not. + // + // So the clock is a sibling, anchored to the centre, and compactView is inset + // on both sides by half the clock's width. That reserves a band exactly the + // width of the clock, which means the row cannot paint into it at any content + // width -- the two can never overlap, whatever the pill count does. + // + // The two dividers move with it. In the row they bracketed the clock to + // separate it from the pills; left behind in the flow they would be a rule at + // the end of the workspace group with nothing beside it. + Row { + id: centeredClock + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingMedium + // Mirrors compactView, which is faded rather than hidden. + opacity: compactView.opacity + visible: compactView.visible && opacity > 0 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Theme.borderWidth + height: 22 + visible: root.isCommandCenterOpen + color: Theme.border + } + + // Date over clock. The design stacks a small date on a large time, which + // is also the only arrangement that fits both without shrinking the clock + // down to caption size. + Item { + width: Math.max(clockDate.implicitWidth, clockTime.implicitWidth) + height: clockTime.implicitHeight + + (root.showDetail ? clockDate.implicitHeight + 2 : 0) + + Text { + id: clockDate + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + visible: root.showDetail + text: Qt.formatDateTime(systemClock.date, "ddd dd MMM").toUpperCase() + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeMicro + font.weight: Theme.fontWeightDemiBold + } + + Text { + id: clockTime + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + text: Qt.formatDateTime(systemClock.date, + root.showDetail ? "HH:mm:ss" : "HH:mm") + color: Theme.textPrimary + font.family: Theme.fontFamilyMonoNumeric + font.pixelSize: Theme.fontSizeBody + font.weight: Theme.fontWeightDemiBold + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleCalendar() + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Theme.borderWidth + height: 22 + visible: root.isCommandCenterOpen + color: Theme.border + } + } + + // Band reserved for the centred clock, plus a little breathing room. compactView + // is inset by half of this on each side, so the row's usable width is the notch + // minus the clock's band. + readonly property real clockBand: centeredClock.width + Theme.spacingLarge + + // Half the width each side group may occupy, so neither can reach the centred + // clock. Derived from the notch width rather than from compactView, because + // reading the row's own width inside its layout would be a binding loop. + readonly property real sideColumnMax: Math.max(root.leftContentWidth, + root.rightContentWidth) + + // View 2: Media State (Play/Pause, Title // Artist, Equalizer, Clock) // ========================================================================= RowLayout { id: mediaView @@ -538,7 +921,7 @@ Item { id: playPauseText anchors.centerIn: parent text: root.isPlaying ? "▶" : "⏸" - color: root.isPlaying ? Theme.acidGreen : Theme.textMuted + color: root.isPlaying ? Theme.statusGreen : Theme.textMuted font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption font.weight: Theme.fontWeightBold @@ -547,18 +930,11 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } onClicked: { if (root.activePlayer && typeof root.activePlayer.playPause === "function") { root.activePlayer.playPause(); } } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } } } @@ -589,7 +965,7 @@ Item { Layout.preferredHeight: root.isPlaying ? Math.max(3, Math.round((Math.sin((eqBar.index * 1.1) + root.visualizerPhase) * 0.4 + 0.5) * 14)) : 3 - color: root.isPlaying ? Theme.acidGreen : Theme.gray600 + color: root.isPlaying ? Theme.statusGreen : Theme.gray600 radius: 1 } } @@ -612,14 +988,7 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } onClicked: root.toggleCalendar() - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } } } } @@ -680,372 +1049,6 @@ Item { } } - // ========================================================================= - // View 4: Hover State (2 Rows: Row 1 System Bar, Row 2 Expanded System Tray) - // ========================================================================= - ColumnLayout { - id: hoverView - anchors.fill: parent - anchors.margins: Theme.paddingSmall - spacing: 4 - opacity: (root.notchState === "hover") ? 1.0 : 0.0 - visible: opacity > 0.0 - - Behavior on opacity { - NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } - } - - // --- Row 1: Logo/CommandDeck, Workspaces, Full Date + Time --- - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 24 - spacing: Theme.spacingMedium - - // Logo & CommandDeck trigger - Rectangle { - Layout.preferredWidth: 20 - Layout.preferredHeight: 20 - radius: Theme.radiusSmall - color: logoHoverMouse.containsMouse ? Theme.surfaceHover : "transparent" - - Image { - anchors.centerIn: parent - width: 16 - height: 16 - source: "os-icon.svg" - sourceSize.width: 16 - sourceSize.height: 16 - } - - MouseArea { - id: logoHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: { - OverlayController.openCommandDeck(); - root.openCommandDeckRequested(); - } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Workspaces Cells - RowLayout { - spacing: 3 - Layout.alignment: Qt.AlignVCenter - - Repeater { - model: root.workspaceList - - Rectangle { - id: wsHoverCell - required property var modelData - readonly property int wsId: (typeof modelData === "object" && modelData !== null) ? Number(modelData.id) : Number(modelData) - readonly property bool isActive: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.active || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isFocused: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.focused || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isUrgent: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.urgent) : false - - Layout.preferredWidth: 18 - Layout.preferredHeight: 18 - radius: Theme.radiusSmall - color: isFocused ? Theme.surfaceSelected : (wsHoverMouse.containsMouse ? Theme.surfaceHover : "transparent") - border.color: isUrgent ? Theme.accentRed : (isFocused ? Theme.accent : Theme.borderMuted) - border.width: Theme.borderWidth - - Text { - anchors.centerIn: parent - text: String(wsHoverCell.wsId) - color: wsHoverCell.isUrgent ? Theme.accentRed : (wsHoverCell.isFocused ? Theme.accent : (wsHoverCell.isActive ? Theme.textPrimary : Theme.textMuted)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: wsHoverCell.isFocused ? Theme.fontWeightBold : Theme.fontWeightNormal - } - - MouseArea { - id: wsHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: CompositorService.switchToWorkspace(wsHoverCell.wsId) - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - } - } - - Item { Layout.fillWidth: true } - - // Full Date + Time (Click opens calendar) - Item { - Layout.preferredWidth: dateHoverText.implicitWidth - Layout.preferredHeight: 20 - Layout.alignment: Qt.AlignVCenter - - Text { - id: dateHoverText - anchors.centerIn: parent - text: Qt.formatDateTime(systemClock.date, "ddd dd MMM HH:mm:ss").toUpperCase() - color: dateHoverMouse.containsMouse ? Theme.accent : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightDemiBold - } - - MouseArea { - id: dateHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: root.toggleCalendar() - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - } - - // --- Row 2: Expanded System Tray Indicators --- - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 24 - spacing: Theme.spacingMedium - - // Network Info - Item { - Layout.preferredWidth: netRow.implicitWidth - Layout.preferredHeight: 20 - Layout.alignment: Qt.AlignVCenter - - RowLayout { - id: netRow - anchors.fill: parent - spacing: 4 - - CtosIcon { - size: 12 - name: !NetworkService.isConnected ? "wifi-slash" : (NetworkService.isEthernet ? "network" : "wifi") - color: netHoverMouse.containsMouse ? Theme.accent : (NetworkService.isConnected ? Theme.acidGreen : Theme.destructive) - } - - Text { - text: NetworkService.isConnected ? (NetworkService.networkName !== "" ? NetworkService.networkName : "NET") : "OFFLINE" - color: netHoverMouse.containsMouse ? Theme.accent : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - elide: Text.ElideRight - Layout.maximumWidth: 80 - } - } - - MouseArea { - id: netHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - NetworkService.toggleWifi(); - } else { - root.toggleNetworkRequested(); - } - } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Volume Info - Item { - Layout.preferredWidth: volRow.implicitWidth - Layout.preferredHeight: 20 - Layout.alignment: Qt.AlignVCenter - - RowLayout { - id: volRow - anchors.fill: parent - spacing: 4 - - CtosIcon { - size: 12 - name: AudioService.muted ? "volume-slash" : "volume" - color: volHoverMouse.containsMouse ? Theme.accent : (AudioService.muted ? Theme.destructive : Theme.textSecondary) - } - - Text { - text: AudioService.muted ? "[MUTED]" : Math.round(AudioService.volume * 100) + "%" - color: volHoverMouse.containsMouse ? Theme.accent : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - } - } - - MouseArea { - id: volHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: AudioService.toggleMute() - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Battery Info (Desktop omission: hidden when battery absent) - Item { - visible: PowerService.available && PowerService.isBatteryPresent - Layout.preferredWidth: visible ? batRow.implicitWidth : 0 - Layout.preferredHeight: 20 - Layout.alignment: Qt.AlignVCenter - - RowLayout { - id: batRow - anchors.fill: parent - spacing: 4 - - CtosIcon { - size: 12 - name: PowerService.isCharging ? "battery-charging" : (PowerService.percentage <= 20 ? "battery-low" : "battery") - color: PowerService.isCharging ? Theme.acidGreen : (PowerService.percentage <= 20 ? Theme.warningRed : Theme.textSecondary) - } - - Text { - text: Math.round(PowerService.percentage) + "%" - color: PowerService.percentage <= 20 && !PowerService.isCharging ? Theme.warningRed : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: { - OverlayController.toggleCommandCenter(); - root.toggleCommandCenterRequested(); - } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - // Bluetooth Info (Hidden when bluetooth hardware unavailable) - Item { - visible: BluetoothService.available - Layout.preferredWidth: visible ? btRow.implicitWidth : 0 - Layout.preferredHeight: 20 - Layout.alignment: Qt.AlignVCenter - - RowLayout { - id: btRow - anchors.fill: parent - spacing: 4 - - CtosIcon { - size: 12 - name: BluetoothService.powered ? "bluetooth" : "bluetooth-slash" - color: BluetoothService.isConnected ? Theme.acidGreen : (BluetoothService.powered ? Theme.textPrimary : Theme.textMuted) - } - - Text { - text: !BluetoothService.powered ? "OFF" : (BluetoothService.isConnected ? (BluetoothService.deviceName || "CONNECTED") : "ON") - color: btHoverMouse.containsMouse ? Theme.accent : Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - elide: Text.ElideRight - Layout.maximumWidth: 80 - } - } - - MouseArea { - id: btHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) { - BluetoothService.togglePower(); - } else { - root.toggleBluetoothRequested(); - } - } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - - Item { Layout.fillWidth: true } - - // Rail Toggle Glyph "=" - Rectangle { - Layout.preferredWidth: 18 - Layout.preferredHeight: 18 - radius: Theme.radiusSmall - color: railHoverMouse.containsMouse ? Theme.surfaceHover : "transparent" - border.color: Theme.borderMuted - border.width: Theme.borderWidth - - Text { - anchors.centerIn: parent - text: "=" - color: Theme.accent - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - } - - MouseArea { - id: railHoverMouse - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: { hoverDebounceTimer.stop(); root._isHovered = true; } - onExited: { hoverDebounceTimer.restart(); } - onClicked: { - OverlayController.toggleCommandCenter(); - root.toggleCommandCenterRequested(); - } - onWheel: (wheel) => { - const delta = wheel.angleDelta.y > 0 ? 0.05 : -0.05; - AudioService.stepVolume(delta); - } - } - } - } - } - // ========================================================================= // View 5: Calendar State (NotchCalendarGrid Morph Container) // ========================================================================= @@ -1068,7 +1071,7 @@ Item { Loader { id: calendarLoader anchors.fill: parent - active: root.notchState === "calendar" || root._isCalendarOpen + active: root.notchState === "calendar" || root.calendarOpen source: "NotchCalendarGrid.qml" onLoaded: { @@ -1089,6 +1092,11 @@ Item { id: mouseArea anchors.fill: parent cursorShape: Qt.PointingHandCursor + // Sole hover authority for the notch. The per-item targets below carry + // no hover handling at all: they only route clicks. That is deliberate. + // Their onEntered handlers were ungated -- they set _isHovered without + // the isInsidePill test applied here -- which is the hover-loop class + // this file needed two separate patches for. hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton z: -1 @@ -1131,11 +1139,16 @@ Item { } else { if (root.notchState === "notification") { collapseTimer.stop(); - root._notificationActive = false; - root.isExpanded = false; - } else if (root.notchState === "compact") { + root.notificationActive = false; + } else if (root.notchState === "compact" || root.notchState === "hover") { + // "hover" has to be in this list, and it was missing. Hovering + // is what expands the notch, so by release time the state is + // "hover" and a "compact"-only test could never be true -- the + // click was swallowed instead of opening the command centre. + // Reachability is the point: the pointer is over the pill for + // every click, so "compact" only ever held for a notch the + // user was not touching. root.closeCalendar(); - OverlayController.toggleCommandCenter(); root.toggleCommandCenterRequested(); } } diff --git a/shell/desktop/surfaces/components/NetworkPopup.qml b/shell/desktop/surfaces/components/NetworkPopup.qml deleted file mode 100644 index edd61d19..00000000 --- a/shell/desktop/surfaces/components/NetworkPopup.qml +++ /dev/null @@ -1,910 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell -import "../../core" -import "../../services" -import "../widgets" - -Rectangle { - id: root - - // ========================================================================= - // Public Interface & Signals - // ========================================================================= - - signal closeRequested - - // ========================================================================= - // Geometry & Theme Styling - // ========================================================================= - - implicitWidth: 320 - width: 320 - implicitHeight: mainColumn.implicitHeight + Theme.paddingLarge * 2 - color: Theme.gray900 - radius: Theme.radiusSmall - border.color: Theme.borderMuted - border.width: Theme.borderWidth - - property string selectedSsid: "" - property string confirmingForgetSsid: "" - - // Consume all clicks inside popup so backdrop does not dismiss - MouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - onClicked: mouse => mouse.accepted = true - } - - // Cyberpunk Corner Brackets - CornerBrackets { - id: cornerBrackets - bracketColor: Theme.acidGreen - z: 10 - } - - // ========================================================================= - // Content Layout - // ========================================================================= - - ColumnLayout { - id: mainColumn - - anchors.fill: parent - anchors.margins: Theme.paddingLarge - spacing: Theme.spacingMedium - - // Header: [PWR] Title [SCAN] [x] - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - // Power Toggle Button - Rectangle { - id: powerBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: powerLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: !NetworkService.available ? Theme.textDisabled : (NetworkService.wifiEnabled ? Theme.accent : (powerMouse.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: !NetworkService.available ? "transparent" : (NetworkService.wifiEnabled ? Theme.surfaceSelected : (powerMouse.containsMouse ? Theme.surfaceHover : "transparent")) - radius: Theme.radiusSmall - - Text { - id: powerLabel - - anchors.centerIn: parent - color: !NetworkService.available ? Theme.textDisabled : (NetworkService.wifiEnabled ? Theme.accent : (powerMouse.containsMouse ? Theme.accent : Theme.textSecondary)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: NetworkService.wifiEnabled ? "[PWR ON]" : "[PWR OFF]" - } - - MouseArea { - id: powerMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: NetworkService.available - - onClicked: NetworkService.toggleWifi() - } - } - - // Title - Text { - id: titleText - - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - horizontalAlignment: Text.AlignHCenter - text: "NETWORK // WI-FI" - elide: Text.ElideRight - } - - // Scan Button - Rectangle { - id: scanBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: scanLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: !NetworkService.wifiEnabled ? Theme.borderMuted : (NetworkService.isScanning ? Theme.accent : (scanMouse.containsMouse ? Theme.accent : Theme.borderMuted)) - border.width: Theme.borderWidth - color: NetworkService.isScanning ? Theme.surfaceSelected : (scanMouse.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - opacity: NetworkService.wifiEnabled ? 1.0 : 0.4 - - Text { - id: scanLabel - - anchors.centerIn: parent - color: !NetworkService.wifiEnabled ? Theme.textMuted : (NetworkService.isScanning ? Theme.accent : (scanMouse.containsMouse ? Theme.accent : Theme.textSecondary)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: NetworkService.isScanning ? "[SCANNING]" : "[SCAN]" - } - - MouseArea { - id: scanMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: NetworkService.wifiEnabled - - onClicked: NetworkService.scanNetworks() - } - } - - // Close Button - Rectangle { - id: closeBtn - - Layout.preferredHeight: 24 - Layout.preferredWidth: 24 - border.color: closeMouse.containsMouse ? Theme.destructive : Theme.borderMuted - border.width: Theme.borderWidth - color: closeMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: closeMouse.containsMouse ? Theme.destructive : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: "x" - } - - MouseArea { - id: closeMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: root.closeRequested() - } - } - } - - // Header Divider - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Theme.borderWidth - color: Theme.divider - } - - // Body Content: Empty State OR Scrollable Network List - Item { - id: bodyContainer - - Layout.fillWidth: true - Layout.preferredHeight: { - const count = NetworkService.availableNetworks ? (NetworkService.availableNetworks.count || NetworkService.availableNetworks.length || 0) : 0; - if (!NetworkService.wifiEnabled || count === 0) { - return 140; - } - return Math.min(320, Math.max(140, networkListColumn.implicitHeight)); - } - - // Empty State: Radio Off - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: !NetworkService.wifiEnabled - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "wifi-slash" - color: Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: "// WI-FI RADIO OFF\nUSE [PWR ON] TO ACTIVATE" - } - } - - // Empty State: Radio On, No Networks Found - ColumnLayout { - anchors.centerIn: parent - spacing: Theme.spacingSmall - visible: { - const count = NetworkService.availableNetworks ? (NetworkService.availableNetworks.count || NetworkService.availableNetworks.length || 0) : 0; - return NetworkService.wifiEnabled && count === 0; - } - - CtosIcon { - Layout.alignment: Qt.AlignHCenter - size: 24 - name: "wifi" - color: (NetworkService.isScanning || NetworkService.isConnecting) ? Theme.acidGreen : Theme.textMuted - } - - Text { - Layout.alignment: Qt.AlignHCenter - color: (NetworkService.isScanning || NetworkService.isConnecting) ? Theme.acidGreen : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - horizontalAlignment: Text.AlignHCenter - text: (NetworkService.isScanning || NetworkService.isConnecting) ? "// SCANNING FOR NEARBY NETWORKS..." : "// NO NETWORKS FOUND\nCLICK [SCAN] TO DISCOVER" - } - } - - // Scrollable Network List - Flickable { - id: networkFlickable - - anchors.fill: parent - clip: true - boundsBehavior: Flickable.StopAtBounds - contentHeight: networkListColumn.implicitHeight - contentWidth: width - visible: { - const count = NetworkService.availableNetworks ? (NetworkService.availableNetworks.count || NetworkService.availableNetworks.length || 0) : 0; - return NetworkService.wifiEnabled && count > 0; - } - - ColumnLayout { - id: networkListColumn - - width: networkFlickable.width - spacing: Theme.spacingMedium - - // ========================================================= - // Section 1: Connected Networks - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = NetworkService.connectedNetworks ? (NetworkService.connectedNetworks.count || NetworkService.connectedNetworks.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.acidGreen - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = NetworkService.connectedNetworks ? (NetworkService.connectedNetworks.count || NetworkService.connectedNetworks.length || 0) : 0; - return "// CONNECTED (" + count + ")"; - } - } - } - - Repeater { - model: NetworkService.connectedNetworks - - delegate: Rectangle { - id: connectedTile - - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 46 - color: tileMouseArea.containsMouse ? Theme.surfaceHover : Theme.surfaceSelected - border.color: Theme.acidGreen - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: 3 - color: Theme.acidGreen - radius: Theme.radiusSmall - } - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: 2 - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "wifi" - color: Theme.acidGreen - } - - Text { - Layout.fillWidth: true - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightBold - text: connectedTile.modelData.ssid || "Network" - elide: Text.ElideRight - } - - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: disconnectLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: disconnectMouse.containsMouse ? Theme.warningRed : Theme.borderMuted - border.width: Theme.borderWidth - color: disconnectMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: disconnectLabel - - anchors.centerIn: parent - color: disconnectMouse.containsMouse ? Theme.warningRed : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: "[DISCONNECT]" - } - - MouseArea { - id: disconnectMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: NetworkService.disconnectCurrentNetwork() - } - } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: { - const strength = (typeof connectedTile.modelData.signalStrength === "number") ? Math.round(connectedTile.modelData.signalStrength * 100) : 0; - return "SIGNAL: " + strength + "% // ACTIVE"; - } - } - } - } - - MouseArea { - id: tileMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } - } - } - - // ========================================================= - // Section 2: Saved / Known Networks - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = NetworkService.savedNetworks ? (NetworkService.savedNetworks.count || NetworkService.savedNetworks.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.textSecondary - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = NetworkService.savedNetworks ? (NetworkService.savedNetworks.count || NetworkService.savedNetworks.length || 0) : 0; - return "// SAVED (" + count + ")"; - } - } - } - - Repeater { - model: NetworkService.savedNetworks - - delegate: Rectangle { - id: savedTile - - required property var modelData - - Layout.fillWidth: true - Layout.preferredHeight: 46 - color: savedMouseArea.containsMouse ? Theme.surfaceHover : Theme.gray800 - border.color: savedMouseArea.containsMouse ? Theme.accent : Theme.gray700 - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: 2 - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "wifi" - color: Theme.textSecondary - } - - CtosIcon { - size: 12 - name: "lock" - color: Theme.textMuted - visible: Boolean(savedTile.modelData.security !== undefined && savedTile.modelData.security !== 0) - } - - Text { - Layout.fillWidth: true - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: savedTile.modelData.ssid || "Network" - elide: Text.ElideRight - } - - // Connect Button - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: savedConnLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: savedConnMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: savedConnMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: savedConnLabel - - anchors.centerIn: parent - color: savedConnMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: (NetworkService.isConnecting && NetworkService.connectingSsid === savedTile.modelData.ssid) ? "[CONN...]" : "[CONNECT]" - } - - MouseArea { - id: savedConnMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - enabled: !NetworkService.isConnecting - - onClicked: NetworkService.connectToNetwork(savedTile.modelData.ssid, "") - } - } - - // Forget Button - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: savedForgetLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: savedForgetMouse.containsMouse ? Theme.destructive : Theme.borderMuted - border.width: Theme.borderWidth - color: savedForgetMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: savedForgetLabel - - anchors.centerIn: parent - color: savedForgetMouse.containsMouse ? Theme.destructive : Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: "x" - } - - MouseArea { - id: savedForgetMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: NetworkService.forgetNetwork(savedTile.modelData.ssid) - } - } - } - - RowLayout { - Layout.fillWidth: true - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: { - const strength = (typeof savedTile.modelData.signalStrength === "number") ? Math.round(savedTile.modelData.signalStrength * 100) : 0; - return "SIGNAL: " + strength + "% // SAVED PROFILE"; - } - } - } - } - - MouseArea { - id: savedMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - } - } - } - } - - // ========================================================= - // Section 3: Available / Discovered Networks - // ========================================================= - ColumnLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - visible: { - const count = NetworkService.discoveredNetworks ? (NetworkService.discoveredNetworks.count || NetworkService.discoveredNetworks.length || 0) : 0; - return count > 0; - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Rectangle { - Layout.preferredHeight: 6 - Layout.preferredWidth: 6 - color: Theme.textMuted - radius: 3 - } - - Text { - Layout.fillWidth: true - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: { - const count = NetworkService.discoveredNetworks ? (NetworkService.discoveredNetworks.count || NetworkService.discoveredNetworks.length || 0) : 0; - return "// DISCOVERED (" + count + ")"; - } - } - } - - Repeater { - model: NetworkService.discoveredNetworks - - delegate: Rectangle { - id: availTile - - required property var modelData - - readonly property bool isSelected: root.selectedSsid === availTile.modelData.ssid - readonly property bool isSecured: Boolean(availTile.modelData.requiresPassword || (availTile.modelData.security !== undefined && availTile.modelData.security !== 0)) - - Layout.fillWidth: true - Layout.preferredHeight: isSelected ? 100 : 46 - color: isSelected ? Theme.gray900 : (availMouseArea.containsMouse ? Theme.surfaceHover : "transparent") - border.color: isSelected ? Theme.acidGreen : (availMouseArea.containsMouse ? Theme.accent : Theme.borderMuted) - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - ColumnLayout { - anchors.fill: parent - anchors.leftMargin: Theme.paddingMedium - anchors.rightMargin: Theme.paddingSmall - anchors.topMargin: Theme.paddingXs - anchors.bottomMargin: Theme.paddingXs - spacing: Theme.spacingSmall - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - CtosIcon { - size: 14 - name: "wifi" - color: availTile.isSelected ? Theme.acidGreen : Theme.textMuted - } - - CtosIcon { - size: 12 - name: "lock" - color: Theme.textMuted - visible: availTile.isSecured - } - - Text { - Layout.fillWidth: true - color: availTile.isSelected ? Theme.textPrimary : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - text: availTile.modelData.ssid || "Network" - elide: Text.ElideRight - } - - Rectangle { - Layout.preferredHeight: 18 - Layout.preferredWidth: availConnLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: availConnMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: availConnMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: availConnLabel - - anchors.centerIn: parent - color: availConnMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: (NetworkService.isConnecting && NetworkService.connectingSsid === availTile.modelData.ssid) ? "[CONN...]" : (availTile.isSelected ? "[CANCEL]" : "[CONNECT]") - } - - MouseArea { - id: availConnMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: { - if (availTile.isSelected) { - root.selectedSsid = ""; - } else if (availTile.isSecured) { - root.selectedSsid = availTile.modelData.ssid; - } else { - NetworkService.connectToNetwork(availTile.modelData.ssid, ""); - } - } - } - } - } - - // Secondary Info / Signal strength (when not expanded) - RowLayout { - Layout.fillWidth: true - visible: !availTile.isSelected - - Text { - Layout.fillWidth: true - color: Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - text: { - const strength = (typeof availTile.modelData.signalStrength === "number") ? Math.round(availTile.modelData.signalStrength * 100) : 0; - const secText = availTile.isSecured ? "SECURED" : "OPEN"; - return "SIGNAL: " + strength + "% // " + secText; - } - } - } - - // Cyberpunk Inline Password Prompt (when expanded) - ColumnLayout { - Layout.fillWidth: true - spacing: 4 - visible: availTile.isSelected - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Text { - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightBold - text: "PASSWORD >_" - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 22 - color: Theme.gray800 - border.color: Theme.acidGreen - border.width: Theme.borderWidth - radius: Theme.radiusSmall - - TextInput { - id: pwInput - - anchors.fill: parent - anchors.leftMargin: Theme.paddingSmall - anchors.rightMargin: Theme.paddingSmall - color: Theme.textPrimary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - echoMode: TextInput.Password - focus: availTile.isSelected - verticalAlignment: TextInput.AlignVCenter - selectByMouse: true - - Keys.onReturnPressed: { - NetworkService.connectToNetwork(availTile.modelData.ssid, pwInput.text); - root.selectedSsid = ""; - } - - Keys.onEscapePressed: { - root.selectedSsid = ""; - } - } - } - - Rectangle { - Layout.preferredHeight: 22 - Layout.preferredWidth: submitLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: submitMouse.containsMouse ? Theme.acidGreen : Theme.accent - border.width: Theme.borderWidth - color: submitMouse.containsMouse ? Theme.surfaceSelected : Theme.surfaceHover - radius: Theme.radiusSmall - - Text { - id: submitLabel - - anchors.centerIn: parent - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: 10 - font.weight: Theme.fontWeightBold - text: "[CONNECT]" - } - - MouseArea { - id: submitMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: { - NetworkService.connectToNetwork(availTile.modelData.ssid, pwInput.text); - root.selectedSsid = ""; - } - } - } - } - - Text { - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: 9 - text: "[ENTER] Connect [ESC] Cancel" - } - } - } - - MouseArea { - id: availMouseArea - - anchors.fill: parent - z: -1 - hoverEnabled: true - - onClicked: { - if (availTile.isSecured) { - root.selectedSsid = availTile.isSelected ? "" : availTile.modelData.ssid; - } else { - NetworkService.connectToNetwork(availTile.modelData.ssid, ""); - } - } - } - } - } - } - } - } - } - - // Footer Divider - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Theme.borderWidth - color: Theme.divider - } - - // Footer Row - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSmall - - Text { - Layout.fillWidth: true - color: Theme.textMuted - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: { - if (!NetworkService.wifiEnabled) return "// STATUS: OFF"; - if (NetworkService.isScanning) return "// STATUS: SCANNING..."; - if (NetworkService.isConnecting) return "// STATUS: CONNECTING TO " + (NetworkService.connectingSsid ? NetworkService.connectingSsid.toUpperCase() : "..."); - if (NetworkService.isConnected) return "// STATUS: CONNECTED (" + NetworkService.networkName + ")"; - return "// STATUS: STANDBY"; - } - elide: Text.ElideRight - } - - Rectangle { - id: refreshBtn - - Layout.preferredHeight: 18 - Layout.preferredWidth: refreshLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: refreshMouse.containsMouse ? Theme.accent : Theme.borderMuted - border.width: Theme.borderWidth - color: refreshMouse.containsMouse ? Theme.surfaceHover : "transparent" - radius: Theme.radiusSmall - - Text { - id: refreshLabel - - anchors.centerIn: parent - color: refreshMouse.containsMouse ? Theme.accent : Theme.textSecondary - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - text: "[REFRESH]" - } - - MouseArea { - id: refreshMouse - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: NetworkService.refresh() - } - } - } - } -} diff --git a/shell/desktop/surfaces/components/NetworkWidget.qml b/shell/desktop/surfaces/components/NetworkWidget.qml deleted file mode 100644 index a70bd5a1..00000000 --- a/shell/desktop/surfaces/components/NetworkWidget.qml +++ /dev/null @@ -1,75 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../core" -import "../../services" - -Item { - id: root - - property bool isHovered: false - readonly property bool isOnline: NetworkService.available && NetworkService.isConnected - readonly property string netText: { - if (!NetworkService.available || !NetworkService.isConnected) { - return "--N/A--"; - } - return NetworkService.networkName !== "" ? NetworkService.networkName : "--N/A--"; - } - readonly property string prefixTag: { - if (!NetworkService.available || !NetworkService.isConnected) { - return "NET"; - } - if (NetworkService.isEthernet) { - return "ETH"; - } - if (NetworkService.isWifi) { - return "WIFI"; - } - return "NET"; - } - - implicitHeight: 36 - implicitWidth: layout.implicitWidth - - function openWifiSubmenu(): void { - OverlayController.openWifiSubmenu(); - } - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingXs - - CtosIcon { - Layout.alignment: Qt.AlignVCenter - size: 14 - name: { - if (!root.isOnline) return "wifi-slash"; - if (NetworkService.isEthernet) return "network"; - return "wifi"; - } - active: root.isOnline - destructive: !root.isOnline && NetworkService.available - color: { - if (!NetworkService.available) return Theme.unavailable; - if (!root.isOnline) return Theme.destructive; - return root.isHovered ? Theme.accent : Theme.textSecondary; - } - } - - Text { - id: label - - Layout.alignment: Qt.AlignVCenter - Layout.maximumWidth: 120 - color: root.isOnline ? Theme.textPrimary : Theme.unavailable - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - maximumLineCount: 1 - text: root.netText - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/NotchCalendarGrid.qml b/shell/desktop/surfaces/components/NotchCalendarGrid.qml index 30a96629..bfd9fce3 100644 --- a/shell/desktop/surfaces/components/NotchCalendarGrid.qml +++ b/shell/desktop/surfaces/components/NotchCalendarGrid.qml @@ -55,6 +55,8 @@ Rectangle { SystemClock { id: systemClock + // Plural form, not Qt's documented "MinutePrecision": the documented + // names resolve to undefined on this Qt. See CalendarPopup.qml. precision: SystemClock.Minutes } diff --git a/shell/desktop/surfaces/components/StatRing.qml b/shell/desktop/surfaces/components/StatRing.qml new file mode 100644 index 00000000..234e79f3 --- /dev/null +++ b/shell/desktop/surfaces/components/StatRing.qml @@ -0,0 +1,126 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../core" +import "../../services" + +// Circular utilisation gauge: a track ring with a value arc over it and the +// percentage in the middle. +// +// Drawn on a Canvas rather than with Shape. Shape would need either +// ShapeLinearGradient or a stack of polygon segments to approximate the arc, +// and neither resolves in this Qt packaging -- QtQuick's linktarget for +// qtquick2plugin cannot be satisfied in the Nix sandbox, so Shape-based +// gradients fail at instantiation. Canvas has no such dependency. +Item { + id: root + + // Used fraction, [0.0, 1.0]. Clamped on read: callers hand this live + // telemetry, and a momentary >1 would otherwise draw an arc that overshoots + // the track and looks like a rendering fault. + property real fraction: 0.0 + + property color ringColor: Theme.accent + property color trackColor: Theme.borderMuted + property real lineWidth: 5 + property string valueText: "" + property string caption: "" + + // Colour of the percentage in the middle. Defaults to the arc's own colour + // so a ring and its number cannot disagree: the design sets each percentage + // in the colour of its arc, and a neutral grey number next to a coloured arc + // reads as two unrelated facts. + property color valueColor: ringColor + + // Falls back to a muted tone when the sampler is offline, so a dead ring is + // visibly dead rather than confidently reporting zero. + readonly property color effectiveValueColor: + SystemMonitorService.available ? root.valueColor : Theme.textDisabled + + implicitWidth: 56 + implicitHeight: caption !== "" ? 78 : 60 + + // Real sizes, not just implicit ones. Row and Flow measure themselves from + // their children's *height*, not from child implicitHeight, so a StatRing + // that only declared implicit sizes measured zero tall and collapsed the + // whole row it sat in. + width: implicitWidth + height: implicitHeight + + Canvas { + id: ring + width: Math.min(56, root.width) + height: width + anchors.top: captionText.visible ? captionText.bottom : parent.top + anchors.topMargin: Theme.spacingSmall + antialiasing: true + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + const cx = width / 2; + const cy = height / 2; + // Inset by half the stroke so the ring is not clipped by the canvas. + const radius = (Math.min(width, height) - root.lineWidth) / 2; + if (radius <= 0) + return; + + ctx.lineWidth = root.lineWidth; + ctx.lineCap = "round"; + + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = root.trackColor; + ctx.stroke(); + + const f = Math.max(0, Math.min(1, root.fraction)); + if (f > 0) { + // Start at 12 o'clock and sweep clockwise, so the arc reads as + // a gauge filling up rather than as a pie chart. + ctx.beginPath(); + ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + f * Math.PI * 2); + ctx.strokeStyle = root.ringColor; + ctx.stroke(); + } + } + + // Live telemetry repaints every second; without these the ring would + // show whatever fraction happened to be set when the card was created. + Connections { + target: root + function onFractionChanged() { ring.requestPaint(); } + function onRingColorChanged() { ring.requestPaint(); } + function onTrackColorChanged() { ring.requestPaint(); } + function onLineWidthChanged() { ring.requestPaint(); } + function onWidthChanged() { ring.requestPaint(); } + } + } + + // Caption above the ring. The design treats CPU / Memory / Disk as column + // headers with their rings beneath, which also puts the number nearest the + // label it belongs to. + Text { + id: captionText + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: root.caption + visible: root.caption !== "" + color: Theme.textSecondary + font.family: Theme.fontFamilySans + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightDemiBold + elide: Text.ElideRight + } + + Text { + anchors.centerIn: ring + text: root.valueText + color: root.effectiveValueColor + font.family: Theme.fontFamilyMonoNumeric + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightBold + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/ToggleSwitch.qml b/shell/desktop/surfaces/components/ToggleSwitch.qml new file mode 100644 index 00000000..c87bbb45 --- /dev/null +++ b/shell/desktop/surfaces/components/ToggleSwitch.qml @@ -0,0 +1,90 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import "../../core" + +// Pill toggle switch: a track with a knob that slides across it. +// +// Sizes itself. The toggles this replaces were fixed-width text buttons -- +// `width: 60` for "[ON]", `width: 70` for "[UNMUTE]" -- with the width chosen +// by hand to fit the current string. Changing the label silently broke the +// layout. Nothing here depends on the text, so there is no width to get wrong. +// +// Every dimension is a property with a token-sourced default, so a card can +// override scale without redefining the drawing. +Item { + id: root + + // Off/on state. Deliberately a plain bool property rather than a two-state + // enum: a toggle has exactly two positions, and anything else in a toggle's + // API is a bug waiting to happen. + property bool checked: false + + // Emitted on user interaction only. Assigning `checked` programmatically + // does not re-emit, so a service pushing state in will not loop back + // through a handler that writes it again. + signal toggled() + + property color onColor: Theme.accentBlue + property color offColor: Theme.navyBorder + property color knobColor: Theme.textPrimary + + property real trackWidth: 30 + property real trackHeight: 17 + property real knobInset: 2.5 + + implicitWidth: trackWidth + implicitHeight: trackHeight + width: trackWidth + height: trackHeight + + readonly property real knobDiameter: height - knobInset * 2 + readonly property real knobTravel: width - knobDiameter - knobInset * 2 + + Rectangle { + id: track + anchors.fill: parent + radius: height / 2 + color: root.checked ? root.onColor : root.offColor + border.width: Theme.borderWidth + border.color: root.checked ? root.onColor : Theme.borderSubtle + opacity: hoverArea.containsMouse ? 1.0 : 0.92 + + Behavior on color { + ColorAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + } + } + } + + Rectangle { + id: knob + width: root.knobDiameter + height: width + radius: width / 2 + color: root.knobColor + y: root.knobInset + x: root.checked ? root.knobInset + root.knobTravel : root.knobInset + + Behavior on x { + NumberAnimation { + duration: Settings.reducedMotion ? 0 : Theme.durationFast + easing.type: Easing.OutCubic + } + } + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // The whole track is the hit target, not just the knob. A 30x17 target + // is already small; making the user aim at a 12px circle inside it would + // be needlessly fiddly. + onClicked: { + root.checked = !root.checked; + root.toggled(); + } + } +} \ No newline at end of file diff --git a/shell/desktop/surfaces/components/VolumeWidget.qml b/shell/desktop/surfaces/components/VolumeWidget.qml deleted file mode 100644 index 5729edc1..00000000 --- a/shell/desktop/surfaces/components/VolumeWidget.qml +++ /dev/null @@ -1,72 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../core" -import "../../services" - -Item { - id: root - - property bool isHovered: false - readonly property bool isAvail: AudioService.available - readonly property bool isMuted: AudioService.muted - readonly property int volumePercent: Math.round(AudioService.volume * 100) - readonly property string volumeText: { - if (!isAvail) { - return "--N/A--"; - } - if (isMuted) { - return "[MUTED]"; - } - return volumePercent.toString() + "%"; - } - - implicitHeight: 36 - implicitWidth: layout.implicitWidth - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingXs - - CtosIcon { - Layout.alignment: Qt.AlignVCenter - size: 14 - name: { - if (!root.isAvail) return "volume-slash"; - if (root.isMuted) return "volume-slash"; - return "volume"; - } - active: root.isHovered && root.isAvail && !root.isMuted - destructive: root.isMuted - color: { - if (!root.isAvail) return Theme.unavailable; - if (root.isMuted) return Theme.destructive; - if (root.isHovered) return Theme.accent; - return Theme.textSecondary; - } - } - - Text { - id: valText - - Layout.alignment: Qt.AlignVCenter - color: { - if (!root.isAvail) { - return Theme.unavailable; - } - if (root.isMuted) { - return Theme.accentRed; - } - return root.isHovered ? Theme.accent : Theme.textPrimary; - } - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - maximumLineCount: 1 - text: root.volumeText - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/WindowTitleWidget.qml b/shell/desktop/surfaces/components/WindowTitleWidget.qml deleted file mode 100644 index 768401c1..00000000 --- a/shell/desktop/surfaces/components/WindowTitleWidget.qml +++ /dev/null @@ -1,79 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../core" -import "../../services" - -Item { - id: root - - readonly property string windowAddress: CompositorService.activeWindowAddress ?? "" - readonly property string windowClass: CompositorService.activeWindowClass ?? "" - readonly property string windowTitle: CompositorService.activeWindowTitle ?? "" - readonly property bool hasWindow: windowAddress !== "" || windowTitle !== "" || windowClass !== "" - - readonly property string displayTitle: { - if (windowTitle !== "") { - return windowTitle; - } - if (windowClass !== "") { - return windowClass; - } - return "ctOS"; - } - - clip: true - implicitHeight: 36 - implicitWidth: Math.min(layout.implicitWidth, 400) - - RowLayout { - id: layout - - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - spacing: Theme.spacingSmall - - Rectangle { - id: classBadge - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: 18 - Layout.preferredWidth: classLabel.implicitWidth + Theme.paddingSmall * 2 - border.color: Theme.borderMuted - border.width: Theme.borderWidth - color: Theme.surfaceHover - radius: Theme.radiusSmall - visible: root.hasWindow && root.windowClass !== "" - - Text { - id: classLabel - - anchors.centerIn: parent - color: Theme.textSecondary - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: Theme.fontWeightDemiBold - maximumLineCount: 1 - text: root.windowClass.toUpperCase() - wrapMode: Text.NoWrap - } - } - - Text { - id: titleText - - Layout.alignment: Qt.AlignVCenter - Layout.fillWidth: true - clip: true - color: root.hasWindow ? Theme.textPrimary : Theme.textMuted - elide: Text.ElideRight - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightNormal - maximumLineCount: 1 - text: root.displayTitle - wrapMode: Text.NoWrap - } - } -} diff --git a/shell/desktop/surfaces/components/WorkspacesWidget.qml b/shell/desktop/surfaces/components/WorkspacesWidget.qml deleted file mode 100644 index dd3f91c8..00000000 --- a/shell/desktop/surfaces/components/WorkspacesWidget.qml +++ /dev/null @@ -1,79 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import "../../adapters/hyprland" -import "../../core" -import "../../services" - -Item { - id: root - - property string monitorName: "" - readonly property var workspaceList: { - if (root.monitorName !== "" && HyprlandAdapter.available) { - const monList = HyprlandAdapter.workspacesForMonitor(root.monitorName); - if (monList && monList.length > 0) { - return monList; - } - } - - const rawList = CompositorService.workspaces; - if (rawList && rawList.length > 0) { - return rawList; - } - - return [1, 2, 3, 4, 5]; - } - - implicitHeight: 36 - implicitWidth: layout.implicitWidth - - RowLayout { - id: layout - - anchors.centerIn: parent - spacing: Theme.spacingXs - - Repeater { - model: root.workspaceList - - Rectangle { - id: wsCell - - readonly property int wsId: (typeof modelData === "object" && modelData !== null) ? Number(modelData.id) : Number(modelData) - readonly property bool isActive: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.active) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isFocused: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.focused || (wsId === CompositorService.focusedWorkspaceId)) : (wsId === CompositorService.focusedWorkspaceId) - readonly property bool isUrgent: (typeof modelData === "object" && modelData !== null) ? Boolean(modelData.urgent) : false - required property var modelData - - Layout.alignment: Qt.AlignVCenter - Layout.preferredHeight: 22 - Layout.preferredWidth: 24 - border.color: isUrgent ? Theme.accentRed : (isFocused ? Theme.accent : (mouseArea.containsMouse ? Theme.ctosGray : Theme.borderMuted)) - border.width: Theme.borderWidth - color: isFocused ? Theme.surfaceSelected : (mouseArea.containsMouse ? Theme.surfaceHover : "transparent") - radius: Theme.radiusSmall - - Text { - anchors.centerIn: parent - color: wsCell.isUrgent ? Theme.accentRed : (wsCell.isFocused ? Theme.accent : (wsCell.isActive ? Theme.textPrimary : Theme.textMuted)) - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeCaption - font.weight: wsCell.isFocused ? Theme.fontWeightBold : Theme.fontWeightMedium - text: (typeof wsCell.modelData === "object" && wsCell.modelData !== null && wsCell.modelData.name) ? wsCell.modelData.name : String(wsCell.wsId) - } - - MouseArea { - id: mouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - - onClicked: { - CompositorService.switchToWorkspace(wsCell.wsId); - } - } - } - } - } -} diff --git a/shell/desktop/surfaces/components/blume-logo.svg b/shell/desktop/surfaces/components/blume-logo.svg deleted file mode 100644 index 269eabdd..00000000 --- a/shell/desktop/surfaces/components/blume-logo.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - diff --git a/shell/desktop/surfaces/components/qmldir b/shell/desktop/surfaces/components/qmldir index b85ccf48..db332814 100644 --- a/shell/desktop/surfaces/components/qmldir +++ b/shell/desktop/surfaces/components/qmldir @@ -1,15 +1,10 @@ -WorkspacesWidget 1.0 WorkspacesWidget.qml -WindowTitleWidget 1.0 WindowTitleWidget.qml -NetworkWidget 1.0 NetworkWidget.qml -VolumeWidget 1.0 VolumeWidget.qml -BatteryWidget 1.0 BatteryWidget.qml -ClockWidget 1.0 ClockWidget.qml CtosIcon 1.0 CtosIcon.qml -DynamicIsland 1.0 DynamicIsland.qml CalendarPopup 1.0 CalendarPopup.qml -BluetoothWidget 1.0 BluetoothWidget.qml -BluetoothPopup 1.0 BluetoothPopup.qml -NetworkPopup 1.0 NetworkPopup.qml -AccordionSection 1.0 AccordionSection.qml +CalendarAgenda 1.0 CalendarAgenda.qml NotchCalendarGrid 1.0 NotchCalendarGrid.qml LivingNotch 1.0 LivingNotch.qml +StatRing 1.0 StatRing.qml +Card 1.0 Card.qml +ToggleSwitch 1.0 ToggleSwitch.qml +GlyphIcon 1.0 GlyphIcon.qml +LevelSlider 1.0 LevelSlider.qml diff --git a/shell/desktop/surfaces/components/shapes/DolphinShape.qml b/shell/desktop/surfaces/components/shapes/DolphinShape.qml index de1188cf..12d18f2e 100644 --- a/shell/desktop/surfaces/components/shapes/DolphinShape.qml +++ b/shell/desktop/surfaces/components/shapes/DolphinShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/EmacsShape.qml b/shell/desktop/surfaces/components/shapes/EmacsShape.qml index afaf201e..2762d944 100644 --- a/shell/desktop/surfaces/components/shapes/EmacsShape.qml +++ b/shell/desktop/surfaces/components/shapes/EmacsShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/GhosttyShape.qml b/shell/desktop/surfaces/components/shapes/GhosttyShape.qml index 7010f731..6b906281 100644 --- a/shell/desktop/surfaces/components/shapes/GhosttyShape.qml +++ b/shell/desktop/surfaces/components/shapes/GhosttyShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/KittyShape.qml b/shell/desktop/surfaces/components/shapes/KittyShape.qml index 48af0a48..e88e0c6a 100644 --- a/shell/desktop/surfaces/components/shapes/KittyShape.qml +++ b/shell/desktop/surfaces/components/shapes/KittyShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/MpvShape.qml b/shell/desktop/surfaces/components/shapes/MpvShape.qml index 318d15ed..cd37b0f0 100644 --- a/shell/desktop/surfaces/components/shapes/MpvShape.qml +++ b/shell/desktop/surfaces/components/shapes/MpvShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/NvidiaSettingsShape.qml b/shell/desktop/surfaces/components/shapes/NvidiaSettingsShape.qml index 8f322f2f..c9a17828 100644 --- a/shell/desktop/surfaces/components/shapes/NvidiaSettingsShape.qml +++ b/shell/desktop/surfaces/components/shapes/NvidiaSettingsShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/ObsidianShape.qml b/shell/desktop/surfaces/components/shapes/ObsidianShape.qml index 1cff8a92..208b9ff1 100644 --- a/shell/desktop/surfaces/components/shapes/ObsidianShape.qml +++ b/shell/desktop/surfaces/components/shapes/ObsidianShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.2 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/OkularShape.qml b/shell/desktop/surfaces/components/shapes/OkularShape.qml index 6f6b7489..d9a08c0f 100644 --- a/shell/desktop/surfaces/components/shapes/OkularShape.qml +++ b/shell/desktop/surfaces/components/shapes/OkularShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.5 property bool active: false diff --git a/shell/desktop/surfaces/components/shapes/ZedShape.qml b/shell/desktop/surfaces/components/shapes/ZedShape.qml index 6c0f45e7..b797c9ae 100644 --- a/shell/desktop/surfaces/components/shapes/ZedShape.qml +++ b/shell/desktop/surfaces/components/shapes/ZedShape.qml @@ -1,11 +1,14 @@ import QtQuick import QtQuick.Shapes +import "../../../core" Item { id: root property real size: 24 - property color color: "#1BFD9C" + // Always overridden by CuratedAppShape; the default only needs to not + // be stale. It used to carry the pre-rebrand acid green. + property color color: Theme.accentMagenta property real strokeWidth: 1.2 property bool active: false diff --git a/shell/desktop/surfaces/radial/CircularSettingsMenu.qml b/shell/desktop/surfaces/radial/CircularSettingsMenu.qml index f71d130d..8296270e 100644 --- a/shell/desktop/surfaces/radial/CircularSettingsMenu.qml +++ b/shell/desktop/surfaces/radial/CircularSettingsMenu.qml @@ -26,14 +26,14 @@ Item { Behavior on innerRadius { NumberAnimation { - duration: Theme.durationSlow + duration: Settings.reducedMotion ? 0 : Theme.durationSlow easing.type: Easing.OutCubic } } Behavior on wheelCenterX { NumberAnimation { - duration: Theme.durationSlow + duration: Settings.reducedMotion ? 0 : Theme.durationSlow easing.type: Easing.OutCubic } } @@ -49,7 +49,7 @@ Item { id: wheelRotationBehavior NumberAnimation { id: wheelRotationAnim - duration: Theme.durationSlow + duration: Settings.reducedMotion ? 0 : Theme.durationSlow easing.type: Easing.OutCubic onRunningChanged: { if (!running && !root.isExpanded) { @@ -70,8 +70,10 @@ Item { onBaseTargetAngleChanged: updateRotationTarget() // Signals + // categoryHovered is gone: its only emitter was a handler on a signal that + // was never raised. categoryClicked stays because the hub MouseArea below + // still raises it directly. signal categoryClicked(int index) - signal categoryHovered(int index) signal collapseRequested() // Rotating Wheel Container @@ -107,13 +109,6 @@ Item { isSelected: root.focusedIndex === index && root.isExpanded isDimmed: root.isExpanded && root.focusedIndex !== index isExpanded: root.isExpanded - - onClicked: { - root.categoryClicked(index); - } - onHovered: { - root.categoryHovered(index); - } } } } @@ -135,7 +130,7 @@ Item { border.width: 1 Behavior on border.color { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } MouseArea { @@ -171,7 +166,7 @@ Item { opacity: root.isExpanded ? 0.0 : 1.0 Behavior on opacity { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } Text { diff --git a/shell/desktop/surfaces/radial/ContextPanel.qml b/shell/desktop/surfaces/radial/ContextPanel.qml index fa27f2a8..b3ea1ae4 100644 --- a/shell/desktop/surfaces/radial/ContextPanel.qml +++ b/shell/desktop/surfaces/radial/ContextPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import "../../core" +import "../../services" import "../components" import "../widgets" @@ -27,6 +28,25 @@ Item { return typeof currentNode.value === "function" && Boolean(currentNode.value()); } + // Whether a status pill means anything for this control. + // + // isActive is Boolean(value()), which is the right question for a toggle and + // a slider and nonsense for a picker: a picker's value() is the name of the + // selected option, so every option is truthy and the pill would read + // "ONLINE // ACTIVE" no matter which one was chosen. Rather than make picker + // nodes lie about their value to satisfy the pill, the pill stands down. + readonly property bool showsStatusPill: + currentNode + && currentNode.controlType !== "picker" + && currentNode.controlType !== "browser" + && currentNode.controlType !== "readonly" + && !root.isLocked + + // The one control that is a list rather than a row, and the only one that + // needs the panel's remaining height. See the control box below. + readonly property bool currentIsBrowser: + currentNode && currentNode.controlType === "browser" + width: parent ? Math.min(440, Math.max(340, parent.width * 0.3)) : 440 anchors.top: parent ? parent.top : undefined anchors.bottom: parent ? parent.bottom : undefined @@ -39,14 +59,14 @@ Item { visible: opacity > 0.01 Behavior on opacity { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } // Slide transition transform: Translate { x: root.isExpanded ? 0 : 60 Behavior on x { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } } @@ -90,7 +110,7 @@ Item { target: layout property: "opacity" to: 1.0 - duration: Theme.durationNormal + duration: Settings.reducedMotion ? 0 : Theme.durationNormal easing.type: Easing.OutCubic } } @@ -141,7 +161,12 @@ Item { } // Status Pill Badge + // + // Hidden where it would be meaningless -- see root.showsStatusPill. A + // readonly node states its value in the control area below, and a picker + // states it beside the SELECT OPTION caption. Rectangle { + visible: root.showsStatusPill Layout.preferredHeight: 24 Layout.preferredWidth: statusText.implicitWidth + 16 radius: Theme.radiusSmall @@ -199,15 +224,23 @@ Item { spacing: 2 Text { - text: "PREREQUISITE LOCKED" + // Was an unconditional "PREREQUISITE LOCKED". No node is + // gated on another node's state: `requires` records what a + // branch hangs off, which `edges` already expresses, and + // every setting in the tree is independently reachable. The + // locks that remain are real conditions -- an absent + // NetworkManager, an absent input device, a dead awww + // daemon -- so the banner names the condition rather than + // inventing a prerequisite graph that does not exist. + text: "UNAVAILABLE" font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption font.weight: Theme.fontWeightBold - color: Theme.warningRed + color: Theme.destructive } Text { - text: root.currentNode ? (root.currentNode.lockReason || "Prerequisites not fulfilled.") : "" + text: root.currentNode ? (root.currentNode.lockReason || "This control is unavailable right now.") : "" font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall color: Theme.textPrimaryDim @@ -233,7 +266,14 @@ Item { // Interactive Settings Control Box Item { Layout.fillWidth: true - Layout.preferredHeight: 90 + + // 90 for the four single-row controls, which is what this has always + // been sized for. The browser is a list, so it takes the rest of the + // panel instead -- and only it does, because filling the height + // unconditionally would leave every other control's row floating in + // the middle of a very tall empty box. + Layout.preferredHeight: root.currentIsBrowser ? 0 : 90 + Layout.fillHeight: root.currentIsBrowser visible: root.currentNode !== null // 1. Toggle Control @@ -497,7 +537,440 @@ Item { } } - // 4. Readonly Value Readout + // 4. Picker Control + // + // A discrete choice among named options, where a slider would be a + // lie: theme and wallpaper are a small closed set, not a magnitude. + // + // Deliberately not arrow-driven. NavigationController.handleKeyPress + // already spends Left/Right on spatial traversal between nodes, so a + // picker that claimed them would either break node navigation or be + // unreachable by keyboard. Instead a chip selects directly on click, + // and the node's own execute() cycles when called with no argument, + // which is what ENTER reaches through executeCurrentNode(). + Item { + anchors.fill: parent + visible: root.currentNode && root.currentNode.controlType === "picker" && !root.isLocked + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacingSmall + + RowLayout { + Layout.fillWidth: true + + Text { + text: "SELECT OPTION" + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + color: Theme.textMuted + Layout.fillWidth: true + } + + Text { + text: { + if (!root.currentNode || !root.model) return "--"; + var _ = root.model.revision; + return (typeof root.currentNode.valueText === "function") ? root.currentNode.valueText() : "--"; + } + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeBody + font.weight: Theme.fontWeightBold + color: Theme.acidGreen + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + Repeater { + model: (root.currentNode && Array.isArray(root.currentNode.options)) ? root.currentNode.options : [] + + delegate: Rectangle { + id: optionChip + required property var modelData + + // Options are either bare strings or objects with a + // label and an optional distinct value, so a node can + // display something readable while storing an + // identifier the service understands. + readonly property string label: { + if (typeof modelData === "string") return modelData; + if (modelData && modelData.label) return modelData.label; + return ""; + } + readonly property string optionValue: { + if (typeof modelData === "string") return modelData; + if (modelData && modelData.value !== undefined) return modelData.value; + return optionChip.label; + } + readonly property bool isCurrent: { + if (!root.currentNode || !root.model) return false; + var _ = root.model.revision; + if (typeof root.currentNode.value !== "function") return false; + return root.currentNode.value() === optionChip.optionValue; + } + + Layout.fillWidth: true + Layout.preferredHeight: 32 + radius: Theme.radiusSmall + color: isCurrent ? Theme.acidGreen : Theme.gray800 + border.color: isCurrent ? Theme.acidGreen : Theme.gray600 + border.width: 1 + + Text { + anchors.centerIn: parent + width: parent.width - Theme.spacingMedium + text: optionChip.label + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightBold + color: optionChip.isCurrent ? Theme.gray900 : Theme.textSecondary + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.currentNode && typeof root.currentNode.execute === "function") { + root.currentNode.execute(optionChip.optionValue); + } + } + } + } + } + } + } + } + + // 5. Browser Control + // + // Directory plus a grid of what is in it. Not a picker: a picker + // offers a closed set the panel already knows about, and this is an + // open one the user points at. It also has to stay legible when the + // directory is empty, when it does not exist, and while a scan is in + // flight, which a row of chips has no room to say. + Item { + id: browserControl + anchors.fill: parent + visible: root.currentNode && root.currentNode.controlType === "browser" && !root.isLocked + + // Held on the service so it survives the node being unselected, + // and so a half-typed path is not thrown away by a click + // elsewhere in the tree. + property string dirDraft: "" + + function adoptDirectory() { + if (browserControl.dirDraft !== WallpaperService.directory) { + WallpaperService.setDirectory(browserControl.dirDraft); + } + } + + // Adopting on activation rather than on every keystroke: a scan + // per character would fork a process per character, and a + // half-typed path is not a directory anyway. + onVisibleChanged: { + if (visible) { + browserControl.dirDraft = WallpaperService.directory; + // Re-list on open. The service scans once at startup and + // again only when the directory changes -- deliberately, + // since a watch would mean holding a descriptor open on a + // path the user can point anywhere -- so files added or + // deleted while the shell was running never appeared or + // disappeared here. The grid showed whatever the directory + // held at boot, indefinitely. + // + // Opening the browser is a deliberate act and costs one + // `sh`, so that is the moment worth paying for a fresh + // listing. + WallpaperService.rescan(); + } + } + Component.onCompleted: { + browserControl.dirDraft = WallpaperService.directory; + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacingSmall + + // --- directory row --- + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSmall + + Text { + text: "DIRECTORY" + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + color: Theme.textMuted + } + + // Core TextInput with a sibling background, not a Controls + // TextField. The desktop tree does not take a + // QtQuick.Controls dependency: the Wi-Fi prompt in + // CommandCenter hit exactly this and had to be rewritten, + // because `background:` is a Controls feature and a core + // TextInput does not have it. Placeholder text is a + // Controls feature too, hence the sibling Text. + Item { + id: dirField + Layout.fillWidth: true + Layout.preferredHeight: 28 + + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: Theme.gray800 + border.color: dirField.activeFocus ? Theme.acidGreen : Theme.gray600 + border.width: 1 + } + + Text { + anchors.fill: parent + anchors.leftMargin: Theme.spacingSmall + anchors.rightMargin: Theme.spacingSmall + verticalAlignment: Text.AlignVCenter + visible: dirInput.text.length === 0 + text: "~/.local/share/ctos/wallpapers" + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + color: Theme.textMuted + elide: Text.ElideMiddle + } + + TextInput { + id: dirInput + anchors.fill: parent + anchors.leftMargin: Theme.spacingSmall + anchors.rightMargin: Theme.spacingSmall + verticalAlignment: TextInput.AlignVCenter + clip: true + + text: browserControl.dirDraft + color: Theme.textPrimary + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + selectByMouse: true + selectionColor: Theme.acidGreen + selectedTextColor: Theme.textInverse + + onTextEdited: browserControl.dirDraft = text + onAccepted: browserControl.adoptDirectory() + + Keys.onEscapePressed: { + // Abandon the edit rather than committing a + // path the user did not mean to type. + browserControl.dirDraft = WallpaperService.directory; + text = browserControl.dirDraft; + } + } + } + + Rectangle { + id: applyDirButton + Layout.preferredWidth: scanLabel.implicitWidth + Theme.spacingLarge + Layout.preferredHeight: 28 + radius: Theme.radiusSmall + color: applyDirMouse.pressed ? Theme.acidGreen : Theme.gray800 + border.color: Theme.acidGreen + border.width: 1 + + Text { + id: scanLabel + anchors.centerIn: parent + text: WallpaperService.scanning ? "SCAN" : "SET" + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeCaption + font.weight: Theme.fontWeightBold + color: applyDirMouse.pressed ? Theme.textInverse : Theme.acidGreen + } + + MouseArea { + id: applyDirMouse + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: !WallpaperService.scanning + onClicked: browserControl.adoptDirectory() + } + } + } + + // --- status line --- + Text { + Layout.fillWidth: true + text: { + if (WallpaperService.scanning) return "SCANNING " + WallpaperService.directory + " ..."; + if (!WallpaperService.scanned) return ""; + if (WallpaperService.lastError !== "") return WallpaperService.lastError; + if (WallpaperService.count === 0) return "NO IMAGES IN " + WallpaperService.directory; + return WallpaperService.count + (WallpaperService.count === 1 ? " IMAGE" : " IMAGES") + + (WallpaperService.count >= WallpaperService.maxWallpapers ? " (CAPPED)" : "") + + " // " + WallpaperService.currentName; + } + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeMicro + color: WallpaperService.lastError !== "" ? Theme.destructive : Theme.textMuted + elide: Text.ElideMiddle + Layout.maximumHeight: 14 + } + + // --- the grid --- + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radiusSmall + color: Theme.gray900 + border.color: Theme.gray700 + border.width: 1 + + // Two per row. With the control box now taking the panel's + // remaining height there is room for tiles big enough to + // recognise a wallpaper, and one-per-row wasted most of + // it on scrolling. + // + // cellWidth and cellHeight are derived from `width` + // directly. Routing them through intermediate readonly + // properties on the GridView broke the column count -- + // the view laid the delegates out one per row instead of + // two, which is a silent layout fault rather than a + // visible error. + // + // The height is floor(halfWidth * 9/16) plus a 16px label + // band, so the thumbnails keep their aspect and the + // filename stays legible underneath. + GridView { + id: wallpaperGrid + anchors.fill: parent + anchors.margins: Theme.spacingSmall + clip: true + model: WallpaperService.wallpapers + boundsBehavior: Flickable.StopAtBounds + + cellWidth: Math.floor(width / 2) + cellHeight: Math.floor(Math.floor(width / 2) * 9 / 16) + 20 + + delegate: Rectangle { + id: wallpaperTile + required property var modelData + + readonly property bool isCurrent: + modelData.name === WallpaperService.currentName + readonly property int labelHeight: 16 + + width: GridView.view.cellWidth - Theme.spacingSmall + height: GridView.view.cellHeight - Theme.spacingSmall + radius: Theme.radiusSmall + color: mouse.containsMouse ? Theme.gray800 : Theme.surface + border.color: isCurrent ? Theme.acidGreen : Theme.gray700 + border.width: isCurrent ? 2 : 1 + + // sourceSize is what keeps this affordable: the + // shipped images are 6000-7500px wide and tens of + // megabytes, and a grid that decoded them at full + // size would exhaust memory long before it filled. + Image { + id: thumb + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 2 + height: parent.height - wallpaperTile.labelHeight - 4 + source: "file://" + modelData.path + sourceSize.width: 300 + sourceSize.height: 170 + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + clip: true + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 3 + text: modelData.name + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeMicro + color: Theme.textSecondary + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + } + + MouseArea { + id: mouse + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + if (root.currentNode && typeof root.currentNode.execute === "function") { + root.currentNode.execute(modelData.name); + } + } + } + } + } + + Text { + anchors.centerIn: parent + visible: WallpaperService.scanned && WallpaperService.count === 0 && !WallpaperService.scanning + text: "EMPTY" + font.family: Theme.fontFamilyMonospace + font.pixelSize: Theme.fontSizeSmall + color: Theme.textDisabled + } + + // Scroll indicator, hand-rolled. + // + // QtQuick.Controls' ScrollBar is not available here -- this + // panel deliberately has no Controls dependency, the same + // constraint that forced the directory field above to be a + // core TextInput with a sibling background -- so the track + // and thumb are built by hand. The grid already flicks on + // wheel and drag; what it had was no indication that it + // could, which matters more now that it holds five rows. + Rectangle { + id: scrollTrack + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 4 + radius: 2 + visible: wallpaperGrid.contentHeight > wallpaperGrid.height + color: Theme.gray700 + + readonly property real usable: + height - thumb.height + + Rectangle { + id: thumb + width: parent.width + radius: 2 + color: Theme.acidGreen + opacity: 0.8 + + // Proportional to the visible fraction, with a + // floor so a long list still leaves a thumb you + // can see and aim at. + height: Math.max( + 24, + scrollTrack.height + * (wallpaperGrid.height / Math.max(1, wallpaperGrid.contentHeight))) + + y: wallpaperGrid.contentY + * (scrollTrack.usable / Math.max(1, wallpaperGrid.contentHeight - wallpaperGrid.height)) + } + } + } + } + } + + // 6. Readonly Value Readout Item { anchors.fill: parent visible: root.currentNode && (root.currentNode.controlType === "readonly" || root.isLocked) @@ -538,8 +1011,16 @@ Item { } } + // Filler that pins the key hint to the bottom. + // + // It yields when the browser is active, because the control box above is + // also fillHeight and QtQuick.Layouts splits leftover space equally among + // every fillHeight item. With both filling, the browser's control box + // received roughly half the leftover and the wallpaper grid was left at + // 328px regardless of a 972px panel -- two rows on a screen with room for + // six. Only one of the two needs the space, and it is the browser. Item { - Layout.fillHeight: true + Layout.fillHeight: !root.currentIsBrowser } // Bottom Tactical Key Navigation Hint diff --git a/shell/desktop/surfaces/radial/RadialGeometry.js b/shell/desktop/surfaces/radial/RadialGeometry.js index a4bb5c7f..edbd2ab7 100644 --- a/shell/desktop/surfaces/radial/RadialGeometry.js +++ b/shell/desktop/surfaces/radial/RadialGeometry.js @@ -259,3 +259,301 @@ function getSegmentTargetLayout(index, focusedIndex, categoryCount, gapAngle, fo }; } + +// --------------------------------------------------------------------------- +// Tree layout +// +// Takes declared relationships and parameters, and produces coordinates. It +// never sees a tree that has positions baked in: RadialTopology.qml holds only +// who is connected to whom, and this decides where they land in one view while +// layoutPreviewTree below decides where they land in the other. Same +// relationships, two projections, so the views cannot disagree about a +// category's shape -- only about how it is drawn. +// +// Polar convention, and it is worth stating because it is easy to get backwards: +// x = parentX + sin(angle) * distance +// y = parentY - cos(angle) * distance +// Angle 0 points UP, +90 points right. The expanded branch grows rightward from +// a wheel anchored off the left edge, so rootAngle defaults to 90 -- at 0 the +// tree grows up out of the ring. +// +// Angles accumulate down a branch rather than being measured from the root, so +// a child placed at -85 puts its own children around -85. Without that, every +// generation tries to orbit the original root and the tree becomes a disc of +// unrelated spokes instead of branches. +// --------------------------------------------------------------------------- + +// Offsets for childCount children spread evenly across a total fan of `total` +// degrees, centred on zero. +// +// One child gets no spread at all: a single child is a link in a chain, and +// giving it an offset is what turns a three-deep chain into three stray nodes. +function fanOffsets(childCount, total) { + if (childCount <= 1) return [0.0]; + var half = total / 2.0; + var out = []; + for (var i = 0; i < childCount; ++i) + out.push(-half + (total * i) / (childCount - 1)); + return out; +} + +// The narrowest total fan that still leaves `minSeparation` between siblings. +// +// A fixed fan collapses as children are added: at 120 degrees, six children sit +// 30 degrees apart, which is 72px apart at a 140px distance -- closer than the +// nodes are wide. So the fan has to grow with the child count, which is what +// this returns, and it is the reason nodeRadius is worth passing in at all. +function minimumFan(childCount, distance, minSeparation) { + if (childCount <= 1 || distance <= 0.0) return 0.0; + var ratio = Math.min(1.0, minSeparation / (2.0 * distance)); + var step = 2.0 * Math.asin(ratio) * 180.0 / Math.PI; + return step * (childCount - 1); +} + +function layoutTree(nodes, opts) { + opts = opts || {}; + + var rootAngle = (typeof opts.rootAngle === "number") ? opts.rootAngle : 90.0; + + // Total fan per depth, index = depth - 1, clamped at the last entry. + // + // 150 at the root, 100 below. This was 170/120, which is what the reference + // first level measures, and it was too wide to stay clear of the wheel: a + // branch that had already rotated once put its own children at up to -145, + // which is left of the root. security's recon node landed 67px that way -- + // toward a ring whose outer radius is 180 -- and on a deeper tree the worst + // case reached 204px, which is inside the ring itself. + // + // 150/100 puts security's furthest node exactly at the root's own x and pulls + // the 40-node worst case back to 156px, clear of the rim. Going narrower + // still starts to cost the reference's up/right/down first level, which is + // the one thing currently matching it exactly. + var fans = (opts.fans && opts.fans.length > 0) ? opts.fans : [150.0, 100.0]; + + var baseDistance = (typeof opts.distance === "number") ? opts.distance : 140.0; + var nodeRadius = (typeof opts.nodeRadius === "number") ? opts.nodeRadius : 24.0; + var gap = (typeof opts.gap === "number") ? opts.gap : 10.0; + + // Distance holds for the first two levels, then shortens. A tree that keeps + // marching at full stride spreads very wide by depth 4; security already + // reaches x = -344 at depth 3. + var shrinkFromDepth = (typeof opts.shrinkFromDepth === "number") ? opts.shrinkFromDepth : 2; + var shrinkPerLevel = (typeof opts.shrinkPerLevel === "number") ? opts.shrinkPerLevel : 0.8; + + // Measure a fork from the direction its own branch arrived on (the default), + // or from rootAngle. The second reads as a fork hanging off the side of a + // chain, which shoots back across the tree; the first reads as a tree. + var recentreDeepForks = opts.recentreDeepForks === true; + + function fanAt(depth) { + return fans[Math.min(depth - 1, fans.length - 1)]; + } + + function distanceAt(depth) { + if (depth <= shrinkFromDepth) return baseDistance; + return baseDistance * Math.pow(shrinkPerLevel, depth - shrinkFromDepth); + } + + var out = {}; + if (!nodes || nodes.length === 0) return out; + + out[nodes[0].id] = { + x: 0.0, y: 0.0, depth: 0, angle: rootAngle, parent: null, spoke: -1 + }; + + var queue = [{ node: nodes[0], angle: rootAngle, depth: 0 }]; + + while (queue.length > 0) { + var cur = queue.shift(); + var kids = cur.node.children || []; + if (kids.length === 0) continue; + + var depth = cur.depth + 1; + var distance = distanceAt(depth); + + // Widen the fan if this many children would otherwise sit inside each + // other at this distance. + var total = Math.max( + fanAt(depth), + minimumFan(kids.length, distance, 2.0 * nodeRadius + gap) + ); + var offsets = fanOffsets(kids.length, total); + + var origin = out[cur.node.id]; + var from = (recentreDeepForks && cur.depth > 0) ? rootAngle : cur.angle; + + for (var i = 0; i < kids.length; ++i) { + var kid = kids[i]; + // Already placed means it was reached by another branch; leave it + // where it is rather than letting a second parent drag it. + if (!kid || out[kid.id]) continue; + + var angle = from + offsets[i]; + var rad = angle * Math.PI / 180.0; + out[kid.id] = { + x: origin.x + Math.sin(rad) * distance, + y: origin.y - Math.cos(rad) * distance, + depth: depth, + angle: angle, + parent: cur.node.id, + spoke: i + }; + queue.push({ node: kid, angle: angle, depth: depth }); + } + } + + // Nodes the relationships never reach. They cannot be placed relative to a + // parent that does not exist, but leaving them out would make them vanish + // instead of failing visibly, so park them on a shallow arc off the root. + var orphans = []; + for (var n = 0; n < nodes.length; ++n) + if (nodes[n] && !out[nodes[n].id]) orphans.push(nodes[n]); + + for (var o = 0; o < orphans.length; ++o) { + var spread = orphans.length === 1 ? 0.0 : -45.0 + (90.0 * o) / (orphans.length - 1); + var orad = (rootAngle + spread) * Math.PI / 180.0; + out[orphans[o].id] = { + x: Math.sin(orad) * baseDistance, + y: -Math.cos(orad) * baseDistance, + depth: 1, + angle: rootAngle + spread, + parent: nodes[0].id, + spoke: 90 + o + }; + } + + return out; +} + +// Rotate a laid-out point so the tree's +x axis points along `angleDeg`, then +// translate it to `origin`. +// +// This is the whole of the difference between the two renderings: the expanded +// branch draws the layout directly, the wheel's preview draws it through this. +// Same angles, same fan, same parent-to-child pairs -- only the scale, the +// anchor and the rotation differ, which is why the two cannot drift apart. +function projectPoint(p, angleDeg, scale, origin) { + var rad = angleDeg * Math.PI / 180.0; + var cos = Math.cos(rad) * scale; + var sin = Math.sin(rad) * scale; + return { + x: origin.x + p.x * cos - p.y * sin, + y: origin.y + p.x * sin + p.y * cos + }; +} + +// --------------------------------------------------------------------------- +// Wheel preview tree layout +// +// Deliberately not layoutTree() above. The wheel packs eight of these into a +// ring, so a tree here is compact and symmetric about its own segment axis, +// where the expanded branch is read one node at a time and fans to fill space. +// +// The rule, in the tree's own space where +x is "outward along the segment": +// +// o +// / \ +// o o +// | | +// o o +// | | +// o o +// +// A fork straddles the axis by +/- forkDeg, and then a node's descendants carry +// on straight down that same axis. The fork angle is local to the generation +// that forked: it decides where a node is placed and nothing else. A child's own +// children are placed from the axis again, so the two arms below the first fork +// stay parallel columns rather than curving away from each other. +// +// Inheriting the fork instead -- each node's children measuring from the angle +// that node itself left on -- produces +// +// o +// / \ +// o o +// / \ / \ +// o o o o +// +// which is a dendrite that spreads ever wider. It is the more obvious reading +// of "branch", and it is wrong here: the wheel packs eight of these into a +// ring, and a spreading tree at depth 3 reaches past its neighbours' segments. +// The wheel wants columns, not a fan. +// +// Direction convention: dir 0 is +x, so the step is (cos, sin) and a tree grows +// rightward in its own space. That is a 90 degree clockwise turn from the (sin, +// -cos) frame, where 0 would mean up, and it has to be +x because +// projectPoint() rotates by the segment angle and so maps local +x onto the +// outward radial direction (cos a, sin a). Under the old (sin, -cos) frame a +// segment's own axis mapped to (sin a, -cos a), which coincides with the +// outward ray only at a = 180 -- so every tree grew sideways across its +// segment instead of away from the wheel. +function layoutPreviewTree(nodes, opts) { + opts = opts || {}; + var step = (typeof opts.step === "number") ? opts.step : 56.0; + var forkDeg = (typeof opts.forkDeg === "number") ? opts.forkDeg : 45.0; + + // The direction a single child follows, i.e. the tree's own outward axis. + // Every fork is centred on it, and every node's children are placed from it, + // so it is the one angle that survives a fork. + var axisDir = (typeof opts.axisDir === "number") ? opts.axisDir : 0.0; + + var out = {}; + if (!nodes || nodes.length === 0) return out; + out[nodes[0].id] = { + x: 0.0, y: 0.0, depth: 0, dir: axisDir, spoke: -1, parent: null + }; + + var queue = [{ node: nodes[0], depth: 0, dir: axisDir }]; + + while (queue.length > 0) { + var cur = queue.shift(); + var kids = cur.node.children || []; + if (kids.length === 0) continue; + + var depth = cur.depth + 1; + var distance = step; + + // One child gets no offset at all: it is a link in a column, and it + // continues along the axis. + var offsets = fanOffsets(kids.length, forkDeg * 2.0); + + for (var i = 0; i < kids.length; ++i) { + var kid = kids[i]; + if (!kid || out[kid.id]) continue; + + // Placement uses the forked angle, but the direction handed to this + // node's own children is the axis. That is the whole of the rule: + // the fork offsets where a node sits, not where its subtree grows. + var dir = cur.dir + offsets[i]; + var rad = dir * Math.PI / 180.0; + out[kid.id] = { + x: out[cur.node.id].x + Math.cos(rad) * distance, + y: out[cur.node.id].y + Math.sin(rad) * distance, + depth: depth, + dir: axisDir, + spoke: offsets[i] === 0.0 ? -1 : i, + parent: cur.node.id + }; + queue.push({ node: kid, depth: depth, dir: axisDir }); + } + } + + // Nodes the relationships never reach cannot be placed against a parent that + // does not exist, but dropping them would make them vanish instead of failing + // visibly. Park them on the parent's ray. + var orphans = []; + for (var n = 0; n < nodes.length; ++n) + if (nodes[n] && !out[nodes[n].id]) orphans.push(nodes[n]); + + for (var o = 0; o < orphans.length; ++o) { + var orad = (out[nodes[0].id].dir) * Math.PI / 180.0; + out[orphans[o].id] = { + x: out[nodes[0].id].x + Math.cos(orad) * step, + y: out[nodes[0].id].y + Math.sin(orad) * step, + depth: 1, dir: out[nodes[0].id].dir, spoke: 90 + o, parent: nodes[0].id + }; + } + + return out; +} + diff --git a/shell/desktop/surfaces/radial/RadialSegment.qml b/shell/desktop/surfaces/radial/RadialSegment.qml index b37a8620..ad29198b 100644 --- a/shell/desktop/surfaces/radial/RadialSegment.qml +++ b/shell/desktop/surfaces/radial/RadialSegment.qml @@ -23,14 +23,20 @@ Item { property real _lastTargetAngle: -90.0 + index * (360.0 / Math.max(1, typeof categoryCount !== "undefined" ? categoryCount : 8)) property var layoutInfo: RadialGeometry.getSegmentTargetLayout( index, - typeof globalFocusedIndex !== "undefined" ? globalFocusedIndex : 0, + _focusedIndex, typeof categoryCount !== "undefined" ? categoryCount : 8, 2.0, 90.0, _lastTargetAngle ) - onLayoutInfoChanged: { + // The focused index is what actually moves a segment, so latch the + // continuity hint on that. Writing it from onLayoutInfoChanged made + // layoutInfo depend on a property its own handler mutated, which is a + // binding loop. + readonly property int _focusedIndex: typeof globalFocusedIndex !== "undefined" ? globalFocusedIndex : 0 + + on_FocusedIndexChanged: { if (layoutInfo && layoutInfo.centerAngle !== undefined) { _lastTargetAngle = layoutInfo.centerAngle; } @@ -42,16 +48,16 @@ Item { property real endAngle: layoutInfo.endAngle Behavior on currentWidth { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } Behavior on centerAngle { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } Behavior on startAngle { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } Behavior on endAngle { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } property real wheelRotation: 0.0 @@ -70,9 +76,11 @@ Item { readonly property real iconCenterX: root.cx + root.iconRadius * Math.cos(root.iconRad) readonly property real iconCenterY: root.cy + root.iconRadius * Math.sin(root.iconRad) - // Signals - signal clicked() - signal hovered() + // No signals here. It used to declare clicked() and hovered(), and + // CircularSettingsMenu connected both, but nothing in this file ever emitted + // them -- there is no MouseArea in it. Hit-testing is surfaceMouseArea in + // RadialSettings.qml, which covers the whole radial, so the handlers were + // dead code that looked live. // Annular Sector Vector Shape Shape { @@ -84,15 +92,15 @@ Item { id: sectorPath strokeWidth: 2.0 strokeColor: root.isFocused ? Theme.gray50 : (root.isSelected ? Theme.acidGreen : "transparent") - fillColor: (root.isFocused || root.isSelected) ? Theme.gray50 : "black" + fillColor: (root.isFocused || root.isSelected) ? Theme.gray50 : Theme.gray900 capStyle: ShapePath.FlatCap joinStyle: ShapePath.MiterJoin Behavior on fillColor { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } Behavior on strokeColor { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } PathSvg { @@ -118,7 +126,7 @@ Item { opacity: root.isExpanded ? 0.0 : 1.0 Behavior on opacity { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } // Counter-rotate by wheel rotation so icon stays strictly upright @@ -129,10 +137,10 @@ Item { anchors.centerIn: parent size: 22 name: root.iconName - color: (root.isFocused || root.isSelected) ? "black" : Theme.gray300 + color: (root.isFocused || root.isSelected) ? Theme.gray900 : Theme.gray300 Behavior on color { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } } diff --git a/shell/desktop/surfaces/radial/RadialSettings.qml b/shell/desktop/surfaces/radial/RadialSettings.qml index 6d5788a5..f9498cd9 100644 --- a/shell/desktop/surfaces/radial/RadialSettings.qml +++ b/shell/desktop/surfaces/radial/RadialSettings.qml @@ -57,14 +57,17 @@ FocusScope { property: "opacity" from: 1.0 to: 0.0 - duration: 130 + duration: Settings.reducedMotion ? 0 : 130 easing.type: Easing.OutCubic } // Expand Choreography: Wheel translates left first, branch deploys once wheel nears left edge Timer { id: expandTimer - interval: 180 + // Zero under reducedMotion: these three stagger the wheel + // rotation and the branch origin, so snapping them is what makes the + // expansion instant rather than merely fast. + interval: Settings.reducedMotion ? 0 : 180 repeat: false onTriggered: { if (root.isExpanded) { @@ -76,7 +79,7 @@ FocusScope { // Collapse Choreography: Branch retracts first (140ms), then wheel returns to center Timer { id: collapseTimerWheelReturn - interval: 140 + interval: Settings.reducedMotion ? 0 : 140 repeat: false onTriggered: { if (!root.isExpanded) { @@ -89,7 +92,7 @@ FocusScope { // Preview trees blossom back in as the wheel approaches screen center Timer { id: collapseTimerPreviewRestore - interval: 320 + interval: Settings.reducedMotion ? 0 : 320 repeat: false onTriggered: { if (!root.isExpanded) { @@ -135,12 +138,18 @@ FocusScope { // Background Scrim & Tactical Grid // ========================================================================= - Rectangle { - id: scrimBackdrop - anchors.fill: parent - color: Qt.rgba(13/255, 58/255, 143/255, 1.0) - opacity: 0.95 - } +Rectangle { + id: scrimBackdrop + anchors.fill: parent + // Its own slot, not palBase. The radial covers the whole screen while + // it is open and reads as a separate place, so it takes a + // palette-specific floor -- bronze under pine, deep teal under acid -- + // rather than sharing the desktop's page background. The grid below + // and the ContextPanel both sit on top of this and read from the same + // token, so the whole surface tints together. + color: Theme.palRadialBackdrop + opacity: 0.95 + } // Cybernetic Grid Background Canvas { @@ -153,7 +162,10 @@ FocusScope { ctx.clearRect(0, 0, width, height); var step = 48; - ctx.fillStyle = Qt.rgba(0, 0, 0, 0.15); + // Tinted from the backdrop, not from bg. Both are dark and the + // checkerboard is only 15% alpha, so using bg here would leave a + // faintly blue-grey checker on top of a bronze or teal floor. + ctx.fillStyle = Theme.withAlpha(Theme.palRadialBackdrop, 0.55); for (var y = 0; y < height; y += step) { for (var x = 0; x < width; x += step) { if (((x / step) + (y / step)) % 2 === 0) { @@ -283,6 +295,16 @@ FocusScope { isExpanded: root.branchExpanded wheelCenterX: wheelMenu.wheelCenterX wheelCenterY: wheelMenu.wheelCenterY + // SkillTree kept its own outerRadius of 210 while the wheel's ring is + // 180, so everything anchored to the rim started 30px outside it. + outerRadius: wheelMenu.outerRadius + // Where the expanded branch's base node sits: 350px right of the wheel's + // left anchor, which is where it was before the geometry work. + // + // I moved this to leftAnchorX + outerRadius + 11 while chasing the base + // wheel's short trunks, on the theory that the expanded tree should hug + // the ring too. It should not -- the expanded view has the whole canvas + // to itself, so its branch starts further out and runs longer. branchOriginX: wheelMenu.leftAnchorX + 350 branchOriginY: root.height / 2 z: 5 @@ -299,6 +321,17 @@ FocusScope { model: settingsModel focusedIndex: root.focusedCategoryIndex isExpanded: root.wheelExpanded + + // Negative on purpose. outerRadius is 180, so a centre at -70 leaves + // 110px of a 360px diameter on screen -- about a third of the circle, + // showing only the arc the branch grows out of. + // + // This was previously "fixed" to Math.max(220, width * 0.12) on the + // theory that the wheel hanging off the left edge was a bug. It is not: + // the expanded reference shows exactly that, one third of the arc, with + // the branch tree reading as if it grew out of the hub. Centring the + // whole wheel instead puts a large empty circle between the arc and the + // base node, so the tree stops looking attached to anything. leftAnchorX: -70 z: 10 @@ -310,11 +343,12 @@ FocusScope { } } - onCategoryHovered: function(idx) { - if (!root.isExpanded) { - root.focusedCategoryIndex = idx; - } - } + // onCategoryHovered is gone with the signal. It was connected here but + // never raised: RadialSegment declared hovered() and had no MouseArea to + // emit it from, so hovering a segment did nothing through this path. + // Hovering the wheel to change category is surfaceMouseArea's + // onPositionChanged -> handleMouseMove, which is what has always actually + // driven it. onCollapseRequested: { root.isExpanded = false; @@ -408,9 +442,22 @@ FocusScope { if (distFromCenter < wheelMenu.innerRadius) { // Clicked center hub -> expand root.isExpanded = true; - } else if (distFromCenter > 280) { - // Clicked far background -> dismiss - OverlayController.close(); + } else if (distFromCenter > wheelMenu.outerRadius + 100) { + // Outside the wheel entirely: ignore the click. + // + // This used to be `> 280` -> OverlayController.close(), so any + // click more than 280px from the wheel centre dismissed the + // whole surface. The wheel's outerRadius is only 180, which + // meant the dead zone swallowed most of a 1920px screen: a + // stray click on the wallpaper, the notch, or empty space + // tore down a menu the user had just opened. Nothing inside + // the wheel needs the dismiss -- the hub collapses, segments + // select, and Esc closes -- so the wide zone was pure loss. + // + // Distances past the rim are ignored rather than snapped to + // the nearest segment, because angle-only selection would + // make a click at the far corner of the screen jump the + // selection to whatever happened to be nearest. } else { // Clicked on a segment var angle = RadialGeometry.angleFromCenter(wheelMenu.wheelCenterX, wheelMenu.wheelCenterY, mouse.x, mouse.y); diff --git a/shell/desktop/surfaces/radial/RadialSettingsModel.qml b/shell/desktop/surfaces/radial/RadialSettingsModel.qml index 1bacfc48..c1780c6b 100644 --- a/shell/desktop/surfaces/radial/RadialSettingsModel.qml +++ b/shell/desktop/surfaces/radial/RadialSettingsModel.qml @@ -9,6 +9,31 @@ Item { // Reactive counter to trigger view refreshes when settings mutate property int revision: 0 + // ========================================================================= + // Palette options + // + // Display label -> persisted value, kept here rather than inline on the node + // so valueText() and execute() can both read them. A node object literal has + // no binding for itself, so an inline list would force these functions to + // reach through `this`, which works right up until it is destructured. + // + // The values are Settings.theme strings and must match Theme._registry's + // keys. Theme falls back loudly to ctos-pine on anything it does not + // recognise, so a typo here shows up as the picker disagreeing with the + // shell rather than as an error at the point of the mistake. + // ========================================================================= + readonly property var paletteOptions: [ + { label: "PINE", value: "ctos-pine" }, + { label: "ACID", value: "ctos-dark" } + ] + + function paletteLabel(value) { + for (var i = 0; i < root.paletteOptions.length; ++i) { + if (root.paletteOptions[i].value === value) return root.paletteOptions[i].label; + } + return String(value); + } + Connections { target: Settings function onSettingsSaved(): void { @@ -39,7 +64,79 @@ Item { // 5: INPUT (135 deg) // 6: POWER (180 deg) // 7: SECURITY (-135 deg) - readonly property var categories: [ + // Lock state is computed, never authored. + // + // Every node used to carry `locked` and `lockReason` by hand: 27 of the 31 + // said false and "", and the four that mattered each repeated a service check + // that their own parent already made -- audio-mic-mute declared the same + // `!AudioService.micAvailable` as audio-mic, so the two had to be kept in step + // by hand and nothing enforced that. Worse, nothing derived a child's state + // from its parent, so a node whose parent was unavailable could still present + // itself as usable. + // + // Now a node declares only when *it* is unavailable, and everything else + // follows: a node is locked if it or any ancestor above it reports a reason, + // and the reason shown is the nearest one up. Ancestry comes from + // RadialTopology, so the tree and the lock state cannot disagree. + // + // Evaluated as a binding rather than on demand, so the reads inside + // unavailable() are tracked: starting awww un-locks the wallpaper subtree + // without anything asking this to re-run. + readonly property var categories: _deriveLockState(rawCategories) + + function _deriveLockState(cats: var): var { + var out = []; + if (!cats) return out; + + for (var i = 0; i < cats.length; ++i) { + var cat = cats[i]; + if (!cat || !cat.nodes) { out.push(cat); continue; } + + // Per-node reason first, so lookup below is one pass. + var reason = {}; + for (var n = 0; n < cat.nodes.length; ++n) { + var nd = cat.nodes[n]; + var why = ""; + if (typeof nd.unavailable === "function") { + try { why = nd.unavailable() || ""; } catch (e) { why = ""; } + } + reason[nd.id] = why; + } + + var nodes = []; + for (var m = 0; m < cat.nodes.length; ++m) { + var node = cat.nodes[m]; + var locked = false; + var why2 = reason[node.id] || ""; + + if (why2) { + locked = true; + } else { + var ancestors = RadialTopology.ancestorsOf(cat.id, node.id); + for (var a = 0; a < ancestors.length; ++a) { + if (reason[ancestors[a]]) { + locked = true; + why2 = reason[ancestors[a]]; + break; + } + } + } + + // Shallow copy: the node keeps its own fields and closures, and + // gains the two values that used to be typed out by hand. + var copy = {}; + for (var key in node) copy[key] = node[key]; + copy.locked = locked; + copy.lockReason = locked ? why2 : ""; + nodes.push(copy); + } + + out.push(Object.assign({}, cat, { nodes: nodes })); + } + return out; + } + + readonly property var rawCategories: [ // ===================================================================== // 0. SYSTEM // ===================================================================== @@ -56,11 +153,6 @@ Item { subtitle: "SYS.01 // SCHEDULER", icon: "cpu", description: "ctOS Linux kernel telemetry subsystem and process scheduler core.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 98, y: -25 }, - edges: ["sys-cpu-hex", "sys-ram-bar"], controlType: "readonly", value: function() { return SystemMonitorService.available ? 1 : 0; }, valueText: function() { return SystemMonitorService.available ? "ONLINE" : "OFFLINE"; }, @@ -72,11 +164,6 @@ Item { subtitle: "SYS.02 // CORES", icon: "memory", description: "Per-core CPU utilization hex matrix in the ambient desktop status bar.", - locked: false, - lockReason: "", - requires: ["sys-core"], - pos: { x: 238, y: -130 }, - edges: ["sys-profiler"], controlType: "toggle", value: function() { return Settings.widgetCpuHexGridVisible; }, valueText: function() { return Settings.widgetCpuHexGridVisible ? "ENABLED" : "DISABLED"; }, @@ -92,11 +179,6 @@ Item { subtitle: "SYS.03 // MEMORY", icon: "storage", description: "Physical memory and swap page allocation telemetry bar widget.", - locked: false, - lockReason: "", - requires: ["sys-core"], - pos: { x: 260, y: 97 }, - edges: [], controlType: "toggle", value: function() { return Settings.widgetRamBlockBarVisible; }, valueText: function() { return Settings.widgetRamBlockBarVisible ? "ENABLED" : "DISABLED"; }, @@ -112,11 +194,6 @@ Item { subtitle: "SYS.04 // THREADS", icon: "terminal", description: "Active system process profiler and execution latency inspector.", - locked: false, - lockReason: "", - requires: ["sys-cpu-hex"], - pos: { x: 430, y: -61 }, - edges: [], controlType: "toggle", value: function() { return Settings.widgetTargetProfilerVisible; }, valueText: function() { return Settings.widgetTargetProfilerVisible ? "ENABLED" : "DISABLED"; }, @@ -135,9 +212,9 @@ Item { { id: "appearance", name: "APPEARANCE", - subtitle: "VISUAL PIPELINE // MOTION", + subtitle: "VISUAL PIPELINE // BACKDROP", icon: "palette", - description: "Compositor styling engine, reduced motion dampening, and panel geometry dimensions.", + description: "Compositor styling engine, palette, reduced motion dampening, panel geometry, and desktop backdrop.", nodes: [ { id: "app-engine", @@ -145,11 +222,6 @@ Item { subtitle: "APP.01 // THEME", icon: "palette", description: "ctOS visual styling pipeline and industrial cyberpunk theme framework.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 76, y: 33 }, - edges: ["app-motion", "app-bar-height"], controlType: "readonly", value: function() { return 1; }, valueText: function() { return Settings.theme.toUpperCase(); }, @@ -161,18 +233,55 @@ Item { subtitle: "APP.02 // KINETICS", icon: "timer", description: "High-speed cybernetic UI animations, transitions, and kinetic physics.", - locked: false, - lockReason: "", - requires: ["app-engine"], - pos: { x: 214, y: -132 }, - edges: ["app-palette"], controlType: "toggle", + // Was `!Settings.reducedMotion`, so the switch read ON when + // motion was switched OFF. execute() flipped the setting the + // right way round, which meant the control worked and its + // readout was the inverse of the thing it controlled -- the + // toggle showed enabled while animations were damped to + // nothing. valueText below already reported the setting + // honestly, so the two disagreed on screen simultaneously. value: function() { return !Settings.reducedMotion; }, valueText: function() { return Settings.reducedMotion ? "REDUCED" : "FULL FX"; }, execute: function() { Settings.reducedMotion = !Settings.reducedMotion; Settings.save(); root.revision++; + console.log("[RadialSettingsModel] MOTION_SET: " + + (Settings.reducedMotion ? "reduced" : "full")); + } + }, + { + id: "app-wallpaper", + title: "WALLPAPER", + subtitle: "APP.05 // BACKDROP", + icon: "layers", + description: WallpaperService.available + ? "Desktop backdrop. Fades over one second." + : "Desktop backdrop. The awww daemon is not answering, so switching is unavailable.", + unavailable: function() { + return WallpaperService.available ? "" : "The awww wallpaper daemon is not running."; + }, + controlType: "browser", + value: function() { return Settings.wallpaper; }, + valueText: function() { return Settings.wallpaper; }, + execute: function(name) { + if (typeof name !== "string" || name.length === 0) return; + + // Persist regardless of whether the apply succeeded. The + // selection is what the user asked for; if the daemon is + // briefly down the service retries on its own, and + // refusing to record the choice would make the browser + // snap back under them. + Settings.wallpaper = name; + Settings.save(); + root.revision++; + + if (!WallpaperService.apply(name)) { + console.warn("[RadialSettingsModel] wallpaper not applied: " + name); + } else { + console.log("[RadialSettingsModel] WALLPAPER_SET: " + name); + } } }, { @@ -181,11 +290,6 @@ Item { subtitle: "APP.03 // GEOMETRY", icon: "layers", description: "Vertical pixel thickness of the top ambient system telemetry bar.", - locked: false, - lockReason: "", - requires: ["app-engine"], - pos: { x: 195, y: 89 }, - edges: [], controlType: "slider", minVal: 28, maxVal: 48, @@ -202,21 +306,40 @@ Item { root.revision++; } }, - { + { id: "app-palette", - title: "ACID ACCENT", + title: "COLORWAY", subtitle: "APP.04 // COLORWAY", - icon: "brightness", - description: "Primary phosphor luminescence wavelength (Acid Green).", - locked: false, - lockReason: "", - requires: ["app-motion"], - pos: { x: 387, y: -130 }, - edges: [], - controlType: "readonly", - value: function() { return 1; }, - valueText: function() { return "ACID GREEN"; }, - execute: function() {} + icon: "palette", + description: "Shell palette. Repaints every surface immediately; no restart.", + controlType: "picker", + options: root.paletteOptions, + value: function() { return Settings.theme; }, + valueText: function() { return root.paletteLabel(Settings.theme); }, + execute: function(val) { + // No argument means "advance", which is what ENTER reaches + // through ContextPanel.executeCurrentNode(). A picker cannot + // be arrow-driven -- NavigationController already spends + // Left/Right on moving between nodes -- so cycling on ENTER + // and selecting by click are the two paths. + var opts = root.paletteOptions; + var target = val; + if (typeof target !== "string" || target.length === 0) { + var idx = 0; + for (var k = 0; k < opts.length; ++k) { + if (opts[k].value === Settings.theme) { idx = k; break; } + } + target = opts[(idx + 1) % opts.length].value; + } + if (target === Settings.theme) return; + Settings.theme = target; + Settings.save(); + // Settings writes the file asynchronously; bump the model + // revision as well so the chips restyle on the same frame + // as the Theme repaint rather than a beat later. + root.revision++; + console.log("[RadialSettingsModel] THEME_SET: " + target); + } } ] }, @@ -237,11 +360,6 @@ Item { subtitle: "DT.01 // SERVER", icon: "layers", description: "Native Wayland compositor session (Hyprland / Niri protocols).", - locked: false, - lockReason: "", - requires: [], - pos: { x: 59, y: -28 }, - edges: ["dt-command-deck", "dt-system-rail"], controlType: "readonly", value: function() { return 1; }, valueText: function() { return SessionService.compositorName.toUpperCase(); }, @@ -253,11 +371,6 @@ Item { subtitle: "DT.02 // LAUNCHER", icon: "terminal", description: "Fuzzy search launcher and system action execution console.", - locked: false, - lockReason: "", - requires: ["dt-compositor"], - pos: { x: 238, y: -105 }, - edges: ["dt-notifications"], controlType: "toggle", value: function() { return Settings.featuresCommandDeck; }, valueText: function() { return Settings.featuresCommandDeck ? "ENABLED" : "DISABLED"; }, @@ -268,21 +381,16 @@ Item { } }, { - id: "dt-system-rail", - title: "SYSTEM RAIL", - subtitle: "DT.03 // SIDE HUB", + id: "dt-command-center", + title: "COMMAND CENTER", + subtitle: "DT.03 // CCC", icon: "power", - description: "Collapsible side panel with hardware controls and audio routing.", - locked: false, - lockReason: "", - requires: ["dt-compositor"], - pos: { x: 248, y: 106 }, - edges: [], + description: "Adaptive Command & Control Center: hardware controls and audio routing.", controlType: "toggle", - value: function() { return Settings.featuresSystemRail; }, - valueText: function() { return Settings.featuresSystemRail ? "ENABLED" : "DISABLED"; }, + value: function() { return Settings.featuresCommandCenter; }, + valueText: function() { return Settings.featuresCommandCenter ? "ENABLED" : "DISABLED"; }, execute: function() { - Settings.featuresSystemRail = !Settings.featuresSystemRail; + Settings.featuresCommandCenter = !Settings.featuresCommandCenter; Settings.save(); root.revision++; } @@ -293,11 +401,6 @@ Item { subtitle: "DT.04 // NOTIFIER", icon: "warning", description: "Tactical notification alert toasts and desktop signal daemon.", - locked: false, - lockReason: "", - requires: ["dt-command-deck"], - pos: { x: 370, y: -93 }, - edges: [], controlType: "toggle", value: function() { return Settings.featuresNotifications; }, valueText: function() { return Settings.featuresNotifications ? "ENABLED" : "DISABLED"; }, @@ -325,15 +428,18 @@ Item { title: "LINK ADAPTER", subtitle: "NET.01 // INTERFACE", icon: "wifi", + // A transport state, not the network's name. It used to return + // NetworkService.networkName outright, which on wifi is the SSID + // and on a wired link is the NetworkManager profile name -- so one + // field meant "which network" on one transport and "which profile" + // on the other, under a node titled LINK ADAPTER. description: "Primary network interface connection and transport controller.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 75, y: -14 }, - edges: ["net-flow", "net-tracer"], controlType: "readonly", value: function() { return NetworkService.isConnected ? 1 : 0; }, - valueText: function() { return NetworkService.isConnected ? NetworkService.networkName : "OFFLINE"; }, + valueText: function() { + if (!NetworkService.isConnected) return "OFFLINE"; + return NetworkService.connectionType === "ethernet" ? "WIRED" : NetworkService.networkName; + }, execute: function() {} }, { @@ -342,11 +448,6 @@ Item { subtitle: "NET.02 // BANDWIDTH", icon: "storage", description: "Real-time bandwidth throughput matrix in ambient status bar.", - locked: false, - lockReason: "", - requires: ["net-core"], - pos: { x: 224, y: -131 }, - edges: ["net-sniffer"], controlType: "toggle", value: function() { return Settings.widgetNetworkFlowVisible; }, valueText: function() { return Settings.widgetNetworkFlowVisible ? "ENABLED" : "DISABLED"; }, @@ -362,11 +463,6 @@ Item { subtitle: "NET.03 // LATENCY", icon: "bolt", description: "Hop latency tracer and subnet target ping telemetry widget.", - locked: false, - lockReason: "", - requires: ["net-core"], - pos: { x: 267, y: 81 }, - edges: [], controlType: "toggle", value: function() { return Settings.widgetNetworkTracerVisible; }, valueText: function() { return Settings.widgetNetworkTracerVisible ? "ENABLED" : "DISABLED"; }, @@ -377,20 +473,25 @@ Item { } }, { - id: "net-sniffer", - title: "PACKET SNIFFER", - subtitle: "NET.04 // SURVEILLANCE", - icon: "policy", - description: "Deep packet inspection and socket surveillance matrix.", - locked: true, - lockReason: "Requires raw CAP_NET_RAW / promiscuous socket privileges.", - requires: ["net-flow"], - pos: { x: 418, y: -109 }, - edges: [], - controlType: "readonly", - value: function() { return 0; }, - valueText: function() { return "RESTRICTED"; }, - execute: function() {} + // Was "PACKET SNIFFER": locked behind an invented CAP_NET_RAW + // privilege reason with a RESTRICTED readout, promising a packet + // inspector that does not exist. Replaced with the one network + // capability the shell can actually drive. + id: "net-wifi-radio", + title: "WI-FI RADIO", + subtitle: "NET.04 // RADIO", + icon: "wifi", + description: "Wi-Fi subsystem radio. Turning it off drops the current association.", + unavailable: function() { + return NetworkService.available ? "" : "NetworkManager is not available."; + }, + controlType: "toggle", + value: function() { return NetworkService.wifiEnabled; }, + valueText: function() { return NetworkService.wifiEnabled ? "ENABLED" : "DISABLED"; }, + execute: function() { + NetworkService.setWifiEnabled(!NetworkService.wifiEnabled); + root.revision++; + } } ] }, @@ -411,11 +512,6 @@ Item { subtitle: "AUD.01 // PIPEWIRE", icon: "volume", description: "PipeWire master output stream sink: " + (AudioService.sinkName || "Default"), - locked: false, - lockReason: "", - requires: [], - pos: { x: 50, y: 19 }, - edges: ["audio-mute", "audio-spectrum"], controlType: "slider", minVal: 0, maxVal: 100, @@ -437,11 +533,6 @@ Item { subtitle: "AUD.02 // ATTENUATION", icon: "volume-mute", description: "Master output hardware gate toggle.", - locked: false, - lockReason: "", - requires: ["audio-master"], - pos: { x: 238, y: -106 }, - edges: ["audio-dsp"], controlType: "toggle", value: function() { return !AudioService.muted; }, valueText: function() { return AudioService.muted ? "MUTED" : "ACTIVE"; }, @@ -456,11 +547,6 @@ Item { subtitle: "AUD.03 // FFT BARS", icon: "tune", description: "Real-time FFT audio surveillance frequency visualization widget.", - locked: false, - lockReason: "", - requires: ["audio-master"], - pos: { x: 261, y: 88 }, - edges: [], controlType: "toggle", value: function() { return Settings.widgetAudioSurveillanceVisible; }, valueText: function() { return Settings.widgetAudioSurveillanceVisible ? "ENABLED" : "DISABLED"; }, @@ -471,20 +557,59 @@ Item { } }, { - id: "audio-dsp", - title: "DSP SPATIALIZER", - subtitle: "AUD.04 // FILTER", - icon: "memory", - description: "Hardware acoustic spatializer and real-time noise cancellation matrix.", - locked: true, - lockReason: "DSP kernel pipeline locked by audio server driver.", - requires: ["audio-mute"], - pos: { x: 391, y: -133 }, - edges: [], - controlType: "readonly", - value: function() { return 0; }, - valueText: function() { return "RESTRICTED"; }, - execute: function() {} + // Was "DSP SPATIALIZER": locked behind an invented kernel-driver + // reason with a RESTRICTED readout, for a filter that does not + // exist. Replaced with capture control, which the shell can drive + // and which the CCC is being stripped of. + id: "audio-mic", + title: "MIC VOLUME", + subtitle: "AUD.04 // CAPTURE", + icon: "microphone", + description: AudioService.micAvailable + ? "Capture gain for the default input device." + : "No input device is available.", + unavailable: function() { + return AudioService.micAvailable ? "" : "No input device is available."; + }, + controlType: "slider", + // The slider contract is minVal/maxVal/step -- those are the + // names ContextPanel reads (and the only ones it reads). This + // node used minValue/maxValue/stepSize, which nothing reads, + // so it silently inherited the defaults minVal 0 / maxVal 100 + // / step 1. Against a 0..1 gain that made the track render at + // micVolume/100 -- empty even at full gain -- and turned a + // click anywhere past 1% of the track into setMicVolume(100), + // which clamps to 1.0. Off or full, nothing between, and the + // step buttons had the same two outcomes. + value: function() { return AudioService.micVolume; }, + minVal: 0.0, + maxVal: 1.0, + step: 0.02, + valueText: function() { return Math.round(AudioService.micVolume * 100) + "%"; }, + execute: function(value) { + if (typeof value === "number") { + AudioService.setMicVolume(value); + // The track width reads model.revision to establish its + // dependency, because value() is called imperatively + // inside that binding and QML cannot track through a + // function call. Without this the bar does not move. + root.revision++; + } + } + }, + { + id: "audio-mic-mute", + title: "MIC MUTE", + subtitle: "AUD.05 // CAPTURE", + icon: "microphone-slash", + description: "Mute the default input device without changing its level.", + controlType: "toggle", + value: function() { return !AudioService.micMuted; }, + valueText: function() { return AudioService.micMuted ? "MUTED" : "LIVE"; }, + execute: function() { + AudioService.toggleMicMute(); + root.revision++; + } } ] }, @@ -497,7 +622,7 @@ Item { name: "INPUT", subtitle: "LIBINPUT // HID DEVICES", icon: "keyboard", - description: "Pointer acceleration, keyboard repeat rate, and multi-finger gestures.", + description: "Pointer acceleration profile and input device sensing.", nodes: [ { id: "in-engine", @@ -505,11 +630,6 @@ Item { subtitle: "INP.01 // LIBINPUT", icon: "keyboard", description: "Kernel evdev abstraction and libinput driver layer.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 59, y: -36 }, - edges: ["in-pointer", "in-repeat"], controlType: "readonly", value: function() { return 1; }, valueText: function() { return "ACTIVE"; }, @@ -521,11 +641,6 @@ Item { subtitle: "INP.02 // CURSOR", icon: "mouse", description: "Pointer acceleration profile and sensitivity curves.", - locked: false, - lockReason: "", - requires: ["in-engine"], - pos: { x: 230, y: -89 }, - edges: ["in-gestures"], controlType: "action", actionLabel: "CYCLE PROFILE", value: function() { return 1; }, @@ -534,38 +649,7 @@ Item { root.revision++; } }, - { - id: "in-repeat", - title: "KEY REPEAT", - subtitle: "INP.03 // TYPEMATIC", - icon: "timer", - description: "Keyboard repeat delay and typematic strike frequency.", - locked: false, - lockReason: "", - requires: ["in-engine"], - pos: { x: 224, y: 68 }, - edges: [], - controlType: "readonly", - value: function() { return 1; }, - valueText: function() { return "25ms / 600ms"; }, - execute: function() {} - }, - { - id: "in-gestures", - title: "TOUCH GESTURES", - subtitle: "INP.04 // PRECISION", - icon: "layers", - description: "Multi-touch workspace navigation and boundary swipe recognition.", - locked: true, - lockReason: "No supported precision touchpad hardware detected.", - requires: ["in-pointer"], - pos: { x: 377, y: -68 }, - edges: [], - controlType: "readonly", - value: function() { return 0; }, - valueText: function() { return "RESTRICTED"; }, - execute: function() {} - } + ] }, @@ -585,11 +669,6 @@ Item { subtitle: "PWR.01 // UPOWER", icon: "bolt", description: "UPower daemon connection and system power supply status.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 70, y: -13 }, - edges: ["pwr-supply", "pwr-sleep"], controlType: "readonly", value: function() { return PowerService.available ? 1 : 0; }, valueText: function() { return PowerService.stateText; }, @@ -601,48 +680,12 @@ Item { subtitle: "PWR.02 // CHARGE", icon: "battery", description: "Lithium-ion energy storage cell state and charge level.", - locked: false, - lockReason: "", - requires: ["pwr-governor"], - pos: { x: 253, y: -90 }, - edges: ["pwr-threshold"], controlType: "readonly", value: function() { return PowerService.isBatteryPresent ? PowerService.percentage : 100; }, valueText: function() { return PowerService.isBatteryPresent ? Math.round(PowerService.percentage) + "%" : "AC MAINS"; }, execute: function() {} }, - { - id: "pwr-sleep", - title: "IDLE BLANKING", - subtitle: "PWR.03 // DPMS", - icon: "timer", - description: "Display DPMS timeout and screen power conservation.", - locked: false, - lockReason: "", - requires: ["pwr-governor"], - pos: { x: 248, y: 78 }, - edges: [], - controlType: "readonly", - value: function() { return 1; }, - valueText: function() { return "300 SEC"; }, - execute: function() {} - }, - { - id: "pwr-threshold", - title: "CHARGE THRESHOLD", - subtitle: "PWR.04 // CONSERVATION", - icon: "battery-charging", - description: "Firmware-level 80% battery longevity threshold limit.", - locked: true, - lockReason: "Requires ACPI battery charge threshold driver support.", - requires: ["pwr-supply"], - pos: { x: 383, y: -123 }, - edges: [], - controlType: "readonly", - value: function() { return 0; }, - valueText: function() { return "RESTRICTED"; }, - execute: function() {} - } + ] }, @@ -662,11 +705,6 @@ Item { subtitle: "SEC.01 // PAM", icon: "verified-user", description: "ctOS session security supervisor and authentication guard.", - locked: false, - lockReason: "", - requires: [], - pos: { x: 61, y: 31 }, - edges: ["sec-lock", "sec-privacy"], controlType: "readonly", value: function() { return 1; }, valueText: function() { return "SECURE"; }, @@ -678,11 +716,6 @@ Item { subtitle: "SEC.02 // DISPLAY", icon: "lock", description: "Immediately engage loginctl lock screen overlay.", - locked: false, - lockReason: "", - requires: ["sec-subsystem"], - pos: { x: 258, y: -107 }, - edges: ["sec-vault"], controlType: "action", actionLabel: "LOCK SESSION", value: function() { return 1; }, @@ -692,17 +725,55 @@ Item { root.revision++; } }, +{ + id: "sec-toolkit", + title: "TOOLKIT", + subtitle: "SEC.04 // TOOLKIT", + icon: "terminal", + description: ToolkitService.scanned + ? "Security and diagnostic tooling present in the Nix profile." + : "Scanning the Nix profile for installed tooling.", + controlType: "readonly", + value: function() { return ToolkitService.installedCount; }, + valueText: function() { + if (!ToolkitService.scanned) return "SCANNING"; + if (ToolkitService.installedCount === 0) return "NOT INSTALLED"; + return ToolkitService.installedCount + " / " + ToolkitService.totalCount + " TOOLS"; + }, + execute: function() { + ToolkitService.rescan(); + root.revision++; + } + }, + + // One node per tool group, generated rather than hand-written so + // the group list lives in exactly one place. These are readouts, + // not switches: Nix decides what is installed, so a toggle here + // would be a control that cannot do what it says. + ...ToolkitService.groups.map(function (g) { + return { + id: "sec-tk-" + g.id, + title: g.label, + subtitle: "TK // " + g.id.toUpperCase(), + icon: g.icon, + description: g.tools.join(", "), + controlType: "readonly", + value: function() { return ToolkitService.groupInstalled(g.id); }, + valueText: function() { + if (!ToolkitService.scanned) return "SCANNING"; + const n = ToolkitService.groupInstalled(g.id); + return n + " / " + g.tools.length; + }, + execute: function() {} + }; + }), + { id: "sec-privacy", title: "ALERT PRIVACY", subtitle: "SEC.03 // COOLDOWN", icon: "visibility-off", description: "Enforce anti-spam cooldown intervals on notification popups.", - locked: false, - lockReason: "", - requires: ["sec-subsystem"], - pos: { x: 264, y: 114 }, - edges: [], controlType: "toggle", value: function() { return Settings.notificationCooldownSeconds > 0; }, valueText: function() { return Settings.notificationCooldownSeconds > 0 ? "ENABLED" : "DISABLED"; }, @@ -712,21 +783,7 @@ Item { root.revision++; } }, - { - id: "sec-vault", - title: "ENCRYPTED VAULT", - subtitle: "SEC.04 // LUKS-HSM", - icon: "vpn-key", - description: "Hardware security module and encrypted credentials vault.", - locked: true, - lockReason: "Requires LUKS hardware token or biometric key-ring authorization.", - requires: ["sec-lock"], - pos: { x: 424, y: -89 }, - edges: [], - controlType: "readonly", - value: function() { return 0; }, - valueText: function() { return "RESTRICTED"; }, - } + ] } // END OF CATEGORIES diff --git a/shell/desktop/surfaces/radial/RadialTopology.qml b/shell/desktop/surfaces/radial/RadialTopology.qml new file mode 100644 index 00000000..8b72e556 --- /dev/null +++ b/shell/desktop/surfaces/radial/RadialTopology.qml @@ -0,0 +1,193 @@ +pragma Singleton + +import QtQuick +import "../../services" + +// Topology of the settings tree: what depends on what, and nothing else. +// +// This file is the single source of the tree's *shape*. Both renderings -- the +// expanded branch and the wheel's preview -- lay out from this, and neither +// stores a position. That ordering is the point: when the preview carried its +// own geometry and the model carried hand-placed `pos` values, the two views +// disagreed about a category's shape, because nothing tied them together. +// +// Nesting rather than a flat per-node `requires`: every node here has exactly +// one parent (verified across all eight categories), which is what makes this a +// tree. A flat list would encode the same graph while making you scan for a +// node's parent instead of reading its children in place. +// +// This is QML rather than JSON because `sec-toolkit`'s children are computed at +// runtime from ToolkitService, not listed. +QtObject { + readonly property var categories: [ + { + id: "system", + tree: [ + { + id: "sys-core", + children: [ + { id: "sys-cpu-hex", children: [ { id: "sys-profiler" } ] }, + { id: "sys-ram-bar" } + ] + } + ] + }, + { + id: "appearance", + tree: [ + { + // Three branches off one base, which is the shape the + // three-branch reference shows. The other categories have two, + // so this is the only tree that fans three ways. + id: "app-engine", + children: [ + { id: "app-motion", children: [ { id: "app-palette" } ] }, + { id: "app-bar-height" }, + { id: "app-wallpaper" } + ] + } + ] + }, + { + id: "desktop", + tree: [ + { + id: "dt-compositor", + children: [ + { id: "dt-command-deck", children: [ { id: "dt-notifications" } ] }, + { id: "dt-command-center" } + ] + } + ] + }, + { + id: "network", + tree: [ + { + id: "net-core", + children: [ + { id: "net-flow", children: [ { id: "net-wifi-radio" } ] }, + { id: "net-tracer" } + ] + } + ] + }, + { + id: "audio", + tree: [ + { + // Two branches, one of which is a three-deep chain. The chain is + // what the depth flattening exists for: without it the three + // nodes sit on one ray and read as a single column rather than + // as a spoke leaving the base. + id: "audio-master", + children: [ + { + id: "audio-mute", + children: [ + { id: "audio-mic", children: [ { id: "audio-mic-mute" } ] } + ] + }, + { id: "audio-spectrum" } + ] + } + ] + }, + { + id: "input", + tree: [ + { id: "in-engine", children: [ { id: "in-pointer" } ] } + ] + }, + { + id: "power", + tree: [ + { id: "pwr-governor", children: [ { id: "pwr-supply" } ] } + ] + }, + { + id: "security", + tree: [ + { + id: "sec-subsystem", + children: [ + { + id: "sec-lock", + children: [ + { + // Toolkit groups arrive at runtime, so this node's + // children are computed rather than listed. They + // used to be synthesized as node objects with a + // hand-placed `x: 40 + i * 122` row and no edge + // back here, which drew them as an unconnected + // strip in the expanded view. Declaring them as + // children is what gives them relations. + id: "sec-toolkit", + children: ToolkitService.groups.map(function (g) { + return { id: "sec-tk-" + g.id }; + }) + } + ] + }, + { id: "sec-privacy" } + ] + } + ] + } + ] + + // Roots of a category's tree, or [] if the id is unknown. + function treeFor(categoryId: string): var { + if (!categoryId) return []; + for (var i = 0; i < categories.length; ++i) + if (categories[i].id === categoryId) + return categories[i].tree ? categories[i].tree : []; + return []; + } + + // Every node id a category declares, nested children included. + function idsFor(categoryId: string): var { + var out = []; + var walk = function (nodes) { + if (!nodes) return; + for (var i = 0; i < nodes.length; ++i) { + out.push(nodes[i].id); + walk(nodes[i].children); + } + }; + walk(treeFor(categoryId)); + return out; + } + + // The id directly above `nodeId`, or "" for a category root. + function parentOf(categoryId: string, nodeId: string): string { + var found = ""; + var walk = function (nodes) { + if (!nodes || found) return; + for (var i = 0; i < nodes.length; ++i) { + var kids = nodes[i].children; + if (!kids) continue; + for (var j = 0; j < kids.length; ++j) + if (kids[j].id === nodeId) { found = nodes[i].id; return; } + walk(kids); + } + }; + walk(treeFor(categoryId)); + return found; + } + + // Ids above `nodeId`, nearest first. Empty for a root. + function ancestorsOf(categoryId: string, nodeId: string): var { + var chain = []; + var cursor = parentOf(categoryId, nodeId); + // Bounded because this walks a hand-edited file; a cycle introduced by a + // typo should stop here rather than hang the shell. + var guard = 0; + while (cursor && guard < 64) { + chain.push(cursor); + cursor = parentOf(categoryId, cursor); + ++guard; + } + return chain; + } +} diff --git a/shell/desktop/surfaces/radial/SkillEdge.qml b/shell/desktop/surfaces/radial/SkillEdge.qml index 64157fa5..a1b80430 100644 --- a/shell/desktop/surfaces/radial/SkillEdge.qml +++ b/shell/desktop/surfaces/radial/SkillEdge.qml @@ -50,7 +50,7 @@ Item { ShapePath { strokeWidth: root.isPreview ? 2.0 : 3.0 - strokeColor: "black" + strokeColor: Theme.gray900 fillColor: "transparent" capStyle: ShapePath.RoundCap @@ -71,7 +71,7 @@ Item { property: "progress" from: 0.0 to: 1.0 - duration: root.pulseDuration + duration: Settings.reducedMotion ? 0 : root.pulseDuration easing.type: Easing.InOutQuad } diff --git a/shell/desktop/surfaces/radial/SkillNode.qml b/shell/desktop/surfaces/radial/SkillNode.qml index cf9fe9cf..28397ce1 100644 --- a/shell/desktop/surfaces/radial/SkillNode.qml +++ b/shell/desktop/surfaces/radial/SkillNode.qml @@ -21,12 +21,15 @@ Item { property bool isHovered: false property bool isActive: false + // Preview nodes sit at 30 against 42 interactive. The reference wheel draws + // proportionally smaller dots, but matching that ratio cost legibility on + // every icon in the tree, so the shipped size stayed. property real nodeSize: (!isPreview && (isSelected || isHovered)) ? 60 : (isPreview ? 30 : 42) property real screenX: x + width / 2 property real screenY: y + height / 2 Behavior on nodeSize { - NumberAnimation { duration: Theme.durationFast } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } width: nodeSize @@ -67,14 +70,18 @@ Item { ParallelAnimation { id: pulseAnim - running: root.shouldPulse + // Gated on running, not just on duration. This loop is infinite, so a + // zero duration would not make it still -- it would make it spin as + // fast as the frame clock, which is the opposite of reduced motion and + // costs a wakeup per frame. It has to not run at all. + running: root.shouldPulse && !Settings.reducedMotion loops: Animation.Infinite NumberAnimation { target: outerRing property: "scale" from: 1.0 to: 1.8 - duration: 1000 + duration: Settings.reducedMotion ? 0 : 1000 easing.type: Easing.OutCubic } NumberAnimation { @@ -82,7 +89,7 @@ Item { property: "opacity" from: 0.8 to: 0.0 - duration: 1000 + duration: Settings.reducedMotion ? 0 : 1000 easing.type: Easing.OutCubic } } @@ -94,42 +101,101 @@ Item { id: nodeDisc anchors.fill: parent radius: Theme.radiusPill - color: (!root.isPreview || root.isSelected || root.isHovered) ? "black" : Theme.gray900 - border.width: (!root.isPreview) ? 0 : ((root.isSelected || root.isHovered) ? 2 : 1) - border.color: root.locked ? Theme.warningRed : ((root.isSelected || root.isHovered) ? "white" : (root.isPreview ? Theme.gray800 : Theme.gray700)) + // Was `(... ? "black" : Theme.gray900)`. + // + // The "black" branch cannot become gray900: gray900 *is* the page + // background, so every branch node disc went the same colour as the + // scrim behind it and vanished. "black" had been standing in for + // "something darker than the page", and surface is the palette's answer + // to that -- one step up from bg rather than a literal that only ever + // worked on a near-black palette. + // + // The preview branch keeps gray900, which is intentional: an unselected + // preview node is meant to recede into the grid. + // Base-tree discs are Theme.palBase -- the palette's darkest slot -- so the whole + // wheel reads as flat near-black dots and a section's glyphs are the only + // thing that lights up when it is focused. That is what the reference + // draws, and it is why its preview nodes look like featureless dots: the + // glyph is present, painted the same colour as the disc, and invisible + // until that section is highlighted. + // + // Theme.palBase rather than a literal black: Phase 1 removed the hardcoded + // "black" from this file, and neither palette has a #000 slot. bg is the + // darkest each one gets (#13111E pine, #0E0E0E acid) and sits below + // radialBackdrop in both, so the discs still read against the backdrop. + // + // Preview discs deliberately keep a constant 1px border. Highlighting one + // used to take the border to 2px in text colour, which put a lit ring + // around every node of the focused section -- the reference highlights + // the glyphs only, and leaves the rings alone. + color: root.isPreview + ? (root.locked ? Theme.destructive : Theme.palBase) + : Theme.surface + border.width: root.isPreview ? 1 : 0 + border.color: root.isPreview + ? (root.locked ? Theme.destructive : Theme.gray800) + : (root.locked ? Theme.warningRed : Theme.gray700) Behavior on border.color { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } Behavior on color { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } // Center Node Icon CtosIcon { id: nodeIcon anchors.centerIn: parent + +// Base-tree glyphs sit at 24 inside a 30px disc, which is the size this + // repo shipped and the smallest that keeps every icon legible at a + // glance. They were briefly dropped to 13 to chase the reference + // wheel's dot-to-ring ratio, and the icons became unreadable -- + // matching a proportion is not worth that. + // + // Once expanded, glyphs grow with the node so selecting a branch + // enlarges its art without a second set. size: root.isPreview ? 24 : Math.max(20, root.nodeSize * 0.44) name: root.locked ? "lock" : root.iconName - color: root.locked ? Theme.warningRed : ((!root.isPreview || root.isSelected || root.isHovered) ? "white" : (root.isPreview ? Theme.gray800 : Theme.gray500)) - visible: !root.isPreview || root.isSelected + + // In the base tree the glyph is its own disc's colour, so it is + // invisible until that section is highlighted -- and then it goes to + // full text, which is the reference's "highlighted turns white" + // behaviour. Locked nodes keep the destructive colour in both states, + // because a lock you cannot see is not information. + color: root.isPreview + ? (root.locked + ? Theme.destructive + : ((root.isSelected || root.isHovered) ? Theme.textPrimary : Theme.palBase)) + : (root.locked + ? Theme.destructive + : ((root.isSelected || root.isHovered) + ? Theme.textPrimary + : Theme.gray500)) Behavior on color { - ColorAnimation { duration: Theme.durationFast } + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } } } - // Active State Indicator Dot + // State blip: green when the setting is doing its thing, red when it is + // not. It used to be accent-coloured and visible only while active, so + // "off" had no marker at all -- the reference shows both states. Rectangle { id: activeDot width: 6 height: 6 radius: Theme.radiusPill - color: Theme.acidGreen + color: root.isActive ? Theme.status : Theme.destructive anchors.bottom: parent.bottom anchors.right: parent.right anchors.margins: 3 - visible: root.isActive && !root.locked + visible: !root.isPreview && !root.locked + + Behavior on color { + ColorAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationFast } + } } // Lock Badge Mini Indicator diff --git a/shell/desktop/surfaces/radial/SkillTree.qml b/shell/desktop/surfaces/radial/SkillTree.qml index 31380161..9b391b06 100644 --- a/shell/desktop/surfaces/radial/SkillTree.qml +++ b/shell/desktop/surfaces/radial/SkillTree.qml @@ -17,12 +17,67 @@ Item { property real wheelCenterX: width / 2 property real wheelCenterY: height / 2 - property real outerRadius: 210 + property real outerRadius: 180 property real branchOriginX: 300 property real branchOriginY: height / 2 readonly property var rootNode: (root.model && root.model.categories[root.focusedCategoryIndex]?.nodes?.length > 0) ? root.model.categories[root.focusedCategoryIndex].nodes[0] : null + // ------------------------------------------------------------------------- + // One topology, one layout, two renderings. + // ------------------------------------------------------------------------- + + readonly property string focusedCategoryId: (root.model && root.model.categories[root.focusedCategoryIndex]) ? root.model.categories[root.focusedCategoryIndex].id : "" + + // Roots to lay out: the category's declared tree, plus any model node the + // topology does not declare. + // + // Those extras are the reason this is not just RadialTopology.treeFor(). A + // node added to the model without a matching entry in the topology would + // otherwise have no coordinates and silently disappear -- and the whole + // promise of this file is that adding a node anywhere shows up. Parked as + // extra roots, they land on the layout's orphan arc and stay visible. + readonly property var categoryTree: { + var declared = RadialTopology.treeFor(root.focusedCategoryId); + var roots = (declared && declared.length > 0) ? declared.slice() : []; + var known = RadialTopology.idsFor(root.focusedCategoryId); + + if (root.model) { + var cat = root.model.getCategory(root.focusedCategoryIndex); + if (cat && cat.nodes) { + for (var i = 0; i < cat.nodes.length; ++i) { + if (known.indexOf(cat.nodes[i].id) < 0) + roots.push({ id: cat.nodes[i].id }); + } + } + } + return roots; + } + + // Step size for the expanded branch. Left at the value the tree had before + // the geometry work; do not shrink this to make an expanded tree fit. + // + // I reduced it from 140 to 60 while chasing the base wheel's proportions, + // and the expanded branches went with it -- the trees came out with edges so + // short the nodes read as a cluster rather than a branch. The two views were + // given different numbers for one reason: they are different views. The base + // wheel is dense and compact; the expanded branch is one node at a time and + // has room to breathe. + readonly property real expandedStep: 140.0 + + readonly property var expandedLayout: RadialGeometry.layoutTree(root.categoryTree, { + // 90, not 0: the polar convention puts 0 pointing up, and this tree grows + // rightward from a wheel anchored off the left edge. + rootAngle: 90.0, + distance: root.expandedStep, + nodeRadius: 24.0 + }) + + function posOf(nodeId: string): var { + var p = root.expandedLayout[nodeId]; + return p ? p : { x: 0.0, y: 0.0, depth: 0, spoke: -1, parent: "" }; + } + signal nodeSelected(string nodeId) signal nodeHovered(string nodeId) signal nodeActivated(string nodeId) @@ -33,9 +88,9 @@ Item { var cat = model.getCategory(focusedCategoryIndex); if (!cat || !cat.nodes) return []; var list = []; - var rootPosY = cat.nodes.length > 0 ? cat.nodes[0].pos.y : 0; for (var i = 0; i < cat.nodes.length; ++i) { var n = cat.nodes[i]; + var p = root.posOf(n.id); list.push({ id: n.id, title: n.title, @@ -44,9 +99,11 @@ Item { locked: n.locked, lockReason: n.lockReason, controlType: n.controlType, - pos: n.pos, - screenX: root.branchOriginX + n.pos.x, - screenY: root.branchOriginY + n.pos.y - rootPosY + pos: p, + // The layout puts the root at (0,0), so there is no rootY to + // subtract the way there was with hand-placed `pos` values. + screenX: root.branchOriginX + p.x, + screenY: root.branchOriginY + p.y }); } return list; @@ -64,7 +121,7 @@ Item { visible: opacity > 0.01 Behavior on opacity { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } Repeater { @@ -73,6 +130,8 @@ Item { id: previewSubtree required property int index readonly property int categoryIdx: previewSubtree.index + // Referenced by onFocusedCategoryIndexChanged below. + readonly property int focusedCategoryIndex: root.focusedCategoryIndex property real _lastTargetAngle: -90.0 + previewSubtree.index * (360.0 / Math.max(1, root.model ? root.model.categoryCount : 8)) property var layoutInfo: RadialGeometry.getSegmentTargetLayout( @@ -84,7 +143,13 @@ Item { _lastTargetAngle ) - onLayoutInfoChanged: { + // Continuity hint for the shortest angular path. This used to + // be written from onLayoutInfoChanged, which made layoutInfo + // depend on a property its own change handler mutated -- a + // binding loop that fired continuously on every load. Latch on + // the category change instead, which is when the target + // actually moves. + onFocusedCategoryIndexChanged: { if (layoutInfo && layoutInfo.centerAngle !== undefined) { _lastTargetAngle = layoutInfo.centerAngle; } @@ -93,11 +158,9 @@ Item { property real angleDeg: layoutInfo.centerAngle Behavior on angleDeg { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } readonly property real rad: previewSubtree.angleDeg * Math.PI / 180.0 - readonly property real radMinus8: (previewSubtree.angleDeg - 8.0) * Math.PI / 180.0 - readonly property real radPlus8: (previewSubtree.angleDeg + 8.0) * Math.PI / 180.0 readonly property real radMinus12: (previewSubtree.angleDeg - 12.0) * Math.PI / 180.0 readonly property bool isCatFocused: root.focusedCategoryIndex === previewSubtree.categoryIdx @@ -105,10 +168,69 @@ Item { // Subtree polar anchor points (reactive trigonometric properties) readonly property var r0Point: ({ x: 150 * Math.cos(previewSubtree.rad), y: 150 * Math.sin(previewSubtree.rad) }) - readonly property var r1Point: ({ x: 320 * Math.cos(previewSubtree.rad), y: 320 * Math.sin(previewSubtree.rad) }) - readonly property var c1Point: ({ x: 380 * Math.cos(previewSubtree.radMinus8), y: 380 * Math.sin(previewSubtree.radMinus8) }) - readonly property var c2Point: ({ x: 380 * Math.cos(previewSubtree.radPlus8), y: 380 * Math.sin(previewSubtree.radPlus8) }) - readonly property var c3Point: ({ x: 440 * Math.cos(previewSubtree.radMinus8), y: 440 * Math.sin(previewSubtree.radMinus8) }) + + // How far out the preview tree's base node sits, and so how long + // the trunk runs before the tree starts. + // + // Measured off the reference rather than guessed: its ring has an + // outer radius of 119px and its tree roots sit at ~160, so its trunk + // is 0.35 x the ring radius. Scaled to our 180px ring that is 243. + // + // This was 320, then 300, and both left a trunk longer than the tree + // it introduced -- the branches read as floating clear of the wheel. + // What is actually drawn runs from outerRadius+6 to this minus + // node2Radius, because SkillEdge starts one node radius in from each + // end: 99px of line at 300, 41px at 242. + readonly property var r1Point: ({ x: 242 * Math.cos(previewSubtree.rad), y: 242 * Math.sin(previewSubtree.rad) }) + + // The wheel's preview trees, laid out by their own algorithm + // rather than the expanded branch's. + // + // RadialGeometry.layoutPreviewTree draws one fork at 45 degrees at + // the first node and then straight radial rays; layoutTree, which + // the expanded branch uses, fans 74-85 degrees wide and curves + // each chain as it goes. Those are different shapes and the base + // wants the compact one -- it packs eight trees into a ring. + // + // It used to call layoutTree with a smaller step, which meant the + // base inherited that wide fan and that flattening. At 74 degrees + // and curving, a fork of two reads as a sprawl rather than as the + // pair of branches it is. + // + // Topology is still shared: both layouts walk the same + // RadialTopology tree, so the two views cannot disagree about a + // category's shape, only about how it is drawn. + // + // Step 56 against a 30px node leaves a 26px gap, so each edge + // reads as a branch rather than the two discs nearly touching. + readonly property var previewChildren: { + var nodes = previewSubtree.cat && previewSubtree.cat.nodes ? previewSubtree.cat.nodes : []; + if (nodes.length === 0) return []; + + var tree = RadialTopology.treeFor(previewSubtree.cat.id); + var known = RadialTopology.idsFor(previewSubtree.cat.id); + var roots = (tree && tree.length > 0) ? tree.slice() : []; + for (var w = 0; w < nodes.length; ++w) + if (known.indexOf(nodes[w].id) < 0) + roots.push({ id: nodes[w].id }); + + var small = RadialGeometry.layoutPreviewTree(roots, { step: 56.0, forkDeg: 45.0 }); + var origin = previewSubtree.r1Point; + var slots = []; + + for (var i = 1; i < nodes.length; ++i) { + var p = small[nodes[i].id]; + if (!p || p.depth < 1) continue; + var parent = small[p.parent] ? small[p.parent] : { x: 0.0, y: 0.0 }; + slots.push({ + nodeIndex: i, + icon: nodes[i] ? nodes[i].icon : previewSubtree.cat.icon, + point: RadialGeometry.projectPoint(p, previewSubtree.angleDeg, 1.0, origin), + parentPoint: RadialGeometry.projectPoint(parent, previewSubtree.angleDeg, 1.0, origin) + }); + } + return slots; + } // Trunk edge SkillEdge { @@ -116,46 +238,37 @@ Item { y1: root.wheelCenterY x2: (root.wheelCenterX + previewSubtree.r1Point.x) y2: (root.wheelCenterY + previewSubtree.r1Point.y) - node1Radius: root.outerRadius + 10 + // The gap and the visible run are the same edge. SkillEdge + // starts its path one node radius in from each end, so what + // gets drawn is the run between outerRadius+6 and r1Point-15, + // and an opaque hub disc covers the inner half of it. + // + // That run was 6px. The path began at a hardcoded + // outerRadius+10 -- 220 against a 210 ring radius -- while the + // tree root sat at 241, so the connector from ring to tree was + // effectively invisible and the trees looked unattached. 40px + // of dead gap, then 6px of line. + node1Radius: root.outerRadius + 6 node2Radius: 15 isActive: previewSubtree.isCatFocused isPreview: true } - // Branch edge 1 - SkillEdge { - x1: (root.wheelCenterX + previewSubtree.r1Point.x) - y1: (root.wheelCenterY + previewSubtree.r1Point.y) - x2: (root.wheelCenterX + previewSubtree.c1Point.x) - y2: (root.wheelCenterY + previewSubtree.c1Point.y) - node1Radius: 10 - node2Radius: 10 - isActive: previewSubtree.isCatFocused - isPreview: true - } - - // Branch edge 2 - SkillEdge { - x1: (root.wheelCenterX + previewSubtree.r1Point.x) - y1: (root.wheelCenterY + previewSubtree.r1Point.y) - x2: (root.wheelCenterX + previewSubtree.c2Point.x) - y2: (root.wheelCenterY + previewSubtree.c2Point.y) - node1Radius: 10 - node2Radius: 10 - isActive: previewSubtree.isCatFocused - isPreview: true - } - - // Branch edge 3 (Leaf extension) - SkillEdge { - x1: (root.wheelCenterX + previewSubtree.c1Point.x) - y1: (root.wheelCenterY + previewSubtree.c1Point.y) - x2: (root.wheelCenterX + previewSubtree.c3Point.x) - y2: (root.wheelCenterY + previewSubtree.c3Point.y) - node1Radius: 10 - node2Radius: 10 - isActive: previewSubtree.isCatFocused - isPreview: true + // Branch edges, one per generated child slot + Repeater { + model: previewSubtree.previewChildren + + delegate: SkillEdge { + required property var modelData + x1: root.wheelCenterX + modelData.parentPoint.x + y1: root.wheelCenterY + modelData.parentPoint.y + x2: root.wheelCenterX + modelData.point.x + y2: root.wheelCenterY + modelData.point.y + node1Radius: 10 + node2Radius: 10 + isActive: previewSubtree.isCatFocused + isPreview: true + } } // Root Preview Node @@ -167,31 +280,18 @@ Item { iconName: previewSubtree.cat ? previewSubtree.cat.icon : "gear" } - // Child Preview Node 1 - SkillNode { - x: (root.wheelCenterX + previewSubtree.c1Point.x) - width / 2 - y: (root.wheelCenterY + previewSubtree.c1Point.y) - height / 2 - isPreview: true - isSelected: previewSubtree.isCatFocused - iconName: (previewSubtree.cat && previewSubtree.cat.nodes && previewSubtree.cat.nodes[1]) ? previewSubtree.cat.nodes[1].icon : (previewSubtree.cat ? previewSubtree.cat.icon : "gear") - } - - // Child Preview Node 2 - SkillNode { - x: (root.wheelCenterX + previewSubtree.c2Point.x) - width / 2 - y: (root.wheelCenterY + previewSubtree.c2Point.y) - height / 2 - isPreview: true - isSelected: previewSubtree.isCatFocused - iconName: (previewSubtree.cat && previewSubtree.cat.nodes && previewSubtree.cat.nodes[2]) ? previewSubtree.cat.nodes[2].icon : (previewSubtree.cat ? previewSubtree.cat.icon : "gear") - } - - // Child Preview Node 3 - SkillNode { - x: (root.wheelCenterX + previewSubtree.c3Point.x) - width / 2 - y: (root.wheelCenterY + previewSubtree.c3Point.y) - height / 2 - isPreview: true - isSelected: previewSubtree.isCatFocused - iconName: (previewSubtree.cat && previewSubtree.cat.nodes && previewSubtree.cat.nodes[3]) ? previewSubtree.cat.nodes[3].icon : (previewSubtree.cat ? previewSubtree.cat.icon : "gear") + // Child Preview Nodes + Repeater { + model: previewSubtree.previewChildren + + delegate: SkillNode { + required property var modelData + x: (root.wheelCenterX + modelData.point.x) - width / 2 + y: (root.wheelCenterY + modelData.point.y) - height / 2 + isPreview: true + isSelected: previewSubtree.isCatFocused + iconName: modelData.icon + } } } } @@ -208,17 +308,14 @@ Item { opacity: root.isExpanded ? 1.0 : 0.0 visible: opacity > 0.01 - property real currentRootPosY: { - if (!root.model || !root.isExpanded) return 0.0; - var cat = root.model.getCategory(root.focusedCategoryIndex); - if (cat && cat.nodes && cat.nodes.length > 0) { - return cat.nodes[0].pos.y; - } - return 0.0; - } + // The layout puts the root at (0, 0), so there is no rootY offset to + // apply. This used to read cat.nodes[0].pos.y and subtract it from every + // node, which existed only because the old `pos` values were hand-placed + // rather than computed from a known origin. + readonly property real currentRootPosY: 0.0 Behavior on opacity { - NumberAnimation { duration: Theme.durationSlow; easing.type: Easing.OutCubic } + NumberAnimation { duration: Settings.reducedMotion ? 0 : Theme.durationSlow; easing.type: Easing.OutCubic } } // Staggered cascade controller for nodes and connecting edges @@ -257,14 +354,14 @@ Item { cascadeAnim7.restart(); } - NumberAnimation { id: cascadeAnim0; target: cascadeController; property: "offset0"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 0; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim1; target: cascadeController; property: "offset1"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 60; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim2; target: cascadeController; property: "offset2"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 120; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim3; target: cascadeController; property: "offset3"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 180; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim4; target: cascadeController; property: "offset4"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 240; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim5; target: cascadeController; property: "offset5"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 300; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim6; target: cascadeController; property: "offset6"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 360; running: root.isExpanded; easing.type: Easing.OutBack } - NumberAnimation { id: cascadeAnim7; target: cascadeController; property: "offset7"; from: -30.0; to: 0.0; duration: Theme.durationSlow + 420; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim0; target: cascadeController; property: "offset0"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 0; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim1; target: cascadeController; property: "offset1"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 60; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim2; target: cascadeController; property: "offset2"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 120; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim3; target: cascadeController; property: "offset3"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 180; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim4; target: cascadeController; property: "offset4"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 240; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim5; target: cascadeController; property: "offset5"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 300; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim6; target: cascadeController; property: "offset6"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 360; running: root.isExpanded; easing.type: Easing.OutBack } + NumberAnimation { id: cascadeAnim7; target: cascadeController; property: "offset7"; from: -30.0; to: 0.0; duration: Settings.reducedMotion ? 0 : Theme.durationSlow + 420; running: root.isExpanded; easing.type: Easing.OutBack } } // Horizontal Anchor Ray from Wheel to Root Node @@ -272,7 +369,7 @@ Item { id: anchorRay x1: root.wheelCenterX y1: root.wheelCenterY - x2: root.branchOriginX + (root.rootNode ? root.rootNode.pos.x : 70) + cascadeController.offset0 + x2: root.branchOriginX + (root.rootNode ? root.posOf(root.rootNode.id).x : 0) + cascadeController.offset0 y2: root.wheelCenterY node1Radius: root.outerRadius + 10 node2Radius: 16 @@ -302,44 +399,43 @@ Item { } // Inter-Node Edges + // + // Built by walking the layout rather than each node's `edges` list, so + // the parent-child pairs here and the ones the preview draws are the same + // pairs. Two lists that both claimed to describe the tree is how they came + // to describe different ones. Repeater { id: edgesRepeater model: { if (!root.model || !root.isExpanded) return []; var cat = root.model.getCategory(root.focusedCategoryIndex); if (!cat || !cat.nodes) return []; + + var layout = root.expandedLayout; var edgesList = []; - var rootPosY = expandedBranchContainer.currentRootPosY; for (var i = 0; i < cat.nodes.length; ++i) { - var parentNode = cat.nodes[i]; - if (!parentNode.edges) continue; - for (var j = 0; j < parentNode.edges.length; ++j) { - var childId = parentNode.edges[j]; - var childNode = root.model.getNode(root.focusedCategoryIndex, childId); - if (childNode) { - var childIdx = -1; - for (var k = 0; k < cat.nodes.length; ++k) { - if (cat.nodes[k].id === childId) { - childIdx = k; - break; - } - } - edgesList.push({ - parentId: parentNode.id, - childId: childId, - parentIndex: i, - childIndex: childIdx, - parentPosX: parentNode.pos.x, - parentPosY: parentNode.pos.y, - childPosX: childNode.pos.x, - childPosY: childNode.pos.y, - x1: root.branchOriginX + parentNode.pos.x, - y1: root.branchOriginY + parentNode.pos.y - rootPosY, - x2: root.branchOriginX + childNode.pos.x, - y2: root.branchOriginY + childNode.pos.y - rootPosY - }); - } + var id = cat.nodes[i].id; + var p = layout[id]; + if (!p || !p.parent) continue; + + var parentPos = layout[p.parent]; + if (!parentPos) continue; + + var childIdx = -1; + for (var k = 0; k < cat.nodes.length; ++k) { + if (cat.nodes[k].id === id) { childIdx = k; break; } } + + edgesList.push({ + parentId: p.parent, + childId: id, + parentIndex: -1, + childIndex: childIdx, + parentPosX: parentPos.x, + parentPosY: parentPos.y, + childPosX: p.x, + childPosY: p.y + }); } return edgesList; } @@ -347,7 +443,14 @@ Item { delegate: SkillEdge { id: edgeItem required property var modelData - x1: root.branchOriginX + edgeItem.modelData.parentPosX + cascadeController.getOffset(edgeItem.modelData.parentIndex) + // The parent's stagger is keyed off the layout's depth rather + // than the model's array index: the two orders differ (the + // topology nests, the model lists flat), and keying off array + // position made the cascade run in an order that had nothing to + // do with how the tree reads. + readonly property int parentIndex: root.expandedLayout[edgeItem.modelData.parentId] + ? root.expandedLayout[edgeItem.modelData.parentId].depth : 0 + x1: root.branchOriginX + edgeItem.modelData.parentPosX + cascadeController.getOffset(edgeItem.parentIndex) y1: root.branchOriginY + edgeItem.modelData.parentPosY - expandedBranchContainer.currentRootPosY x2: root.branchOriginX + edgeItem.modelData.childPosX + cascadeController.getOffset(edgeItem.modelData.childIndex) y2: root.branchOriginY + edgeItem.modelData.childPosY - expandedBranchContainer.currentRootPosY @@ -381,8 +484,9 @@ Item { required property var modelData required property int index readonly property real cascadeOffset: cascadeController.getOffset(nodeWrapper.index) - x: root.branchOriginX + nodeWrapper.modelData.pos.x - 24 + nodeWrapper.cascadeOffset - y: root.branchOriginY + nodeWrapper.modelData.pos.y - expandedBranchContainer.currentRootPosY - 24 + readonly property var pos: root.posOf(nodeWrapper.modelData.id) + x: root.branchOriginX + nodeWrapper.pos.x - 24 + nodeWrapper.cascadeOffset + y: root.branchOriginY + nodeWrapper.pos.y - expandedBranchContainer.currentRootPosY - 24 width: 48 height: 48 diff --git a/shell/desktop/surfaces/radial/qmldir b/shell/desktop/surfaces/radial/qmldir index e693b96a..37a922ec 100644 --- a/shell/desktop/surfaces/radial/qmldir +++ b/shell/desktop/surfaces/radial/qmldir @@ -8,3 +8,4 @@ ContextPanel 1.0 ContextPanel.qml NavigationController 1.0 NavigationController.qml CircularSettingsMenu 1.0 CircularSettingsMenu.qml RadialSettings 1.0 RadialSettings.qml +singleton RadialTopology 1.0 RadialTopology.qml diff --git a/shell/desktop/surfaces/widgets/AmbientGlitch.qml b/shell/desktop/surfaces/widgets/AmbientGlitch.qml deleted file mode 100644 index 7d95ceb0..00000000 --- a/shell/desktop/surfaces/widgets/AmbientGlitch.qml +++ /dev/null @@ -1,300 +0,0 @@ -import QtQuick -import "../../core" -import "../../services" - -Item { - id: root - - // ========================================================================= - // Public Interface Contract (Requirement R5) - // ========================================================================= - - // Target item to which spatial jitter displacement is applied (defaults to parent) - property Item targetItem: root.parent - - // Feature toggles - property bool cpuDistortionEnabled: true - property bool chromaticFlashEnabled: true - property bool autoTrigger: true - - // Thresholds - property real cpuThreshold: 0.90 - property real networkDeltaThreshold: 1048576.0 // 1 MB/s throughput shift - - // Readonly status - readonly property bool isGlitching: cpuGlitchSequence.running || chromaSequence.running - readonly property bool reducedMotionActive: Settings.reducedMotion - - // Timing constants (strictly <= 160ms) - readonly property int maxGlitchDuration: 130 - readonly property int maxChromaDuration: 120 - - anchors.fill: parent - z: 20 - - // Internal state tracking - property real _lastNetRx: 0.0 - property real _lastNetTx: 0.0 - property real _lastGlitchTimestamp: 0.0 - - // ========================================================================= - // Motion Safety Listener - // ========================================================================= - - onReducedMotionActiveChanged: { - if (reducedMotionActive) { - root.stop(); - } - } - - // ========================================================================= - // Reactive Telemetry Triggers (Zero Polling) - // ========================================================================= - - Connections { - target: SystemMonitorService - enabled: root.autoTrigger && !root.reducedMotionActive - - function onCpuTelemetryUpdated(total, threads): void { - if (root.cpuDistortionEnabled && total > root.cpuThreshold) { - const now = Date.now(); - // Cooldown debounce: minimum 2000ms between automatic CPU spikes - if (now - root._lastGlitchTimestamp >= 2000) { - root._lastGlitchTimestamp = now; - root.triggerCpuGlitch(); - } - } - } - - function onNetworkTelemetryUpdated(rxPerSec, txPerSec): void { - if (root.chromaticFlashEnabled) { - const deltaRx = Math.abs(rxPerSec - root._lastNetRx); - const deltaTx = Math.abs(txPerSec - root._lastNetTx); - if (deltaRx > root.networkDeltaThreshold || deltaTx > root.networkDeltaThreshold) { - root.triggerChromaticFlash(); - } - } - root._lastNetRx = rxPerSec; - root._lastNetTx = txPerSec; - } - } - - Connections { - target: NetworkService - enabled: root.autoTrigger && !root.reducedMotionActive - - function onNetworkStateChanged(connected, connType, name): void { - if (root.chromaticFlashEnabled) { - root.triggerChromaticFlash(); - } - } - } - - // ========================================================================= - // Visual Distortion Overlay Elements - // ========================================================================= - - // Scanline laser shimmer - Rectangle { - id: scanline - x: 0 - y: 0 - width: parent.width - height: 1 - color: Theme.acidGreen - opacity: 0.0 - z: 21 - } - - // Chromatic Aberration Red Channel Overlay - Rectangle { - id: chromaRed - anchors.fill: parent - anchors.leftMargin: 2 - anchors.rightMargin: -2 - color: Theme.destructive - opacity: 0.0 - z: 22 - } - - // Chromatic Aberration Acid Green Channel Overlay - Rectangle { - id: chromaGreen - anchors.fill: parent - anchors.leftMargin: -2 - anchors.rightMargin: 2 - color: Theme.acidGreen - opacity: 0.0 - z: 23 - } - - // ========================================================================= - // Discrete Animation Sequences (Bounded <= 160ms, running: false default) - // ========================================================================= - - // 1. CPU Spike Distortion Sequence (Total Duration: 130ms) - ParallelAnimation { - id: cpuGlitchSequence - running: false - loops: 1 - - // Spatial Jitter Sequence (<= 3px offset, total 130ms) - SequentialAnimation { - NumberAnimation { - target: root.targetItem || root - property: "x" - to: root.reducedMotionActive ? 0 : -2.0 - duration: 30 - } - NumberAnimation { - target: root.targetItem || root - property: "x" - to: root.reducedMotionActive ? 0 : 1.5 - duration: 40 - } - NumberAnimation { - target: root.targetItem || root - property: "x" - to: root.reducedMotionActive ? 0 : -1.0 - duration: 30 - } - NumberAnimation { - target: root.targetItem || root - property: "x" - to: 0 - duration: 30 - } - } - - // Opacity Signal Pulse (subtle, never drops below 0.7, total 130ms) - SequentialAnimation { - NumberAnimation { - target: root.targetItem || root - property: "opacity" - to: root.reducedMotionActive ? 1.0 : 0.85 - duration: 35 - } - NumberAnimation { - target: root.targetItem || root - property: "opacity" - to: root.reducedMotionActive ? 1.0 : 0.95 - duration: 35 - } - NumberAnimation { - target: root.targetItem || root - property: "opacity" - to: 1.0 - duration: 60 - } - } - - // Scanline Sweep (total 120ms) - SequentialAnimation { - ParallelAnimation { - NumberAnimation { - target: scanline - property: "y" - from: 0 - to: root.height - duration: 120 - } - SequentialAnimation { - NumberAnimation { - target: scanline - property: "opacity" - from: 0.0 - to: root.reducedMotionActive ? 0.0 : 0.6 - duration: 40 - } - NumberAnimation { - target: scanline - property: "opacity" - from: root.reducedMotionActive ? 0.0 : 0.6 - to: 0.0 - duration: 80 - } - } - } - PropertyAction { target: scanline; property: "y"; value: 0 } - PropertyAction { target: scanline; property: "opacity"; value: 0.0 } - } - } - - // 2. Chromatic Aberration Flash Sequence (Total Duration: 120ms) - ParallelAnimation { - id: chromaSequence - running: false - loops: 1 - - // Red Channel Flash - SequentialAnimation { - NumberAnimation { - target: chromaRed - property: "opacity" - from: 0.0 - to: root.reducedMotionActive ? 0.0 : 0.4 - duration: 30 - } - NumberAnimation { - target: chromaRed - property: "opacity" - from: root.reducedMotionActive ? 0.0 : 0.4 - to: 0.0 - duration: 90 - } - } - - // Green Channel Flash - SequentialAnimation { - NumberAnimation { - target: chromaGreen - property: "opacity" - from: 0.0 - to: root.reducedMotionActive ? 0.0 : 0.35 - duration: 30 - } - NumberAnimation { - target: chromaGreen - property: "opacity" - from: root.reducedMotionActive ? 0.0 : 0.35 - to: 0.0 - duration: 90 - } - } - } - - // ========================================================================= - // Public Trigger Methods - // ========================================================================= - - function triggerCpuGlitch(): void { - if (root.reducedMotionActive) { - return; - } - cpuGlitchSequence.restart(); - } - - function triggerChromaticFlash(): void { - if (root.reducedMotionActive) { - return; - } - chromaSequence.restart(); - } - - function stop(): void { - cpuGlitchSequence.stop(); - chromaSequence.stop(); - if (root.targetItem) { - root.targetItem.x = 0; - root.targetItem.opacity = 1.0; - } - scanline.opacity = 0.0; - scanline.y = 0; - chromaRed.opacity = 0.0; - chromaGreen.opacity = 0.0; - } - - Component.onDestruction: { - root.stop(); - } -} diff --git a/shell/desktop/surfaces/widgets/AudioSurveillanceWidget.qml b/shell/desktop/surfaces/widgets/AudioSurveillanceWidget.qml index 09dc04e0..75d84945 100644 --- a/shell/desktop/surfaces/widgets/AudioSurveillanceWidget.qml +++ b/shell/desktop/surfaces/widgets/AudioSurveillanceWidget.qml @@ -15,7 +15,7 @@ Item { width: implicitWidth height: implicitHeight - property color bracketColor: root.isPlaying ? Theme.acidGreen : Theme.gray500 + property color bracketColor: root.isPlaying ? Theme.statusGreen : Theme.gray500 // Optional direct player override for testing / dynamic injection property var playerOverride: null @@ -169,8 +169,8 @@ Item { Rectangle { id: bgSurface anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) - border.color: root.isPlaying ? Theme.acidGreen : Theme.gray700 + color: Theme.surfaceScrim + border.color: root.isPlaying ? Theme.statusGreen : Theme.gray700 border.width: Theme.borderWidth } @@ -205,7 +205,7 @@ Item { Text { text: root.statusText - color: root.isPlaying ? Theme.acidGreen : Theme.textMuted + color: root.isPlaying ? Theme.statusGreen : Theme.textMuted font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall font.weight: Theme.fontWeightBold @@ -223,7 +223,7 @@ Item { Text { Layout.fillWidth: true text: root.hasMedia ? root.interceptString : root.fallbackString - color: root.hasMedia ? Theme.acidGreen : Theme.textMuted + color: root.hasMedia ? Theme.statusGreen : Theme.textMuted font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall font.weight: Theme.fontWeightBold @@ -277,7 +277,7 @@ Item { Layout.preferredHeight: root.isPlaying ? Math.max(3, Math.round((Math.sin((barRect.index * 0.72) + root.visualizerPhase) * 0.35 + Math.cos((barRect.index * 1.25) - root.visualizerPhase * 1.3) * 0.25 + 0.5) * 16)) : 2 - color: root.isPlaying ? Theme.acidGreen : Theme.gray600 + color: root.isPlaying ? Theme.statusGreen : Theme.gray600 } } } diff --git a/shell/desktop/surfaces/widgets/CpuHexGrid.qml b/shell/desktop/surfaces/widgets/CpuHexGrid.qml index afdb3757..b38022f7 100644 --- a/shell/desktop/surfaces/widgets/CpuHexGrid.qml +++ b/shell/desktop/surfaces/widgets/CpuHexGrid.qml @@ -19,7 +19,7 @@ Item { readonly property var cpuThreadLoads: SystemMonitorService.cpuThreadLoads readonly property int threadCount: root.cpuThreadLoads ? root.cpuThreadLoads.length : 0 readonly property bool isOverload: root.cpuTotal >= 0.90 - property color bracketColor: root.isOverload ? Theme.destructive : Theme.acidGreen + property color bracketColor: root.isOverload ? Theme.destructive : Theme.statusGreen // ========================================================================= // State Tracking & Conditional Animations (Zero-Polling Compliant) @@ -195,7 +195,7 @@ Item { // Background Surface Rectangle { anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: root.isOverload ? Theme.destructive : Theme.gray700 border.width: Theme.borderWidth } @@ -228,7 +228,7 @@ Item { Text { text: "CPU " + Math.round(root.cpuTotal * 100) + "%" - color: root.isOverload ? Theme.destructive : Theme.acidGreen + color: root.isOverload ? Theme.destructive : Theme.statusGreen font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall font.weight: Theme.fontWeightBold @@ -264,7 +264,7 @@ Item { ctx.clearRect(0, 0, width, height); if (!SystemMonitorService.available) { - ctx.fillStyle = "#7A7A7A"; + ctx.fillStyle = Theme.textDimHex; ctx.font = "12px JetBrainsMono Nerd Font, monospace"; ctx.textAlign = "center"; ctx.fillText("TELEMETRY OFFLINE", width / 2, height / 2); @@ -274,7 +274,7 @@ Item { const loads = root.cpuThreadLoads || []; const count = loads.length; if (count === 0) { - ctx.fillStyle = "#7A7A7A"; + ctx.fillStyle = Theme.textDimHex; ctx.font = "12px JetBrainsMono Nerd Font, monospace"; ctx.textAlign = "center"; ctx.fillText("SCANNING THREADS...", width / 2, height / 2); @@ -317,37 +317,37 @@ Item { // 5 visual states + offline core if (isOffline) { ctx.fillStyle = "transparent"; - ctx.strokeStyle = "rgba(32, 32, 32, 0.40)"; + ctx.strokeStyle = Theme.withAlpha(Theme.border, 0.40); ctx.lineWidth = 1.0; ctx.stroke(); } else if (load >= 0.99) { - ctx.fillStyle = root.flickerState ? "#FC3E38" : "#801010"; - ctx.strokeStyle = "#FC3E38"; + ctx.fillStyle = root.flickerState ? Theme.dangerHex : Theme.dangerDimHex; + ctx.strokeStyle = Theme.dangerHex; ctx.lineWidth = 1.5; ctx.fill(); ctx.stroke(); } else if (load >= 0.80) { const alpha = 0.75 * root.pulseAlpha; - ctx.fillStyle = "rgba(27, 253, 156, " + alpha.toFixed(2) + ")"; - ctx.strokeStyle = "#1BFD9C"; + ctx.fillStyle = Theme.withAlpha(Theme.statusGreen, alpha.toFixed(2)); + ctx.strokeStyle = Theme.statusGreenHex; ctx.lineWidth = 1.5; ctx.fill(); ctx.stroke(); } else if (load >= 0.40) { - ctx.fillStyle = "rgba(27, 253, 156, 0.70)"; - ctx.strokeStyle = "#1BFD9C"; + ctx.fillStyle = Theme.withAlpha(Theme.statusGreen, 0.70); + ctx.strokeStyle = Theme.statusGreenHex; ctx.lineWidth = 1.2; ctx.fill(); ctx.stroke(); } else if (load >= 0.05) { - ctx.fillStyle = "rgba(27, 253, 156, 0.25)"; - ctx.strokeStyle = "rgba(27, 253, 156, 0.50)"; + ctx.fillStyle = Theme.withAlpha(Theme.statusGreen, 0.25); + ctx.strokeStyle = Theme.withAlpha(Theme.statusGreen, 0.50); ctx.lineWidth = 1.0; ctx.fill(); ctx.stroke(); } else { ctx.fillStyle = "transparent"; - ctx.strokeStyle = "#202020"; + ctx.strokeStyle = Theme.gray700Hex; ctx.lineWidth = 1.0; ctx.stroke(); } diff --git a/shell/desktop/surfaces/widgets/NetworkFlowMatrix.qml b/shell/desktop/surfaces/widgets/NetworkFlowMatrix.qml index adab6083..5eb25be3 100644 --- a/shell/desktop/surfaces/widgets/NetworkFlowMatrix.qml +++ b/shell/desktop/surfaces/widgets/NetworkFlowMatrix.qml @@ -90,7 +90,7 @@ Item { Rectangle { id: bgSurface anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: Theme.gray700 border.width: Theme.borderWidth } @@ -119,35 +119,27 @@ Item { font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption font.weight: Theme.fontWeightMedium - } - - Item { + // The title absorbs the slack and yields when the value needs + // the room. Without this the row overflows the 280px widget and + // the rate is cut off mid-glyph at the right edge. Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideRight } - // RX Speed Label (Theme.acidGreen) + // RX / TX rates. Capped so an unusually large or unit-suffixed + // rate cannot push the row past the widget. Text { text: "↓ " + root.formatRate(SystemMonitorService.netRxBytesPerSec) - color: Theme.acidGreen - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - font.weight: Theme.fontWeightMedium - } - - Text { - text: " " - color: Theme.gray700 - font.family: Theme.fontFamilyMonospace - font.pixelSize: Theme.fontSizeSmall - } - - // TX Speed Label (Theme.gray300) - Text { - text: "↑ " + root.formatRate(SystemMonitorService.netTxBytesPerSec) - color: Theme.gray300 + + " ↑ " + root.formatRate(SystemMonitorService.netTxBytesPerSec) + color: Theme.statusGreen font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall font.weight: Theme.fontWeightMedium + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.preferredWidth: implicitWidth + Layout.maximumWidth: 150 } } @@ -173,7 +165,7 @@ Item { ctx.clearRect(0, 0, w, h); if (!SystemMonitorService.available) { - ctx.fillStyle = "#7A7A7A"; + ctx.fillStyle = Theme.textDimHex; ctx.font = "12px JetBrainsMono Nerd Font, monospace"; ctx.textAlign = "center"; ctx.fillText("NETWORK OFFLINE", w / 2, h / 2); @@ -183,7 +175,7 @@ Item { // ------------------------------------------------------------- // 1. Wireframe Grid Lines (Theme.gray700: #202020) // ------------------------------------------------------------- - ctx.strokeStyle = "#202020"; + ctx.strokeStyle = Theme.gray700Hex; ctx.lineWidth = 1.0; // Horizontal wireframe grid lines (4 equal divisions) @@ -233,7 +225,7 @@ Item { // ------------------------------------------------------------- // 3. Draw TX Line (Theme.gray300: #C3C3C3) // ------------------------------------------------------------- - ctx.strokeStyle = "#C3C3C3"; + ctx.strokeStyle = Theme.gray300Hex; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < n; ++i) { @@ -245,10 +237,10 @@ Item { ctx.stroke(); // ------------------------------------------------------------- - // 4. Draw RX Area Glow & Line (Theme.acidGreen: #1BFD9C) + // 4. Draw RX Area Glow & Line (Theme.statusGreen) // ------------------------------------------------------------- // Faint tactical gradient fill under RX curve - ctx.fillStyle = "rgba(27, 253, 156, 0.08)"; + ctx.fillStyle = Theme.withAlpha(Theme.statusGreen, 0.08); ctx.beginPath(); ctx.moveTo(startOffset, h - 2); for (let i = 0; i < n; ++i) { @@ -259,7 +251,7 @@ Item { ctx.fill(); // Crisp RX Stroke - ctx.strokeStyle = "#1BFD9C"; + ctx.strokeStyle = Theme.statusGreenHex; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < n; ++i) { diff --git a/shell/desktop/surfaces/widgets/NetworkTracerWidget.qml b/shell/desktop/surfaces/widgets/NetworkTracerWidget.qml index 3576a810..529085b4 100644 --- a/shell/desktop/surfaces/widgets/NetworkTracerWidget.qml +++ b/shell/desktop/surfaces/widgets/NetworkTracerWidget.qml @@ -25,7 +25,7 @@ Item { Rectangle { id: bgSurface anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: Theme.gray700 border.width: Theme.borderWidth } diff --git a/shell/desktop/surfaces/widgets/PacketAnalyzerWidget.qml b/shell/desktop/surfaces/widgets/PacketAnalyzerWidget.qml index 02af28f4..ffebe906 100644 --- a/shell/desktop/surfaces/widgets/PacketAnalyzerWidget.qml +++ b/shell/desktop/surfaces/widgets/PacketAnalyzerWidget.qml @@ -14,7 +14,7 @@ Item { Rectangle { id: bgSurface anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: Theme.gray700 border.width: Theme.borderWidth } diff --git a/shell/desktop/surfaces/widgets/RamBlockBar.qml b/shell/desktop/surfaces/widgets/RamBlockBar.qml index 8206b11e..55f75255 100644 --- a/shell/desktop/surfaces/widgets/RamBlockBar.qml +++ b/shell/desktop/surfaces/widgets/RamBlockBar.qml @@ -45,14 +45,14 @@ Item { Rectangle { id: bgSurface anchors.fill: parent - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: root.isCritical ? Theme.destructive : Theme.gray700 border.width: Theme.borderWidth } // Framing Corner Brackets (Transitions to destructive red when RAM >= 80%) CornerBrackets { - bracketColor: root.isCritical ? Theme.destructive : Theme.acidGreen + bracketColor: root.isCritical ? Theme.destructive : Theme.statusGreen } // ========================================================================= @@ -75,10 +75,9 @@ Item { font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption font.weight: Theme.fontWeightMedium - } - - Item { Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideRight } // Terse Label: RAM 8.2 / 16.0 GB @@ -88,6 +87,13 @@ Item { font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeSmall font.weight: Theme.fontWeightMedium + // Takes its natural width and is capped. It must NOT also fill: + // with both texts filling, the row splits evenly and the value + // elides too, which is the same clipping in nicer clothing. + Layout.preferredWidth: implicitWidth + Layout.maximumWidth: 150 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight } } @@ -110,12 +116,12 @@ Item { // Color transitions to Theme.destructive when usage >= 80% color: { if (!isFilled) return Theme.gray900; - return root.isCritical ? Theme.destructive : Theme.acidGreen; + return root.isCritical ? Theme.destructive : Theme.statusGreen; } border.color: { if (!isFilled) return Theme.gray700; - return root.isCritical ? Theme.destructive : Qt.rgba(27 / 255, 253 / 255, 156 / 255, 0.85); + return root.isCritical ? Theme.destructive : Qt.alpha(Theme.statusGreen, 0.85); } border.width: Theme.borderWidth } @@ -133,10 +139,9 @@ Item { font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption - 1 font.weight: Theme.fontWeightMedium - } - - Item { Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideRight } Text { @@ -147,6 +152,10 @@ Item { font.family: Theme.fontFamilyMonospace font.pixelSize: Theme.fontSizeCaption - 1 font.weight: Theme.fontWeightMedium + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.preferredWidth: implicitWidth + Layout.maximumWidth: 170 } } diff --git a/shell/desktop/surfaces/widgets/TargetProfilerWidget.qml b/shell/desktop/surfaces/widgets/TargetProfilerWidget.qml index 8dab551c..e0386e15 100644 --- a/shell/desktop/surfaces/widgets/TargetProfilerWidget.qml +++ b/shell/desktop/surfaces/widgets/TargetProfilerWidget.qml @@ -184,7 +184,7 @@ Item { Rectangle { id: bgSurface anchors.fill: root - color: Qt.rgba(14 / 255, 14 / 255, 14 / 255, 0.85) + color: Theme.surfaceScrim border.color: Theme.gray700 border.width: Theme.borderWidth } diff --git a/shell/desktop/surfaces/widgets/qmldir b/shell/desktop/surfaces/widgets/qmldir index feabfb02..4efda3cd 100644 --- a/shell/desktop/surfaces/widgets/qmldir +++ b/shell/desktop/surfaces/widgets/qmldir @@ -2,7 +2,6 @@ CornerBrackets 1.0 CornerBrackets.qml CpuHexGrid 1.0 CpuHexGrid.qml NetworkFlowMatrix 1.0 NetworkFlowMatrix.qml RamBlockBar 1.0 RamBlockBar.qml -AmbientGlitch 1.0 AmbientGlitch.qml NetworkTracerWidget 1.0 NetworkTracerWidget.qml AudioSurveillanceWidget 1.0 AudioSurveillanceWidget.qml TargetProfilerWidget 1.0 TargetProfilerWidget.qml diff --git a/shell/docs/design/README.md b/shell/docs/design/README.md index 8f0bc6fa..ea05c1f6 100644 --- a/shell/docs/design/README.md +++ b/shell/docs/design/README.md @@ -1,30 +1,46 @@ # ctOS design package -This directory is the source of truth for the ctOS desktop-shell redesign. It describes the intended product before implementation so that feature work can be evaluated against the same decisions. +Product and structural intent for the desktop shell. + +**The visual and behavioural target is [`../target/`](../target/README.md).** +Where these documents and the target disagree, the target is correct. | Document | Purpose | | --- | --- | -| [Vision](vision.md) | Product identity, visual grammar, scope, and accessibility rules. | -| [Interaction specification](interaction-spec.md) | Surface behavior, input model, shortcuts, and state transitions. | -| [Architecture](architecture.md) | Runtime boundaries, data flow, services, and failure behavior. | +| [Target specification](../target/README.md) | Authoritative design: images, geometry, colour, behaviour, implementation order. | +| [Vision](vision.md) | Product identity, visual grammar, scope. | +| [Interaction specification](interaction-spec.md) | Input routing, state machines, session safety. | +| [Architecture](architecture.md) | Runtime boundaries, single-surface hosting, service contract. | | [Nix integration](nix-module.md) | Flake/Home Manager packaging and configuration contract. | Implementation-facing material lives in the sibling [technical package](../technical/README.md). -## Locked v1 decisions +## Locked decisions - ctOS is a personal, NixOS-first Quickshell rice. - Hyprland is the only supported compositor in v1. -- The desktop is minimal by default; ctOS’s rich visual language is reserved for on-demand panels. -- The shell uses a fixed dark ctOS-inspired theme, not dynamic wallpaper theming. -- The current greeter and lockscreen are preserved and packaged, not redesigned. -- The primary public integration is a Home Manager module exported from a flake-parts-organized flake. - -Deferred work is intentionally not an implicit v1 commitment: Niri, overview, file search, clipboard history, screenshot/recording controls, dynamic theming, and a full media panel. - -## Future Enhancements (v2 Roadmap) -- **Network / Wi-Fi Popup**: A drop-down to list and connect to specific SSIDs. -- **Audio Mixer & Sink Selector**: A drop-down to switch audio output devices and control microphone input volume/mute. -- **Clipboard History Manager**: A utility surface to track and paste recent clipboard copies. -- **Power Profile Manager**: A drop-down attached to the Battery widget to toggle system power states (Performance, Balanced, Power-Saver). -- **Packet Analyzer Widget**: A live scrolling stream of network traffic (powered by `tshark`) to provide a real-time tactical overview of network activity. +- **The notch is the only top-edge surface.** No tray, no second bar. +- The CCC is the notch unfolding, hosted in the notch's own window — not a + separate panel or popup. +- The shell ships two palettes, `ctos-pine` (default) and `ctos-dark`, both + defined as 22 named slots in + [`../desktop/core/palettes/`](../desktop/core/palettes) and documented in + [`../target/colors.md`](../target/colors.md). The active palette is a user + setting, switchable from the radial's appearance panel without a restart. + Dynamic *wallpaper-derived* theming remains out of scope. +- The greeter and lockscreen are preserved and packaged, not redesigned. +- The primary public integration is a Home Manager module exported from a + flake-parts-organized flake. + +## Known divergences + +Tracked so they are not mistaken for oversights: + +- `programs.ctOS` exposes only `enable`. The contract in + [nix-module.md](nix-module.md) is unimplemented, and no module generates + `settings.json`. +- `Theme.barHeight` (36) and `Settings.barHeight` (32) disagree. Being + reconciled. +- The shipped keybinding for `Super+Space` opens the Command Deck, not the CCC. +- Brightness and power-profile services described in earlier revisions of the + architecture document do not exist and are not in scope. diff --git a/shell/docs/design/architecture.md b/shell/docs/design/architecture.md index 6d92864e..a398b105 100644 --- a/shell/docs/design/architecture.md +++ b/shell/docs/design/architecture.md @@ -1,61 +1,101 @@ -# ctOS v1 architecture +# ctOS shell — architecture + +Visual and behavioural target lives in [`../target/`](../target/README.md). This +document covers only the structural rules that target cannot express. ## Runtime shape -The current `bar.qml` prototype is replaced by a desktop `shell.qml` scope. The existing greeter remains a separate entry point and security boundary. They may share palette, typography, and primitive visual components, but desktop code must not depend on greeter authentication or state code. +`shell.qml` is a Quickshell `Scope` that owns **every** `PanelWindow` in the +desktop shell. Surfaces are `Item`s hosted by it; only `AmbientBar` is itself a +`PanelWindow`, because it is instantiated per output via `Variants`. -```text -shell.qml - ├─ ShellState / OverlayController - ├─ service adapters - ├─ per-output AmbientBar windows - ├─ one CommandDeck surface - ├─ one SystemRail/EventLog surface - └─ non-focus-stealing OSD and notification surfaces ``` +shell.qml (Scope) +├─ IpcHandler { target: "ctos" } external control surface +├─ per-output AmbientBar × N the notch and, when open, the CCC +├─ per-output telemetry windows × N optional desktop widgets +└─ host windows overlay, toasts, packet analyzer +``` + +The CCC is **not** a separate host window. See below. + +## Single-surface hosting + +The target requires that the CCC read as the notch unfolding — same border, same +glow, no seam. Two independently positioned layer-shell surfaces cannot do this: +the compositor separates them and no border can wrap continuously across the join. + +Therefore the CCC is a child of `AmbientBar`, positioned directly beneath the +notch pill, and `AmbientBar`'s window grows downward to contain it. Consequences +that must be respected: -`OverlayController` is the sole owner of primary-overlay state. Its public operations are `openCommandDeck`, `openSystemRail`, `openEventLog`, and `close`. Opening one primary surface replaces the prior one. OSDs and toasts are independent transient layers. +- `AmbientBar` must stay `ExclusionMode.Ignore` and must never grow its + exclusive zone. +- The input region must be recomputed whenever the notch or CCC changes size. + `flushWaylandMask()` (`AmbientBar.qml`) exists for exactly this and must be + extended to cover the CCC bounds, not just the pill. +- `AmbientBar` is per-output, so the CCC is per-output. That is correct — the CCC + belongs on the output that was focused when it opened. +- Anything that must sit *above* the CCC (toasts) keeps its own host window on a + higher layer. ## Module boundaries | Boundary | Responsibility | | --- | --- | -| `common` | Design tokens, reusable frame/input/list primitives, configuration loading, logging, and no desktop-specific policy. | -| `desktop/core` | Shell lifecycle, overlay state, settings, screen selection, keyboard focus, and action registry. | -| `desktop/services` | Normalized read/write state for compositor, audio, network, power, notifications, brightness, and MPRIS. | -| `desktop/surfaces` | Bars, panels, OSDs, and toasts; renders state but does not invoke shell commands directly. | -| `desktop/adapters/hyprland` | Hyprland-specific workspace/focused-window/session behavior behind compositor-neutral service interfaces. | -| `greeter` | Existing greetd/lockd modes and PAM/authentication behavior. | +| `desktop/core` | Shell lifecycle, overlay state, settings, design tokens, action registry. | +| `desktop/services` | Normalised read/write state. Exposes `available` plus named actions. | +| `desktop/surfaces` | Rendering only. Never spawns a process. | +| `desktop/adapters/hyprland` | Hyprland specifics behind compositor-neutral service interfaces. | +| `greeter` | Separate entry point and security boundary. Desktop code must never import it. | + +**The rule that keeps breaking:** no surface may spawn a process. `CommandCenter` +currently violates this for session actions; that is being removed. Command-line +bridges belong in `desktop/adapters/`, not in services and never in surfaces. + +## Overlay ownership + +`OverlayController` is the sole owner of primary-overlay state. Nothing outside +it may write `activeSurface`, and it should be made `readonly` with a private +setter to enforce that. Popup state for calendar, Bluetooth and network is held +in `shell.qml` and is mutually exclusive with the primary overlay. + +Screens that disappear must close whatever was hosted on them. `onScreensChanged` +in `shell.qml` implements this; the same check must cover the per-output CCC. + +## Service contract -No UI surface polls shell commands itself. If a backend requires a command-line bridge, its adapter owns lifecycle, parsing, debouncing, error state, and cleanup. UI receives reactive values and named actions only. +Every service exposes `available` and a set of named actions, and every read +property is guarded so a missing backend yields a defined value rather than +`undefined` or a fabricated one. A service that is unavailable must not prevent +startup, and it must not be retried without bound. -## Services and degraded behavior +`available` must be a real probe of the backend, not a copy of a user setting and +not a hardcoded `true`. Optional services should log once on degrade. -Services expose `available`, `status`, state values, and named actions. An unavailable optional service never prevents shell startup. +Command-line-backed services must capture `stderr` and handle `exited`, so a +failed integration is observable rather than silent. -| Service | V1 source | Degraded behavior | -| --- | --- | --- | -| Compositor | Quickshell Hyprland integration | Shell starts; workspace/focused-window widgets hide or show unavailable state. | -| Audio | PipeWire | Audio controls hide; no OSD value is fabricated. | -| Network | NetworkManager | Network segment reports unavailable; System Rail omits network controls. | -| Battery | UPower | Battery segment/control is omitted on desktops or unavailable systems. | -| Brightness | configured brightness backend | Brightness control and OSD are omitted. | -| Power profiles | power-profiles backend | Power profile section is omitted. | -| Notifications | Quickshell notification integration | Feature is disabled with a clear startup diagnostic. | -| MPRIS | Quickshell or a dedicated adapter | Ambient media marker is omitted. | +## Configuration -The action registry gives Command Deck and clickable controls the same action definitions. Session actions use a confirmation state in the UI; adapters only execute a confirmed request. +Runtime settings are read from `~/.config/ctos/settings.json` by `Settings.qml`, +overridable via `CTOS_SETTINGS_PATH`. The Home Manager module should generate +this file; today it does not, so the file is hand-maintained. -## Configuration and multi-monitor behavior +`Theme.qml` is the only source of colour. New colours become tokens. The navy +ramp and neon palette are defined in [`../target/colors.md`](../target/colors.md). -Runtime settings are supplied declaratively by the Home Manager module. QML receives a generated, user-readable settings file rather than embedding machine-specific values. Settings cover enabled features, theme/wallpaper selection, reduced motion, and keybinding integration. +## Scaling -Each output receives an ambient bar. Full-screen panels use the focused output at opening time. If the output disappears, the panel closes and focus returns to the remaining desktop; no stale screen reference may keep the shell alive. The shell reload path must preserve no authentication state and may safely restart desktop surfaces. +`Theme` currently has no scale factor and the shell contains roughly 2,400 +absolute `Theme.*` references plus hundreds of literal pixel values. Nothing is +DPI-aware. A scale token is required before the CCC's larger geometry is tuned; +see the target spec's implementation order. -## Security and reliability constraints +## Security and reliability -- ctOS does not collect, persist, or log Wi-Fi secrets, notification body data beyond in-memory/current-session history, or authentication credentials. -- Notification history is session-local in v1. -- The desktop shell must not run privileged operations. NixOS services own system privilege boundaries. -- Greeter and lockscreen remain independently launched and retain their current PAM/greetd/lockd security model. -- A failed optional integration is logged once with actionable diagnostics; repeated retries must be bounded. +- The shell runs unprivileged. Privileged operations belong to NixOS services. +- Wi-Fi secrets are never logged or persisted, and must not be passed as process + arguments where they become visible in `/proc`. +- Notification history is session-local. +- The greeter retains its own PAM/greetd model and is launched independently. diff --git a/shell/docs/design/dynamic-island-event-log-morph.md b/shell/docs/design/dynamic-island-event-log-morph.md deleted file mode 100644 index 1b496cb6..00000000 --- a/shell/docs/design/dynamic-island-event-log-morph.md +++ /dev/null @@ -1,93 +0,0 @@ -# Dynamic Island & Command Center Event Log Morph Architecture - -## 1. Context & Problem Statement - -In ctOS v1, notifications and system events are surfaced through the **Dynamic Island** in the top bar (`AmbientBar`), while the **Event Log** was initially conceived as a separate dropdown popup window below the bar. - -When attempting to morph the Dynamic Island by enlarging `DynamicIsland.qml` directly inside `AmbientBar.qml`, two critical Wayland compositor and Layer-Shell issues occurred: - -1. **Wayland Layer Conflict & Input Interception (`overlayHost` covers `AmbientBar`)**: - - `AmbientBar` resides on `WlrLayer.Top` (default for panels). - - When the Event Log overlay opens, `overlayHost` in `shell.qml` becomes visible on `WlrLayer.Overlay`. - - In the Wayland Layer-Shell specification, `WlrLayer.Overlay` is strictly **above** `WlrLayer.Top`. - - The full-screen `scrimBackdrop` MouseArea on `WlrLayer.Overlay` intercepts all pointer clicks below the top bar's 40px exclusive zone. Clicks meant for the expanded Event Log hit the scrim instead and close the surface, making only the top 40px strip clickable. - -2. **Per-Frame Wayland Surface Reconfiguration & Mask Thrashing**: - - Animating `implicitHeight` on `AmbientBar` during the 300ms transition forces Quickshell to send `zwlr_layer_surface_v1.set_size` requests to Hyprland on every animation frame. - - The compositor must negotiate `configure` events and reallocate/render a full-screen-width surface buffer (1920–3840px wide) 60–144 times per second. - - Concurrently, `centerSection.widthChanged` and `centerSection.heightChanged` triggers `flushWaylandMask()`, thrashing `wl_region` allocations and destroying frame rates. - ---- - -## 2. Architectural Approaches - -### Approach A: The Hybrid Takeover (Selected for Implementation) - -#### Core Concept -Keep `AmbientBar` lean, static, and performant at a fixed 40px height on `WlrLayer.Top`. The morphing animation and expanded Command Center are delegated to a dedicated window on **`WlrLayer.Overlay`** (`eventLogPopupHost`), positioned at the exact screen coordinates of the Dynamic Island. - -```text -Idle State: -[ Left Sections ] -------- [ Dynamic Island (120x34) ] -------- [ Right Sections ] (AmbientBar on WlrLayer.Top) - -Click / Open EventLog: -1. AmbientBar's Dynamic Island pill is hidden / opacity 0. -2. overlayHost scrim fades in on WlrLayer.Overlay. -3. eventLogPopupHost on WlrLayer.Overlay (anchored top-center, margin-top: 3px) - begins animation from 120x34 down to 360x500px on the GPU. -4. All contents (Event Log tabs, notifications, controls) are 100% interactive. -``` - -#### Key Architecture Specifications -- **Window Layer**: `WlrLayershell.layer: WlrLayer.Overlay`, `exclusionMode: ExclusionMode.Ignore`. -- **Fixed Canvas Geometry**: The `PanelWindow` has a static transparent canvas (`width: 360, height: 508`, `anchors { top: true; horizontalCenter: true }`, `margins { top: 3 }`). -- **Zero Wayland Resizing**: The Wayland surface size remains completely static. The visual morphing happens entirely within QML via GPU rendering of the inner `Rectangle` (from 120×34 to 360×500). -- **Zero Mask Thrashing**: No dynamic Wayland `Region` mask updates are required during animation. -- **Full Pointer Interactivity**: Because the window lives on `WlrLayer.Overlay` above the scrim, all buttons, tabs, scrollbars, and cards receive pointer events without interference. - ---- - -### Approach B: 100% Standalone Autonomous Island Window - -#### Core Concept -Decouple the Dynamic Island from `AmbientBar` completely. The center of `AmbientBar` remains an empty gap, while the Dynamic Island is instantiated as an independent, floating `PanelWindow` on `WlrLayer.Overlay`. - -```text -[ AmbientBar: Left Sections ] (Empty Gap) [ AmbientBar: Right Sections ] - │ - [ Floating Dynamic Island ] (Autonomous Window on Overlay) - │ - (Morphs down to Command Center) -``` - -#### Key Architecture Specifications -- **True Floating Island**: Sits above all desktop content, full-screen video, and overlays. -- **Multi-State Live Activities**: Can morph between: - - `Compact Pill` (120×34): Idle status, unread indicator, DND indicator. - - `Media Pill` (240×34): Mini player with album art, song title, and animated visualizer bars. - - `Notification Toast` (320×44): Incoming alert banner. - - `Command Center HUD` (380×520): Multi-tab control center. -- **Wayland Input Considerations**: Requires managing transparent input boundaries via a single, simple `Region` mask or sizing the window dynamically between distinct discrete states when not transitioning. - ---- - -## 3. Decision & Implementation Plan - -**Selected Strategy**: **Approach A (Hybrid Takeover)** - -### Step-by-Step Execution Plan: -1. **Restore `AmbientBar.qml`**: - - Revert `implicitHeight` to static `Theme.barHeight` (40px). - - Keep `centerSection` (Dynamic Island) compact/notification pill inside the bar. - - When `OverlayController.activeSurface === OverlayController.Surface.EventLog`, hide or synchronize the bar's Dynamic Island pill. - -2. **Refactor `DynamicIsland.qml`**: - - Keep the clean compact (120×34) and notification (300×34) states inside the bar. - - On click: call `OverlayController.openEventLog()` to trigger the overlay takeover. - -3. **Implement Morphing in `EventLog.qml` / `eventLogPopupHost`**: - - In `shell.qml`, re-enable `eventLogPopupHost` on `WlrLayer.Overlay`. - - Anchor `eventLogPopupHost` to `top: true, horizontalCenter: true` with `margins { top: 3 }`. - - Set fixed transparent canvas (`width: 360, height: 508`). - - In `EventLog.qml`, animate the root container geometry from `width: 120, height: 34` up to `width: 360, height: 500` upon opening (and down upon closing). - - Cross-fade header, tabs, and content smoothly as the panel reaches its expanded dimensions. diff --git a/shell/docs/design/interaction-spec.md b/shell/docs/design/interaction-spec.md index 68759c73..b0c8a212 100644 --- a/shell/docs/design/interaction-spec.md +++ b/shell/docs/design/interaction-spec.md @@ -1,61 +1,104 @@ -# ctOS v1 interaction specification +# ctOS shell — interaction specification -## Global behavior +Visual target: [`../target/`](../target/README.md). Geometry, colour and section +contents are specified there and are not repeated here. This document covers +input routing, state transitions and safety rules. -ctOS has one primary overlay at a time. Opening the Command Deck, System Rail, or Event Log closes another primary overlay first. A click on the desktop background or `Escape` closes the active overlay. Closing never discards a completed action or notification history. +## One primary surface at a time -The bar exists on each active output. Workspace and focused-window data correspond to that output where Hyprland exposes it; otherwise ctOS shows global focused state without fabricating per-monitor data. +The notch is always present. On top of it, at most one of the following is open: +Command Deck, CCC, Calendar C. -Suggested default bindings are part of the Home Manager module and remain overridable: +Opening one closes another. `Escape` and an outside click both close the active +surface. Closing never discards notification history or a completed action. -| Action | Default binding | +The Command Deck and the CCC are opened by **different** targets and must never be +ambiguous: + +| Target | Opens | | --- | --- | -| Toggle Command Deck | `Super` | -| Toggle System Rail | `Super+Space` | -| Toggle Event Log | `Super+N` | -| Lock session | `Super+L` | +| Notch **logo** | Command Deck | +| Notch **body** (idle or expanded) | CCC | +| Notch **clock** | Calendar C | +| Notch **workspace slot** | switches workspace; does not open a surface | + +## Notch state machine + +``` +IDLE ──hover──► EXPANDED ──pointer leave──► IDLE + │ │ + └──click──► CCC_OPEN ──collapse / Esc / outside──► EXPANDED +``` -The module must avoid silently replacing a user binding; documented overrides and disabling generated bindings are supported. +`EXPANDED` and `CCC_OPEN` are mutually exclusive. While `CCC_OPEN`, pointer exit +must not collapse the notch. Any implementation that lets hover-collapse and +CCC-open run concurrently produces flicker — this has been regressed before and +the pin is not optional. -## Ambient bar +Notification arrival and media playback are *content* changes within the current +state, not separate states. They do not alter the hover contract. -The bar is a thin, low-contrast per-output panel. It shows: +## Output selection -- workspace state and the focused workspace; -- focused application identity when it adds context; -- compact network, output-volume, battery, and time state; -- an active-media marker when an MPRIS player is playing. +The CCC and Calendar C open on the output that was focused at the moment of the +click. If that output disappears while the surface is open, the surface closes and +state returns to the remaining desktop. No stale screen reference may keep a +surface alive. -CPU, RAM, expanded network names, and detailed media controls do not remain visible. Clicking an actionable state opens its corresponding utility surface; scroll on the volume indicator adjusts output volume. Missing state is omitted or represented by an unambiguous unavailable indicator, never invented sample data. +## Input handling -## Command Deck +The notch changes size during hover, so pointer hit-testing must follow the +*visible rounded pill*, not the item's rectangular bounds. A rectangular test +re-triggers hover in the corners as the pill grows and produces a hover loop. -The centered Command Deck opens with focus already in its query field. It is a hybrid command palette: +- Hover is tracked with a `HoverHandler`, not a `MouseArea`. `MouseArea` both + consumes button events and reports hover, which conflicts with the button + routing the notch needs. +- Button routing stays on a `MouseArea` placed behind the content, which rejects + presses outside the pill. +- Clicking inside a surface must not be swallowed by the surface that dismisses + on outside click. Use a dismissal layer beneath the panel plus an accept + consumer inside it. +- Wheel over the notch or CCC header adjusts volume in 5 % steps. -- normal text searches installed desktop applications; -- built-in actions include opening ctOS surfaces, lock, and session actions; -- arrow keys change selection, `Enter` activates it, and `Escape` closes the deck; -- mouse selection and activation match keyboard behavior; -- results are grouped as Applications and Actions, ordered by exact/prefix match then stable desktop-entry order; -- no files, clipboard records, shell execution, or arbitrary command evaluation occur in v1. +## Keyboard -Launching an application or completing an action closes the Deck. Destructive session actions are routed to the System Rail confirmation step rather than executing directly from a search result. +| Action | Default binding | +| --- | --- | +| Toggle Command Deck | `Super` | +| Toggle CCC | `Super+Space` | +| Toggle Calendar C | `Super+N` | +| Lock session | `Super+L` | + +These are proposed defaults exposed through the Home Manager module and must be +overridable without the module silently replacing a user binding. Note that +`Super+Space` currently maps to `toggleCommandDeck` in the shipped config; that +mapping must change to match this table. -## System Rail and session safety +Command Deck keyboard model: focus starts in the query field; arrows move +selection; `Enter` activates; `Escape` closes. Mouse behaviour must match. -The right-side System Rail contains output volume/mute, microphone volume/mute, brightness, Wi-Fi state and available networks, battery state, power profile, do-not-disturb, and session actions. It is a focused control surface, not a telemetry dashboard. +## Session safety -- Audio and microphone support click mute and wheel/slider adjustment. -- Brightness supports slider and hardware-key OSD adjustment where a brightness backend exists. -- Wi-Fi shows its connected state and requests a connection only through the configured NetworkManager backend; secrets are never displayed or stored by ctOS. -- Power-profile controls appear only when a power-profile backend is available. - **Lock** executes immediately. -- **Logout**, **reboot**, and **power off** replace the action list with an explicit in-panel confirmation state. `Escape` or Cancel returns safely; Confirm executes the selected action. +- **Logout**, **reboot** and **power off** replace the action row with an explicit + in-panel confirmation. `Escape` or Cancel returns safely. +- Power off is the only element rendered in the danger colour. +- All four go through `SessionService`. No surface may spawn the underlying + command itself. + +## Notifications -## Notifications, OSDs, and media +ctOS is the notification server when the feature is enabled. An incoming +notification creates a brief toast and a history record. Do-not-disturb suppresses +toasts and retains history. Urgency is conveyed by icon tint and an edge stripe, +not by recolouring text. -ctOS is the sole notification server when its notifications feature is enabled. Incoming notifications create a brief, non-blocking toast and an Event Log record. The Event Log opens in the right rail, supports dismiss-one and clear-all, and visually distinguishes urgency. Do-not-disturb suppresses toasts but retains history. +## Accessibility -Volume and brightness changes show compact, short-lived OSDs. OSDs never steal keyboard focus and are suppressed or simplified under reduced motion. +`Settings.reducedMotion` collapses all durations and disables springs. Every +surface must honour it — this is currently not true of the radial subsystem or +the notification urgency pulse. -When a player is actively playing, the bar shows an ambient title/player marker. V1 does not provide playback controls or a media panel. +Meaning is never carried by colour alone. All controls expose visible focus and +readable contrast independent of the accent. diff --git a/shell/docs/design/vision.md b/shell/docs/design/vision.md index b61db933..7e72aef3 100644 --- a/shell/docs/design/vision.md +++ b/shell/docs/design/vision.md @@ -1,54 +1,81 @@ -# ctOS desktop shell — vision +# ctOS shell — vision ## Product statement -**ctOS is a calm Wayland desktop that reveals a live operations interface when summoned.** It is a personal NixOS/Hyprland rice built with Quickshell. It takes inspiration from ctOS’s interface grammar—machine identity, terse signals, grids, segmented controls, and verification states—without reproducing game screens or claiming affiliation. - -The desktop must remain pleasant to use for long work sessions. Visual spectacle is an interaction response, not permanent background noise. - -## Audience and platform - -- Primary audience: the maintainer’s personal NixOS configuration. -- Runtime target: NixOS with Hyprland and Wayland. -- Input target: keyboard-first, with every v1 interaction also usable by mouse. -- Development hosts may differ from NixOS; they are not supported deployment targets. +**ctOS is a calm Wayland desktop whose entire top surface is one shape that +changes according to what you are doing.** A single notch rests at the top of +every output, breathes open on hover, and unfolds into a full command centre on +click. There is no other bar, no tray, and no cluster of independent popups. ## Experience model -| Layer | Purpose | Visual intensity | +| State | Surface | Intensity | | --- | --- | --- | -| Ambient | Bar, state indicators, OSDs, brief notification toasts | Quiet, low contrast, compact | -| Utility | Command Deck, System Rail, Event Log | Functional panels with ctOS framing | -| Immersive | Future overview and optional dashboard | Rich grid, telemetry, and staged motion | +| Idle | 220 × 30 notch | quiet, near-black, one gradient edge | +| Hover | 320 × 60 notch | adds a second content row | +| Open | Command & Control Center | the working surface — dense on demand | +| Deep | Calendar C | full-width, month + timeline | -Only ambient and utility layers are included in v1. At rest, the user sees one thin bar per output and the wallpaper. All dense information is intentional and on demand. +At rest the user sees a small pill and the wallpaper. Everything else is a +consequence of an explicit action. ## Visual grammar -- **Base:** near-black surfaces, subtle grid/noise, hairline dividers, square or minimally rounded geometry. -- **Typography:** a legible monospace family for identifiers, values, and metadata; reserve large type for the active command/query, not decoration. -- **Color:** neutral grayscale carries almost all content. Acid green means active, connected, verified, selected, or recording. Red means error, warning, or destructive action. Color never supplies meaning alone. -- **Identity:** use neutral ctOS-inspired terms such as `NODE`, `SESSION`, `EVENT LOG`, and `COMMAND DECK`; avoid copied in-game text, layouts, or assets. -- **Density:** labels are short and values are scannable. Details expand inside panels rather than crowding the bar. -- **Wallpaper:** v1 ships a curated fixed dark wallpaper selection. Wallpaper-derived palettes are out of scope. - -## Motion and accessibility - -- Opening a utility surface may use a short scan/assemble transition; closing is immediate or nearly immediate. -- Motion communicates hierarchy and state only. It must not delay typing, clicking, or closing. -- `reducedMotion` removes decorative scans, staggered content, and persistent noise while retaining essential focus/state changes. -- All controls expose visible keyboard focus, accessible text labels/tooltips where useful, and readable contrast independent of green/red accents. -- The shell must remain usable when a font, battery, network, media player, or optional backend is unavailable. - -## v1 outcome and non-goals - -The v1 daily-driver experience consists of an ambient bar, Command Deck, System Rail, volume/brightness OSDs, native notifications with history, ambient playback state, and declarative packaging of the existing greeter. - -V1 explicitly does not include Niri support, a window overview, clipboard history, file search, screenshot/recording workflows, dynamic theming, a media-control panel, or broad cross-distro support. - -## Future Enhancements (v2 Roadmap) -- **Network / Wi-Fi Popup**: A drop-down to list and connect to specific SSIDs. -- **Audio Mixer & Sink Selector**: A drop-down to switch audio output devices and control microphone input volume/mute. -- **Clipboard History Manager**: A utility surface to track and paste recent clipboard copies. -- **Power Profile Manager**: A drop-down attached to the Battery widget to toggle system power states (Performance, Balanced, Power-Saver). -- **Packet Analyzer Widget**: A live scrolling stream of network traffic (powered by `tshark`) to provide a real-time tactical overview of network activity. +- **Base:** deep and low-chroma, never a flat neutral. Two palettes ship; + `ctos-pine` is a Rosé Pine derivative and `ctos-dark` a neutral grey with one + green-tinted surface. Neither is "the" palette -- the shell picks one from + `Settings.theme`. +- **Identity:** a horizontal gradient along the notch and CCC border, running + `cool → accent → accent`. Because the mid and end stops resolve to the same + slot, only the left half carries a hue shift: teal→lavender in pine, + teal→green in acid. +- **Meaning of colour** is per-role and identical in both palettes: + - `accent` = active, selected, focused, today + - `cool` = interactive affordance, live value + - `status` = healthy, connected, positive + - `destructive` = error, danger **only** + - `warning` = caution +- The accent is not green. That was the central change from the shell's first + identity, where one green meant everything at once. It holds in pine, where + the accent is lavender. In acid the accent happens to be green again, but by + coincidence of that palette rather than by intent — the slots are separate so + pulling them apart is a one-value change. +- **Typography:** monospace for data, a proportional sans for labels. Large type + is reserved for the clock and active query, never decoration. +- **Geometry:** stadium-shaped pill at rest; 12–16 px radii on cards; hairline + borders; generous internal padding. +- **Glow:** present but restrained. A shell, not a nightclub. + +## Motion + +Motion communicates state change only. Springs drive the notch on both axes with +a slight overshoot; accordions interpolate height; content crossfades. No +persistent ambient animation except a subtle NOW marker in Calendar C. + +Motion must never delay input. `reducedMotion` removes all of it. + +## Identity and originality + +The shell uses original terms and construction. It is inspired by the general +grammar of tactical interfaces — terse labels, grids, segmented controls, +verification states — without reproducing game screens, layouts, or assets, and +without claiming affiliation. + +## Scope + +**In:** the unified notch, the CCC, Calendar C, Command Deck, notifications, +session controls with confirmation, and declarative packaging including the +existing greeter. + +**Out:** Niri support, window overview, clipboard history, file search, screenshot +workflows, dynamic wallpaper theming, and any second top-edge surface. + +The radial settings skill tree is now a target surface rather than a retained +prototype. It is where the shell's own settings live, including the palette +switch, so it is held to the same rules as the notch and the CCC: its controls +are real, its values are read from and written to Settings, and its lock states +name a requirement they actually enforce. + +The floating desktop telemetry widgets still predate this target and are +retained unchanged. They are not part of the target design and their removal is +a separate decision. diff --git a/shell/docs/hardcoded_offsets_analysis.md b/shell/docs/hardcoded_offsets_analysis.md deleted file mode 100644 index c613688a..00000000 --- a/shell/docs/hardcoded_offsets_analysis.md +++ /dev/null @@ -1,36 +0,0 @@ -# Radial Skill Tree UI - Hardcoded Offsets Analysis - -This document provides a comprehensive analysis of the hardcoded screen offsets and sizing within the Radial Skill Tree UI and their potential impact on functionality and layout across different screen resolutions. - -## Findings - -### 1. Hardcoded Radial Wheel Hitboxes & Dimensions -- **File:** [`CircularSettingsMenu.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/CircularSettingsMenu.qml#L17-L19) - - `baseDiameter: 420` and `outerRadius: 210` are defined as absolute values. -- **File:** [`RadialSettings.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/RadialSettings.qml#L397-L400) - - The surface-wide `MouseArea` relies on strict absolute radius limits: `distFromCenter < 170` triggers expansion and `distFromCenter > 280` triggers dismissal. -- **Impact:** Since the radial menu diameter and the click interaction radii are fixed in absolute pixels, they do not scale on smaller displays (e.g., 1280x800) or high-DPI screens. - -### 2. Expanded Branch Origin and Node Overlap -- **File:** [`CircularSettingsMenu.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/CircularSettingsMenu.qml#L20) and [`RadialSettings.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/RadialSettings.qml#L276) - - `wheelMenu.leftAnchorX` is dynamically calculated as `Math.max(100, width * 0.06)`, establishing a minimum 100px anchor. - - The tree branch origin is explicitly offset by `branchOriginX: wheelMenu.leftAnchorX + 350`. -- **File:** [`ContextPanel.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/ContextPanel.qml#L30-L36) - - The right-side context panel scales its width: `Math.min(440, Math.max(340, parent.width * 0.3))`. -- **File:** [`SkillTree.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/SkillTree.qml#L325-L328) - - Edges and nodes are laid out using fixed model coordinates added directly to `branchOriginX`. -- **Impact:** At smaller resolutions, the fixed `branchOriginX` (minimum 450px) and the responsive `ContextPanel` (e.g., 384px wide at 1280px screen width) leave very limited horizontal space (e.g., ~430px) for the actual skill tree nodes. Any deep tree branches will inevitably overlap the `ContextPanel` or render off-screen since node layouts don't scale or wrap. - -### 3. Navigation Controller Interaction Boundaries -- **File:** [`NavigationController.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/shell/desktop/surfaces/radial/NavigationController.qml#L52-L55) - - The hover zone logic for the skill tree uses: `treeMaxX = Math.max(treeMinX + 100.0, root.surfaceWidth - 480.0)`. -- **Impact:** While `Math.max` prevents `treeMaxX` from crossing `treeMinX`, on narrow screens where `surfaceWidth - 480.0` is small, the hover interaction area could compress to a mere 100px wide strip. Nodes rendering outside this strip will not respond to mouse hover or clicks, breaking spatial interaction. - -### 4. Correction on Test Validations -- A prior investigation hypothesized that `test_m2_multimonitor_deep_stress.qml` might validate layout overlaps for the radial skill tree components. -- **Correction:** Inspection of [`test_m2_multimonitor_deep_stress.qml`](file:///home/reze/.gemini/antigravity/worktrees/ctOS/radial_skill_tree_ui/tests/e2e/harness/test_m2_multimonitor_deep_stress.qml) reveals that it exclusively measures and tests the `AmbientBar` and `DynamicIsland` top bar components to ensure they don't overlap across 2560x1440, 1920x1080, 1366x768, and 1280x800 resolutions. It contains absolutely no stress testing or overlap validation for the Radial Settings or Skill Tree components. - -## Remaining Questions & Gaps -- **Model Node Coordinates:** We have verified that `SkillTree.qml` maps node coordinates as `root.branchOriginX + node.pos.x`. However, the actual distribution of `pos.x` values in `RadialSettingsModel.qml` hasn't been mapped to verify the maximum expected horizontal span of the tree. -- **Dynamic Scaling Solution:** What is the best strategy to introduce relative scaling? Implementing a viewBox or scaling transform on the `SkillTree.qml` could fix the overlap, but may require coordination with the hover logic in `NavigationController.qml` to map scaled coordinates back to raw screen coordinates. -- **C++ Component Overrides:** It remains unverified if the underlying C++ `OverlayController` bounds or resizes the surface beyond the QtQuick constraints. diff --git a/shell/docs/target/README.md b/shell/docs/target/README.md new file mode 100644 index 00000000..be624b85 --- /dev/null +++ b/shell/docs/target/README.md @@ -0,0 +1,42 @@ +# Target specification + +The authoritative design target for the ctOS shell. + +| File | Purpose | +| --- | --- | +| [`neon-notch-spec.md`](neon-notch-spec.md) | The specification. Behaviour, geometry, interaction, implementation order. | +| [`colors.md`](colors.md) | Palette, navy ramp, semantic token mapping for `Theme.qml`. | +| `images/` | The four reference diagrams. Source of truth for visual decisions. | + +## Reference images + +| # | File | State | +| --- | --- | --- | +| 1 | [`images/01-notch-idle.png`](images/01-notch-idle.png) | Unexpanded / idle notch | +| 2 | [`images/02-notch-expanded.png`](images/02-notch-expanded.png) | Expanded / hover notch | +| 3 | [`images/03-command-center.png`](images/03-command-center.png) | Adaptive Command & Control Center | +| 4 | [`images/04-calendar-timeline.png`](images/04-calendar-timeline.png) | Calendar C — Event Timeline Fusion | + +`images/before/` holds the current green-on-graphite shell for comparison. These +are historical only and carry no specification weight. + +## Authority order + +1. The reference images decide visual and layout questions. +2. `neon-notch-spec.md` decides behaviour, and decides every question the images + leave ambiguous — it states the chosen interpretation rather than deferring. +3. `colors.md` decides colour. + +Where an older document contradicts this directory, this directory wins. The +`../design/` documents have been reconciled to agree; if they ever drift again, +this directory is correct. + +## Conventions for implementers + +- Read `neon-notch-spec.md` §7 before starting. It fixes the order, and the early + steps exist to make later steps verifiable. +- Do not introduce fixed heights for any CCC section. The spec's height + requirement is content-driven throughout. +- Do not add a second top-edge surface. The notch is the only one. +- New colours go into `Theme.qml` as tokens. Do not inline hex values. +- `Settings.reducedMotion` must be honoured by anything that animates. diff --git a/shell/docs/target/colors.md b/shell/docs/target/colors.md new file mode 100644 index 00000000..ea953879 --- /dev/null +++ b/shell/docs/target/colors.md @@ -0,0 +1,181 @@ +# ctOS Colour System + +Source of truth for `shell/desktop/core/Theme.qml` and the palettes it selects +between, in `shell/desktop/core/palettes/`. + +This document previously described a navy palette (`#050E1E` base, `#EB4ADF` +magenta accent) that the code no longer contains. The tables below are generated +from the palette files and were checked against `Theme`'s resolved token values +under both palettes. + +## Architecture + +Three layers, and only the bottom one holds a hex: + +| Layer | Where | What it is | +| --- | --- | --- | +| Slots | `palettes/*.js` | 22 named slots per palette. The only place a colour value exists. | +| Roots | `Theme.qml`, `pal*` | One token per slot, wrapped in `Qt.color`. | +| Aliases | `Theme.qml`, everything else | ~90 call-facing tokens pointing at the roots. | + +A theme change is therefore a change to `Settings.theme` and nothing else. Every +alias re-resolves, which is why switching repaints the shell without a restart. + +## Palettes + +`ctos-pine` is the default. `ctos-dark` is the acid palette and predates the +navy rebrand, which is where its name comes from — it is not the darker of the +two, and both are dark. + +| Slot | `ctos-pine` | `ctos-dark` | Role | +| --- | --- | --- | --- | +| `bg` | `#13111E` | `#0E0E0E` | page background | +| `surface` | `#191724` | `#202020` | card, panel body | +| `raised` | `#262431` | `#333333` | anything lifted off the page | +| `hover` | `#2E2C39` | `#2A2A2A` | hover state | +| `active` | `#3F3D4A` | `#333333` | pressed / selected border | +| `selected` | `#262431` | `#1A2E24` | selected row. Tinted in acid, grey in pine | +| `border` | `#3F3D4A` | `#3F3D4A` | control edge | +| `borderMuted` | `#6E6A86` | `#7A7A7A` | de-emphasised edge | +| `divider` | `#3F3D4A` | `#3F3D4A` | separator between rows | +| `text` | `#E0DEF4` | `#FFFFFF` | primary text | +| `textDim` | `#C8C5DC` | `#CACACA` | secondary text | +| `textMuted` | `#908CAA` | `#9E9E9E` | tertiary text | +| `textDisabled` | `#3F3D4A` | `#4A4A4A` | disabled text | +| `textInverse` | `#13111E` | `#0E0E0E` | text on an accent fill | +| `accent` | `#C4A7E7` | `#1BFD9C` | active, selected, focused | +| `status` | `#31748F` | `#1BFD9C` | connected, charging, positive | +| `destructive` | `#EB6F92` | `#FC3E38` | error, danger | +| `warning` | `#F6C177` | `#FF964F` | caution | +| `cool` | `#8BBEC7` | `#66B2B2` | informational secondary | +| `love` | `#EB6F92` | `#1BFD9C` | the notch's identity mark | +| `destructiveDim` | `#5E3246` | `#6D211F` | muted half of a destructive pair | +| `radialBackdrop` | `#2A2313` | `#0C2126` | the radial settings surface's own floor | + +Notes that do not fit in a cell: + +- **`radialBackdrop` is not `bg`.** The radial covers the whole screen while it is + open and reads as its own place, so it takes a palette-specific floor — + bronze under pine, deep teal under acid — instead of sharing the desktop's page + background. Its grid checkerboard is tinted from the same slot, or the grid + would sit a different hue on the floor. Both values are dark enough for the + light text and grid that sit on them. +- **`border` and `divider` are separate slots that hold the same value in both + palettes.** They are kept apart because they mean different things and + because acid is where they would diverge if they were going to. +- **`selected` is the only tinted surface.** Acid's `#1A2E24` is a desaturated + green so a selected row reads as *selected* rather than merely brighter. Pine + cannot afford it: pine's `selected` is also its `raised`, and tinting it would + drag every raised surface green with it. +- **`accent` and `status` are the same value in acid** (`#1BFD9C`). That collapses + the accent/status distinction for that palette only. The slots stay separate so + pulling them apart later is a one-value change. +- **`destructiveDim` in acid is derived**, not picked: `#FC3E38` at 40% toward + `#0E0E0E`. No acid-era equivalent existed — the slot was introduced with the + navy rebrand — so it is expressed as a ratio so it keeps tracking + `destructive` and `bg`. +- `ctos-pine` is a Rosé Pine derivative from nvchad's base46 theme, not the + official Rosé Pine release. Its base is `#13111E` against official Main's + `#191724`, so it reads deeper and more muted. Per-slot provenance is in + `PinePalette.js`'s `notes()`. + +## Validation + +`Theme.themeName` resolves `Settings.theme` against its registry and checks that +all 22 slots are present and non-empty. An unknown name or an incomplete palette +falls back to `ctos-pine` with a `console.error` naming the problem. + +This is checked rather than assumed because a missing slot does not fail — it +resolves to `undefined`, `Qt.color(undefined)` is transparent black, and the +visible result is an invisible border or unreadable text with nothing in the log. + +## Token naming: two known warts + +Both are preserved deliberately rather than fixed, because fixing either changes +rendering or breaks call sites, and neither is worth that in a palette extraction. + +### `acidGreen` is not green + +`Theme.acidGreen` and `Theme.accentGreen` both resolve to the **accent** slot. +Under pine that is lavender; under acid it happens to be green. The name is +accurate only by coincidence in one of the two palettes. + +It is kept because it has ~127 call sites. Audited by meaning rather than name, +the large majority are borders, corner brackets, hover and focus rings — the +generic accent — and only about 39 are genuine status sites, which now use +`statusGreen` explicitly. Aliasing it to `statusGreen` would paint ~88 borders +and focus rings green. + +New code must not reference it. Use `accent` / `accentMagenta` for attention and +`statusGreen` for health. + +### `textMuted` resolves to the *border*-muted slot + +The slots distinguish `borderMuted` (`#6E6A86` in pine) from `textMuted` +(`#908CAA`). Before the split, `Theme.textDim` and `Theme.textMuted` were both +`#6E6A86` — the tree conflated the two roles. + +Mapping the tokens to the same-named slots would have quietly lightened every +muted label in pine, so the tokens keep the value they have always rendered with: + +| Token | Resolves to | Pine value | +| --- | --- | --- | +| `Theme.textDim` | `palette.borderMuted` | `#6E6A86` | +| `Theme.textMuted` | `palette.borderMuted` | `#6E6A86` | +| `Theme.textPrimaryDim` | `palette.textDim` | `#C8C5DC` | +| `Theme.textPrimaryDimmer` | `palette.textMuted` | `#908CAA` | + +The clean fix is to rename the slots to match the tokens rather than the reverse, +but that is a breaking change for anything that names a slot in config. + +## `gray50`–`gray900` are an alias layer, not a ramp + +These are the most-used tokens in the tree and the worst-named: `gray700` is a +hover surface, `gray900` is the page background, `gray600` is a border, and only +`gray50`/`100`/`200` are text steps. Each resolves to the slot that means the same +thing, which is what lets the neutral ladder follow the active palette instead of +pinning it. + +| Token | Slot | Pine | Acid | +| --- | --- | --- | --- | +| `gray50` | `text` | `#E0DEF4` | `#FFFFFF` | +| `gray100` | `textDim` | `#C8C5DC` | `#CACACA` | +| `gray200` | `text` | `#E0DEF4` | `#FFFFFF` | +| `gray300` | `textMuted` | `#908CAA` | `#9E9E9E` | +| `gray400` | `borderMuted` | `#6E6A86` | `#7A7A7A` | +| `gray500` | `borderMuted` | `#6E6A86` | `#7A7A7A` | +| `gray600` | `border` | `#3F3D4A` | `#3F3D4A` | +| `gray700` | `hover` | `#2E2C39` | `#2A2A2A` | +| `gray800` | `surface` | `#191724` | `#202020` | +| `gray900` | `bg` | `#13111E` | `#0E0E0E` | + +`border`, `ctosGray`, `divider` and `hairline` are the four tokens that resolve +identically in both palettes. + +## Canvas colours + +Canvas drawing APIs take CSS colour strings, not QML colours, so a small set of +string-form tokens exists: `accentMagentaHex`, `statusGreenHex`, `dangerHex`, +`dangerDimHex`, `textDimHex`, `gray300Hex`, `gray700Hex`. Each is a direct read +of the slot its colour-token namesake resolves to. + +For translucent draws use `Theme.withAlpha(colour, a)`. Six call sites in +`CpuHexGrid.qml` and one in `NetworkFlowMatrix.qml` used to hand-write +`rgba(27, 253, 156, …)` — invisible to a grep for palette drift, because the +literal never mentioned a token. That is how those two widgets stayed green +through the last rebrand while everything around them changed. + +## Gradients + +The notch ring is a three-stop horizontal gradient, `cool → accent → accent` +(`accentBlue → accentViolet → accentMagenta`, or `danger` at both ends while a +notification is at urgency 2). Because the mid and end stops resolve to the same +slot, only the left half of the ring carries a hue shift: teal→lavender in pine, +teal→green in acid. + +## Out-of-band colour + +The QML tree is clean: no hex literal outside the palette files, and no +`"black"`/`"white"` in QML. The remaining literals are the SVG assets in +`shell/desktop/assets/icons/`, whose `fill="white"` is the tinting mechanism — +`CtosIcon.color` recolours them — not a palette leak. diff --git a/shell/docs/target/images/01-notch-idle.png b/shell/docs/target/images/01-notch-idle.png new file mode 100644 index 00000000..903e81a1 Binary files /dev/null and b/shell/docs/target/images/01-notch-idle.png differ diff --git a/shell/docs/target/images/02-notch-expanded.png b/shell/docs/target/images/02-notch-expanded.png new file mode 100644 index 00000000..e338a8ca Binary files /dev/null and b/shell/docs/target/images/02-notch-expanded.png differ diff --git a/shell/docs/target/images/03-command-center.png b/shell/docs/target/images/03-command-center.png new file mode 100644 index 00000000..29397607 Binary files /dev/null and b/shell/docs/target/images/03-command-center.png differ diff --git a/shell/docs/target/images/04-calendar-timeline.png b/shell/docs/target/images/04-calendar-timeline.png new file mode 100644 index 00000000..8456f0d4 Binary files /dev/null and b/shell/docs/target/images/04-calendar-timeline.png differ diff --git a/shell/docs/target/images/before/01-idle.png b/shell/docs/target/images/before/01-idle.png new file mode 100644 index 00000000..32bbd7f3 Binary files /dev/null and b/shell/docs/target/images/before/01-idle.png differ diff --git a/shell/docs/target/images/before/02-expanded.png b/shell/docs/target/images/before/02-expanded.png new file mode 100644 index 00000000..ff34a9b4 Binary files /dev/null and b/shell/docs/target/images/before/02-expanded.png differ diff --git a/shell/docs/target/neon-notch-spec.md b/shell/docs/target/neon-notch-spec.md new file mode 100644 index 00000000..82f33040 --- /dev/null +++ b/shell/docs/target/neon-notch-spec.md @@ -0,0 +1,406 @@ +# Neon Living Notch — Target Specification + +**Status:** authoritative. This document supersedes all prior shell design documents. + +**Source of truth:** the four reference images in `images/`. Where prose and image +disagree, the image wins. Where an image is ambiguous, this document states the +chosen interpretation explicitly rather than leaving it open. + +**Supersedes:** the former `shell/docs/design/dynamic-island-event-log-morph.md`, +`shell/docs/hardcoded_offsets_analysis.md`, and the retired `migration/` planning +set (`living-notch-target.md`, `Implementation guidelines for Living notch.pdf`). + +--- + +## 0. Baseline — where the shell is today + +![current idle](images/before/01-idle.png) +![current expanded](images/before/02-expanded.png) + +The shipped shell is **green-on-graphite**: accent `#1BFD9C` on a neutral +`#0E0E0E`–`#CACACA` grayscale ramp, danger `#FC3E38`. The active workspace is +green. The target replaces this with a **navy-and-neon** identity where green is +demoted to a *status* role only and magenta becomes the attention/accent role. + +Geometry of the current notch already matches the target idle state (220 × 30) +and expanded height (60). Expanded **width** is 380 in code and must become 320. + +--- + +## 1. Section 1 — Unexpanded (Idle) Notch + +![idle notch](images/01-notch-idle.png) + +A single centered stadium-shaped pill, floating at the top of every output. + +### Geometry + +| Property | Value | +| --- | --- | +| Width | 220 px | +| Height | 30 px | +| Corner radius | `height / 2` (15 px) — fully rounded ends | +| Border | 2 px, horizontal gradient | +| Position | horizontally centered, anchored top | +| Reserved layout space | none (`exclusionMode: ExclusionMode.Ignore`) | + +### Fill and border + +- Fill: near-black navy, slightly translucent so wallpaper shows through. +- Border: 2 px gradient running **blue → violet → magenta** left to right. +- Outer glow: soft, matching the border gradient, strongest at the ends. + Glow must not bloom far enough to be visible against a light wallpaper. + +### Content order (single row) + +| # | Element | Treatment | +| --- | --- | --- | +| 1 | Logo tile | ~28 px rounded-square, dark navy fill, subtle border. Contains a hexagon glyph with a blue→violet gradient. | +| 2 | Workspace indicators | 5 fixed slots. Inactive = dark navy filled circle, muted number. **Active = magenta filled circle**, white number. Urgent = pulse. | +| 3 | Separator | 1 px vertical rule, muted blue, ~30 % opacity | +| 4 | Clock | `HH:MM`, large, text-primary | +| 5 | Separator | as above | +| 6 | Network dot | solid green circle when connected; red when disconnected; absent when unavailable | +| 7 | Wi-Fi glyph | outline | +| 8 | Battery glyph | outline, green when charging | +| 9 | Status text | `IDLE` / `DND` / `MEDIA` / `NOTIFICATION`, muted, letterspaced uppercase | + +### Interaction + +| Target | Action | +| --- | --- | +| Logo | click → Command Deck (launcher/search) | +| Clock | click → Calendar C | +| Workspaces | click slot *n* → switch to workspace *n* | +| Anywhere else | hover → expand; click → Command & Control Center | +| Wheel | volume ±5 % | + +### Required behaviour + +- The notch is the **sole** top-level bar element. No other top-edge surface + may exist. +- Nothing may be pushed down. The window must not reserve exclusive zone. +- The Wayland input region must track the pill exactly, at every size. + +--- + +## 2. Section 2 — Expanded (Hover) Notch + +![expanded notch](images/02-notch-expanded.png) + +Same pill, larger. Must read as the *same object breathing open*, never as a +different widget appearing. + +### Geometry + +| Property | Value | +| --- | --- | +| Width | 320 px | +| Height | 60 px | +| Corner radius | `height / 2` (30 px) | +| Border | 2 px, same gradient | + +### Content + +Row 1 (logo + workspaces, vertically centred) is unchanged from idle. + +The remainder becomes a **two-row block**: + +``` +┌────────────────────────────────────────────────────────────┐ +│ [logo] 1 2 3 4 5 │ TUE 01 OCT ⌁ 🔊 🔋 │ +│ │ 23:47:32 72% 72% 85% │ +└────────────────────────────────────────────────────────────┘ +``` + +- Date: uppercase, letterspaced, muted blue. +- Time: includes seconds, text-primary, larger than date. +- Wi-Fi glyph with percentage beneath. Percentage is **green** when connected. +- Volume glyph with percentage beneath, text-primary. +- Battery glyph with percentage beneath, text-primary. +- Status text retained at the trailing edge. + +### Animation + +Spring-driven on both axes. The pill must overshoot slightly and settle. + +```qml +SpringAnimation { + spring: 4 + damping: 0.3 + mass: 0.8 +} +``` + +Requirements: + +- Width, height, and corner radius interpolate together; no independent easing. +- Content opacity crossfades — no element may pop. +- Content must **not** be clipped during interpolation. +- `Settings.reducedMotion` collapses all durations to 0 and disables springs. +- Expansion is pinned while the CCC is open (see §3.4). + +--- + +## 3. Section 3 — Adaptive Command & Control Center + +![command center](images/03-command-center.png) + +### 3.1 Structure — one surface, not a panel + +The CCC is the notch **unfolding downward**. The notch header remains visible at +the top of the CCC, showing logo, workspaces, date/time, indicators, and a +collapse chevron. Body content sits directly beneath, inside the same rounded, +gradient-bordered container. + +``` +┌────────────────────────────────────────────────┐ +│ notch header ………………………………………… ⌃ │ ← stays +├─────────────────────────┬──────────────────────┤ +│ Notifications │ Power & Session │ +│ Wi-Fi │ Calendar & Events │ +│ Bluetooth │ System Status │ +│ Audio │ │ +└─────────────────────────┴──────────────────────┘ +``` + +This continuity is the central design requirement. Two separately-positioned +layer-shell surfaces cannot achieve it: the compositor will insert a seam and the +border/glow cannot wrap continuously. **The CCC must therefore be hosted inside +the notch's own window, which grows downward.** See +`../design/architecture.md` § "Single-surface hosting" for the mechanism. + +### 3.2 Geometry + +| Property | Value | +| --- | --- | +| Width | 760 px, clamped to `min(760, screenWidth - 80)` | +| Columns | 2, equal width, 12 px gutter | +| Top edge | flush with the notch's bottom edge | +| Corner radius | matches the notch pill at the join; 16 px at the bottom | +| Height | **content-driven**. No fixed height anywhere. | + +### 3.3 Sections + +Each section is a card: 12 px radius (`Theme.commandCenterSectionRadius`), +`Theme.surfaceDeep` fill, 1 px `Theme.borderSubtle`, 12 px internal padding +(`Theme.cardPadding`). + +| Section | Column | Header content | Body | +| --- | --- | --- | --- | +| Notifications | left | count, DND toggle, chevron | list of notification rows | +| Wi-Fi | left | state label, power toggle | connected network + network list | +| Bluetooth | left | state label, power toggle | paired + available devices | +| Audio | left | state label, power toggle | volume slider + media card | +| Power & Session | right | chevron; battery % and charge state as subtitle | 4 session buttons | +| Calendar & Events | right | `+` action button; date as subtitle | today's timeline | +| System Status | right | — | CPU / Memory / Disk / Network | + +**Only one or two sections may be expanded at a time.** Expanded state is +single-selection; opening one collapses the other. + +#### Notifications + +Each row: app icon tile (rounded square, tinted by source), bold summary, +muted relative time. DND is a pill switch, blue when engaged. Urgency is shown +by the icon tile tint and a left edge stripe, never by changing the text colour. + +#### Wi-Fi + +Connected network gets a row with a green icon tile, network name in bold, and +`Connected · 5 GHz` muted beneath. Expansion reveals the full network list with +inline password entry. + +#### Bluetooth + +Compact when collapsed. Power toggle in the header. Body holds paired devices +then discovered devices. + +#### Audio + +- Volume slider: horizontal, gradient track (violet → blue), circular handle, + percentage label at the trailing end. +- Media card: album art tile, track title bold, artist muted, transport row + (previous / play-pause in a filled circle / next), plus a waveform glyph. +- Only one of output-volume and mic is shown at a time to keep the card compact. + +#### Power & Session + +Four equal tiles in a row: **Lock, Logout, Reboot, Poweroff**. Each is an icon +above a text label. + +- Lock / Logout / Reboot: `--surface-deep` fill, subtle border. +- **Poweroff: `--danger` fill**, the strongest warning treatment in the shell. +- Reboot, Logout and Poweroff require inline destructive confirmation before + execution. Lock does not. + +#### Calendar & Events + +Vertical timeline for the selected day. + +``` + 09:00 ┃ [icon] Platform Team Standup ⋮ + 10:00 ┃ Microsoft Teams [in 14 min] + ┃ + 11:00 ┃ [icon] IFMS 3.0 – UAT Review ⋮ + 12:00 ┃ Conference Room A +``` + +- Time range on the left, muted, start above end. +- A thin vertical rule connects all events; each event contributes a thicker + coloured segment (magenta or green). +- Event card: app icon, bold title, muted source, trailing `⋮` menu. +- Nearest upcoming event carries a relative-time pill. +- Empty day shows an explicit empty state, not a blank area. + +#### System Status + +Four compact items in a row, glanceable only — this is awareness, not +observability: + +- CPU, Memory, Disk — each a donut gauge with the percentage in the centre. + Gauge colours: CPU green, Memory blue, Disk magenta. +- Network — upload and download rates with directional arrows. + +### 3.4 Interaction and state + +| Input | Result | +| --- | --- | +| Click notch (idle or expanded) | open CCC, pinned expanded | +| Click logo | Command Deck (never the CCC) | +| Click collapse chevron, or Escape | close CCC → notch returns to hover state | +| Click outside | close CCC | +| Wheel | volume, only while the notch/CCC header is targeted | + +**Hover pinning.** While the CCC is open the notch must not collapse on pointer +exit. State machine, single source of truth: + +``` +IDLE ──hover──► EXPANDED ──leave──► IDLE + │ │ + └──click──► CCC_OPEN ──close──► EXPANDED +``` + +`EXPANDED` and `CCC_OPEN` are mutually exclusive hover intents. Any +implementation that lets hover-collapse and CCC-open run concurrently will +produce the flicker this design is meant to avoid. + +**Context-aware priority.** On open, pre-expand the highest-priority section: + +| Condition | Section | +| --- | --- | +| media actively playing | Audio | +| `NetworkService.isConnecting`, or unexpected disconnect | Wi-Fi | +| battery < 20 % | Power & Session (highlighted) | +| otherwise | Notifications | + +Priority never expands more than one section automatically. + +--- + +## 4. Section 4 — Calendar C, Event Timeline Fusion + +![calendar timeline](images/04-calendar-timeline.png) + +A standalone overlay, centred, reached from the notch clock or from the CCC +Calendar & Events section. + +### 4.1 Geometry + +| Property | Value | +| --- | --- | +| Width | 1080 px, clamped to `min(1080, screenWidth - 80)` | +| Height | 600 px, clamped to available screen height | +| Panes | left month grid, right day timeline, 1 px vertical divider | +| Radius | 16 px, 2 px violet→magenta gradient border, strong glow | + +### 4.2 Left pane — month grid + +- Header: `‹` chevron, `October 2026`, `›` chevron, `Today` button + (filled blue→violet). +- Weekday headers `Mon…Sun`, muted. +- Leading and trailing days from adjacent months render dimmed. +- **Current day**: magenta→violet gradient filled circle, white numeral, glow. +- Days with events carry a small dot beneath the numeral, coloured by urgency. + +### 4.3 Right pane — day timeline + +- Header: `Today • Tue 01 Oct 2026` bold; `+ Add Event` outlined button, + blue border and label. +- Events listed chronologically with the structure defined in §3.3. +- A **NOW marker** indicates the current time and is visually prominent, + animating subtly. It must not obscure an event row. +- `⋮` per-event menu is required. + +### 4.4 Grid/timeline relationship + +Grid and timeline are shown **simultaneously** and are two views of one +selection. Selecting a day in the grid replaces the timeline. There is no +separate "grid mode" toggle — the earlier proposal for a `[GRID]` toggle is +withdrawn; the image shows both panes permanently. + +--- + +## 5. Out of scope for this migration + +Stated explicitly so it is not mistaken for an oversight. + +- **Radial Settings skill tree** (`desktop/surfaces/radial/`, 9 files). Retained + and still functional; not represented in the target images. Removal is a + separate decision. +- **Floating desktop telemetry widgets** (`desktop/surfaces/widgets/`, 9 files). + Retained. The CCC carries its own compact System Status section; the floating + widgets are a desktop-decorative layer and may co-exist. +- **Event persistence backend.** Calendar C's timeline requires an event source + that does not exist. See §6. +- **Disk telemetry.** Required by System Status; no service provides it. See §6. + +--- + +## 6. Known gaps — resolve before the dependent section ships + +These are not stylistic; they are missing subsystems. + +### 6.1 No calendar event source + +Nothing in the shell reads events. There is no iCalendar, CalDAV, or local store +integration. Required for: timeline rows, event dots in the month grid, the NOW +marker, `+ Add Event`, and the relative-time pill. + +Decision required: event backend. Recommended for a first cut is a local JSON +store owned by a new `CalendarService`, with the UI reading a single +`eventsForDate(date)` projection. `+ Add Event` can ship disabled behind this +until a write path exists. + +### 6.2 No disk telemetry + +`SystemMonitorService` exposes CPU, memory, swap and network only. Disk usage +requires a `statvfs` read. Note the existing service sets `available = false` +permanently on any single read failure; that must be fixed before adding a +fifth source, or the gauge will latch offline. + +--- + +## 7. Implementation order + +Ordered so that every step is independently verifiable and no step depends on an +undecided question. + +| # | Step | Gate | +| --- | --- | --- | +| 0 | Fix the CCC double-toggle; route lock through `SessionService` | click → CCC actually opens | +| 1 | Delete dead code (`legacyCompatibilityLayer`, `DynamicIsland.qml`, duplicate calendar, orphan files) | no behavioural change | +| 2 | Rebase `Theme.qml` onto the navy scale; add neon tokens; keep legacy aliases | shell still renders | +| 3 | Reconcile `Theme.barHeight` / `Settings.barHeight` to one source | bar and popups agree | +| 4 | Notch idle state re-skin to §1 | idle matches image 1 | +| 5 | Notch expanded state to §2, spring + reduced-motion | hover matches image 2 | +| 6 | Hover/CCC-open state machine (§3.4) | no flicker | +| 7 | Single-surface CCC hosting (§3.1) | no compositor seam | +| 8 | CCC two-column layout + dynamic height | matches image 3 | +| 9 | CCC sections in order: Notifications, Power, Audio, Wi-Fi, Bluetooth, System Status | each section usable | +| 10 | Disk telemetry + `CalendarService` + Calendar C | matches image 4 | +| 11 | Context-aware priority | §3.4 rules hold | +| 12 | Service-layer hardening, DPI scale tokens | no regressions | + +Steps 4–5 and 8–9 are independent of each other and can proceed in parallel by +different authors once steps 0–3 land. diff --git a/shell/docs/technical/README.md b/shell/docs/technical/README.md index 11987e2f..9c782cbb 100644 --- a/shell/docs/technical/README.md +++ b/shell/docs/technical/README.md @@ -4,12 +4,15 @@ These documents turn the approved design into an implementation baseline. They a | Document | Purpose | | --- | --- | -| [Repository audit](repository-audit.md) | Current codebase inventory, reusable assets, constraints, and migration risks. | | [Technical decision log](decision-log.md) | Accepted implementation decisions and deferred decisions. | -| [Delivery tracker](delivery-tracker.md) | Ordered milestones, definition of done, and the current implementation state. | +| [Delivery tracker](delivery-tracker.md) | Ordered phases, definition of done, and the current implementation state. | | [Validation strategy](validation-strategy.md) | Checks and manual acceptance evidence required for each milestone. | | [Engineering guidelines](engineering-guidelines.md) | Rules for implementing ctOS without coupling, unsafe runtime behavior, or undocumented scope growth. | +The design target these implement is [`../target/`](../target/README.md). The +pre-Nix repository audit was removed: it described a `bar.qml` prototype layout +that no longer exists. + ## Update rules - Update the tracker in the same change that implements or verifies work. diff --git a/shell/docs/technical/decision-log.md b/shell/docs/technical/decision-log.md index a5369130..4b9c1092 100644 --- a/shell/docs/technical/decision-log.md +++ b/shell/docs/technical/decision-log.md @@ -11,8 +11,10 @@ | TD-005 | Own primary overlay state centrally. | Prevents overlapping panels and inconsistent focus behavior. | A single OverlayController routes Command Deck, System Rail, and Event Log requests. | | TD-006 | Use native ctOS notifications in v1. | Event Log and toast behavior are core to the intended desktop language. | ctOS must be the only active notification server when enabled. | | TD-007 | Use a fixed ctOS dark theme for v1. | Provides a coherent baseline with fewer runtime dependencies. | Dynamic and wallpaper-derived themes are postponed. | +| TD-007 | **Superseded by TD-010.** ~~Use a fixed ctOS dark theme for v1.~~ | A single hardcoded palette in `Theme.qml` made every colour change a file edit, and the token names drifted from their meanings — `acidGreen` resolved to magenta. | Retained for history. The fixed-theme decision no longer holds. | | TD-008 | Preserve greeter/lockscreen behavior and package it separately. | Authentication code has a different security and lifecycle boundary from a desktop shell. | No desktop feature may depend on greeter services. | | TD-009 | Prefer reactive Quickshell services/adapters over UI-owned shell polling. | Keeps rendering, data acquisition, error handling, and resource lifetime separate. | Prototype CPU/RAM loops are not reused. | +| TD-010 | Ship the shell palette as 22 named slots in per-palette data files, selected at runtime from `Settings.theme`. Supersedes TD-007; decides TD-D03. | Two palettes are wanted, so the choice cannot live in the source. Slots keep colour out of the token file, which is what lets the ~90 call-facing tokens be pure aliases and lets a switch repaint without a restart. | `Theme.qml` holds no hex values. An unknown theme name or a palette missing a slot falls back to `ctos-pine` with a logged error rather than rendering undefined colours. Palette values and provenance are documented in [`../target/colors.md`](../target/colors.md) and in `core/palettes/`. Wallpaper-*derived* theming stays out of scope — the palette is chosen, not computed. | ## Deferred decisions @@ -20,7 +22,7 @@ | --- | --- | --- | | TD-D01 | NixOS system module | A system-owned feature requires declarative installation beyond Home Manager. | | TD-D02 | Niri adapter | The Hyprland v1 adapter and core surface contracts are stable. | -| TD-D03 | Dynamic theming | Fixed-theme contrast and token system are validated in daily use. | +| TD-D03 | **Decided by TD-010.** ~~Dynamic theming~~ | Fixed-theme contrast and token system are validated in daily use. | Runtime palette selection is in. Themes are a closed set of authored palettes; deriving one from the wallpaper is still deferred and has no trigger yet. | | TD-D04 | File search and clipboard history | Command Deck action/application search has a stable data model and interaction flow. | | TD-D05 | Bluetooth, overview, calendar, screenshots, recording, and media controls | v1 daily-driver acceptance is complete. | @@ -28,6 +30,23 @@ - New desktop modules use `desktop/` and do not import `greeter/`. - Services expose availability, status, reactive values, and named actions; surfaces do not execute backend shell commands directly. -- Generated Home Manager settings are the desktop runtime’s configuration boundary. Do not write machine-specific settings to `/etc/ctos` for the desktop shell. +- Generated Home Manager settings are the desktop runtime's configuration boundary. Do not write machine-specific settings to `/etc/ctos` for the desktop shell. - Every new optional integration has a visible unavailable state and a startup diagnostic. - Any change to these decisions updates this log and the affected design document in the same commit. + +## Bug fix decisions + +| ID | Decision | Rationale | Consequence | +|---|---|---|---| +| BF-001 | Radial local frame uses `(cos, sin)` so `dir 0` = +x (outward). | `(sin, -cos)` made trees grow sideways across their segment. | Every tree now grows along its own segment axis. | +| BF-002 | Fork angle is local to the generation; descendants return to the axis. | Inherited fork created ever-widening dendrites. | Trees read as parallel columns. | +| BF-003 | Preview trunk `r1Point` = 0.35 × ring radius (242 at 180). | 300 left a 99px drawn trunk vs reference 41px. | Trunk now matches reference proportion. | +| BF-004 | Mic volume slider uses `minVal`/`maxVal`/`step` contract; added `revision++`. | Node used wrong property names; slider inherited defaults 0/100/1. | Slider now tracks gain 0–1 correctly and repaints. | +| BF-005 | Wallpaper authority: shell applies recorded choice; unit reads settings.json. | Unit hardcoded v1; shell only applied on fallback. | Desktop now shows recorded choice on boot; unit falls back to v1. | +| BF-006 | Wallpaper fallback deferred until Settings settled. | Premature scan saw default, overwrote user's choice with first image. | Recorded choice no longer destroyed by race. | +| BF-007 | Scan output self-describes its directory; guard compares against that. | `_scannedDir` overwritten by next rescan; stale results passed old guard. | Stale results discarded regardless of arrival order. | +| BF-008 | Network/Bluetooth popups retired; notch and IPC rewired to CCC. | Popups were live and reachable from notch; deleting without rewire broke indicators. | Notch indicators open CCC cards; IPC calls still work. | +| BF-009 | Conditional network glyph: wifi on WiFi, ethernet on wired. | Notch always showed wifi glyph even on ethernet. | Notch now shows correct transport glyph and detail text. | +| BF-010 | Volume/battery glyph sizes matched to wifi scale. | Volume 0.85, battery 0.73 vs wifi 0.90 made them read small. | Visual parity across all three indicator glyphs. | +| BF-011 | DevShell `CTOS_INPUT` escaping fixed. | `${CTOS_INPUT: -0}` was Nix interpolation, not shell parameter expansion. | `CTOS_INPUT=1` now reaches `WLR_LIBINPUT_NO_DEVICES`. | +| BF-012 | `defaultWallpaper` dead code removed. | Defined but never referenced; install was longhand. | Removed dead code. | diff --git a/shell/docs/technical/delivery-tracker.md b/shell/docs/technical/delivery-tracker.md index 25a58367..7611a827 100644 --- a/shell/docs/technical/delivery-tracker.md +++ b/shell/docs/technical/delivery-tracker.md @@ -1,77 +1,163 @@ # ctOS delivery tracker -**Status key:** `done` = acceptance evidence exists; `active` = current focus; `todo` = not started; `blocked` = external dependency or decision prevents work. +Replaces the previous T0–T8 milestone list, which described a bar-and-panel +architecture that no longer exists. The plan now follows the implementation order +in [`../target/neon-notch-spec.md`](../target/neon-notch-spec.md) §7. -| Milestone | Status | Exit gate | -| --- | --- | --- | -| T0 — Design baseline | done | Product, interaction, architecture, Nix, audit, technical decisions, and validation documents exist. | -| T1 — Nix package and module | done | Flake evaluates; package builds; Home Manager module installs and starts the shell service. | -| T2 — Shell foundation | todo | `shell.qml`, generated settings, design tokens, OverlayController, and per-output window lifecycle work. | -| T3 — Hyprland ambient bar | todo | Reactive workspace/focused-window state and compact system state render on each output without UI-owned polling. | -| T4 — Command Deck | todo | Keyboard/mouse application and action search satisfies the interaction specification. | -| T5 — System Rail and OSDs | todo | Audio, mic, brightness, network, battery/power, and session confirmation work or degrade safely. | -| T6 — Notifications and Event Log | todo | ctOS owns notification delivery, toast behavior, history, and do-not-disturb. | -| T7 — Greeter packaging and documentation | active | Existing greeter is opt-in packageable and docs describe Nix activation/recovery without changing auth behavior. | -| T8 — V1 acceptance | todo | All validation gates pass in the ctos NixOS/Hyprland configuration. | - -## T1 — Nix package and module - -- [x] Create a flake-parts flake exporting a ctOS package and `homeManagerModules.default`. -- [x] Define the initial `programs.ctOS` option skeleton from the Nix integration design. -- [x] Package QML and resources with a stable `${package}/share/ctos` install path. -- [x] Add a systemd user service for the desktop shell with bounded restart behavior. -- [x] Add a minimal `shell.qml` smoke entry that starts without greeter imports. -- [x] Record successful `nix flake check --no-build` and `nix build .#ctos-shell` evidence in the validation log. -- [x] Add the declarative `awww` daemon and default bundled wallpaper service to the desktop profile. - -T1 evidence (2026-09-02): `nix flake check --no-build` passed, `nix build .#ctos-shell` passed, and the built output contains `share/ctos/shell.qml`. Graphical service startup remains a manual acceptance step on the Hyprland host. - -T7 progress (2026-09-02): the Makima host now opts into `ctos.features.greeter.enable`; evaluation confirms Greetd launches Cage and the packaged `greeter.qml`, with `/etc/ctos/greeter.config.json` generated declaratively. Live PAM authentication and Hyprland handoff were verified on the host (2026-09-03); recovery/documentation work remains. - -Wallpaper progress (2026-09-03): the desktop profile starts `awww-daemon` and applies the bundled `wallpaper-v1.png` through a retrying user service. The initial systemd ordering cycle was fixed and the units now require a live host acceptance check. - -## T2 — Shell foundation - -- [ ] Create the `desktop/core`, `desktop/services`, `desktop/surfaces`, and `desktop/adapters/hyprland` boundaries. -- [ ] Move or recreate generic design tokens without a greeter import. -- [ ] Implement generated-settings loading and feature toggles. -- [ ] Implement OverlayController ownership, focus handoff, `Escape`, and background-close behavior. -- [ ] Implement per-output window creation and output-removal cleanup. - -## T3 — Hyprland ambient bar +**Status key:** `done` · `active` · `todo` · `blocked` -- [ ] Implement reactive Hyprland workspace and focused-window adapter state. -- [ ] Implement per-output ambient bar layout and unavailable-state handling. -- [ ] Implement audio, network, battery, time, and ambient MPRIS indicators. -- [ ] Preserve wheel volume adjustment with safe bounds and mute behavior. -- [ ] Remove desktop dependency on the prototype CPU/RAM polling loops. +## Phase A — Foundations -## T4 — Command Deck - -- [ ] Implement shared action registry and launch contract. -- [ ] Load/search desktop applications and built-in actions with specified ranking/grouping. -- [ ] Implement keyboard focus, arrows, Enter, Escape, and equivalent mouse behavior. -- [ ] Route destructive session results to confirmation instead of direct execution. - -## T5 — System Rail and OSDs - -- [ ] Implement right-rail routing for system controls and Event Log. -- [ ] Implement PipeWire output/mic controls and compact volume OSD. -- [ ] Implement brightness adapter/control/OSD with unavailable fallback. -- [ ] Implement NetworkManager status/network selection without storing secrets. -- [ ] Implement battery and power-profile state with unavailable fallback. -- [ ] Implement lock and confirmed logout/reboot/power-off actions. - -## T6 — Notifications and Event Log - -- [ ] Claim/configure native notification ownership only when the feature is enabled. -- [ ] Implement urgency-aware non-blocking toasts. -- [ ] Implement session-local Event Log, dismiss-one, clear-all, and do-not-disturb. -- [ ] Verify no duplicate notification daemon owns the session bus name. +| # | Item | Status | +| --- | --- | --- | +| A0 | Design baseline: target spec, colour system, reference images | done | +| A1 | Reconcile `shell/docs/` with the target; delete superseded documents | done | +| A2 | Fix CCC double-toggle so click → CCC actually opens | todo | +| A3 | Route session actions through `SessionService`; delete duplicate `Process` objects | todo | +| A4 | Delete dead code: `legacyCompatibilityLayer`, `DynamicIsland.qml`, duplicate calendar grids, orphan files | todo | +| A5 | Rebase `Theme.qml` on the navy ramp; add neon tokens; keep legacy aliases | done — superseded | +| A6 | Reconcile `Theme.barHeight` / `Settings.barHeight` to one source of truth | todo | + +A2 and A3 are small and unblock visual evaluation. A4 must land before Calendar C +is built so the duplicated date math is not extended a third time. + +A5 was closed as superseded rather than completed: the navy ramp it asked for is +not the palette the shell ended up with. It was replaced by extracting the +existing pine palette into `core/palettes/PinePalette.js`, adding the acid +palette alongside it as `AcidPalette.js`, and reducing `Theme.qml` to aliases over +21 validated slots. Two user-selectable palettes, not one navy ramp. + +## Phase B — Notch + +| # | Item | Status | +|---|---|---| +| B1 | Idle state re-skin: stadium pill, gradient border, glow | done | +| B2 | Expanded state: hoverWidth/hoverHeight, two-row date/time | done | +| B3 | `reducedMotion` honoured by animating surfaces | partial — some animations respect it | +| B4 | Hover / CCC-open pin state machine — no flicker | partial — hover debounce + pin logic exists | + +The visual re-skin (stadium pill, gradient border) is implemented. The hover state +(expands to show date/seconds/indicator values) is implemented with spring +animation. `reducedMotion` is respected by the spring animations and hover +debounce; a few edge animations may not yet honour it. The hover/CCC pin state +machine exists via `_isHovered` + `isCommandCenterOpen` with debounce; minor +edge cases may remain. + +## Phase C — Command & Control Center + +| # | Item | Status | +|---|---|---| +| C1 | Single-surface hosting: CCC inside `AmbientBar`, window grows downward | done | +| C2 | ~~Extend `flushWaylandMask()` to cover CCC bounds~~ | **dropped** — CCC lives on overlay layer; mask would swallow input | +| C3 | Two-column layout, 760 px, content-driven height | done | +| C4 | Section: Notifications (count, DND, list, urgency) | done | +| C5 | Section: Power & Session (4 tiles, danger poweroff, inline confirmation) | done | +| C6 | Section: Audio (slider + media card with transport) | done | +| C7 | Section: Wi-Fi and Bluetooth | done | +| C8 | Section: System Status (CPU / Memory / Disk / Network) | blocked on A-scope: disk telemetry missing | +| C9 | Context-aware priority expansion | todo | + +C1 is the architectural keystone. Until the CCC is hosted in the notch's own +window, the "one surface" requirement cannot be met regardless of layout work. + +## Phase D — Calendar C + +| # | Item | Status | +| --- | --- | --- | +| D0 | Decide event backend | blocked — no decision recorded | +| D1 | `CalendarService` with an `eventsForDate()` projection | todo | +| D2 | Month grid: navigation, Today, gradient current-day marker, event dots | todo | +| D3 | Day timeline: times, coloured segments, source, `⋮` menu, relative-time pill | todo | +| D4 | NOW marker | todo | +| D5 | `+ Add Event` | todo — may ship disabled until a write path exists | -## T7–T8 — Release readiness +## Phase E — Hardening -- [x] Package the preserved greeter only behind its opt-in feature. -- [ ] Update user-facing README/install guidance after the Nix path is proven. -- [ ] Exercise all manual acceptance scenarios in the validation strategy. -- [ ] Record known limitations and deferred features for the first usable release. +| # | Item | Status | +| --- | --- | --- | +| E1 | Services: capture `stderr`, handle `exited`, bound retries, log once on degrade | todo | +| E2 | `SystemMonitorService`: fix the one-way `available` latch | todo | +| E3 | `NetworkService`: stop passing Wi-Fi secrets as process arguments | todo | +| E4 | `NetworkTracerService`: real `available` probe; stop defaulting `ss` on | todo | +| E5 | DPI scale token in `Theme.qml`; retire absolute pixel literals | todo | +| E6 | `programs.ctOS` generates `settings.json`; implement the documented contract | todo | +| E7 | QML linting and execution wired into `nix flake check` / CI | todo | +| E8 | `Settings.barHeight` no longer conflicts with the notch geometry | todo | + +`tests/` contains roughly 20,000 lines of QML e2e harness across four tiers and is +entirely outside CI. A4 will invalidate a portion of it; E7 should follow so the +remainder is actually exercised. + +## Phase R — Radial settings skill tree + +Brought in scope when the radial gained the palette switch and became the place +the shell's own settings live. Previously listed under "Out of scope" as a +retained prototype; it is now held to the same rules as the notch and the CCC. + +Reference images are in the repository root: `base-settings-tree.png`, +`base-settings-tree-with-highlight.png`, `2-branch-tree-expanded.png`, +`3-branch-tree-expanded.png` and `WatchDogs1 Skill tree background..png`. + +| # | Item | Status | +|---|---|---| +| R1 | Stop printing "PREREQUISITE LOCKED" while enforcing nothing | done — see below | +| R2 | Resolve the stub nodes and the invented lock reasons | done — 5 removed, 3 replaced | +| R3 | Honour `Settings.reducedMotion` in every animating surface | done — 35 durations, 3 timers, 1 infinite loop | +| R4 | `leftAnchorX: -70` | **no change** — reviewed against the references and it is deliberate | +| R5 | Delete the never-emitted `RadialSegment.clicked()` / `hovered()` signals | done | +| R6 | `net-core` reports a raw interface name instead of a connection state | done | +| R7 | Glyphs on preview nodes | done — were hidden outright, so the base-state trees were blank | +| R8 | Blip in both states: red when off, green when on | done | +| R9 | Resolve the `readonly` nodes | done — 5 deleted, 3 made functional | +| R10 | More nodes and branches per section | todo — plus the security tooling branches | +| R11 | Animated 3D-city skill-tree background (`WatchDogs1` reference). Not implemented | todo | + +R7–R10 came from reading the reference images against the running shell. The arc +maths the references describe is already implemented and was verified against +them: `focusedWidth` is 90°, exactly 1/4 of the circumference, the focused arc is +anchored at the top and neighbours are pushed out sequentially, and the remaining +7 share the other 3/4. The gap unit is degrees rather than the 2px the reference +implies, which was reviewed and accepted. + +## Phase W — Wallpaper Authority + +| # | Item | Status | +|---|---|---| +| W1 | Wallpaper unit reads recorded choice from settings.json | done | +| W2 | Wallpaper fallback deferred until Settings settled | done | +| W3 | Stale scan guard self-describing | done | +| W4 | Preview trunk length matches reference proportion | done | +| W5 | Wallpaper grid shows 5 rows (was 2) | done | +| W6 | Wallpaper grid scroll indicator | done | +| W6 | Wallpaper rescans on browser open | done | +| W7 | Stale scan guard self-describing output | done | +| W8 | Remove v1 boot flash | done | +| W9 | Remove `defaultWallpaper` dead code | done | + +## Phase N — Notch Refinements + +| # | Item | Status | +|---|---|---| +| N1 | Conditional wifi/ethernet glyph | done | +| N2 | Volume glyph size parity with wifi | done | +| N2 | Battery glyph size parity with wifi | done | +| N3 | Ethernet glyph added (Material Symbols U+E328) | done | + +## Phase P — Popup Retirement + +| # | Item | Status | +|---|---|---| +| P1 | Delete NetworkPopup.qml + BluetoothPopup.qml | done | +| P2 | Rewire notch handlers to CCC submenus | done | +| P3 | Rewire IPC handlers to CCC submenus | done | +| P4 | Remove popup properties and handlers from shell.qml | done | +| P5 | Verify zero ReferenceErrors on boot | done | + +## Phase M — Mic Volume Fix + +| # | Item | Status | +|---|---|---| +| M1 | Fix slider contract (minVal/maxVal/step) | done | +| M2 | Add missing revision++ for repaint | done | + +Their removal is a separate decision. diff --git a/shell/docs/technical/repository-audit.md b/shell/docs/technical/repository-audit.md deleted file mode 100644 index 4ae08f1b..00000000 --- a/shell/docs/technical/repository-audit.md +++ /dev/null @@ -1,45 +0,0 @@ -# Repository audit - -**Audited:** 2026-09-01 -**Baseline:** `468be1b` (`init: forked to create a standalone NixOS distribution`) - -## Current shape - -| Area | Current state | Reuse decision | -| --- | --- | --- | -| Desktop entry point | `bar.qml` is one full-width `PanelWindow`. | Replace with a desktop `shell.qml`; retain individual visual primitives only after review. | -| Bar UI | Workspace display, CPU/RAM polling, network text, PipeWire volume/mic, and clock are implemented inline or under `bar/`. | Treat as a prototype, not a v1 bar implementation. | -| Compositor support | `bar/config/Desktop.qml` detects Hyprland/Niri, but `WorkspaceManager` implements only Hyprland and throws for other compositors. | Establish Hyprland as v1; move compositor logic behind an adapter. | -| Shared QML | `common/` provides theme, env/config paths, logging, utility functions, focus manager, and accents. | Reuse selectively; remove greeter coupling from desktop-shared components. | -| Greeter/locker | Separate `greeter.qml` entry point with greetd, PAM/lockd, session, terminal, and test-mode services. | Preserve as an independent security boundary. | -| Install path | `install.sh` copies to `/opt/ctos`, writes `/etc/ctos`, and scaffolds greetd/compositor configuration. | Legacy only; do not extend for the NixOS desktop shell. | -| Packaging | No `flake.nix`, Nix modules, or package derivation exists. | First implementation milestone. | -| Tests/checks | No automated test suite, flake check, or CI configuration exists. | Add validation incrementally with packaging. | - -## Existing runtime dependencies - -The current QML imports show that this repository already relies on Quickshell, QtQuick, Quickshell I/O, Quickshell networking, PipeWire, Hyprland, UPower, Greetd, and PAM/Wayland locking APIs. The greeter documentation separately calls out `greetd`, `uwsm`, and optionally `cage`. - -The prototype bar additionally starts persistent shell loops to read `/proc/stat` and `free`. Those loops are confined to the prototype and must not be carried into the v1 ambient bar. Desktop services own data acquisition; surfaces render reactive state. - -## Assets and visual material - -- `extras/wallpapers/` contains the curated dark wallpapers selected for v1. -- `bar/resources/` contains corner and distribution SVGs that may be reviewed for reuse. -- `greeter/resources/` contains lockscreen-specific identity and barcode assets. Do not move authentication/identity assets into desktop surfaces without an explicit visual decision. -- `common/Theme.qml` is the current palette source. It is a useful reference but does not yet define the complete token system required by the design documents. - -## Migration constraints and risks - -1. **No packaging boundary exists.** Imports currently resolve from a source-tree layout and the imperative installer copies the whole repository. Nix packaging must establish a stable QML/resource layout before the shell can be activated declaratively. -2. **The bar is monolithic.** `bar.qml` owns window layout, hardware polling, networking presentation, and indicators. Extending it for overlays would couple unrelated behavior; new surfaces must be composed under the planned shell scope instead. -3. **Shared code is not universally shared.** `common/components/Accents.qml` imports greeter settings, so it cannot be used by the desktop as-is. Shared primitives must have no greeter or compositor dependency. -4. **Current configuration is system-path oriented.** `common/Paths.qml` names `/etc/ctos` and `/var/lib/ctos`; desktop settings need a generated Home Manager-owned user path. Greeter compatibility must be maintained independently. -5. **Documentation drift exists.** The root and bar READMEs still describe an early WIP bar and imperative install path. Update them only when the corresponding Nix replacement is usable. -6. **Notification ownership is exclusive.** Enabling a native notification server requires detecting/documenting conflicts with any existing daemon. -7. **Two shared-code repairs are required before reuse.** `common/components/Accents.qml` imports greeter settings despite its shared location, and `common/Logger.qml` appends debug output through shell-quoted `echo` to a fixed `/tmp/ctos.log` path. The desktop foundation must decouple the former and use a safe, user-runtime logging policy for the latter. -8. **Greeter schema/config drift needs containment.** `schema/greeter.schema.json` describes a legacy top-level shape while the current `GeneralDto` loads a nested `general` object. Do not expand the schema during desktop work; reconcile it as a separately verified greeter maintenance task. - -## Baseline conclusion - -The repository has a reusable visual direction and a valuable greeter, but the desktop implementation is pre-foundation. The next code milestone is packaging and a minimal shell scaffold—not an incremental expansion of `bar.qml`. diff --git a/shell/extras/wallpapers/wallpaper-v1.png b/shell/extras/wallpapers/wallpaper-v1.png index 48124c47..3aaaf3ab 100644 Binary files a/shell/extras/wallpapers/wallpaper-v1.png and b/shell/extras/wallpapers/wallpaper-v1.png differ diff --git a/shell/extras/wallpapers/wallpaper-v2.png b/shell/extras/wallpapers/wallpaper-v2.png index 04c1e1b4..22f6ea0b 100644 Binary files a/shell/extras/wallpapers/wallpaper-v2.png and b/shell/extras/wallpapers/wallpaper-v2.png differ diff --git a/shell/extras/wallpapers/wallpaper-v3.png b/shell/extras/wallpapers/wallpaper-v3.png deleted file mode 100644 index e12cb12d..00000000 Binary files a/shell/extras/wallpapers/wallpaper-v3.png and /dev/null differ diff --git a/shell/extras/wallpapers/wallpaper-v4.png b/shell/extras/wallpapers/wallpaper-v4.png deleted file mode 100644 index 9c9da8cd..00000000 Binary files a/shell/extras/wallpapers/wallpaper-v4.png and /dev/null differ diff --git a/shell/greeter/components/Time.qml b/shell/greeter/components/Time.qml index 2c63e15c..76e3941c 100644 --- a/shell/greeter/components/Time.qml +++ b/shell/greeter/components/Time.qml @@ -10,6 +10,8 @@ Item { SystemClock { id: clock + // Plural form, not Qt's documented "SecondPrecision": the documented + // names resolve to undefined on this Qt. precision: SystemClock.Seconds } diff --git a/shell/nix/package.nix b/shell/nix/package.nix index ce806297..a42c1030 100644 --- a/shell/nix/package.nix +++ b/shell/nix/package.nix @@ -17,7 +17,8 @@ stdenvNoCC.mkDerivation { # Copy assets mkdir -p "$out/share/ctos" cp -R . "$out/share/ctos/" - rm -rf "$out/share/ctos/.git" "$out/share/ctos/nix" + # Docs carry reference imagery (~4 MB) and are not runtime inputs. + rm -rf "$out/share/ctos/.git" "$out/share/ctos/nix" "$out/share/ctos/docs" # Create executable wrapper mkdir -p "$out/bin" diff --git a/shell/shell.qml b/shell/shell.qml index 1ee3e592..78173858 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -19,12 +19,6 @@ Scope { property bool calendarVisible: false property var calendarScreen: null - property bool bluetoothVisible: false - property var bluetoothScreen: null - - property bool networkVisible: false - property var networkScreen: null - signal calendarDismissRequested property bool _calendarDismissing: false @@ -43,8 +37,6 @@ Scope { root.calendarScreen = resolved; } } else { - root.closeBluetooth(); - root.closeNetwork(); root.calendarScreen = resolved; root.calendarVisible = true; } @@ -58,56 +50,8 @@ Scope { root._calendarDismissing = false; } - function toggleBluetooth(targetScreen): void { - const resolved = (targetScreen !== null && targetScreen !== undefined) ? targetScreen : root.resolveTargetScreen(); - - if (root.bluetoothVisible) { - if (targetScreen === null || targetScreen === undefined || root.bluetoothScreen === resolved) { - root.bluetoothVisible = false; - root.bluetoothScreen = null; - } else { - root.bluetoothScreen = resolved; - } - } else { - root.closeCalendar(); - root.closeNetwork(); - root.bluetoothScreen = resolved; - root.bluetoothVisible = true; - } - } - - function closeBluetooth(): void { - root.bluetoothVisible = false; - root.bluetoothScreen = null; - } - - function toggleNetwork(targetScreen): void { - const resolved = (targetScreen !== null && targetScreen !== undefined) ? targetScreen : root.resolveTargetScreen(); - - if (root.networkVisible) { - if (targetScreen === null || targetScreen === undefined || root.networkScreen === resolved) { - root.networkVisible = false; - root.networkScreen = null; - } else { - root.networkScreen = resolved; - } - } else { - root.closeCalendar(); - root.closeBluetooth(); - root.networkScreen = resolved; - root.networkVisible = true; - } - } - - function closeNetwork(): void { - root.networkVisible = false; - root.networkScreen = null; - } - function closeAllPopups(): void { root.closeCalendar(); - root.closeBluetooth(); - root.closeNetwork(); } IpcHandler { @@ -132,11 +76,11 @@ Scope { } function toggleBluetooth(): void { - root.toggleBluetooth(null); + OverlayController.openSystemRailWithSubmenu("bluetooth"); } function toggleNetwork(): void { - root.toggleNetwork(null); + OverlayController.openWifiSubmenu(); } function toggleRadialSettings(): void { @@ -199,7 +143,7 @@ Scope { right: Settings.getWidgetAnchor("cpuHexGrid", "right", true) } margins { - top: Settings.getWidgetMargin("cpuHexGrid", "top", Theme.barHeight + Theme.spacingXl) + top: Settings.getWidgetMargin("cpuHexGrid", "top", Settings.barHeight + Theme.spacingXl) bottom: Settings.getWidgetMargin("cpuHexGrid", "bottom", 0) left: Settings.getWidgetMargin("cpuHexGrid", "left", 0) right: Settings.getWidgetMargin("cpuHexGrid", "right", Theme.spacing2Xl) @@ -243,8 +187,8 @@ Scope { right: Settings.getWidgetAnchor("ramBlockBar", "right", true) } margins { - // Default fallback: top: Settings.widgetCpuHexGridVisible ? (Theme.barHeight + Theme.spacingXl + 200 + Theme.spacingXl) : (Theme.barHeight + Theme.spacingXl) - top: Settings.hasWidgetMargin("ramBlockBar", "top") ? Settings.getWidgetMargin("ramBlockBar", "top", 0) : (Settings.widgetCpuHexGridVisible ? (Theme.barHeight + Theme.spacingXl + 200 + Theme.spacingXl) : (Theme.barHeight + Theme.spacingXl)) + // Default fallback: top: Settings.widgetCpuHexGridVisible ? (Settings.barHeight + Theme.spacingXl + 200 + Theme.spacingXl) : (Settings.barHeight + Theme.spacingXl) + top: Settings.hasWidgetMargin("ramBlockBar", "top") ? Settings.getWidgetMargin("ramBlockBar", "top", 0) : (Settings.widgetCpuHexGridVisible ? (Settings.barHeight + Theme.spacingXl + 200 + Theme.spacingXl) : (Settings.barHeight + Theme.spacingXl)) bottom: Settings.getWidgetMargin("ramBlockBar", "bottom", 0) left: Settings.getWidgetMargin("ramBlockBar", "left", 0) right: Settings.getWidgetMargin("ramBlockBar", "right", Theme.spacing2Xl) @@ -333,7 +277,7 @@ Scope { right: Settings.getWidgetAnchor("targetProfiler", "right", false) } margins { - top: Settings.getWidgetMargin("targetProfiler", "top", Theme.barHeight + Theme.spacingXl) + top: Settings.getWidgetMargin("targetProfiler", "top", Settings.barHeight + Theme.spacingXl) bottom: Settings.getWidgetMargin("targetProfiler", "bottom", 0) left: Settings.getWidgetMargin("targetProfiler", "left", Theme.spacing2Xl) right: Settings.getWidgetMargin("targetProfiler", "right", 0) @@ -377,8 +321,8 @@ Scope { right: Settings.getWidgetAnchor("networkTracer", "right", false) } margins { - // Default fallback: top: Settings.widgetTargetProfilerVisible ? (Theme.barHeight + Theme.spacingXl + 220 + Theme.spacingXl) : (Theme.barHeight + Theme.spacingXl) - top: Settings.hasWidgetMargin("networkTracer", "top") ? Settings.getWidgetMargin("networkTracer", "top", 0) : (Settings.widgetTargetProfilerVisible ? (Theme.barHeight + Theme.spacingXl + 220 + Theme.spacingXl) : (Theme.barHeight + Theme.spacingXl)) + // Default fallback: top: Settings.widgetTargetProfilerVisible ? (Settings.barHeight + Theme.spacingXl + 220 + Theme.spacingXl) : (Settings.barHeight + Theme.spacingXl) + top: Settings.hasWidgetMargin("networkTracer", "top") ? Settings.getWidgetMargin("networkTracer", "top", 0) : (Settings.widgetTargetProfilerVisible ? (Settings.barHeight + Theme.spacingXl + 220 + Theme.spacingXl) : (Settings.barHeight + Theme.spacingXl)) bottom: Settings.getWidgetMargin("networkTracer", "bottom", 0) left: Settings.getWidgetMargin("networkTracer", "left", Theme.spacing2Xl) right: Settings.getWidgetMargin("networkTracer", "right", 0) @@ -422,7 +366,7 @@ Scope { right: Settings.getWidgetAnchor("audioSurveillance", "right", true) } margins { - top: Settings.hasWidgetMargin("audioSurveillance", "top") ? Settings.getWidgetMargin("audioSurveillance", "top", 0) : (Theme.barHeight + Theme.spacingXl + (Settings.widgetCpuHexGridVisible ? 200 + Theme.spacingXl : 0) + (Settings.widgetRamBlockBarVisible ? 110 + Theme.spacingXl : 0)) + top: Settings.hasWidgetMargin("audioSurveillance", "top") ? Settings.getWidgetMargin("audioSurveillance", "top", 0) : (Settings.barHeight + Theme.spacingXl + (Settings.widgetCpuHexGridVisible ? 200 + Theme.spacingXl : 0) + (Settings.widgetRamBlockBarVisible ? 110 + Theme.spacingXl : 0)) bottom: Settings.getWidgetMargin("audioSurveillance", "bottom", 0) left: Settings.getWidgetMargin("audioSurveillance", "left", 0) right: Settings.getWidgetMargin("audioSurveillance", "right", Theme.spacing2Xl) @@ -454,8 +398,11 @@ Scope { screen: modelData onToggleCalendar: root.toggleCalendar(modelData) - onToggleBluetooth: root.toggleBluetooth(modelData) - onToggleNetwork: root.toggleNetwork(modelData) + // The network and Bluetooth popups are gone. These open the + // Command Center on the matching card instead, which is where + // those controls live now. + onToggleBluetooth: OverlayController.openSystemRailWithSubmenu("bluetooth") + onToggleNetwork: OverlayController.openWifiSubmenu() Connections { target: root @@ -520,47 +467,6 @@ Scope { } } - if (root.bluetoothVisible) { - const currentBtScreen = root.bluetoothScreen; - if (!currentBtScreen) { - root.closeBluetooth(); - return; - } - - const screenList = Quickshell.screens; - let isBtAlive = false; - for (let i = 0; i < screenList.length; ++i) { - if (screenList[i] && screenList[i].name === currentBtScreen.name) { - isBtAlive = true; - break; - } - } - - if (!isBtAlive) { - root.closeBluetooth(); - } - } - - if (root.networkVisible) { - const currentNetScreen = root.networkScreen; - if (!currentNetScreen) { - root.closeNetwork(); - return; - } - - const screenList = Quickshell.screens; - let isNetAlive = false; - for (let i = 0; i < screenList.length; ++i) { - if (screenList[i] && screenList[i].name === currentNetScreen.name) { - isNetAlive = true; - break; - } - } - - if (!isNetAlive) { - root.closeNetwork(); - } - } } } @@ -569,17 +475,41 @@ Scope { function onOverlayOpened(activeSurface: int): void { root.closeCalendar(); - root.closeBluetooth(); - root.closeNetwork(); + // Anything already toasting would sit on top of the opening overlay. + NotificationService.dismissToasts(); overlayHost.screen = root.resolveTargetScreen(); + OverlayController.setHostScreen(overlayHost.screen); } function onOverlayClosed(previousSurface: int): void { overlayHost.screen = null; + OverlayController.setHostScreen(null); } } + // Where content below the bar starts, as an anchor offset from the top. + // + // The max, not Settings.barHeight alone: the notch's resting height is a + // design token (Theme.notchHeightCompact) and no longer tracks that setting, + // so anchoring to the setting alone let a 42px pill overlap the toasts -- + // by 1px at the barHeight this config happens to have persisted, and by 9px + // at the bottom of the radial slider's range. + readonly property int barContentTop: + Math.max(Settings.barHeight, Theme.notchHeightCompact) + + Theme.notchHostPadding + Theme.spacingMedium + + // Surfaces that render *inside* the scrim host and so need it. + // + // The command centre is not one of them: it is a child of AmbientBar on the + // Top layer, which is below this window's Overlay layer. Gating this host on + // isOverlayActive therefore raised a full-screen scrim, with a full-screen + // MouseArea, directly over the command centre -- dimming it and swallowing + // every click aimed at it. + readonly property bool scrimHostedSurfaces: + OverlayController.activeSurface === OverlayController.Surface.CommandDeck + || OverlayController.activeSurface === OverlayController.Surface.RadialSettings + PanelWindow { id: overlayHost @@ -591,12 +521,47 @@ Scope { } color: "transparent" - visible: OverlayController.isOverlayActive && overlayHost.screen !== null - - WlrLayershell.keyboardFocus: OverlayController.isOverlayActive ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + // Only the intent, deliberately. The `&& screen !== null` this replaced + // closed a loop: `screen` is resolved when the window is placed, and + // placement waits on `visible`, so each re-evaluation invalidated the + // other. Qt reported it as "Binding loop detected for property visible". + // The guard was redundant anyway -- an anchored panel is sized by the + // screen it is shown on, so there is nothing to draw until then. + visible: root.scrimHostedSurfaces + + WlrLayershell.keyboardFocus: root.scrimHostedSurfaces ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "ctos-overlay" + // Input region: the whole surface. + // + // A PanelWindow with no mask is given an empty pointer input region, so + // the compositor delivers it no pointer events at all. Everything + // mouse-driven in here was therefore unreachable: the radial's segments + // and preview subtrees could not be hovered, ContextPanel's toggle, + // slider, action and picker controls could not be clicked, and + // scrimBackdrop below never saw the click it exists to handle. Keyboard + // navigation worked the whole time, which is what made it read as "the + // radial is just awkward" rather than "the radial cannot be pointed at". + // + // Full screen because that is what the scrim is: modal, anchored on all + // four sides, with a dismiss MouseArea filling it. Content that should + // swallow a click instead of dismissing has its own MouseArea and is + // declared after scrimBackdrop, so it wins. + // + // AmbientBar sets a matching mask for the same reason; see the comment + // on its windowRegion. + Region { + id: overlayRegion + + x: 0 + y: 0 + width: overlayHost.width + height: overlayHost.height + } + + mask: overlayRegion + Rectangle { id: scrimVisual @@ -623,12 +588,11 @@ Scope { anchors.centerIn: parent asynchronous: false - active: true + active: Settings.featuresCommandDeck visible: OverlayController.activeSurface === OverlayController.Surface.CommandDeck source: "desktop/surfaces/CommandDeck.qml" } - // EventLog moved to DynamicIsland Loader { @@ -659,7 +623,7 @@ Scope { right: true } margins { - top: Settings.barHeight + Theme.spacingMedium + top: root.barContentTop right: Theme.spacingMedium } @@ -673,35 +637,6 @@ Scope { } } - PanelWindow { - id: commandCenterPopupHost - - screen: overlayHost.screen - color: "transparent" - visible: OverlayController.activeSurface === OverlayController.Surface.CommandCenter && overlayHost.screen !== null - exclusionMode: ExclusionMode.Ignore - - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - WlrLayershell.namespace: "ctos-commandcenter-popup" - - anchors { - top: true - } - margins { - top: 3 - } - - implicitWidth: commandCenterPopup.implicitWidth - implicitHeight: commandCenterPopup.implicitHeight - - CommandCenter { - id: commandCenterPopup - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - } - } - PanelWindow { id: calendarBackdropHost @@ -747,7 +682,7 @@ Scope { right: true } margins { - top: Settings.barHeight + Theme.spacingMedium + top: root.barContentTop right: Theme.barPaddingHorizontal } @@ -761,124 +696,6 @@ Scope { } } - PanelWindow { - id: bluetoothBackdropHost - - screen: root.bluetoothScreen - color: "transparent" - visible: root.bluetoothVisible && root.bluetoothScreen !== null - exclusionMode: ExclusionMode.Ignore - - WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - WlrLayershell.namespace: "ctos-bluetooth-backdrop" - - anchors { - bottom: true - left: true - right: true - top: true - } - - MouseArea { - id: bluetoothBackdropMouseArea - - anchors.fill: parent - - onClicked: root.closeBluetooth() - } - } - - PanelWindow { - id: bluetoothPopupHost - - screen: root.bluetoothScreen - color: "transparent" - visible: root.bluetoothVisible && root.bluetoothScreen !== null - exclusionMode: ExclusionMode.Ignore - - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - WlrLayershell.namespace: "ctos-bluetooth-popup" - - anchors { - top: true - right: true - } - margins { - top: Settings.barHeight + Theme.spacingMedium - right: Theme.barPaddingHorizontal + 60 - } - - implicitWidth: bluetoothPopup.implicitWidth - implicitHeight: bluetoothPopup.implicitHeight - - BluetoothPopup { - id: bluetoothPopup - - onCloseRequested: root.closeBluetooth() - } - } - - PanelWindow { - id: networkBackdropHost - - screen: root.networkScreen - color: "transparent" - visible: root.networkVisible && root.networkScreen !== null - exclusionMode: ExclusionMode.Ignore - - WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - WlrLayershell.namespace: "ctos-network-backdrop" - - anchors { - bottom: true - left: true - right: true - top: true - } - - MouseArea { - id: networkBackdropMouseArea - - anchors.fill: parent - - onClicked: root.closeNetwork() - } - } - - PanelWindow { - id: networkPopupHost - - screen: root.networkScreen - color: "transparent" - visible: root.networkVisible && root.networkScreen !== null - exclusionMode: ExclusionMode.Ignore - - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - WlrLayershell.namespace: "ctos-network-popup" - - anchors { - top: true - right: true - } - margins { - top: Settings.barHeight + Theme.spacingMedium - right: Theme.barPaddingHorizontal + 160 - } - - implicitWidth: networkPopup.implicitWidth - implicitHeight: networkPopup.implicitHeight - - NetworkPopup { - id: networkPopup - - onCloseRequested: root.closeNetwork() - } - } - PanelWindow { id: packetAnalyzerHost diff --git a/shell/tools/README.md b/shell/tools/README.md new file mode 100644 index 00000000..8f2019fe --- /dev/null +++ b/shell/tools/README.md @@ -0,0 +1,73 @@ +# ctOS shell tooling + +Headless verification for the shell. Nothing here is shipped in the package -- +`shell/nix/package.nix` strips nothing from this directory, so keep it +development-only. + +## Why a compositor is required + +The shell is `wlr-layer-shell` only. Every surface is a Quickshell `PanelWindow`, +which maps to a layer surface. Qt's `offscreen` platform cannot present one -- +quickshell fails with `No PanelWindow backend loaded` -- and `qmltestrunner` +cannot either. So visual checks need a real Wayland compositor with layer-shell +support. + +`sway` with `WLR_BACKENDS=headless` and the **pixman** renderer provides one with +no GPU, no X server and no input devices. That is what makes this work over SSH +and in CI. + +## ctos-shot.sh + +Boots sway headless, runs the shell, screenshots the output with `grim`. + +```bash +ctos-shot # /tmp/ctos-shell.png at 1280x800 +ctos-shot /tmp/hover.png 1280x800 8 # explicit path, size, settle seconds +ctos-shot /tmp/x.png 640 480 4 # size as separate args +CTOS_KEEP=1 ctos-shot # leave the compositor up for poking at +``` + +Geometry is accepted as `1280x800` or as two arguments. Quickshell's stdout is +echoed on exit, because a surface that fails to appear usually explains itself +there. + +Three environment details that are easy to get wrong and are handled in the +script: + +- `WLR_HEADLESS_OUTPUTS=1` is required. The headless backend creates no outputs + on its own and sway exits with `Could not find config for output`. +- The output name is not stable across wlroots versions, so the config matches + `output *` rather than `HEADLESS-1`. +- `QT_QPA_PLATFORM` must be `wayland`. The devshell presets it to `offscreen` + for `qmllint` and `qmltestrunner`, which is fatal here. + +## ctos-pick.py + +Reads a render and prints exact colours, so design tokens are verifiable rather +than approximate. + +```bash +ctos-pick shot.png # dominant colours +ctos-pick shot.png --top 20 # more of them +ctos-pick shot.png --crop 528,0,224,34 # just the notch +ctos-pick shot.png --at 640,15 # one pixel +``` + +Percentages matter more than the hex list: the surface is translucent over a +background and pixman dithers, so a crop of the notch legitimately contains +several hundred distinct colours. To check a token, sample a pixel with `--at`. +To check that a surface rendered at all, look at the top colour's share -- the +script warns above 92 %, which is what an empty frame looks like. + +A wall with a distinct background is worth setting before capturing: the notch +surface is `rgba(5,14,30,0.94)`, so what is behind it changes the measured +value. + +## Adding this to CI + +`flake/checks.nix` currently gates on parse errors only, because qmllint cannot +resolve QtQuick's `linktarget Qt6::qtquick2plugin` from a Nix sandbox and so +cannot type-check. Render comparison needs the compositor above and a baseline +image per surface state. `ctos-shot` plus a pixel comparison is enough to build +that; it is deliberately not wired up yet because a baseline has to be reviewed +by a human before it becomes an assertion. diff --git a/shell/tools/ctos-pick.py b/shell/tools/ctos-pick.py new file mode 100755 index 00000000..950cb98b --- /dev/null +++ b/shell/tools/ctos-pick.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""ctos-pick - inspect the exact colours in a shell render. + +The notch is drawn with a three-stop gradient border over a translucent navy +surface, and a screenshot is the only way to confirm those are the values the +design specifies. This prints the dominant colours and, optionally, samples a +specific pixel, so "is that border actually #EB4ADF" is a checkable question +rather than an impression. + +Usage: + ctos-pick FILE.png [--top N] [--crop X,Y,W,H] [--at X,Y] +""" +import argparse +import sys +from collections import Counter + +try: + from PIL import Image +except ImportError: + sys.exit("ctos-pick: Pillow is required (python3Packages.pillow)") + + +def hexof(rgb): + return "#{:02X}{:02X}{:02X}".format(*rgb[:3]) + + +def main(): + ap = argparse.ArgumentParser(add_help=True) + ap.add_argument("image") + ap.add_argument("--top", type=int, default=12, help="how many colours to list") + ap.add_argument("--crop", help="X,Y,W,H to restrict analysis to") + ap.add_argument("--at", help="X,Y to sample a single pixel") + args = ap.parse_args() + + img = Image.open(args.image).convert("RGB") + + if args.at: + x, y = (int(v) for v in args.at.split(",")) + r, g, b = img.getpixel((x, y)) + print(f"{args.at} {hexof((r, g, b))} rgb({r},{g},{b})") + return + + if args.crop: + x, y, w, h = (int(v) for v in args.crop.split(",")) + img = img.crop((x, y, x + w, y + h)) + + # getcolors rather than getdata(): the latter is deprecated in Pillow 12. + raw = img.getcolors(maxcolors=1 << 24) or [] + counts = Counter() + for n, rgb in raw: + counts[rgb] += n + total = sum(counts.values()) + + print(f"{args.image} {img.width}x{img.height} {len(counts)} distinct colours") + print() + print(f"{'colour':<10} {'share':>8} {'px':>9}") + for rgb, n in counts.most_common(args.top): + share = (n / total) * 100.0 + print(f"{hexof(rgb):<10} {share:>7.2f}% {n:>9}") + + # A render that is mostly one flat colour means a surface failed to appear. + top_share = (counts.most_common(1)[0][1] / total) * 100.0 if total else 0.0 + if top_share > 92.0: + print() + print( + f"WARNING: one colour covers {top_share:.1f}% of the frame. " + "That usually means no shell surface was captured." + ) + + +if __name__ == "__main__": + main() diff --git a/shell/tools/ctos-shot.sh b/shell/tools/ctos-shot.sh new file mode 100755 index 00000000..bf15e2e7 --- /dev/null +++ b/shell/tools/ctos-shot.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# ============================================================================== +# ctos-shot.sh - render the ctOS shell in a headless compositor and screenshot it +# +# The shell is wlr-layer-shell only. Qt's offscreen platform cannot present a +# layer surface, so verifying it visually needs a real compositor. This boots +# sway with the headless backend and the pixman software renderer, runs the +# shell inside it, captures the output with grim, and tears everything down. +# +# No GPU, no X server, no seat. Works over SSH and in CI. +# +# Usage: +# ctos-shot [OUTFILE] [WIDTHxHEIGHT] [SETTLE_SECONDS] +# +# Examples: +# ctos-shot -> /tmp/ctos-shell.png at 1280x800 +# ctos-shot /tmp/hover.png 1280 800 2 +# +# Environment: +# CTOS_SHELL_QML override the entry point (default: shell/shell.qml) +# CTOS_IPC comma-separated IpcHandler functions to invoke after boot, +# e.g. CTOS_IPC=toggleCommandCenter. Runs inside this +# script's environment, which matters: the IPC socket lives +# under XDG_RUNTIME_DIR and calling it from outside finds +# nothing. +# CTOS_SETTLE extra seconds to wait after the IPC calls +# CTOS_PRE shell command run inside the compositor environment just +# before settling, for driving non-IPC state (notify-send etc.) +# CTOS_KEEP set to 1 to leave the compositor running for inspection +# CTOS_INPUT set to 1 to let libinput see real/virtual input devices, so +# an injected uinput pointer (ydotool) can drive hover and +# clicks. Off by default so host input cannot perturb a render +# CTOS_SHARE_BUS set to 1 to inherit the caller's D-Bus session bus instead +# of starting a private one (breaks notification capture) +# ============================================================================== +set -uo pipefail + +OUT="${1:-/tmp/ctos-shell.png}" +GEOM="${2:-1280x800}" + +# Accept either "1280x800" or "1280 800" for geometry. +if [[ "$GEOM" == *x* ]]; then + W="${GEOM%x*}" + H="${GEOM#*x}" + SETTLE="${3:-5}" +else + W="$GEOM" + H="${3:-800}" + SETTLE="${4:-5}" +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENTRY="${CTOS_SHELL_QML:-$REPO_ROOT/shell/shell.qml}" + +for bin in sway grim quickshell; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "ctos-shot: '$bin' not in PATH. Run inside 'nix develop'." >&2 + exit 127 + fi +done + +RUNDIR="$(mktemp -d /tmp/ctos-shot.XXXXXX)" +export XDG_RUNTIME_DIR="$RUNDIR" +# headless by default. CTOS_WLR_BACKENDS=wayland nests inside an existing +# compositor instead, which is the only way to get real input: wlroots' headless +# backend has no libinput session at all, so even with a uinput device present +# and CTOS_INPUT=1 sway reports "devices": [] and no pointer event is delivered. +export WLR_BACKENDS="${CTOS_WLR_BACKENDS:-headless}" +# The headless backend creates no outputs unless asked to. +export WLR_HEADLESS_OUTPUTS=1 +export WLR_RENDERER=pixman +# Devices are off by default so a render cannot be perturbed by the host's real +# keyboard or mouse. CTOS_INPUT=1 turns them back on, which is what lets an +# injected uinput device (ydotool) reach the compositor as a genuine pointer: +# with the flag set, sway's seat reports "devices": [] and the cursor can be +# moved in sway's model but no motion or button event is ever delivered to a +# client, so hover and click cannot be exercised at all. +if [ "${CTOS_INPUT:-0}" = "1" ]; then + unset WLR_LIBINPUT_NO_DEVICES +else + export WLR_LIBINPUT_NO_DEVICES=1 +fi +export LIBGL_ALWAYS_SOFTWARE=1 + +# The devshell presets QT_QPA_PLATFORM=offscreen for qmllint and qmltestrunner. +# That is fatal here: quickshell needs the real wayland platform to have a +# PanelWindow backend, and it fails with "No PanelWindow backend loaded". +unset QT_QPA_PLATFORM +unset QT_QUICK_BACKEND +export QT_QPA_PLATFORM=wayland + +# Keep QML console output; it is the only diagnostic when a surface fails to +# appear in the capture. +export QT_LOGGING_RULES="${QT_LOGGING_RULES:-qt.*=false}" + +SWAY_PID="" +QS_PID="" +SWAY_DISPLAY="" + +cleanup() { + if [ "${CTOS_KEEP:-0}" = "1" ]; then + echo "ctos-shot: CTOS_KEEP=1, compositor left running." >&2 + echo "ctos-shot: dir=$RUNDIR display=$SWAY_DISPLAY" >&2 + return + fi + [ -n "$QS_PID" ] && kill "$QS_PID" 2>/dev/null + [ -n "$SWAY_PID" ] && kill "$SWAY_PID" 2>/dev/null + sleep 0.3 + pkill -f "sway.*$RUNDIR" 2>/dev/null + rm -rf "$RUNDIR" +} +trap cleanup EXIT + +# ------------------------------------------------------------------------------ +# Minimal sway config. One headless output at the requested size. +# ------------------------------------------------------------------------------ +cat > "$RUNDIR/sway.conf" <&2 +sway -c "$RUNDIR/sway.conf" > "$RUNDIR/sway.log" 2>&1 & +SWAY_PID=$! + +# Wait for the socket rather than sleeping a fixed amount. +for _ in $(seq 1 60); do + SWAY_DISPLAY="$(find "$RUNDIR" -maxdepth 1 -name 'wayland-*' -type s -printf '%f\n' 2>/dev/null | head -1)" + [ -n "$SWAY_DISPLAY" ] && break + if ! kill -0 "$SWAY_PID" 2>/dev/null; then + echo "ctos-shot: sway exited during startup:" >&2 + sed 's/^/ /' "$RUNDIR/sway.log" >&2 + exit 1 + fi + sleep 0.25 +done + +if [ -z "$SWAY_DISPLAY" ]; then + echo "ctos-shot: no wayland socket appeared in $RUNDIR" >&2 + sed 's/^/ /' "$RUNDIR/sway.log" >&2 + exit 1 +fi + +export WAYLAND_DISPLAY="$SWAY_DISPLAY" +echo "ctos-shot: compositor up on \$WAYLAND_DISPLAY=$SWAY_DISPLAY" >&2 + +# ------------------------------------------------------------------------------ +# Bring up just enough session state that services degrade rather than abort. +# Absence of these is a legitimate condition the shell must survive, but the +# notification server in particular wants a bus. +# ------------------------------------------------------------------------------ +# Always start a private session bus, even when one is inherited. +# +# Reusing the caller's bus makes the render non-hermetic, and specifically breaks +# notifications: org.freedesktop.Notifications is a single well-known name, so +# if the caller's desktop already has a notification server on it, the headless +# shell cannot claim the name and notify-send delivers to the caller's server +# instead. The notification card then renders empty no matter what is sent to it. +# +# Network and Bluetooth go through the system bus, so a private session bus does +# not affect them. Set CTOS_SHARE_BUS=1 to inherit instead, when debugging +# against the live session on purpose. +if command -v dbus-daemon >/dev/null 2>&1 && [ "${CTOS_SHARE_BUS:-0}" != "1" ]; then + dbus-daemon --session --fork --print-address=3 3> "$RUNDIR/dbus.addr" 2>/dev/null + if [ -s "$RUNDIR/dbus.addr" ]; then + export DBUS_SESSION_BUS_ADDRESS="$(cat "$RUNDIR/dbus.addr")" + echo "ctos-shot: session bus at \$DBUS_SESSION_BUS_ADDRESS" >&2 + fi +fi + +# A plain dark wallpaper so translucency and the border gradient are visible +# against something. +if command -v python3 >/dev/null 2>&1 && python3 -c "import PIL" 2>/dev/null; then + python3 - "$RUNDIR/bg.png" "$W" "$H" <<'PY' +import sys +from PIL import Image, ImageDraw +out, w, h = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +img = Image.new("RGB", (w, h), (16, 24, 40)) +d = ImageDraw.Draw(img) +# Faint grid so transparency and the pill's edges are distinguishable. +for x in range(0, w, 64): + d.line([(x, 0), (x, h)], fill=(24, 34, 54), width=1) +for y in range(0, h, 64): + d.line([(0, y), (w, y)], fill=(24, 34, 54), width=1) +img.save(out) +PY + if command -v swaymsg >/dev/null 2>&1; then + : + fi +fi + +echo "ctos-shot: starting quickshell on $ENTRY ..." >&2 +quickshell -p "$ENTRY" > "$RUNDIR/quickshell.log" 2>&1 & +QS_PID=$! + +sleep 2 + +# Drive the shell into a specific state before capturing. Overlay state is only +# reachable over IPC, and the socket is per-XDG_RUNTIME_DIR, so this has to +# happen here rather than from the caller. +if [ -n "${CTOS_IPC:-}" ]; then + IFS=',' read -r -a CALLS <<< "$CTOS_IPC" + for fn in "${CALLS[@]}"; do + [ -z "$fn" ] && continue + echo "ctos-shot: ipc call ctos $fn ..." >&2 + quickshell ipc -p "$ENTRY" call ctos "$fn" 2>&1 | sed 's/^/ /' >&2 || true + done + sleep "${CTOS_SETTLE:-3}" +fi + +# Optionally drive service state that is not reachable over the shell's own IPC. +# Runs inside the compositor environment, so anything that talks to the session +# bus (notify-send, nmcli, bluetoothctl) works the same way it would in a live +# shell. This is how the notification card gets verified with notifications in it +# rather than only against its empty state. +if [ -n "${CTOS_PRE:-}" ]; then + echo "ctos-shot: running CTOS_PRE ..." >&2 + # shellcheck disable=SC2086 + eval "$CTOS_PRE" 2>&1 | sed 's/^/ /' >&2 || true +fi + +echo "ctos-shot: settling ${SETTLE}s ..." >&2 +sleep "$SETTLE" + +if ! kill -0 "$QS_PID" 2>/dev/null; then + echo "ctos-shot: WARNING quickshell exited before capture; log follows:" >&2 + sed 's/^/ /' "$RUNDIR/quickshell.log" | tail -40 >&2 +fi + +# ------------------------------------------------------------------------------ +# Capture. grim needs a live client on the output; if the shell produced no +# surface the capture will be the bare background, which is itself the signal. +# ------------------------------------------------------------------------------ +if ! grim "$OUT" 2> "$RUNDIR/grim.err"; then + echo "ctos-shot: grim failed:" >&2 + sed 's/^/ /' "$RUNDIR/grim.err" >&2 + exit 1 +fi + +if [ ! -s "$OUT" ]; then + echo "ctos-shot: capture produced an empty file" >&2 + exit 1 +fi + +SIZE="$(python3 - "$OUT" <<'PY' 2>/dev/null || echo "?" +import sys, struct +d = open(sys.argv[1], "rb").read(33) +w, h = struct.unpack(">II", d[16:24]) +print(f"{w}x{h}") +PY +)" + +echo "ctos-shot: wrote $OUT ($SIZE)" >&2 + +# Surface any QML warnings once, they are usually the reason a surface is wrong. +if [ -s "$RUNDIR/quickshell.log" ]; then + echo "ctos-shot: quickshell output (tail):" >&2 + tail -20 "$RUNDIR/quickshell.log" | sed 's/^/ /' >&2 +fi + +echo "$OUT"