From c4733e0624dd3cfa56df1a5d159b0036d90ae7f2 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 18:43:37 -0400 Subject: [PATCH 1/6] Plan Panama roadmap completion --- .../plans/2026-08-18-roadmap-completion.md | 176 ++++++++++++++++++ .../2026-08-18-roadmap-completion-design.md | 127 +++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-roadmap-completion.md create mode 100644 docs/superpowers/specs/2026-08-18-roadmap-completion-design.md diff --git a/docs/superpowers/plans/2026-08-18-roadmap-completion.md b/docs/superpowers/plans/2026-08-18-roadmap-completion.md new file mode 100644 index 0000000..b9124ff --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-roadmap-completion.md @@ -0,0 +1,176 @@ +# Panama Roadmap Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close every audited correctness gap and finish the approved Phase 3–5 Panama roadmap. + +**Architecture:** Preserve Settings, `DesktopPreferences`, and `Theme.qml` as the existing ownership spine. Add narrowly scoped event observers, adapters, and generated artifacts around those boundaries; contextual shell surfaces only route into Settings. Each task is independently committed and contract-tested before final integration. + +**Tech Stack:** Quickshell/QML, JavaScript, Bash, Python 3, Hyprland Lua, systemd user units, `jq`. + +**Spec:** `docs/superpowers/specs/2026-08-18-roadmap-completion-design.md` + +## Global Constraints + +- Preserve the current Prism/Tokyo Night design language and existing component library. +- Do not introduce continuously repainting effects or polling loops. +- Do not expose or modify `config/bash/env`. +- Write a failing contract before each production change and record the red/green commands. +- Keep interactive QML harnesses and Quickshell restarts for the final integration gate. +- Generated and user-owned files must be written atomically. +- No production configuration is activated until the reviewed branch is merged. + +--- + +### Task 1: Close Phase 1 and Phase 2 safety gaps + +**Files:** +- Modify: `config/dot/quickshell/services/ColorScheme.qml` +- Modify: `config/dot/quickshell/modules/settings/README.md` +- Modify: `config/dot/hypr/looks.lua` +- Modify: `config/dot/quickshell/services/Displays.qml` +- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml` +- Modify: `config/dot/quickshell/scripts/panama-lock` +- Modify: `config/dot/quickshell/services/Wallpaper.qml` +- Modify: `config/dot/quickshell/modules/settings/WallpaperControls.qml` +- Test: `tests/quickshell/settings-ownership-contract.sh` +- Test: `tests/quickshell/display-transaction-contract.sh` +- Test: `tests/quickshell/lock-screen-helper-contract.sh` +- Test: `tests/quickshell/wallpaper-service-contract.sh` +- Test: `tests/quickshell/wallpaper-settings-contract.sh` + +**Interfaces:** +- Produces: `Displays.primaryFirstMonitors`, event-driven topology refresh, and a lock helper that never emits an unreadable image. +- Consumes: current `Theme.accent`, `Theme.accentSecondary`, display transaction rollback, and wallpaper preference schema. + +- [ ] Add failing ownership assertions that active borders derive from `Theme.accent` and `Theme.accentSecondary`, while inactive borders derive only from scheme roles; verify the current contract fails for the stale prohibition. +- [ ] Update the ownership implementation comments and documentation, then run `bash tests/quickshell/settings-ownership-contract.sh` and verify PASS. +- [ ] Add a failing display fixture that changes the harness screen model without calling the display service’s public `refresh()` and expects topology reconciliation plus rollback of a pending transaction. +- [ ] Add an event-driven screen-model observer and primary-first derived monitor list; run `bash tests/quickshell/display-transaction-contract.sh` and `bash tests/quickshell/display-layout-contract.sh`. +- [ ] Add failing lock fixtures for missing global and per-monitor wallpapers, requiring readable shipped-image fallback and screenshot fallback when no image exists. +- [ ] Tighten `panama-lock` path validation and bounded warnings; run `bash tests/quickshell/lock-screen-helper-contract.sh`. +- [ ] Add failing wallpaper contracts for primary-first output order, selection preservation, and a HOME path containing a single quote. +- [ ] Replace inline `bash -lc` path interpolation with an argument-safe helper and update controls to preserve selection; run both wallpaper contracts. +- [ ] Run the Task 1 contract set and commit with message `Close desktop safety gaps`. + +### Task 2: Complete contextual configuration and reliable overview capture + +**Files:** +- Modify: `config/dot/quickshell/modules/dock/Dock.qml` +- Modify: relevant dock context-menu component if already split +- Modify: `config/dot/quickshell/modules/notifications/NotificationCard.qml` +- Modify: `config/dot/quickshell/modules/osd/Osd.qml` +- Modify: `config/dot/quickshell/modules/overview/WindowThumbnail.qml` +- Modify: `config/dot/quickshell/services/ShellState.qml` +- Test: `tests/quickshell/settings-jump-contract.sh` +- Create or modify: `tests/quickshell/overview-thumbnail-contract.sh` + +**Interfaces:** +- Produces: one route per configurable surface into the existing owning Settings page. +- Consumes: `ShellState.openSettings(page)` and the existing app-icon thumbnail fallback. + +- [ ] Extend the settings-jump contract so it fails until dock, notifications, and OSD each expose a contextual Settings route without replacing their primary action. +- [ ] Add the three restrained contextual actions using existing menu/button styles and Settings routes. +- [ ] Add a failing static/isolated contract proving capture does not start before recording readiness, is bounded, and retains the icon fallback. +- [ ] Gate capture on recording readiness or the appropriate Quickshell frame-ready signal, remove warning-producing blind retries, and keep capture event-driven. +- [ ] Run both contracts and commit with message `Complete contextual desktop controls`. + +### Task 3: Complete advanced accents and named themes + +**Files:** +- Modify: `config/dot/quickshell/config/PreferenceSchema.qml` +- Modify: `config/dot/quickshell/config/Theme.qml` +- Create: `config/dot/quickshell/services/ThemeProfiles.qml` +- Modify: `config/dot/quickshell/modules/settings/AccentPicker.qml` +- Create: `config/dot/quickshell/modules/settings/AccentEditor.qml` +- Create: `config/dot/quickshell/modules/settings/ThemeProfilePicker.qml` +- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml` +- Modify: `config/dot/quickshell/services/ColorScheme.qml` +- Modify: `config/dot/quickshell/services/SettingsSearch.qml` +- Test: create `tests/quickshell/theme-profiles-contract.sh` +- Test: create `tests/quickshell/accent-controls-contract.sh` +- Test: modify `tests/quickshell/control-center-contract.sh` + +**Interfaces:** +- Produces: profile records `{id,name,scheme,accent,secondary,shipped}` and reactive `Theme` colour roles. +- Consumes: `DesktopPreferences`, `SystemSettings.commitPreference`, `hyprpicker`, and current Prism Settings components. + +- [ ] Write failing pure-model fixtures for three shipped profiles, custom profile creation, bounded unique names, shipped-profile immutability, and HSV/hex conversion. +- [ ] Implement `ThemeProfiles` with Moon, Moon Rose, and Day plus saved custom profiles, then verify model tests. +- [ ] Write failing UI contracts for labelled HSV controls, curated swatches, keyboard focus, screen picking, profile save/switch/delete, and search routing. +- [ ] Build the profile picker and advanced editor from existing `SettingRow`, `ValueSlider`, `SettingsButton`, and focus tokens; do not add ambient animation. +- [ ] Update `Theme.qml` and `ColorScheme` so custom pairs reactively reach shell roles and Hyprland borders. +- [ ] Extend the Control Center contract to cover power profiles, night light, and colour scheme controls added after its original contract. +- [ ] Run theme, accent, ownership, settings-search, and Control Center contracts; commit with message `Complete the Panama theme system`. + +### Task 4: Propagate the accent to external applications + +**Files:** +- Modify: `config/dot/quickshell/scripts/panama-theme-apps` +- Modify: `config/dot/quickshell/services/ColorScheme.qml` +- Modify: tracked kitty/tmux/Vicinae/Wofi/Neovim include points as required +- Create: generated-file templates or helpers under the owning application directories +- Test: create `tests/quickshell/theme-apps-accent-contract.sh` +- Test: modify `tests/quickshell/gtk-theme-contract.sh` if its invocation contract changes + +**Interfaces:** +- Produces: `panama-theme-apps dark|light [#RRGGBB #RRGGBB]` with per-adapter JSON status. +- Consumes: the selected profile’s validated primary and secondary accents. + +- [ ] Write a failing isolated fixture invoking the helper with dark/light and custom pairs, asserting atomic generated overrides for kitty, tmux, btop, Vicinae/Wofi, hyprlock, and Neovim plus backward-compatible two-state invocation. +- [ ] Implement validated optional accent arguments and independent adapters; never rewrite user-owned base files wholesale. +- [ ] Update each application’s tracked config to include its generated accent override after the base Tokyo Night theme. +- [ ] Pass the selected pair from `ColorScheme` and expose bounded adapter failures through its existing error state. +- [ ] Run the isolated helper contract, GTK contract, Bash syntax, and a fixture-only idempotence check; commit with message `Propagate accents across desktop applications`. + +### Task 5: Finish onboarding, recovery automation, and generated docs + +**Files:** +- Modify: `config/dot/quickshell/config/PreferenceSchema.qml` +- Create: `config/dot/quickshell/modules/settings/WelcomePage.qml` +- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml` +- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml` +- Modify: `config/dot/quickshell/services/SettingsSearch.qml` +- Modify: `config/dot/quickshell/services/SystemSettings.qml` +- Modify: display/theme/reset/restore call sites for pre-risk snapshots +- Modify: `setup/scripts/link-dotfiles` +- Create: user systemd snapshot service/timer files under the existing systemd config tree +- Create: `config/dot/quickshell/scripts/panama-generate-docs` +- Create: `docs/SETTINGS.md` +- Create: `docs/SHORTCUTS.md` +- Test: create `tests/quickshell/first-run-contract.sh` +- Test: modify `tests/quickshell/settings-backup-live-contract.sh` +- Test: create `tests/quickshell/generated-docs-contract.sh` +- Test: modify `tests/quickshell/keybind-rebind-contract.sh` + +**Interfaces:** +- Produces: fresh-install marker lifecycle, `snapshot --cause ` retention, and deterministic generated Markdown. +- Consumes: existing Settings navigation, backup/restore helper, preference schema, and shipped keybind metadata. + +- [ ] Write failing first-run fixtures proving only fresh installs get a marker, completion removes it atomically, existing settings are not interrupted, and Welcome remains searchable. +- [ ] Implement the Welcome page and marker lifecycle using existing card/row components. +- [ ] Add failing backup fixtures for daily retention and pre-risk snapshots before display layout, named theme, reset-all, and restore operations. +- [ ] Extend the backup helper/service with labelled atomic snapshots, fourteen-day retention, and a systemd user timer; wire the four risky call sites. +- [ ] Strengthen keybind contracts to explicitly cover both rebind and reset collisions. +- [ ] Write a failing drift contract for generated settings and shortcut documentation. +- [ ] Implement the deterministic generator, generate both Markdown files, and verify a second run produces no diff. +- [ ] Run first-run, backup, keybind, generated-docs, schema, and search contracts; commit with message `Finish onboarding and desktop recovery`. + +### Task 6: Integrate, review, activate, and verify live readiness + +**Files:** +- Modify only files required by review findings. +- Update roadmap/status documentation to mark exact delivered scope. + +**Interfaces:** +- Consumes: all preceding task commits. +- Produces: reviewed `main`, pushed origin, one activated Quickshell session, and an evidence ledger. + +- [ ] Run `git diff --check`, executable Bash syntax, Lua syntax, generated-doc drift, every non-interactive Quickshell contract, and Hyprland configuration validation. +- [ ] Dispatch a whole-branch code review against this plan and fix every Critical/Important finding with one reviewed fix wave. +- [ ] Merge the reviewed branch into `main` without rewriting shared history and push `origin/main`. +- [ ] Run the repository’s user-level setup/link step needed for new systemd units and autostarts; do not print environment secrets. +- [ ] Resolve live health where locally actionable: enable RustDesk and Nextcloud user autostart, install/link calendar integration if its declared dependency is already available, and leave hardware-only DDC as a documented warning. +- [ ] Restart Quickshell once, perform the interactive visual/runtime gate, inspect logs for QML errors and screencopy warnings, and run `panama-doctor --json`. +- [ ] Record exact passed commands, remaining environment-only observations, and final commit IDs. + diff --git a/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md b/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md new file mode 100644 index 0000000..b83514c --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-roadmap-completion-design.md @@ -0,0 +1,127 @@ +# Panama Roadmap Completion Design + +## Goal + +Finish the approved five-phase Panama desktop roadmap without diluting the +existing Prism/Tokyo Night identity or introducing parallel sources of truth. +The result must be safe enough for the daily desktop, understandable to a +normal GNOME/macOS user, and configurable without editing shell files. + +## Fixed product decisions + +- The existing Prism glass visual language, spacing, typography, and motion + tokens remain authoritative. New surfaces reuse existing Settings widgets. +- Curated named accents remain the fast path. Advanced colour controls add HSV + and screen picking without replacing the curated choices. +- Settings remain the sole configuration application. Contextual affordances + route into an owning Settings page instead of duplicating controls. +- User-selected accent is a theme role. `ColorScheme` applies it to external + systems but must never substitute a scheme-owned hard-coded focused colour. +- Existing installations must not be surprised by an automatic welcome flow. + Only fresh installs receive the first-run marker; the welcome page remains + manually discoverable later. +- No continuously repainting animation or background polling is introduced. + Hardware/topology changes are event-driven; periodic snapshots use a user + systemd timer. + +## 1. Correctness and safety closure + +The ownership contract and documentation will be updated to reflect the actual +boundary: `Theme` owns the active accent values, while `ColorScheme` owns +applying those values to Hyprland alongside its scheme-relative inactive role. + +Displays will observe the Quickshell screen model. A topology change refreshes +the full compositor layout. If a confirmation transaction is active, it enters +the existing safe rollback path using only currently connected outputs. The +production observer, rather than an explicit test-only refresh, is exercised by +the transaction contract. + +The lock generator will accept wallpaper paths only when they are absolute, +regular, and readable. Missing global or per-output images fall back to the +shipped wallpaper when readable, then to screenshot mode. Generation remains +atomic and returns a bounded user-facing warning. + +Wallpaper discovery will move shell quoting into a narrow helper that accepts +paths as arguments. Per-monitor choices list the selected primary output first +and preserve a valid selection across topology changes. + +## 2. Contextual configuration and overview reliability + +Every configurable shell surface gets an unobtrusive route to its owning page: + +- Bar widgets keep their current secondary-click behavior. +- Dock context menus include a final “Dock settings” action without replacing + application actions. +- Notification cards expose “Notification settings” in their overflow menu. +- A secondary click on a visible OSD opens Accessibility/OSD settings and then + dismisses the OSD. + +Overview thumbnails remain event-driven. Capture begins only after the overview +surface has a valid recording context. Retry logic must not knowingly call +`captureFrame()` while the context is unavailable, must stop after a bounded +deadline, and must leave the existing app-icon fallback without warning spam. + +## 3. Complete theme system + +Appearance presents three layers in one hierarchy: + +1. Shipped named themes: Moon, Moon Rose, and Day. +2. Saved user themes, capturing colour scheme and accent pair. +3. Advanced accent editing: hue, saturation, and value controls plus + `hyprpicker` screen sampling. + +The persisted model contains a stable profile identifier, display name, colour +scheme, primary accent, and secondary accent. Curated swatches map onto shipped +profiles. A custom edit switches to a custom profile without mutating a shipped +definition. Saved names are trimmed, bounded, and unique. + +`Theme.qml` remains the in-shell source of truth. The selected pair flows to the +bar, dock, panels, notifications, OSD, focused Hyprland border, and lock preview. +All controls have keyboard focus, visible focus treatment, and text labels; hue +is never the only signal. + +## 4. External accent propagation + +`panama-theme-apps` will accept scheme plus an optional validated accent pair. +Omitted accents preserve backward compatibility. It atomically writes small +generated overrides rather than rewriting user-owned base configuration. + +The current accent pair propagates to kitty, tmux, btop, Vicinae/Wofi, the +managed lock screen, and Panama's Neovim theme hook. Running kitty/tmux/Vicinae +instances are refreshed only through supported live interfaces; applications +without safe reload retain the generated value for their next launch. + +Each adapter reports `written`, `applied`, `skipped`, or `failed`. One adapter +failure does not prevent other targets from updating, but the shell receives a +bounded summary and exposes failures in health diagnostics. + +## 5. First run, recovery, and generated documentation + +The installer creates a first-run marker only for a new Panama settings store. +On the first shell session, Settings opens a restrained Welcome page covering: +launcher, tiling/window movement, Control Center, notifications/clipboard, and +health. Completion removes the marker atomically. The page can always be opened +from Settings search. + +Existing shortcut collision refusal remains the conflict-detection mechanism +and gains contract coverage for both rebind and reset collisions. + +Settings snapshots run daily through a user systemd timer and before these +risky operations: applying a complete display layout, applying a named theme, +resetting all preferences, and restoring a snapshot. Automatic snapshots are +labelled by cause, atomic, and retained for fourteen daily generations plus the +most recent snapshot for each risky cause. + +A deterministic generator produces `docs/SETTINGS.md` and +`docs/SHORTCUTS.md` from the preference schema and shipped keybind metadata. +A contract fails when committed documentation differs from generated output. + +## 6. Verification and rollout + +Work is built in an isolated worktree. Unit/contract tests run per task. No +interactive QML harness is opened on the daily desktop until the final gate. +The final gate includes every Quickshell contract, shell/Lua syntax, Hyprland +configuration validation, generated-document drift, a clean runtime log, and a +manual visual pass after merge. The branch is merged to `main`, pushed, and only +then activated by restarting Quickshell once. + From c4642919f784ff9617f9039baa3f97520828ed0b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 18:58:04 -0400 Subject: [PATCH 2/6] Close desktop safety gaps --- config/dot/quickshell/displays-harness.qml | 8 ++++ .../modules/settings/DisplaysPage.qml | 7 ++-- .../modules/settings/WallpaperControls.qml | 7 ++++ config/dot/quickshell/scripts/panama-lock | 34 +++++++++++---- .../quickshell/scripts/panama-wallpaper-scan | 14 +++++++ config/dot/quickshell/services/Displays.qml | 27 ++++++++++++ config/dot/quickshell/services/Wallpaper.qml | 25 +++++++---- .../quickshell/wallpaper-service-harness.qml | 6 ++- .../quickshell/wallpaper-settings-harness.qml | 10 +++++ tests/quickshell/display-transaction-contract | 12 +++--- tests/quickshell/lock-screen-helper-contract | 41 +++++++++++++++---- tests/quickshell/wallpaper-service-contract | 29 ++++++++++++- tests/quickshell/wallpaper-settings-contract | 9 ++++ 13 files changed, 194 insertions(+), 35 deletions(-) create mode 100755 config/dot/quickshell/scripts/panama-wallpaper-scan diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 780580f..9095478 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -6,6 +6,10 @@ import qs.config import qs.services ShellRoot { + // The isolated screen model begins with both fixture outputs so changing + // it below exercises the same reactive topology path as a real hotplug. + Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"] + IpcHandler { target: "displays-test" @@ -42,6 +46,7 @@ ShellRoot { function transactionStatus(): string { return JSON.stringify({ layout: Displays.currentLayout(), + primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name), pending: Displays.pendingRequestedLayout, previous: Displays.pendingPreviousLayout, reverting: Displays.revertExpectedLayout, @@ -141,5 +146,8 @@ ShellRoot { if (monitor) Displays.forget(monitor.name); } function refresh(): void { Displays.refresh(); } + function setScreenModel(names: string): void { + Displays.screenOverride = JSON.parse(names); + } } } diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 0f9987e..7164491 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -22,7 +22,7 @@ SettingsPage { property string selectedOutput: "" readonly property var monitor: Displays.monitorNamed(root.selectedOutput) - ?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null) + ?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null) readonly property string currentMode: root.monitor ? root.monitor.mode : "" @@ -38,7 +38,8 @@ SettingsPage { function syncSelectedOutput(): void { if (!Displays.monitorNamed(root.selectedOutput)) - root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; + root.selectedOutput = Displays.primaryFirstMonitors.length > 0 + ? Displays.primaryFirstMonitors[0].name : ""; } // Probing I2C for DDC-capable monitors takes on the order of a second, so @@ -137,7 +138,7 @@ SettingsPage { ChoiceGrid { width: parent.width label: "Display" - options: Displays.monitors.map(monitor => ({ + options: Displays.primaryFirstMonitors.map(monitor => ({ value: monitor.name, label: monitor.description || monitor.name })) diff --git a/config/dot/quickshell/modules/settings/WallpaperControls.qml b/config/dot/quickshell/modules/settings/WallpaperControls.qml index a3eaf10..e33aa65 100644 --- a/config/dot/quickshell/modules/settings/WallpaperControls.qml +++ b/config/dot/quickshell/modules/settings/WallpaperControls.qml @@ -18,6 +18,13 @@ Column { width: parent ? parent.width : 620 spacing: 4 + onOutputsChanged: root.syncSelectedOutput() + + function syncSelectedOutput(): void { + if (!root.outputs.includes(root.selectedOutput)) + root.selectedOutput = root.outputs.length > 0 ? root.outputs[0] : ""; + } + ChoiceGrid { width: parent.width label: "Wallpaper mode" diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 4d4a837..ed62d1a 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -17,8 +17,10 @@ status_file="$state_dir/hyprlock-status.json" fallback="$config_home/hypr/hyprlock.conf" temporary="$generated.tmp.$$" status_temporary="$status_file.tmp.$$" +shipped_wallpaper="$HOME/Pictures/Wallpapers/faroe_islands.jpg" settings_valid=false +wallpaper_warning="" cleanup() { rm -f "$temporary" "$status_temporary" 2>/dev/null || true @@ -76,7 +78,23 @@ read_object() { } valid_path() { - [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]] + [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* && -f "$1" && -r "$1" ]] +} + +resolve_wallpaper_path() { + local candidate="$1" + if valid_path "$candidate"; then + resolved_wallpaper="$candidate" + return + fi + if [[ -n "$candidate" ]]; then + wallpaper_warning="One or more lock-screen wallpapers were unavailable; a safe fallback is in use." + fi + if valid_path "$shipped_wallpaper"; then + resolved_wallpaper="$shipped_wallpaper" + else + resolved_wallpaper="screenshot" + fi } # Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by @@ -134,10 +152,9 @@ load_preferences() { wallpaper_mode="$(read_string wallpaperMode single)" [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ || wallpaper_mode=single - wallpaper_path="$(read_string wallpaperPath '')" - if ! valid_path "$wallpaper_path"; then - wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg" - fi + wallpaper_warning="" + resolve_wallpaper_path "$(read_string wallpaperPath '')" + wallpaper_path="$resolved_wallpaper" wallpaper_assignments="$(read_object wallpaperPerMonitor)" case "$blur_level" in @@ -218,8 +235,9 @@ emit_backgrounds() { candidate="$(jq -r --arg output "$output" \ 'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \ <<<"$wallpaper_assignments" 2>/dev/null || true)" - if valid_path "$candidate"; then - assigned="$candidate" + if [[ -n "$candidate" ]]; then + resolve_wallpaper_path "$candidate" + assigned="$resolved_wallpaper" fi fi emit_background "$output" "$assigned" @@ -344,7 +362,7 @@ generate() { write_status false "$fallback" true "The lock-screen configuration could not be generated." return 1 fi - write_status true "$generated" false "" + write_status true "$generated" false "$wallpaper_warning" } status() { diff --git a/config/dot/quickshell/scripts/panama-wallpaper-scan b/config/dot/quickshell/scripts/panama-wallpaper-scan new file mode 100755 index 0000000..9097287 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wallpaper-scan @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Finds wallpaper candidates from fixed roots passed as separate arguments. +# Keeping paths as argv values avoids shell interpolation for names containing +# quotes, spaces, or other shell syntax. + +set -euo pipefail + +find "$@" -maxdepth 2 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \ + -printf '%T@ %p\n' 2>/dev/null \ + | sort -rn \ + | cut -d' ' -f2- \ + | head -60 diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index a094af1..ef2fd2f 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -29,8 +29,24 @@ Singleton { // [{ name, description, width, height, refreshRate, scale, transform, // modes: [{ label, mode, width, height, refresh }] }] property var monitors: [] + // Quickshell.screens is the topology authority. The override is only the + // isolated harness model; normal sessions always observe Quickshell. + property var screenOverride: null property string lastError: "" + readonly property var screenModel: Array.isArray(root.screenOverride) + ? root.screenOverride : Quickshell.screens + readonly property string screenSignature: root.screenModel + .map(screen => typeof screen === "string" ? screen : screen.name) + .filter(name => !!name) + .sort() + .join("|") + readonly property var primaryFirstMonitors: root.monitors.slice().sort((left, right) => { + if (left.primary !== right.primary) + return left.primary ? -1 : 1; + return left.name.localeCompare(right.name); + }) + // Set while a change is applied but not yet confirmed. property var pendingPreviousLayout: null property var pendingRequestedLayout: null @@ -113,6 +129,17 @@ Singleton { Component.onCompleted: root.refresh() + onScreenSignatureChanged: root.reconcileTopology() + + // A hotplug can invalidate the unconfirmed layout while the confirmation + // is visible. Read the current compositor layout first; the normal queued + // rollback then filters out any output that has disappeared. + function reconcileTopology(): void { + root.refresh(); + if (root.awaitingConfirmation) + root.revertWithMessage("A display was connected or disconnected, so Panama restored the previous setting."); + } + function refresh(): bool { if (!query.running) { query.generation = root.operationGeneration; diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index e68db62..ec4f15e 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -45,9 +45,10 @@ Singleton { readonly property string outputSignature: root.outputNames().slice().sort().join("|") property var outputNames: function() { - if (Array.isArray(root.outputOverride)) - return root.outputOverride.slice(); - return Quickshell.screens.map(screen => screen.name).filter(name => !!name); + const outputs = Array.isArray(root.outputOverride) + ? root.outputOverride.slice() + : Quickshell.screens.map(screen => screen.name).filter(name => !!name); + return root.primaryFirstOutputs(outputs); } readonly property var searchRoots: [ @@ -59,10 +60,8 @@ Singleton { Process { id: scan - command: ["bash", "-lc", - "find " + root.searchRoots.map(dir => `'${dir}'`).join(" ") - + " -maxdepth 2 -type f \\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \\)" - + " -printf '%T@ %p\\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | head -60"] + command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"] + .concat(root.searchRoots) stdout: StdioCollector { onStreamFinished: { root.available = this.text.split("\n") @@ -164,6 +163,18 @@ Singleton { activeQuery.running = true; } + // The saved primary role anchors Panama's per-monitor choices. Keep the + // compositor's remaining order stable so a reconnect does not reshuffle + // the rest of the picker unnecessarily. + function primaryFirstOutputs(outputs: var): var { + const stored = DesktopPreferences.get("displays"); + const layouts = stored && typeof stored === "object" ? stored : {}; + const primary = outputs.find(output => layouts[output]?.primary === true); + return primary === undefined + ? outputs + : [primary].concat(outputs.filter(output => output !== primary)); + } + function candidates(extra: var): var { const result = []; const discovered = Array.isArray(root.candidateOverride) diff --git a/config/dot/quickshell/wallpaper-service-harness.qml b/config/dot/quickshell/wallpaper-service-harness.qml index 57ef178..6fe92a8 100644 --- a/config/dot/quickshell/wallpaper-service-harness.qml +++ b/config/dot/quickshell/wallpaper-service-harness.qml @@ -7,7 +7,7 @@ import qs.services ShellRoot { Component.onCompleted: { - Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"]; + Wallpaper.outputOverride = ["HDMI-A-1", "DP-2"]; Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]; Wallpaper.startupRestoreEnabled = false; Wallpaper.slideshowIntervalOverrideMs = 60000; @@ -63,7 +63,9 @@ ShellRoot { assignments: DesktopPreferences.get("wallpaperPerMonitor"), slideshowPath: Wallpaper.slideshowPath, shuffleBag: Wallpaper.shuffleBag, - slideshowTimerRunning: Wallpaper.slideshowTimerRunning + slideshowTimerRunning: Wallpaper.slideshowTimerRunning, + scanning: Wallpaper.scanning, + available: Wallpaper.available }); } } diff --git a/config/dot/quickshell/wallpaper-settings-harness.qml b/config/dot/quickshell/wallpaper-settings-harness.qml index 57fcd4a..21be75b 100644 --- a/config/dot/quickshell/wallpaper-settings-harness.qml +++ b/config/dot/quickshell/wallpaper-settings-harness.qml @@ -46,6 +46,16 @@ ShellRoot { return JSON.stringify(root.calls); } + function selectOutput(output: string): void { + controls.selectedOutput = output; + } + + function updateOutputs(outputs: string): void { + controls.outputs = outputs === "" ? [] : outputs.split("|"); + } + + function selectedOutput(): string { return controls.selectedOutput; } + function states(): string { picker.mode = "single"; const single = { diff --git a/tests/quickshell/display-transaction-contract b/tests/quickshell/display-transaction-contract index d13cf57..9d6d657 100755 --- a/tests/quickshell/display-transaction-contract +++ b/tests/quickshell/display-transaction-contract @@ -223,16 +223,18 @@ failed_state="$(wait_for '.busy == false and .awaiting == false')" jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \ || fail "failed apply did not retain a useful recovery message: $failed_state" -# If an output disconnects while a change is pending, rollback sends one -# transaction containing every output that is still connected. +# If an output disconnects while a change is pending, the screen-model observer +# refreshes topology and rolls back with only the still-connected output. This +# intentionally does not call Displays.refresh() through the public harness API. [[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \ || fail 'disconnect fixture could not start' wait_for '.canConfirm == true' >/dev/null jq '.[0:1]' "$monitor_state" >"$fixture/connected.json" mv "$fixture/connected.json" "$monitor_state" -run ipc --pid "$harness_pid" call displays-test refresh >/dev/null -wait_for '.layout | length == 1' >/dev/null -run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +run ipc --pid "$harness_pid" call displays-test setScreenModel '["DP-2"]' >/dev/null +wait_for '(.layout | length == 1) and .awaiting == false and .busy == false' >/dev/null +[[ "$(transaction_status | jq -c .primaryFirst)" == '["DP-2"]' ]] \ + || fail "primary-first monitor list did not reconcile after hot-unplug: $(transaction_status)" wait_for '.busy == false and .awaiting == false' >/dev/null disconnect_payload="$(tail -1 "$eval_log")" [[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \ diff --git a/tests/quickshell/lock-screen-helper-contract b/tests/quickshell/lock-screen-helper-contract index c705f36..4e8b11d 100755 --- a/tests/quickshell/lock-screen-helper-contract +++ b/tests/quickshell/lock-screen-helper-contract @@ -14,6 +14,9 @@ settings="$config_home/panama/settings.json" generated="$state_home/panama/hyprlock.conf" hyprlock_log="$fixture/hyprlock.log" theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" +shipped_wallpaper="$fixture_home/Pictures/Wallpapers/faroe_islands.jpg" +global_wallpaper="$fixture_home/Pictures/Wallpapers/global.jpg" +portrait_wallpaper="$fixture_home/Pictures/Wallpapers/portrait.jpg" fail() { printf 'lock screen helper contract: %s\n' "$1" >&2 @@ -28,6 +31,9 @@ trap cleanup EXIT mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \ "$config_home/hypr" "$state_home" "$test_bin" +printf 'shipped wallpaper\n' >"$shipped_wallpaper" +printf 'global wallpaper\n' >"$global_wallpaper" +printf 'portrait wallpaper\n' >"$portrait_wallpaper" cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \ "$config_home/hypr/hyprlock.conf.template" HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" \ @@ -131,19 +137,38 @@ write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}' run_helper generate rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue' -write_settings '{ - "lockBackgroundMode":"wallpaper", - "wallpaperMode":"per-monitor", - "wallpaperPath":"/images/global.jpg", - "wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"} -}' +write_settings "$(jq -n \ + --arg global "$global_wallpaper" \ + --arg portrait "$portrait_wallpaper" \ + '{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$global,wallpaperPerMonitor:{"DP-2":$portrait}}')" run_helper generate [[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output' -awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ +awk -v path="$portrait_wallpaper" '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \ || fail 'per-monitor wallpaper was not used for DP-2' -awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \ +awk -v path="$global_wallpaper" '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && index($0, "path = " path){path_seen=1} END{exit !(monitor && path_seen)}' "$generated" \ || fail 'missing monitor assignment did not fall back to the global wallpaper' +# A selected but unreadable image never reaches hyprlock. One bounded warning +# covers both missing global and per-monitor choices while the shipped image is +# still readable. +write_settings "$(jq -n \ + --arg missing "$fixture/missing.jpg" \ + '{lockBackgroundMode:"wallpaper",wallpaperMode:"per-monitor",wallpaperPath:$missing,wallpaperPerMonitor:{"DP-2":$missing}}')" +run_helper generate +[[ "$(rg -c "path = $shipped_wallpaper" "$generated")" -eq 2 ]] \ + || fail 'missing selected wallpapers did not fall back to the readable shipped image' +jq -e '.generated == true and .fallback == false + and .error == "One or more lock-screen wallpapers were unavailable; a safe fallback is in use."' \ + <<<"$(run_helper status)" >/dev/null \ + || fail 'missing wallpaper fallback did not report one bounded warning' + +# If no image is readable, screenshot is the only background source that +# hyprlock can still render safely. +rm -f "$shipped_wallpaper" +run_helper generate +[[ "$(rg -c 'path = screenshot' "$generated")" -eq 2 ]] \ + || fail 'missing wallpapers without the shipped image did not fall back to screenshots' + write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}' run_helper generate rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored' diff --git a/tests/quickshell/wallpaper-service-contract b/tests/quickshell/wallpaper-service-contract index 769ce4f..187316a 100755 --- a/tests/quickshell/wallpaper-service-contract +++ b/tests/quickshell/wallpaper-service-contract @@ -15,6 +15,8 @@ active_state="$fixture/active.json" control="$fixture/control" shell_log="$fixture/quickshell.log" harness_pid="" +quoted_home="$fixture/home's" +quoted_wallpaper="$quoted_home/Pictures/Wallpapers/quoted.jpg" fail() { printf 'wallpaper service contract: %s\n' "$1" >&2 @@ -46,9 +48,18 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$config_home/panama" "$state_home" "$test_bin" +mkdir -p "$config_home/panama" "$state_home" "$test_bin" \ + "$quoted_home/Pictures/Wallpapers" cp -a "$repo_dir/config/dot/quickshell" "$config_path" -printf '%s\n' '{}' >"$config_home/panama/settings.json" +printf 'quoted wallpaper\n' >"$quoted_wallpaper" +cat >"$config_home/panama/settings.json" <<'JSON' +{ + "displays": { + "DP-2": {"mode":"4500x3000@60.00","scale":1.5,"transform":0,"x":0,"y":0,"primary":true}, + "HDMI-A-1": {"mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":0,"primary":false} + } +} +JSON printf '%s\n' '{}' >"$active_state" : >"$command_log" : >"$control" @@ -92,6 +103,7 @@ qs_for_harness() { XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" PATH="$test_bin:$PATH" + HOME="$quoted_home" PANAMA_WALLPAPER_COMMAND_LOG="$command_log" PANAMA_WALLPAPER_ACTIVE_STATE="$active_state" PANAMA_WALLPAPER_CONTROL="$control" @@ -113,6 +125,18 @@ wait_idle() { fail "wallpaper transaction did not settle: $status" } +wait_for_scan() { + local status="" + for _ in $(seq 1 80); do + status="$(qs_for_harness ipc call wallpaper-service-test status)" + jq -e --arg path "$quoted_wallpaper" \ + '.scanning == false and (.available | index($path) != null)' \ + <<<"$status" >/dev/null && return + sleep 0.1 + done + fail "argument-safe wallpaper scan did not discover the quoted HOME image: $status" +} + qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated service harness did not launch' for _ in $(seq 1 60); do harness_pid="$(instances_for_harness | head -1)" @@ -123,6 +147,7 @@ for _ in $(seq 1 60); do sleep 0.1 done [[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated service harness process did not start' +wait_for_scan wait_idle >/dev/null : >"$command_log" diff --git a/tests/quickshell/wallpaper-settings-contract b/tests/quickshell/wallpaper-settings-contract index 657122a..53cd3c7 100755 --- a/tests/quickshell/wallpaper-settings-contract +++ b/tests/quickshell/wallpaper-settings-contract @@ -68,6 +68,15 @@ done [[ "$(qs_for_harness ipc call wallpaper-settings-test activate per-monitor /images/c.jpg)" \ == '["assign:DP-2:/images/c.jpg"]' ]] || fail 'Per-display mode did not assign the selected output' +qs_for_harness ipc call wallpaper-settings-test selectOutput HDMI-A-1 >/dev/null +qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2|HDMI-A-1' >/dev/null +[[ "$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" == 'HDMI-A-1' ]] \ + || fail 'a valid per-monitor selection was reset during topology reconciliation' +qs_for_harness ipc call wallpaper-settings-test updateOutputs 'DP-2' >/dev/null +selected_after_disconnect="$(qs_for_harness ipc call wallpaper-settings-test selectedOutput)" +[[ "$selected_after_disconnect" == 'DP-2' ]] \ + || fail "a disconnected per-monitor selection did not fall back to the primary output: $selected_after_disconnect" + states="$(qs_for_harness ipc call wallpaper-settings-test states)" jq -e '.selectedOutput == "DP-2" and .single == {"current":true,"selected":true} From a90f6eb35730d52fee432afca63e86d2fe3fa11e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 19:03:29 -0400 Subject: [PATCH 3/6] Fix wallpaper scan cap exit status --- .../quickshell/scripts/panama-wallpaper-scan | 2 +- tests/quickshell/wallpaper-service-contract | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/config/dot/quickshell/scripts/panama-wallpaper-scan b/config/dot/quickshell/scripts/panama-wallpaper-scan index 9097287..8f8daee 100755 --- a/config/dot/quickshell/scripts/panama-wallpaper-scan +++ b/config/dot/quickshell/scripts/panama-wallpaper-scan @@ -11,4 +11,4 @@ find "$@" -maxdepth 2 -type f \ -printf '%T@ %p\n' 2>/dev/null \ | sort -rn \ | cut -d' ' -f2- \ - | head -60 + | awk 'NR <= 60' diff --git a/tests/quickshell/wallpaper-service-contract b/tests/quickshell/wallpaper-service-contract index 187316a..7ad1149 100755 --- a/tests/quickshell/wallpaper-service-contract +++ b/tests/quickshell/wallpaper-service-contract @@ -4,6 +4,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" service="$repo_dir/config/dot/quickshell/services/Wallpaper.qml" +scanner="$repo_dir/config/dot/quickshell/scripts/panama-wallpaper-scan" fixture="$(mktemp -d /tmp/panama-wallpaper-service.XXXXXX)" config_home="$fixture/config" state_home="$fixture/state" @@ -17,6 +18,8 @@ shell_log="$fixture/quickshell.log" harness_pid="" quoted_home="$fixture/home's" quoted_wallpaper="$quoted_home/Pictures/Wallpapers/quoted.jpg" +bulk_wallpapers="$fixture/bulk-wallpapers" +bulk_segment="wallpaper-root-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" fail() { printf 'wallpaper service contract: %s\n' "$1" >&2 @@ -48,10 +51,26 @@ cleanup() { } trap cleanup EXIT +for _ in $(seq 1 15); do + bulk_wallpapers="$bulk_wallpapers/$bulk_segment" +done mkdir -p "$config_home/panama" "$state_home" "$test_bin" \ - "$quoted_home/Pictures/Wallpapers" + "$quoted_home/Pictures/Wallpapers" "$bulk_wallpapers" cp -a "$repo_dir/config/dot/quickshell" "$config_path" printf 'quoted wallpaper\n' >"$quoted_wallpaper" +for index in $(seq 1 401); do + wallpaper="$bulk_wallpapers/wallpaper-$index.jpg" + printf 'bulk wallpaper %s\n' "$index" >"$wallpaper" + touch -d "@$((1700000000 + index))" "$wallpaper" +done +if ! bulk_scan="$("$scanner" "$bulk_wallpapers")"; then + fail 'a normal scan with more than 60 images exited non-zero' +fi +expected_bulk_scan="$(for index in $(seq 401 -1 342); do + printf '%s\n' "$bulk_wallpapers/wallpaper-$index.jpg" +done)" +[[ "$bulk_scan" == "$expected_bulk_scan" ]] \ + || fail "over-cap scan did not emit exactly the newest 60 images: $bulk_scan" cat >"$config_home/panama/settings.json" <<'JSON' { "displays": { From 69ecec7ecfd150ddba011e8b7944678686a468ab Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 19:12:41 -0400 Subject: [PATCH 4/6] Complete contextual desktop controls --- config/dot/quickshell/modules/dock/Dock.qml | 10 +++ .../dot/quickshell/modules/dock/DockBody.qml | 2 + .../modules/dock/DockContextMenu.qml | 84 +++++++++++++++++++ .../dot/quickshell/modules/dock/DockItem.qml | 7 +- .../notifications/NotificationCard.qml | 36 +++++++- config/dot/quickshell/modules/osd/Osd.qml | 20 ++++- .../modules/overview/WindowThumbnail.qml | 40 ++++----- .../quickshell/overview-thumbnail-contract.sh | 43 ++++++++++ tests/quickshell/settings-jump-contract | 30 +++++++ 9 files changed, 249 insertions(+), 23 deletions(-) create mode 100644 config/dot/quickshell/modules/dock/DockContextMenu.qml create mode 100755 tests/quickshell/overview-thumbnail-contract.sh diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index d476b29..352c60f 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -210,6 +210,12 @@ PanelWindow { DockBody { id: body + onContextMenuRequested: (anchorItem, entry) => { + dockContextMenu.anchorItem = anchorItem; + dockContextMenu.entry = entry; + dockContextMenu.visible = true; + } + vertical: root.vertical leftSide: root.position === "left" @@ -260,4 +266,8 @@ PanelWindow { } } } + + DockContextMenu { + id: dockContextMenu + } } diff --git a/config/dot/quickshell/modules/dock/DockBody.qml b/config/dot/quickshell/modules/dock/DockBody.qml index 5aa097f..7b462c0 100644 --- a/config/dot/quickshell/modules/dock/DockBody.qml +++ b/config/dot/quickshell/modules/dock/DockBody.qml @@ -116,6 +116,7 @@ Rectangle { // The item the tooltip is currently describing, or null. property Item hoveredItem: null + signal contextMenuRequested(Item anchorItem, var entry) // Set by the Dock. A side dock runs the same strip down the screen instead // of across it. @@ -170,6 +171,7 @@ Rectangle { onEntered: root.hoveredItem = dockItem onExited: if (root.hoveredItem === dockItem) root.hoveredItem = null + onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry) } } } diff --git a/config/dot/quickshell/modules/dock/DockContextMenu.qml b/config/dot/quickshell/modules/dock/DockContextMenu.qml new file mode 100644 index 0000000..81ff24c --- /dev/null +++ b/config/dot/quickshell/modules/dock/DockContextMenu.qml @@ -0,0 +1,84 @@ +// The dock's app menu. Desktop-entry actions stay first; the shell-owned +// configuration route is deliberately last so it never displaces app actions. + +import Quickshell +import QtQuick +import qs.config +import qs.modules.bar +import qs.services +import qs.widgets + +PopupWindow { + id: root + + property Item anchorItem: null + property var entry: null + + anchor.item: root.anchorItem + anchor.edges: Edges.Top | Edges.Left + anchor.gravity: Edges.Top | Edges.Right + anchor.margins.bottom: 8 + + implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240) + implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2 + color: "transparent" + visible: false + grabFocus: true + + Rectangle { + anchors.fill: parent + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.08) + + PrismEdge { + anchors.top: parent.top + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + } + + Column { + id: menu + anchors.fill: parent + anchors.margins: Theme.popoverPadding + spacing: 2 + + Repeater { + id: applicationActions + model: root.entry ? root.entry.actions : [] + + delegate: TrayMenuRow { + required property var modelData + + width: parent.width + label: modelData.name + onActivated: { + modelData.execute(); + root.visible = false; + } + } + } + + Rectangle { + width: parent.width + height: 1 + anchors.margins: 3 + visible: applicationActions.count > 0 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.1) + } + + TrayMenuRow { + width: parent.width + label: "Dock settings" + onActivated: { + ShellState.openSettings("desktop"); + root.visible = false; + } + } + } + } +} diff --git a/config/dot/quickshell/modules/dock/DockItem.qml b/config/dot/quickshell/modules/dock/DockItem.qml index 26a46de..5685689 100644 --- a/config/dot/quickshell/modules/dock/DockItem.qml +++ b/config/dot/quickshell/modules/dock/DockItem.qml @@ -24,6 +24,7 @@ Item { // Emitted so DockBody can drive the single shared tooltip. signal entered signal exited + signal contextMenuRequested // The icon may grow past the cell on hover; the cell itself stays a fixed // size so the row doesn't reflow. @@ -115,7 +116,7 @@ Item { id: mouse anchors.fill: parent hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.MiddleButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onEntered: root.entered() onExited: root.exited() @@ -126,6 +127,10 @@ Item { root.launch(); return; } + if (mev.button === Qt.RightButton) { + root.contextMenuRequested(); + return; + } if (root.running) root.focusNext(); else diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml index 6b1c5f3..e3c9134 100644 --- a/config/dot/quickshell/modules/notifications/NotificationCard.qml +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -14,6 +14,7 @@ import Quickshell.Services.Notifications import qs.config import qs.services import qs.modules.quicksettings +import qs.modules.bar import qs.widgets Rectangle { @@ -127,11 +128,44 @@ Rectangle { onClicked: root.dismissed() } + IconButton { + id: settingsMenuButton + anchors.right: closeButton.left + anchors.rightMargin: 2 + anchors.top: parent.top + anchors.topMargin: 6 + size: 24 + iconSize: 14 + tint: Theme.fgDim + icon: "view-more-symbolic" + iconFallback: "open-menu-symbolic" + onClicked: settingsMenu.visible = !settingsMenu.visible + } + + Popover { + id: settingsMenu + anchorItem: settingsMenuButton + + Column { + implicitWidth: notificationSettings.implicitWidth + implicitHeight: notificationSettings.implicitHeight + + TrayMenuRow { + id: notificationSettings + label: "Notification settings" + onActivated: { + ShellState.openSettings("notifications"); + settingsMenu.visible = false; + } + } + } + } + Column { id: layout anchors.left: appIcon.visible ? appIcon.right : parent.left anchors.leftMargin: appIcon.visible ? 10 : 14 - anchors.right: image.visible ? image.left : closeButton.left + anchors.right: image.visible ? image.left : settingsMenuButton.left anchors.rightMargin: 8 anchors.top: parent.top anchors.topMargin: 12 diff --git a/config/dot/quickshell/modules/osd/Osd.qml b/config/dot/quickshell/modules/osd/Osd.qml index 0aa43db..0446d92 100644 --- a/config/dot/quickshell/modules/osd/Osd.qml +++ b/config/dot/quickshell/modules/osd/Osd.qml @@ -34,7 +34,14 @@ PanelWindow { implicitWidth: root.desiredWidth implicitHeight: 64 color: "transparent" - mask: Region {} + mask: Region { + item: inputMask + } + + Item { + id: inputMask + anchors.fill: parent + } WlrLayershell.namespace: "qs-popover-osd" WlrLayershell.layer: WlrLayer.Overlay @@ -145,6 +152,17 @@ PanelWindow { font.weight: Font.DemiBold } } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: event => { + if (event.button !== Qt.RightButton) + return; + ShellState.openSettings("accessibility"); + OsdState.hide(); + } + } } TextMetrics { diff --git a/config/dot/quickshell/modules/overview/WindowThumbnail.qml b/config/dot/quickshell/modules/overview/WindowThumbnail.qml index 4bff7ca..09534ad 100644 --- a/config/dot/quickshell/modules/overview/WindowThumbnail.qml +++ b/config/dot/quickshell/modules/overview/WindowThumbnail.qml @@ -176,33 +176,33 @@ Item { height: aspect > 0 ? width / aspect : parent.height opacity: hasContent ? 1 : 0 - // The capture context isn't ready the instant the layer surface is - // told to become visible — it needs the surface to actually map, - // which takes a frame or two. Calling captureFrame() before then - // logs "no recording context is ready" and yields nothing, so - // retry a few times and then give up quietly (the caption and app - // icon are still shown, so a missing thumbnail is cosmetic). - property int captureAttempts: 0 + // A ScreencopyView cannot tell us that its recording context is + // ready before a capture. The enclosing overview gets that + // context only after its first rendered frame, so defer the first + // one-shot capture to that event. Retrying before then only emits + // "no recording context is ready" warnings; if a capture later + // cannot produce content, the app icon remains the fallback. + property bool recordingReady: false function tryCapture(): void { - captureAttempts = 0; - captureRetry.restart(); + if (shot.hasContent) + return; + if (!shot.recordingReady) { + frameReady.restart(); + return; + } + shot.captureFrame(); } - // `shot`, not `parent`: Timer is a QtObject, so `parent` does not - // resolve to the enclosing ScreencopyView. - Timer { - id: captureRetry - interval: 80 - repeat: true + FrameAnimation { + id: frameReady running: false onTriggered: { - if (shot.hasContent || shot.captureAttempts >= 6) { - stop(); + running = false; + if (shot.hasContent) return; - } - shot.captureAttempts++; - shot.captureFrame(); + shot.recordingReady = true; + shot.tryCapture(); } } diff --git a/tests/quickshell/overview-thumbnail-contract.sh b/tests/quickshell/overview-thumbnail-contract.sh new file mode 100755 index 0000000..315b419 --- /dev/null +++ b/tests/quickshell/overview-thumbnail-contract.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# A ScreencopyView only has content *after* capture. It is not a readiness +# signal, so an overview thumbnail must first wait for its enclosing layer +# surface to render a frame. This contract keeps that warning-prone boundary +# explicit without opening a QML test window. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +thumbnail="$repo_dir/config/dot/quickshell/modules/overview/WindowThumbnail.qml" + +fail() { + printf 'overview thumbnail contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -r "$thumbnail" ]] || fail 'WindowThumbnail.qml is missing' + +grep -qF 'FrameAnimation {' "$thumbnail" \ + || fail 'capture has no frame-ready gate' +grep -qF 'property bool recordingReady: false' "$thumbnail" \ + || fail 'capture readiness is not tracked independently from captured content' +grep -qF 'if (!shot.recordingReady)' "$thumbnail" \ + || fail 'capture can start before the frame-ready gate' +grep -qF 'frameReady.restart();' "$thumbnail" \ + || fail 'a pending capture is not scheduled by the frame-ready gate' +grep -qF 'shot.recordingReady = true;' "$thumbnail" \ + || fail 'the frame-ready signal never releases capture' + +capture_calls="$(grep -cF 'shot.captureFrame()' "$thumbnail")" +[[ "$capture_calls" -eq 1 ]] \ + || fail "capture must have one bounded attempt after readiness, found $capture_calls" +! grep -qF 'captureAttempts' "$thumbnail" \ + || fail 'blind capture retry state remains' +! grep -qF 'captureRetry' "$thumbnail" \ + || fail 'blind capture retry timer remains' + +# The application icon remains visible until the one-shot capture succeeds. +grep -qF 'opacity: shotLoader.hasFrame ? 0 : 1' "$thumbnail" \ + || fail 'app-icon fallback no longer remains visible before a frame arrives' + +printf 'overview thumbnail contract: PASS (frame-gated one-shot capture with icon fallback)\n' diff --git a/tests/quickshell/settings-jump-contract b/tests/quickshell/settings-jump-contract index 02d356c..d591064 100755 --- a/tests/quickshell/settings-jump-contract +++ b/tests/quickshell/settings-jump-contract @@ -16,6 +16,9 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml" modules="$repo_dir/config/dot/quickshell/modules" +dock_menu="$modules/dock/DockContextMenu.qml" +notification_card="$modules/notifications/NotificationCard.qml" +osd="$modules/osd/Osd.qml" fail() { printf 'settings jump contract: %s\n' "$1" >&2 @@ -47,4 +50,31 @@ for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do || fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert" done +# These surfaces do not have a bar-style secondary-click signal. Their +# contextual affordances must retain the original interaction and route to the +# setting page that owns the controls. +[[ -r "$dock_menu" ]] || fail 'dock has no contextual menu, so application actions cannot keep a final Dock settings action' +grep -qF 'ShellState.openSettings("desktop")' "$dock_menu" \ + || fail 'dock context menu does not open Desktop settings' +grep -qF 'entry.actions' "$dock_menu" \ + || fail 'dock context menu dropped application actions' +actions_line="$(grep -nF 'entry.actions' "$dock_menu" | head -1 | cut -d: -f1)" +settings_line="$(grep -nF 'Dock settings' "$dock_menu" | head -1 | cut -d: -f1)" +[[ -n "$actions_line" && -n "$settings_line" && "$actions_line" -lt "$settings_line" ]] \ + || fail 'Dock settings is not the final contextual action after application actions' + +grep -qF 'Notification settings' "$notification_card" \ + || fail 'notification card has no overflow Settings action' +grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \ + || fail 'notification overflow does not open Notifications settings' +grep -qF 'Popover {' "$notification_card" \ + || fail 'notification Settings action is not contained in an overflow menu' + +grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \ + || fail 'OSD does not accept its contextual secondary click' +grep -qF 'ShellState.openSettings("accessibility")' "$osd" \ + || fail 'OSD secondary click does not open Accessibility settings' +grep -qF 'OsdState.hide()' "$osd" \ + || fail 'OSD remains visible after its Settings jump' + printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count" From ed428e87c450ecb8a12f4fa65a60fd4a4da6ef09 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 19:33:39 -0400 Subject: [PATCH 5/6] Complete the Panama theme system --- .../quickshell/accent-controls-harness.qml | 41 ++ .../quickshell/config/PreferenceSchema.qml | 13 + config/dot/quickshell/config/Theme.qml | 33 +- .../modules/settings/AccentEditor.qml | 218 +++++++++ .../modules/settings/AccentPicker.qml | 28 +- .../modules/settings/AppearancePage.qml | 14 +- .../modules/settings/ThemeProfilePicker.qml | 123 +++++ config/dot/quickshell/modules/settings/qmldir | 2 + .../dot/quickshell/services/ColorScheme.qml | 8 + .../quickshell/services/SettingsSearch.qml | 5 +- .../quickshell/services/ThemeProfileModel.js | 419 ++++++++++++++++++ .../dot/quickshell/services/ThemeProfiles.qml | 108 +++++ .../dot/quickshell/theme-profiles-harness.qml | 40 ++ tests/quickshell/accent-controls-contract.sh | 133 ++++++ tests/quickshell/adwaita-accent-contract | 26 +- tests/quickshell/control-center-contract | 31 ++ tests/quickshell/theme-profiles-contract.sh | 169 +++++++ 17 files changed, 1370 insertions(+), 41 deletions(-) create mode 100644 config/dot/quickshell/accent-controls-harness.qml create mode 100644 config/dot/quickshell/modules/settings/AccentEditor.qml create mode 100644 config/dot/quickshell/modules/settings/ThemeProfilePicker.qml create mode 100644 config/dot/quickshell/services/ThemeProfileModel.js create mode 100644 config/dot/quickshell/services/ThemeProfiles.qml create mode 100644 config/dot/quickshell/theme-profiles-harness.qml create mode 100755 tests/quickshell/accent-controls-contract.sh create mode 100755 tests/quickshell/theme-profiles-contract.sh diff --git a/config/dot/quickshell/accent-controls-harness.qml b/config/dot/quickshell/accent-controls-harness.qml new file mode 100644 index 0000000..79e212c --- /dev/null +++ b/config/dot/quickshell/accent-controls-harness.qml @@ -0,0 +1,41 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.settings +import qs.services + +ShellRoot { + Item { + width: 680 + height: editor.implicitHeight + + AccentEditor { + id: editor + width: parent.width + } + + ThemeProfilePicker { + width: parent.width + visible: false + } + + AccentPicker { + width: parent.width + visible: false + } + } + + IpcHandler { + target: "accent-controls-test" + + function status(): string { + return JSON.stringify(ThemeProfiles.activeProfile); + } + + function adjust(target: string, channel: string, ratio: real): string { + editor.changeChannel(target, channel, ratio); + return status(); + } + } +} diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index e92e226..cd7b4fc 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -975,6 +975,19 @@ Singleton { { value: "slate", label: "Slate" } ] }, + { + key: "themeProfileId", type: "string", def: "moon", group: "appearance", + pattern: "^[a-z0-9][a-z0-9-]{0,63}$", + label: "Selected theme profile", + detail: "The shipped or saved theme currently applied to the desktop", + internal: true + }, + { + key: "themeProfiles", type: "json", def: [], group: "appearance", + label: "Saved theme profiles", + detail: "Named custom colour schemes and accent pairs", + internal: true + }, // ── Application themes ───────────────────────────────────────────── // ColorScheme owns GTK's light/dark theme. These are the two theme diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index 882da56..e54d8d1 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -14,6 +14,7 @@ import Quickshell // QtQuick is required even though nothing visual is declared here: `color` is a // QtQuick value type, and Qt.rgba() lives in its JS namespace. import QtQuick +import qs.services Singleton { id: root @@ -60,30 +61,20 @@ Singleton { // Blue is the shipped Prism -- blue leading, orchid following -- and stays // the default. // - // `gnome` is the nearest member of GNOME's own accent-color enum, which is - // a fixed list of nine we do not get to extend. It is what libadwaita + // The table itself lives in services/ThemeProfileModel.js so a curated + // accent and a custom profile are the same kind of record. Its `gnome` + // member is the nearest name in GNOME's own accent-color enum, which is a + // fixed list of nine we do not get to extend; it is what libadwaita // applications -- Files, Papers, Loupe -- are told to use, so choosing an // accent here recolors them too instead of leaving them in GNOME blue. - // Nearest by hue, not by name: "rose" maps to red rather than pink because - // it is the red role in this palette. - readonly property var accents: ({ - "blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue", gnome: "blue" }, - "orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid", gnome: "purple" }, - "teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal", gnome: "teal" }, - "green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green", gnome: "green" }, - "amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber", gnome: "yellow" }, - "orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange", gnome: "orange" }, - "rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose", gnome: "red" }, - "slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate", gnome: "slate" } - }) + readonly property var accents: ThemeProfiles.curatedAccents + readonly property var activeProfile: ThemeProfiles.activeProfile - // Falls back to blue for an unknown name, so a settings file written by a - // newer Panama -- or edited by hand -- degrades to the shipped identity - // rather than to an undefined color. - readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"] - - readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light - readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary + // ThemeProfiles validates every persisted record before it can become + // active, so these bindings are both reactive and safe to expose as the + // shell-wide colour roles. + readonly property color accent: root.activeProfile.accent + readonly property color accentSecondary: root.activeProfile.secondary readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue readonly property color cyan: root.dark ? "#86e1fc" : "#007197" readonly property color teal: root.dark ? "#4fd6be" : "#118c74" diff --git a/config/dot/quickshell/modules/settings/AccentEditor.qml b/config/dot/quickshell/modules/settings/AccentEditor.qml new file mode 100644 index 0000000..93ca53d --- /dev/null +++ b/config/dot/quickshell/modules/settings/AccentEditor.qml @@ -0,0 +1,218 @@ +// Advanced accent editing remains a labelled, keyboard-operable extension of +// the named fast path. Hue is always accompanied by saturation, value, a +// numeric readout, and the two-colour preview supplied by Appearance. + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.services +import qs.widgets +import "../../services/ThemeProfileModel.js" as ThemeProfileModel + +Column { + id: editor + + width: parent ? parent.width : 620 + spacing: 0 + + property string pickerTarget: "primary" + property string lastError: "" + + readonly property var primaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 }) + readonly property var secondaryHsv: ThemeProfileModel.hexToHsv( + ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 }) + + function changeChannel(target: string, channel: string, ratio: real): void { + const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv; + const next = { h: source.h, s: source.s, v: source.v }; + next[channel] = Math.round(Math.max(0, Math.min(1, ratio)) + * (channel === "h" ? 360 : 100)); + const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v); + const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent; + const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + function pick(target: string): void { + if (screenPicker.running) + return; + editor.pickerTarget = target; + editor.lastError = ""; + screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]); + } + + function acceptPicked(value: string): void { + const picked = String(value).trim().toLowerCase(); + if (ThemeProfileModel.hexToHsv(picked) === null) { + editor.lastError = "The sampled colour was not valid."; + return; + } + const primary = editor.pickerTarget === "primary" + ? picked : ThemeProfiles.activeProfile.accent; + const secondary = editor.pickerTarget === "secondary" + ? picked : ThemeProfiles.activeProfile.secondary; + ThemeProfiles.setAccentPair(primary, secondary); + } + + component HsvRow: SettingRow { + id: root + + required property string target + required property string channel + required property real channelValue + required property real channelMaximum + property string suffix: "%" + + controlWidth: 280 + + Item { + id: keyboardSlider + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 270 + height: 32 + activeFocusOnTab: true + + Accessible.role: Accessible.Slider + Accessible.name: root.label + Accessible.description: Math.round(root.channelValue) + root.suffix + + ", range 0 to " + root.channelMaximum + Accessible.focusable: true + Accessible.focused: activeFocus + Accessible.onIncreaseAction: keyboardSlider.step(1) + Accessible.onDecreaseAction: keyboardSlider.step(-1) + + function step(direction: int): void { + const increment = root.channel === "h" ? 1 : 1; + const value = Math.max(0, Math.min(root.channelMaximum, + root.channelValue + direction * increment)); + editor.changeChannel(root.target, root.channel, value / root.channelMaximum); + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) { + keyboardSlider.step(-1); + event.accepted = true; + } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) { + keyboardSlider.step(1); + event.accepted = true; + } else if (event.key === Qt.Key_Home) { + editor.changeChannel(root.target, root.channel, 0); + event.accepted = true; + } else if (event.key === Qt.Key_End) { + editor.changeChannel(root.target, root.channel, 1); + event.accepted = true; + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: readout.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + height: 24 + radius: 9 + color: "transparent" + border.width: keyboardSlider.activeFocus ? 2 : 1 + border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08) + + ValueSlider { + anchors.fill: parent + anchors.margins: 4 + value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0 + onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio) + } + } + + Text { + id: readout + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 56 + horizontalAlignment: Text.AlignRight + text: Math.round(root.channelValue) + root.suffix + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } + } + } + + HsvRow { + target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees" + } + HsvRow { + target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100 + label: "Primary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100 + label: "Primary value"; detail: "Brightness from black to full colour" + } + HsvRow { + target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360 + suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient" + } + HsvRow { + target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100 + label: "Secondary saturation"; detail: "Colour intensity from grey to vivid" + } + HsvRow { + target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100 + label: "Secondary value"; detail: "Brightness from black to full colour" + } + + SettingRow { + label: "Pick colour from screen" + detail: editor.lastError !== "" + ? editor.lastError + : "Sample either end of the accent gradient with hyprpicker" + divider: false + controlWidth: 330 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + SettingsButton { + text: "Pick primary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("primary") + Keys.onReturnPressed: if (enabled) editor.pick("primary") + Keys.onSpacePressed: if (enabled) editor.pick("primary") + } + + SettingsButton { + text: "Pick secondary from screen" + enabled: !screenPicker.running + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: editor.pick("secondary") + Keys.onReturnPressed: if (enabled) editor.pick("secondary") + Keys.onSpacePressed: if (enabled) editor.pick("secondary") + } + } + } + + Process { + id: screenPicker + + stdout: StdioCollector { + onStreamFinished: editor.acceptPicked(this.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) + editor.lastError = "Screen colour picking was cancelled or unavailable."; + } + } +} diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml index cb7ec8f..7784333 100644 --- a/config/dot/quickshell/modules/settings/AccentPicker.qml +++ b/config/dot/quickshell/modules/settings/AccentPicker.qml @@ -18,7 +18,7 @@ Flow { spacing: 10 - readonly property string current: DesktopPreferences.get("accentName") || "blue" + readonly property string current: ThemeProfiles.activeAccentName // The schema's own option list, not Theme.accents directly -- two lists // hand-kept in sync is how they drift. This is the same pattern @@ -60,6 +60,8 @@ Flow { } Rectangle { + id: swatch + width: 46 height: 46 radius: 23 @@ -67,8 +69,25 @@ Flow { color: "transparent" // The ring sits outside the gradient rather than over it, so a // selected swatch still shows its true colors. - border.width: entry.selected ? 2 : 1 - border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14) + border.width: swatch.activeFocus || entry.selected ? 2 : 1 + border.color: swatch.activeFocus + ? Theme.accentSecondary + : (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)) + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: entry.pair.label + " accent" + Accessible.description: String(entry.start) + " to " + String(entry.end) + Accessible.focusable: true + Accessible.focused: activeFocus + + function choose(): void { + if (!ThemeProfiles.useCuratedAccent(entry.modelData)) + console.warn("AccentPicker: accent was rejected", entry.modelData); + } + + Keys.onReturnPressed: swatch.choose() + Keys.onSpacePressed: swatch.choose() Rectangle { anchors.fill: parent @@ -81,6 +100,9 @@ Flow { GradientStop { position: 1.0; color: entry.end } } } + + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: swatch.choose() } } // Always shown, not a tooltip. Telling swatches apart by color is diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 720171c..de6ab49 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -129,18 +129,26 @@ SettingsPage { SettingsCard { visible: root.tab === "theme" - title: "Color scheme" + // Not "Color scheme" any more: the card holds the saved themes as well + // as the scheme, and a theme carries both ends of the accent with it. + title: "Theme" subtitle: ColorScheme.lastError !== "" ? ColorScheme.lastError - : "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow." + : "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent." - ChoiceRow { setting: "colorScheme" } + ThemeProfilePicker { + width: parent.width + } // Drawn as the gradient each accent produces rather than a flat dot, // because the gradient is what is being chosen. AccentPicker { width: parent.width } + + AccentEditor { + width: parent.width + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml new file mode 100644 index 0000000..db68d14 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml @@ -0,0 +1,123 @@ +// Named themes are the first layer of Appearance: shipped profiles stay +// immutable, while saved profiles can be selected or removed in place. + +import QtQuick +import qs.config +import qs.services + +Column { + id: root + + width: parent ? parent.width : 620 + spacing: 0 + + Repeater { + model: ThemeProfiles.profiles + + SettingRow { + id: profileRow + + required property var modelData + + label: profileRow.modelData.name + detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark") + + " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary + controlWidth: profileRow.modelData.shipped ? 88 : 170 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + SettingsButton { + id: useButton + text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use" + enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1) + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id) + } + + SettingsButton { + id: deleteButton + visible: !profileRow.modelData.shipped + text: "Delete" + activeFocusOnTab: visible + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08) + onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id) + } + } + } + } + + SettingRow { + label: "Save current theme" + detail: "Stores this scheme and accent pair under a unique name" + divider: false + controlWidth: 292 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Rectangle { + width: 196 + height: 31 + radius: 8 + color: Theme.alpha(Theme.fg, 0.05) + border.width: nameInput.activeFocus ? 2 : 1 + border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12) + + TextInput { + id: nameInput + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + activeFocusOnTab: true + verticalAlignment: TextInput.AlignVCenter + maximumLength: 40 + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.35) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + Accessible.name: "Theme profile name" + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: nameInput.text === "" && !nameInput.activeFocus + text: "Theme name" + color: Theme.fgMuted + font: nameInput.font + } + + onAccepted: saveButton.save() + } + } + + SettingsButton { + id: saveButton + text: "Save" + tone: "accent" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 0 + border.color: activeFocus ? Theme.fg : "transparent" + + function save(): void { + if (ThemeProfiles.saveProfile(nameInput.text)) + nameInput.text = ""; + } + + onClicked: save() + Keys.onReturnPressed: save() + Keys.onSpacePressed: save() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 3275adf..183f6de 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -80,3 +80,5 @@ SearchPicker 1.0 SearchPicker.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml AccentPicker 1.0 AccentPicker.qml TextFieldRow 1.0 TextFieldRow.qml +AccentEditor 1.0 AccentEditor.qml +ThemeProfilePicker 1.0 ThemeProfilePicker.qml diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index bf95585..2cb8a22 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -93,6 +93,14 @@ Singleton { function onRevisionChanged(): void { coalesce.restart(); } } + // Profile selection and custom edits both update preferences, but binding + // directly to the effective profile also documents the in-shell ownership + // boundary and keeps this propagation reactive if that storage changes. + Connections { + target: ThemeProfiles + function onActiveProfileChanged(): void { coalesce.restart(); } + } + Timer { id: coalesce interval: 250 diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 553a257..c85d72e 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -136,7 +136,10 @@ Singleton { { label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }, { label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" }, { label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" }, - { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" } + { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }, + { label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" }, + { label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" }, + { label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" } ] function pageFor(group: string): string { diff --git a/config/dot/quickshell/services/ThemeProfileModel.js b/config/dot/quickshell/services/ThemeProfileModel.js new file mode 100644 index 0000000..ed0a213 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfileModel.js @@ -0,0 +1,419 @@ +var MAX_NAME_LENGTH = 40; + +var SHIPPED = [ + { + id: "moon", + name: "Moon", + scheme: "dark", + accent: "#82aaff", + secondary: "#b172b0", + shipped: true + }, + { + id: "moon-rose", + name: "Moon Rose", + scheme: "dark", + accent: "#ff757f", + secondary: "#c099ff", + shipped: true + }, + { + id: "day", + name: "Day", + scheme: "light", + accent: "#2e7de9", + secondary: "#9854f1", + shipped: true + } +]; + +// The curated accents. `gnome` is the nearest member of GNOME's own +// accent-color enum, which is a fixed list of nine we do not get to extend. It +// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so +// choosing an accent here recolors them too instead of leaving them in GNOME +// blue. Nearest by hue, not by name: "rose" maps to red rather than pink +// because it is the red role in this palette. adwaita-accent-contract reads +// this table and fails when a member is missing or is not in GNOME's enum. +var CURATED = { + blue: { + dark: "#82aaff", darkSecondary: "#b172b0", + light: "#2e7de9", lightSecondary: "#9854f1", + label: "Prism blue", + gnome: "blue" + }, + orchid: { + dark: "#c099ff", darkSecondary: "#fca7ea", + light: "#7847bd", lightSecondary: "#9854f1", + label: "Orchid", + gnome: "purple" + }, + teal: { + dark: "#86e1fc", darkSecondary: "#82aaff", + light: "#007197", lightSecondary: "#2e7de9", + label: "Teal", + gnome: "teal" + }, + green: { + dark: "#c3e88d", darkSecondary: "#86e1fc", + light: "#587539", lightSecondary: "#007197", + label: "Green", + gnome: "green" + }, + amber: { + dark: "#ffc777", darkSecondary: "#ff966c", + light: "#8c6c3e", lightSecondary: "#b15c00", + label: "Amber", + gnome: "yellow" + }, + orange: { + dark: "#ff966c", darkSecondary: "#ff757f", + light: "#b15c00", lightSecondary: "#c64343", + label: "Orange", + gnome: "orange" + }, + rose: { + dark: "#ff757f", darkSecondary: "#c099ff", + light: "#f52a65", lightSecondary: "#9854f1", + label: "Rose", + gnome: "red" + }, + slate: { + dark: "#828bb8", darkSecondary: "#82aaff", + light: "#6172b0", lightSecondary: "#2e7de9", + label: "Slate", + gnome: "slate" + } +}; + +function copyProfile(profile) { + return { + id: profile.id, + name: profile.name, + scheme: profile.scheme, + accent: profile.accent, + secondary: profile.secondary, + shipped: profile.shipped === true + }; +} + +function shippedProfiles() { + return SHIPPED.map(copyProfile); +} + +function curatedAccents() { + var result = {}; + Object.keys(CURATED).forEach(function(name) { + result[name] = Object.assign({}, CURATED[name]); + }); + return result; +} + +function isScheme(value) { + return value === "dark" || value === "light"; +} + +function normalizedColor(value) { + var color = String(value || "").trim().toLowerCase(); + return /^#[0-9a-f]{6}$/.test(color) ? color : null; +} + +function normalizeStoredProfile(value) { + if (!value || typeof value !== "object" || value.shipped === true) + return null; + + var id = String(value.id || "").trim(); + var name = String(value.name || "").trim(); + var accent = normalizedColor(value.accent); + var secondary = normalizedColor(value.secondary); + if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id) + || name.length === 0 || name.length > MAX_NAME_LENGTH + || !isScheme(value.scheme) || !accent || !secondary) + return null; + + return { + id: id, + name: name, + scheme: value.scheme, + accent: accent, + secondary: secondary, + shipped: false + }; +} + +function validCustomProfiles(values) { + if (!Array.isArray(values)) + return []; + + var ids = {}; + var names = {}; + SHIPPED.forEach(function(profile) { + ids[profile.id] = true; + names[profile.name.toLowerCase()] = true; + }); + + var result = []; + values.forEach(function(value) { + var profile = normalizeStoredProfile(value); + if (!profile) + return; + var foldedName = profile.name.toLowerCase(); + if (ids[profile.id] || names[foldedName]) + return; + ids[profile.id] = true; + names[foldedName] = true; + result.push(profile); + }); + return result; +} + +function profileCatalog(values) { + return shippedProfiles().concat(validCustomProfiles(values)); +} + +function boundedName(value) { + var name = String(value || "").trim(); + if (!name) + name = "Custom theme"; + return name.slice(0, MAX_NAME_LENGTH); +} + +function uniqueName(value, profiles) { + var requested = boundedName(value); + var names = {}; + profiles.forEach(function(profile) { + names[profile.name.toLowerCase()] = true; + }); + if (!names[requested.toLowerCase()]) + return requested; + + for (var suffix = 2; suffix < 10000; suffix++) { + var ending = " " + suffix; + var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending; + if (!names[candidate.toLowerCase()]) + return candidate; + } + return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000"; +} + +function slug(value) { + var result = String(value || "").toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + .replace(/-+$/g, ""); + return result || "theme"; +} + +function uniqueId(name, profiles) { + var base = "custom-" + slug(name); + var ids = {}; + profiles.forEach(function(profile) { ids[profile.id] = true; }); + if (!ids[base]) + return base; + for (var suffix = 2; suffix < 10000; suffix++) { + var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix; + if (!ids[candidate]) + return candidate; + } + return base.slice(0, 52) + "-10000"; +} + +function createCustomProfile(values, input) { + var customs = validCustomProfiles(values); + var catalog = shippedProfiles().concat(customs); + var scheme = input && input.scheme; + var accent = normalizedColor(input && input.accent); + var secondary = normalizedColor(input && input.secondary); + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + var name = uniqueName(input && input.name, catalog); + var profile = { + id: uniqueId(name, catalog), + name: name, + scheme: scheme, + accent: accent, + secondary: secondary, + shipped: false + }; + return { profiles: customs.concat([profile]), profile: profile }; +} + +function findProfile(values, id) { + var catalog = profileCatalog(values); + for (var index = 0; index < catalog.length; index++) { + if (catalog[index].id === id) + return catalog[index]; + } + return null; +} + +function editProfile(values, selected, changes) { + var customs = validCustomProfiles(values); + if (!selected || typeof selected !== "object") + return { profiles: customs, profile: null }; + + var accent = normalizedColor(changes && changes.accent !== undefined + ? changes.accent : selected.accent); + var secondary = normalizedColor(changes && changes.secondary !== undefined + ? changes.secondary : selected.secondary); + var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme; + if (!isScheme(scheme) || !accent || !secondary) + return { profiles: customs, profile: null }; + + if (selected.shipped === true) { + return createCustomProfile(customs, { + name: selected.name + " custom", + scheme: scheme, + accent: accent, + secondary: secondary + }); + } + + var stored = normalizeStoredProfile(selected); + if (!stored) + return { profiles: customs, profile: null }; + + var updated = Object.assign({}, stored, { + scheme: scheme, + accent: accent, + secondary: secondary + }); + var found = false; + var next = customs.map(function(profile) { + if (profile.id !== updated.id) + return profile; + found = true; + return updated; + }); + if (!found) + next.push(updated); + return { profiles: next, profile: updated }; +} + +function deleteProfile(values, id) { + var customs = validCustomProfiles(values); + for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) { + if (SHIPPED[shippedIndex].id === id) + return { profiles: customs, removed: false }; + } + var next = customs.filter(function(profile) { return profile.id !== id; }); + return { profiles: next, removed: next.length !== customs.length }; +} + +function curatedPair(name, scheme) { + var entry = CURATED[name] || CURATED.blue; + if (scheme === "light") + return { accent: entry.light, secondary: entry.lightSecondary }; + return { accent: entry.dark, secondary: entry.darkSecondary }; +} + +function matchingShippedProfile(scheme, accent, secondary) { + var first = normalizedColor(accent); + var second = normalizedColor(secondary); + for (var index = 0; index < SHIPPED.length; index++) { + var profile = SHIPPED[index]; + if (profile.scheme === scheme && profile.accent === first && profile.secondary === second) + return copyProfile(profile); + } + return null; +} + +function curatedNameForProfile(profile) { + if (!profile || !isScheme(profile.scheme)) + return ""; + var accent = normalizedColor(profile.accent); + var secondary = normalizedColor(profile.secondary); + var names = Object.keys(CURATED); + for (var index = 0; index < names.length; index++) { + var name = names[index]; + var pair = curatedPair(name, profile.scheme); + if (pair.accent === accent && pair.secondary === secondary) + return name; + } + return ""; +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, Number(value))); +} + +function channelHex(value) { + var text = Math.round(value).toString(16); + return text.length < 2 ? "0" + text : text; +} + +function hsvToHex(hue, saturation, value) { + var h = Number(hue); + var s = Number(saturation); + var v = Number(value); + if (!isFinite(h) || !isFinite(s) || !isFinite(v)) + return null; + h = ((h % 360) + 360) % 360; + s = clamp(s, 0, 100) / 100; + v = clamp(v, 0, 100) / 100; + + var chroma = v * s; + var section = h / 60; + var x = chroma * (1 - Math.abs(section % 2 - 1)); + var red = 0; + var green = 0; + var blue = 0; + if (section < 1) { red = chroma; green = x; } + else if (section < 2) { red = x; green = chroma; } + else if (section < 3) { green = chroma; blue = x; } + else if (section < 4) { green = x; blue = chroma; } + else if (section < 5) { red = x; blue = chroma; } + else { red = chroma; blue = x; } + var match = v - chroma; + return "#" + channelHex((red + match) * 255) + + channelHex((green + match) * 255) + + channelHex((blue + match) * 255); +} + +function hexToHsv(value) { + var color = normalizedColor(value); + if (!color) + return null; + var red = parseInt(color.slice(1, 3), 16) / 255; + var green = parseInt(color.slice(3, 5), 16) / 255; + var blue = parseInt(color.slice(5, 7), 16) / 255; + var maximum = Math.max(red, green, blue); + var minimum = Math.min(red, green, blue); + var delta = maximum - minimum; + var hue = 0; + if (delta !== 0) { + if (maximum === red) + hue = 60 * (((green - blue) / delta) % 6); + else if (maximum === green) + hue = 60 * ((blue - red) / delta + 2); + else + hue = 60 * ((red - green) / delta + 4); + } + if (hue < 0) + hue += 360; + return { + h: Math.round(hue), + s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100), + v: Math.round(maximum * 100) + }; +} + +if (typeof module !== "undefined") { + module.exports = { + MAX_NAME_LENGTH: MAX_NAME_LENGTH, + shippedProfiles: shippedProfiles, + curatedAccents: curatedAccents, + validCustomProfiles: validCustomProfiles, + profileCatalog: profileCatalog, + createCustomProfile: createCustomProfile, + findProfile: findProfile, + editProfile: editProfile, + deleteProfile: deleteProfile, + curatedPair: curatedPair, + matchingShippedProfile: matchingShippedProfile, + curatedNameForProfile: curatedNameForProfile, + hsvToHex: hsvToHex, + hexToHsv: hexToHsv + }; +} diff --git a/config/dot/quickshell/services/ThemeProfiles.qml b/config/dot/quickshell/services/ThemeProfiles.qml new file mode 100644 index 0000000..24d7c48 --- /dev/null +++ b/config/dot/quickshell/services/ThemeProfiles.qml @@ -0,0 +1,108 @@ +pragma Singleton + +import Quickshell +import QtQuick +import qs.config +import "ThemeProfileModel.js" as ThemeProfileModel + +Singleton { + id: root + + readonly property var customProfiles: ThemeProfileModel.validCustomProfiles( + DesktopPreferences.get("themeProfiles")) + readonly property var curatedAccents: ThemeProfileModel.curatedAccents() + readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles) + readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon" + readonly property var activeProfile: ThemeProfileModel.findProfile( + root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0] + readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile( + root.activeProfile) + + property bool reconciling: false + + function commitActive(profile: var): bool { + if (!profile) + return false; + + // Scheme first: if another control changed it, the reconciliation hook + // may briefly select Day/Moon before this final stable profile id lands. + const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme); + const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id); + return schemeAccepted && profileAccepted; + } + + function selectProfile(id: string): bool { + return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id)); + } + + // Editing a shipped profile creates a saved custom copy. Editing a custom + // profile updates only that record. ThemeProfileModel enforces both rules, + // leaving this singleton responsible only for validated preference routing. + function setAccentPair(accent: string, secondary: string): bool { + const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, { + accent: accent, + secondary: secondary + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function saveProfile(name: string): bool { + const result = ThemeProfileModel.createCustomProfile(root.customProfiles, { + name: name, + scheme: root.activeProfile.scheme, + accent: root.activeProfile.accent, + secondary: root.activeProfile.secondary + }); + if (!result.profile) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + return SystemSettings.commitPreference("themeProfileId", result.profile.id); + } + + function deleteProfile(id: string): bool { + const result = ThemeProfileModel.deleteProfile(root.customProfiles, id); + if (!result.removed) + return false; + if (!SystemSettings.commitPreference("themeProfiles", result.profiles)) + return false; + if (root.activeId === id) + return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon"); + return true; + } + + function useCuratedAccent(name: string): bool { + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + const pair = ThemeProfileModel.curatedPair(name, scheme); + const shipped = ThemeProfileModel.matchingShippedProfile( + scheme, pair.accent, pair.secondary); + const accepted = shipped + ? root.commitActive(shipped) + : root.setAccentPair(pair.accent, pair.secondary); + if (accepted) + SystemSettings.commitPreference("accentName", name); + return accepted; + } + + function reconcileScheme(): void { + if (root.reconciling) + return; + const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"; + if (root.activeProfile.scheme === scheme) + return; + root.reconciling = true; + SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon"); + root.reconciling = false; + } + + Component.onCompleted: root.reconcileScheme() + + Connections { + target: DesktopPreferences + function onRevisionChanged(): void { root.reconcileScheme(); } + } +} diff --git a/config/dot/quickshell/theme-profiles-harness.qml b/config/dot/quickshell/theme-profiles-harness.qml new file mode 100644 index 0000000..b42e648 --- /dev/null +++ b/config/dot/quickshell/theme-profiles-harness.qml @@ -0,0 +1,40 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.config +import qs.services + +ShellRoot { + IpcHandler { + target: "theme-profiles-test" + + function status(): string { + return JSON.stringify({ + active: ThemeProfiles.activeProfile, + profiles: ThemeProfiles.profiles, + stored: DesktopPreferences.get("themeProfiles") + }); + } + + function select(id: string): bool { + return ThemeProfiles.selectProfile(id); + } + + function scheme(value: string): bool { + return SystemSettings.commitPreference("colorScheme", value); + } + + function edit(accent: string, secondary: string): bool { + return ThemeProfiles.setAccentPair(accent, secondary); + } + + function save(name: string): bool { + return ThemeProfiles.saveProfile(name); + } + + function remove(id: string): bool { + return ThemeProfiles.deleteProfile(id); + } + } +} diff --git a/tests/quickshell/accent-controls-contract.sh b/tests/quickshell/accent-controls-contract.sh new file mode 100755 index 0000000..9dc9108 --- /dev/null +++ b/tests/quickshell/accent-controls-contract.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +settings="$repo_dir/config/dot/quickshell/modules/settings" +theme="$repo_dir/config/dot/quickshell/config/Theme.qml" +scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml" +search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" + +fail() { + printf 'accent controls contract: %s\n' "$1" >&2 + exit 1 +} + +for component in AccentPicker AccentEditor ThemeProfilePicker; do + [[ -f "$settings/$component.qml" ]] || fail "$component is missing" + rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \ + || fail "$component is not registered in the settings module" +done + +for label in \ + 'Primary hue' 'Primary saturation' 'Primary value' \ + 'Secondary hue' 'Secondary saturation' 'Secondary value'; do + rg -Fq "$label" "$settings/AccentEditor.qml" \ + || fail "HSV control is missing the visible label $label" +done + +rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \ + || fail 'advanced accents do not reuse ValueSlider' +rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \ + || fail 'HSV controls do not expose slider semantics' +rg -Fq 'Accessible.name: root.label' "$settings/AccentEditor.qml" \ + || fail 'HSV controls are not programmatically labelled' +rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \ + || fail 'HSV controls do not expose their numeric value as a non-hue signal' +rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \ + || fail 'HSV controls cannot receive keyboard focus' +rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \ + || fail 'HSV controls cannot be adjusted from the keyboard' +rg -Fq 'activeFocus ? Theme.accentSecondary' "$settings/AccentEditor.qml" \ + || fail 'HSV controls have no visible keyboard focus treatment' + +rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \ + "$settings/AccentEditor.qml" \ + || fail 'screen picking does not use the validated hyprpicker hex invocation' +for target in 'Pick primary from screen' 'Pick secondary from screen'; do + rg -Fq "$target" "$settings/AccentEditor.qml" \ + || fail "screen picker action is missing $target" +done + +rg -Fq 'model: Object.keys(Theme.accents)' "$settings/AccentPicker.qml" \ + || fail 'curated swatches are no longer the fast path' +rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \ + || fail 'curated swatches do not select a profile-backed accent' +rg -Fq 'readonly property string current: ThemeProfiles.activeAccentName' "$settings/AccentPicker.qml" \ + || fail 'swatch selection does not follow the active profile' +rg -Fq 'Accessible.name: entry.pair.label + " accent"' "$settings/AccentPicker.qml" \ + || fail 'swatches rely on hue without a programmatic name' +rg -Fq 'activeFocusOnTab: true' "$settings/AccentPicker.qml" \ + || fail 'swatches cannot receive keyboard focus' +rg -Fq 'Keys.onReturnPressed:' "$settings/AccentPicker.qml" \ + || fail 'swatches cannot be selected from the keyboard' + +rg -Fq 'model: ThemeProfiles.profiles' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile picker does not show shipped and saved profiles' +rg -Fq 'ThemeProfiles.selectProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile switching is not wired' +rg -Fq 'ThemeProfiles.saveProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile saving is not wired' +rg -Fq 'maximumLength: 40' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile names are not visibly bounded to the model limit' +rg -Fq 'ThemeProfiles.deleteProfile(' "$settings/ThemeProfilePicker.qml" \ + || fail 'custom profile deletion is not wired' +rg -Fq 'activeFocusOnTab:' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile actions cannot receive keyboard focus' +rg -Fq 'Keys.onReturnPressed:' "$settings/ThemeProfilePicker.qml" \ + || fail 'profile actions cannot be triggered from the keyboard' + +for component in ThemeProfilePicker AccentPicker AccentEditor; do + rg -Fq "$component {" "$settings/AppearancePage.qml" \ + || fail "Appearance does not include $component" +done + +rg -Fq 'ThemeProfiles.activeProfile' "$theme" \ + || fail 'Theme roles do not react to the selected profile' +rg -Fq 'root.hyprColor(Theme.accent)' "$scheme" \ + || fail 'the focused border start does not follow Theme.accent' +rg -Fq 'root.hyprColor(Theme.accentSecondary)' "$scheme" \ + || fail 'the focused border end does not follow Theme.accentSecondary' + +for term in 'Theme profiles' 'Advanced accent' 'Pick colour from screen'; do + rg -Fq "$term" "$search" || fail "Settings search is missing $term" +done + +if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \ + "$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then + fail 'theme controls introduce continuously repainting or decorative animation' +fi + +state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)" +harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml" + +qs_for_test() { + XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \ + QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@" +} + +cleanup() { + qs_for_test kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_test --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' && break + sleep 0.1 +done +qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \ + || fail 'headless AccentEditor harness did not start' + +before="$(qs_for_test ipc call accent-controls-test status)" +jq -e '.id == "moon" and .shipped == true' <<<"$before" >/dev/null \ + || fail 'headless editor did not begin on Moon' +after="$(qs_for_test ipc call accent-controls-test adjust primary h 0)" +jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"' \ + <<<"$after" >/dev/null \ + || fail 'an HSV adjustment did not create a custom profile with the unchanged secondary colour' + +trap - EXIT +cleanup +printf 'accent controls contract: PASS\n' diff --git a/tests/quickshell/adwaita-accent-contract b/tests/quickshell/adwaita-accent-contract index 1192f90..4cc6c34 100755 --- a/tests/quickshell/adwaita-accent-contract +++ b/tests/quickshell/adwaita-accent-contract @@ -8,7 +8,8 @@ # inside Panama's own surfaces, which look correct either way. # # Three things have to line up, and none of them share a source: -# - config/Theme.qml carries the accent table with its `gnome` member +# - services/ThemeProfileModel.js carries the accent table with its `gnome` +# member (Theme.qml exposes it; the theme-profile system owns it) # - scripts/panama-theme-apps maps the same names in shell # - the portal routes Settings to a backend that can serve accent-color # @@ -17,7 +18,7 @@ set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -theme="$repo_dir/config/dot/quickshell/config/Theme.qml" +accents="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" script="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" portals="$repo_dir/config/dot/xdg-desktop-portal/hyprland-portals.conf" @@ -26,25 +27,24 @@ fail() { exit 1 } -for path in "$theme" "$script" "$portals"; do +for path in "$accents" "$script" "$portals"; do [[ -r "$path" ]] || fail "missing $path" done # ── The accent table ───────────────────────────────────────────────────────── -mapping="$(python3 - "$theme" <<'PYTHON' +mapping="$(python3 - "$accents" <<'PYTHON' import re, sys source = open(sys.argv[1]).read() -table = re.search(r"readonly property var accents: \(\{(.*?)\n \}\)", source, re.S) +table = re.search(r"var CURATED = \{(.*?)\n\};", source, re.S) if not table: - raise SystemExit("accents table not found") -for line in table.group(1).splitlines(): - name = re.search(r'"([a-z]+)":', line) - if not name: - continue - member = re.search(r'gnome: "([a-z]+)"', line) - print(name.group(1) + "\t" + (member.group(1) if member else "")) + raise SystemExit("curated accent table not found") +# Each accent is a multi-line record now, so the name opens a block and the +# member may be several lines below it. +for entry in re.finditer(r"\n ([a-z]+): \{(.*?)\n \}", table.group(1), re.S): + member = re.search(r'gnome: "([a-z]+)"', entry.group(2)) + print(entry.group(1) + "\t" + (member.group(1) if member else "")) PYTHON -)" || fail 'could not read the accent table from Theme.qml' +)" || fail 'could not read the curated accent table from ThemeProfileModel.js' [[ -n "$mapping" ]] || fail 'the accent table is empty' diff --git a/tests/quickshell/control-center-contract b/tests/quickshell/control-center-contract index 8f6829e..f11d210 100755 --- a/tests/quickshell/control-center-contract +++ b/tests/quickshell/control-center-contract @@ -157,6 +157,37 @@ rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_p rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \ || fail 'Home tile does not give its dimmer an accessory-specific accessible name' +# Controls added after the original contract remain service-backed rather than +# becoming optimistic local toggles. Keep these checks in the static section so +# they can run without mapping the Control Center on a daily-driver desktop. +rg -Fq 'visible: PowerProfiles.available' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile control does not follow daemon availability' +rg -Fq 'PowerProfiles.refresh();' "$quicksettings_path/QuickSettings.qml" \ + || fail 'opening Control Center does not refresh the external power profile' +rg -Fq 'expanded: root.expandedSection === "power"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices have no detail section' +rg -Fq 'PowerProfileList {' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'power profile choices are not mounted' + +rg -Fq 'active: NightLight.active' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light control does not reflect service state' +rg -Fq 'onToggled: NightLight.toggle()' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light primary action is not wired' +rg -Fq 'onExpanded: NightLight.automatic = !NightLight.automatic' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'Night Light schedule action is not wired' + +rg -Fq 'sublabel: ColorScheme.dark ? "Dark" : "Light"' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control does not expose its current state' +rg -Fq 'SystemSettings.commitPreference("colorScheme",' "$quicksettings_path/QuickSettingsPanel.qml" \ + || fail 'colour scheme control bypasses the preference commit path' + +if [[ "${1:-}" == "--static-only" ]]; then + trap - EXIT + cleanup + printf 'Control Center contract (static): PASS\n' + exit 0 +fi + cp -a "$source_config_path" "$config_path" : >"$helper_log" cat >"$config_path/scripts/panama-home-assistant" <<'EOF' diff --git a/tests/quickshell/theme-profiles-contract.sh b/tests/quickshell/theme-profiles-contract.sh new file mode 100755 index 0000000..9520d16 --- /dev/null +++ b/tests/quickshell/theme-profiles-contract.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" + +node - "$model" <<'JS' +const assert = require('node:assert/strict') +const model = require(process.argv[2]) + +const shipped = model.shippedProfiles() +assert.deepEqual(shipped, [ + { + id: 'moon', + name: 'Moon', + scheme: 'dark', + accent: '#82aaff', + secondary: '#b172b0', + shipped: true + }, + { + id: 'moon-rose', + name: 'Moon Rose', + scheme: 'dark', + accent: '#ff757f', + secondary: '#c099ff', + shipped: true + }, + { + id: 'day', + name: 'Day', + scheme: 'light', + accent: '#2e7de9', + secondary: '#9854f1', + shipped: true + } +]) + +const first = model.createCustomProfile([], { + name: ' Ocean ', + scheme: 'dark', + accent: '#86E1FC', + secondary: '#82AAFF' +}) +assert.deepEqual(first.profile, { + id: 'custom-ocean', + name: 'Ocean', + scheme: 'dark', + accent: '#86e1fc', + secondary: '#82aaff', + shipped: false +}) + +const second = model.createCustomProfile(first.profiles, { + name: 'ocean', + scheme: 'light', + accent: '#007197', + secondary: '#2e7de9' +}) +assert.equal(second.profile.name, 'ocean 2') +assert.equal(second.profile.id, 'custom-ocean-2') + +const bounded = model.createCustomProfile(second.profiles, { + name: 'A theme name that is deliberately much longer than forty characters', + scheme: 'dark', + accent: '#c3e88d', + secondary: '#86e1fc' +}) +assert.equal(bounded.profile.name.length, 40) + +const originalMoon = shipped[0] +const edited = model.editProfile([], originalMoon, { + accent: '#ffc777', + secondary: '#ff966c' +}) +assert.equal(edited.profile.shipped, false) +assert.equal(edited.profile.name, 'Moon custom') +assert.equal(edited.profiles.length, 1) +assert.deepEqual(originalMoon, shipped[0]) +assert.equal(shipped[0].accent, '#82aaff') + +const refusedDelete = model.deleteProfile(edited.profiles, 'moon') +assert.equal(refusedDelete.removed, false) +assert.deepEqual(refusedDelete.profiles, edited.profiles) + +assert.deepEqual(model.profileCatalog([ + edited.profile, + { id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false }, + { id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false } +]), [...shipped, edited.profile]) + +assert.equal(model.hsvToHex(0, 100, 100), '#ff0000') +assert.equal(model.hsvToHex(120, 100, 100), '#00ff00') +assert.equal(model.hsvToHex(240, 100, 100), '#0000ff') +assert.equal(model.hsvToHex(360, 100, 100), '#ff0000') +assert.equal(model.hsvToHex(0, 0, 50), '#808080') +assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 }) +assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 }) +assert.equal(model.hexToHsv('not-a-colour'), null) +assert.equal(model.curatedNameForProfile(shipped[0]), 'blue') +assert.equal(model.curatedNameForProfile(shipped[1]), 'rose') +assert.equal(model.matchingShippedProfile( + 'dark', '#ff757f', '#c099ff' +).id, 'moon-rose') +assert.equal(model.matchingShippedProfile( + 'light', '#2e7de9', '#9854f1' +).id, 'day') +assert.equal(model.curatedNameForProfile({ + id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark', + accent: '#123456', secondary: '#654321', shipped: false +}), '') + +console.log('theme profiles contract: PASS') +JS + +harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml" +state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)" + +qs_for_test() { + XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \ + QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@" +} + +cleanup() { + qs_for_test kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_test --daemonize >/dev/null +for _ in $(seq 1 40); do + qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break + sleep 0.1 +done +qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \ + || { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; } + +status="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null + +qs_for_test ipc call theme-profiles-test scheme light >/dev/null +jq -e '.active.id == "day" and .active.scheme == "light"' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null + +qs_for_test ipc call theme-profiles-test select day >/dev/null +jq -e '.active.id == "day" and .active.scheme == "light"' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null + +qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null +edited="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \ + <<<"$edited" >/dev/null +custom_id="$(jq -r '.active.id' <<<"$edited")" + +qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null +saved="$(qs_for_test ipc call theme-profiles-test status)" +jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null + +[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]] +forest_id="$(jq -r '.active.id' <<<"$saved")" +[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]] +jq -e '.active.id == "day" and .active.shipped == true' \ + <<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null +[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]] + +trap - EXIT +cleanup +printf 'theme profiles service contract: PASS\n' From 6016efa436d07a43ce822b630252376927944d51 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 22:23:27 -0400 Subject: [PATCH 6/6] Meet main where it has moved since this branch was written The rebase itself is the previous five commits replayed onto main; this is what they needed once they landed there, kept separate so the replay stays readable. The shell did not start. A Column in this branch's notification settings menu assigned its own implicitWidth and implicitHeight, which a Qt 6 positioner computes and does not let you set. That took out every contract that launches a shell -- six of the seven failures were this one line, and none of them said so until the error was read to the bottom. A Column already measures itself from its children, so the bindings are simply gone. Three assertions pinned an implementation main has since replaced, and each is updated to pin the intent rather than the mechanism: - The display picker now reads primaryFirstMonitors, which is monitors sorted with the primary first. Still populated from what is connected, which is what the contract is for; the sorted list is the point, so the picker opens on the display somebody is most likely to mean. This branch made that change and broke its own contract without noticing. - The accent swatches come from the accentName schema rather than Object.keys(Theme.accents). Same swatches, same order, one source shared with every other enum row. - The OSD used to take no pointer input at all. It takes some now, because this branch's own design calls for a secondary click on a visible OSD to open its settings, and a Wayland input region cannot admit one button and refuse another. The rule that survives is that the region stays bounded to the OSD's own card: it floats over other windows for a couple of seconds, and a region bigger than the card would swallow clicks meant for something underneath. Theme's accent table moved to ThemeProfiles, which is this branch's point -- a curated accent and a custom profile become the same kind of record. main had meanwhile given each accent a `gnome` member, the nearest name in GNOME's fixed accent-color enum, which is what makes libadwaita applications recolor instead of staying in GNOME blue. That member moved into the curated table rather than being dropped, and adwaita-accent-contract now reads it where it lives. Where main had simply moved further along the same path, main won: the focused border driven by the chosen accent rather than a hardcoded pair, the gradient built through the shared serializer rather than a hand-rolled string, the multi-edge dock geometry. This branch's context menu, keyboard focus and accessibility work sit on top of those rather than beside them. Three new contracts arrived carrying .sh and lost it, along with the references in this branch's own plan. 124 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj --- .../notifications/NotificationCard.qml | 6 +-- .../plans/2026-08-18-roadmap-completion.md | 38 +++++++++---------- ...s-contract.sh => accent-controls-contract} | 9 ++++- tests/quickshell/displays-contract | 6 ++- tests/quickshell/osd-ui-contract | 15 +++++++- ...ontract.sh => overview-thumbnail-contract} | 0 ...es-contract.sh => theme-profiles-contract} | 0 7 files changed, 49 insertions(+), 25 deletions(-) rename tests/quickshell/{accent-controls-contract.sh => accent-controls-contract} (92%) rename tests/quickshell/{overview-thumbnail-contract.sh => overview-thumbnail-contract} (100%) rename tests/quickshell/{theme-profiles-contract.sh => theme-profiles-contract} (100%) diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml index e3c9134..966ed8d 100644 --- a/config/dot/quickshell/modules/notifications/NotificationCard.qml +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -146,10 +146,10 @@ Rectangle { id: settingsMenu anchorItem: settingsMenuButton + // A Column already measures itself from its children, and in Qt 6 both + // implicit sizes are read-only on a positioner -- assigning them makes + // the whole shell fail to load rather than just this menu. Column { - implicitWidth: notificationSettings.implicitWidth - implicitHeight: notificationSettings.implicitHeight - TrayMenuRow { id: notificationSettings label: "Notification settings" diff --git a/docs/superpowers/plans/2026-08-18-roadmap-completion.md b/docs/superpowers/plans/2026-08-18-roadmap-completion.md index b9124ff..1f5a32e 100644 --- a/docs/superpowers/plans/2026-08-18-roadmap-completion.md +++ b/docs/superpowers/plans/2026-08-18-roadmap-completion.md @@ -33,22 +33,22 @@ - Modify: `config/dot/quickshell/scripts/panama-lock` - Modify: `config/dot/quickshell/services/Wallpaper.qml` - Modify: `config/dot/quickshell/modules/settings/WallpaperControls.qml` -- Test: `tests/quickshell/settings-ownership-contract.sh` -- Test: `tests/quickshell/display-transaction-contract.sh` -- Test: `tests/quickshell/lock-screen-helper-contract.sh` -- Test: `tests/quickshell/wallpaper-service-contract.sh` -- Test: `tests/quickshell/wallpaper-settings-contract.sh` +- Test: `tests/quickshell/settings-ownership-contract` +- Test: `tests/quickshell/display-transaction-contract` +- Test: `tests/quickshell/lock-screen-helper-contract` +- Test: `tests/quickshell/wallpaper-service-contract` +- Test: `tests/quickshell/wallpaper-settings-contract` **Interfaces:** - Produces: `Displays.primaryFirstMonitors`, event-driven topology refresh, and a lock helper that never emits an unreadable image. - Consumes: current `Theme.accent`, `Theme.accentSecondary`, display transaction rollback, and wallpaper preference schema. - [ ] Add failing ownership assertions that active borders derive from `Theme.accent` and `Theme.accentSecondary`, while inactive borders derive only from scheme roles; verify the current contract fails for the stale prohibition. -- [ ] Update the ownership implementation comments and documentation, then run `bash tests/quickshell/settings-ownership-contract.sh` and verify PASS. +- [ ] Update the ownership implementation comments and documentation, then run `bash tests/quickshell/settings-ownership-contract` and verify PASS. - [ ] Add a failing display fixture that changes the harness screen model without calling the display service’s public `refresh()` and expects topology reconciliation plus rollback of a pending transaction. -- [ ] Add an event-driven screen-model observer and primary-first derived monitor list; run `bash tests/quickshell/display-transaction-contract.sh` and `bash tests/quickshell/display-layout-contract.sh`. +- [ ] Add an event-driven screen-model observer and primary-first derived monitor list; run `bash tests/quickshell/display-transaction-contract` and `bash tests/quickshell/display-layout-contract`. - [ ] Add failing lock fixtures for missing global and per-monitor wallpapers, requiring readable shipped-image fallback and screenshot fallback when no image exists. -- [ ] Tighten `panama-lock` path validation and bounded warnings; run `bash tests/quickshell/lock-screen-helper-contract.sh`. +- [ ] Tighten `panama-lock` path validation and bounded warnings; run `bash tests/quickshell/lock-screen-helper-contract`. - [ ] Add failing wallpaper contracts for primary-first output order, selection preservation, and a HOME path containing a single quote. - [ ] Replace inline `bash -lc` path interpolation with an argument-safe helper and update controls to preserve selection; run both wallpaper contracts. - [ ] Run the Task 1 contract set and commit with message `Close desktop safety gaps`. @@ -62,8 +62,8 @@ - Modify: `config/dot/quickshell/modules/osd/Osd.qml` - Modify: `config/dot/quickshell/modules/overview/WindowThumbnail.qml` - Modify: `config/dot/quickshell/services/ShellState.qml` -- Test: `tests/quickshell/settings-jump-contract.sh` -- Create or modify: `tests/quickshell/overview-thumbnail-contract.sh` +- Test: `tests/quickshell/settings-jump-contract` +- Create or modify: `tests/quickshell/overview-thumbnail-contract` **Interfaces:** - Produces: one route per configurable surface into the existing owning Settings page. @@ -87,9 +87,9 @@ - Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml` - Modify: `config/dot/quickshell/services/ColorScheme.qml` - Modify: `config/dot/quickshell/services/SettingsSearch.qml` -- Test: create `tests/quickshell/theme-profiles-contract.sh` -- Test: create `tests/quickshell/accent-controls-contract.sh` -- Test: modify `tests/quickshell/control-center-contract.sh` +- Test: create `tests/quickshell/theme-profiles-contract` +- Test: create `tests/quickshell/accent-controls-contract` +- Test: modify `tests/quickshell/control-center-contract` **Interfaces:** - Produces: profile records `{id,name,scheme,accent,secondary,shipped}` and reactive `Theme` colour roles. @@ -110,8 +110,8 @@ - Modify: `config/dot/quickshell/services/ColorScheme.qml` - Modify: tracked kitty/tmux/Vicinae/Wofi/Neovim include points as required - Create: generated-file templates or helpers under the owning application directories -- Test: create `tests/quickshell/theme-apps-accent-contract.sh` -- Test: modify `tests/quickshell/gtk-theme-contract.sh` if its invocation contract changes +- Test: create `tests/quickshell/theme-apps-accent-contract` +- Test: modify `tests/quickshell/gtk-theme-contract` if its invocation contract changes **Interfaces:** - Produces: `panama-theme-apps dark|light [#RRGGBB #RRGGBB]` with per-adapter JSON status. @@ -138,10 +138,10 @@ - Create: `config/dot/quickshell/scripts/panama-generate-docs` - Create: `docs/SETTINGS.md` - Create: `docs/SHORTCUTS.md` -- Test: create `tests/quickshell/first-run-contract.sh` -- Test: modify `tests/quickshell/settings-backup-live-contract.sh` -- Test: create `tests/quickshell/generated-docs-contract.sh` -- Test: modify `tests/quickshell/keybind-rebind-contract.sh` +- Test: create `tests/quickshell/first-run-contract` +- Test: modify `tests/quickshell/settings-backup-live-contract` +- Test: create `tests/quickshell/generated-docs-contract` +- Test: modify `tests/quickshell/keybind-rebind-contract` **Interfaces:** - Produces: fresh-install marker lifecycle, `snapshot --cause ` retention, and deterministic generated Markdown. diff --git a/tests/quickshell/accent-controls-contract.sh b/tests/quickshell/accent-controls-contract similarity index 92% rename from tests/quickshell/accent-controls-contract.sh rename to tests/quickshell/accent-controls-contract index 9dc9108..ef0fa96 100755 --- a/tests/quickshell/accent-controls-contract.sh +++ b/tests/quickshell/accent-controls-contract @@ -49,7 +49,14 @@ for target in 'Pick primary from screen' 'Pick secondary from screen'; do || fail "screen picker action is missing $target" done -rg -Fq 'model: Object.keys(Theme.accents)' "$settings/AccentPicker.qml" \ +# The curated swatches come from the accentName schema rather than from +# Object.keys(Theme.accents): the schema's option order is the palette's order, +# and it is the same source every other enum row reads. What matters here is +# unchanged -- the curated accents are still one click away, ahead of the +# editor. +rg -Fq 'PreferenceSchema.spec("accentName")' "$settings/AccentPicker.qml" \ + || fail 'curated swatches are no longer sourced from the accent schema' +rg -Fq 'model: root.options' "$settings/AccentPicker.qml" \ || fail 'curated swatches are no longer the fast path' rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \ || fail 'curated swatches do not select a profile-backed accent' diff --git a/tests/quickshell/displays-contract b/tests/quickshell/displays-contract index 6ea3364..52e7915 100755 --- a/tests/quickshell/displays-contract +++ b/tests/quickshell/displays-contract @@ -54,7 +54,11 @@ rg -Fq 'options: Displays.scalesForMode(' "$page" \ || fail 'scale choices are not filtered for the active resolution' rg -Fq 'property string selectedOutput:' "$page" \ || fail 'connected outputs cannot be selected' -rg -Fq 'options: Displays.monitors.map(' "$page" \ +# primaryFirstMonitors is monitors sorted with the primary first, so the +# selector is still populated from what is connected -- which is what this +# protects. Naming the sorted list rather than the raw one is the point: the +# picker should open on the display somebody is most likely to mean. +rg -Fq 'options: Displays.primaryFirstMonitors.map(' "$page" \ || fail 'the output selector is not populated from connected displays' rg -Fq 'id: revertVerifyTimer' "$service" \ || fail 'automatic restoration has no bounded readback verification' diff --git a/tests/quickshell/osd-ui-contract b/tests/quickshell/osd-ui-contract index b600846..dc2119e 100755 --- a/tests/quickshell/osd-ui-contract +++ b/tests/quickshell/osd-ui-contract @@ -33,7 +33,20 @@ rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \ || fail 'OSD does not use the existing Prism blur namespace' rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \ || fail 'OSD may steal keyboard focus' -rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input' +# The OSD used to take no pointer input at all. It now takes some, because a +# secondary click on a visible OSD opens its settings and dismisses it -- and a +# Wayland input region cannot be told to admit one button and not another, so +# the feature is not available without a region. +# +# What still has to hold is that the region is BOUNDED to the OSD's own surface +# rather than the screen. The OSD floats over whatever is underneath for a +# couple of seconds; a region larger than the card would swallow clicks meant +# for a window nobody could see was being covered. +rg -Fq 'mask: Region {' "$osd" || fail 'OSD declares no input region at all' +rg -Fq 'item: inputMask' "$osd" \ + || fail 'the OSD input region is not bound to an item, so it may cover more than the OSD itself' +rg -Fq 'anchors.fill: parent' "$osd" \ + || fail 'the OSD input mask does not track the surface it belongs to' rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge' rg -Fq 'font.features: Theme.tabularFigures' "$osd" \ || fail 'changing percentages do not use tabular figures' diff --git a/tests/quickshell/overview-thumbnail-contract.sh b/tests/quickshell/overview-thumbnail-contract similarity index 100% rename from tests/quickshell/overview-thumbnail-contract.sh rename to tests/quickshell/overview-thumbnail-contract diff --git a/tests/quickshell/theme-profiles-contract.sh b/tests/quickshell/theme-profiles-contract similarity index 100% rename from tests/quickshell/theme-profiles-contract.sh rename to tests/quickshell/theme-profiles-contract