Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b01f1e020 | ||
|
|
23141673a2 | ||
|
|
495fb9b41b | ||
|
|
3b657e0a56 | ||
|
|
c148bae4ac | ||
|
|
4cbab01ae2 | ||
|
|
de45f205ad | ||
|
|
52e2a83a78 | ||
|
|
79b3d5cb85 | ||
|
|
1b845c0126 | ||
|
|
f53ca16392 | ||
|
|
6dc606b872 | ||
|
|
32fab59d24 | ||
|
|
f6b970da21 | ||
|
|
7e1c85b094 | ||
|
|
0c364f38e6 | ||
|
|
1aa1324083 | ||
|
|
ac5e6e2130 | ||
|
|
536958430f | ||
|
|
fd99569666 | ||
|
|
a412e3d894 | ||
|
|
4cbe3b882a | ||
|
|
edc504af2e | ||
|
|
a15f019c17 | ||
|
|
116510caa8 | ||
|
|
b10f8e2593 | ||
|
|
8f0fe23377 | ||
|
|
89cf0f8c29 | ||
|
|
e290a262f3 | ||
|
|
6997dd535f | ||
|
|
1f40f8e136 | ||
|
|
e9d567aa72 | ||
|
|
914d58f52b | ||
|
|
a23b42841a | ||
|
|
e0c0e53ae0 | ||
|
|
180308135a | ||
|
|
b743f44c5b | ||
|
|
2528edfddc | ||
|
|
dbd1472e6b | ||
|
|
eeb49c5aff | ||
|
|
8e93f08977 | ||
|
|
99433c0e8e | ||
|
|
a68e4f6dcd | ||
|
|
2b3b762793 | ||
|
|
91306ce810 | ||
|
|
1b94af1163 | ||
|
|
b4ce148caf | ||
|
|
2fcaada7e8 | ||
|
|
588dec4adc | ||
|
|
d96863b687 | ||
|
|
8156fc47ca | ||
|
|
9bc771bcca | ||
|
|
fe74a82a46 | ||
|
|
ac231eeb54 | ||
|
|
8cf03d4529 | ||
|
|
2a716dac9e | ||
|
|
719ef2f38e | ||
|
|
9ba224d776 | ||
|
|
8b59b78d9f | ||
|
|
e6b4d3c1a1 | ||
|
|
70d8d32ee2 | ||
|
|
c37ca3baee | ||
|
|
bd5d030d91 |
+2
-2
@@ -14,10 +14,10 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
# Generated from the color scheme; machine state, not configuration.
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
|
||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
||||
|
||||
# 2.4) Run flatpak updates (user then system)
|
||||
flatpak update -y
|
||||
sudo flatpak update
|
||||
sudo flatpak update -y
|
||||
|
||||
# 2.5) Optional firmware via fwupd
|
||||
if $firmware; then
|
||||
sudo fwupdmgr refresh
|
||||
# fwupdmgr exits non-zero when metadata is already current -- that is
|
||||
# not an error, but under 'set -e' it would abort the script before
|
||||
# 'fwupdmgr update' ever runs.
|
||||
sudo fwupdmgr refresh || true
|
||||
sudo fwupdmgr update
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
||||
# theme (flat-remix-light) is a different palette that happens to have a similar
|
||||
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
|
||||
# and what runs inside it cannot disagree.
|
||||
#
|
||||
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GENERATED FILE -- edit settings.ini.template instead.
|
||||
#
|
||||
# The theme name and dark preference below follow Panama's colour
|
||||
# The theme name and dark preference below follow Panama's color
|
||||
# scheme, so this file is regenerated on every switch and is not
|
||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||
|
||||
@@ -15,15 +15,15 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
|
||||
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
|
||||
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
|
||||
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favourites and intellihide | Live |
|
||||
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||
| Dock | Quickshell dock with GNOME favorites and intellihide | Live |
|
||||
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
|
||||
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
|
||||
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
|
||||
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
||||
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, and KDE Connect phone continuity | Live |
|
||||
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
|
||||
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||
@@ -36,14 +36,14 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
|
||||
| System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
|
||||
|
||||
## System health and recovery
|
||||
|
||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||
appears only for actionable warnings or errors and opens the same **System
|
||||
Health** page available from Settings and the Vicinae command **Panama: Check
|
||||
Health** page available from Settings and the Vicinae command **Check
|
||||
System Health**. The terminal summary is available with:
|
||||
|
||||
```bash
|
||||
@@ -59,7 +59,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
|
||||
arbitrary configuration.
|
||||
|
||||
Generic Fedora configuration remains with the system tools that own it. The
|
||||
final System Health card hands network settings, users, sharing, colour
|
||||
final System Health card hands network settings, users, sharing, color
|
||||
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||
than presenting inert Hyprland controls.
|
||||
|
||||
@@ -77,9 +77,9 @@ than presenting inert Hyprland controls.
|
||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||
| Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
|
||||
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Home Assistant | Replaced by secure favorites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||
| App Hider | Hidden app list is now applied in Vicinae |
|
||||
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
|
||||
@@ -111,4 +111,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
|
||||
- <https://github.com/hyprwm/Hyprland/discussions/1358>
|
||||
|
||||
A future focused-window menu could expose compositor actions such as float,
|
||||
move, fullscreen, and close. It should not be labelled as an application menu.
|
||||
move, fullscreen, and close. It should not be labeled as an application menu.
|
||||
|
||||
+14
-14
@@ -30,7 +30,7 @@ Don't "fix" them.
|
||||
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||
| `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
|
||||
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||||
| `keybinds.lua` | The full keymap |
|
||||
@@ -87,7 +87,7 @@ persistence, validation, reset, and the live write are all derived from that
|
||||
entry.
|
||||
|
||||
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
|
||||
wrong-typed settings file costs you your customisations and nothing else;
|
||||
wrong-typed settings file costs you your customizations and nothing else;
|
||||
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
|
||||
accepts the config in each of those states.
|
||||
|
||||
@@ -163,7 +163,7 @@ four places, and nowhere else —
|
||||
dock, popovers), running blue on the left to orchid on the right and fading
|
||||
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
||||
2. The focused window's border — `general.col.active_border`, blue→orchid at
|
||||
115°. Unfocused windows get no colour at all, because the gradient only
|
||||
115°. Unfocused windows get no color at all, because the gradient only
|
||||
means something if one window on screen is wearing it.
|
||||
3. The active workspace pill in the bar.
|
||||
4. Slider fills.
|
||||
@@ -179,9 +179,9 @@ which is the opposite of the intent.
|
||||
|
||||
The Nerd Font is still used, but only to draw **icon glyphs** — it is the
|
||||
pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a
|
||||
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
|
||||
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
|
||||
paints a palette colour through the icon's alpha.
|
||||
paints a palette color through the icon's alpha.
|
||||
|
||||
Anything whose digits change in place — the clock, the vitals percentages, the
|
||||
recording timer, the selection readout — sets `font.features:
|
||||
@@ -247,7 +247,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + SHIFT + H/J/K/L` | Move window |
|
||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
||||
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter |
|
||||
| `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
|
||||
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
|
||||
| `SUPER + Q` | Close |
|
||||
| `SUPER + U` | Fullscreen |
|
||||
@@ -256,7 +256,7 @@ The mental model is unchanged from Forge:
|
||||
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
|
||||
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
||||
| `SUPER + SHIFT + grave` | Last window |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad |
|
||||
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
|
||||
|
||||
### Workspaces (dynamic, like GNOME)
|
||||
| Key | Action |
|
||||
@@ -270,19 +270,19 @@ The mental model is unchanged from Forge:
|
||||
### Launcher and shell
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite |
|
||||
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
|
||||
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||||
| `SUPER + V` | Clipboard history |
|
||||
| `SUPER + .` | Emoji picker |
|
||||
| `SUPER + S` | Quick settings |
|
||||
| `SUPER + I` | Settings |
|
||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||
| `SUPER + B` | Notification centre |
|
||||
| `SUPER + B` | Notification center |
|
||||
| `SUPER + grave` | Workspace overview |
|
||||
| `Print` | Screenshot / record picker |
|
||||
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
||||
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
|
||||
| `SUPER + SHIFT + P` | Colour picker |
|
||||
| `SUPER + SHIFT + P` | Color picker |
|
||||
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") |
|
||||
| `CTRL + ALT + Delete` | Power menu |
|
||||
|
||||
@@ -325,8 +325,8 @@ Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
|
||||
one place. Home and Phone continue the same surface rather than opening extra
|
||||
dashboard windows.
|
||||
|
||||
Home shows the first four selected favourites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favourites,
|
||||
Home shows the first four selected favorites at rest and every selected light
|
||||
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||||
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||
only previews the value; releasing it sends one brightness request. A normal
|
||||
power toggle leaves Home Assistant responsible for restoring its previous
|
||||
@@ -334,7 +334,7 @@ level.
|
||||
|
||||
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||||
existing GNOME extension and Secret Service setup retained as a compatibility
|
||||
fallback. Favourites, aliases, and order live in Quickshell state. No shell
|
||||
fallback. Favorites, aliases, and order live in Quickshell state. No shell
|
||||
restart is required after changing credentials; close and reopen Control Center
|
||||
to refresh. If Home Assistant is offline, the last known values stay visible
|
||||
with a stale-state label and Retry action.
|
||||
@@ -361,7 +361,7 @@ These are the places a 1:1 port was impossible, and what was done instead:
|
||||
|
||||
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
|
||||
axis and lets the layout pick the edge. The eight Forge keys collapse to four
|
||||
behaviours, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
behaviors, keeping the horizontal/vertical and grow/shrink pairing.
|
||||
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
|
||||
modifier misfires when you're quick with SUPER combos.
|
||||
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
|
||||
|
||||
@@ -23,8 +23,21 @@ hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||
-- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
|
||||
-- commands without waiting for them to finish, so the dbus-update call
|
||||
-- above racing this one is not safe to assume complete -- a lost race
|
||||
-- leaves the Condition unmet and the unit silently never starts (exit 0,
|
||||
-- no error). hypridle is the only listener for the logind Lock signal,
|
||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||
-- synchronously in the same shell invocation first so the Condition
|
||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
|
||||
-- into its binary and cannot be themed. Only one agent may register per
|
||||
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
|
||||
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
|
||||
-- prompt if Panama's ever fails to come up.
|
||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||
|
||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||
@@ -39,18 +52,22 @@ hl.on("hyprland.start", function()
|
||||
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
|
||||
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
|
||||
|
||||
-- Tray applications carried over from ~/.config/autostart. These need the
|
||||
-- Quickshell tray (an SNI host) to be up, hence starting after it.
|
||||
-- Nextcloud and Bitwarden are NOT started here. Both ship a
|
||||
-- ~/.config/autostart/*.desktop entry, and systemd's own
|
||||
-- systemd-xdg-autostart-generator turns every such entry into a
|
||||
-- `PartOf=graphical-session.target` unit (`[email protected]`,
|
||||
-- `[email protected]`) that fires once the uwsm
|
||||
-- session brings up graphical-session.target -- confirmed live via
|
||||
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
|
||||
-- second launch here used to duplicate that: for Bitwarden specifically,
|
||||
-- each `flatpak run` gets its own sandbox instance, so the two starts
|
||||
-- didn't just race, they left two competing processes fighting over the
|
||||
-- app's single-instance lock, with neither reliably owning a usable
|
||||
-- window. Trust the generator instead of re-launching.
|
||||
--
|
||||
-- Commands are copied verbatim from the GNOME .desktop files rather than
|
||||
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
|
||||
-- its autostart entry launches a specific script with --autostart.
|
||||
--
|
||||
-- RustDesk is deliberately absent: it ships an enabled *system* service
|
||||
-- (`rustdesk --service`) that spawns --server and --tray for the session on
|
||||
-- its own. Starting it here as well would give you two trays.
|
||||
hl.exec_cmd("nextcloud --background")
|
||||
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
|
||||
-- RustDesk is deliberately absent too: it ships an enabled *system*
|
||||
-- service (`rustdesk --service`) that spawns --server and --tray for the
|
||||
-- session on its own. Starting it here as well would give you two trays.
|
||||
end)
|
||||
|
||||
hl.on("hyprland.shutdown", function()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Environment
|
||||
--
|
||||
-- hl.env() sets variables before the display server initialises, which is what
|
||||
-- hl.env() sets variables before the display server initializes, which is what
|
||||
-- makes it valid for AQ_* and cursor variables.
|
||||
--
|
||||
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
|
||||
@@ -58,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
|
||||
|
||||
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
|
||||
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
|
||||
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also
|
||||
-- approximating it -- and it gives them GTK file/font/color dialogs. It also
|
||||
-- covers Qt5 and Qt6 with one value.
|
||||
--
|
||||
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hypridle — idle behaviour
|
||||
# hypridle — idle behavior
|
||||
#
|
||||
# Mirrors the GNOME power profile this replaces, exactly:
|
||||
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Shared preferences first: looks/input/monitors read their defaults through it.
|
||||
-- It never raises, so a missing or malformed settings file costs customisations
|
||||
-- It never raises, so a missing or malformed settings file costs customizations
|
||||
-- and nothing else.
|
||||
require("prefs")
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next colour scheme change.
|
||||
# next color scheme change.
|
||||
#
|
||||
# The colours here follow the desktop's light/dark setting. They used to be
|
||||
# The colors here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# hyprland-guiutils hyprland-dialog / hyprland-run
|
||||
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
|
||||
#
|
||||
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# Colors are Tokyo Night Moon, matching quickshell/config/Theme.qml and
|
||||
# hypr/looks.lua. Format is hyprlang; path is fixed at
|
||||
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,10 +22,10 @@ hl.config({
|
||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||
|
||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
||||
-- previously claimed 1 was "click to focus, GNOME's behaviour", which
|
||||
-- previously claimed 1 was "click to focus, GNOME's behavior", which
|
||||
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behaviour
|
||||
-- so click-to-focus is 0. Changing the shipped value is a behavior
|
||||
-- decision rather than a correction, so the value is left alone and
|
||||
-- only the description is fixed; Settings exposes all four.
|
||||
follow_mouse = prefs.getInt("followMouse", 1),
|
||||
|
||||
@@ -118,7 +118,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
|
||||
-- which Hyprland has no equivalent for.
|
||||
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
||||
|
||||
-- Notification centre.
|
||||
-- Notification center.
|
||||
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||
|
||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||
@@ -133,8 +133,8 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
|
||||
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
||||
{ description = "Screen Intelligence" })
|
||||
|
||||
-- Colour picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" })
|
||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||
|
||||
-- ── Window management ───────────────────────────────────────────────────────
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
@@ -180,20 +180,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
||||
|
||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||
--
|
||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
||||
-- eight Forge keys collapse onto four behaviours. The pairing is kept
|
||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
||||
-- and "increase" always grows while "decrease" always shrinks.
|
||||
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||
-- positive amount in every direction, and the edge only decides which side
|
||||
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||
-- an axis and lets the layout choose the border, so those eight distinct
|
||||
-- behaviours collapse onto four and the direction is simply not expressible.
|
||||
--
|
||||
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
|
||||
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
|
||||
-- correct on paper and wrong under the fingers: with the edge gone, the keys
|
||||
-- that used to pull a window open from one side now push it from the other.
|
||||
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
|
||||
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
|
||||
local step = 60
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
|
||||
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
||||
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
-- SUPER+SHIFT+P was double-bound with the color picker above; moved to
|
||||
-- Comma, which continues the bottom-row cluster (B/M/N) this axis already
|
||||
-- uses rather than landing on an arbitrary free key.
|
||||
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
@@ -223,6 +233,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
|
||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||
-- moving right past the last workspace creates a new one, and moving left from
|
||||
-- the first clamps instead of wrapping.
|
||||
|
||||
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
|
||||
-- `binds:` options -- not part of general/dwindle -- and have no other home
|
||||
-- in the config, so they are read here rather than in looks.lua.
|
||||
hl.config({
|
||||
binds = {
|
||||
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
|
||||
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
|
||||
},
|
||||
})
|
||||
|
||||
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||
@@ -245,18 +266,18 @@ end
|
||||
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||
|
||||
-- Minimise, as far as Hyprland has one.
|
||||
-- Minimize, as far as Hyprland has one.
|
||||
--
|
||||
-- Hyprland has no minimise: it receives the request (the binary has
|
||||
-- Hyprland has no minimize: it receives the request (the binary has
|
||||
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
|
||||
-- no dispatcher, no config option and not even an event to hook, so titlebar
|
||||
-- minimise buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- minimize buttons are inert and cannot be made to work. A tiling WM has no
|
||||
-- iconified state and no taskbar to restore from.
|
||||
--
|
||||
-- The scratchpad is the honest equivalent: the window goes away, and the same
|
||||
-- key brings it back. Bound to X to match the muscle memory it replaces.
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
||||
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
|
||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
|
||||
|
||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
||||
|
||||
+39
-11
@@ -1,8 +1,8 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Look and feel -- Tokyo Night Moon
|
||||
--
|
||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent #82aaff borders / focus
|
||||
-- Colors here must stay in sync with quickshell/config/Theme.qml.
|
||||
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||
-- bg #222436 base
|
||||
--
|
||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||
@@ -14,6 +14,30 @@
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
|
||||
-- singleton, so the hex pairs are restated here -- just the four hex strings
|
||||
-- each named accent needs, not the labels, which stay UI-only.
|
||||
local accents = {
|
||||
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
|
||||
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
|
||||
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
|
||||
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
|
||||
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
|
||||
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
|
||||
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
|
||||
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
|
||||
}
|
||||
|
||||
-- The accent pair a fresh session or `hyprctl reload` starts from.
|
||||
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
|
||||
-- data, once the shell settles (~1.2s after startup -- see its `settle`
|
||||
-- Timer). This table exists only so the compositor is never observably blue
|
||||
-- for a non-blue accent during the gap before that first live apply.
|
||||
local accentScheme = prefs.get("colorScheme", "dark")
|
||||
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
|
||||
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
|
||||
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
gaps_in = prefs.get("gapsIn", 5),
|
||||
@@ -22,12 +46,14 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- The focused accent role, on a diagonal so the pair is visible
|
||||
-- on both a tall and a wide window. Driven by the chosen
|
||||
-- accentName (see the `accents` table above); services/
|
||||
-- ColorScheme.qml applies the same values live, and restates them
|
||||
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||
-- too, since each accent carries a separate pair per scheme.
|
||||
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
|
||||
-- The neutral inactive role follows the color scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
@@ -104,11 +130,13 @@ hl.config({
|
||||
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||
-- border is the signature, and a strong halo would compete with it.
|
||||
-- This is just enough to lift the focused window off the wallpaper.
|
||||
-- Derived from the same accent as active_border above, not hardcoded,
|
||||
-- so the halo never disagrees with the border it surrounds.
|
||||
glow = {
|
||||
enabled = prefs.get("glowEnabled", true),
|
||||
range = prefs.get("glowRange", 8),
|
||||
render_power = 2,
|
||||
color = "rgba(82aaff33)",
|
||||
color = "rgba(" .. accentStart .. "33)",
|
||||
color_inactive = "rgba(00000000)",
|
||||
},
|
||||
|
||||
@@ -165,12 +193,12 @@ hl.config({
|
||||
-- Let a crashed lock screen be recovered rather than stranding you.
|
||||
allow_session_lock_restore = true,
|
||||
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behaviour.
|
||||
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
|
||||
focus_on_activate = false,
|
||||
},
|
||||
|
||||
render = {
|
||||
-- Colour management is stable and on by default in 0.56.
|
||||
-- Color management is stable and on by default in 0.56.
|
||||
cm_enabled = true,
|
||||
|
||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||
|
||||
@@ -25,7 +25,7 @@ local prefs = require("prefs")
|
||||
-- x = 0, y = 0, primary = true,
|
||||
-- } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- Only mode, scale, and transform are read. Color management and bit depth
|
||||
-- stay here, because those are the settings with a documented reason attached
|
||||
-- (see the header) rather than preferences, and a settings page has no way to
|
||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||
@@ -147,7 +147,7 @@ hl.monitor({
|
||||
})
|
||||
|
||||
-- Other connected outputs use the same validated per-output store. They keep
|
||||
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||
-- automatic placement and the compositor's normal color policy; DP-2 alone
|
||||
-- carries the panel-specific 10-bit policy documented above.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- cannot drift apart.
|
||||
--
|
||||
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
|
||||
-- file must cost the user nothing worse than their customisations; it must
|
||||
-- file must cost the user nothing worse than their customizations; it must
|
||||
-- never cost them a working compositor. Every failure path returns the caller's
|
||||
-- fallback.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,7 +69,7 @@ hl.window_rule({
|
||||
float = true,
|
||||
})
|
||||
|
||||
-- Settings-style utility windows and pickers: float and centre, like GNOME did.
|
||||
-- Settings-style utility windows and pickers: float and center, like GNOME did.
|
||||
hl.window_rule({
|
||||
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
||||
float = true,
|
||||
@@ -77,6 +77,18 @@ hl.window_rule({
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Quick Look. The GNOME previewer is what the file manager opens on space,
|
||||
-- and it is an overlay rather than a window someone manages: tiled, it shoves
|
||||
-- the file manager aside and has to be dismissed before the list is usable
|
||||
-- again. Sized generously because a preview that needs zooming is not a
|
||||
-- preview; it still gets a margin so the file underneath stays visible.
|
||||
hl.window_rule({
|
||||
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
|
||||
float = true,
|
||||
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
|
||||
center = true,
|
||||
})
|
||||
|
||||
-- Portal dialogs (file chooser, screen share picker) should always float.
|
||||
hl.window_rule({
|
||||
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
|
||||
@@ -150,7 +162,7 @@ hl.layer_rule({
|
||||
ignore_alpha = 0.3,
|
||||
})
|
||||
|
||||
-- Popovers: calendar, quick settings, notification centre, tray menus.
|
||||
-- Popovers: calendar, quick settings, notification center, tray menus.
|
||||
hl.layer_rule({
|
||||
name = "qs-popover",
|
||||
match = { namespace = "^qs-popover" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# includes current-theme.conf, which Panama generates from the color scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- user their color scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
-- Tokyo Night, following the desktop's color scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
|
||||
-- applying them to Day would put light gray text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
|
||||
[[blocks.segments]]
|
||||
type = 'git'
|
||||
style = 'plain'
|
||||
foreground = 'p:grey'
|
||||
foreground = 'p:gray'
|
||||
background = 'transparent'
|
||||
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ Singleton {
|
||||
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||
|
||||
// Upgrade before anything reads a value. A stored key the current
|
||||
// schema no longer recognises is carried through untouched and silently
|
||||
// schema no longer recognizes is carried through untouched and silently
|
||||
// stops taking effect, so the conversion has to happen here rather than
|
||||
// being noticed later by whoever owns that setting.
|
||||
const result = Migrations.apply(raw);
|
||||
|
||||
@@ -19,7 +19,7 @@ Singleton {
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
onSaved: root.saveError = ""
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||
onSaveFailed: error => root.saveError = "Could not save Home favorites."
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
@@ -5,7 +5,7 @@ pragma Singleton
|
||||
// The schema is the single source of truth for what a setting IS, but it cannot
|
||||
// describe what a setting USED to be. Renaming a key, changing its units, or
|
||||
// splitting one setting into two all leave a stored value that the new schema
|
||||
// does not recognise -- and an unrecognised key is silently carried through
|
||||
// does not recognize -- and an unrecognized key is silently carried through
|
||||
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||
// say why. That is the failure this exists to prevent.
|
||||
//
|
||||
|
||||
@@ -91,6 +91,31 @@ Singleton {
|
||||
label: "Automatically hide the Dock",
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
},
|
||||
{
|
||||
key: "dockPosition", type: "enum", def: "bottom", group: "dock",
|
||||
label: "Position",
|
||||
detail: "Which edge the Dock lives on",
|
||||
options: [
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" }
|
||||
]
|
||||
},
|
||||
{
|
||||
// A "json" value: the screen names the Dock appears on. Empty means
|
||||
// every screen, which is both the sensible default and the right
|
||||
// answer for the common single-monitor case -- storing a list of
|
||||
// names there would go stale the moment a display is unplugged.
|
||||
key: "dockScreens", type: "json", def: [], group: "dock",
|
||||
label: "Screens",
|
||||
detail: "Which displays show the Dock"
|
||||
},
|
||||
{
|
||||
key: "dockIconSize", type: "int", def: 48, min: 32, max: 80, step: 4,
|
||||
unit: "px", group: "dock",
|
||||
label: "Icon size",
|
||||
detail: "How large the Dock's application icons are drawn"
|
||||
},
|
||||
{
|
||||
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||
unit: "ms",
|
||||
@@ -107,6 +132,41 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
// ── Focus modes ─────────────────────────────────────────────────────
|
||||
// A "json" value: named modes, each with what turns it on and what it
|
||||
// does. Triggers rather than a scheduler -- a mode is on because a
|
||||
// condition is true right now, which is re-evaluated rather than fired
|
||||
// once. A schedule is one of those conditions ("is now inside this
|
||||
// window?"), which is why suspend, a reboot mid-window, and a lid
|
||||
// opened after the start time all behave correctly without special
|
||||
// cases: there is no alarm to have missed.
|
||||
//
|
||||
// Gaming ships enabled because the behaviour already existed as
|
||||
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
|
||||
// that starts silencing someone on first boot has overstepped.
|
||||
{
|
||||
key: "focusModes", type: "json", group: "focus",
|
||||
label: "Focus modes",
|
||||
detail: "What quiets this machine, and what turns it on",
|
||||
def: [
|
||||
{
|
||||
id: "deep-work", name: "Deep work", enabled: true,
|
||||
triggers: [{ kind: "manual" }],
|
||||
durationMinutes: 45, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "gaming", name: "Gaming", enabled: true,
|
||||
triggers: [{ kind: "game" }],
|
||||
durationMinutes: 0, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "sleep", name: "Sleep", enabled: false,
|
||||
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
|
||||
days: [0, 1, 2, 3, 4, 5, 6] }],
|
||||
durationMinutes: 0, silence: true, keepAwake: false, allow: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
@@ -171,7 +231,7 @@ Singleton {
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Inner gaps",
|
||||
detail: "Space between neighbouring tiled windows",
|
||||
detail: "Space between neighboring tiled windows",
|
||||
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||
},
|
||||
{
|
||||
@@ -278,7 +338,7 @@ Singleton {
|
||||
// ── Master layout ───────────────────────────────────────────────────
|
||||
// Only meaningful when the tiling layout is Master and stack. Offering
|
||||
// that layout with none of its options was an omission: it is the one
|
||||
// layout whose whole behaviour is in these settings.
|
||||
// layout whose whole behavior is in these settings.
|
||||
{
|
||||
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||
group: "master",
|
||||
@@ -295,7 +355,7 @@ Singleton {
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
{ value: "center", label: "Centre" }
|
||||
{ value: "center", label: "Center" }
|
||||
],
|
||||
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||
},
|
||||
@@ -429,7 +489,7 @@ Singleton {
|
||||
{
|
||||
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||
// Same shape as the layout list and for the same reason: this is
|
||||
// serialised into an hl.config string.
|
||||
// serialized into an hl.config string.
|
||||
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||
label: "Layout variant",
|
||||
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||
@@ -475,7 +535,7 @@ Singleton {
|
||||
// The compositor publishes the authoritative mapping itself --
|
||||
// `hyprctl descriptions` gives
|
||||
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
|
||||
// -- so a desktop labelled "Click to focus" was in fact following
|
||||
// -- so a desktop labeled "Click to focus" was in fact following
|
||||
// the pointer, and the way to actually get click-to-focus was to
|
||||
// choose "Never". Value 3 was missing entirely.
|
||||
//
|
||||
@@ -702,6 +762,14 @@ Singleton {
|
||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "highContrast", type: "bool", def: false, group: "accessibility",
|
||||
label: "High contrast",
|
||||
detail: "Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here.",
|
||||
// No hypr mapping: this is a GNOME interface setting the portal
|
||||
// republishes as org.freedesktop.appearance contrast, which is what
|
||||
// libadwaita actually reads. DesktopStyle applies it.
|
||||
},
|
||||
{
|
||||
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||
label: "Dim inactive windows",
|
||||
@@ -716,6 +784,22 @@ Singleton {
|
||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Gaming ──────────────────────────────────────────────────────────
|
||||
// What Panama does while a game runs. gamemode tells us when that
|
||||
// starts and stops through its own hook scripts, so these are real
|
||||
// behaviours rather than hints -- and each one is undone afterwards to
|
||||
// whatever it was before, not to a default.
|
||||
{
|
||||
key: "gamingPerformanceProfile", type: "bool", def: true, group: "gaming",
|
||||
label: "Use the performance power profile",
|
||||
detail: "Switches while a game runs and switches back when it exits"
|
||||
},
|
||||
{
|
||||
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
|
||||
label: "Say when Game Mode engages",
|
||||
detail: "A notification when a game requests it, which is otherwise invisible"
|
||||
},
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
@@ -852,14 +936,14 @@ Singleton {
|
||||
detail: "A time earlier than the start simply means the next morning"
|
||||
},
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────
|
||||
// ── Color scheme ───────────────────────────────────────────────────
|
||||
// Light is Tokyo Night Day, the official light variant, rather than a
|
||||
// palette invented to merely not be dark. Both share the same hues at
|
||||
// different lightness, which is what keeps the Prism identity intact
|
||||
// across the switch.
|
||||
//
|
||||
// services/ColorScheme.qml pushes the choice to GTK and to the
|
||||
// compositor's border colours, because an application toolbar or a
|
||||
// compositor's border colors, because an application toolbar or a
|
||||
// window border still wearing the other scheme is more jarring than
|
||||
// either scheme on its own.
|
||||
{
|
||||
@@ -871,6 +955,26 @@ Singleton {
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "accentName", type: "enum", def: "blue", group: "appearance",
|
||||
label: "Accent color",
|
||||
detail: "Drives the focused window border, the bar hairline, and every active state",
|
||||
// NAMED accents, not a free color. Each name carries a curated
|
||||
// pair per scheme, because one hex cannot serve both: a color
|
||||
// legible on the dark ground is usually illegible on the light one.
|
||||
// The palette and its measured contrast live in config/Theme.qml,
|
||||
// which is also what stops this list drifting from what is drawn.
|
||||
options: [
|
||||
{ value: "blue", label: "Prism blue" },
|
||||
{ value: "orchid", label: "Orchid" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "amber", label: "Amber" },
|
||||
{ value: "orange", label: "Orange" },
|
||||
{ value: "rose", label: "Rose" },
|
||||
{ value: "slate", label: "Slate" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Application themes ─────────────────────────────────────────────
|
||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||
@@ -978,7 +1082,7 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Application titlebars ──────────────────────────────────────────
|
||||
// These affect applications that honour GNOME's window preferences.
|
||||
// These affect applications that honor GNOME's window preferences.
|
||||
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||
// deliberately absent rather than presented as a switch that lies.
|
||||
{
|
||||
@@ -1041,7 +1145,7 @@ Singleton {
|
||||
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||
// A sysfs path rather than a card number, because the number is neither
|
||||
// stable across machines nor meaningful. Constrained to the one shape
|
||||
// that can be read for utilisation; VitalsWidget hides itself when the
|
||||
// that can be read for utilization; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
@@ -1057,7 +1161,7 @@ Singleton {
|
||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
||||
// takes and it needs no API key. weatherLocation is only the label shown
|
||||
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||
// reading recognisable.
|
||||
// reading recognizable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
@@ -1192,7 +1296,7 @@ Singleton {
|
||||
|
||||
// ── Display configuration ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||
// hypr/monitors.lua on top of the shipped values. Color management and
|
||||
// bit depth are deliberately not here: those carry a documented
|
||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||
// you would be changing it.
|
||||
@@ -1305,7 +1409,7 @@ Singleton {
|
||||
case "string": {
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
// A constrained string is rejected rather than sanitised. Several
|
||||
// of these are serialised into an hl.config payload, and quietly
|
||||
// of these are serialized into an hl.config payload, and quietly
|
||||
// stripping characters would turn a typo into a different setting
|
||||
// instead of an error the user can see.
|
||||
if (entry.pattern && !new RegExp(entry.pattern).test(text))
|
||||
|
||||
@@ -62,7 +62,12 @@ Singleton {
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
// Pinned apps, in order, taken from the GNOME dash favorites.
|
||||
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
|
||||
readonly property var dockScreens: {
|
||||
const stored = DesktopPreferences.get("dockScreens");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tokyo Night Moon — the single source of truth for every colour, radius,
|
||||
// Tokyo Night Moon — the single source of truth for every color, radius,
|
||||
// duration and font in the shell. Nothing below this file should hardcode a
|
||||
// colour; if you find yourself writing "#" outside this file, add a token here.
|
||||
// color; if you find yourself writing "#" outside this file, add a token here.
|
||||
//
|
||||
// The bar keeps GNOME's 36px information density but sits directly on the
|
||||
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
|
||||
@@ -18,7 +18,7 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// ── Color scheme ───────────────────────────────────────────────────────
|
||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
||||
// rather than a palette invented to merely not be dark. The two share the
|
||||
// same hues at different lightness, which is what lets the Prism identity
|
||||
@@ -42,13 +42,48 @@ Singleton {
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||
// gradient. That restraint is the whole point: the two colors meeting is
|
||||
// the signature, so the second color stops being special the moment it is
|
||||
// used as a flat fill.
|
||||
//
|
||||
// NAMED accents rather than a free color. Each name carries a curated
|
||||
// triple per scheme, because an arbitrary hex cannot work in both: a color
|
||||
// legible on the Moon background is usually illegible on the Day one, and a
|
||||
// picker that lets someone choose an unreadable desktop is not a feature.
|
||||
// Every pair below measures at least 3:1 against the ground it sits on.
|
||||
// This is also GNOME's model, which is the parity being chased.
|
||||
//
|
||||
// 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
|
||||
// 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" }
|
||||
})
|
||||
|
||||
// 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
|
||||
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"
|
||||
@@ -75,6 +110,13 @@ Singleton {
|
||||
// light one, and text on it stops being legible.
|
||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||
|
||||
// Toasts sit a little lighter than a popover you opened on purpose. A
|
||||
// notification arrives unbidden over whatever you were doing, and at full
|
||||
// popover weight it reads as a dialog demanding an answer -- but the 6%
|
||||
// foreground tint it used to have left the text competing with the desktop
|
||||
// behind it. This is the point between the two.
|
||||
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
|
||||
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||
@@ -84,7 +126,7 @@ Singleton {
|
||||
readonly property int barGap: 6 // breathing room below the bar for popovers
|
||||
readonly property int barSideMargin: 10 // inset for floating popovers
|
||||
|
||||
readonly property int dockIconSize: 48
|
||||
readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
|
||||
readonly property int dockPadding: 8
|
||||
readonly property int dockGap: 8
|
||||
readonly property int dockRadius: 20
|
||||
@@ -93,7 +135,6 @@ Singleton {
|
||||
readonly property int popoverPadding: 14
|
||||
readonly property int popoverWidth: 380
|
||||
readonly property int controlCenterWidth: 430
|
||||
readonly property int controlCenterTopGap: 2
|
||||
|
||||
readonly property int cardRadius: 12
|
||||
readonly property int pillRadius: 999
|
||||
@@ -108,7 +149,7 @@ Singleton {
|
||||
|
||||
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
|
||||
// hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
@@ -152,7 +193,7 @@ Singleton {
|
||||
}
|
||||
|
||||
// Blend two tokens — used for hover/pressed states so we never invent a
|
||||
// colour that isn't derived from the palette.
|
||||
// color that isn't derived from the palette.
|
||||
function mix(a: color, b: color, ratio: real): color {
|
||||
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
|
||||
}
|
||||
@@ -163,7 +204,7 @@ Singleton {
|
||||
// the active workspace pill and slider fills.
|
||||
//
|
||||
// Where the gradient runs left-to-right the blue leads, because the eye
|
||||
// reads left first and blue is the colour that carries meaning.
|
||||
// reads left first and blue is the color that carries meaning.
|
||||
readonly property real prismStart: 0.22
|
||||
readonly property real prismEnd: 0.78
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ ShellRoot {
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
const modes = Displays.normalizeModes([
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Fixture steps rather than the real list: the real one is empty until the
|
||||
// first breaking schema change, and a mechanism that has never been run against
|
||||
// a failing step is not one to discover the behaviour of during an upgrade.
|
||||
// a failing step is not one to discover the behavior of during an upgrade.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
@@ -15,7 +15,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
implicitWidth: 350
|
||||
@@ -25,10 +29,18 @@ PanelWindow {
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Deliberately does not read Capture.recordingSeconds (or anything else
|
||||
// that ticks once a second): this array is a plain JS array, not an
|
||||
// identity-preserving model, so any dependency that changes every second
|
||||
// would make the whole thing re-derive every second, and the Repeater
|
||||
// below would destroy and recreate every row -- including one the user
|
||||
// might be hovering or about to click. The set of activities should only
|
||||
// change when an activity actually starts or stops. Elapsed time is
|
||||
// rendered by each row's own Text binding instead, further down.
|
||||
readonly property var activities: {
|
||||
const result = [];
|
||||
if (PrivacyState.recordingActive)
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
|
||||
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
|
||||
if (PrivacyState.screenSharingActive)
|
||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||
if (PrivacyState.cameraActive)
|
||||
@@ -38,11 +50,12 @@ PanelWindow {
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsed(): string {
|
||||
const total = Capture.recordingSeconds;
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(total / 60) % 60;
|
||||
const hours = Math.floor(total / 3600);
|
||||
// Pure formatter, no ticking property read here -- callers decide what
|
||||
// seconds value to pass, and only they take on the per-second dependency.
|
||||
function formatElapsed(totalSeconds: int): string {
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
@@ -164,7 +177,11 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: activityRow.modelData.detail
|
||||
// Only this Text re-evaluates every second while
|
||||
// recording -- Capture.recordingSeconds is read
|
||||
// here, not in the parent `activities` array, so
|
||||
// the row itself is never torn down for a tick.
|
||||
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Bar {}
|
||||
// }
|
||||
//
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre,
|
||||
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather center,
|
||||
// media + tray + status cluster right.
|
||||
|
||||
import Quickshell
|
||||
@@ -83,7 +83,7 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Centre ──────────────────────────────────────────────────────────
|
||||
// ── Center ──────────────────────────────────────────────────────────
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// The calendar that drops out of the clock — GNOME's date menu, minus the
|
||||
// notification list (that lives in its own panel). Month grid, today marked
|
||||
// with the accent, arrows to page through months, current weather at the foot.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Popover {
|
||||
id: root
|
||||
|
||||
// Popover's container is a plain Item, which does not derive an implicit
|
||||
// size from its children, so the window has to be sized from the body.
|
||||
implicitWidth: body.implicitWidth + contentPadding * 2
|
||||
implicitHeight: body.implicitHeight + contentPadding * 2
|
||||
|
||||
readonly property int cellSize: 34
|
||||
readonly property int cellHeight: 30
|
||||
|
||||
// Hours precision is enough: the only thing that has to change on its own
|
||||
// is which cell counts as "today", and that only moves at midnight.
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Hours
|
||||
}
|
||||
|
||||
readonly property int todayYear: clock.date.getFullYear()
|
||||
readonly property int todayMonth: clock.date.getMonth()
|
||||
readonly property int todayDay: clock.date.getDate()
|
||||
|
||||
// The month currently on screen. Reset to today every time the popover
|
||||
// opens, so it never comes back showing wherever you paged off to.
|
||||
property int viewYear: root.todayYear
|
||||
property int viewMonth: root.todayMonth
|
||||
|
||||
onVisibleChanged: if (visible)
|
||||
root.showToday()
|
||||
|
||||
function showToday(): void {
|
||||
root.viewYear = root.todayYear;
|
||||
root.viewMonth = root.todayMonth;
|
||||
}
|
||||
|
||||
function stepMonth(delta: int): void {
|
||||
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
|
||||
root.viewYear = d.getFullYear();
|
||||
root.viewMonth = d.getMonth();
|
||||
}
|
||||
|
||||
// Six weeks of cells, so the grid height never changes as you page through
|
||||
// months. Days from the neighbouring months fill the edges, dimmed.
|
||||
readonly property var cells: {
|
||||
const first = new Date(root.viewYear, root.viewMonth, 1);
|
||||
const offset = first.getDay(); // 0 = Sunday, matching the header row
|
||||
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
|
||||
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const n = i - offset + 1;
|
||||
if (n < 1)
|
||||
out.push({
|
||||
day: inPrevMonth + n,
|
||||
current: false
|
||||
});
|
||||
else if (n > inThisMonth)
|
||||
out.push({
|
||||
day: n - inThisMonth,
|
||||
current: false
|
||||
});
|
||||
else
|
||||
out.push({
|
||||
day: n,
|
||||
current: true
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Month header ────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: root.cellSize * 7
|
||||
height: 28
|
||||
|
||||
CalendarArrow {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0141}" // md-chevron_left
|
||||
onActivated: root.stepMonth(-1)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.fg
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.showToday()
|
||||
}
|
||||
}
|
||||
|
||||
CalendarArrow {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
glyph: "\u{F0142}" // md-chevron_right
|
||||
onActivated: root.stepMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday header ──────────────────────────────────────────────────
|
||||
Row {
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
delegate: Text {
|
||||
required property string modelData
|
||||
|
||||
width: root.cellSize
|
||||
height: 20
|
||||
text: modelData
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day grid ────────────────────────────────────────────────────────
|
||||
Grid {
|
||||
columns: 7
|
||||
|
||||
Repeater {
|
||||
model: root.cells
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
|
||||
|
||||
width: root.cellSize
|
||||
height: root.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.cellHeight - 2
|
||||
height: root.cellHeight - 2
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
visible: cell.isToday
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: cell.modelData.day
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
|
||||
|
||||
color: {
|
||||
if (cell.isToday)
|
||||
return Theme.bgDark;
|
||||
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: root.cellSize * 7
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
visible: Weather.available
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Weather.available
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Weather.icon
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
color: Theme.accentAlt
|
||||
}
|
||||
|
||||
Column {
|
||||
Text {
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Weather.description
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// The centre clock, kept in the same place and the same format GNOME had it:
|
||||
// The center clock, kept in the same place and the same format GNOME had it:
|
||||
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
|
||||
// exactly like GNOME's date menu.
|
||||
|
||||
@@ -35,7 +35,7 @@ Pill {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, root.format)
|
||||
font.family: Theme.fontFamily
|
||||
// Seconds tick once a second in the centre of the bar; without tabular
|
||||
// Seconds tick once a second in the center of the bar; without tabular
|
||||
// figures the whole clock shifts sideways on every digit change.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
|
||||
@@ -62,7 +62,7 @@ Pill {
|
||||
if (!root.wifiNetwork)
|
||||
return "\u{F092D}"; // md-wifi_strength_off
|
||||
|
||||
// NetworkManager reports strength 0-100; some backends normalise to
|
||||
// NetworkManager reports strength 0-100; some backends normalize to
|
||||
// 0-1, so accept either rather than showing one bar forever.
|
||||
const raw = root.wifiNetwork.signalStrength ?? 0;
|
||||
const strength = raw <= 1 ? raw * 100 : raw;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One glyph + percentage pair inside VitalsWidget. Split out so the three
|
||||
// fields cannot drift apart in spacing, width or colour thresholds.
|
||||
// fields cannot drift apart in spacing, width or color thresholds.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
|
||||
// _gpu#1_usage_).
|
||||
//
|
||||
// Values are colour-coded rather than graphed: at bar size a number you can
|
||||
// Values are color-coded rather than graphed: at bar size a number you can
|
||||
// glance at beats a sparkline you have to squint at.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -36,7 +36,7 @@ Item {
|
||||
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
// Full bar height makes (0, 0) part of the wheel target. The dots stay
|
||||
// optically centred by the row and active-pill geometry below.
|
||||
// optically centered by the row and active-pill geometry below.
|
||||
implicitHeight: Theme.barHeight
|
||||
|
||||
// Scroll over the padding either side of the row, not just over a dot.
|
||||
@@ -59,7 +59,7 @@ Item {
|
||||
border.width: 0 // QTBUG-137166: rounded rects can lose their corners
|
||||
|
||||
// The prism, as a fill rather than a hairline. This is the one element
|
||||
// in the bar that is always coloured, so it is where the pair belongs.
|
||||
// in the bar that is always colored, so it is where the pair belongs.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The floating control panel at the bottom of the capture overlay.
|
||||
//
|
||||
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
|
||||
// control on top, and below it the shutter dead-centre with "Show Pointer" on
|
||||
// control on top, and below it the shutter dead-center with "Show Pointer" on
|
||||
// the left and the screenshot/screencast switch on the right.
|
||||
|
||||
import QtQuick
|
||||
@@ -13,7 +13,7 @@ Rectangle {
|
||||
|
||||
signal captureRequested
|
||||
|
||||
// Set by the overlay so the shutter can grey out when Window mode has
|
||||
// Set by the overlay so the shutter can gray out when Window mode has
|
||||
// nothing pickable (no Hyprland, or an empty workspace).
|
||||
property bool captureEnabled: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// One segment of the Screen / Window / Selection control.
|
||||
//
|
||||
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolor
|
||||
// them, so they'd be invisible on this dark overlay.
|
||||
|
||||
import QtQuick
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// The "you are being recorded" pill.
|
||||
//
|
||||
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
|
||||
// clicking it stops the recording. The bar is another module's window, so this
|
||||
// is its own tiny layer surface parked just below it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: Capture.recording
|
||||
color: "transparent"
|
||||
|
||||
// Top only: with neither left nor right anchored, layer-shell centres the
|
||||
// surface horizontally.
|
||||
anchors.top: true
|
||||
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
|
||||
|
||||
implicitWidth: pill.implicitWidth
|
||||
implicitHeight: pill.implicitHeight
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-recording"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
function elapsed(): string {
|
||||
const t = Capture.recordingSeconds;
|
||||
const s = ("0" + (t % 60)).slice(-2);
|
||||
const m = Math.floor(t / 60) % 60;
|
||||
const h = Math.floor(t / 3600);
|
||||
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.redDeep
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
// Static dot, not a blinking one: nothing in this shell repaints
|
||||
// while idle.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: win.elapsed()
|
||||
color: Theme.fg
|
||||
// Tabular figures: the timer must not shuffle as it counts.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: stop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: stopMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// SIGINT, so wf-recorder finalises the container.
|
||||
onClicked: Capture.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Selection mode: a GNOME-style rubber band over the frozen screen.
|
||||
//
|
||||
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred
|
||||
// Behaviors copied from GNOME 42: the overlay opens with a sensible centered
|
||||
// selection already in place (so the shutter is immediately useful), dragging
|
||||
// inside moves it, dragging a corner resizes from the opposite corner, and
|
||||
// dragging anywhere else starts a fresh band.
|
||||
@@ -52,7 +52,7 @@ Item {
|
||||
root.resetSelection();
|
||||
}
|
||||
|
||||
// Centred, roughly the middle half of the screen.
|
||||
// Centered, roughly the middle half of the screen.
|
||||
function resetSelection(): void {
|
||||
if (root.width <= 0 || root.height <= 0)
|
||||
return;
|
||||
|
||||
@@ -20,8 +20,11 @@ PanelWindow {
|
||||
top: true
|
||||
right: true
|
||||
}
|
||||
// The gap ALONE, not the bar height plus the gap -- see the note in
|
||||
// QuickSettings.qml: exclusiveZone 0 already places this below the bar's
|
||||
// reserved zone, so adding the bar height counted it twice.
|
||||
margins {
|
||||
top: Theme.barHeight + Theme.barGap * 2
|
||||
top: Theme.barGap * 2
|
||||
right: Theme.barSideMargin
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// The month grid from GNOME's date menu.
|
||||
//
|
||||
// Six week rows always, so the grid height never changes as you page through
|
||||
// months and the panel below it never jumps. Days belonging to the neighbouring
|
||||
// months and the panel below it never jumps. Days belonging to the neighboring
|
||||
// months fill the edges, dimmed. Today wears the accent.
|
||||
//
|
||||
// Days become real navigation targets now that Evolution Data Server backs the
|
||||
// panel. Up to three source-coloured markers show whether a date has events.
|
||||
// panel. Up to three source-colored markers show whether a date has events.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
@@ -134,7 +134,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the row rather than between the pill and the arrows,
|
||||
// Centered on the row rather than between the pill and the arrows,
|
||||
// so it does not shift sideways when the pill appears.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
|
||||
// dropping from the top centre because that is where GNOME put it and the
|
||||
// dropping from the top center because that is where GNOME put it and the
|
||||
// muscle memory is the whole point.
|
||||
//
|
||||
// A layer-shell surface rather than a PopupWindow because the inline reply
|
||||
// fields have to be typeable, and because the clock it hangs from is owned by a
|
||||
// different module.
|
||||
//
|
||||
// Layer-shell centres a surface on whichever axis it is not anchored to, so
|
||||
// Layer-shell centers a surface on whichever axis it is not anchored to, so
|
||||
// anchoring only `top` is what puts it in the middle.
|
||||
|
||||
import QtQuick
|
||||
@@ -28,7 +28,11 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 760
|
||||
@@ -87,7 +91,7 @@ PanelWindow {
|
||||
}
|
||||
|
||||
// Grows out of the top edge it is anchored to, rather than out of its
|
||||
// own centre — the panel should look like it came from the bar.
|
||||
// own center — the panel should look like it came from the bar.
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width / 2
|
||||
|
||||
@@ -22,10 +22,34 @@ PanelWindow {
|
||||
property var modelData: null
|
||||
screen: root.modelData
|
||||
|
||||
// Which edge this dock lives on, and everything that follows from it. The
|
||||
// bottom case is unchanged in every particular: same anchors, same
|
||||
// geometry, same slide -- so a machine that never touches the setting sees
|
||||
// exactly the dock it had.
|
||||
readonly property string position: Settings.dockPosition
|
||||
readonly property bool vertical: root.position === "left" || root.position === "right"
|
||||
|
||||
// Only on the screens asked for. An empty list means all of them, which is
|
||||
// what a single-monitor machine wants and what an unplugged display should
|
||||
// not be able to change.
|
||||
readonly property bool onThisScreen: {
|
||||
const wanted = Settings.dockScreens;
|
||||
if (!wanted || wanted.length === 0)
|
||||
return true;
|
||||
return wanted.indexOf(String(root.screen?.name ?? "")) >= 0;
|
||||
}
|
||||
|
||||
visible: root.onThisScreen
|
||||
|
||||
// A dock spans the edge it lives on, which means anchoring BOTH ends of
|
||||
// that edge: bottom+left+right across the screen, or top+bottom plus one
|
||||
// side down it. Anchoring only one end leaves the surface free to collapse
|
||||
// to its implicit size on that axis -- a side dock came out one pixel tall.
|
||||
anchors {
|
||||
top: root.vertical
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
left: root.position !== "right"
|
||||
right: root.position !== "left"
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
@@ -42,18 +66,31 @@ PanelWindow {
|
||||
// ── Geometry ────────────────────────────────────────────────────────────
|
||||
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
||||
readonly property int revealStripHeight: 3
|
||||
readonly property int bottomMargin: Theme.barGap
|
||||
readonly property int edgeMargin: Theme.barGap
|
||||
readonly property int tooltipSpace: 34
|
||||
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
// Only the axis the dock is thin on gets an implicit size; the other is
|
||||
// spanned by the anchors above. Setting both would fight them.
|
||||
implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin
|
||||
implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// This instance's own monitor, the same lookup Workspaces.qml uses to
|
||||
// scope a per-screen bar to its own screen. Falls back to null when this
|
||||
// Dock is created standalone (no `screen` set) rather than via Variants.
|
||||
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
|
||||
|
||||
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
||||
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||
// practice: an empty workspace keeps the dock out.
|
||||
//
|
||||
// Deliberately this instance's own monitor's active workspace, not the
|
||||
// globally-focused one -- with one Dock per screen, keying off the global
|
||||
// focus would make focusing an empty workspace on monitor A hide the dock
|
||||
// on monitor B even though B's own workspace is still busy.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
@@ -83,15 +120,31 @@ PanelWindow {
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
// Other modules (the bar, the capture overlay) read this. It is one
|
||||
// shared flag but there is one Dock per monitor, so only the instance on
|
||||
// the currently-focused monitor is allowed to write it -- otherwise
|
||||
// whichever instance last changed reveal state would stomp the others,
|
||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
||||
// which is what a capture overlay or the bar actually care about.
|
||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
||||
// become keyed by screen, which is out of scope here -- see the report.)
|
||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
||||
|
||||
onRevealedChanged: root._syncShellState()
|
||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||
|
||||
function _syncShellState(): void {
|
||||
if (root.isFocusedMonitorInstance)
|
||||
ShellState.dockRevealed = root.revealed;
|
||||
}
|
||||
|
||||
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||
// false (the default), so the initial state has to be taken explicitly —
|
||||
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||
Component.onCompleted: {
|
||||
revealed = wantRevealed;
|
||||
ShellState.dockRevealed = revealed;
|
||||
root._syncShellState();
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
@@ -114,22 +167,71 @@ PanelWindow {
|
||||
id: pointer
|
||||
}
|
||||
|
||||
// Revealed: the dock plus everything between it and the edge, so
|
||||
// crossing the gap does not count as leaving. Hidden: a sliver along
|
||||
// the edge the dock lives on, which is the only thing that can bring
|
||||
// it back -- every other click passes through to the window beneath.
|
||||
Item {
|
||||
id: maskItem
|
||||
x: root.revealed ? body.x : 0
|
||||
y: root.revealed ? body.y : surface.height - root.revealStripHeight
|
||||
width: root.revealed ? body.width : surface.width
|
||||
height: root.revealed ? surface.height - body.y : root.revealStripHeight
|
||||
|
||||
x: {
|
||||
if (!root.revealed)
|
||||
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
||||
return root.position === "right" ? body.x : 0;
|
||||
}
|
||||
y: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||
return root.vertical ? body.y : body.y;
|
||||
}
|
||||
width: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? root.revealStripHeight : surface.width;
|
||||
return root.vertical
|
||||
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
|
||||
: body.width;
|
||||
}
|
||||
height: {
|
||||
if (!root.revealed)
|
||||
return root.vertical ? surface.height : root.revealStripHeight;
|
||||
return root.vertical ? body.height : surface.height - body.y;
|
||||
}
|
||||
}
|
||||
|
||||
DockBody {
|
||||
id: body
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Slides off the bottom edge when hidden.
|
||||
y: root.revealed ? root.tooltipSpace : surface.height
|
||||
vertical: root.vertical
|
||||
leftSide: root.position === "left"
|
||||
|
||||
// Both axes are computed rather than anchored. Anchoring the centre
|
||||
// on one axis and binding a position on the other looks tidier and
|
||||
// is a conflict: an anchored centre owns that coordinate, so the
|
||||
// binding beside it is fighting for the same value.
|
||||
//
|
||||
// Centred on the long axis; on the short one it sits a tooltip's
|
||||
// width in from the edge when revealed, and off-screen when not.
|
||||
x: {
|
||||
if (!root.vertical)
|
||||
return (surface.width - width) / 2;
|
||||
if (root.position === "left")
|
||||
return root.revealed ? root.tooltipSpace : -width;
|
||||
return root.revealed ? surface.width - width - root.tooltipSpace : surface.width;
|
||||
}
|
||||
y: {
|
||||
if (root.vertical)
|
||||
return (surface.height - height) / 2;
|
||||
return root.revealed ? root.tooltipSpace : surface.height;
|
||||
}
|
||||
opacity: root.revealed ? 1 : 0
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
||||
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Asymmetric on purpose. Revealing is a response to something the
|
||||
// user just did, so it has to feel immediate — any delay there
|
||||
// reads as lag. Hiding is not a response to anything, so it can
|
||||
|
||||
@@ -111,30 +111,51 @@ Rectangle {
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
||||
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
|
||||
Row {
|
||||
id: row
|
||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||
// of across it.
|
||||
property bool vertical: false
|
||||
|
||||
// Which way a tooltip points on a side dock: away from the screen edge, so
|
||||
// it never opens off-screen.
|
||||
property bool leftSide: true
|
||||
|
||||
// Explicit rather than left to Grid's wrapping. This is always one line, so
|
||||
// saying how many cells it holds is both simpler to read and immune to
|
||||
// Grid's default column count quietly wrapping a long dock.
|
||||
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
|
||||
|
||||
// A Grid rather than a Row so one declaration serves both orientations.
|
||||
// Row and Column would each need their own children, and the cross-axis
|
||||
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
|
||||
// a Column -- Grid centres through its own alignment properties instead,
|
||||
// which is the same result without the anchors positioners disallow.
|
||||
Grid {
|
||||
id: strip
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.dockGap
|
||||
rows: root.vertical ? root.cellCount : 1
|
||||
columns: root.vertical ? 1 : root.cellCount
|
||||
horizontalItemAlignment: Grid.AlignHCenter
|
||||
verticalItemAlignment: Grid.AlignVCenter
|
||||
|
||||
ShowAppsButton {
|
||||
id: showApps
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = showApps
|
||||
onExited: if (root.hoveredItem === showApps)
|
||||
root.hoveredItem = null
|
||||
}
|
||||
|
||||
// Separator between the launcher and the apps, as in GNOME's dash.
|
||||
// Separator between the launcher and the apps, as in GNOME's dash. It
|
||||
// turns with the dock: a hairline across a column, down a row.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: Theme.dockIconSize * 0.7
|
||||
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
||||
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
@@ -146,7 +167,6 @@ Rectangle {
|
||||
id: dockItem
|
||||
required property var modelData
|
||||
app: modelData
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
@@ -172,16 +192,26 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the hovered item, clamped inside the dock. Reads the item's
|
||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
||||
// the row reflows.
|
||||
// Centred on the hovered item along the dock's own axis, and placed just
|
||||
// outside the edge it lives on. Reads the item's position directly
|
||||
// (rather than mapToItem) so the binding re-evaluates when the strip
|
||||
// reflows.
|
||||
x: {
|
||||
if (!root.hoveredItem)
|
||||
return 0;
|
||||
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
if (root.vertical)
|
||||
return root.leftSide ? root.width + 8 : -width - 8;
|
||||
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
||||
}
|
||||
y: -height - 8
|
||||
y: {
|
||||
if (!root.hoveredItem)
|
||||
return -height - 8;
|
||||
if (!root.vertical)
|
||||
return -height - 8;
|
||||
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
|
||||
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
|
||||
}
|
||||
|
||||
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||
height: tipLabel.implicitHeight + 8
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One dock icon: the app, its running-window dots, hover scale and click
|
||||
// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// underneath, grow-on-hover, click to focus / cycle / launch.
|
||||
|
||||
import Quickshell
|
||||
@@ -72,7 +72,7 @@ Item {
|
||||
}
|
||||
|
||||
// Last resort for an app with no resolvable icon: an initial, which is
|
||||
// still recognisable, unlike the theme's broken-icon placeholder.
|
||||
// still recognizable, unlike the theme's broken-icon placeholder.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.iconSource === ""
|
||||
@@ -155,7 +155,7 @@ Item {
|
||||
return;
|
||||
|
||||
// Quickshell reports the address without the 0x prefix Hyprland's
|
||||
// window selector expects; normalise so either form works.
|
||||
// window selector expects; normalize so either form works.
|
||||
if (next.address) {
|
||||
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
||||
// optional image, action buttons and an inline reply box.
|
||||
//
|
||||
// Shared by the toasts and the notification centre so a notification looks the
|
||||
// Shared by the toasts and the notification center so a notification looks the
|
||||
// same wherever you meet it.
|
||||
//
|
||||
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
||||
@@ -21,7 +21,7 @@ Rectangle {
|
||||
|
||||
required property var notification
|
||||
|
||||
// The centre renders a denser card than a toast banner.
|
||||
// The center renders a denser card than a toast banner.
|
||||
property bool compact: false
|
||||
|
||||
signal dismissed
|
||||
@@ -35,7 +35,17 @@ Rectangle {
|
||||
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06)
|
||||
// A real popover surface, the same one the date menu and clipboard panel
|
||||
// use, rather than a 6% foreground tint.
|
||||
//
|
||||
// A tint that faint has nothing behind it: the toast window is transparent,
|
||||
// so the card was sitting directly on whatever happened to be on screen and
|
||||
// the text competed with it. Notifications are the one surface someone reads
|
||||
// without having chosen to look at it, so it has to be legible over a
|
||||
// bright photo and a white document alike.
|
||||
color: hover.containsMouse
|
||||
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
|
||||
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
@@ -43,7 +53,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Critical notifications get a red edge rather than a colour wash, so the
|
||||
// Critical notifications get a red edge rather than a color wash, so the
|
||||
// text contrast never changes.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// GNOME's message tray: everything that has arrived, grouped by app.
|
||||
//
|
||||
// Drops from the top centre because that is where GNOME's tray lives and the
|
||||
// muscle memory is the whole point. Layer-shell centres a surface on whichever
|
||||
// axis it is not anchored to, so anchoring only `top` does it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 440
|
||||
implicitHeight: surface.implicitHeight
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
Notifs.markAllRead();
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
|
||||
// border so the two don't fight for the same row of pixels.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Notifications"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 30
|
||||
iconSize: 16
|
||||
icon: "notifications-disabled-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear all"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 120
|
||||
visible: !Notifs.hasNotifications
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grouped history ─────────────────────────────────────────
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 620
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
Column {
|
||||
id: group
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: group.modelData.app
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 13
|
||||
tint: Theme.fgDim
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: Notifs.dismissApp(group.modelData.app)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: group.modelData.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: group.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// A single banner: a notification card that slides in and times itself out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -14,28 +13,25 @@ Item {
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// Mirrors the signal above so the dismiss timer below can read it.
|
||||
property bool replyFocused: false
|
||||
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||
// "server decides", which is our default.
|
||||
readonly property int timeoutMs: {
|
||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (root.notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (root.notification.expireTimeout > 0)
|
||||
return Math.round(root.notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||
// gives a DND-hidden transient notification the same lifetime.
|
||||
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
// Hovering, or actively typing a reply, holds the banner open; the
|
||||
// countdown restarts (from the top, same as hover) once both let go.
|
||||
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
@@ -45,7 +41,10 @@ Item {
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
onReplyFocusChanged: focused => {
|
||||
root.replyFocused = focused;
|
||||
root.replyFocusChanged(focused);
|
||||
}
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
|
||||
@@ -17,7 +17,11 @@ PanelWindow {
|
||||
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
// The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
|
||||
// "reserve nothing, but respect what others reserved", so this surface
|
||||
// already begins below the bar's zone -- adding the bar height here counted
|
||||
// it twice and left the surface floating 48px under the bar instead of 12.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
@@ -45,7 +49,17 @@ PanelWindow {
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
// Notifs.popups.slice() is a fresh array on every change (a new
|
||||
// arrival, a dismissal, a sibling toast timing out). Handing that
|
||||
// straight to Repeater would reset the model and rebuild every
|
||||
// delegate each time, blowing away whichever toast has a reply
|
||||
// field mid-typing. ScriptModel diffs by object identity
|
||||
// (Notification instances are unique QObjects), so only genuinely
|
||||
// added/removed notifications add/remove delegates — unrelated
|
||||
// toasts, and their slide-in animations, are untouched.
|
||||
model: ScriptModel {
|
||||
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
}
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// One workspace in the overview: a proportional card holding live thumbnails
|
||||
// of its windows, labelled underneath. The focused workspace is outlined in the
|
||||
// accent colour, as GNOME outlines the current workspace.
|
||||
// of its windows, labeled underneath. The focused workspace is outlined in the
|
||||
// accent color, as GNOME outlines the current workspace.
|
||||
//
|
||||
// A card with a null `workspace` is the trailing "new workspace" affordance
|
||||
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// The authentication prompt.
|
||||
//
|
||||
// Deliberately a Panama surface rather than the compositor's stock agent: this
|
||||
// is the window that asks for the password to everything, and it was the one
|
||||
// window on the desktop that looked like it belonged to something else.
|
||||
//
|
||||
// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus,
|
||||
// so keystrokes cannot reach the window underneath while a password is being
|
||||
// typed. And the field is cleared on every exit path, including the ones nobody
|
||||
// plans for.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.settings
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: Polkit.active
|
||||
color: "transparent"
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-polkit"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// Exclusive, not OnDemand: a password prompt that lets keystrokes through
|
||||
// to whatever is behind it is a keylogger with extra steps.
|
||||
WlrLayershell.keyboardFocus: Polkit.active
|
||||
? WlrKeyboardFocus.Exclusive
|
||||
: WlrKeyboardFocus.None
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible) {
|
||||
field.text = "";
|
||||
field.forceActiveFocus();
|
||||
} else {
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Dims what is behind, and swallows clicks so nothing outside the dialog
|
||||
// can be operated while it is waiting.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
// Clicking outside does nothing on purpose. Dismissing an
|
||||
// authentication request by misclick, and having the thing that
|
||||
// asked report a mysterious failure, is worse than an explicit
|
||||
// Cancel.
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: dialog
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 420
|
||||
implicitHeight: layout.implicitHeight + 44
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 44
|
||||
spacing: 14
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Authentication required"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Polkit.message
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Polkit.users.length > 1
|
||||
text: "Authenticating as " + Polkit.chosenUser
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
PasswordField {
|
||||
id: field
|
||||
width: parent.width
|
||||
placeholder: "Password"
|
||||
enabled: !Polkit.authenticating
|
||||
onAccepted: {
|
||||
if (field.text !== "")
|
||||
Polkit.submit(field.text);
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Polkit.failureText !== ""
|
||||
text: Polkit.failureText
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: Polkit.cancel()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: Polkit.authenticating ? "Checking…" : "Authenticate"
|
||||
tone: "accent"
|
||||
enabled: !Polkit.authenticating
|
||||
onClicked: {
|
||||
if (field.text !== "")
|
||||
Polkit.submit(field.text);
|
||||
field.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escape cancels, which is what every other dialog on this desktop does.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: Polkit.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
module qs.modules.polkit
|
||||
PolkitPrompt 1.0 PolkitPrompt.qml
|
||||
@@ -114,7 +114,7 @@ Item {
|
||||
|
||||
width: parent.width
|
||||
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
||||
// the symbolic variant is the one that can be recoloured.
|
||||
// the symbolic variant is the one that can be recolored.
|
||||
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
||||
iconFallback: "bluetooth-symbolic"
|
||||
label: deviceRow.modelData.name || deviceRow.modelData.address
|
||||
|
||||
@@ -29,7 +29,7 @@ Item {
|
||||
property real backlightValue: 0
|
||||
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
readonly property bool labeled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
@@ -89,7 +89,7 @@ Item {
|
||||
// Hyprland already knows what each output is called, so the
|
||||
// name comes from there rather than from a second source that
|
||||
// could disagree with the Displays page. The connector is the
|
||||
// fallback, so a display is never an unlabelled slider.
|
||||
// fallback, so a display is never an unlabeled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
@@ -113,7 +113,7 @@ Item {
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
visible: root.labeled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
@@ -124,7 +124,7 @@ Item {
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// both a caption and a center line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
@@ -134,7 +134,7 @@ Item {
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
// recoloring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
|
||||
@@ -36,9 +36,30 @@ Item {
|
||||
|
||||
Rectangle {
|
||||
id: restingCard
|
||||
|
||||
// Shown when the section is closed; the expanded grid below replaces
|
||||
// it when open.
|
||||
readonly property bool shown: root.hasSelection && !root.expanded
|
||||
|
||||
width: parent.width
|
||||
height: visible ? restingGrid.implicitHeight + 20 : 0
|
||||
visible: root.hasSelection && !root.expanded
|
||||
|
||||
// Animated, and on the same curve and duration as the Section that
|
||||
// replaces it. Previously this hid the instant `expanded` flipped:
|
||||
// a Column skips invisible children, so everything above snapped up
|
||||
// while the expanded grid was still sliding open underneath. That
|
||||
// read as a glitch rather than as an animation, and only here,
|
||||
// because this is the only control with a resting and an expanded
|
||||
// form that swap.
|
||||
height: restingCard.shown ? restingGrid.implicitHeight + 20 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.warn, 0.028)
|
||||
border.width: 1
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// The three system power profiles, as an expandable list.
|
||||
//
|
||||
// A row per profile rather than a cycling button: there are three, and cycling
|
||||
// through them means passing through one you did not want on a machine where
|
||||
// the change is immediate and audible. The same shape the audio device lists
|
||||
// use, so the panel reads consistently.
|
||||
//
|
||||
// The daemon owns the profile -- it survives a shell restart and anything else
|
||||
// on the system can change it -- so this reads back rather than assuming, the
|
||||
// same as monitor brightness.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
|
||||
RowButton {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
icon: {
|
||||
switch (modelData) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: PowerProfiles.label(modelData)
|
||||
sublabel: PowerProfiles.detail(modelData)
|
||||
selected: modelData === PowerProfiles.active
|
||||
dimmed: PowerProfiles.busy
|
||||
onClicked: PowerProfiles.set(modelData)
|
||||
}
|
||||
}
|
||||
|
||||
// Only when it is true. A machine that cannot deliver the profile it is set
|
||||
// to is the one case where the label alone is misleading.
|
||||
Text {
|
||||
visible: PowerProfiles.degraded !== ""
|
||||
width: root.width
|
||||
text: "Limited right now: " + PowerProfiles.degraded
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
leftPadding: 10
|
||||
topPadding: 4
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,21 @@ PanelWindow {
|
||||
|
||||
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||
// preserve the Prism edge without making the surface feel detached.
|
||||
//
|
||||
// The margin is the gap ALONE, not the bar height plus the gap. An
|
||||
// exclusiveZone of 0 means "reserve nothing, but respect what others
|
||||
// reserved", so this surface already begins below the bar's 36px zone;
|
||||
// adding the bar height here counted it twice and left the panel floating
|
||||
// 38px under the bar instead of 2px. It also means the panel follows the
|
||||
// bar on its own -- if the bar ever stops reserving space, this closes up
|
||||
// against the top edge rather than hanging under nothing.
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||
// The same gap every other popover uses. This had its own constant set to
|
||||
// 2, which left the widest surface in the shell hanging ten pixels higher
|
||||
// than the date menu beside it -- an early value nothing else converged on
|
||||
// rather than a decision; it carried no reason, while barGap does.
|
||||
margins.top: Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
@@ -45,6 +57,9 @@ PanelWindow {
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
// The daemon owns the power profile and anything on the system can
|
||||
// change it, so the panel asks rather than trusting what it last saw.
|
||||
PowerProfiles.refresh();
|
||||
openAnim.restart();
|
||||
} else {
|
||||
panel.reset();
|
||||
|
||||
@@ -107,6 +107,27 @@ Item {
|
||||
onExpanded: root.expand("wifi")
|
||||
}
|
||||
|
||||
// Only present when there is a wired device at all -- most machines
|
||||
// this runs on have one, but a laptop without a dock does not, and
|
||||
// an Ethernet tile there would be a control for absent hardware.
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
icon: Connectivity.wiredOn
|
||||
? "network-wired-symbolic"
|
||||
: "network-wired-disconnected-symbolic"
|
||||
label: "Ethernet"
|
||||
active: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
sublabel: {
|
||||
if (Connectivity.wiredOn)
|
||||
return Connectivity.wiredDevice.linkSpeed > 0
|
||||
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
|
||||
return "Off";
|
||||
}
|
||||
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||
@@ -130,6 +151,21 @@ Item {
|
||||
onToggled: Caffeine.toggle()
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
||||
label: "Appearance"
|
||||
sublabel: ColorScheme.dark ? "Dark" : "Light"
|
||||
// "active" reads as the non-default state across this grid, and
|
||||
// dark is what Panama ships, so light is the lit one.
|
||||
active: !ColorScheme.dark
|
||||
// Writes the preference and stops. ColorScheme propagates it to
|
||||
// GTK, the portal, the terminals, the launcher, btop, tmux and
|
||||
// the lock screen; nothing here needs to know that list.
|
||||
onToggled: SystemSettings.commitPreference("colorScheme",
|
||||
ColorScheme.dark ? "light" : "dark")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "night-light-symbolic"
|
||||
@@ -190,6 +226,38 @@ Item {
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Power ───────────────────────────────────────────────────────────
|
||||
// Only where a power-profiles daemon is actually running. A desktop
|
||||
// without one should not show a control that cannot do anything.
|
||||
RowButton {
|
||||
visible: PowerProfiles.available
|
||||
width: content.width
|
||||
icon: {
|
||||
switch (PowerProfiles.active) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
label: "Power profile"
|
||||
sublabel: PowerProfiles.degraded !== ""
|
||||
? PowerProfiles.label(PowerProfiles.active) + " · limited"
|
||||
: PowerProfiles.label(PowerProfiles.active)
|
||||
selected: root.expandedSection === "power"
|
||||
onClicked: root.expand("power")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "power"
|
||||
|
||||
PowerProfileList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sliders ─────────────────────────────────────────────────────────
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
@@ -257,15 +325,45 @@ Item {
|
||||
width: content.width
|
||||
height: 36
|
||||
|
||||
ThemedIcon {
|
||||
// The account's own picture, the same file the lock screen and the
|
||||
// login screen read. A generic glyph sat here while a real avatar
|
||||
// was already set, which made the desktop look like it did not know
|
||||
// whose it was.
|
||||
Item {
|
||||
id: avatar
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 22
|
||||
height: 22
|
||||
|
||||
ClippingRectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
visible: UserAccounts.avatarUrl !== ""
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: UserAccounts.avatarUrl
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
// Replaced in place when the picture changes, so the
|
||||
// cache has to be told to let go of the old one.
|
||||
cache: false
|
||||
asynchronous: true
|
||||
sourceSize.width: 44
|
||||
sourceSize.height: 44
|
||||
}
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: UserAccounts.avatarUrl === ""
|
||||
size: 20
|
||||
icon: "avatar-default-symbolic"
|
||||
iconFallback: "user-info-symbolic"
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: avatar.right
|
||||
@@ -273,7 +371,9 @@ Item {
|
||||
anchors.right: actions.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Quickshell.env("USER") || "user"
|
||||
text: UserAccounts.me
|
||||
? UserAccounts.displayName(UserAccounts.me)
|
||||
: (Quickshell.env("USER") || "user")
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -95,7 +95,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Full-height rather than vertically centred so slot contents can anchor
|
||||
// Full-height rather than vertically centered so slot contents can anchor
|
||||
// their own verticalCenter to it without a size loop.
|
||||
Row {
|
||||
id: trailingRow
|
||||
|
||||
@@ -137,7 +137,18 @@ Item {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.networks
|
||||
// root.networks is a fresh array on every signal-strength tick (the
|
||||
// sort comparator reads signalStrength/connected/known, so any of
|
||||
// those changing on ANY network recomputes the whole list). Handing
|
||||
// that straight to Repeater would reset the model and rebuild every
|
||||
// delegate each tick, blowing away whichever row has its password
|
||||
// Section open and focused. ScriptModel diffs by object identity
|
||||
// (WifiNetwork instances are unique QObjects) and turns a reorder
|
||||
// into move operations, so existing delegates -- and their expanded
|
||||
// state -- survive.
|
||||
model: ScriptModel {
|
||||
values: root.networks
|
||||
}
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
PowerProfileList 1.0 PowerProfileList.qml
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Choosing the desktop accent.
|
||||
//
|
||||
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
|
||||
// dot, because the gradient is the thing being chosen -- the focused window
|
||||
// border, the bar hairline and every active state are the two colors meeting.
|
||||
// A row of flat circles would misrepresent all of them.
|
||||
//
|
||||
// Named accents rather than a color wheel: each name carries a curated pair
|
||||
// per scheme, so every choice stays legible in both light and dark. See
|
||||
// config/Theme.qml for the palette and the reasoning.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
spacing: 10
|
||||
|
||||
readonly property string current: DesktopPreferences.get("accentName") || "blue"
|
||||
|
||||
// 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
|
||||
// ChoiceRow.qml uses for every other enum row.
|
||||
readonly property var spec: PreferenceSchema.spec("accentName")
|
||||
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||
|
||||
Repeater {
|
||||
// The schema's option order is the palette's order, so blue is first
|
||||
// because it is what Panama ships.
|
||||
model: root.options
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string name: entry.modelData.value
|
||||
readonly property var pair: Theme.accents[entry.name]
|
||||
readonly property bool selected: entry.name === root.current
|
||||
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
|
||||
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
|
||||
|
||||
spacing: 5
|
||||
|
||||
// The hit target is the whole swatch+label unit, not just the
|
||||
// 46px circle: the label exists specifically so someone with a
|
||||
// color vision deficiency can identify an accent without it, and
|
||||
// a label that cannot itself be tapped defeats that.
|
||||
HoverHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
if (!SystemSettings.commitPreference("accentName", entry.name))
|
||||
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 46
|
||||
height: 46
|
||||
radius: 23
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
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)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: entry.selected ? 4 : 3
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: entry.start }
|
||||
GradientStop { position: 1.0; color: entry.end }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always shown, not a tooltip. Telling swatches apart by color is
|
||||
// exactly what someone with a color vision deficiency cannot do,
|
||||
// and it is the reason the palette is named rather than freeform --
|
||||
// hiding the names behind a hover would waste that.
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: entry.modelData.label
|
||||
color: entry.selected ? Theme.fg : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: entry.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ SettingsPage {
|
||||
title: "Text"
|
||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||
|
||||
SliderRow { setting: "textScale"; divider: false }
|
||||
SliderRow { setting: "textScale" }
|
||||
// Reaches GTK4 applications through the desktop portal, which
|
||||
// republishes it as org.freedesktop.appearance contrast. No
|
||||
// high-contrast theme is involved, and none is installed here -- older
|
||||
// GTK3 applications will not change.
|
||||
ToggleRow { setting: "highContrast"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -19,6 +19,19 @@ SettingsPage {
|
||||
|
||||
property string expandedPicker: ""
|
||||
|
||||
// Which group of cards is on screen. Theme leads deliberately: light and
|
||||
// dark is the control reached most often, and it used to be the third
|
||||
// section down, below a wallpaper grid and the whole lock screen.
|
||||
property string tab: "theme"
|
||||
|
||||
// Arriving from another page that named a section opens on it. Taken once
|
||||
// rather than bound, so the tabs still work normally afterwards.
|
||||
Component.onCompleted: {
|
||||
const section = ShellState.takeSettingsSection();
|
||||
if (section !== "")
|
||||
root.tab = section;
|
||||
}
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||
|
||||
@@ -44,7 +57,20 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsTabs {
|
||||
tabs: [
|
||||
{ value: "theme", label: "Theme" },
|
||||
{ value: "background", label: "Background" },
|
||||
{ value: "type", label: "Typography" },
|
||||
{ value: "windows", label: "Windows" },
|
||||
{ value: "shell", label: "Shell" },
|
||||
]
|
||||
current: root.tab
|
||||
onSelected: value => root.tab = value
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "background"
|
||||
title: "Background"
|
||||
subtitle: Wallpaper.lastError !== ""
|
||||
? Wallpaper.lastError
|
||||
@@ -83,6 +109,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "background"
|
||||
title: "Lock screen"
|
||||
subtitle: LockScreen.lastError !== ""
|
||||
? LockScreen.lastError
|
||||
@@ -101,15 +128,23 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Colour scheme"
|
||||
visible: root.tab === "theme"
|
||||
title: "Color scheme"
|
||||
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."
|
||||
|
||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
||||
ChoiceRow { setting: "colorScheme" }
|
||||
|
||||
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||
// because the gradient is what is being chosen.
|
||||
AccentPicker {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Shell typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
@@ -151,6 +186,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Application typography"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
@@ -223,6 +259,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "type"
|
||||
title: "Icons & pointer"
|
||||
subtitle: DesktopStyle.lastError !== ""
|
||||
? DesktopStyle.lastError
|
||||
@@ -273,6 +310,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "windows"
|
||||
title: "Titlebars"
|
||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||
|
||||
@@ -282,6 +320,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "windows"
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
|
||||
@@ -296,6 +335,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "theme"
|
||||
title: "Effects"
|
||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
||||
|
||||
@@ -312,6 +352,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "Clock"
|
||||
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
@@ -320,6 +361,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
|
||||
|
||||
@@ -13,15 +13,26 @@ SettingsPage {
|
||||
|
||||
property string expandedRole: ""
|
||||
property bool addingAutostart: false
|
||||
|
||||
// Which entry has been asked to be removed. Removal deletes a file, so
|
||||
// it never happens on a first press.
|
||||
property string confirmingAutostartRemoval: ""
|
||||
readonly property var applications: DesktopEntries.applications.values
|
||||
// Each role governs a whole family of types, not one representative: setting
|
||||
// "Images" writes PNG, JPEG, WebP and the rest together, so a file manager
|
||||
// can never open one image in a viewer and its neighbour in an editor.
|
||||
// The detail line names the family the way someone would describe it.
|
||||
readonly property var roles: [
|
||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
||||
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
|
||||
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
|
||||
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
|
||||
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
||||
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
|
||||
{ key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||
{ key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
|
||||
{ key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] },
|
||||
{ key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] },
|
||||
{ key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] },
|
||||
{ key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] }
|
||||
]
|
||||
|
||||
function desktopId(entry: var): string {
|
||||
@@ -43,17 +54,27 @@ SettingsPage {
|
||||
}
|
||||
|
||||
function matchesRole(entry: var, role: var): bool {
|
||||
const rawCategories = Array.isArray(entry.categories)
|
||||
? entry.categories
|
||||
: [String(entry.categories ?? "")];
|
||||
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
||||
// Array.isArray is false for it. The old code took that as "this is a
|
||||
// string", stringified the list into "Network,WebBrowser" and then split
|
||||
// on ";" only -- producing the single token "network,webbrowser", which
|
||||
// matches no category at all.
|
||||
//
|
||||
// Nothing failed loudly. Browsers still appeared because their generic
|
||||
// name contains "web browser", so the terms fallback carried the role
|
||||
// by itself. Archives matched NOTHING, which meant that row could only
|
||||
// ever offer the application it already had.
|
||||
//
|
||||
// Joining first and splitting on both separators handles the list form
|
||||
// and a plain string equally.
|
||||
const raw = entry.categories;
|
||||
const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? "");
|
||||
const categories = [];
|
||||
for (const rawCategory of rawCategories) {
|
||||
for (const value of String(rawCategory).split(";")) {
|
||||
for (const value of joined.split(/[;,]/)) {
|
||||
const category = value.trim().toLowerCase();
|
||||
if (category !== "")
|
||||
categories.push(category);
|
||||
}
|
||||
}
|
||||
const metadata = [entry.name, entry.genericName]
|
||||
.map(value => String(value ?? "").toLowerCase())
|
||||
.join(" ");
|
||||
@@ -95,14 +116,46 @@ SettingsPage {
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
id: roleRow
|
||||
|
||||
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
||||
|
||||
label: roleBlock.modelData.label
|
||||
detail: roleBlock.modelData.detail
|
||||
value: DefaultApps.busy ? "Loading…" : (
|
||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
||||
controlWidth: 210
|
||||
|
||||
// Drawn rather than left to SettingRow's plain value text, so
|
||||
// the row carries the same chevron a PickerRow does. These
|
||||
// open a chooser but looked completely inert without it.
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: DefaultApps.busy ? "Loading…" : (
|
||||
roleBlock.selectedEntry
|
||||
? root.displayName(roleBlock.selectedEntry)
|
||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
||||
)
|
||||
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: roleBlock.choices.length > 0
|
||||
text: roleRow.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
||||
onActivated: {
|
||||
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
||||
@@ -219,12 +272,50 @@ SettingsPage {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
|
||||
|
||||
label: autostartRow.modelData.name
|
||||
detail: autostartRow.modelData.id
|
||||
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
|
||||
activatable: !DefaultApps.busy
|
||||
detail: autostartRow.confirming
|
||||
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||
: autostartRow.modelData.id
|
||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
||||
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
|
||||
controlWidth: 210
|
||||
|
||||
// A switch, not the words "Enabled"/"Disabled". The row always
|
||||
// toggled on click, but read as static text, so a control that
|
||||
// worked looked like a status nobody could change.
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: autostartRow.confirming
|
||||
text: "Remove it"
|
||||
tone: "danger"
|
||||
enabled: !DefaultApps.busy
|
||||
onClicked: {
|
||||
root.confirmingAutostartRemoval = "";
|
||||
DefaultApps.removeAutostart(String(autostartRow.modelData.id));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: autostartRow.confirming ? "Keep" : "Remove…"
|
||||
enabled: !DefaultApps.busy
|
||||
onClicked: root.confirmingAutostartRemoval =
|
||||
autostartRow.confirming ? "" : String(autostartRow.modelData.id)
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: autostartRow.modelData.enabled
|
||||
onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Choosing which part of a picture becomes the profile picture.
|
||||
//
|
||||
// Without this, whatever was picked went to accountsservice whole, and a
|
||||
// landscape photograph became a squashed thumbnail with the subject off the
|
||||
// edge. The circle is drawn where the avatar is actually round elsewhere in the
|
||||
// shell, so what is framed here is what appears there.
|
||||
//
|
||||
// The crop is reported in the picture's OWN pixels, not screen ones, so the
|
||||
// result does not depend on the size this happened to be displayed at.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Absolute path of the picture being framed.
|
||||
property string source: ""
|
||||
|
||||
// Side of the square viewport, in screen pixels.
|
||||
readonly property int viewport: 260
|
||||
|
||||
// How far in the picture is zoomed, as a multiple of the smallest scale
|
||||
// that still covers the viewport. 1 means "just covers".
|
||||
property real zoom: 1
|
||||
readonly property real maxZoom: 4
|
||||
|
||||
// Top-left of the drawn picture, relative to the viewport's top-left.
|
||||
property real offsetX: 0
|
||||
property real offsetY: 0
|
||||
|
||||
readonly property bool ready: picture.status === Image.Ready
|
||||
&& picture.sourceSize.width > 0 && picture.sourceSize.height > 0
|
||||
|
||||
// The scale at which the shorter side exactly fills the viewport. Any
|
||||
// smaller and the square would include area the picture does not cover.
|
||||
readonly property real baseScale: root.ready
|
||||
? root.viewport / Math.min(picture.sourceSize.width, picture.sourceSize.height)
|
||||
: 1
|
||||
readonly property real scale: root.baseScale * root.zoom
|
||||
|
||||
readonly property real drawnWidth: root.ready ? picture.sourceSize.width * root.scale : 0
|
||||
readonly property real drawnHeight: root.ready ? picture.sourceSize.height * root.scale : 0
|
||||
|
||||
// Emitted with a square in the picture's own pixel coordinates.
|
||||
signal cropped(int x, int y, int size)
|
||||
signal cancelled
|
||||
|
||||
implicitWidth: parent ? parent.width : 620
|
||||
implicitHeight: column.implicitHeight
|
||||
|
||||
// Keeps the viewport covered: the picture may never be dragged far enough
|
||||
// to expose an edge, so the crop is always entirely inside the image.
|
||||
function clamp(): void {
|
||||
root.offsetX = Math.min(0, Math.max(root.viewport - root.drawnWidth, root.offsetX));
|
||||
root.offsetY = Math.min(0, Math.max(root.viewport - root.drawnHeight, root.offsetY));
|
||||
}
|
||||
|
||||
// Start centred, filling the viewport.
|
||||
function reset(): void {
|
||||
root.zoom = 1;
|
||||
root.offsetX = (root.viewport - root.drawnWidth) / 2;
|
||||
root.offsetY = (root.viewport - root.drawnHeight) / 2;
|
||||
}
|
||||
|
||||
onReadyChanged: if (root.ready) root.reset()
|
||||
onZoomChanged: root.clamp()
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
spacing: 14
|
||||
|
||||
Row {
|
||||
spacing: 20
|
||||
|
||||
Rectangle {
|
||||
id: frame
|
||||
width: root.viewport
|
||||
height: root.viewport
|
||||
radius: 12
|
||||
clip: true
|
||||
color: Theme.bgDark
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Image {
|
||||
id: picture
|
||||
source: root.source === "" ? "" : "file://" + root.source
|
||||
x: root.offsetX
|
||||
y: root.offsetY
|
||||
width: root.drawnWidth
|
||||
height: root.drawnHeight
|
||||
fillMode: Image.Stretch
|
||||
asynchronous: true
|
||||
// The picture is drawn at whatever size the frame needs, so
|
||||
// decoding it at full resolution wastes memory on a photo.
|
||||
sourceSize.width: 1600
|
||||
sourceSize.height: 1600
|
||||
smooth: true
|
||||
}
|
||||
|
||||
// The round mask: a dimming fill with the circle punched out of
|
||||
// it, then the ring drawn on top. Painted once per change, not
|
||||
// continuously.
|
||||
Canvas {
|
||||
id: mask
|
||||
anchors.fill: parent
|
||||
onPaint: {
|
||||
const context = mask.getContext("2d");
|
||||
context.clearRect(0, 0, mask.width, mask.height);
|
||||
context.fillStyle = Qt.rgba(0.12, 0.13, 0.19, 0.62);
|
||||
context.fillRect(0, 0, mask.width, mask.height);
|
||||
context.globalCompositeOperation = "destination-out";
|
||||
context.beginPath();
|
||||
context.arc(mask.width / 2, mask.height / 2,
|
||||
mask.width / 2 - 8, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.globalCompositeOperation = "source-over";
|
||||
context.strokeStyle = Qt.rgba(0.78, 0.83, 0.96, 0.85);
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(mask.width / 2, mask.height / 2,
|
||||
mask.width / 2 - 8, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// The offset is tracked from where the press started rather than
|
||||
// accumulated per frame, which would drift.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.ready
|
||||
cursorShape: Qt.OpenHandCursor
|
||||
property real pressX: 0
|
||||
property real pressY: 0
|
||||
property real originX: 0
|
||||
property real originY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
pressX = mouse.x; pressY = mouse.y;
|
||||
originX = root.offsetX; originY = root.offsetY;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (!pressed)
|
||||
return;
|
||||
root.offsetX = originX + (mouse.x - pressX);
|
||||
root.offsetY = originY + (mouse.y - pressY);
|
||||
root.clamp();
|
||||
}
|
||||
onWheel: wheel => {
|
||||
const before = root.scale;
|
||||
root.zoom = Math.max(1, Math.min(root.maxZoom,
|
||||
root.zoom * (wheel.angleDelta.y > 0 ? 1.1 : 1 / 1.1)));
|
||||
// Keep the centre of the frame pointing at the same part
|
||||
// of the picture, so zooming does not walk the subject
|
||||
// out of the circle.
|
||||
const ratio = root.scale / before;
|
||||
root.offsetX = root.viewport / 2 - (root.viewport / 2 - root.offsetX) * ratio;
|
||||
root.offsetY = root.viewport / 2 - (root.viewport / 2 - root.offsetY) * ratio;
|
||||
root.clamp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: column.width - frame.width - 20
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.ready
|
||||
? "Drag to reposition, scroll to zoom."
|
||||
: (root.source === "" ? "" : "Opening the picture…")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.ready
|
||||
text: "The circle is what other people see. Written out at 512 × 512."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: picture.status === Image.Error
|
||||
text: "That file could not be opened as a picture."
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Set picture"
|
||||
tone: "accent"
|
||||
enabled: root.ready
|
||||
onClicked: {
|
||||
// Screen coordinates back into the picture's own.
|
||||
const size = root.viewport / root.scale;
|
||||
root.cropped(Math.round(-root.offsetX / root.scale),
|
||||
Math.round(-root.offsetY / root.scale),
|
||||
Math.round(size));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Reset"
|
||||
enabled: root.ready
|
||||
onClicked: root.reset()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Cancel"
|
||||
onClicked: root.cancelled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Choosing a profile picture.
|
||||
//
|
||||
// A thin wrapper on the same FileDialog the phone controls use, so picking a
|
||||
// picture is the ordinary file chooser rather than something this desktop
|
||||
// invented. Nothing privileged happens here -- reading a file the user selected
|
||||
// needs no authorization; only setting it on the account does.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Dialogs
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal picked(path: string)
|
||||
|
||||
function open(): void {
|
||||
dialog.open();
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: dialog
|
||||
title: "Choose a profile picture"
|
||||
fileMode: FileDialog.OpenFile
|
||||
nameFilters: ["Pictures (*.png *.jpg *.jpeg *.webp *.gif *.bmp)", "All files (*)"]
|
||||
onAccepted: {
|
||||
const value = String(dialog.selectedFile);
|
||||
if (!value.startsWith("file://"))
|
||||
return;
|
||||
root.picked(decodeURIComponent(value.slice(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,27 @@ SettingsPage {
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
TextRow {
|
||||
SwitchRow {
|
||||
label: "Ethernet"
|
||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||
// Three states worth telling apart: on, off but plugged in, and
|
||||
// nothing in the socket. "Not connected" covered all three and
|
||||
// explained none of them.
|
||||
detail: {
|
||||
const device = Connectivity.wiredDevice;
|
||||
if (!device)
|
||||
return "";
|
||||
if (device.connected)
|
||||
return device.name + (device.linkSpeed > 0
|
||||
? " · " + device.linkSpeed + " Mb/s" : "");
|
||||
// Not "no cable": that cannot be told apart from "switched off"
|
||||
// by anything reliable here, and guessing produced a switch
|
||||
// that blamed the hardware for what it had just done itself.
|
||||
return device.name + " · off";
|
||||
}
|
||||
checked: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
divider: false
|
||||
onToggled: value => Connectivity.setWired(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,24 +185,15 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||
subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here."
|
||||
|
||||
ActionRow {
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and per-connection settings"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Printers"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Online accounts"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
// Rootless podman-compose stacks, led by what needs attention.
|
||||
//
|
||||
// The grouping is the compose project, because that is the unit a person thinks
|
||||
// in: not "thirteen containers" but "the Command Center database" and "the
|
||||
// capacity planner's Supabase". State decides prominence within that grouping --
|
||||
// what is running gets rows, what is stopped collapses to a line -- so neither
|
||||
// axis has to be chosen over the other.
|
||||
//
|
||||
// The findings at the top are the same crossing the Firewall page reports, seen
|
||||
// from the side that can close it: the firewall knows only that something is
|
||||
// listening, while this page knows which container, which compose file, and
|
||||
// which token is missing from it.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
objectName: "containers"
|
||||
|
||||
// "images", "volumes", or empty. Removal never happens on a first press.
|
||||
property string confirmingPrune: ""
|
||||
|
||||
// Projects whose stopped containers have been expanded, by project name.
|
||||
property var expanded: []
|
||||
|
||||
function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; }
|
||||
|
||||
function toggleExpanded(name: string): void {
|
||||
root.expanded = root.isExpanded(name)
|
||||
? root.expanded.filter(entry => entry !== name)
|
||||
: root.expanded.concat([name]);
|
||||
}
|
||||
|
||||
function uptimeOf(container: var): string {
|
||||
const status = String(container.status ?? "");
|
||||
// podman already phrases this well ("Up 32 hours (healthy)"); the health
|
||||
// is shown separately, so only the duration is wanted here.
|
||||
const match = /^Up ([^(]+)/.exec(status);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
function portSummary(container: var): string {
|
||||
const ports = container.ports ?? [];
|
||||
if (ports.length === 0)
|
||||
return "no published ports";
|
||||
return ports.map(port => {
|
||||
const host = String(port.hostIp ?? "");
|
||||
const where = host === "" ? "every interface" : host;
|
||||
return where + ":" + port.hostPort + " → " + port.containerPort;
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
Component.onCompleted: Containers.refresh()
|
||||
|
||||
// ── the list ────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsPage {
|
||||
anchors.fill: parent
|
||||
visible: Containers.logTarget === ""
|
||||
|
||||
title: "Containers"
|
||||
lede: "Local services you run for development — what they expose, and what they cost."
|
||||
|
||||
TextRow {
|
||||
visible: Containers.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: Containers.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.scanned && !Containers.available
|
||||
label: "podman is not available"
|
||||
detail: "Nothing here can be shown until podman is installed."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── finding: published to the network ───────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.reachable.length > 0
|
||||
title: Containers.reachable.length === 1
|
||||
? "A container is published to your whole network"
|
||||
: "Containers are published to your whole network"
|
||||
subtitle: "These bind every interface, so any machine on your network can connect. "
|
||||
+ "For a development database, loopback is almost always what you want. "
|
||||
+ "Binding adds an address to the compose file and changes nothing else."
|
||||
|
||||
Repeater {
|
||||
model: Containers.reachable
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.container ?? "")
|
||||
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
|
||||
+ " · " + String(modelData.image ?? "")
|
||||
+ (modelData.configFile
|
||||
? "\n" + Containers.shorten(String(modelData.configFile))
|
||||
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
|
||||
controlWidth: 250
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Open compose file"
|
||||
visible: String(modelData.configFile ?? "") !== ""
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.openFile(String(modelData.configFile))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Bind to localhost"
|
||||
tone: "accent"
|
||||
// Needs both halves of the label to find the entry in
|
||||
// the compose file; the Supabase CLI records neither.
|
||||
visible: String(modelData.configFile ?? "") !== ""
|
||||
&& String(modelData.service ?? "") !== ""
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.bindLocal(
|
||||
String(modelData.project), String(modelData.service))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.reachable.length > 0
|
||||
label: "After binding"
|
||||
detail: "The change takes effect the next time the stack comes up, because a "
|
||||
+ "published port is fixed when the container is created."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── finding: disk nothing references ────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
|
||||
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
|
||||
subtitle: "Images and volumes no container references. Removing them frees the space; "
|
||||
+ "anything still needed downloads again on next use."
|
||||
|
||||
Repeater {
|
||||
model: Containers.unusedImages.slice(0, 5)
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: ""
|
||||
value: Containers.formatBytes(Number(modelData.size ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.unusedImages.length > 5
|
||||
label: "and " + (Containers.unusedImages.length - 5) + " more"
|
||||
detail: ""
|
||||
value: ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: root.confirmingPrune === "images"
|
||||
? "Remove " + Containers.unusedImages.length + " images?"
|
||||
: "Unused images"
|
||||
detail: root.confirmingPrune === "images"
|
||||
? "Each is removed by name. Nothing that a container references is touched."
|
||||
: Containers.unusedImages.length + " images, "
|
||||
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
|
||||
visible: Containers.unusedImages.length > 0
|
||||
controlWidth: 230
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
|
||||
enabled: !Containers.busy
|
||||
onClicked: root.confirmingPrune =
|
||||
root.confirmingPrune === "images" ? "" : "images"
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingPrune === "images"
|
||||
text: "Remove them"
|
||||
tone: "danger"
|
||||
enabled: !Containers.busy
|
||||
onClicked: {
|
||||
root.confirmingPrune = "";
|
||||
Containers.pruneImages();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Containers.unusedVolumes.length > 0
|
||||
label: root.confirmingPrune === "volumes"
|
||||
? "Remove " + Containers.unusedVolumes.length + " volumes?"
|
||||
: "Unused volumes"
|
||||
detail: root.confirmingPrune === "volumes"
|
||||
? "A volume holds data. These are the ones podman reports as referenced by "
|
||||
+ "nothing, but removing them cannot be undone."
|
||||
: Containers.unusedVolumes.length + " volumes, "
|
||||
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
|
||||
controlWidth: 230
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
|
||||
enabled: !Containers.busy
|
||||
onClicked: root.confirmingPrune =
|
||||
root.confirmingPrune === "volumes" ? "" : "volumes"
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingPrune === "volumes"
|
||||
text: "Remove them"
|
||||
tone: "danger"
|
||||
enabled: !Containers.busy
|
||||
onClicked: {
|
||||
root.confirmingPrune = "";
|
||||
Containers.pruneVolumes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the stacks ──────────────────────────────────────────────────────
|
||||
|
||||
Repeater {
|
||||
model: Containers.projects
|
||||
|
||||
delegate: SettingsCard {
|
||||
id: projectCard
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var runningSet:
|
||||
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
|
||||
readonly property var stoppedSet:
|
||||
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
|
||||
readonly property string projectName: String(projectCard.modelData.name ?? "")
|
||||
|
||||
title: String(projectCard.modelData.title ?? "")
|
||||
subtitle: {
|
||||
const total = Number(projectCard.modelData.total ?? 0);
|
||||
const up = Number(projectCard.modelData.running ?? 0);
|
||||
const where = String(projectCard.modelData.configFile ?? "");
|
||||
const counts = up === 0
|
||||
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
|
||||
: up + " of " + total + " running";
|
||||
return where === "" ? counts : counts + " · " + Containers.shorten(where);
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "The whole stack"
|
||||
detail: projectCard.runningSet.length === 0
|
||||
? "Starts every container this project defines."
|
||||
: "Stopping leaves the containers in place; nothing is removed."
|
||||
controlWidth: 250
|
||||
divider: projectCard.runningSet.length > 0
|
||||
|| projectCard.stoppedSet.length > 0
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Start all"
|
||||
visible: projectCard.stoppedSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.startProject(projectCard.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Restart all"
|
||||
visible: projectCard.runningSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.restartProject(projectCard.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Stop all"
|
||||
visible: projectCard.runningSet.length > 0
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.stopProject(projectCard.projectName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: projectCard.runningSet
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
|
||||
value: {
|
||||
const health = String(modelData.health ?? "");
|
||||
const up = root.uptimeOf(modelData);
|
||||
if (health !== "" && up !== "")
|
||||
return health + " · " + up;
|
||||
return health !== "" ? health : up;
|
||||
}
|
||||
controlWidth: 250
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Restart"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.restart(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Stop"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.stop(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: projectCard.stoppedSet.length > 0
|
||||
activatable: true
|
||||
divider: root.isExpanded(projectCard.projectName)
|
||||
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
|
||||
+ projectCard.stoppedSet.length
|
||||
+ (projectCard.stoppedSet.length === 1
|
||||
? " stopped container" : " stopped containers")
|
||||
detail: {
|
||||
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
|
||||
return bad.length === 0
|
||||
? ""
|
||||
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
|
||||
}
|
||||
onActivated: root.toggleExpanded(projectCard.projectName)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||
controlWidth: 180
|
||||
divider: index < projectCard.stoppedSet.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Start"
|
||||
enabled: !Containers.busy
|
||||
onClicked: Containers.start(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── containers no compose project claims ────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Containers.loose.length > 0
|
||||
title: "Not part of a project"
|
||||
subtitle: "Started directly rather than by a compose file."
|
||||
|
||||
Repeater {
|
||||
model: Containers.loose
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
|
||||
controlWidth: 180
|
||||
divider: index < Containers.loose.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Logs"
|
||||
onClicked: Containers.openLogs(String(modelData.name))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: modelData.state === "running" ? "Stop" : "Start"
|
||||
enabled: !Containers.busy
|
||||
onClicked: modelData.state === "running"
|
||||
? Containers.stop(String(modelData.name))
|
||||
: Containers.start(String(modelData.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Containers.scanned && Containers.available && Containers.total === 0
|
||||
label: "No containers"
|
||||
detail: "Nothing has been created on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── logs ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A drill-in rather than a panel inside the scrolling page: logs need their
|
||||
// own scrollback, and nesting one scrolling view inside another makes the
|
||||
// wheel ambiguous over the region where you most want to use it.
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: Containers.logTarget !== ""
|
||||
|
||||
Column {
|
||||
id: logHeader
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 34
|
||||
anchors.rightMargin: 34
|
||||
anchors.topMargin: 30
|
||||
spacing: 6
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
SettingsButton {
|
||||
text: "‹ Back"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: Containers.closeLogs()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Containers.logTarget
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 22
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Containers.logError !== ""
|
||||
? Containers.logError
|
||||
: (Containers.logFollowing
|
||||
? "Following. The last " + Containers.logLines.count + " lines are shown."
|
||||
: "The stream has ended.")
|
||||
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: logHeader.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 34
|
||||
anchors.rightMargin: 34
|
||||
anchors.topMargin: 14
|
||||
anchors.bottomMargin: 30
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.bgDark, 0.75)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
ListView {
|
||||
id: logList
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
clip: true
|
||||
model: Containers.logLines
|
||||
spacing: 1
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
cacheBuffer: 400
|
||||
|
||||
// Stay pinned to the newest line while the reader is already at
|
||||
// the bottom, and leave the view alone the moment they scroll up
|
||||
// to read something.
|
||||
property bool pinned: true
|
||||
onContentYChanged: logList.pinned =
|
||||
logList.contentY >= logList.contentHeight - logList.height - 24
|
||||
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
|
||||
|
||||
delegate: Text {
|
||||
required property string line
|
||||
width: logList.width - 24
|
||||
text: line
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WrapAnywhere
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
// because that genuinely is a Panama preference.
|
||||
//
|
||||
// Changing the timezone or network time needs privilege. timedatectl asks
|
||||
// polkit, and a cancelled dialog surfaces as an error rather than as a value
|
||||
// polkit, and a canceled dialog surfaces as an error rather than as a value
|
||||
// that appears to have been accepted.
|
||||
|
||||
import QtQuick
|
||||
@@ -33,7 +33,7 @@ SettingsPage {
|
||||
SettingRow {
|
||||
label: "Set automatically"
|
||||
detail: DateTime.ntpEnabled
|
||||
? (DateTime.ntpSynchronised ? "Synchronised with a time server" : "Waiting to synchronise")
|
||||
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
|
||||
: "The clock is set by hand"
|
||||
controlWidth: 48
|
||||
divider: false
|
||||
@@ -50,8 +50,16 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Timezone"
|
||||
subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone)
|
||||
+ ". Type to narrow the list."
|
||||
|
||||
PickerRow {
|
||||
id: zonePicker
|
||||
|
||||
label: "Time zone"
|
||||
detail: DateTime.timezone === ""
|
||||
? "Reading the system clock"
|
||||
: DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone)
|
||||
value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone)
|
||||
divider: false
|
||||
|
||||
SearchField {
|
||||
id: zoneSearch
|
||||
@@ -72,7 +80,10 @@ SettingsPage {
|
||||
controlWidth: 90
|
||||
divider: index < root.matchingZones.length - 1
|
||||
activatable: true
|
||||
onActivated: DateTime.setTimezone(modelData)
|
||||
onActivated: {
|
||||
DateTime.setTimezone(modelData);
|
||||
zonePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +94,7 @@ SettingsPage {
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: DateTime.lastError !== ""
|
||||
|
||||
@@ -8,21 +8,100 @@
|
||||
// them.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Turning the last screen off would leave no dock anywhere and no obvious
|
||||
// way back, so the final one cannot be removed -- it collapses to "every
|
||||
// screen" instead, which is the same thing on one display and recoverable
|
||||
// on several.
|
||||
function toggleDockScreen(name: string): void {
|
||||
const all = Quickshell.screens.map(screen => String(screen.name));
|
||||
const current = Settings.dockScreens.length === 0
|
||||
? all.slice()
|
||||
: Settings.dockScreens.map(String);
|
||||
const at = current.indexOf(name);
|
||||
let next = current.slice();
|
||||
if (at >= 0)
|
||||
next.splice(at, 1);
|
||||
else
|
||||
next.push(name);
|
||||
if (next.length === 0 || next.length === all.length)
|
||||
next = [];
|
||||
DesktopPreferences.set("dockScreens", next);
|
||||
}
|
||||
|
||||
title: "Desktop & Dock"
|
||||
lede: "Keep the shell instant, spatial, and out of your way."
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
|
||||
ChoiceRow { setting: "dockPosition" }
|
||||
|
||||
// One row per connected screen. Nothing selected means every screen,
|
||||
// which is stated rather than left as an empty list somebody has to
|
||||
// interpret -- and it is what a single-monitor machine should do
|
||||
// without being configured at all.
|
||||
SettingRow {
|
||||
label: "Screens"
|
||||
detail: Settings.dockScreens.length === 0
|
||||
? "On every display"
|
||||
: "On " + Settings.dockScreens.length + " of "
|
||||
+ Quickshell.screens.length + " displays"
|
||||
visible: Quickshell.screens.length > 1
|
||||
controlWidth: 260
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Rectangle {
|
||||
id: screenPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string screenName: String(screenPill.modelData.name ?? "")
|
||||
// An empty list means all, so every pill reads as on.
|
||||
readonly property bool on: Settings.dockScreens.length === 0
|
||||
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
|
||||
|
||||
width: pillLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: 8
|
||||
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: screenPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: screenPill.screenName
|
||||
color: screenPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockIconSize"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -62,7 +141,7 @@ SettingsPage {
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
action: "Open Appearance"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettings("appearance")
|
||||
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +201,71 @@ SettingsPage {
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
// Distinct from the snapshots below, which put THIS machine back as it
|
||||
// was. This carries settings to a different one, and deliberately leaves
|
||||
// behind anything that describes hardware.
|
||||
SettingsCard {
|
||||
title: "Carry settings to another machine"
|
||||
subtitle: SettingsSync.lastError !== ""
|
||||
? SettingsSync.lastError
|
||||
: "Everything except what describes this machine: the display arrangement stays here."
|
||||
|
||||
ActionRow {
|
||||
label: "Export"
|
||||
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
|
||||
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
|
||||
: "Writes " + SettingsSync.defaultPath
|
||||
action: "Export"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "See what an import would change"
|
||||
detail: SettingsSync.previewed
|
||||
? SettingsSync.changes.length + " would change, "
|
||||
+ SettingsSync.skipped.length + " skipped"
|
||||
: "Reads " + SettingsSync.defaultPath + " without applying anything"
|
||||
action: "Preview"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
// Only offered once a preview has said what it would do. Importing
|
||||
// settings sight unseen is how somebody ends up wondering why their
|
||||
// desktop changed.
|
||||
ActionRow {
|
||||
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
|
||||
label: "Apply those " + SettingsSync.changes.length + " changes"
|
||||
detail: "Settings the file does not mention are left alone"
|
||||
action: "Import"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: SettingsSync.previewed ? SettingsSync.skipped : []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.key ?? "")
|
||||
detail: "Skipped: " + String(modelData.reason ?? "")
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
|
||||
label: SettingsSync.applied === 0
|
||||
? "Nothing needed changing"
|
||||
: SettingsSync.applied + " settings applied"
|
||||
detail: "From " + (SettingsSync.exportedFrom || "the export")
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Snapshots"
|
||||
subtitle: SettingsBackup.lastError !== ""
|
||||
|
||||
@@ -50,7 +50,7 @@ Rectangle {
|
||||
|
||||
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||
// repaint forever behind a settings page.
|
||||
// Follows the colour scheme. A preview that stays dark while the shell
|
||||
// Follows the color scheme. A preview that stays dark while the shell
|
||||
// around it is light does not read as "your desktop" -- it reads as a
|
||||
// screenshot of someone else's.
|
||||
gradient: Gradient {
|
||||
@@ -129,7 +129,7 @@ Rectangle {
|
||||
required property bool focused
|
||||
|
||||
// The gradient border is drawn as a filled rounded rect with the window
|
||||
// body inset on top of it: QML's Rectangle border takes a colour, not a
|
||||
// body inset on top of it: QML's Rectangle border takes a color, not a
|
||||
// gradient, and the prism border is the whole signature here.
|
||||
Rectangle {
|
||||
id: frame
|
||||
|
||||
@@ -15,6 +15,9 @@ Column {
|
||||
property var monitor: null
|
||||
property bool enabled: true
|
||||
|
||||
// Emitted once a mode has been asked for, so a container can put the list
|
||||
// away. Applying is still this component's job; closing is not.
|
||||
signal picked
|
||||
spacing: 0
|
||||
|
||||
readonly property var grouped: {
|
||||
@@ -106,11 +109,14 @@ Column {
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !rate.selected
|
||||
onTapped: Displays.apply(
|
||||
onTapped: {
|
||||
Displays.apply(
|
||||
root.monitor.name,
|
||||
rate.modelData.mode,
|
||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||
root.monitor.transform)
|
||||
root.monitor.transform);
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@ SettingsPage {
|
||||
? root.monitor.mode
|
||||
: ""
|
||||
|
||||
// Every mode at the resolution in use, which is what a refresh-rate choice
|
||||
// actually is: the same width and height at a different rate.
|
||||
readonly property var ratesForCurrentResolution: {
|
||||
if (!root.monitor || !root.monitor.modes)
|
||||
return [];
|
||||
return root.monitor.modes.filter(mode => mode.width === root.monitor.width
|
||||
&& mode.height === root.monitor.height);
|
||||
}
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!Displays.monitorNamed(root.selectedOutput))
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
@@ -175,10 +184,61 @@ SettingsPage {
|
||||
title: "Resolution"
|
||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||
|
||||
PickerRow {
|
||||
id: modePicker
|
||||
|
||||
label: "Resolution"
|
||||
detail: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height + " native"
|
||||
: "No display selected"
|
||||
value: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
|
||||
DisplayModePicker {
|
||||
width: parent.width
|
||||
monitor: root.monitor
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: modePicker.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh rate on its own, because the rates for a resolution used to be
|
||||
// reachable only by opening the resolution list -- which is now
|
||||
// collapsed, so changing only the rate meant going through the mode you
|
||||
// already had.
|
||||
PickerRow {
|
||||
id: ratePicker
|
||||
|
||||
label: "Refresh rate"
|
||||
detail: "Rates this display offers at " + (root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: "the current resolution")
|
||||
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
|
||||
visible: root.ratesForCurrentResolution.length > 1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
|
||||
Repeater {
|
||||
model: root.ratesForCurrentResolution
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.refreshLabel ?? "")
|
||||
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.ratesForCurrentResolution.length - 1
|
||||
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
|
||||
onActivated: {
|
||||
root.applyWith({ mode: modelData.mode });
|
||||
ratePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,50 @@ Column {
|
||||
DesktopPreferences.set("dockPinned", next);
|
||||
}
|
||||
|
||||
// ── Dragging ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// By a grip rather than the whole row. The objection this file used to
|
||||
// record -- that dragging inside a Flickable inside a scrolling page is
|
||||
// hard to get right and fails in a way that reads as breakage -- is real,
|
||||
// and the answer is preventStealing on the grip: the Flickable cannot take
|
||||
// a gesture that started there, so a vertical drag reorders instead of
|
||||
// scrolling the page out from under it. The arrow buttons stay, because
|
||||
// they are the keyboard-reachable path and a grip is not.
|
||||
//
|
||||
// The order is held here while the drag runs and written once on release.
|
||||
// Committing on every slot crossed would rewrite settings.json a dozen
|
||||
// times for one gesture.
|
||||
property int draggingIndex: -1
|
||||
property var workingOrder: []
|
||||
|
||||
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
|
||||
|
||||
function beginDrag(index: int): void {
|
||||
root.workingOrder = root.pinned.slice();
|
||||
root.draggingIndex = index;
|
||||
}
|
||||
|
||||
function dragTo(target: int): void {
|
||||
if (root.draggingIndex < 0 || target === root.draggingIndex)
|
||||
return;
|
||||
if (target < 0 || target >= root.workingOrder.length)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
const moved = next.splice(root.draggingIndex, 1)[0];
|
||||
next.splice(target, 0, moved);
|
||||
root.workingOrder = next;
|
||||
root.draggingIndex = target;
|
||||
}
|
||||
|
||||
function endDrag(): void {
|
||||
if (root.draggingIndex < 0)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
root.draggingIndex = -1;
|
||||
root.workingOrder = [];
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function move(from: int, to: int): void {
|
||||
if (to < 0 || to >= root.pinned.length)
|
||||
return;
|
||||
@@ -67,7 +111,7 @@ Column {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.pinned
|
||||
model: root.displayed
|
||||
|
||||
SettingRow {
|
||||
id: pin
|
||||
@@ -76,15 +120,71 @@ Column {
|
||||
required property int index
|
||||
|
||||
label: root.nameFor(pin.modelData)
|
||||
detail: pin.modelData
|
||||
// The desktop id is shown only when the name alone would not say
|
||||
// which entry this is. It is developer text, and repeating it under
|
||||
// fifteen recognisable application names is noise that makes the
|
||||
// list harder to scan, not easier.
|
||||
detail: root.pinned.filter(other =>
|
||||
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
|
||||
? pin.modelData
|
||||
: ""
|
||||
divider: pin.index < root.pinned.length - 1
|
||||
controlWidth: 132
|
||||
|
||||
// Lifted while dragging so the row being moved is the one that
|
||||
// looks moved.
|
||||
z: root.draggingIndex === pin.index ? 2 : 0
|
||||
opacity: root.draggingIndex === pin.index ? 0.85 : 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
// The grip. preventStealing is the whole reason this works
|
||||
// inside a scrolling page: without it the Flickable claims the
|
||||
// vertical gesture and the row never moves.
|
||||
Item {
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u2261"
|
||||
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: grip
|
||||
anchors.fill: parent
|
||||
preventStealing: true
|
||||
cursorShape: Qt.SizeVerCursor
|
||||
|
||||
property real pressY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
grip.pressY = mouse.y;
|
||||
root.beginDrag(pin.index);
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (root.draggingIndex < 0 || pin.height <= 0)
|
||||
return;
|
||||
// How many whole rows the pointer has travelled from
|
||||
// where it started. Rounded, so the swap happens as
|
||||
// the grip passes the midpoint of the next row.
|
||||
const travelled = (mouse.y - grip.pressY);
|
||||
const slots = Math.round(travelled / pin.height);
|
||||
if (slots !== 0)
|
||||
root.dragTo(root.draggingIndex + slots);
|
||||
}
|
||||
onReleased: root.endDrag()
|
||||
onCanceled: root.endDrag()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "↑"
|
||||
enabled: pin.index > 0
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// The firewall, led by what another machine can actually reach.
|
||||
//
|
||||
// A rules list alone is not an answer: a port is reachable only when something
|
||||
// is listening on a network address AND the firewall permits it. On this
|
||||
// machine that crossing is the whole story -- the rules look unremarkable while
|
||||
// a database and a cache sit open, because Fedora Workstation's zone opens
|
||||
// every port above 1024 and rootless containers publish on all interfaces.
|
||||
//
|
||||
// Rich rules are shown but never edited. They are a syntax rather than a
|
||||
// setting, and a page that half-supports a syntax is a trap -- but hiding them
|
||||
// would mean the page misrepresents the configuration.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "firewall"
|
||||
title: "Firewall"
|
||||
lede: "What another machine on your network can reach, and what allows it."
|
||||
|
||||
property string confirmingRemoval: ""
|
||||
property bool confirmingRange: false
|
||||
|
||||
Component.onCompleted: Firewall.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Firewall.lastError !== ""
|
||||
label: "The firewall needs attention"
|
||||
detail: Firewall.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── The finding, when there is one ───────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Firewall.exposedDataStores.length > 0
|
||||
title: Firewall.exposedDataStores.length === 1
|
||||
? "A database is reachable from your network"
|
||||
: "Databases are reachable from your network"
|
||||
subtitle: {
|
||||
const names = Firewall.exposedDataStores.map(entry => String(entry.name));
|
||||
return names.join(" and ") + " "
|
||||
+ (names.length === 1 ? "is" : "are")
|
||||
+ " listening on every interface, and this zone permits it. Anyone on your network can connect.";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Firewall.exposedDataStores
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
|
||||
+ (String(modelData.process ?? "") !== ""
|
||||
? " · " + String(modelData.process) : "")
|
||||
+ " · allowed by " + String(modelData.allowedBy ?? "")
|
||||
value: ""
|
||||
divider: index < Firewall.exposedDataStores.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Everything reachable ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Reachable right now"
|
||||
subtitle: !Firewall.scanned
|
||||
? "Checking what is listening and what the firewall permits…"
|
||||
: (Firewall.available
|
||||
? "Listening on a network address, and permitted by the firewall. Both have to be true."
|
||||
: "The firewall is not running, so nothing here is being filtered.")
|
||||
|
||||
Repeater {
|
||||
model: Firewall.exposed
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Port " + modelData.port + "/" + String(modelData.protocol ?? "")
|
||||
+ (String(modelData.process ?? "") !== "" && String(modelData.process) !== String(modelData.name)
|
||||
? " · " + String(modelData.process) : "")
|
||||
+ " · allowed by " + String(modelData.allowedBy ?? "")
|
||||
value: String(modelData.kind ?? "") === "data" ? "Database" : ""
|
||||
divider: index < Firewall.exposed.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Firewall.exposed.length === 0 && Firewall.scanned && Firewall.available
|
||||
label: "Nothing is reachable"
|
||||
detail: "No service is both listening on a network address and permitted"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── The rules that allow it ──────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Firewall.zone !== null
|
||||
title: "What this zone allows"
|
||||
subtitle: Firewall.zone
|
||||
? String(Firewall.zone.name) + ", applied to "
|
||||
+ (Firewall.zone.interfaces ?? []).join(" and ")
|
||||
: ""
|
||||
|
||||
// The single rule that explains almost every row above.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: Firewall.wideOpen
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "Ports " + Firewall.openRanges.join(", ")
|
||||
detail: root.confirmingRange
|
||||
? "Closing this cuts off " + Firewall.rangeDependents().length
|
||||
+ " reachable service" + (Firewall.rangeDependents().length === 1 ? "" : "s")
|
||||
+ ", including " + Firewall.rangeDependents().slice(0, 3)
|
||||
.map(entry => String(entry.name)).join(", ")
|
||||
+ ". Anything that needs a port will have to be allowed by name."
|
||||
: "Fedora Workstation opens these so applications can listen without asking. It is why most of the list above is reachable."
|
||||
controlWidth: 230
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingRange ? "Keep it open" : "Close the range…"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: root.confirmingRange = !root.confirmingRange
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingRange
|
||||
text: "Close it"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
root.confirmingRange = false;
|
||||
for (const spec of Firewall.openRanges)
|
||||
Firewall.removePort(String(spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Firewall.zone?.services ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: serviceRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string serviceName: String(serviceRow.modelData)
|
||||
readonly property bool confirming: root.confirmingRemoval === serviceRow.serviceName
|
||||
// Removing ssh while someone is connected over it ends their
|
||||
// session. Worth saying before, not after.
|
||||
readonly property bool risky: serviceRow.serviceName === "ssh"
|
||||
&& Firewall.sshSessions > 0
|
||||
|
||||
width: parent.width
|
||||
label: serviceRow.serviceName
|
||||
detail: serviceRow.confirming
|
||||
? (serviceRow.risky
|
||||
? "Someone is connected over SSH right now. Removing this ends that session."
|
||||
: "Anything relying on this service stops being reachable.")
|
||||
: "Allowed by name, so it works whatever the port range says"
|
||||
controlWidth: 210
|
||||
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: serviceRow.confirming ? "Keep" : "Remove…"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: root.confirmingRemoval =
|
||||
serviceRow.confirming ? "" : serviceRow.serviceName
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: serviceRow.confirming
|
||||
text: "Remove"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
Firewall.removeService(serviceRow.serviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shown, never edited.
|
||||
TextRow {
|
||||
visible: (Firewall.zone?.richRules ?? []).length > 0
|
||||
label: "Rich rules"
|
||||
detail: "Custom rules in firewalld's own syntax. Shown here so this page does not misrepresent your configuration; edit them with firewall-cmd."
|
||||
value: (Firewall.zone?.richRules ?? []).length + " defined"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zones ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Zones"
|
||||
subtitle: "A zone is a set of rules. Each network connection uses one."
|
||||
|
||||
Repeater {
|
||||
model: Object.keys(Firewall.activeZones ?? ({}))
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData)
|
||||
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
|
||||
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Default for new connections"
|
||||
detail: "Used when a network does not ask for a particular zone"
|
||||
value: Firewall.defaultZone
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── The service underneath ───────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Firewall service"
|
||||
|
||||
TextRow {
|
||||
label: "firewalld"
|
||||
detail: !Firewall.scanned
|
||||
? "Reading the firewall's state…"
|
||||
: (Firewall.running
|
||||
? (Firewall.enabledAtBoot
|
||||
? "Running, and starts with the system"
|
||||
: "Running, but not started at boot")
|
||||
: "Not running, so nothing is being filtered")
|
||||
value: !Firewall.scanned ? "Checking…" : (Firewall.running ? "Running" : "Stopped")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Gaming: what the machine is doing now, and what it should do while you play.
|
||||
//
|
||||
// Live first, because unlike every other page here this one has a genuinely
|
||||
// live dimension -- "is Game Mode actually on, and how hot is the card" is the
|
||||
// question that brings someone here mid-session.
|
||||
//
|
||||
// The part that makes this Panama's page rather than a gamemode config editor
|
||||
// is "While a game is running": gamemode runs a script when a game starts and
|
||||
// another when it exits, so the power profile and Do Not Disturb can follow the
|
||||
// game and be put back afterwards -- back to what they were, not to a default.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "gaming"
|
||||
title: "Gaming"
|
||||
lede: "What this machine is doing, and how it should behave while you play."
|
||||
|
||||
readonly property var gpu: Gaming.primaryGpu
|
||||
|
||||
// Polling only while this page is on screen.
|
||||
Component.onCompleted: {
|
||||
Gaming.refresh();
|
||||
Gaming.watching = true;
|
||||
}
|
||||
Component.onDestruction: Gaming.watching = false
|
||||
|
||||
TextRow {
|
||||
visible: Gaming.lastError !== ""
|
||||
label: "Gaming needs attention"
|
||||
detail: Gaming.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Right now ────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: Gaming.active ? "Playing" : "Idle"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Gaming.active
|
||||
? "Game Mode is engaged"
|
||||
: "No game has requested Game Mode"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Gaming.gpus
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: modelData.discrete ? "Graphics card" : "Integrated graphics"
|
||||
detail: String(modelData.driver ?? "")
|
||||
+ (modelData.discrete ? " · the one games use" : " · idle unless something asks for it")
|
||||
value: Gaming.gpuSummary(modelData)
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "CPU governor"
|
||||
detail: "What the cores are scaling to right now"
|
||||
value: String(Gaming.gameMode?.governorNow ?? "unknown")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── What Panama does about it ────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "While a game is running"
|
||||
subtitle: Gaming.gameMode?.hooksInstalled === true
|
||||
? "Panama reacts when Game Mode engages, and puts everything back when the game exits."
|
||||
: "Panama can react when Game Mode engages. This needs a hook in gamemode's configuration."
|
||||
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled !== true
|
||||
label: "Let Panama react to games"
|
||||
detail: "Adds a start and end hook to your gamemode configuration. Nothing else in that file is touched."
|
||||
action: "Enable"
|
||||
enabled: !Gaming.busy && Gaming.gameMode?.available === true
|
||||
onTriggered: Gaming.installHooks()
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
setting: "gamingPerformanceProfile"
|
||||
}
|
||||
|
||||
// Silencing while a game runs is a focus mode with a trigger, and it
|
||||
// now lives with the others -- so "something silenced my notifications"
|
||||
// has one answer rather than two places to look.
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
label: "Silence notifications"
|
||||
detail: {
|
||||
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
||||
if (!mode)
|
||||
return "Handled by a focus mode on Notifications & Focus.";
|
||||
return mode.enabled === true
|
||||
? "Handled by the Gaming focus mode, which is on."
|
||||
: "The Gaming focus mode is off, so notifications are not silenced.";
|
||||
}
|
||||
action: "Open Focus"
|
||||
onTriggered: ShellState.settingsPage = "notifications"
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
setting: "gamingNotifyOnStart"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
label: "Stop reacting to games"
|
||||
detail: "Removes the hooks. The settings above are kept."
|
||||
action: "Disable"
|
||||
enabled: !Gaming.busy
|
||||
divider: false
|
||||
onTriggered: Gaming.removeHooks()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game Mode itself ─────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Game Mode"
|
||||
subtitle: Gaming.gameMode?.available === true
|
||||
? "Applied by gamemode to a game while it runs, then undone."
|
||||
: "gamemode is not installed."
|
||||
|
||||
TextRow {
|
||||
label: "Daemon"
|
||||
detail: Gaming.gameMode?.daemonRunning === true
|
||||
? "Running, waiting for a game to ask"
|
||||
: "Not running, so no game can request it"
|
||||
value: Gaming.gameMode?.daemonRunning === true ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
// Said plainly rather than implied: on a machine already running the
|
||||
// governor gamemode would switch to, its headline effect is nothing.
|
||||
TextRow {
|
||||
label: "Governor while gaming"
|
||||
detail: Gaming.governorAlreadyThere
|
||||
? "This machine already runs that governor, so Game Mode changes nothing here"
|
||||
: "Switched for the duration of the game"
|
||||
value: String(Gaming.gameMode?.governorWhileGaming ?? "unknown")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay ──────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Performance overlay"
|
||||
subtitle: Gaming.overlay?.installed === true
|
||||
? "MangoHud, drawn on top of the game."
|
||||
: "MangoHud is not installed."
|
||||
|
||||
SwitchRow {
|
||||
label: "Show the overlay in games"
|
||||
detail: "Takes effect for games launched after your next sign-in, because it is read from the session environment"
|
||||
checked: Gaming.overlay?.globallyEnabled === true
|
||||
enabled: !Gaming.busy && Gaming.overlay?.installed === true
|
||||
onToggled: value => Gaming.setOverlay(value)
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
label: "What it shows"
|
||||
detail: "Frame rate alone, or the full readout with GPU and CPU"
|
||||
options: [
|
||||
{ value: "fps", label: "Frame rate" },
|
||||
{ value: "detailed", label: "Detailed" }
|
||||
]
|
||||
value: String(Gaming.overlay?.preset ?? "fps")
|
||||
enabled: !Gaming.busy && Gaming.overlay?.installed === true
|
||||
onSelected: value => Gaming.setOverlayPreset(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Toggle in game"
|
||||
detail: "Shows and hides the overlay without leaving the game"
|
||||
value: String(Gaming.overlay?.toggleKey ?? "")
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library ──────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Library"
|
||||
subtitle: "Where games live, and what can run them."
|
||||
|
||||
TextRow {
|
||||
label: "Installed games"
|
||||
detail: String(Gaming.library?.path ?? "")
|
||||
value: String(Gaming.library?.games ?? 0)
|
||||
}
|
||||
|
||||
// Listed, not chosen. Steam picks the runtime per game in its own
|
||||
// properties, and a control here would be claiming an authority this
|
||||
// page does not have.
|
||||
Repeater {
|
||||
model: Gaming.library?.protonBuilds ?? []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: modelData.community === true
|
||||
? "Community build · chosen per game in Steam"
|
||||
: "Valve · chosen per game in Steam"
|
||||
value: "Installed"
|
||||
divider: true
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "gamescope"
|
||||
detail: "Micro-compositor for scaling and frame limiting, used per game from Steam"
|
||||
value: Gaming.library?.gamescope === true ? "Available" : "Not installed"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,27 +422,11 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:users"
|
||||
label: "Users"
|
||||
detail: "Accounts, passwords, and automatic login"
|
||||
action: "Open users"
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:sharing"
|
||||
label: "Sharing"
|
||||
detail: "Remote desktop, media sharing, and remote login"
|
||||
action: "Open sharing"
|
||||
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
objectName: "health-fedora-handoff:color"
|
||||
label: "Colour profiles"
|
||||
detail: "ICC profiles for displays, printers, and scanners"
|
||||
action: "Open colour"
|
||||
label: "Color profiles"
|
||||
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
|
||||
action: "Open color"
|
||||
onTriggered: SystemSettings.openGnomePanel("color")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
// Home — the page you open to do something, not to read a report.
|
||||
//
|
||||
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
|
||||
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
|
||||
// thing on the screen, and nobody has ever needed it here. Below it sat a
|
||||
// permanently open search field for a weather location that is set about once a
|
||||
// year, and then roughly half a page of nothing.
|
||||
//
|
||||
// What Panama already knows is the useful material: whether anything needs
|
||||
// attention, what is running, and the two or three things worth doing next. A
|
||||
// finding is shown when there is one and the page is quiet when there is not,
|
||||
// which is the same shape the Firewall and Containers pages use.
|
||||
//
|
||||
// Weather stays. It is the one genuinely ambient thing here, and it belongs
|
||||
// next to a greeting rather than in a card of its own with a search box open.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
@@ -6,119 +22,101 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
readonly property int openedHour: new Date().getHours()
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
readonly property string greeting: openedHour < 12
|
||||
? "Good morning"
|
||||
: (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
// Findings, in the order they deserve attention. Each names the page that
|
||||
// can actually resolve it, because a home page that reports a problem it
|
||||
// cannot help with is just an alarm.
|
||||
readonly property var findings: {
|
||||
const found = [];
|
||||
const exposed = Firewall.exposedDataStores ?? [];
|
||||
if (exposed.length > 0) {
|
||||
found.push({
|
||||
label: exposed.length === 1
|
||||
? "A database is reachable from your network"
|
||||
: "Databases are reachable from your network",
|
||||
detail: exposed.map(entry => String(entry.name)).join(" and ")
|
||||
+ ", published on every interface",
|
||||
page: "firewall",
|
||||
action: "Review"
|
||||
});
|
||||
}
|
||||
if (Updates.securityCount > 0) {
|
||||
found.push({
|
||||
label: Updates.securityCount + " update"
|
||||
+ (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory",
|
||||
detail: "Worth installing before the rest",
|
||||
page: "updates",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
if (Updates.rebootNeeded) {
|
||||
found.push({
|
||||
label: "A newer kernel is installed than the one running",
|
||||
detail: "Restart to use it",
|
||||
page: "updates",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
|
||||
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
|
||||
found.push({
|
||||
label: count + " health check" + (count === 1 ? "" : "s") + " need attention",
|
||||
detail: "Something the desktop owns is not in its expected state",
|
||||
page: "services",
|
||||
action: "Open"
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// One line of everything that is fine, so the quiet case still says
|
||||
// something rather than showing an empty page.
|
||||
readonly property string reassurance: {
|
||||
const parts = [];
|
||||
if (Health.checks.length > 0)
|
||||
parts.push(Health.checks.length + " health checks pass");
|
||||
if (Disks.rootFilesystem)
|
||||
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free");
|
||||
const newest = Snapshots.configs.length > 0
|
||||
? (Snapshots.configs[0].snapshots ?? [])[0]
|
||||
: null;
|
||||
if (newest)
|
||||
parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? "")));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// snapper prints a full date; only the time is wanted in a summary line.
|
||||
function clockOf(stamp: string): string {
|
||||
const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp);
|
||||
if (!match)
|
||||
return stamp;
|
||||
return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1];
|
||||
}
|
||||
|
||||
title: `${root.greeting}, Gabriel`
|
||||
lede: "Your desktop is configured and ready."
|
||||
lede: Weather.available
|
||||
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
|
||||
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
|
||||
: "Your desktop is configured and ready."
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||
Component.onCompleted: {
|
||||
if (!Firewall.scanned)
|
||||
Firewall.refresh();
|
||||
if (!Containers.scanned)
|
||||
Containers.refresh();
|
||||
if (!Snapshots.scanned)
|
||||
Snapshots.refresh();
|
||||
if (!Disks.scanned)
|
||||
Disks.refresh();
|
||||
}
|
||||
|
||||
// ── What you came here to do ────────────────────────────────────────────
|
||||
|
||||
Grid {
|
||||
id: monitorLayout
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 620 ? 2 : 1
|
||||
columnSpacing: 28
|
||||
rowSpacing: 16
|
||||
|
||||
Item {
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: 164
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(parent.width - 24, 260)
|
||||
height: width * 0.64
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 5
|
||||
radius: 12
|
||||
color: Theme.alpha(Theme.bgDark, 0.94)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.34)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
radius: 7
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
|
||||
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: SystemSettings.monitorName || "DISPLAY"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||
spacing: 13
|
||||
|
||||
Text {
|
||||
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
|
||||
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
TextRow {
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name below is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
}
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
id: doing
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
@@ -126,20 +124,46 @@ SettingsPage {
|
||||
rowSpacing: 16
|
||||
|
||||
SettingsCard {
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Quiet focus"
|
||||
subtitle: "Notifications and focused work"
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Focus"
|
||||
subtitle: FocusSession.active
|
||||
? "A session is running · " + FocusSession.remainingText + " left"
|
||||
: (FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason
|
||||
: (Notifs.doNotDisturb
|
||||
? "Do Not Disturb is on"
|
||||
: "Nothing is quieting this machine"))
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? "End the session" : "Start a focus session"
|
||||
detail: FocusSession.active
|
||||
? "Puts Do Not Disturb and Caffeine back as they were"
|
||||
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.active ? "End" : "Start"
|
||||
tone: FocusSession.active ? "normal" : "accent"
|
||||
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
|
||||
detail: FocusModes.active
|
||||
? "Held on by the " + FocusModes.activeName + " mode"
|
||||
: "Banners are held; the notification centre still fills"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !FocusModes.active && !FocusSession.active
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
@@ -147,18 +171,119 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Desktop services"
|
||||
subtitle: "The essentials are running"
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Right now"
|
||||
subtitle: Containers.running > 0
|
||||
? Containers.running + " of " + Containers.total + " containers running"
|
||||
: "No containers are running"
|
||||
|
||||
TextRow {
|
||||
label: "Sync & remote access"
|
||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||
label: Containers.running > 0
|
||||
? Containers.projects.filter(project => project.running > 0)
|
||||
.map(project => String(project.title)).join(", ")
|
||||
: "Nothing to report"
|
||||
detail: Containers.running > 0
|
||||
? "Started for development, still up"
|
||||
: "Start a stack from the Containers page"
|
||||
value: ""
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Containers"
|
||||
detail: "Projects, what they expose, and what they cost"
|
||||
action: "Open"
|
||||
divider: false
|
||||
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── This machine ────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "This machine"
|
||||
subtitle: root.reassurance
|
||||
|
||||
TextRow {
|
||||
visible: root.findings.length === 0
|
||||
label: "Nothing needs your attention"
|
||||
detail: "Anything worth acting on would appear here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.findings
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.label ?? "")
|
||||
detail: String(modelData.detail ?? "")
|
||||
controlWidth: 110
|
||||
divider: index < root.findings.length - 1
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(modelData.action ?? "Open")
|
||||
onClicked: ShellState.settingsPage = String(modelData.page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worth doing, when there is something ────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Updates.total > 0 || Containers.reclaimable > 0
|
||||
title: "Do next"
|
||||
subtitle: "Small things this machine is waiting on."
|
||||
|
||||
ActionRow {
|
||||
visible: Updates.total > 0
|
||||
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
|
||||
detail: "Packages, applications and firmware, from wherever each comes from"
|
||||
action: "Open"
|
||||
divider: Containers.reclaimable > 0
|
||||
onTriggered: ShellState.settingsPage = "updates"
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: Containers.reclaimable > 0
|
||||
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
|
||||
detail: "Container images and volumes nothing references"
|
||||
action: "Review"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Shown above and in the date menu."
|
||||
|
||||
// The location is set roughly once a year, so the search that changes it
|
||||
// is behind a press rather than permanently open.
|
||||
PickerRow {
|
||||
id: locationPicker
|
||||
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
onPicked: locationPicker.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,14 @@ SettingsPage {
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
// The entity list is a one-time migration seed for the Control Center
|
||||
// selection, not the light catalog -- the helper discovers that live from
|
||||
// Home Assistant. It is passed back unchanged so that saving a URL or a
|
||||
// token cannot disturb the seed.
|
||||
function saveHomeAssistantConfig(): void {
|
||||
HomeAssistantConfig.save(
|
||||
homeUrlInput.text,
|
||||
homeEntitiesInput.text,
|
||||
HomeAssistantConfig.entities.join(", "),
|
||||
homeTokenInput.text
|
||||
);
|
||||
}
|
||||
@@ -57,7 +61,6 @@ SettingsPage {
|
||||
function onConfigurationSaved(): void {
|
||||
homeTokenInput.clear();
|
||||
homeUrlInput.text = HomeAssistantConfig.url;
|
||||
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,65 +133,6 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
topPadding: 10
|
||||
bottomPadding: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Light entities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 72
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||
border.color: homeEntitiesInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
TextEdit {
|
||||
id: homeEntitiesInput
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.entities.join(", ")
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: TextEdit.Wrap
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeEntitiesInput.text === ""
|
||||
text: "light.living_room, light.kitchen"
|
||||
color: Theme.fgMuted
|
||||
font: homeEntitiesInput.font
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: HomeAssistantConfig.lastError !== ""
|
||||
|
||||
@@ -12,6 +12,10 @@ import qs.modules.clipboard
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Emitted once a place has been chosen, so a container can put the search
|
||||
// away. Choosing is still this component's job; closing is not.
|
||||
signal picked
|
||||
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
@@ -37,8 +41,10 @@ Column {
|
||||
divider: place.index < Geocoding.results.length - 1
|
||||
activatable: true
|
||||
onActivated: {
|
||||
if (Geocoding.choose(place.modelData))
|
||||
if (Geocoding.choose(place.modelData)) {
|
||||
query.text = "";
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ SettingsPage {
|
||||
|
||||
title: "Mouse & Touchpad"
|
||||
lede: InputDevices.hasTouchpad
|
||||
? "Pointer behaviour for your mouse and touchpad."
|
||||
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||
? "Pointer behavior for your mouse and touchpad."
|
||||
: "Pointer behavior. Touchpad settings appear when a touchpad is attached."
|
||||
|
||||
SettingsCard {
|
||||
title: "Mouse"
|
||||
|
||||
@@ -3,6 +3,52 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Only one application is expanded at a time; the point is a card you
|
||||
// can read, not twenty open at once.
|
||||
property string expandedApp: ""
|
||||
|
||||
// Which focus mode is open for editing. One at a time, like the app rules.
|
||||
property string expandedMode: ""
|
||||
|
||||
// Which mode's exception list is open. Separate from expandedMode so the
|
||||
// list does not unfold every time a mode is opened to change a switch.
|
||||
property string expandedAllow: ""
|
||||
|
||||
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
|
||||
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
|
||||
const at = current.indexOf(appId);
|
||||
if (allowed && at < 0)
|
||||
current.push(appId);
|
||||
else if (!allowed && at >= 0)
|
||||
current.splice(at, 1);
|
||||
FocusModes.update(String(mode.id), { allow: current });
|
||||
}
|
||||
|
||||
// Schedule edits go through the mode's trigger list rather than replacing
|
||||
// it, so a mode that also has a game or fullscreen trigger keeps it.
|
||||
function reschedule(mode: var, changes: var): void {
|
||||
FocusModes.update(String(mode.id), {
|
||||
triggers: (mode.triggers ?? []).map(trigger =>
|
||||
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDay(mode: var, day: int): void {
|
||||
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
||||
if (!schedule)
|
||||
return;
|
||||
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
|
||||
const at = days.indexOf(day);
|
||||
if (at >= 0)
|
||||
days.splice(at, 1);
|
||||
else
|
||||
days.push(day);
|
||||
days.sort((a, b) => a - b);
|
||||
root.reschedule(mode, { days: days });
|
||||
}
|
||||
|
||||
title: "Notifications & Focus"
|
||||
lede: "Control interruptions without losing useful history."
|
||||
|
||||
@@ -25,7 +71,7 @@ SettingsPage {
|
||||
TextRow {
|
||||
label: "Notification history"
|
||||
detail: "Live notifications retained by the shell"
|
||||
value: `${Notifs.history.length} items`
|
||||
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
@@ -65,26 +111,65 @@ SettingsPage {
|
||||
model: Notifs.applications
|
||||
|
||||
Column {
|
||||
id: appEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var app: modelData
|
||||
readonly property var app: appEntry.modelData
|
||||
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
||||
readonly property bool open: root.expandedApp === String(appEntry.app.id)
|
||||
|
||||
// What the two lock-screen switches add up to, so the common
|
||||
// case -- reading rather than changing -- needs no interaction.
|
||||
// Every app repeated both switch labels verbatim before this,
|
||||
// which made twenty apps sixty rows of identical sentences.
|
||||
readonly property string summary: {
|
||||
if (!appEntry.rule.enabled)
|
||||
return "Notifications off";
|
||||
if (!appEntry.rule.showOnLockScreen)
|
||||
return "On · hidden on the lock screen";
|
||||
return appEntry.rule.showContentOnLockScreen
|
||||
? "On · lock screen shows content"
|
||||
: "On · lock screen shows the sender only";
|
||||
}
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
label: app.name
|
||||
detail: app.id
|
||||
controlWidth: 48
|
||||
width: parent.width
|
||||
label: appEntry.app.name
|
||||
// The identifier is only worth the space while the app is
|
||||
// open, which is the only time it disambiguates anything.
|
||||
detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary
|
||||
activatable: true
|
||||
divider: !appEntry.open
|
||||
controlWidth: 92
|
||||
onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id)
|
||||
|
||||
SettingsToggle {
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).enabled
|
||||
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
||||
spacing: 11
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: appEntry.rule.enabled
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: appEntry.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: appEntry.open
|
||||
label: "Show on lock screen"
|
||||
detail: "Allow this app's notifications on the lock screen"
|
||||
controlWidth: 48
|
||||
@@ -92,22 +177,264 @@ SettingsPage {
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
||||
checked: appEntry.rule.showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: appEntry.open
|
||||
label: "Show content on lock screen"
|
||||
detail: "Show message details when this app is visible there"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
// Meaningless unless the app reaches the lock screen at all.
|
||||
opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: appEntry.rule.showOnLockScreen
|
||||
checked: appEntry.rule.showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus modes"
|
||||
subtitle: FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
||||
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
||||
|
||||
Repeater {
|
||||
model: FocusModes.modes
|
||||
|
||||
delegate: Column {
|
||||
id: modeEntry
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var mode: modeEntry.modelData
|
||||
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
||||
readonly property bool open: root.expandedMode === modeEntry.modeId
|
||||
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
||||
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
||||
.find(trigger => trigger.kind === "schedule") ?? null
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: String(modeEntry.mode.name ?? "")
|
||||
detail: modeEntry.running
|
||||
? "On now — " + FocusModes.activeReason
|
||||
: FocusModes.summary(modeEntry.mode)
|
||||
activatable: true
|
||||
divider: !modeEntry.open
|
||||
controlWidth: 92
|
||||
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 11
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.enabled === true
|
||||
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Silence notifications"
|
||||
detail: "Banners are held until the mode ends"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
||||
checked: modeEntry.mode.silence === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Keep the screen awake"
|
||||
detail: "Caffeine, for as long as the mode is on"
|
||||
controlWidth: 48
|
||||
divider: modeEntry.schedule !== null
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.keepAwake === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
||||
}
|
||||
}
|
||||
|
||||
// Only modes that actually have a schedule get the schedule
|
||||
// controls; a game trigger has no times to set.
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "From"
|
||||
detail: "24-hour, such as 23:30"
|
||||
text: String(modeEntry.schedule?.start ?? "")
|
||||
placeholder: "23:30"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "Until"
|
||||
detail: "A time earlier than the start means the window crosses midnight"
|
||||
text: String(modeEntry.schedule?.end ?? "")
|
||||
placeholder: "07:00"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.mode.silence === true
|
||||
label: "May interrupt"
|
||||
detail: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing gets through while this mode is on"
|
||||
: "Everything else is held until the mode ends"
|
||||
activatable: true
|
||||
controlWidth: 150
|
||||
onActivated: root.expandedAllow =
|
||||
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing"
|
||||
: (modeEntry.mode.allow ?? []).length + " apps"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn from the applications that have actually sent a
|
||||
// notification, which is the same list the rules above use --
|
||||
// an exception for something that never notifies is not a
|
||||
// choice worth offering.
|
||||
Repeater {
|
||||
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
||||
? Notifs.applications
|
||||
: []
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.id ?? "")
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
||||
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
||||
&& Notifs.applications.length === 0
|
||||
label: "No applications yet"
|
||||
detail: "They appear here once they have sent a notification."
|
||||
value: ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "On these days"
|
||||
detail: "A window that crosses midnight belongs to the day it starts on"
|
||||
controlWidth: 250
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
||||
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
||||
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
||||
{ value: 0, label: "S" }
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
id: dayPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool on:
|
||||
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
||||
|
||||
width: 30
|
||||
height: 28
|
||||
radius: 8
|
||||
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: dayPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: String(dayPill.modelData.label)
|
||||
color: dayPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler {
|
||||
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// quietly stops syncing for weeks.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
@@ -57,6 +58,22 @@ SettingsPage {
|
||||
id: accountCard
|
||||
required property var modelData
|
||||
|
||||
// GOA hands back a preference-ordered chain; this walks it and takes
|
||||
// the first name the active icon theme actually has. Taking the
|
||||
// first name blindly, or the last as a fallback, both looked right
|
||||
// and were not: on Adwaita the tail of these chains ("mail",
|
||||
// "goa-symbolic") does not exist, so a miss would have rendered
|
||||
// nothing at all.
|
||||
icon: {
|
||||
const chain = accountCard.modelData.providerIcons ?? [];
|
||||
for (const name of chain) {
|
||||
if (Quickshell.iconPath(String(name), true) !== "")
|
||||
return String(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
iconFallback: "avatar-default-symbolic"
|
||||
|
||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||
subtitle: accountCard.modelData.needsAttention
|
||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// A row whose trailing control is a masked password field.
|
||||
//
|
||||
// Reports every keystroke rather than committing on Enter, because the page it
|
||||
// serves has to compare two fields as they are typed and say whether they match
|
||||
// before offering to set anything.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string placeholder: "Password"
|
||||
|
||||
signal changed(value: string)
|
||||
|
||||
controlWidth: 220
|
||||
|
||||
PasswordField {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
placeholder: root.placeholder
|
||||
onTextChanged: root.changed(this.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// A setting whose value is chosen from far more options than belong on screen.
|
||||
//
|
||||
// PickerRow {
|
||||
// label: "Language"
|
||||
// value: SystemLocale.currentLabel
|
||||
// SearchPicker { items: SystemLocale.locales; onPicked: ... }
|
||||
// }
|
||||
//
|
||||
// Three pages rendered an entire dataset as rows -- every installed locale, the
|
||||
// whole tz database, every mode a monitor advertises -- so the one line telling
|
||||
// you what is currently set was buried under hundreds that were not. The chooser
|
||||
// itself was never the problem and is unchanged: this only collapses it behind
|
||||
// the current value, which is what a person came to the page to read.
|
||||
//
|
||||
// Collapsed by default, and closes again once something is picked, so the page
|
||||
// returns to being readable rather than staying open on a list nobody needs any
|
||||
// more.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string detail: ""
|
||||
// What is set right now. Shown on the collapsed row, so the common case --
|
||||
// looking rather than changing -- needs no interaction at all.
|
||||
property string value: ""
|
||||
property bool enabled: true
|
||||
property bool divider: true
|
||||
property bool expanded: false
|
||||
|
||||
default property alias content: body.data
|
||||
|
||||
// Pages call this from their picker's onPicked, so choosing something puts
|
||||
// the list away instead of leaving it open over the rest of the page.
|
||||
function collapse(): void { root.expanded = false; }
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: root.label
|
||||
detail: root.detail
|
||||
activatable: root.enabled
|
||||
divider: root.divider && !root.expanded
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
controlWidth: 210
|
||||
onActivated: root.expanded = !root.expanded
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.value
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
width: parent.width
|
||||
visible: root.expanded
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ SettingsPage {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Idle behaviour"
|
||||
title: "Idle behavior"
|
||||
subtitle: IdleLock.managed
|
||||
? "Idle timings are managed here. Changes take effect immediately."
|
||||
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// Printers, and one queue across all of them.
|
||||
//
|
||||
// The list answers "which printers exist"; the queue answers "where is my
|
||||
// document", which is the question that actually brings someone here. They are
|
||||
// separate cards because they are separate questions -- a job that has not come
|
||||
// out is not necessarily a problem with the printer it was sent to.
|
||||
//
|
||||
// Driverless only. A printer old enough to need a PPD is named as such rather
|
||||
// than offered and then failing at the moment it is added.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "printers"
|
||||
title: "Printers"
|
||||
lede: "Printers this machine can use, and what they are waiting on."
|
||||
|
||||
property string expandedPrinter: ""
|
||||
property string confirmingRemoval: ""
|
||||
property bool addingByAddress: false
|
||||
property string manualUri: ""
|
||||
property string manualName: ""
|
||||
|
||||
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
|
||||
&& root.manualName.trim() !== ""
|
||||
|
||||
Component.onCompleted: Printers.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Printers.lastError !== ""
|
||||
label: "Printing needs attention"
|
||||
detail: Printers.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Which printers exist ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.anyPrinters
|
||||
title: "Printers"
|
||||
subtitle: "Open one to change what it does by default."
|
||||
|
||||
Repeater {
|
||||
model: Printers.printers
|
||||
|
||||
delegate: Column {
|
||||
id: printerBlock
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string name: String(printerBlock.modelData.name ?? "")
|
||||
readonly property bool open: root.expandedPrinter === printerBlock.name
|
||||
readonly property bool paused: String(printerBlock.modelData.state ?? "") === "stopped"
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: printerBlock.width
|
||||
icon: printerBlock.paused ? "\u{F0026}" : "\u{F042A}"
|
||||
label: String(printerBlock.modelData.description ?? printerBlock.name)
|
||||
+ (printerBlock.modelData.isDefault ? " · Default" : "")
|
||||
detail: Printers.stateSummary(printerBlock.modelData)
|
||||
+ " · " + String(printerBlock.modelData.uri ?? "")
|
||||
value: Printers.jobsFor(printerBlock.name) + " job"
|
||||
+ (Printers.jobsFor(printerBlock.name) === 1 ? "" : "s")
|
||||
activatable: !Printers.busy
|
||||
divider: !printerBlock.open
|
||||
&& printerBlock.index < Printers.printers.length - 1
|
||||
onActivated: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedPrinter = printerBlock.open ? "" : printerBlock.name;
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: printerBlock.width
|
||||
visible: printerBlock.open
|
||||
|
||||
TextRow {
|
||||
width: printerBlock.width
|
||||
label: "Model"
|
||||
detail: "Reported by the printer itself"
|
||||
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
visible: !printerBlock.modelData.isDefault
|
||||
label: "Use by default"
|
||||
detail: "Applications print here unless they are told otherwise"
|
||||
action: "Make default"
|
||||
enabled: !Printers.busy
|
||||
onTriggered: Printers.setDefault(printerBlock.name)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
label: printerBlock.paused ? "Resume printing" : "Pause printing"
|
||||
detail: printerBlock.paused
|
||||
? "Queued jobs start again"
|
||||
: "Jobs keep queuing, but nothing is sent until it resumes"
|
||||
action: printerBlock.paused ? "Resume" : "Pause"
|
||||
enabled: !Printers.busy
|
||||
onTriggered: printerBlock.paused
|
||||
? Printers.resume(printerBlock.name)
|
||||
: Printers.pause(printerBlock.name)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
label: "Print a test page"
|
||||
detail: "Confirms the printer answers and puts ink on paper"
|
||||
action: "Print"
|
||||
enabled: !Printers.busy && !printerBlock.paused
|
||||
onTriggered: Printers.testPage(printerBlock.name)
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: printerBlock.width
|
||||
label: "Remove this printer"
|
||||
detail: root.confirmingRemoval === printerBlock.name
|
||||
? "Anything still queued for it is cancelled."
|
||||
: "It can be added again later"
|
||||
controlWidth: 200
|
||||
divider: printerBlock.index < Printers.printers.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.confirmingRemoval === printerBlock.name ? "Keep" : "Remove…"
|
||||
enabled: !Printers.busy
|
||||
onClicked: root.confirmingRemoval =
|
||||
root.confirmingRemoval === printerBlock.name ? "" : printerBlock.name
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: root.confirmingRemoval === printerBlock.name
|
||||
text: "Remove"
|
||||
tone: "danger"
|
||||
enabled: !Printers.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedPrinter = "";
|
||||
Printers.remove(printerBlock.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Where my document is ─────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.anyPrinters
|
||||
title: "Print queue"
|
||||
subtitle: Printers.jobs.length === 0
|
||||
? "Nothing is waiting."
|
||||
: "Everything waiting, across every printer."
|
||||
|
||||
Repeater {
|
||||
model: Printers.jobs
|
||||
|
||||
delegate: ActionRow {
|
||||
id: jobRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(jobRow.modelData.name ?? "Untitled")
|
||||
detail: String(jobRow.modelData.printer ?? "") + " · "
|
||||
+ String(jobRow.modelData.state ?? "")
|
||||
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
||||
? " · " + jobRow.modelData.pages + " pages" : "")
|
||||
action: "Cancel"
|
||||
enabled: !Printers.busy
|
||||
divider: jobRow.index < Printers.jobs.length - 1
|
||||
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Printers.jobs.length === 0
|
||||
label: "Queue is empty"
|
||||
detail: "Jobs appear here while they wait, and can be cancelled from here"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nothing set up yet ───────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.scanned && !Printers.anyPrinters
|
||||
title: "No printers yet"
|
||||
subtitle: "Printers that announce themselves on your network appear here on their own."
|
||||
|
||||
ActionRow {
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " found"
|
||||
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
divider: false
|
||||
onTriggered: Printers.search()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adding ───────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Add a printer"
|
||||
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
||||
|
||||
ActionRow {
|
||||
visible: Printers.anyPrinters
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " printer"
|
||||
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
|
||||
: "Looks for printers over mDNS")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
onTriggered: Printers.search()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Printers.addable
|
||||
|
||||
delegate: ActionRow {
|
||||
id: foundRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: String(foundRow.modelData.name ?? "")
|
||||
detail: foundRow.modelData.driverless === true
|
||||
? String(foundRow.modelData.uri ?? "") + " · driverless"
|
||||
: String(foundRow.modelData.uri ?? "") + " · needs a manufacturer driver"
|
||||
action: foundRow.modelData.driverless === true ? "Add" : "Not supported"
|
||||
enabled: !Printers.busy && foundRow.modelData.driverless === true
|
||||
onTriggered: Printers.add(String(foundRow.modelData.uri ?? ""),
|
||||
Printers.suggestedName(String(foundRow.modelData.name ?? "")))
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Printers.searched && Printers.addable.length === 0 && !Printers.searching
|
||||
label: "Nothing found"
|
||||
detail: "No printer announced itself. It may be asleep, on another network, or may not support network discovery."
|
||||
value: ""
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Add by address"
|
||||
detail: "For a printer that does not announce itself"
|
||||
action: root.addingByAddress ? "Cancel" : "Enter…"
|
||||
enabled: !Printers.busy
|
||||
divider: root.addingByAddress
|
||||
onTriggered: {
|
||||
root.addingByAddress = !root.addingByAddress;
|
||||
root.manualUri = "";
|
||||
root.manualName = "";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.addingByAddress
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Address"
|
||||
detail: "ipp://, ipps://, or socket:// followed by the printer's host"
|
||||
placeholder: "ipp://printer.local/ipp/print"
|
||||
text: root.manualUri
|
||||
onAccepted: value => root.manualUri = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Name"
|
||||
detail: "What this printer is called on this machine"
|
||||
placeholder: "office-printer"
|
||||
text: root.manualName
|
||||
onAccepted: value => root.manualName = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Add this printer"
|
||||
detail: "The printer is asked what it can do; if it cannot answer, it is not added"
|
||||
action: "Add"
|
||||
enabled: root.manualReady && !Printers.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
Printers.add(root.manualUri, Printers.suggestedName(root.manualName));
|
||||
root.addingByAddress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The service underneath ───────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Printing service"
|
||||
subtitle: "What has to be running for any of this to work."
|
||||
|
||||
TextRow {
|
||||
label: "CUPS"
|
||||
detail: Printers.service?.running === true
|
||||
? "Running, so a printer added here works immediately"
|
||||
: "Not running, so nothing can print"
|
||||
value: Printers.service?.running === true ? "Running" : "Stopped"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Starts at boot"
|
||||
detail: Printers.service?.startsAtBoot === true
|
||||
? "Started with the system"
|
||||
: "Started on demand, when something prints. This is a normal configuration."
|
||||
value: Printers.service?.startsAtBoot === true ? "Yes" : "On demand"
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Network discovery"
|
||||
detail: Printers.service?.discoveryAvailable === true
|
||||
? "Avahi is running, which is what finds printers that announce themselves"
|
||||
: "Avahi is not running, so network printers cannot be discovered"
|
||||
value: Printers.service?.discoveryAvailable === true ? "Available" : "Unavailable"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,25 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Privacy & Security"
|
||||
|
||||
// The stored-secret list is collapsed until asked for, and one item at a
|
||||
// time can be waiting on a confirmed Forget.
|
||||
property bool showingSecrets: false
|
||||
property string confirmingPath: ""
|
||||
|
||||
// What a stored secret is FOR, from its attributes. Never its value.
|
||||
function describe(item: var): string {
|
||||
const attributes = item?.attributes ?? {};
|
||||
const parts = [];
|
||||
for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) {
|
||||
if (attributes[key])
|
||||
parts.push(String(attributes[key]));
|
||||
}
|
||||
if (parts.length > 0)
|
||||
return parts.join(" · ");
|
||||
const schema = String(item?.schema ?? "");
|
||||
return schema !== "" ? schema : "No further detail stored";
|
||||
}
|
||||
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||
@@ -28,6 +47,8 @@ SettingsPage {
|
||||
DeviceSecurity.refresh();
|
||||
if (!Keyring.scanned)
|
||||
Keyring.refresh();
|
||||
if (!Permissions.scanned)
|
||||
Permissions.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -96,6 +117,140 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── What is actually stored ──────────────────────────────────────────────
|
||||
// Collapsed until asked. Opening the Privacy page should not enumerate
|
||||
// someone's saved passwords as a side effect, and the list is long enough
|
||||
// that it would bury every other setting on the page.
|
||||
SettingsCard {
|
||||
visible: Keyring.scanned && Keyring.available && !Keyring.locked
|
||||
title: "Stored secrets"
|
||||
subtitle: Keyring.listed
|
||||
? "Passwords and tokens applications have saved. The values are never shown here."
|
||||
: "Passwords and tokens applications have saved, listed only when you ask."
|
||||
|
||||
ActionRow {
|
||||
label: "Saved items"
|
||||
detail: Keyring.listed
|
||||
? Keyring.storedCount + " stored across "
|
||||
+ Keyring.collections.length + " keyring"
|
||||
+ (Keyring.collections.length === 1 ? "" : "s")
|
||||
: "Read the keyring and list what is in it"
|
||||
action: root.showingSecrets
|
||||
? "Hide"
|
||||
: (Keyring.listing ? "Reading…" : "Show")
|
||||
enabled: !Keyring.listing
|
||||
divider: root.showingSecrets
|
||||
onTriggered: {
|
||||
if (root.showingSecrets) {
|
||||
root.showingSecrets = false;
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
root.showingSecrets = true;
|
||||
if (!Keyring.listed)
|
||||
Keyring.list();
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.copiedPath !== ""
|
||||
label: "Copied to the clipboard"
|
||||
detail: "It clears itself in about a minute, unless you copy something else first."
|
||||
value: ""
|
||||
divider: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
|
||||
|
||||
delegate: Column {
|
||||
id: collectionBlock
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: collectionBlock.width
|
||||
label: String(collectionBlock.modelData.label ?? "")
|
||||
detail: collectionBlock.modelData.locked
|
||||
? "Locked, so its contents cannot be listed"
|
||||
: (collectionBlock.modelData.items ?? []).length + " stored"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: collectionBlock.modelData.items ?? []
|
||||
|
||||
delegate: SettingRow {
|
||||
id: secretRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string itemPath: String(secretRow.modelData.path ?? "")
|
||||
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
|
||||
|
||||
width: collectionBlock.width
|
||||
label: String(secretRow.modelData.label ?? "")
|
||||
// Attributes, never the value: what the secret is FOR is
|
||||
// the part that identifies it.
|
||||
detail: root.describe(secretRow.modelData)
|
||||
controlWidth: 200
|
||||
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: secretRow.confirming ? "Cancel" : "Copy"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (secretRow.confirming) {
|
||||
root.confirmingPath = "";
|
||||
return;
|
||||
}
|
||||
Keyring.copy(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
// Two presses, always. Forgetting a stored
|
||||
// password cannot be undone, and the button
|
||||
// sits next to Copy where a misclick is cheap.
|
||||
text: secretRow.confirming ? "Forget it" : "Forget"
|
||||
tone: secretRow.confirming ? "danger" : "normal"
|
||||
enabled: !Keyring.working
|
||||
onClicked: {
|
||||
if (!secretRow.confirming) {
|
||||
root.confirmingPath = secretRow.itemPath;
|
||||
return;
|
||||
}
|
||||
root.confirmingPath = "";
|
||||
Keyring.forget(secretRow.itemPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0
|
||||
label: "Nothing stored yet"
|
||||
detail: "Applications that save a password or token will appear here."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Camera & microphone"
|
||||
subtitle: PrivacyState.anyActive
|
||||
@@ -128,6 +283,70 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application permissions"
|
||||
// The limit is stated here rather than left to be discovered. Saying
|
||||
// "your camera is protected" when a native binary can open it
|
||||
// directly would be a claim this page cannot back up.
|
||||
subtitle: Permissions.available
|
||||
? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly."
|
||||
: (Permissions.lastError || "The desktop portal's permission store is not running.")
|
||||
|
||||
Repeater {
|
||||
model: Permissions.devices
|
||||
|
||||
delegate: Column {
|
||||
id: deviceBlock
|
||||
|
||||
required property var modelData
|
||||
readonly property var applications: deviceBlock.modelData.applications ?? []
|
||||
|
||||
width: parent.width
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: deviceBlock.applications.length === 0
|
||||
label: String(deviceBlock.modelData.label ?? "")
|
||||
detail: "No application has asked for this."
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: deviceBlock.applications
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.app ?? "")
|
||||
detail: String(deviceBlock.modelData.label ?? "")
|
||||
controlWidth: 150
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Ask again"
|
||||
enabled: !Permissions.busy
|
||||
onClicked: Permissions.forget(
|
||||
String(deviceBlock.modelData.id), String(modelData.app))
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modelData.allowed === true
|
||||
onToggled: value => Permissions.setAllowed(
|
||||
String(deviceBlock.modelData.id), String(modelData.app), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Device security"
|
||||
subtitle: DeviceSecurity.attentionCount === 0
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Settings
|
||||
|
||||
The control centre for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||
The control center for everything Panama owns. Anything the system owns —
|
||||
hardware, accounts, printers — is delegated to GNOME Settings and labeled as
|
||||
such rather than half-reimplemented.
|
||||
|
||||
## System Health
|
||||
|
||||
The stable internal `services` route renders **System Health**. It is reachable
|
||||
from the Settings sidebar and its live 54px footer, the degraded-only bar
|
||||
indicator, and Vicinae's **Panama: Check System Health** command. Healthy scans
|
||||
indicator, and Vicinae's **Check System Health** command. Healthy scans
|
||||
reserve no bar space and produce no notification.
|
||||
|
||||
`services/Health.qml` owns the last accepted redacted snapshot. It invokes
|
||||
@@ -28,7 +28,7 @@ package, runs `sudo`, deletes user data, or repairs a service Panama does not
|
||||
own. A repair remains degraded until a fresh scan observes recovery.
|
||||
|
||||
The final card is the ownership boundary. Network configuration and the exact
|
||||
Users, Sharing, Colour profiles, and Digital wellbeing handoffs open GNOME
|
||||
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
|
||||
Settings because Fedora's system services own those areas.
|
||||
|
||||
## Adding a setting
|
||||
@@ -64,7 +64,7 @@ file.
|
||||
Every preference has **one primary page**, derived from its schema `group` and
|
||||
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||
owner. A control may appear on a second page only when the same adaptation is
|
||||
part of another established mental model; otherwise use a labelled handoff to
|
||||
part of another established mental model; otherwise use a labeled handoff to
|
||||
the owner instead of duplicating it.
|
||||
|
||||
### Intentional mirrors
|
||||
@@ -72,7 +72,7 @@ the owner instead of duplicating it.
|
||||
| Setting | Primary page | Mirror | Why the mirror earns its place |
|
||||
|---|---|---|---|
|
||||
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. |
|
||||
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||
@@ -93,12 +93,14 @@ Mirrors must remain the same schema-backed control, never a second preference
|
||||
or a copied default. Additions to this table require a concrete discoverability
|
||||
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||
|
||||
Window border colour follows the same ownership rule. The inactive border is a
|
||||
Window border color follows the same ownership rule. The inactive border is a
|
||||
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||
neutral contrast in light and dark modes. The focused Prism border is the
|
||||
accent role owned by the visual theme (and, eventually, an accent picker).
|
||||
`ColorScheme.qml` must never write the focused border, so changing schemes
|
||||
cannot erase a user-selected accent.
|
||||
**accent role**, driven by the chosen `accentName` and also owned by
|
||||
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
|
||||
`ColorScheme.qml` restates the focused border alongside the inactive one on
|
||||
every scheme change, rather than leaving a scheme flip to erase a
|
||||
user-selected accent.
|
||||
|
||||
## The rows
|
||||
|
||||
@@ -163,7 +165,7 @@ file with `qs -p`, and discover its PID from the exact Config path in
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
|
||||
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
|
||||
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favorites and aliases |
|
||||
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
|
||||
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
|
||||
| `$XDG_STATE_HOME/panama/hyprlock.conf` | Generated lock-screen appearance |
|
||||
|
||||
@@ -29,13 +29,15 @@ SettingsPage {
|
||||
title: "Language"
|
||||
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||
|
||||
TextRow {
|
||||
PickerRow {
|
||||
id: languagePicker
|
||||
|
||||
label: "Current language"
|
||||
detail: SystemLocale.pendingRestart
|
||||
? "Chosen, but not in use until you sign out and back in"
|
||||
: "Used by programs that ask the system what language to speak"
|
||||
value: SystemLocale.currentLabel || "Reading…"
|
||||
}
|
||||
divider: false
|
||||
|
||||
SearchPicker {
|
||||
width: parent.width
|
||||
@@ -43,7 +45,11 @@ SettingsPage {
|
||||
current: SystemLocale.current
|
||||
placeholder: "Search languages and regions"
|
||||
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||
onPicked: value => SystemLocale.set(value)
|
||||
onPicked: value => {
|
||||
SystemLocale.set(value);
|
||||
languagePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// A segmented choice that is NOT schema-bound.
|
||||
//
|
||||
// ChoiceRow reads its options from the preference schema. This one is handed
|
||||
// them, for choices that describe system state rather than a Panama setting.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
// [{ value, label }]
|
||||
property var options: []
|
||||
property var value: null
|
||||
property bool enabled: true
|
||||
|
||||
signal selected(value: var)
|
||||
|
||||
controlWidth: Math.max(150, root.options.length * 92)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
delegate: Rectangle {
|
||||
id: segment
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: root.value === segment.modelData.value
|
||||
|
||||
width: Math.max(84, segmentLabel.implicitWidth + 22)
|
||||
height: 30
|
||||
radius: 9
|
||||
color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, segmentMouse.containsMouse ? 0.12 : 0.06)
|
||||
border.width: 1
|
||||
border.color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: segmentLabel
|
||||
anchors.centerIn: parent
|
||||
text: String(segment.modelData.label ?? "")
|
||||
color: segment.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: segment.current ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: segmentMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.enabled && !segment.current
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.selected(segment.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// "normal", "accent", or "danger". Danger is for an action that cannot be
|
||||
// undone -- it never initiates one on a single press, it marks the press
|
||||
// that confirms it, so the confirming button cannot be mistaken for the
|
||||
// Cancel sitting next to it.
|
||||
property string tone: "normal"
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
@@ -16,16 +20,20 @@ Rectangle {
|
||||
color: {
|
||||
if (tone === "accent")
|
||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
||||
if (tone === "danger")
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.danger, 0.22) : Theme.alpha(Theme.danger, 0.12);
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
|
||||
}
|
||||
border.width: tone === "accent" ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
border.color: tone === "danger" ? Theme.alpha(Theme.danger, 0.45) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone === "accent" ? Theme.bgDark : Theme.fg
|
||||
color: root.tone === "accent"
|
||||
? Theme.bgDark
|
||||
: (root.tone === "danger" ? Theme.danger : Theme.fg)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
|
||||
@@ -9,6 +9,13 @@ Rectangle {
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
|
||||
// An optional themed icon beside the title, for cards that represent a
|
||||
// specific thing rather than a topic -- an online account is one of four
|
||||
// that otherwise differ only by a line of small grey text. Empty by
|
||||
// default, and when empty the header lays out exactly as it did before.
|
||||
property string icon: ""
|
||||
property string iconFallback: "dialog-information-symbolic"
|
||||
|
||||
width: parent ? parent.width : 680
|
||||
implicitHeight: body.implicitHeight + 30
|
||||
radius: Theme.cardRadius + 2
|
||||
@@ -32,15 +39,38 @@ Rectangle {
|
||||
anchors.margins: 15
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Row {
|
||||
width: parent.width
|
||||
visible: root.title !== ""
|
||||
spacing: root.icon === "" ? 0 : 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.icon !== ""
|
||||
width: root.icon === "" ? 0 : 22
|
||||
icon: root.icon
|
||||
iconFallback: root.iconFallback
|
||||
size: 22
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (root.icon === "" ? 0 : 32)
|
||||
text: root.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
bottomPadding: root.subtitle === "" ? 10 : 3
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// The title's bottom padding lived on the title Text; it moves here so
|
||||
// the Row above can centre an icon against it without the padding
|
||||
// pushing the icon off-centre.
|
||||
Item {
|
||||
width: 1
|
||||
height: root.title === "" ? 0 : (root.subtitle === "" ? 10 : 3)
|
||||
}
|
||||
|
||||
Text {
|
||||
|
||||
@@ -109,6 +109,7 @@ Rectangle {
|
||||
case "home-phone": return homePhonePage;
|
||||
case "desktop": return desktopPage;
|
||||
case "sound": return soundPage;
|
||||
case "gaming": return gamingPage;
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
@@ -120,6 +121,15 @@ Rectangle {
|
||||
case "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
case "applications": return applicationsPage;
|
||||
case "storage": return storagePage;
|
||||
case "snapshots": return snapshotsPage;
|
||||
case "updates": return updatesPage;
|
||||
case "users": return usersPage;
|
||||
case "sharing": return sharingPage;
|
||||
case "firewall": return firewallPage;
|
||||
case "printers": return printersPage;
|
||||
case "containers": return containersPage;
|
||||
case "ssh-keys": return sshKeysPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -158,6 +168,15 @@ Rectangle {
|
||||
|
||||
Component { id: homePage; HomePage {} }
|
||||
Component { id: applicationsPage; ApplicationsPage {} }
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: snapshotsPage; SnapshotsPage {} }
|
||||
Component { id: updatesPage; UpdatesPage {} }
|
||||
Component { id: usersPage; UsersPage {} }
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: firewallPage; FirewallPage {} }
|
||||
Component { id: containersPage; ContainersPage {} }
|
||||
Component { id: sshKeysPage; SshKeysPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
Component { id: dateTimePage; DateTimePage {} }
|
||||
@@ -167,6 +186,7 @@ Rectangle {
|
||||
Component { id: homePhonePage; HomePhonePage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: gamingPage; GamingPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
|
||||
@@ -26,9 +26,15 @@ Rectangle {
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
||||
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
|
||||
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "gaming", label: "Gaming", icon: "\u{F0297}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||
@@ -40,6 +46,10 @@ Rectangle {
|
||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
|
||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
||||
{ page: "users", label: "Users", icon: "\u{F0004}" },
|
||||
{ page: "services", label: "System Health", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user