Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86825e7327 | ||
|
|
89417cd6d4 | ||
|
|
41b86c7a9e | ||
|
|
185d7edaa5 | ||
|
|
538c0a887c | ||
|
|
033d5b21f8 | ||
|
|
7cd4131327 | ||
|
|
b280bd02d7 | ||
|
|
4c77bc2f61 | ||
|
|
9092a80f66 | ||
|
|
62dce86b4e | ||
|
|
725e274ef4 | ||
|
|
f09763ef5d | ||
|
|
215da285f3 | ||
|
|
48f7c1e962 | ||
|
|
f457c1eb9f | ||
|
|
d61792433c | ||
|
|
6016efa436 | ||
|
|
ed428e87c4 | ||
|
|
69ecec7ecf | ||
|
|
a90f6eb357 | ||
|
|
c4642919f7 | ||
|
|
c4733e0624 | ||
|
|
e1faaf7a76 | ||
|
|
47f29f9fa9 | ||
|
|
88497826ec | ||
|
|
b319d1a5e1 | ||
|
|
13f3648e4d | ||
|
|
359fb922aa | ||
|
|
b6448c9876 | ||
|
|
15d54b16f6 | ||
|
|
96e4085919 | ||
|
|
6bcc351e07 | ||
|
|
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 |
+10
-2
@@ -14,11 +14,19 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.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
|
/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-3.0/settings.ini
|
||||||
/config/dot/gtk-4.0/settings.ini
|
/config/dot/gtk-4.0/settings.ini
|
||||||
/config/dot/tmux/current-theme.conf
|
/config/dot/tmux/current-theme.conf
|
||||||
/config/dot/hypr/hyprlock.conf
|
/config/dot/hypr/hyprlock.conf
|
||||||
|
|
||||||
|
# Build products of the Vicinae extension. The source is the repository's; the
|
||||||
|
# dependency tree and the bundle it produces are machine state, rebuilt by
|
||||||
|
# `panama apps`.
|
||||||
|
/config/local/share/vicinae/extensions/*/node_modules/
|
||||||
|
/config/local/share/vicinae/extensions/*/dist/
|
||||||
|
/config/local/share/vicinae/extensions/*/build/
|
||||||
|
/config/local/share/vicinae/extensions/*/package-lock.json
|
||||||
|
|||||||
@@ -8,35 +8,64 @@ git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama
|
|||||||
~/.local/share/Panama/install
|
~/.local/share/Panama/install
|
||||||
```
|
```
|
||||||
|
|
||||||
`install` runs everything in `setup/scripts/` in order:
|
`install` asks its questions first and then runs the stages in `setup/scripts/`
|
||||||
|
in order, without stopping again:
|
||||||
|
|
||||||
| Script | Does |
|
| Script | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` |
|
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>` |
|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||||
|
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||||
|
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||||
|
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
|
||||||
|
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
|
||||||
|
|
||||||
|
The run ends with a health summary from `panama-doctor`, which reports what is
|
||||||
|
actually running rather than what was attempted. It never fails the install: on a
|
||||||
|
fresh machine it legitimately reports things as not yet configured.
|
||||||
|
|
||||||
|
### Optional applications
|
||||||
|
|
||||||
|
Every machine gets the lists in `setup/packages/`. The interview also offers the
|
||||||
|
categories in `setup/packages/extras/` as a checklist, so a work laptop need not
|
||||||
|
acquire emulators and a desktop need not skip Steam. Nothing is preselected.
|
||||||
|
|
||||||
|
A category is one file. A bare line is a dnf package and a `flatpak:` line is a
|
||||||
|
Flathub ID, because the applications in a category do not all come from one
|
||||||
|
place. Adding a category is adding a file — the menu is read from the directory,
|
||||||
|
not written down anywhere.
|
||||||
|
|
||||||
Existing configs are moved to `config/old/` rather than overwritten.
|
Existing configs are moved to `config/old/` rather than overwritten.
|
||||||
|
|
||||||
## Desktops
|
## The desktop
|
||||||
|
|
||||||
Panama configures **two** desktops that coexist. Both sessions stay available in
|
Hyprland, with a shell written from scratch. It began as a replacement for a
|
||||||
GDM, so you can switch back and forth while you settle in.
|
GNOME session — Forge for tiling, Dash-to-Dock, Openbar, Vitals — and was built
|
||||||
|
to reproduce it closely enough that muscle memory transferred: same keybinds,
|
||||||
|
same panel contents, same dock, same Tokyo Night Moon palette.
|
||||||
|
|
||||||
### GNOME
|
That is history now rather than a second option. Panama installs and configures
|
||||||
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals,
|
one desktop, and the GNOME session it grew out of is neither installed nor
|
||||||
AppIndicator support. Configured through `config/dot/forge/` and gsettings.
|
configured here. What each piece replaced is recorded in
|
||||||
|
[`config/dot/hypr/DESKTOP-PARITY.md`](config/dot/hypr/DESKTOP-PARITY.md) and in
|
||||||
|
the comments of the components themselves, because knowing what a thing was
|
||||||
|
modelled on explains why it behaves the way it does.
|
||||||
|
|
||||||
### Hyprland
|
GNOME is not gone from the machine: `gnome-control-center` is a declared
|
||||||
A from-scratch replacement built to reproduce the GNOME setup closely enough
|
dependency, and Panama's own Settings hands off to it for the panels it
|
||||||
that muscle memory transfers — same keybinds, same panel contents, same dock,
|
deliberately does not own — Online Accounts, Color, Sound, Network, Keyboard,
|
||||||
same Tokyo Night Moon palette.
|
Privacy, Wellbeing, Accessibility, and System for users, date and time, region
|
||||||
|
and remote desktop. The allow-list in
|
||||||
|
[`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml)
|
||||||
|
is what decides; anything not on it is a panel Panama owns itself.
|
||||||
|
|
||||||
| Piece | What it is |
|
| Piece | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||||
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
| `config/containers/` | Container definitions systemd runs as units — currently the speech-to-text server behind dictation |
|
||||||
|
| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands, and one compiled extension that adds web search with live suggestions |
|
||||||
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
||||||
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
||||||
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
|
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
|
||||||
@@ -50,13 +79,97 @@ Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
bin/ Small user-facing commands on PATH
|
bin/ Small user-facing commands on PATH; `panama` is the entry point
|
||||||
config/
|
config/
|
||||||
bash/ .bashrc, aliases, env (env is gitignored)
|
bash/ .bashrc, aliases, env (env is gitignored)
|
||||||
copy/ Files copied verbatim over / (needs sudo)
|
copy/ Files copied verbatim over / (needs sudo)
|
||||||
dot/ Symlinked into ~/.config
|
dot/ Symlinked into ~/.config
|
||||||
|
firefox/ Vendored Firefox chrome, linked into the browser profile
|
||||||
|
containers/ Quadlets, linked into ~/.config/containers/systemd
|
||||||
|
local/ Icons, the cursor theme, and the launcher's commands and
|
||||||
|
extensions, linked into ~/.local/share
|
||||||
old/ Backups of whatever was replaced (gitignored)
|
old/ Backups of whatever was replaced (gitignored)
|
||||||
|
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
||||||
setup/
|
setup/
|
||||||
packages/ One package per line
|
apps/ Applications built from source, one file each
|
||||||
|
lib/ Shared by more than one stage; the extras catalog reader
|
||||||
|
packages/ One package per line; extras/ holds the optional categories
|
||||||
scripts/ Run in order by ./install
|
scripts/ Run in order by ./install
|
||||||
|
tests/ Contracts. See below
|
||||||
|
docs/ Settings reference, and the design specs behind the work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
131 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
panama test # everything
|
||||||
|
panama test dock # just the ones matching "dock"
|
||||||
|
tests/setup/interview-contract # or one directly; they are plain executables
|
||||||
|
```
|
||||||
|
|
||||||
|
They are called contracts rather than unit tests because that is what they are:
|
||||||
|
each one pins a decision that was expensive to get right and is cheap to undo by
|
||||||
|
accident. Most read or measure the real thing — launching a shell to measure a
|
||||||
|
surface's geometry, standing stub commands on `PATH` to see what a stage would
|
||||||
|
have installed, running a script against a throwaway `HOME` — rather than
|
||||||
|
asserting things about source text, because the bugs worth catching here have all
|
||||||
|
been ones that source text looked fine for.
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/setup/ The installer: the interview, package lists, hardware, extras
|
||||||
|
tests/quickshell/ The shell and its settings pages
|
||||||
|
tests/hypr/ The compositor config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
A project is the set of windows you open together — which applications, which
|
||||||
|
workspace each was on, and for a terminal, which directory it was sitting in.
|
||||||
|
Arrange the desktop, then run **Save Layout as Project** from the launcher and
|
||||||
|
name it; **Open Project** lays it out again.
|
||||||
|
|
||||||
|
Workspaces are recorded as positions rather than numbers, and opening a project
|
||||||
|
claims free ones, so it never lands on top of what you are already doing. An
|
||||||
|
application that refuses to open twice — Slack, Thunderbird, the browser — is
|
||||||
|
moved into place rather than launched again. Saved layouts are listed on the
|
||||||
|
Desktop settings page, which is also where they are removed.
|
||||||
|
|
||||||
|
## The `panama` command
|
||||||
|
|
||||||
|
```sh
|
||||||
|
panama update # review, commit and sync this repo
|
||||||
|
panama edit # open it in Neovim
|
||||||
|
panama doctor # what is actually running, not what was installed
|
||||||
|
panama test # every contract, or a subset by pattern
|
||||||
|
panama upgrade # re-run ./install from anywhere
|
||||||
|
panama apps # choose applications to install, by category
|
||||||
|
panama app # applications no repository carries; build one by name
|
||||||
|
```
|
||||||
|
|
||||||
|
`panama apps` is the optional-application catalog, opened after the fact. The
|
||||||
|
interview offers the same categories during `./install`, whole; this picks a
|
||||||
|
category and then the applications inside it, so a machine can acquire Slack in
|
||||||
|
March without having wanted Discord in January. Both read
|
||||||
|
`setup/lib/extras-catalog`, so the two cannot describe different catalogues.
|
||||||
|
|
||||||
|
A category is one file under `setup/packages/extras/`. A bare line is a dnf
|
||||||
|
package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something
|
||||||
|
readable, and an indented line belongs to the entry above it — which is how OBS
|
||||||
|
carries its sixteen plugin extensions as one thing to tick.
|
||||||
|
|
||||||
|
`panama app` is deliberately not part of `./install`. Everything else Panama
|
||||||
|
installs comes from dnf or Flathub; these are built from source because no
|
||||||
|
packaged form exists, and a source build is slow, wants the network throughout,
|
||||||
|
and depends on an upstream that moves. That is the failure the interview exists
|
||||||
|
to prevent, so asking for one is something you do on purpose — and it is also
|
||||||
|
how you rebuild when a new version ships. Nothing is pinned: each build takes
|
||||||
|
the current upstream and reports a failure rather than working around it.
|
||||||
|
|
||||||
|
Adding one is adding a file to `setup/apps/`, and the file has to say why the
|
||||||
|
exception exists.
|
||||||
|
|
||||||
|
None of the scripts in this repository carry a `.sh` extension. A shebang and
|
||||||
|
the executable bit already select the interpreter, and the extension only
|
||||||
|
becomes something to keep in sync — which it did not stay.
|
||||||
|
|||||||
@@ -152,11 +152,14 @@ case "$cmd" in
|
|||||||
|
|
||||||
# 2.4) Run flatpak updates (user then system)
|
# 2.4) Run flatpak updates (user then system)
|
||||||
flatpak update -y
|
flatpak update -y
|
||||||
sudo flatpak update
|
sudo flatpak update -y
|
||||||
|
|
||||||
# 2.5) Optional firmware via fwupd
|
# 2.5) Optional firmware via fwupd
|
||||||
if $firmware; then
|
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
|
sudo fwupdmgr update
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+311
@@ -7,6 +7,11 @@
|
|||||||
# Commands:
|
# Commands:
|
||||||
# update Commit & sync local changes (or just pull if clean)
|
# update Commit & sync local changes (or just pull if clean)
|
||||||
# edit Open the Panama repo in Neovim
|
# edit Open the Panama repo in Neovim
|
||||||
|
# doctor Report what is actually running on this machine
|
||||||
|
# test Run every contract under tests/
|
||||||
|
# upgrade Re-run the installer from anywhere
|
||||||
|
# apps Choose applications to install, by category
|
||||||
|
# app Build and install an application that no repository packages
|
||||||
# help Show this help
|
# help Show this help
|
||||||
#
|
#
|
||||||
# Designed to grow: add new subcommands as cmd_<name> functions and
|
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||||||
@@ -62,6 +67,17 @@ ${BOLD}Commands:${RESET}
|
|||||||
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
||||||
clean it simply runs 'git pull'.
|
clean it simply runs 'git pull'.
|
||||||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||||||
|
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||||||
|
than what was installed. Takes --summary for one line per check.
|
||||||
|
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||||||
|
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||||
|
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
||||||
|
idempotent and this is the documented upgrade path.
|
||||||
|
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
|
||||||
|
what you want. The same catalog ./install offers, minus the
|
||||||
|
install.
|
||||||
|
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
||||||
|
Flathub carries. With no name, lists what is available.
|
||||||
${GREEN}help${RESET} Show this help (also -h, --help).
|
${GREEN}help${RESET} Show this help (also -h, --help).
|
||||||
|
|
||||||
${BOLD}Options:${RESET}
|
${BOLD}Options:${RESET}
|
||||||
@@ -71,6 +87,12 @@ ${BOLD}Options:${RESET}
|
|||||||
${BOLD}Examples:${RESET}
|
${BOLD}Examples:${RESET}
|
||||||
$PROGRAM update
|
$PROGRAM update
|
||||||
$PROGRAM edit
|
$PROGRAM edit
|
||||||
|
$PROGRAM doctor --summary
|
||||||
|
$PROGRAM test dock
|
||||||
|
$PROGRAM upgrade
|
||||||
|
$PROGRAM apps
|
||||||
|
$PROGRAM app
|
||||||
|
$PROGRAM app chatgpt-desktop
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +227,290 @@ cmd_edit() {
|
|||||||
exec nvim .
|
exec nvim .
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: doctor
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The health check already exists and the installer already runs it; what it did
|
||||||
|
# not have was a way to reach it from a terminal. Everything is passed straight
|
||||||
|
# through, so --summary and anything added later work without this knowing about
|
||||||
|
# them.
|
||||||
|
cmd_doctor() {
|
||||||
|
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||||||
|
if [[ ! -x "$doctor" ]]; then
|
||||||
|
err "panama-doctor is missing from $doctor"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec "$doctor" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: test
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The contracts are the main safety net in this repository and had no entry
|
||||||
|
# point: 121 executables with no runner and no mention in the README, which is
|
||||||
|
# most of the way to not having them.
|
||||||
|
#
|
||||||
|
# Each runs in its own process and a failure does not stop the rest, because the
|
||||||
|
# useful output is the whole list of what is broken rather than the first thing
|
||||||
|
# that broke. The exit code is what a caller can act on.
|
||||||
|
cmd_test() {
|
||||||
|
local pattern="${1:-}"
|
||||||
|
local -a suite=()
|
||||||
|
|
||||||
|
# Executables, plus the Python suites. Those are unittest files rather than
|
||||||
|
# executables, and collecting only what has the executable bit would skip them
|
||||||
|
# without saying so -- which is how all three came to be run by nothing at all.
|
||||||
|
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||||
|
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
|
||||||
|
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||||
|
|
||||||
|
if (( ${#suite[@]} == 0 )); then
|
||||||
|
err "No contracts match '${pattern}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Running ${#suite[@]} contract(s)"
|
||||||
|
local -a failed=()
|
||||||
|
local path name
|
||||||
|
local -a runner
|
||||||
|
for path in "${suite[@]}"; do
|
||||||
|
name="${path#"$PANAMA_DIR"/tests/}"
|
||||||
|
if [[ "$path" == *_test.py ]]; then
|
||||||
|
runner=(python3 "$path")
|
||||||
|
else
|
||||||
|
runner=("$path")
|
||||||
|
fi
|
||||||
|
if "${runner[@]}" >/dev/null 2>&1; then
|
||||||
|
ok "$name"
|
||||||
|
else
|
||||||
|
err "$name"
|
||||||
|
failed+=("$name")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
header "Result"
|
||||||
|
if (( ${#failed[@]} == 0 )); then
|
||||||
|
ok "${#suite[@]} contract(s) passed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
err "${#failed[@]} of ${#suite[@]} failed:"
|
||||||
|
printf ' %s\n' "${failed[@]}" >&2
|
||||||
|
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: upgrade
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# ./install is the upgrade path -- every stage is idempotent and re-running is
|
||||||
|
# the documented way to repair a machine. This only saves remembering where the
|
||||||
|
# repository lives.
|
||||||
|
cmd_upgrade() {
|
||||||
|
local installer="$PANAMA_DIR/install"
|
||||||
|
if [[ ! -x "$installer" ]]; then
|
||||||
|
err "The installer is missing from $installer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Re-running ${BOLD}${installer}${RESET}"
|
||||||
|
cd "$PANAMA_DIR"
|
||||||
|
exec "$installer" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: app
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The applications that neither dnf nor Flathub carries, built from source into
|
||||||
|
# a package dnf can still own and remove.
|
||||||
|
#
|
||||||
|
# Deliberately NOT part of ./install. A source build is slow, wants the network
|
||||||
|
# for the whole of it, and depends on an upstream that moves -- which is exactly
|
||||||
|
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
|
||||||
|
# error, with nobody at the keyboard. Asking for one of these is a thing you do
|
||||||
|
# on purpose, and it is also the rebuild path when a new version ships.
|
||||||
|
#
|
||||||
|
# Nothing is pinned. Each build takes the current default branch and the current
|
||||||
|
# upstream release, and says so when it fails. A recorded version is a 404
|
||||||
|
# waiting to happen -- sunhat proved that three times over.
|
||||||
|
APPS_DIR="$PANAMA_DIR/setup/apps"
|
||||||
|
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"
|
||||||
|
|
||||||
|
cmd_app() {
|
||||||
|
local name="${1:-}"
|
||||||
|
|
||||||
|
if [[ -z "$name" ]]; then
|
||||||
|
header "Applications"
|
||||||
|
printf 'Built from source, because no repository carries them.\n\n'
|
||||||
|
local file
|
||||||
|
for file in "$APPS_DIR"/*; do
|
||||||
|
[[ -f "$file" ]] || continue
|
||||||
|
local description=""
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$file"
|
||||||
|
printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
|
||||||
|
done
|
||||||
|
printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local definition="$APPS_DIR/$name"
|
||||||
|
if [[ ! -f "$definition" ]]; then
|
||||||
|
err "No such application: '$name'"
|
||||||
|
printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local description="" repo=""
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$definition"
|
||||||
|
[[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }
|
||||||
|
|
||||||
|
# The checkout lives in the cache because it is entirely rebuildable and
|
||||||
|
# should never be mistaken for something to keep. Existing checkouts are
|
||||||
|
# reset to upstream rather than merged: a local edit in a build tree is not
|
||||||
|
# something to preserve silently.
|
||||||
|
local tree="$APPS_WORK/$name"
|
||||||
|
if [[ -d "$tree/.git" ]]; then
|
||||||
|
info "Updating $name"
|
||||||
|
git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
|
||||||
|
git -C "$tree" reset --hard FETCH_HEAD >/dev/null
|
||||||
|
else
|
||||||
|
info "Cloning $name"
|
||||||
|
mkdir -p "$APPS_WORK"
|
||||||
|
rm -rf "$tree"
|
||||||
|
git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
|
||||||
|
if ( cd "$tree" && build ); then
|
||||||
|
ok "$name installed"
|
||||||
|
printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
|
||||||
|
else
|
||||||
|
err "$name failed to build"
|
||||||
|
printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
|
||||||
|
printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: apps
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# The optional applications, chosen a few at a time rather than all at once.
|
||||||
|
#
|
||||||
|
# ./install offers the same catalog as whole categories, because during a first
|
||||||
|
# install you want coarse and fast. This is the other end of it: pick a
|
||||||
|
# category, then tick the applications inside, and install just those. Both read
|
||||||
|
# setup/lib/extras-catalog, so neither can drift from the other.
|
||||||
|
#
|
||||||
|
# Source-built applications appear here too, as their own category. They are not
|
||||||
|
# part of ./install for good reason -- a build is slow, wants the network
|
||||||
|
# throughout, and can fail on an upstream that moved -- but "choose, then
|
||||||
|
# install" is exactly the right shape for them, which is what this is.
|
||||||
|
cmd_apps() {
|
||||||
|
if ! command -v gum >/dev/null 2>&1; then
|
||||||
|
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=../setup/lib/extras-catalog
|
||||||
|
source "$PANAMA_DIR/setup/lib/extras-catalog"
|
||||||
|
|
||||||
|
local extras_dir="$PANAMA_DIR/setup/packages/extras"
|
||||||
|
local apps_dir="$PANAMA_DIR/setup/apps"
|
||||||
|
|
||||||
|
local -a categories=()
|
||||||
|
mapfile -t categories < <(catalog_categories "$extras_dir")
|
||||||
|
[[ -d "$apps_dir" ]] && categories+=("built from source")
|
||||||
|
|
||||||
|
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
|
||||||
|
|
||||||
|
local category
|
||||||
|
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
|
||||||
|
[[ -n "$category" ]] || return 0
|
||||||
|
|
||||||
|
if [[ "$category" == "built from source" ]]; then
|
||||||
|
cmd_apps_source "$apps_dir"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local file="$extras_dir/$category"
|
||||||
|
local -a labels=() targets=()
|
||||||
|
local target label marker
|
||||||
|
while IFS=$'\t' read -r target label; do
|
||||||
|
[[ -n "$target" ]] || continue
|
||||||
|
# Marked, not hidden: reinstalling what is present is harmless, but a menu
|
||||||
|
# that silently omits it leaves you wondering where it went.
|
||||||
|
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
|
||||||
|
targets+=("$target")
|
||||||
|
labels+=("$label$marker")
|
||||||
|
done < <(catalog_entries "$file")
|
||||||
|
|
||||||
|
local chosen
|
||||||
|
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
|
||||||
|
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||||||
|
|
||||||
|
# Back from labels to install targets, then out to everything each one carries.
|
||||||
|
local -a install_targets=()
|
||||||
|
local pick i
|
||||||
|
while IFS= read -r pick; do
|
||||||
|
[[ -n "$pick" ]] || continue
|
||||||
|
for i in "${!labels[@]}"; do
|
||||||
|
if [[ "${labels[$i]}" == "$pick" ]]; then
|
||||||
|
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done <<< "$chosen"
|
||||||
|
|
||||||
|
local -a dnf_packages=() flatpak_ids=()
|
||||||
|
for target in "${install_targets[@]}"; do
|
||||||
|
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
|
||||||
|
done
|
||||||
|
|
||||||
|
header "About to install"
|
||||||
|
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
|
||||||
|
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
|
||||||
|
echo
|
||||||
|
confirm "Install these?" || { warn "Nothing installed."; return 0; }
|
||||||
|
|
||||||
|
if (( ${#dnf_packages[@]} > 0 )); then
|
||||||
|
info "Installing ${#dnf_packages[@]} package(s) with dnf"
|
||||||
|
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
|
||||||
|
fi
|
||||||
|
if (( ${#flatpak_ids[@]} > 0 )); then
|
||||||
|
info "Installing ${#flatpak_ids[@]} flatpak(s)"
|
||||||
|
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
|
||||||
|
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
|
||||||
|
fi
|
||||||
|
ok "Done."
|
||||||
|
}
|
||||||
|
|
||||||
|
# The source-built applications, offered by the same two-step flow and handed to
|
||||||
|
# the existing `panama app` so there is one build path, not two.
|
||||||
|
cmd_apps_source() {
|
||||||
|
local apps_dir="$1"
|
||||||
|
local -a names=()
|
||||||
|
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
|
||||||
|
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
|
||||||
|
|
||||||
|
local chosen
|
||||||
|
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
|
||||||
|
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||||||
|
|
||||||
|
local name
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
header "Building $name"
|
||||||
|
cmd_app "$name" || warn "$name did not build"
|
||||||
|
done <<< "$chosen"
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Dispatcher
|
# Dispatcher
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -213,6 +519,11 @@ main() {
|
|||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
update) shift; cmd_update "$@" ;;
|
update) shift; cmd_update "$@" ;;
|
||||||
edit) shift; cmd_edit "$@" ;;
|
edit) shift; cmd_edit "$@" ;;
|
||||||
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
|
test) shift; cmd_test "$@" ;;
|
||||||
|
upgrade) shift; cmd_upgrade "$@" ;;
|
||||||
|
app) shift; cmd_app "$@" ;;
|
||||||
|
apps) shift; cmd_apps "$@" ;;
|
||||||
help|-h|--help|"") usage ;;
|
help|-h|--help|"") usage ;;
|
||||||
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
+4
-5
@@ -24,8 +24,7 @@ else
|
|||||||
fi
|
fi
|
||||||
export PATH
|
export PATH
|
||||||
fi
|
fi
|
||||||
. "$HOME/.cargo/env"
|
# rustup writes this file, and initial-packages installs rustup rather than
|
||||||
|
# running rustup-init -- so on a fresh machine it does not exist yet and an
|
||||||
# >>> Codex installer >>>
|
# unguarded source made every single shell start with an error.
|
||||||
export PATH="/home/gib/.local/bin:$PATH"
|
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||||
# <<< Codex installer <<<
|
|
||||||
|
|||||||
+12
-3
@@ -22,8 +22,10 @@ export DOTNETPATH="$HOME/.dotnet/tools"
|
|||||||
# Set complete path
|
# Set complete path
|
||||||
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
|
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
|
||||||
|
|
||||||
# Nvm
|
# Nvm. Guarded because the file belongs to the nvm package: before that is
|
||||||
source /etc/profile.d/nvm.sh
|
# installed it does not exist, and an unconditional source means every shell on
|
||||||
|
# a fresh machine opens with an error.
|
||||||
|
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
|
||||||
# Auto-switch Node version when entering a directory with .nvmrc
|
# Auto-switch Node version when entering a directory with .nvmrc
|
||||||
_nvm_auto_use() {
|
_nvm_auto_use() {
|
||||||
if [[ -f .nvmrc ]]; then
|
if [[ -f .nvmrc ]]; then
|
||||||
@@ -41,4 +43,11 @@ fi
|
|||||||
eval "$(zoxide init bash)"
|
eval "$(zoxide init bash)"
|
||||||
|
|
||||||
# Oh My Posh
|
# Oh My Posh
|
||||||
eval "$(oh-my-posh init bash --config $PANAMA_PATH/config/dot/ohmyposh/gib.omp.json)"
|
# Guarded for the same reason the nvm source above is: oh-my-posh is a package,
|
||||||
|
# and a shell opened before it is installed -- a stage re-run by hand, an
|
||||||
|
# install that failed partway -- would otherwise print command-not-found on
|
||||||
|
# every prompt. An unthemed prompt is a worse shell; an erroring one is a
|
||||||
|
# broken-looking machine.
|
||||||
|
if command -v oh-my-posh >/dev/null 2>&1; then
|
||||||
|
eval "$(oh-my-posh init bash --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Dictation's speech-to-text server, as a Quadlet.
|
||||||
|
#
|
||||||
|
# Quadlet rather than a hand-written unit wrapping `podman run`: systemd
|
||||||
|
# generates the unit from this at boot, so there is one description of the
|
||||||
|
# container rather than a unit and a command line drifting apart.
|
||||||
|
#
|
||||||
|
# Upstream's own Vulkan image, not one built here. whisper.cpp publishes it, it
|
||||||
|
# already contains whisper-server, and it is maintained by the people who write
|
||||||
|
# the thing -- a Containerfile in this repository would be a compile step on
|
||||||
|
# every machine and a build to keep working, in exchange for nothing.
|
||||||
|
#
|
||||||
|
# Vulkan rather than ROCm, which is the reason this tag and not another. ROCm's
|
||||||
|
# runtime is seven gigabytes and serves AMD alone; Vulkan compute runs on AMD,
|
||||||
|
# Intel and NVIDIA through whatever Mesa driver a machine already has. These
|
||||||
|
# machines are a mix of all three, and this image works on every one of them.
|
||||||
|
#
|
||||||
|
# Deliberately no [Install] section. whisper-server loads the model when it
|
||||||
|
# starts and holds it, so a container started at login costs half a gigabyte of
|
||||||
|
# memory in every session where nobody dictates. panama-dictate starts it on the
|
||||||
|
# first press of the key, and it stays up for the rest of the session.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Panama dictation speech-to-text server
|
||||||
|
Documentation=https://github.com/ggml-org/whisper.cpp
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=ghcr.io/ggml-org/whisper.cpp:main-vulkan
|
||||||
|
|
||||||
|
# The image's own entrypoint is a shell; the server is what is wanted.
|
||||||
|
Entrypoint=/app/build/bin/whisper-server
|
||||||
|
Exec=--host 0.0.0.0 --port 8791 --model /models/ggml-small.bin --inference-path /inference
|
||||||
|
|
||||||
|
# The whole directory rather than a renderD node: the number differs between
|
||||||
|
# machines, and this file is meant to be identical on all of them.
|
||||||
|
AddDevice=/dev/dri
|
||||||
|
|
||||||
|
# The model is host state -- fetched once, kept across image updates, and shared
|
||||||
|
# with nothing else. Read-only because the server never writes to it, and :z
|
||||||
|
# relabels for SELinux, which is enforcing on Fedora.
|
||||||
|
Volume=%h/.local/share/panama/whisper:/models:ro,z
|
||||||
|
|
||||||
|
# Loopback only. This transcribes whatever it is sent, with no authentication,
|
||||||
|
# and has no business being reachable from the network.
|
||||||
|
PublishPort=127.0.0.1:8791:8791
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Loading the model takes a few seconds on a cold start; systemd should wait for
|
||||||
|
# it rather than give up, and should bring the server back if it dies mid-session.
|
||||||
|
TimeoutStartSec=180
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Clear the mountpoint before starting, so a start that follows a crash lands
|
||||||
|
# on clean ground.
|
||||||
|
#
|
||||||
|
# This service provides the fuse mount at /run/user/$UID/doc, and bwrap binds
|
||||||
|
# doc/by-app/<id> into every flatpak sandbox it builds. No mount, no sandbox,
|
||||||
|
# no application -- all of them, not one, because they all share this.
|
||||||
|
#
|
||||||
|
# The shipped unit is Type=dbus with Restart=no, so nothing restarts it on
|
||||||
|
# failure: it comes back only when something next calls its bus name. On this
|
||||||
|
# machine that was a flatpak launching two seconds after the daemon exited
|
||||||
|
# 21, and the activation raced the dying instance's fuse state and came up
|
||||||
|
# with no mount at all. systemctl then reports active (running) -- the
|
||||||
|
# fusermount3 helper is even still there as a child -- while every new sandbox
|
||||||
|
# fails. Nothing running at the time notices, because a sandbox needs the
|
||||||
|
# mount only while it is being built, so the breakage surfaces whenever you
|
||||||
|
# next open something you had not opened yet. Here that was three days later,
|
||||||
|
# and it was the second time: the first was written off as a one-off.
|
||||||
|
#
|
||||||
|
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
|
||||||
|
# saying so, which is not a failure to start on.
|
||||||
|
[Service]
|
||||||
|
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
"""nautilus extension: nautilus_open_any_terminal"""
|
|
||||||
|
|
||||||
# based on: https://github.com/gnunn1/tilix/blob/master/data/nautilus/open-tilix.py
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from functools import cache
|
|
||||||
from gettext import gettext, translation
|
|
||||||
from os.path import expanduser
|
|
||||||
from subprocess import Popen
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import quote, unquote, urlparse
|
|
||||||
|
|
||||||
from gi import require_version
|
|
||||||
|
|
||||||
try:
|
|
||||||
require_version("Nautilus", "4.1")
|
|
||||||
except ValueError:
|
|
||||||
require_version("Nautilus", "4.0")
|
|
||||||
|
|
||||||
require_version("Gtk", "4.0")
|
|
||||||
|
|
||||||
from gi.repository import Nautilus as FileManager
|
|
||||||
API_VERSION = "4.1"
|
|
||||||
|
|
||||||
from gi.repository import Gio, GLib, GObject, Gtk # noqa: E402 pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Terminal:
|
|
||||||
"""Data class representing a terminal configuration."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
workdir_arguments: Optional[list[str]] = None
|
|
||||||
new_tab_arguments: Optional[list[str]] = None
|
|
||||||
new_window_arguments: Optional[list[str]] = None
|
|
||||||
command_arguments: list[str] = field(default_factory=lambda: ["-e"])
|
|
||||||
flatpak_package: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
_ = gettext
|
|
||||||
for localedir in [expanduser("~/.local/share/locale"), "/usr/share/locale"]:
|
|
||||||
try:
|
|
||||||
trans = translation("nautilus-open-any-terminal", localedir)
|
|
||||||
trans.install()
|
|
||||||
_ = trans.gettext
|
|
||||||
break
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
TERMINALS = {
|
|
||||||
"alacritty": Terminal("Alacritty"),
|
|
||||||
"app2unit-term": Terminal("app2unit-term"),
|
|
||||||
"blackbox": Terminal(
|
|
||||||
"Black Box",
|
|
||||||
workdir_arguments=["--working-directory"],
|
|
||||||
command_arguments=["-c"],
|
|
||||||
flatpak_package="com.raggesilver.BlackBox",
|
|
||||||
),
|
|
||||||
"blackbox-terminal": Terminal(
|
|
||||||
"Black Box",
|
|
||||||
workdir_arguments=["--working-directory"],
|
|
||||||
command_arguments=["-c"],
|
|
||||||
),
|
|
||||||
"bobcat": Terminal(
|
|
||||||
"Bobcat",
|
|
||||||
workdir_arguments=["--working-dir"],
|
|
||||||
command_arguments=["--"],
|
|
||||||
),
|
|
||||||
"cool-retro-term": Terminal("cool-retro-term", workdir_arguments=["--workdir"]),
|
|
||||||
"custom": Terminal(_("Terminal"), command_arguments=[]),
|
|
||||||
"contour": Terminal(
|
|
||||||
"Contour",
|
|
||||||
workdir_arguments=["--working-directory"],
|
|
||||||
flatpak_package="org.contourterminal.Contour",
|
|
||||||
),
|
|
||||||
"cosmic-term": Terminal("COSMIC Terminal"),
|
|
||||||
"deepin-terminal": Terminal("Deepin Terminal"),
|
|
||||||
"ddterm": Terminal(
|
|
||||||
"Drop down Terminal extension",
|
|
||||||
workdir_arguments=["--working-directory"],
|
|
||||||
flatpak_package="com.github.amezin.ddterm",
|
|
||||||
),
|
|
||||||
"foot": Terminal("Foot"),
|
|
||||||
"footclient": Terminal("FootClient"),
|
|
||||||
"ghostty": Terminal("Ghostty"),
|
|
||||||
"gnome-terminal": Terminal("Terminal", new_tab_arguments=["--tab"], command_arguments=["--"]),
|
|
||||||
"guake": Terminal("Guake", workdir_arguments=["--show", "--new-tab"]),
|
|
||||||
"kermit": Terminal("Kermit"),
|
|
||||||
"kgx": Terminal("Console", new_tab_arguments=["--tab"]),
|
|
||||||
"kitty": Terminal("Kitty"),
|
|
||||||
"konsole": Terminal("Konsole", new_tab_arguments=["--new-tab"]),
|
|
||||||
"mate-terminal": Terminal("Mate Terminal", new_tab_arguments=["--tab"]),
|
|
||||||
"mlterm": Terminal("Mlterm"),
|
|
||||||
"ptyxis": Terminal(
|
|
||||||
"Ptyxis",
|
|
||||||
workdir_arguments=["-d"],
|
|
||||||
command_arguments=["--"],
|
|
||||||
new_tab_arguments=["--tab"],
|
|
||||||
new_window_arguments=["--new-window"],
|
|
||||||
flatpak_package="app.devsuite.Ptyxis",
|
|
||||||
),
|
|
||||||
"ptyxis-nightly": Terminal(
|
|
||||||
"Ptyxis",
|
|
||||||
workdir_arguments=["-d"],
|
|
||||||
command_arguments=["--"],
|
|
||||||
new_tab_arguments=["--tab"],
|
|
||||||
new_window_arguments=["--new-window"],
|
|
||||||
flatpak_package="org.gnome.Ptyxis.Devel",
|
|
||||||
),
|
|
||||||
"qterminal": Terminal("QTerminal"),
|
|
||||||
"rio": Terminal("Rio"),
|
|
||||||
"sakura": Terminal("Sakura"),
|
|
||||||
"st": Terminal("Simple Terminal"),
|
|
||||||
"tabby": Terminal("Tabby", command_arguments=["run"], workdir_arguments=["open"]),
|
|
||||||
"terminator": Terminal("Terminator", new_tab_arguments=["--new-tab"]),
|
|
||||||
"terminology": Terminal("Terminology"),
|
|
||||||
"terminus": Terminal("Terminus"),
|
|
||||||
"termite": Terminal("Termite"),
|
|
||||||
"tilix": Terminal("Tilix", flatpak_package="com.gexperts.Tilix"),
|
|
||||||
"urxvt": Terminal("rxvt-unicode"),
|
|
||||||
"urxvtc": Terminal("urxvtc"),
|
|
||||||
"uwsm-terminal": Terminal("uwsm-terminal"),
|
|
||||||
"uxterm": Terminal("UXTerm"),
|
|
||||||
"warp": Terminal(
|
|
||||||
"Warp",
|
|
||||||
new_tab_arguments=["--virtual-arg-for-tabs"], # This is just to indicate tab support
|
|
||||||
),
|
|
||||||
"wezterm": Terminal(
|
|
||||||
"Wez's Terminal Emulator",
|
|
||||||
workdir_arguments=["--cwd"],
|
|
||||||
new_tab_arguments=["start", "--new-tab"],
|
|
||||||
new_window_arguments=["start"],
|
|
||||||
flatpak_package="org.wezfurlong.wezterm",
|
|
||||||
),
|
|
||||||
"xfce4-terminal": Terminal("Xfce Terminal", new_tab_arguments=["--tab"]),
|
|
||||||
"xterm": Terminal("XTerm"),
|
|
||||||
}
|
|
||||||
|
|
||||||
FLATPAK_PARMS = ["off", "system", "user"]
|
|
||||||
|
|
||||||
terminal = "gnome-terminal"
|
|
||||||
terminal_cmd: list[str] = None # type: ignore
|
|
||||||
terminal_data: Terminal = TERMINALS["gnome-terminal"]
|
|
||||||
new_tab = False
|
|
||||||
flatpak = FLATPAK_PARMS[0]
|
|
||||||
custom_local_command: str
|
|
||||||
custom_remote_command: str
|
|
||||||
|
|
||||||
GSETTINGS_PATH = "com.github.stunkymonkey.nautilus-open-any-terminal"
|
|
||||||
GSETTINGS_KEYBINDINGS = "keybindings"
|
|
||||||
GSETTINGS_BIND_REMOTE = "bind-remote"
|
|
||||||
GSETTINGS_TERMINAL = "terminal"
|
|
||||||
GSETTINGS_NEW_TAB = "new-tab"
|
|
||||||
GSETTINGS_FLATPAK = "flatpak"
|
|
||||||
GSETTINGS_USE_GENERIC_TERMINAL_NAME = "use-generic-terminal-name"
|
|
||||||
GSETTINGS_CUSTOM_LOCAL_COMMAND = "custom-local-command"
|
|
||||||
GSETTINGS_CUSTOM_REMOTE_COMMAND = "custom-remote-command"
|
|
||||||
REMOTE_URI_SCHEME = ["ftp", "sftp"]
|
|
||||||
|
|
||||||
|
|
||||||
# Adapted from https://www.freedesktop.org/software/systemd/man/latest/os-release.html
|
|
||||||
def read_os_release():
|
|
||||||
"""Read and parse the OS release information."""
|
|
||||||
possible_os_release_paths = ["/etc/os-release", "/usr/lib/os-release"]
|
|
||||||
for file_path in possible_os_release_paths:
|
|
||||||
try:
|
|
||||||
with open(file_path, mode="r", encoding="utf-8") as os_release:
|
|
||||||
for line_number, line in enumerate(os_release, start=1):
|
|
||||||
line = line.rstrip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
result = re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line)
|
|
||||||
if result:
|
|
||||||
name, val = result.groups()
|
|
||||||
if val and val[0] in "\"'":
|
|
||||||
val = ast.literal_eval(val)
|
|
||||||
yield name, val
|
|
||||||
else:
|
|
||||||
raise OSError(f"{file_path}:{line_number}: bad line {line!r}")
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def distro_id() -> set[str]:
|
|
||||||
"""get the set of distribution ids"""
|
|
||||||
try:
|
|
||||||
os_release = dict(read_os_release())
|
|
||||||
except OSError:
|
|
||||||
return set(["unknown"])
|
|
||||||
ids = [os_release["ID"]]
|
|
||||||
if id_like := os_release.get("ID_LIKE"):
|
|
||||||
ids.extend(id_like.split(" "))
|
|
||||||
return set(ids)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_custom_command(command: str, data: str | list[str]) -> list[str]:
|
|
||||||
"""Substitute every '%s' in the command with data and split it into arguments"""
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = [data]
|
|
||||||
|
|
||||||
return shlex.split(command.replace("%s", shlex.join(data)))
|
|
||||||
|
|
||||||
|
|
||||||
def run_command_in_terminal(command: list[str], *, cwd: str | None = None):
|
|
||||||
if terminal == "custom":
|
|
||||||
cmd = parse_custom_command(custom_remote_command, command)
|
|
||||||
else:
|
|
||||||
cmd = terminal_cmd.copy()
|
|
||||||
if cwd and terminal_data.workdir_arguments:
|
|
||||||
cmd.extend(terminal_data.workdir_arguments)
|
|
||||||
cmd.append(cwd)
|
|
||||||
cmd.extend(terminal_data.command_arguments)
|
|
||||||
cmd.extend(command)
|
|
||||||
|
|
||||||
Popen(cmd, cwd=cwd) # pylint: disable=consider-using-with
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_command_from_uri(uri: str, *, is_directory: bool):
|
|
||||||
"""Creates an ssh command that executes or cd's into remote uri"""
|
|
||||||
result = urlparse(uri)
|
|
||||||
cmd = ["ssh", "-t"]
|
|
||||||
if result.username:
|
|
||||||
cmd.append(f"{result.username}@{result.hostname}")
|
|
||||||
else:
|
|
||||||
cmd.append(result.hostname) # type: ignore
|
|
||||||
|
|
||||||
if result.port:
|
|
||||||
cmd.append("-p")
|
|
||||||
cmd.append(str(result.port))
|
|
||||||
|
|
||||||
target = shlex.quote(unquote(result.path))
|
|
||||||
if is_directory:
|
|
||||||
cmd.extend(["cd", target, ";", "exec", "${SHELL:-/bin/sh}", "-l"])
|
|
||||||
else:
|
|
||||||
cmd.extend(["exec", target])
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
def open_remote_terminal_in_uri(uri: str):
|
|
||||||
"""Open a new remote terminal"""
|
|
||||||
run_command_in_terminal(ssh_command_from_uri(uri, is_directory=True))
|
|
||||||
|
|
||||||
|
|
||||||
def open_local_terminal_in_uri(uri: str):
|
|
||||||
"""open the new terminal with correct path"""
|
|
||||||
result = urlparse(uri)
|
|
||||||
filename = unquote(result.path)
|
|
||||||
if result.scheme == "admin":
|
|
||||||
run_command_in_terminal(["sudo", "-s"], cwd=filename)
|
|
||||||
return
|
|
||||||
|
|
||||||
if terminal == "warp":
|
|
||||||
# Force new_tab to be considered even without traditional tab arguments
|
|
||||||
Popen( # pylint: disable=consider-using-with
|
|
||||||
["xdg-open", f"warp://action/new_{'tab' if new_tab else 'window'}?path={result.path}"]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
cmd = terminal_cmd.copy()
|
|
||||||
if terminal == "custom":
|
|
||||||
cmd = parse_custom_command(custom_local_command, filename)
|
|
||||||
elif filename and terminal_data.workdir_arguments:
|
|
||||||
cmd.extend(terminal_data.workdir_arguments)
|
|
||||||
cmd.append(filename)
|
|
||||||
|
|
||||||
Popen(cmd, cwd=filename) # pylint: disable=consider-using-with
|
|
||||||
|
|
||||||
|
|
||||||
def directory_menu_item_id(*, foreground: bool, remote: bool):
|
|
||||||
return f"OpenTerminal::open{'_' if foreground else '_bg_'}{'remote' if remote else 'file'}_item"
|
|
||||||
|
|
||||||
|
|
||||||
def executable_menu_item_id(*, remote: bool):
|
|
||||||
return f"OpenTerminal::execute{'_remote_' if remote else '_file_'}item"
|
|
||||||
|
|
||||||
|
|
||||||
def get_directory_menu_items(
|
|
||||||
file: FileManager.FileInfo, callback, *, foreground: bool, terminal_name: str | None = None
|
|
||||||
):
|
|
||||||
items = []
|
|
||||||
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
|
|
||||||
terminal_name = terminal_name or terminal_data.name
|
|
||||||
|
|
||||||
if remote:
|
|
||||||
if foreground:
|
|
||||||
REMOTE_LABEL = _("Open in Remote {}")
|
|
||||||
REMOTE_TIP = _("Open Remote {} in {}")
|
|
||||||
LOCAL_LABEL = _("Open in Local {}")
|
|
||||||
LOCAL_TIP = _("Open Local {} in {}")
|
|
||||||
tip = REMOTE_TIP.format(terminal_name, file.get_name())
|
|
||||||
else:
|
|
||||||
REMOTE_LABEL = _("Open Remote {} Here")
|
|
||||||
REMOTE_TIP = _("Open Remote {} in This Directory")
|
|
||||||
LOCAL_LABEL = _("Open Local {} Here")
|
|
||||||
LOCAL_TIP = _("Open Local {} in This Directory")
|
|
||||||
tip = REMOTE_TIP.format(terminal_name)
|
|
||||||
|
|
||||||
item = FileManager.MenuItem(
|
|
||||||
name=directory_menu_item_id(foreground=foreground, remote=True),
|
|
||||||
label=REMOTE_LABEL.format(terminal_name),
|
|
||||||
tip=tip,
|
|
||||||
)
|
|
||||||
item.connect("activate", callback, file, True)
|
|
||||||
items.append(item)
|
|
||||||
elif foreground:
|
|
||||||
LOCAL_LABEL = _("Open in {}")
|
|
||||||
LOCAL_TIP = _("Open {} in {}")
|
|
||||||
else:
|
|
||||||
LOCAL_LABEL = _("Open {} Here")
|
|
||||||
LOCAL_TIP = _("Open {} in This Directory")
|
|
||||||
|
|
||||||
# Let wezterm handle opening a local terminal
|
|
||||||
if terminal == "wezterm" and flatpak == "off":
|
|
||||||
return items
|
|
||||||
|
|
||||||
if foreground:
|
|
||||||
tip = LOCAL_TIP.format(terminal_name, file.get_name())
|
|
||||||
else:
|
|
||||||
tip = LOCAL_TIP.format(terminal_name)
|
|
||||||
|
|
||||||
item = FileManager.MenuItem(
|
|
||||||
name=directory_menu_item_id(foreground=foreground, remote=False),
|
|
||||||
label=LOCAL_LABEL.format(terminal_name),
|
|
||||||
tip=tip,
|
|
||||||
)
|
|
||||||
item.connect("activate", callback, file, False)
|
|
||||||
items.append(item)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def get_executable_menu_items(file: FileManager.FileInfo, callback, *, terminal_name: str | None = None):
|
|
||||||
items = []
|
|
||||||
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
|
|
||||||
terminal_name = terminal_name or terminal_data.name
|
|
||||||
|
|
||||||
if remote:
|
|
||||||
REMOTE_LABEL = _("Execute in Remote {}")
|
|
||||||
REMOTE_TIP = _("Execute {} in {} via SSH")
|
|
||||||
LOCAL_LABEL = _("Execute in Local {}")
|
|
||||||
LOCAL_TIP = _("Execute {} in Local {}")
|
|
||||||
|
|
||||||
tip = REMOTE_TIP.format(file.get_name(), terminal_name)
|
|
||||||
item = FileManager.MenuItem(
|
|
||||||
name=executable_menu_item_id(remote=True),
|
|
||||||
label=REMOTE_LABEL.format(terminal_name),
|
|
||||||
tip=tip,
|
|
||||||
)
|
|
||||||
item.connect("activate", callback, file, True)
|
|
||||||
items.append(item)
|
|
||||||
else:
|
|
||||||
LOCAL_LABEL = _("Execute in {}")
|
|
||||||
LOCAL_TIP = _("Execute {} in {}")
|
|
||||||
|
|
||||||
tip = LOCAL_TIP.format(file.get_name(), terminal_name)
|
|
||||||
item = FileManager.MenuItem(
|
|
||||||
name=executable_menu_item_id(remote=False),
|
|
||||||
label=LOCAL_LABEL.format(terminal_name),
|
|
||||||
tip=tip,
|
|
||||||
)
|
|
||||||
item.connect("activate", callback, file, False)
|
|
||||||
items.append(item)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def is_executable(file: Gio.File) -> bool:
|
|
||||||
try:
|
|
||||||
attributes = file.query_info("access::can-execute", Gio.FileQueryInfoFlags.NONE)
|
|
||||||
except GLib.Error:
|
|
||||||
return False
|
|
||||||
return attributes.get_attribute_boolean("access::can-execute")
|
|
||||||
|
|
||||||
|
|
||||||
def set_terminal_args(*_args):
|
|
||||||
# pylint: disable=possibly-used-before-assignment
|
|
||||||
"""set the terminal_cmd to the correct values"""
|
|
||||||
global new_tab
|
|
||||||
global flatpak
|
|
||||||
global terminal_cmd
|
|
||||||
global terminal_data
|
|
||||||
global custom_local_command
|
|
||||||
global custom_remote_command
|
|
||||||
value = _gsettings.get_string(GSETTINGS_TERMINAL)
|
|
||||||
newer_tab = _gsettings.get_boolean(GSETTINGS_NEW_TAB)
|
|
||||||
flatpak = FLATPAK_PARMS[_gsettings.get_enum(GSETTINGS_FLATPAK)]
|
|
||||||
new_terminal_data = TERMINALS.get(value)
|
|
||||||
if not new_terminal_data:
|
|
||||||
print(f'open-any-terminal: unknown terminal "{value}"')
|
|
||||||
return
|
|
||||||
|
|
||||||
global terminal
|
|
||||||
terminal = value
|
|
||||||
terminal_data = new_terminal_data
|
|
||||||
if newer_tab and terminal_data.new_tab_arguments:
|
|
||||||
new_tab = newer_tab
|
|
||||||
new_tab_text = "opening in a new tab"
|
|
||||||
else:
|
|
||||||
new_tab_text = "opening a new window"
|
|
||||||
if newer_tab and not terminal_data.new_tab_arguments:
|
|
||||||
new_tab_text += " (terminal does not support tabs)"
|
|
||||||
if flatpak != FLATPAK_PARMS[0] and terminal_data.flatpak_package is not None:
|
|
||||||
terminal_cmd = ["flatpak", "run", "--" + flatpak, terminal_data.flatpak_package]
|
|
||||||
flatpak_text = f"with flatpak as {flatpak}"
|
|
||||||
else:
|
|
||||||
terminal_cmd = [terminal]
|
|
||||||
if terminal == "blackbox" and "fedora" in distro_id():
|
|
||||||
# It's called like this on fedora
|
|
||||||
terminal_cmd[0] = "blackbox-terminal"
|
|
||||||
flatpak = FLATPAK_PARMS[0]
|
|
||||||
flatpak_text = ""
|
|
||||||
|
|
||||||
if terminal == "custom":
|
|
||||||
terminal_cmd = []
|
|
||||||
custom_local_command = _gsettings.get_string(GSETTINGS_CUSTOM_LOCAL_COMMAND)
|
|
||||||
custom_remote_command = _gsettings.get_string(GSETTINGS_CUSTOM_REMOTE_COMMAND)
|
|
||||||
elif new_tab and terminal_data.new_tab_arguments:
|
|
||||||
terminal_cmd.extend(terminal_data.new_tab_arguments)
|
|
||||||
elif terminal_data.new_window_arguments:
|
|
||||||
terminal_cmd.extend(terminal_data.new_window_arguments)
|
|
||||||
|
|
||||||
print(f'open-any-terminal: terminal is set to "{terminal}" {new_tab_text} {flatpak_text}')
|
|
||||||
|
|
||||||
|
|
||||||
if API_VERSION == ("4.0", "4.1"):
|
|
||||||
|
|
||||||
class OpenAnyTerminalShortcutProvider(GObject.GObject, FileManager.MenuProvider):
|
|
||||||
"""Provide keyboard shortcuts for opening terminals in Nautilus."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.previous_cwd = expanduser("~")
|
|
||||||
|
|
||||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
|
||||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
|
||||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
|
||||||
self._setup_keybindings()
|
|
||||||
|
|
||||||
def get_background_items(self, current_folder: FileManager.FileInfo):
|
|
||||||
"""Update current URI when folder changes."""
|
|
||||||
if current_folder:
|
|
||||||
if current_folder.get_uri_scheme() in REMOTE_URI_SCHEME:
|
|
||||||
folder_path = current_folder.get_uri()
|
|
||||||
else:
|
|
||||||
folder_path = current_folder.get_location().get_path()
|
|
||||||
|
|
||||||
if folder_path and folder_path != self.previous_cwd:
|
|
||||||
self.previous_cwd = folder_path
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _open_terminal(self, *_args):
|
|
||||||
"""Open the terminal at the specified URI."""
|
|
||||||
if self._gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
|
|
||||||
open_remote_terminal_in_uri(self.previous_cwd)
|
|
||||||
else:
|
|
||||||
open_local_terminal_in_uri(self.previous_cwd)
|
|
||||||
|
|
||||||
def _setup_keybindings(self):
|
|
||||||
"""Set up custom keybindings for the extension."""
|
|
||||||
self.app = Gtk.Application.get_default()
|
|
||||||
if self.app is None:
|
|
||||||
print("No Gtk.Application found. Keybindings cannot be set.")
|
|
||||||
return
|
|
||||||
|
|
||||||
action = Gio.SimpleAction.new("open_any_terminal", None)
|
|
||||||
action.connect("activate", self._open_terminal)
|
|
||||||
self.app.add_action(action)
|
|
||||||
self._bind_shortcut()
|
|
||||||
self._gsettings.connect("changed", self._update_shortcut)
|
|
||||||
|
|
||||||
def _update_shortcut(self, _gsettings, key):
|
|
||||||
"""remove keybinding"""
|
|
||||||
if key == GSETTINGS_KEYBINDINGS:
|
|
||||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
|
||||||
self._bind_shortcut()
|
|
||||||
|
|
||||||
def _bind_shortcut(self):
|
|
||||||
"""Parse and update keybindings when settings change."""
|
|
||||||
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
|
|
||||||
|
|
||||||
if not shortcut:
|
|
||||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
|
||||||
return
|
|
||||||
|
|
||||||
valid, key, mods = Gtk.accelerator_parse(shortcut)
|
|
||||||
if not valid:
|
|
||||||
print("Invalid shortcut in GSettings: %r", shortcut)
|
|
||||||
self.app.set_accels_for_action("app.open_any_terminal", [])
|
|
||||||
return
|
|
||||||
|
|
||||||
normalized = Gtk.accelerator_name(key, mods)
|
|
||||||
self.app.set_accels_for_action("app.open_any_terminal", [normalized])
|
|
||||||
|
|
||||||
elif API_VERSION in ("3.0", "2.0"):
|
|
||||||
|
|
||||||
class OpenAnyTerminalShortcutProviderLegacy(GObject.GObject, FileManager.LocationWidgetProvider):
|
|
||||||
"""Provide keyboard shortcuts for opening terminals in Nautilus/Caja."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
|
||||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
|
||||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
|
||||||
self._gsettings.connect("changed", self._bind_shortcut)
|
|
||||||
self._create_accel_group()
|
|
||||||
self._window = None
|
|
||||||
self._uri = None
|
|
||||||
|
|
||||||
def _create_accel_group(self):
|
|
||||||
self._accel_group = Gtk.AccelGroup()
|
|
||||||
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
|
|
||||||
key, mod = Gtk.accelerator_parse(shortcut)
|
|
||||||
self._accel_group.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._open_terminal)
|
|
||||||
|
|
||||||
def _bind_shortcut(self, _gsettings, key):
|
|
||||||
if key == GSETTINGS_KEYBINDINGS:
|
|
||||||
self._accel_group.disconnect(self._open_terminal)
|
|
||||||
self._create_accel_group()
|
|
||||||
|
|
||||||
def _open_terminal(self, *_args):
|
|
||||||
if _gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
|
|
||||||
open_local_terminal_in_uri(self._uri)
|
|
||||||
else:
|
|
||||||
open_remote_terminal_in_uri(self._uri)
|
|
||||||
|
|
||||||
def get_widget(self, uri, window):
|
|
||||||
"""follows uri and sets the correct window"""
|
|
||||||
self._uri = uri
|
|
||||||
if self._window:
|
|
||||||
self._window.remove_accel_group(self._accel_group)
|
|
||||||
if self._gsettings:
|
|
||||||
window.add_accel_group(self._accel_group)
|
|
||||||
self._window = window
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAnyTerminalExtension(GObject.GObject, FileManager.MenuProvider):
|
|
||||||
"""Provide context menu items for opening terminals in Nautilus."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
gsettings_source = Gio.SettingsSchemaSource.get_default()
|
|
||||||
if gsettings_source.lookup(GSETTINGS_PATH, True):
|
|
||||||
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
|
||||||
|
|
||||||
def _get_terminal_name(self):
|
|
||||||
if self._gsettings.get_boolean(GSETTINGS_USE_GENERIC_TERMINAL_NAME):
|
|
||||||
return _("Terminal")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _menu_dir_activate_cb(self, menu, file_, remote: bool):
|
|
||||||
if remote:
|
|
||||||
open_remote_terminal_in_uri(file_.get_uri())
|
|
||||||
else:
|
|
||||||
if file_.get_uri_scheme() == "smb":
|
|
||||||
file_uri = "file://" + quote(file_.get_location().get_path())
|
|
||||||
else:
|
|
||||||
file_uri = file_.get_uri()
|
|
||||||
open_local_terminal_in_uri(file_uri)
|
|
||||||
|
|
||||||
def _menu_exe_activate_cb(self, menu, file_, remote: bool):
|
|
||||||
if remote:
|
|
||||||
cmd = ssh_command_from_uri(file_.get_uri(), is_directory=False)
|
|
||||||
else:
|
|
||||||
result = urlparse(file_.get_uri())
|
|
||||||
file = unquote(result.path)
|
|
||||||
|
|
||||||
if result.scheme == "admin":
|
|
||||||
cmd = ["sudo", file]
|
|
||||||
elif terminal in ["xterm", "uxterm"]:
|
|
||||||
cmd = [f"exec {shlex.quote(file)}"]
|
|
||||||
else:
|
|
||||||
cmd = [file]
|
|
||||||
run_command_in_terminal(cmd)
|
|
||||||
|
|
||||||
def get_file_items(self, *args):
|
|
||||||
"""Generates a list of menu items for a file or folder in the Nautilus file manager."""
|
|
||||||
# `args` will be `[files: List[Nautilus.FileInfo]]` in Nautilus 4.0 API,
|
|
||||||
# and `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]` in Nautilus 3.0 API.
|
|
||||||
|
|
||||||
files = args[-1]
|
|
||||||
|
|
||||||
if len(files) != 1:
|
|
||||||
return []
|
|
||||||
file_ = files[0]
|
|
||||||
|
|
||||||
if file_.is_directory():
|
|
||||||
return get_directory_menu_items(
|
|
||||||
file_, self._menu_dir_activate_cb, foreground=True, terminal_name=self._get_terminal_name()
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_executable(file_.get_location()):
|
|
||||||
return get_executable_menu_items(file_, self._menu_exe_activate_cb, terminal_name=self._get_terminal_name())
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_background_items(self, *args):
|
|
||||||
"""Generates a list of background menu items for a file or folder in the Nautilus file manager."""
|
|
||||||
# `args` will be `[folder: Nautilus.FileInfo]` in Nautilus 4.0 API,
|
|
||||||
# and `[window: Gtk.Widget, file: Nautilus.FileInfo]` in Nautilus 3.0 API.
|
|
||||||
|
|
||||||
file_ = args[-1]
|
|
||||||
return get_directory_menu_items(
|
|
||||||
file_, self._menu_dir_activate_cb, foreground=False, terminal_name=self._get_terminal_name()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
source = Gio.SettingsSchemaSource.get_default()
|
|
||||||
if source is not None and source.lookup(GSETTINGS_PATH, True):
|
|
||||||
_gsettings = Gio.Settings.new(GSETTINGS_PATH)
|
|
||||||
_gsettings.connect("changed", set_terminal_args)
|
|
||||||
set_terminal_args()
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
#
|
#
|
||||||
# btop ships no Tokyo Night light variant at all, and the nearest stock light
|
# 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
|
# 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.
|
# and what runs inside it cannot disagree.
|
||||||
#
|
#
|
||||||
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
# Gradients keep the same low -> middle -> high meaning as the dark theme, using
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Emulator",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"wmClass": "jetbrains-toolbox",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.amezin.ddterm",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Com.github.donadigo.eddy",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Conky",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Gnome-initial-setup",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "org.gnome.Calculator",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "gnome-terminal-preferences",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Guake",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "zoom",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Hidamari",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.mattjakeman.ExtensionManager",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Cider",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Ulauncher",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "com.nextcloud.desktopclient.nextcloud",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "mpv",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "Spotify",
|
|
||||||
"mode": "float"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"wmClass": "bitwarden",
|
|
||||||
"mode": "float"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
.tiled {
|
|
||||||
color: rgba(236, 94, 94, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split {
|
|
||||||
color: rgba(255, 246, 108, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stacked {
|
|
||||||
color: rgba(247, 162, 43, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbed {
|
|
||||||
color: rgba(17, 199, 224, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floated {
|
|
||||||
color: rgba(180, 167, 214, 1);
|
|
||||||
border-width: 3px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tiled-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-split-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
|
||||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
|
||||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
|
||||||
|
|
||||||
.window-split-horizontal {
|
|
||||||
border-left-width: 0;
|
|
||||||
border-top-width: 0;
|
|
||||||
border-bottom-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-split-vertical {
|
|
||||||
border-left-width: 0;
|
|
||||||
border-top-width: 0;
|
|
||||||
border-right-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-stacked-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-bg {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab {
|
|
||||||
background-color: rgba(54, 47, 45, 1);
|
|
||||||
border-color: rgba(98,160,234,0.6);
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: white;
|
|
||||||
margin: 1px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-active {
|
|
||||||
background-color: rgb(98,160,234);
|
|
||||||
color: black;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-close {
|
|
||||||
padding: 3px;
|
|
||||||
margin: 4px;
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 16px;
|
|
||||||
background-color: #e06666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-icon {
|
|
||||||
margin: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-floated-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-tiled {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-stacked {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-swap {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(162, 247, 43, 0.4);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(162, 247, 43, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-tabbed {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keybindings Cheatsheet Overlay */
|
|
||||||
|
|
||||||
.forge-cheatsheet {
|
|
||||||
background-color: #2d2d2d;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px 32px;
|
|
||||||
border: 1px solid #4d4d4d;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-category {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #78aeed;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-row {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #eeeeec;
|
|
||||||
margin: 4px 0;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-key {
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #f5c211;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-desc {
|
|
||||||
color: #d0cfcc;
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
.tiled {
|
|
||||||
color: rgba(236, 94, 94, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split {
|
|
||||||
color: rgba(255, 246, 108, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stacked {
|
|
||||||
color: rgba(247, 162, 43, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbed {
|
|
||||||
color: rgba(17, 199, 224, 1);
|
|
||||||
opacity: 1;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floated {
|
|
||||||
color: rgba(180, 167, 214, 1);
|
|
||||||
border-width: 3px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tiled-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-split-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* forge-36le: St does not paint an asymmetric (single-side) border when a
|
|
||||||
* border-radius is set, so the directional hint never rendered. Drop the radius
|
|
||||||
* on the one-edge variants; St paints a straight single-side border reliably. */
|
|
||||||
|
|
||||||
.window-split-horizontal {
|
|
||||||
border-left-width: 0;
|
|
||||||
border-top-width: 0;
|
|
||||||
border-bottom-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-split-vertical {
|
|
||||||
border-left-width: 0;
|
|
||||||
border-top-width: 0;
|
|
||||||
border-right-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-stacked-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-border {
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-bg {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab {
|
|
||||||
background-color: rgba(54, 47, 45, 1);
|
|
||||||
border-color: rgba(98,160,234,0.6);
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: white;
|
|
||||||
margin: 1px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-active {
|
|
||||||
background-color: rgb(98,160,234);
|
|
||||||
color: black;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-close {
|
|
||||||
padding: 3px;
|
|
||||||
margin: 4px;
|
|
||||||
border-radius: 16px;
|
|
||||||
width: 16px;
|
|
||||||
background-color: #e06666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tabbed-tab-icon {
|
|
||||||
margin: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-floated-border {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgb(98,160,234);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-tiled {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-stacked {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-swap {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(162, 247, 43, 0.4);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(162, 247, 43, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-tilepreview-tabbed {
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: rgba(98,160,234,0.3);
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: 14px;
|
|
||||||
background-color: rgba(98,160,234,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keybindings Cheatsheet Overlay */
|
|
||||||
|
|
||||||
.forge-cheatsheet {
|
|
||||||
background-color: #2d2d2d;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px 32px;
|
|
||||||
border: 1px solid #4d4d4d;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-category {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #78aeed;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-row {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #eeeeec;
|
|
||||||
margin: 4px 0;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-key {
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #f5c211;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forge-cheatsheet-desc {
|
|
||||||
color: #d0cfcc;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GENERATED FILE -- edit settings.ini.template instead.
|
# 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
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GENERATED FILE -- edit settings.ini.template instead.
|
# 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
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
# XSETTINGS and this file is ignored; under Hyprland there is no
|
# 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 |
|
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
|
||||||
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
|
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
|
||||||
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
|
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
|
||||||
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
| Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
|
||||||
| Dock | Quickshell dock with GNOME favourites and intellihide | 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 |
|
| 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 |
|
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
|
||||||
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
| 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 |
|
| Removable media | udiskie plus udisks notifications | Live |
|
||||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | 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 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, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | 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
|
## System health and recovery
|
||||||
|
|
||||||
Panama stays silent while the desktop is healthy. A compact bar indicator
|
Panama stays silent while the desktop is healthy. A compact bar indicator
|
||||||
appears only for actionable warnings or errors and opens the same **System
|
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:
|
System Health**. The terminal summary is available with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,7 +59,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
|
|||||||
arbitrary configuration.
|
arbitrary configuration.
|
||||||
|
|
||||||
Generic Fedora configuration remains with the system tools that own it. The
|
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
|
profiles, and digital wellbeing to their exact GNOME Settings panels rather
|
||||||
than presenting inert Hyprland controls.
|
than presenting inert Hyprland controls.
|
||||||
|
|
||||||
@@ -77,9 +77,9 @@ than presenting inert Hyprland controls.
|
|||||||
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
| Caffeine | Replaced by a real logind inhibitor in quick settings |
|
||||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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>
|
- <https://github.com/hyprwm/Hyprland/discussions/1358>
|
||||||
|
|
||||||
A future focused-window menu could expose compositor actions such as float,
|
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.
|
||||||
|
|||||||
+16
-16
@@ -30,7 +30,7 @@ Don't "fix" them.
|
|||||||
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
| `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 |
|
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
|
||||||
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
| `rules.lua` | Window rules, gaming rules, layer rules for the shell |
|
||||||
| `keybinds.lua` | The full keymap |
|
| `keybinds.lua` | The full keymap |
|
||||||
@@ -87,8 +87,8 @@ persistence, validation, reset, and the live write are all derived from that
|
|||||||
entry.
|
entry.
|
||||||
|
|
||||||
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
|
`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
|
`tests/hypr/prefs-fallback-contract` pins that, including that Hyprland still
|
||||||
accepts the config in each of those states.
|
accepts the config in each of those states.
|
||||||
|
|
||||||
### Adding a keybind: use `bind`, not `hl.bind`
|
### Adding a keybind: use `bind`, not `hl.bind`
|
||||||
@@ -120,7 +120,7 @@ Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
|||||||
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||||||
one has nothing readable beside its chord, and the Settings app drops it from the
|
one has nothing readable beside its chord, and the Settings app drops it from the
|
||||||
Input & Shortcuts page rather than showing a mystery row.
|
Input & Shortcuts page rather than showing a mystery row.
|
||||||
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
`tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
|
||||||
this cannot regress silently.
|
this cannot regress silently.
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
@@ -163,7 +163,7 @@ four places, and nowhere else —
|
|||||||
dock, popovers), running blue on the left to orchid on the right and fading
|
dock, popovers), running blue on the left to orchid on the right and fading
|
||||||
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
out before the corners. See `quickshell/widgets/PrismEdge.qml`.
|
||||||
2. The focused window's border — `general.col.active_border`, blue→orchid at
|
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.
|
means something if one window on screen is wearing it.
|
||||||
3. The active workspace pill in the bar.
|
3. The active workspace pill in the bar.
|
||||||
4. Slider fills.
|
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
|
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
|
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`
|
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
|
Anything whose digits change in place — the clock, the vitals percentages, the
|
||||||
recording timer, the selection readout — sets `font.features:
|
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 + SHIFT + H/J/K/L` | Move window |
|
||||||
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
| `SUPER + CTRL + H/J/K/L` | Swap window |
|
||||||
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
|
| `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 + [` / `]` / `=` | Shrink / expand / reset split |
|
||||||
| `SUPER + Q` | Close |
|
| `SUPER + Q` | Close |
|
||||||
| `SUPER + U` | Fullscreen |
|
| `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 + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
|
||||||
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
|
||||||
| `SUPER + SHIFT + grave` | Last window |
|
| `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)
|
### Workspaces (dynamic, like GNOME)
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
@@ -270,19 +270,19 @@ The mental model is unchanged from Forge:
|
|||||||
### Launcher and shell
|
### Launcher and shell
|
||||||
| Key | Action |
|
| 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 + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
|
||||||
| `SUPER + V` | Clipboard history |
|
| `SUPER + V` | Clipboard history |
|
||||||
| `SUPER + .` | Emoji picker |
|
| `SUPER + .` | Emoji picker |
|
||||||
| `SUPER + S` | Quick settings |
|
| `SUPER + S` | Quick settings |
|
||||||
| `SUPER + I` | Settings |
|
| `SUPER + I` | Settings |
|
||||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||||
| `SUPER + B` | Notification centre |
|
| `SUPER + B` | Notification center |
|
||||||
| `SUPER + grave` | Workspace overview |
|
| `SUPER + grave` | Workspace overview |
|
||||||
| `Print` | Screenshot / record picker |
|
| `Print` | Screenshot / record picker |
|
||||||
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
|
||||||
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
|
| `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 + L` | Lock (SUPER+L is "focus right") |
|
||||||
| `CTRL + ALT + Delete` | Power menu |
|
| `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
|
one place. Home and Phone continue the same surface rather than opening extra
|
||||||
dashboard windows.
|
dashboard windows.
|
||||||
|
|
||||||
Home shows the first four selected favourites at rest and every selected light
|
Home shows the first four selected favorites at rest and every selected light
|
||||||
when expanded. Use **Settings → Home & Phone** to choose favourites,
|
when expanded. Use **Settings → Home & Phone** to choose favorites,
|
||||||
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||||
only previews the value; releasing it sends one brightness request. A normal
|
only previews the value; releasing it sends one brightness request. A normal
|
||||||
power toggle leaves Home Assistant responsible for restoring its previous
|
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
|
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||||||
existing GNOME extension and Secret Service setup retained as a compatibility
|
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
|
restart is required after changing credentials; close and reopen Control Center
|
||||||
to refresh. If Home Assistant is offline, the last known values stay visible
|
to refresh. If Home Assistant is offline, the last known values stay visible
|
||||||
with a stale-state label and Retry action.
|
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
|
- **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
|
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
|
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
|
||||||
modifier misfires when you're quick with SUPER combos.
|
modifier misfires when you're quick with SUPER combos.
|
||||||
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap.
|
- **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("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
|
||||||
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
hl.exec_cmd("systemctl --user start hyprland-session.target")
|
||||||
|
|
||||||
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock.
|
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
|
||||||
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
-- 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.
|
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
-- 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.
|
-- Keyring unlock, for Nextcloud and Bitwarden credential storage.
|
||||||
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
|
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
|
||||||
|
|
||||||
-- Tray applications carried over from ~/.config/autostart. These need the
|
-- Nextcloud and Bitwarden are NOT started here. Both ship a
|
||||||
-- Quickshell tray (an SNI host) to be up, hence starting after it.
|
-- ~/.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
|
-- RustDesk is deliberately absent too: it ships an enabled *system*
|
||||||
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and
|
-- service (`rustdesk --service`) that spawns --server and --tray for the
|
||||||
-- its autostart entry launches a specific script with --autostart.
|
-- session on its own. Starting it here as well would give you two trays.
|
||||||
--
|
|
||||||
-- 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")
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
hl.on("hyprland.shutdown", function()
|
hl.on("hyprland.shutdown", function()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
-- Environment
|
-- 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.
|
-- makes it valid for AQ_* and cursor variables.
|
||||||
--
|
--
|
||||||
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
|
-- 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
|
-- "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
|
-- 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.
|
-- covers Qt5 and Qt6 with one value.
|
||||||
--
|
--
|
||||||
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
|
-- 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:
|
# Mirrors the GNOME power profile this replaces, exactly:
|
||||||
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
|
# 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.
|
-- 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.
|
-- and nothing else.
|
||||||
require("prefs")
|
require("prefs")
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
#
|
#
|
||||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
# 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
|
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||||
# stayed dark when everything else went light.
|
# stayed dark when everything else went light.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# hyprland-guiutils hyprland-dialog / hyprland-run
|
# hyprland-guiutils hyprland-dialog / hyprland-run
|
||||||
# hyprpolkitagent authentication prompts (via hyprland-qt-support)
|
# 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
|
# hypr/looks.lua. Format is hyprlang; path is fixed at
|
||||||
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
|
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ hl.config({
|
|||||||
repeat_rate = prefs.get("keyRepeatRate", 33),
|
repeat_rate = prefs.get("keyRepeatRate", 33),
|
||||||
|
|
||||||
-- 1 = FOLLOW. The window under the pointer takes focus. This comment
|
-- 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
|
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
|
||||||
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
|
-- 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
|
-- decision rather than a correction, so the value is left alone and
|
||||||
-- only the description is fixed; Settings exposes all four.
|
-- only the description is fixed; Settings exposes all four.
|
||||||
follow_mouse = prefs.getInt("followMouse", 1),
|
follow_mouse = prefs.getInt("followMouse", 1),
|
||||||
@@ -44,7 +44,28 @@ hl.config({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
|
-- ── Touchpad gestures ───────────────────────────────────────────────────────
|
||||||
-- runs this config, add touchpad settings in overrides.lua.
|
--
|
||||||
|
-- GNOME's gestures, reproduced: three fingers sideways moves between
|
||||||
|
-- workspaces, three fingers up opens the overview, three fingers down closes
|
||||||
|
-- it. That is the same muscle memory the keybinds were built to preserve.
|
||||||
|
--
|
||||||
|
-- Registered unconditionally rather than behind a preference. Hyprland 0.56
|
||||||
|
-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
|
||||||
|
-- registration is read at config time -- so a toggle would need a reload to
|
||||||
|
-- take effect, which is worse than the nothing these cost on a machine with no
|
||||||
|
-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
|
||||||
|
-- to travel, and which way round it goes.
|
||||||
|
--
|
||||||
|
-- open and close rather than toggle twice: with a toggle on both directions,
|
||||||
|
-- swiping up from an already-open overview would close it, and swiping down
|
||||||
|
-- would reopen it. GNOME does not do that, and neither does this.
|
||||||
|
local overview = function(fn)
|
||||||
|
return function() hl.exec_cmd("qs ipc call overview " .. fn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
||||||
|
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
|
||||||
|
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ local osd = function(action)
|
|||||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local dictate = function(action)
|
||||||
|
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
|
||||||
|
end
|
||||||
|
|
||||||
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||||
|
|
||||||
@@ -118,7 +122,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
|
|||||||
-- which Hyprland has no equivalent for.
|
-- which Hyprland has no equivalent for.
|
||||||
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
|
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" })
|
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
|
||||||
|
|
||||||
-- Screenshot / screen record. One key, then pick screen / window / region and
|
-- Screenshot / screen record. One key, then pick screen / window / region and
|
||||||
@@ -133,8 +137,8 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
|
|||||||
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
||||||
{ description = "Screen Intelligence" })
|
{ description = "Screen Intelligence" })
|
||||||
|
|
||||||
-- Colour picker: copies the hex under the cursor to the clipboard.
|
-- Color 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" })
|
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||||
|
|
||||||
-- ── Window management ───────────────────────────────────────────────────────
|
-- ── Window management ───────────────────────────────────────────────────────
|
||||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
@@ -180,20 +184,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
|
|||||||
|
|
||||||
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
-- Resize (Forge: window-resize-<edge>-<increase|decrease>).
|
||||||
--
|
--
|
||||||
-- Forge resized one named edge at a time. Hyprland resizes the active window
|
-- Forge resized one named EDGE at a time: its resize() grows the window for a
|
||||||
-- along an axis and lets the layout decide which edge actually moves, so the
|
-- positive amount in every direction, and the edge only decides which side
|
||||||
-- eight Forge keys collapse onto four behaviours. The pairing is kept
|
-- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
|
||||||
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical,
|
-- an axis and lets the layout choose the border, so those eight distinct
|
||||||
-- and "increase" always grows while "decrease" always shrinks.
|
-- 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
|
local step = 60
|
||||||
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
|
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 = "Wider" })
|
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 = "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 = "Narrower" })
|
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 = "Taller" })
|
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 = "Taller" })
|
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||||
bind(mod .. " + SHIFT + P", 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
|
||||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
-- 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
|
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||||
-- showing what you are choosing between.
|
-- showing what you are choosing between.
|
||||||
@@ -223,6 +237,17 @@ bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description
|
|||||||
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
|
||||||
-- moving right past the last workspace creates a new one, and moving left from
|
-- moving right past the last workspace creates a new one, and moving left from
|
||||||
-- the first clamps instead of wrapping.
|
-- 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 + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
||||||
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
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" })
|
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
|
||||||
@@ -245,18 +270,18 @@ end
|
|||||||
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
|
||||||
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
|
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
|
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
|
||||||
-- no dispatcher, no config option and not even an event to hook, so titlebar
|
-- 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.
|
-- iconified state and no taskbar to restore from.
|
||||||
--
|
--
|
||||||
-- The scratchpad is the honest equivalent: the window goes away, and the same
|
-- 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.
|
-- 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 .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
|
||||||
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" })
|
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
|
||||||
|
|
||||||
-- ── Session ─────────────────────────────────────────────────────────────────
|
-- ── Session ─────────────────────────────────────────────────────────────────
|
||||||
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
|
||||||
@@ -272,6 +297,26 @@ bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = t
|
|||||||
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||||
|
|
||||||
|
-- ── Dictation ───────────────────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
|
||||||
|
-- while the key is down, so it cannot be left listening by forgetting about it.
|
||||||
|
-- Two binds on one chord, the second flagged `release`.
|
||||||
|
--
|
||||||
|
-- No `repeating`: holding a key normally repeats the press, which would restart
|
||||||
|
-- the recording several times a second. The daemon refuses a second start while
|
||||||
|
-- one is running, so a repeat would be harmless -- but not asking for it is
|
||||||
|
-- better than relying on being refused.
|
||||||
|
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
|
||||||
|
{ description = "Dictate (hold to talk)" })
|
||||||
|
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
|
||||||
|
{ release = true, description = "Dictate (transcribe on release)" })
|
||||||
|
|
||||||
|
-- Escape out of a recording without transcribing it. Bound to the same modifier
|
||||||
|
-- so it can be reached with the dictation key still held.
|
||||||
|
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
|
||||||
|
{ description = "Cancel dictation" })
|
||||||
|
|
||||||
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
||||||
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||||
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||||
|
|||||||
+51
-11
@@ -1,8 +1,8 @@
|
|||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
-- Look and feel -- Tokyo Night Moon
|
-- Look and feel -- Tokyo Night Moon
|
||||||
--
|
--
|
||||||
-- Colours here must stay in sync with quickshell/config/Theme.qml.
|
-- Colors here must stay in sync with quickshell/config/Theme.qml.
|
||||||
-- accent #82aaff borders / focus
|
-- accent user-selectable, see `accents` below -- blue (#82aaff) ships
|
||||||
-- bg #222436 base
|
-- bg #222436 base
|
||||||
--
|
--
|
||||||
-- Performance note: every animation below is event-driven. Nothing uses the
|
-- Performance note: every animation below is event-driven. Nothing uses the
|
||||||
@@ -14,6 +14,30 @@
|
|||||||
|
|
||||||
local prefs = require("prefs")
|
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({
|
hl.config({
|
||||||
general = {
|
general = {
|
||||||
gaps_in = prefs.get("gapsIn", 5),
|
gaps_in = prefs.get("gapsIn", 5),
|
||||||
@@ -22,12 +46,14 @@ hl.config({
|
|||||||
border_size = prefs.get("borderSize", 2),
|
border_size = prefs.get("borderSize", 2),
|
||||||
|
|
||||||
col = {
|
col = {
|
||||||
-- The focused accent role: blue leads, orchid follows, on a
|
-- The focused accent role, on a diagonal so the pair is visible
|
||||||
-- diagonal so the pair is visible on both a tall and a wide
|
-- on both a tall and a wide window. Driven by the chosen
|
||||||
-- window. ColorScheme never writes this role; a future accent
|
-- accentName (see the `accents` table above); services/
|
||||||
-- picker can own it without fighting light/dark mode.
|
-- ColorScheme.qml applies the same values live, and restates them
|
||||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
-- from Theme.accent/Theme.accentSecondary on every scheme change
|
||||||
-- The neutral inactive role follows the colour scheme because a
|
-- 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.
|
-- dark neutral disappears against a light desktop.
|
||||||
-- services/ColorScheme.qml applies the same values live; this is
|
-- services/ColorScheme.qml applies the same values live; this is
|
||||||
-- the value a fresh session starts from.
|
-- 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
|
-- New in 0.56. Kept deliberately faint: in this direction the gradient
|
||||||
-- border is the signature, and a strong halo would compete with it.
|
-- border is the signature, and a strong halo would compete with it.
|
||||||
-- This is just enough to lift the focused window off the wallpaper.
|
-- 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 = {
|
glow = {
|
||||||
enabled = prefs.get("glowEnabled", true),
|
enabled = prefs.get("glowEnabled", true),
|
||||||
range = prefs.get("glowRange", 8),
|
range = prefs.get("glowRange", 8),
|
||||||
render_power = 2,
|
render_power = 2,
|
||||||
color = "rgba(82aaff33)",
|
color = "rgba(" .. accentStart .. "33)",
|
||||||
color_inactive = "rgba(00000000)",
|
color_inactive = "rgba(00000000)",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -165,12 +193,24 @@ hl.config({
|
|||||||
-- Let a crashed lock screen be recovered rather than stranding you.
|
-- Let a crashed lock screen be recovered rather than stranding you.
|
||||||
allow_session_lock_restore = true,
|
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,
|
focus_on_activate = false,
|
||||||
|
|
||||||
|
-- Window swallowing: a terminal hides itself while a graphical
|
||||||
|
-- application launched from it is open, and comes back when that
|
||||||
|
-- application exits. Off by default -- it is a real change in how the
|
||||||
|
-- desktop behaves, and one that is confusing rather than broken if you
|
||||||
|
-- did not ask for it: your terminal appears to vanish.
|
||||||
|
--
|
||||||
|
-- The regex is narrow on purpose. Anything matching it can swallow, so
|
||||||
|
-- a permissive pattern means windows disappearing in cases nobody
|
||||||
|
-- intended. Only the two terminals this desktop actually ships.
|
||||||
|
enable_swallow = prefs.get("windowSwallow", false),
|
||||||
|
swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
|
||||||
},
|
},
|
||||||
|
|
||||||
render = {
|
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,
|
cm_enabled = true,
|
||||||
|
|
||||||
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
-- 1 = automatically flip the monitor into HDR for fullscreen content
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ local prefs = require("prefs")
|
|||||||
-- x = 0, y = 0, primary = true,
|
-- 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
|
-- 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
|
-- (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.
|
-- 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
|
-- 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.
|
-- carries the panel-specific 10-bit policy documented above.
|
||||||
for output, _ in pairs(displays) do
|
for output, _ in pairs(displays) do
|
||||||
if output ~= "DP-2" then
|
if output ~= "DP-2" then
|
||||||
@@ -172,4 +172,40 @@ hl.monitor({
|
|||||||
scale = "auto",
|
scale = "auto",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- ── Workspaces on the primary display only ──────────────────────────────────
|
||||||
|
--
|
||||||
|
-- GNOME offered one workspace choice worth reproducing: whether the other
|
||||||
|
-- screens join in. Off, every monitor has its own workspaces and switching
|
||||||
|
-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no
|
||||||
|
-- rules at all. On, workspaces 1-10 are pinned to the primary display and a
|
||||||
|
-- second screen keeps a workspace of its own that stays put.
|
||||||
|
--
|
||||||
|
-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in
|
||||||
|
-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and
|
||||||
|
-- binding fewer would leave the last few behaving differently from the rest for
|
||||||
|
-- no reason a person could see.
|
||||||
|
--
|
||||||
|
-- The rules are emitted here rather than written live because Hyprland reads
|
||||||
|
-- them at config time and offers no way to remove one afterwards: writing an
|
||||||
|
-- empty monitor leaves the previous binding in place. So the config is the only
|
||||||
|
-- honest source, and applying a change is a reload.
|
||||||
|
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
||||||
|
local primary = nil
|
||||||
|
for output, entry in pairs(displays) do
|
||||||
|
if type(entry) == "table" and entry.primary == true
|
||||||
|
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
|
||||||
|
primary = output
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Without a primary there is nothing to pin to, and guessing one would move
|
||||||
|
-- every workspace onto whichever screen happened to sort first.
|
||||||
|
if primary ~= nil then
|
||||||
|
for i = 1, 10 do
|
||||||
|
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
-- cannot drift apart.
|
-- cannot drift apart.
|
||||||
--
|
--
|
||||||
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
|
-- 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
|
-- never cost them a working compositor. Every failure path returns the caller's
|
||||||
-- fallback.
|
-- fallback.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ hl.window_rule({
|
|||||||
float = true,
|
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({
|
hl.window_rule({
|
||||||
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
|
||||||
float = true,
|
float = true,
|
||||||
@@ -77,6 +77,18 @@ hl.window_rule({
|
|||||||
center = true,
|
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.
|
-- Portal dialogs (file chooser, screen share picker) should always float.
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
|
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,
|
ignore_alpha = 0.3,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Popovers: calendar, quick settings, notification centre, tray menus.
|
-- Popovers: calendar, quick settings, notification center, tray menus.
|
||||||
hl.layer_rule({
|
hl.layer_rule({
|
||||||
name = "qs-popover",
|
name = "qs-popover",
|
||||||
match = { namespace = "^qs-popover" },
|
match = { namespace = "^qs-popover" },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Tokyo Night Moon — the dark theme Panama ships.
|
# Tokyo Night Moon — the dark theme Panama ships.
|
||||||
#
|
#
|
||||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
# 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.
|
# setting; that generated file is gitignored because it is machine state.
|
||||||
#
|
#
|
||||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
-- Hyprland config read.
|
-- Hyprland config read.
|
||||||
--
|
--
|
||||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
-- 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.
|
-- important than matching the desktop.
|
||||||
|
|
||||||
local M = {}
|
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,
|
-- 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
|
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||||
-- becoming a different-looking application when the scheme flips.
|
-- becoming a different-looking application when the scheme flips.
|
||||||
--
|
--
|
||||||
-- The readability fixes below are deliberately dark-only. They were written
|
-- The readability fixes below are deliberately dark-only. They were written
|
||||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
-- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
|
||||||
-- applying them to Day would put light grey text on a light background, which
|
-- applying them to Day would put light gray text on a light background, which
|
||||||
-- is exactly the legibility problem they exist to solve, inverted.
|
-- is exactly the legibility problem they exist to solve, inverted.
|
||||||
|
|
||||||
local panama = require("config.panama")
|
local panama = require("config.panama")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
|
|||||||
[[blocks.segments]]
|
[[blocks.segments]]
|
||||||
type = 'git'
|
type = 'git'
|
||||||
style = 'plain'
|
style = 'plain'
|
||||||
foreground = 'p:grey'
|
foreground = 'p:gray'
|
||||||
background = 'transparent'
|
background = 'transparent'
|
||||||
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.settings
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
Item {
|
||||||
|
width: 680
|
||||||
|
height: editor.implicitHeight
|
||||||
|
|
||||||
|
AccentEditor {
|
||||||
|
id: editor
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeProfilePicker {
|
||||||
|
width: parent.width
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
|
||||||
|
AccentPicker {
|
||||||
|
width: parent.width
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "accent-controls-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify(ThemeProfiles.activeProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjust(target: string, channel: string, ratio: real): string {
|
||||||
|
editor.changeChannel(target, channel, ratio);
|
||||||
|
return status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,7 +101,7 @@ Singleton {
|
|||||||
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
const raw = (parsed && typeof parsed === "object") ? parsed : {};
|
||||||
|
|
||||||
// Upgrade before anything reads a value. A stored key the current
|
// 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
|
// stops taking effect, so the conversion has to happen here rather than
|
||||||
// being noticed later by whoever owns that setting.
|
// being noticed later by whoever owns that setting.
|
||||||
const result = Migrations.apply(raw);
|
const result = Migrations.apply(raw);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Singleton {
|
|||||||
printErrors: false
|
printErrors: false
|
||||||
atomicWrites: true
|
atomicWrites: true
|
||||||
onSaved: root.saveError = ""
|
onSaved: root.saveError = ""
|
||||||
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
onSaveFailed: error => root.saveError = "Could not save Home favorites."
|
||||||
|
|
||||||
JsonAdapter {
|
JsonAdapter {
|
||||||
id: values
|
id: values
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pragma Singleton
|
|||||||
// The schema is the single source of truth for what a setting IS, but it cannot
|
// 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
|
// 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
|
// 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
|
// untouched, so the user's choice simply stops taking effect with nothing to
|
||||||
// say why. That is the failure this exists to prevent.
|
// say why. That is the failure this exists to prevent.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ Singleton {
|
|||||||
label: "Automatically hide the Dock",
|
label: "Automatically hide the Dock",
|
||||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
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,
|
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||||
unit: "ms",
|
unit: "ms",
|
||||||
@@ -107,6 +133,41 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Focus ───────────────────────────────────────────────────────────
|
// ── 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,
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
unit: "min",
|
unit: "min",
|
||||||
@@ -119,6 +180,19 @@ Singleton {
|
|||||||
// These three are written to the compositor and verified by read-back.
|
// These three are written to the compositor and verified by read-back.
|
||||||
// See services/SystemSettings.qml for why the exit code cannot be
|
// See services/SystemSettings.qml for why the exit code cannot be
|
||||||
// trusted for either hyprctl keyword or hyprctl eval.
|
// trusted for either hyprctl keyword or hyprctl eval.
|
||||||
|
// GNOME's Multitasking panel had exactly this choice, and it is the one
|
||||||
|
// worth reproducing: not which workspace goes on which screen, but
|
||||||
|
// whether the second screen participates in workspaces at all.
|
||||||
|
//
|
||||||
|
// No `hypr` block, because this is not an option. It becomes workspace
|
||||||
|
// rules in monitors.lua, and Hyprland reads those at config time and
|
||||||
|
// will not let one be removed afterwards -- so applying a change is a
|
||||||
|
// reload rather than a write, which is what Workspaces.qml owns.
|
||||||
|
{
|
||||||
|
key: "workspacesOnPrimaryOnly", type: "bool", def: false, group: "display",
|
||||||
|
label: "Workspaces on the primary display only",
|
||||||
|
detail: "Other screens keep one workspace of their own rather than switching along with it"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "autoHdr", type: "bool", def: true, group: "display",
|
key: "autoHdr", type: "bool", def: true, group: "display",
|
||||||
label: "Game-aware HDR",
|
label: "Game-aware HDR",
|
||||||
@@ -171,7 +245,7 @@ Singleton {
|
|||||||
unit: "px",
|
unit: "px",
|
||||||
group: "windows",
|
group: "windows",
|
||||||
label: "Inner gaps",
|
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" }
|
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -278,7 +352,7 @@ Singleton {
|
|||||||
// ── Master layout ───────────────────────────────────────────────────
|
// ── Master layout ───────────────────────────────────────────────────
|
||||||
// Only meaningful when the tiling layout is Master and stack. Offering
|
// 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
|
// 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,
|
key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
|
||||||
group: "master",
|
group: "master",
|
||||||
@@ -295,7 +369,7 @@ Singleton {
|
|||||||
{ value: "right", label: "Right" },
|
{ value: "right", label: "Right" },
|
||||||
{ value: "top", label: "Top" },
|
{ value: "top", label: "Top" },
|
||||||
{ value: "bottom", label: "Bottom" },
|
{ value: "bottom", label: "Bottom" },
|
||||||
{ value: "center", label: "Centre" }
|
{ value: "center", label: "Center" }
|
||||||
],
|
],
|
||||||
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
|
||||||
},
|
},
|
||||||
@@ -429,7 +503,7 @@ Singleton {
|
|||||||
{
|
{
|
||||||
key: "keyboardVariant", type: "string", def: "", group: "input",
|
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||||
// Same shape as the layout list and for the same reason: this is
|
// 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})*$",
|
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||||
label: "Layout variant",
|
label: "Layout variant",
|
||||||
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||||
@@ -475,7 +549,7 @@ Singleton {
|
|||||||
// The compositor publishes the authoritative mapping itself --
|
// The compositor publishes the authoritative mapping itself --
|
||||||
// `hyprctl descriptions` gives
|
// `hyprctl descriptions` gives
|
||||||
// map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
|
// 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
|
// the pointer, and the way to actually get click-to-focus was to
|
||||||
// choose "Never". Value 3 was missing entirely.
|
// choose "Never". Value 3 was missing entirely.
|
||||||
//
|
//
|
||||||
@@ -612,6 +686,24 @@ Singleton {
|
|||||||
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Tuning for the three-finger gestures registered in hypr/input.lua.
|
||||||
|
// The gestures themselves are not settings: Hyprland reads a gesture
|
||||||
|
// registration at config time, so switching one on would need a reload,
|
||||||
|
// and these two are the parts it will accept at runtime.
|
||||||
|
{
|
||||||
|
key: "swipeDistance", type: "int", def: 300, min: 100, max: 800, step: 20,
|
||||||
|
unit: "px", group: "touchpad",
|
||||||
|
label: "Swipe distance",
|
||||||
|
detail: "How far a three-finger swipe must travel to change workspace",
|
||||||
|
hypr: { path: ["gestures", "workspace_swipe_distance"], option: "gestures:workspace_swipe_distance", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "swipeInvert", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Natural swipe direction",
|
||||||
|
detail: "Swiping left moves to the workspace on the right, as content follows your fingers",
|
||||||
|
hypr: { path: ["gestures", "workspace_swipe_invert"], option: "gestures:workspace_swipe_invert", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Multitasking ────────────────────────────────────────────────────
|
// ── Multitasking ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||||
@@ -673,6 +765,12 @@ Singleton {
|
|||||||
detail: "An application asking for attention is switched to, rather than only highlighted",
|
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||||
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Hide the terminal that launched a window",
|
||||||
|
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
|
||||||
|
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||||
label: "Pointer changes active display",
|
label: "Pointer changes active display",
|
||||||
@@ -702,6 +800,14 @@ Singleton {
|
|||||||
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
detail: "Moves the magnified view in increments rather than gliding with the pointer",
|
||||||
hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
|
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",
|
key: "dimInactive", type: "bool", def: false, group: "accessibility",
|
||||||
label: "Dim inactive windows",
|
label: "Dim inactive windows",
|
||||||
@@ -716,6 +822,22 @@ Singleton {
|
|||||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
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 ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
@@ -738,6 +860,19 @@ Singleton {
|
|||||||
// ── Desktop background ──────────────────────────────────────────────
|
// ── Desktop background ──────────────────────────────────────────────
|
||||||
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
|
// Applied through hyprpaper's IPC. Not an hl.config option, so it has
|
||||||
// no `hypr` block; services/Wallpaper.qml owns applying it.
|
// no `hypr` block; services/Wallpaper.qml owns applying it.
|
||||||
|
|
||||||
|
// One folder, not four. The picker used to sweep ~/Pictures/Wallpapers,
|
||||||
|
// ~/Pictures/Backgrounds, ~/.local/share/backgrounds and
|
||||||
|
// /usr/share/backgrounds, which meant the distribution's stock
|
||||||
|
// images turned up mixed in with the user's own and there was no
|
||||||
|
// way to say "only mine". Where wallpapers live is a thing somebody
|
||||||
|
// knows about their own machine; it is a setting, not a search.
|
||||||
|
{
|
||||||
|
key: "wallpaperDir", type: "string", def: "Pictures/Wallpapers", group: "wallpaper",
|
||||||
|
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||||
|
label: "Wallpaper folder",
|
||||||
|
detail: "Where the picker looks. Relative to your home folder unless it starts with /"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
|
key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
|
||||||
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
|
// Reaches hyprpaper as the "<output>,<path>" argument form, so a
|
||||||
@@ -852,14 +987,14 @@ Singleton {
|
|||||||
detail: "A time earlier than the start simply means the next morning"
|
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
|
// 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
|
// palette invented to merely not be dark. Both share the same hues at
|
||||||
// different lightness, which is what keeps the Prism identity intact
|
// different lightness, which is what keeps the Prism identity intact
|
||||||
// across the switch.
|
// across the switch.
|
||||||
//
|
//
|
||||||
// services/ColorScheme.qml pushes the choice to GTK and to the
|
// 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
|
// window border still wearing the other scheme is more jarring than
|
||||||
// either scheme on its own.
|
// either scheme on its own.
|
||||||
{
|
{
|
||||||
@@ -871,6 +1006,39 @@ Singleton {
|
|||||||
{ value: "light", label: "Light" }
|
{ 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" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "themeProfileId", type: "string", def: "moon", group: "appearance",
|
||||||
|
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
|
||||||
|
label: "Selected theme profile",
|
||||||
|
detail: "The shipped or saved theme currently applied to the desktop",
|
||||||
|
internal: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "themeProfiles", type: "json", def: [], group: "appearance",
|
||||||
|
label: "Saved theme profiles",
|
||||||
|
detail: "Named custom colour schemes and accent pairs",
|
||||||
|
internal: true
|
||||||
|
},
|
||||||
|
|
||||||
// ── Application themes ─────────────────────────────────────────────
|
// ── Application themes ─────────────────────────────────────────────
|
||||||
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
// ColorScheme owns GTK's light/dark theme. These are the two theme
|
||||||
@@ -978,7 +1146,7 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Application titlebars ──────────────────────────────────────────
|
// ── 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
|
// Hyprland itself has no server-side titlebar buttons, so minimize is
|
||||||
// deliberately absent rather than presented as a switch that lies.
|
// deliberately absent rather than presented as a switch that lies.
|
||||||
{
|
{
|
||||||
@@ -1041,7 +1209,7 @@ Singleton {
|
|||||||
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||||
// A sysfs path rather than a card number, because the number is neither
|
// A sysfs path rather than a card number, because the number is neither
|
||||||
// stable across machines nor meaningful. Constrained to the one shape
|
// 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
|
// path is unreadable, so a stale value degrades to no readout rather
|
||||||
// than a wrong one.
|
// than a wrong one.
|
||||||
{
|
{
|
||||||
@@ -1057,7 +1225,7 @@ Singleton {
|
|||||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
// 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
|
// 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
|
// 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,
|
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||||
group: "weather", internal: true,
|
group: "weather", internal: true,
|
||||||
@@ -1117,25 +1285,23 @@ Singleton {
|
|||||||
// Directories and encoder arguments are enums rather than free text:
|
// Directories and encoder arguments are enums rather than free text:
|
||||||
// both are handed to a recorder process, and an arbitrary string there
|
// both are handed to a recorder process, and an arbitrary string there
|
||||||
// is a much larger surface than a settings page needs to expose.
|
// is a much larger surface than a settings page needs to expose.
|
||||||
|
|
||||||
|
// Free text rather than three choices. The three were a guess at
|
||||||
|
// where somebody keeps screenshots, and a guess cannot include the
|
||||||
|
// folder the rest of their software already writes to -- which is
|
||||||
|
// the only folder that matters. A path starting with / is taken as
|
||||||
|
// absolute, so a drive that is not under home works too.
|
||||||
{
|
{
|
||||||
key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
|
key: "screenshotDir", type: "string", def: "Pictures/Screenshots", group: "capture",
|
||||||
|
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||||
label: "Screenshot folder",
|
label: "Screenshot folder",
|
||||||
detail: "Folder under your home directory for screenshots",
|
detail: "Where screenshots are saved. Relative to your home folder unless it starts with /"
|
||||||
options: [
|
|
||||||
{ value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
|
|
||||||
{ value: "Pictures", label: "Pictures" },
|
|
||||||
{ value: "Desktop", label: "Desktop" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
|
key: "recordingDir", type: "string", def: "Videos/Screencasts", group: "capture",
|
||||||
|
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
|
||||||
label: "Recording folder",
|
label: "Recording folder",
|
||||||
detail: "Folder under your home directory for screen recordings",
|
detail: "Where screen recordings are saved. Relative to your home folder unless it starts with /"
|
||||||
options: [
|
|
||||||
{ value: "Videos/Recordings", label: "Videos / Recordings" },
|
|
||||||
{ value: "Videos", label: "Videos" },
|
|
||||||
{ value: "Desktop", label: "Desktop" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
|
key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
|
||||||
@@ -1168,7 +1334,7 @@ Singleton {
|
|||||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||||
"io.podman_desktop.PodmanDesktop", "claude-desktop",
|
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||||
"codex-desktop", "md.obsidian.Obsidian",
|
"codex-desktop", "md.obsidian.Obsidian",
|
||||||
"com.obsproject.Studio", "steam"
|
"com.obsproject.Studio", "steam"
|
||||||
]
|
]
|
||||||
@@ -1192,7 +1358,7 @@ Singleton {
|
|||||||
|
|
||||||
// ── Display configuration ───────────────────────────────────────────
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
// { "<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
|
// bit depth are deliberately not here: those carry a documented
|
||||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
// you would be changing it.
|
// you would be changing it.
|
||||||
@@ -1305,7 +1471,7 @@ Singleton {
|
|||||||
case "string": {
|
case "string": {
|
||||||
const text = typeof value === "string" ? value : String(value);
|
const text = typeof value === "string" ? value : String(value);
|
||||||
// A constrained string is rejected rather than sanitised. Several
|
// 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
|
// stripping characters would turn a typo into a different setting
|
||||||
// instead of an error the user can see.
|
// instead of an error the user can see.
|
||||||
if (entry.pattern && !new RegExp(entry.pattern).test(text))
|
if (entry.pattern && !new RegExp(entry.pattern).test(text))
|
||||||
|
|||||||
@@ -62,7 +62,12 @@ Singleton {
|
|||||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||||
|
|
||||||
// ── Dock ────────────────────────────────────────────────────────────────
|
// ── 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")
|
readonly property var dockPinned: DesktopPreferences.get("dockPinned")
|
||||||
|
|
||||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
pragma Singleton
|
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
|
// 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
|
// 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,
|
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
|
||||||
@@ -14,11 +14,12 @@ import Quickshell
|
|||||||
// QtQuick is required even though nothing visual is declared here: `color` is a
|
// QtQuick is required even though nothing visual is declared here: `color` is a
|
||||||
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
|
// QtQuick value type, and Qt.rgba() lives in its JS namespace.
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
// ── Color scheme ───────────────────────────────────────────────────────
|
||||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
// 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
|
// 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
|
// same hues at different lightness, which is what lets the Prism identity
|
||||||
@@ -42,13 +43,38 @@ Singleton {
|
|||||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||||
|
|
||||||
// The pair. `accent` is the primary and carries every state meaning
|
// ── The accent ──────────────────────────────────────────────────────────
|
||||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
//
|
||||||
// theme — it never appears alone, only as the far end of a gradient. That
|
// `accent` is the primary and carries every state meaning (focused, active,
|
||||||
// restraint is the whole point: the two colours meeting is the signature,
|
// on). `accentSecondary` never appears alone, only as the far end of a
|
||||||
// so the pink stops being special the moment it's used as a flat fill.
|
// gradient. That restraint is the whole point: the two colors meeting is
|
||||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
// the signature, so the second color stops being special the moment it is
|
||||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
// 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.
|
||||||
|
//
|
||||||
|
// The table itself lives in services/ThemeProfileModel.js so a curated
|
||||||
|
// accent and a custom profile are the same kind of record. Its `gnome`
|
||||||
|
// member is the nearest name in GNOME's own accent-color enum, which is a
|
||||||
|
// fixed list of nine we do not get to extend; it is what libadwaita
|
||||||
|
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
|
||||||
|
// accent here recolors them too instead of leaving them in GNOME blue.
|
||||||
|
readonly property var accents: ThemeProfiles.curatedAccents
|
||||||
|
readonly property var activeProfile: ThemeProfiles.activeProfile
|
||||||
|
|
||||||
|
// ThemeProfiles validates every persisted record before it can become
|
||||||
|
// active, so these bindings are both reactive and safe to expose as the
|
||||||
|
// shell-wide colour roles.
|
||||||
|
readonly property color accent: root.activeProfile.accent
|
||||||
|
readonly property color accentSecondary: root.activeProfile.secondary
|
||||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||||
@@ -75,6 +101,13 @@ Singleton {
|
|||||||
// light one, and text on it stops being legible.
|
// light one, and text on it stops being legible.
|
||||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
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 overlayAlpha: root.dark ? 0.55 : 0.40
|
||||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||||
@@ -84,7 +117,7 @@ Singleton {
|
|||||||
readonly property int barGap: 6 // breathing room below the bar for popovers
|
readonly property int barGap: 6 // breathing room below the bar for popovers
|
||||||
readonly property int barSideMargin: 10 // inset for floating 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 dockPadding: 8
|
||||||
readonly property int dockGap: 8
|
readonly property int dockGap: 8
|
||||||
readonly property int dockRadius: 20
|
readonly property int dockRadius: 20
|
||||||
@@ -93,7 +126,6 @@ Singleton {
|
|||||||
readonly property int popoverPadding: 14
|
readonly property int popoverPadding: 14
|
||||||
readonly property int popoverWidth: 380
|
readonly property int popoverWidth: 380
|
||||||
readonly property int controlCenterWidth: 430
|
readonly property int controlCenterWidth: 430
|
||||||
readonly property int controlCenterTopGap: 2
|
|
||||||
|
|
||||||
readonly property int cardRadius: 12
|
readonly property int cardRadius: 12
|
||||||
readonly property int pillRadius: 999
|
readonly property int pillRadius: 999
|
||||||
@@ -108,7 +140,7 @@ Singleton {
|
|||||||
|
|
||||||
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
// 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")
|
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||||
|
|
||||||
// Apply to any text whose digits change in place — clocks, percentages,
|
// Apply to any text whose digits change in place — clocks, percentages,
|
||||||
@@ -152,7 +184,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blend two tokens — used for hover/pressed states so we never invent a
|
// 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 {
|
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);
|
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 +195,7 @@ Singleton {
|
|||||||
// the active workspace pill and slider fills.
|
// the active workspace pill and slider fills.
|
||||||
//
|
//
|
||||||
// Where the gradient runs left-to-right the blue leads, because the eye
|
// 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 prismStart: 0.22
|
||||||
readonly property real prismEnd: 0.78
|
readonly property real prismEnd: 0.78
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
|
// The isolated screen model begins with both fixture outputs so changing
|
||||||
|
// it below exercises the same reactive topology path as a real hotplug.
|
||||||
|
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "displays-test"
|
target: "displays-test"
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ ShellRoot {
|
|||||||
function transactionStatus(): string {
|
function transactionStatus(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
layout: Displays.currentLayout(),
|
layout: Displays.currentLayout(),
|
||||||
|
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
|
||||||
pending: Displays.pendingRequestedLayout,
|
pending: Displays.pendingRequestedLayout,
|
||||||
previous: Displays.pendingPreviousLayout,
|
previous: Displays.pendingPreviousLayout,
|
||||||
reverting: Displays.revertExpectedLayout,
|
reverting: Displays.revertExpectedLayout,
|
||||||
@@ -83,7 +88,7 @@ ShellRoot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshIdentityFixture(): string {
|
function refreshIdentityFixture(): string {
|
||||||
const modes = Displays.normaliseModes([
|
const modes = Displays.normalizeModes([
|
||||||
"[email protected]",
|
"[email protected]",
|
||||||
"[email protected]"
|
"[email protected]"
|
||||||
]);
|
]);
|
||||||
@@ -141,5 +146,8 @@ ShellRoot {
|
|||||||
if (monitor) Displays.forget(monitor.name);
|
if (monitor) Displays.forget(monitor.name);
|
||||||
}
|
}
|
||||||
function refresh(): void { Displays.refresh(); }
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
function setScreenModel(names: string): void {
|
||||||
|
Displays.screenOverride = JSON.parse(names);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
// Exercises Migrations.applyWith against fixture steps and prints a verdict per
|
||||||
// case. Run by tests/quickshell/migrations-contract.sh.
|
// case. Run by tests/quickshell/migrations-contract.
|
||||||
//
|
//
|
||||||
// Fixture steps rather than the real list: the real one is empty until the
|
// 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
|
// 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 Quickshell
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
implicitWidth: 350
|
implicitWidth: 350
|
||||||
@@ -25,10 +29,18 @@ PanelWindow {
|
|||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
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: {
|
readonly property var activities: {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (PrivacyState.recordingActive)
|
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)
|
if (PrivacyState.screenSharingActive)
|
||||||
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
|
||||||
if (PrivacyState.cameraActive)
|
if (PrivacyState.cameraActive)
|
||||||
@@ -38,11 +50,12 @@ PanelWindow {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function elapsed(): string {
|
// Pure formatter, no ticking property read here -- callers decide what
|
||||||
const total = Capture.recordingSeconds;
|
// seconds value to pass, and only they take on the per-second dependency.
|
||||||
const seconds = String(total % 60).padStart(2, "0");
|
function formatElapsed(totalSeconds: int): string {
|
||||||
const minutes = Math.floor(total / 60) % 60;
|
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||||
const hours = Math.floor(total / 3600);
|
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}`;
|
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +177,11 @@ PanelWindow {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
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
|
color: Theme.fgDim
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// Bar {}
|
// 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.
|
// media + tray + status cluster right.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
@@ -83,7 +83,7 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Centre ──────────────────────────────────────────────────────────
|
// ── Center ──────────────────────────────────────────────────────────
|
||||||
Row {
|
Row {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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,
|
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
|
||||||
// exactly like GNOME's date menu.
|
// exactly like GNOME's date menu.
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Pill {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: Qt.formatDateTime(clock.date, root.format)
|
text: Qt.formatDateTime(clock.date, root.format)
|
||||||
font.family: Theme.fontFamily
|
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.
|
// figures the whole clock shifts sideways on every digit change.
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ Pill {
|
|||||||
if (!root.wifiNetwork)
|
if (!root.wifiNetwork)
|
||||||
return "\u{F092D}"; // md-wifi_strength_off
|
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.
|
// 0-1, so accept either rather than showing one bar forever.
|
||||||
const raw = root.wifiNetwork.signalStrength ?? 0;
|
const raw = root.wifiNetwork.signalStrength ?? 0;
|
||||||
const strength = raw <= 1 ? raw * 100 : raw;
|
const strength = raw <= 1 ? raw * 100 : raw;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// One glyph + percentage pair inside VitalsWidget. Split out so the three
|
// 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
|
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
|
||||||
// _gpu#1_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.
|
// glance at beats a sparkline you have to squint at.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Item {
|
|||||||
|
|
||||||
implicitWidth: row.implicitWidth + 12
|
implicitWidth: row.implicitWidth + 12
|
||||||
// Full bar height makes (0, 0) part of the wheel target. The dots stay
|
// 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
|
implicitHeight: Theme.barHeight
|
||||||
|
|
||||||
// Scroll over the padding either side of the row, not just over a dot.
|
// 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
|
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
|
// 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 {
|
gradient: Gradient {
|
||||||
orientation: Gradient.Horizontal
|
orientation: Gradient.Horizontal
|
||||||
GradientStop {
|
GradientStop {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The floating control panel at the bottom of the capture overlay.
|
// 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
|
// 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.
|
// the left and the screenshot/screencast switch on the right.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
@@ -13,7 +13,7 @@ Rectangle {
|
|||||||
|
|
||||||
signal captureRequested
|
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).
|
// nothing pickable (no Hyprland, or an empty workspace).
|
||||||
property bool captureEnabled: true
|
property bool captureEnabled: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// One segment of the Screen / Window / Selection control.
|
// One segment of the Screen / Window / Selection control.
|
||||||
//
|
//
|
||||||
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
|
// 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.
|
// them, so they'd be invisible on this dark overlay.
|
||||||
|
|
||||||
import QtQuick
|
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.
|
// 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
|
// selection already in place (so the shutter is immediately useful), dragging
|
||||||
// inside moves it, dragging a corner resizes from the opposite corner, and
|
// inside moves it, dragging a corner resizes from the opposite corner, and
|
||||||
// dragging anywhere else starts a fresh band.
|
// dragging anywhere else starts a fresh band.
|
||||||
@@ -52,7 +52,7 @@ Item {
|
|||||||
root.resetSelection();
|
root.resetSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Centred, roughly the middle half of the screen.
|
// Centered, roughly the middle half of the screen.
|
||||||
function resetSelection(): void {
|
function resetSelection(): void {
|
||||||
if (root.width <= 0 || root.height <= 0)
|
if (root.width <= 0 || root.height <= 0)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ PanelWindow {
|
|||||||
top: true
|
top: true
|
||||||
right: 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 {
|
margins {
|
||||||
top: Theme.barHeight + Theme.barGap * 2
|
top: Theme.barGap * 2
|
||||||
right: Theme.barSideMargin
|
right: Theme.barSideMargin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// The month grid from GNOME's date menu.
|
// The month grid from GNOME's date menu.
|
||||||
//
|
//
|
||||||
// Six week rows always, so the grid height never changes as you page through
|
// 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.
|
// months fill the edges, dimmed. Today wears the accent.
|
||||||
//
|
//
|
||||||
// Days become real navigation targets now that Evolution Data Server backs the
|
// 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 Quickshell
|
||||||
import QtQuick
|
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.
|
// so it does not shift sideways when the pill appears.
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// GNOME's date/time menu: calendar, ongoing activity and messages in one panel,
|
// 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.
|
// muscle memory is the whole point.
|
||||||
//
|
//
|
||||||
// A layer-shell surface rather than a PopupWindow because the inline reply
|
// 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
|
// fields have to be typeable, and because the clock it hangs from is owned by a
|
||||||
// different module.
|
// 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.
|
// anchoring only `top` is what puts it in the middle.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
@@ -28,7 +28,11 @@ PanelWindow {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
anchors.top: true
|
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
|
exclusiveZone: 0
|
||||||
|
|
||||||
implicitWidth: 760
|
implicitWidth: 760
|
||||||
@@ -87,7 +91,7 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Grows out of the top edge it is anchored to, rather than out of its
|
// 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 {
|
transform: Scale {
|
||||||
id: openScale
|
id: openScale
|
||||||
origin.x: surface.width / 2
|
origin.x: surface.width / 2
|
||||||
|
|||||||
@@ -22,10 +22,34 @@ PanelWindow {
|
|||||||
property var modelData: null
|
property var modelData: null
|
||||||
screen: root.modelData
|
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 {
|
anchors {
|
||||||
|
top: root.vertical
|
||||||
bottom: true
|
bottom: true
|
||||||
left: true
|
left: root.position !== "right"
|
||||||
right: true
|
right: root.position !== "left"
|
||||||
}
|
}
|
||||||
|
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
@@ -42,18 +66,31 @@ PanelWindow {
|
|||||||
// ── Geometry ────────────────────────────────────────────────────────────
|
// ── Geometry ────────────────────────────────────────────────────────────
|
||||||
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
||||||
readonly property int revealStripHeight: 3
|
readonly property int revealStripHeight: 3
|
||||||
readonly property int bottomMargin: Theme.barGap
|
readonly property int edgeMargin: Theme.barGap
|
||||||
readonly property int tooltipSpace: 34
|
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 ─────────────────────────────────────────────────────────
|
// ── 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
|
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
// 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
|
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||||
// practice: an empty workspace keeps the dock out.
|
// 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: {
|
readonly property bool workspaceOccupied: {
|
||||||
const ws = Hyprland.focusedWorkspace;
|
const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
|
||||||
return !!ws && ws.toplevels.values.length > 0;
|
return !!ws && ws.toplevels.values.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +120,31 @@ PanelWindow {
|
|||||||
onTriggered: root.revealed = false
|
onTriggered: root.revealed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other modules (the bar, the capture overlay) read this.
|
// Other modules (the bar, the capture overlay) read this. It is one
|
||||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
// 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
|
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||||
// false (the default), so the initial state has to be taken explicitly —
|
// 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.
|
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
revealed = wantRevealed;
|
revealed = wantRevealed;
|
||||||
ShellState.dockRevealed = revealed;
|
root._syncShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Input region ────────────────────────────────────────────────────────
|
// ── Input region ────────────────────────────────────────────────────────
|
||||||
@@ -114,22 +167,86 @@ PanelWindow {
|
|||||||
id: pointer
|
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 {
|
Item {
|
||||||
id: maskItem
|
id: maskItem
|
||||||
x: root.revealed ? body.x : 0
|
|
||||||
y: root.revealed ? body.y : surface.height - root.revealStripHeight
|
x: {
|
||||||
width: root.revealed ? body.width : surface.width
|
if (!root.revealed)
|
||||||
height: root.revealed ? surface.height - body.y : root.revealStripHeight
|
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
||||||
|
// A horizontal dock is centred on its edge, so the region has
|
||||||
|
// to start where the body starts -- starting at 0 puts it over
|
||||||
|
// the left of the screen while the pointer that summoned the
|
||||||
|
// dock is in the middle, and the hover drops on the very frame
|
||||||
|
// the dock arrives. A vertical dock reaches from the body out
|
||||||
|
// to its own edge, which is x 0 on the left and the body on
|
||||||
|
// the right.
|
||||||
|
if (!root.vertical)
|
||||||
|
return body.x;
|
||||||
|
return root.position === "right" ? body.x : 0;
|
||||||
|
}
|
||||||
|
y: {
|
||||||
|
if (!root.revealed)
|
||||||
|
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||||
|
return 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 {
|
DockBody {
|
||||||
id: body
|
id: body
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
|
|
||||||
// Slides off the bottom edge when hidden.
|
onContextMenuRequested: (anchorItem, entry) => {
|
||||||
y: root.revealed ? root.tooltipSpace : surface.height
|
dockContextMenu.anchorItem = anchorItem;
|
||||||
|
dockContextMenu.entry = entry;
|
||||||
|
dockContextMenu.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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
|
// Asymmetric on purpose. Revealing is a response to something the
|
||||||
// user just did, so it has to feel immediate — any delay there
|
// 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
|
// reads as lag. Hiding is not a response to anything, so it can
|
||||||
@@ -149,4 +266,8 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DockContextMenu {
|
||||||
|
id: dockContextMenu
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,30 +111,52 @@ Rectangle {
|
|||||||
inset: parent.radius
|
inset: parent.radius
|
||||||
}
|
}
|
||||||
|
|
||||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
|
||||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
|
||||||
|
|
||||||
// The item the tooltip is currently describing, or null.
|
// The item the tooltip is currently describing, or null.
|
||||||
property Item hoveredItem: null
|
property Item hoveredItem: null
|
||||||
|
signal contextMenuRequested(Item anchorItem, var entry)
|
||||||
|
|
||||||
Row {
|
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||||
id: row
|
// 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
|
anchors.centerIn: parent
|
||||||
spacing: Theme.dockGap
|
spacing: Theme.dockGap
|
||||||
|
rows: root.vertical ? root.cellCount : 1
|
||||||
|
columns: root.vertical ? 1 : root.cellCount
|
||||||
|
horizontalItemAlignment: Grid.AlignHCenter
|
||||||
|
verticalItemAlignment: Grid.AlignVCenter
|
||||||
|
|
||||||
ShowAppsButton {
|
ShowAppsButton {
|
||||||
id: showApps
|
id: showApps
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
onEntered: root.hoveredItem = showApps
|
onEntered: root.hoveredItem = showApps
|
||||||
onExited: if (root.hoveredItem === showApps)
|
onExited: if (root.hoveredItem === showApps)
|
||||||
root.hoveredItem = null
|
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 {
|
Rectangle {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
|
||||||
width: 1
|
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
|
||||||
height: Theme.dockIconSize * 0.7
|
|
||||||
border.width: 0
|
border.width: 0
|
||||||
color: Theme.alpha(Theme.fg, 0.14)
|
color: Theme.alpha(Theme.fg, 0.14)
|
||||||
}
|
}
|
||||||
@@ -146,10 +168,10 @@ Rectangle {
|
|||||||
id: dockItem
|
id: dockItem
|
||||||
required property var modelData
|
required property var modelData
|
||||||
app: modelData
|
app: modelData
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
onEntered: root.hoveredItem = dockItem
|
onEntered: root.hoveredItem = dockItem
|
||||||
onExited: if (root.hoveredItem === dockItem)
|
onExited: if (root.hoveredItem === dockItem)
|
||||||
root.hoveredItem = null
|
root.hoveredItem = null
|
||||||
|
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,16 +194,26 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Centred on the hovered item, clamped inside the dock. Reads the item's
|
// Centred on the hovered item along the dock's own axis, and placed just
|
||||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
// outside the edge it lives on. Reads the item's position directly
|
||||||
// the row reflows.
|
// (rather than mapToItem) so the binding re-evaluates when the strip
|
||||||
|
// reflows.
|
||||||
x: {
|
x: {
|
||||||
if (!root.hoveredItem)
|
if (!root.hoveredItem)
|
||||||
return 0;
|
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));
|
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
|
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||||
height: tipLabel.implicitHeight + 8
|
height: tipLabel.implicitHeight + 8
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
|
||||||
|
// configuration route is deliberately last so it never displaces app actions.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.modules.bar
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property Item anchorItem: null
|
||||||
|
property var entry: null
|
||||||
|
|
||||||
|
anchor.item: root.anchorItem
|
||||||
|
anchor.edges: Edges.Top | Edges.Left
|
||||||
|
anchor.gravity: Edges.Top | Edges.Right
|
||||||
|
anchor.margins.bottom: 8
|
||||||
|
|
||||||
|
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
|
||||||
|
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||||
|
color: "transparent"
|
||||||
|
visible: false
|
||||||
|
grabFocus: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Theme.popoverRadius
|
||||||
|
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
|
PrismEdge {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 1
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
inset: parent.radius
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: menu
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.popoverPadding
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: applicationActions
|
||||||
|
model: root.entry ? root.entry.actions : []
|
||||||
|
|
||||||
|
delegate: TrayMenuRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
label: modelData.name
|
||||||
|
onActivated: {
|
||||||
|
modelData.execute();
|
||||||
|
root.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
anchors.margins: 3
|
||||||
|
visible: applicationActions.count > 0
|
||||||
|
border.width: 0
|
||||||
|
color: Theme.alpha(Theme.fg, 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayMenuRow {
|
||||||
|
width: parent.width
|
||||||
|
label: "Dock settings"
|
||||||
|
onActivated: {
|
||||||
|
ShellState.openSettings("desktop");
|
||||||
|
root.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// One dock icon: the app, its running-window dots, hover scale and click
|
// 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.
|
// underneath, grow-on-hover, click to focus / cycle / launch.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
@@ -24,6 +24,7 @@ Item {
|
|||||||
// Emitted so DockBody can drive the single shared tooltip.
|
// Emitted so DockBody can drive the single shared tooltip.
|
||||||
signal entered
|
signal entered
|
||||||
signal exited
|
signal exited
|
||||||
|
signal contextMenuRequested
|
||||||
|
|
||||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||||
// size so the row doesn't reflow.
|
// size so the row doesn't reflow.
|
||||||
@@ -72,7 +73,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Last resort for an app with no resolvable icon: an initial, which is
|
// 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 {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: root.iconSource === ""
|
visible: root.iconSource === ""
|
||||||
@@ -115,7 +116,7 @@ Item {
|
|||||||
id: mouse
|
id: mouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||||
|
|
||||||
onEntered: root.entered()
|
onEntered: root.entered()
|
||||||
onExited: root.exited()
|
onExited: root.exited()
|
||||||
@@ -126,6 +127,10 @@ Item {
|
|||||||
root.launch();
|
root.launch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mev.button === Qt.RightButton) {
|
||||||
|
root.contextMenuRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (root.running)
|
if (root.running)
|
||||||
root.focusNext();
|
root.focusNext();
|
||||||
else
|
else
|
||||||
@@ -155,7 +160,7 @@ Item {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
// Quickshell reports the address without the 0x prefix Hyprland's
|
// 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) {
|
if (next.address) {
|
||||||
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
||||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
||||||
// optional image, action buttons and an inline reply box.
|
// 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.
|
// same wherever you meet it.
|
||||||
//
|
//
|
||||||
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
||||||
@@ -14,6 +14,7 @@ import Quickshell.Services.Notifications
|
|||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
import qs.modules.quicksettings
|
||||||
|
import qs.modules.bar
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -21,7 +22,7 @@ Rectangle {
|
|||||||
|
|
||||||
required property var notification
|
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
|
property bool compact: false
|
||||||
|
|
||||||
signal dismissed
|
signal dismissed
|
||||||
@@ -35,7 +36,17 @@ Rectangle {
|
|||||||
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
border.width: 0
|
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 {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
@@ -43,7 +54,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.
|
// text contrast never changes.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -117,11 +128,44 @@ Rectangle {
|
|||||||
onClicked: root.dismissed()
|
onClicked: root.dismissed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: settingsMenuButton
|
||||||
|
anchors.right: closeButton.left
|
||||||
|
anchors.rightMargin: 2
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 6
|
||||||
|
size: 24
|
||||||
|
iconSize: 14
|
||||||
|
tint: Theme.fgDim
|
||||||
|
icon: "view-more-symbolic"
|
||||||
|
iconFallback: "open-menu-symbolic"
|
||||||
|
onClicked: settingsMenu.visible = !settingsMenu.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
Popover {
|
||||||
|
id: settingsMenu
|
||||||
|
anchorItem: settingsMenuButton
|
||||||
|
|
||||||
|
// A Column already measures itself from its children, and in Qt 6 both
|
||||||
|
// implicit sizes are read-only on a positioner -- assigning them makes
|
||||||
|
// the whole shell fail to load rather than just this menu.
|
||||||
|
Column {
|
||||||
|
TrayMenuRow {
|
||||||
|
id: notificationSettings
|
||||||
|
label: "Notification settings"
|
||||||
|
onActivated: {
|
||||||
|
ShellState.openSettings("notifications");
|
||||||
|
settingsMenu.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: layout
|
id: layout
|
||||||
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
||||||
anchors.leftMargin: appIcon.visible ? 10 : 14
|
anchors.leftMargin: appIcon.visible ? 10 : 14
|
||||||
anchors.right: image.visible ? image.left : closeButton.left
|
anchors.right: image.visible ? image.left : settingsMenuButton.left
|
||||||
anchors.rightMargin: 8
|
anchors.rightMargin: 8
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.topMargin: 12
|
anchors.topMargin: 12
|
||||||
|
|||||||
@@ -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.
|
// A single banner: a notification card that slides in and times itself out.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell.Services.Notifications
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
@@ -14,28 +13,25 @@ Item {
|
|||||||
// into a keyboard focus request on the layer surface.
|
// into a keyboard focus request on the layer surface.
|
||||||
signal replyFocusChanged(bool focused)
|
signal replyFocusChanged(bool focused)
|
||||||
|
|
||||||
|
// Mirrors the signal above so the dismiss timer below can read it.
|
||||||
|
property bool replyFocused: false
|
||||||
|
|
||||||
implicitHeight: card.implicitHeight
|
implicitHeight: card.implicitHeight
|
||||||
|
|
||||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||||
// "server decides", which is our default.
|
// "server decides", which is our default. Shared with Notifs.qml, which
|
||||||
readonly property int timeoutMs: {
|
// gives a DND-hidden transient notification the same lifetime.
|
||||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hover
|
id: hover
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
// Hovering holds the banner open; the countdown restarts on leave.
|
// Hovering, or actively typing a reply, holds the banner open; the
|
||||||
running: root.timeoutMs > 0 && !hover.hovered
|
// countdown restarts (from the top, same as hover) once both let go.
|
||||||
|
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
|
||||||
interval: root.timeoutMs
|
interval: root.timeoutMs
|
||||||
onTriggered: Notifs.dropPopup(root.notification)
|
onTriggered: Notifs.dropPopup(root.notification)
|
||||||
}
|
}
|
||||||
@@ -45,7 +41,10 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
notification: root.notification
|
notification: root.notification
|
||||||
onDismissed: Notifs.dropPopup(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.
|
// Slide in from the right edge. Runs once, on creation.
|
||||||
NumberAnimation on x {
|
NumberAnimation on x {
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ PanelWindow {
|
|||||||
|
|
||||||
anchors.top: true
|
anchors.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
@@ -45,7 +49,17 @@ PanelWindow {
|
|||||||
spacing: Theme.itemSpacing
|
spacing: Theme.itemSpacing
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Toast {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ PanelWindow {
|
|||||||
implicitWidth: root.desiredWidth
|
implicitWidth: root.desiredWidth
|
||||||
implicitHeight: 64
|
implicitHeight: 64
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
mask: Region {}
|
mask: Region {
|
||||||
|
item: inputMask
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: inputMask
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
|
||||||
WlrLayershell.namespace: "qs-popover-osd"
|
WlrLayershell.namespace: "qs-popover-osd"
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
@@ -145,6 +152,17 @@ PanelWindow {
|
|||||||
font.weight: Font.DemiBold
|
font.weight: Font.DemiBold
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.RightButton
|
||||||
|
onClicked: event => {
|
||||||
|
if (event.button !== Qt.RightButton)
|
||||||
|
return;
|
||||||
|
ShellState.openSettings("accessibility");
|
||||||
|
OsdState.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextMetrics {
|
TextMetrics {
|
||||||
|
|||||||
@@ -176,34 +176,34 @@ Item {
|
|||||||
height: aspect > 0 ? width / aspect : parent.height
|
height: aspect > 0 ? width / aspect : parent.height
|
||||||
opacity: hasContent ? 1 : 0
|
opacity: hasContent ? 1 : 0
|
||||||
|
|
||||||
// The capture context isn't ready the instant the layer surface is
|
// A ScreencopyView cannot tell us that its recording context is
|
||||||
// told to become visible — it needs the surface to actually map,
|
// ready before a capture. The enclosing overview gets that
|
||||||
// which takes a frame or two. Calling captureFrame() before then
|
// context only after its first rendered frame, so defer the first
|
||||||
// logs "no recording context is ready" and yields nothing, so
|
// one-shot capture to that event. Retrying before then only emits
|
||||||
// retry a few times and then give up quietly (the caption and app
|
// "no recording context is ready" warnings; if a capture later
|
||||||
// icon are still shown, so a missing thumbnail is cosmetic).
|
// cannot produce content, the app icon remains the fallback.
|
||||||
property int captureAttempts: 0
|
property bool recordingReady: false
|
||||||
|
|
||||||
function tryCapture(): void {
|
function tryCapture(): void {
|
||||||
captureAttempts = 0;
|
if (shot.hasContent)
|
||||||
captureRetry.restart();
|
return;
|
||||||
}
|
if (!shot.recordingReady) {
|
||||||
|
frameReady.restart();
|
||||||
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
|
|
||||||
// resolve to the enclosing ScreencopyView.
|
|
||||||
Timer {
|
|
||||||
id: captureRetry
|
|
||||||
interval: 80
|
|
||||||
repeat: true
|
|
||||||
running: false
|
|
||||||
onTriggered: {
|
|
||||||
if (shot.hasContent || shot.captureAttempts >= 6) {
|
|
||||||
stop();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shot.captureAttempts++;
|
|
||||||
shot.captureFrame();
|
shot.captureFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FrameAnimation {
|
||||||
|
id: frameReady
|
||||||
|
running: false
|
||||||
|
onTriggered: {
|
||||||
|
running = false;
|
||||||
|
if (shot.hasContent)
|
||||||
|
return;
|
||||||
|
shot.recordingReady = true;
|
||||||
|
shot.tryCapture();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: tryCapture()
|
Component.onCompleted: tryCapture()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// One workspace in the overview: a proportional card holding live thumbnails
|
// One workspace in the overview: a proportional card holding live thumbnails
|
||||||
// of its windows, labelled underneath. The focused workspace is outlined in the
|
// of its windows, labeled underneath. The focused workspace is outlined in the
|
||||||
// accent colour, as GNOME outlines the current workspace.
|
// accent color, as GNOME outlines the current workspace.
|
||||||
//
|
//
|
||||||
// A card with a null `workspace` is the trailing "new workspace" affordance
|
// A card with a null `workspace` is the trailing "new workspace" affordance
|
||||||
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
|
// 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
|
width: parent.width
|
||||||
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
// 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"
|
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
||||||
iconFallback: "bluetooth-symbolic"
|
iconFallback: "bluetooth-symbolic"
|
||||||
label: deviceRow.modelData.name || deviceRow.modelData.address
|
label: deviceRow.modelData.name || deviceRow.modelData.address
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Item {
|
|||||||
property real backlightValue: 0
|
property real backlightValue: 0
|
||||||
|
|
||||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
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
|
visible: root.rowCount > 0
|
||||||
implicitHeight: rows.implicitHeight
|
implicitHeight: rows.implicitHeight
|
||||||
@@ -89,7 +89,7 @@ Item {
|
|||||||
// Hyprland already knows what each output is called, so the
|
// Hyprland already knows what each output is called, so the
|
||||||
// name comes from there rather than from a second source that
|
// name comes from there rather than from a second source that
|
||||||
// could disagree with the Displays page. The connector is the
|
// 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
|
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||||
value: modelData.value / 100
|
value: modelData.value / 100
|
||||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||||
@@ -113,7 +113,7 @@ Item {
|
|||||||
anchors.leftMargin: 6
|
anchors.leftMargin: 6
|
||||||
anchors.rightMargin: 6
|
anchors.rightMargin: 6
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
visible: root.labelled
|
visible: root.labeled
|
||||||
height: visible ? implicitHeight + 2 : 0
|
height: visible ? implicitHeight + 2 : 0
|
||||||
text: row.label
|
text: row.label
|
||||||
color: Theme.fgDim
|
color: Theme.fgDim
|
||||||
@@ -124,7 +124,7 @@ Item {
|
|||||||
|
|
||||||
// The slider and its glyph share one strip so the two stay aligned
|
// 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
|
// 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.
|
// anchor set to undefined is not released.
|
||||||
Item {
|
Item {
|
||||||
id: control
|
id: control
|
||||||
@@ -134,7 +134,7 @@ Item {
|
|||||||
height: 32
|
height: 32
|
||||||
|
|
||||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||||
// recolouring to be visible — see ThemedIcon.
|
// recoloring to be visible — see ThemedIcon.
|
||||||
ThemedIcon {
|
ThemedIcon {
|
||||||
id: glyph
|
id: glyph
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|||||||
@@ -36,9 +36,30 @@ Item {
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: restingCard
|
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
|
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
|
radius: Theme.cardRadius + 2
|
||||||
color: Theme.alpha(Theme.warn, 0.028)
|
color: Theme.alpha(Theme.warn, 0.028)
|
||||||
border.width: 1
|
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
|
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||||
// preserve the Prism edge without making the surface feel detached.
|
// 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.top: true
|
||||||
anchors.right: 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
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
@@ -45,6 +57,9 @@ PanelWindow {
|
|||||||
if (root.visible) {
|
if (root.visible) {
|
||||||
KdeConnect.refresh();
|
KdeConnect.refresh();
|
||||||
HomeAssistant.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();
|
openAnim.restart();
|
||||||
} else {
|
} else {
|
||||||
panel.reset();
|
panel.reset();
|
||||||
|
|||||||
@@ -107,6 +107,27 @@ Item {
|
|||||||
onExpanded: root.expand("wifi")
|
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 {
|
Toggle {
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||||
@@ -130,6 +151,21 @@ Item {
|
|||||||
onToggled: Caffeine.toggle()
|
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 {
|
Toggle {
|
||||||
width: root.cellWidth
|
width: root.cellWidth
|
||||||
icon: "night-light-symbolic"
|
icon: "night-light-symbolic"
|
||||||
@@ -190,6 +226,38 @@ Item {
|
|||||||
color: Theme.alpha(Theme.fg, 0.1)
|
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 ─────────────────────────────────────────────────────────
|
// ── Sliders ─────────────────────────────────────────────────────────
|
||||||
AudioSlider {
|
AudioSlider {
|
||||||
width: content.width
|
width: content.width
|
||||||
@@ -257,15 +325,45 @@ Item {
|
|||||||
width: content.width
|
width: content.width
|
||||||
height: 36
|
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
|
id: avatar
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 4
|
anchors.leftMargin: 4
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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
|
size: 20
|
||||||
icon: "avatar-default-symbolic"
|
icon: "avatar-default-symbolic"
|
||||||
iconFallback: "user-info-symbolic"
|
iconFallback: "user-info-symbolic"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.left: avatar.right
|
anchors.left: avatar.right
|
||||||
@@ -273,7 +371,9 @@ Item {
|
|||||||
anchors.right: actions.left
|
anchors.right: actions.left
|
||||||
anchors.rightMargin: 8
|
anchors.rightMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: Quickshell.env("USER") || "user"
|
text: UserAccounts.me
|
||||||
|
? UserAccounts.displayName(UserAccounts.me)
|
||||||
|
: (Quickshell.env("USER") || "user")
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
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.
|
// their own verticalCenter to it without a size loop.
|
||||||
Row {
|
Row {
|
||||||
id: trailingRow
|
id: trailingRow
|
||||||
|
|||||||
@@ -137,7 +137,18 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Column {
|
||||||
id: entry
|
id: entry
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
|
|||||||
ScrollColumn 1.0 ScrollColumn.qml
|
ScrollColumn 1.0 ScrollColumn.qml
|
||||||
Section 1.0 Section.qml
|
Section 1.0 Section.qml
|
||||||
WifiList 1.0 WifiList.qml
|
WifiList 1.0 WifiList.qml
|
||||||
|
PowerProfileList 1.0 PowerProfileList.qml
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Advanced accent editing remains a labelled, keyboard-operable extension of
|
||||||
|
// the named fast path. Hue is always accompanied by saturation, value, a
|
||||||
|
// numeric readout, and the two-colour preview supplied by Appearance.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.widgets
|
||||||
|
import "../../services/ThemeProfileModel.js" as ThemeProfileModel
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: editor
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
property string pickerTarget: "primary"
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
|
||||||
|
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
|
||||||
|
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
|
||||||
|
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
|
||||||
|
|
||||||
|
function changeChannel(target: string, channel: string, ratio: real): void {
|
||||||
|
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
|
||||||
|
const next = { h: source.h, s: source.s, v: source.v };
|
||||||
|
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
|
||||||
|
* (channel === "h" ? 360 : 100));
|
||||||
|
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
|
||||||
|
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
|
||||||
|
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
|
||||||
|
ThemeProfiles.setAccentPair(primary, secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(target: string): void {
|
||||||
|
if (screenPicker.running)
|
||||||
|
return;
|
||||||
|
editor.pickerTarget = target;
|
||||||
|
editor.lastError = "";
|
||||||
|
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptPicked(value: string): void {
|
||||||
|
const picked = String(value).trim().toLowerCase();
|
||||||
|
if (ThemeProfileModel.hexToHsv(picked) === null) {
|
||||||
|
editor.lastError = "The sampled colour was not valid.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const primary = editor.pickerTarget === "primary"
|
||||||
|
? picked : ThemeProfiles.activeProfile.accent;
|
||||||
|
const secondary = editor.pickerTarget === "secondary"
|
||||||
|
? picked : ThemeProfiles.activeProfile.secondary;
|
||||||
|
ThemeProfiles.setAccentPair(primary, secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
component HsvRow: SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string target
|
||||||
|
required property string channel
|
||||||
|
required property real channelValue
|
||||||
|
required property real channelMaximum
|
||||||
|
property string suffix: "%"
|
||||||
|
|
||||||
|
controlWidth: 280
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: keyboardSlider
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 270
|
||||||
|
height: 32
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Slider
|
||||||
|
Accessible.name: root.label
|
||||||
|
Accessible.description: Math.round(root.channelValue) + root.suffix
|
||||||
|
+ ", range 0 to " + root.channelMaximum
|
||||||
|
Accessible.focusable: true
|
||||||
|
Accessible.focused: activeFocus
|
||||||
|
Accessible.onIncreaseAction: keyboardSlider.step(1)
|
||||||
|
Accessible.onDecreaseAction: keyboardSlider.step(-1)
|
||||||
|
|
||||||
|
function step(direction: int): void {
|
||||||
|
const increment = root.channel === "h" ? 1 : 1;
|
||||||
|
const value = Math.max(0, Math.min(root.channelMaximum,
|
||||||
|
root.channelValue + direction * increment));
|
||||||
|
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
|
||||||
|
keyboardSlider.step(-1);
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
|
||||||
|
keyboardSlider.step(1);
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_Home) {
|
||||||
|
editor.changeChannel(root.target, root.channel, 0);
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_End) {
|
||||||
|
editor.changeChannel(root.target, root.channel, 1);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: readout.left
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: 24
|
||||||
|
radius: 9
|
||||||
|
color: "transparent"
|
||||||
|
border.width: keyboardSlider.activeFocus ? 2 : 1
|
||||||
|
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
|
||||||
|
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: readout
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 56
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
text: Math.round(root.channelValue) + root.suffix
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HsvRow {
|
||||||
|
target: "primary"; channel: "h"; channelValue: editor.primaryHsv.h; channelMaximum: 360
|
||||||
|
suffix: "°"; label: "Primary hue"; detail: "Colour family, measured from 0 to 360 degrees"
|
||||||
|
}
|
||||||
|
HsvRow {
|
||||||
|
target: "primary"; channel: "s"; channelValue: editor.primaryHsv.s; channelMaximum: 100
|
||||||
|
label: "Primary saturation"; detail: "Colour intensity from grey to vivid"
|
||||||
|
}
|
||||||
|
HsvRow {
|
||||||
|
target: "primary"; channel: "v"; channelValue: editor.primaryHsv.v; channelMaximum: 100
|
||||||
|
label: "Primary value"; detail: "Brightness from black to full colour"
|
||||||
|
}
|
||||||
|
HsvRow {
|
||||||
|
target: "secondary"; channel: "h"; channelValue: editor.secondaryHsv.h; channelMaximum: 360
|
||||||
|
suffix: "°"; label: "Secondary hue"; detail: "Colour family at the far end of the Prism gradient"
|
||||||
|
}
|
||||||
|
HsvRow {
|
||||||
|
target: "secondary"; channel: "s"; channelValue: editor.secondaryHsv.s; channelMaximum: 100
|
||||||
|
label: "Secondary saturation"; detail: "Colour intensity from grey to vivid"
|
||||||
|
}
|
||||||
|
HsvRow {
|
||||||
|
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
|
||||||
|
label: "Secondary value"; detail: "Brightness from black to full colour"
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Pick colour from screen"
|
||||||
|
detail: editor.lastError !== ""
|
||||||
|
? editor.lastError
|
||||||
|
: "Sample either end of the accent gradient with hyprpicker"
|
||||||
|
divider: false
|
||||||
|
controlWidth: 330
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Pick primary from screen"
|
||||||
|
enabled: !screenPicker.running
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: editor.pick("primary")
|
||||||
|
Keys.onReturnPressed: if (enabled) editor.pick("primary")
|
||||||
|
Keys.onSpacePressed: if (enabled) editor.pick("primary")
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Pick secondary from screen"
|
||||||
|
enabled: !screenPicker.running
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: editor.pick("secondary")
|
||||||
|
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
|
||||||
|
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: screenPicker
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: editor.acceptPicked(this.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
editor.lastError = "Screen colour picking was cancelled or unavailable.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// 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: ThemeProfiles.activeAccentName
|
||||||
|
|
||||||
|
// The schema's own option list, not Theme.accents directly -- two lists
|
||||||
|
// hand-kept in sync is how they drift. This is the same pattern
|
||||||
|
// 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 {
|
||||||
|
id: swatch
|
||||||
|
|
||||||
|
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: swatch.activeFocus || entry.selected ? 2 : 1
|
||||||
|
border.color: swatch.activeFocus
|
||||||
|
? Theme.accentSecondary
|
||||||
|
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
|
||||||
|
activeFocusOnTab: true
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: entry.pair.label + " accent"
|
||||||
|
Accessible.description: String(entry.start) + " to " + String(entry.end)
|
||||||
|
Accessible.focusable: true
|
||||||
|
Accessible.focused: activeFocus
|
||||||
|
|
||||||
|
function choose(): void {
|
||||||
|
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
|
||||||
|
console.warn("AccentPicker: accent was rejected", entry.modelData);
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: swatch.choose()
|
||||||
|
Keys.onSpacePressed: swatch.choose()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||||
|
TapHandler { onTapped: swatch.choose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
title: "Text"
|
||||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
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 {
|
SettingsCard {
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ SettingsPage {
|
|||||||
|
|
||||||
property string expandedPicker: ""
|
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"
|
title: "Appearance"
|
||||||
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
|
||||||
|
|
||||||
@@ -44,11 +57,26 @@ 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 {
|
SettingsCard {
|
||||||
|
visible: root.tab === "background"
|
||||||
title: "Background"
|
title: "Background"
|
||||||
subtitle: Wallpaper.lastError !== ""
|
subtitle: Wallpaper.lastError !== ""
|
||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "Applied to every display, from the folder below."
|
||||||
|
|
||||||
|
TextEntryRow { setting: "wallpaperDir"; placeholder: "Pictures/Wallpapers" }
|
||||||
|
|
||||||
WallpaperControls {
|
WallpaperControls {
|
||||||
id: wallpaperControls
|
id: wallpaperControls
|
||||||
@@ -83,6 +111,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "background"
|
||||||
title: "Lock screen"
|
title: "Lock screen"
|
||||||
subtitle: LockScreen.lastError !== ""
|
subtitle: LockScreen.lastError !== ""
|
||||||
? LockScreen.lastError
|
? LockScreen.lastError
|
||||||
@@ -101,15 +130,31 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Colour scheme"
|
visible: root.tab === "theme"
|
||||||
|
// Not "Color scheme" any more: the card holds the saved themes as well
|
||||||
|
// as the scheme, and a theme carries both ends of the accent with it.
|
||||||
|
title: "Theme"
|
||||||
subtitle: ColorScheme.lastError !== ""
|
subtitle: ColorScheme.lastError !== ""
|
||||||
? ColorScheme.lastError
|
? ColorScheme.lastError
|
||||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
|
||||||
|
|
||||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
ThemeProfilePicker {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn as the gradient each accent produces rather than a flat dot,
|
||||||
|
// because the gradient is what is being chosen.
|
||||||
|
AccentPicker {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
AccentEditor {
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Shell typography"
|
title: "Shell typography"
|
||||||
subtitle: Fonts.lastError !== ""
|
subtitle: Fonts.lastError !== ""
|
||||||
? Fonts.lastError
|
? Fonts.lastError
|
||||||
@@ -151,6 +196,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Application typography"
|
title: "Application typography"
|
||||||
subtitle: DesktopStyle.lastError !== ""
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
? DesktopStyle.lastError
|
? DesktopStyle.lastError
|
||||||
@@ -223,6 +269,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "type"
|
||||||
title: "Icons & pointer"
|
title: "Icons & pointer"
|
||||||
subtitle: DesktopStyle.lastError !== ""
|
subtitle: DesktopStyle.lastError !== ""
|
||||||
? DesktopStyle.lastError
|
? DesktopStyle.lastError
|
||||||
@@ -273,6 +320,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "windows"
|
||||||
title: "Titlebars"
|
title: "Titlebars"
|
||||||
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
|
||||||
|
|
||||||
@@ -282,6 +330,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "windows"
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||||
|
|
||||||
@@ -296,6 +345,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "theme"
|
||||||
title: "Effects"
|
title: "Effects"
|
||||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
||||||
|
|
||||||
@@ -312,6 +362,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "shell"
|
||||||
title: "Clock"
|
title: "Clock"
|
||||||
|
|
||||||
ToggleRow { setting: "use24Hour" }
|
ToggleRow { setting: "use24Hour" }
|
||||||
@@ -320,6 +371,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
visible: root.tab === "shell"
|
||||||
title: "System vitals"
|
title: "System vitals"
|
||||||
subtitle: "Choose what appears beside the workspace indicator."
|
subtitle: "Choose what appears beside the workspace indicator."
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ SettingsPage {
|
|||||||
|
|
||||||
property string expandedRole: ""
|
property string expandedRole: ""
|
||||||
property bool addingAutostart: false
|
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
|
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: [
|
readonly property var roles: [
|
||||||
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
|
{ 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: "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: "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: "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, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
|
||||||
{ key: "images", label: "Images", detail: "PNG images", 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 video", categorySets: [["video"]], terms: ["video player", "movie 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 {
|
function desktopId(entry: var): string {
|
||||||
@@ -43,17 +54,27 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchesRole(entry: var, role: var): bool {
|
function matchesRole(entry: var, role: var): bool {
|
||||||
const rawCategories = Array.isArray(entry.categories)
|
// DesktopEntries hands back a QML list, not a JavaScript array, so
|
||||||
? entry.categories
|
// Array.isArray is false for it. The old code took that as "this is a
|
||||||
: [String(entry.categories ?? "")];
|
// 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 = [];
|
const categories = [];
|
||||||
for (const rawCategory of rawCategories) {
|
for (const value of joined.split(/[;,]/)) {
|
||||||
for (const value of String(rawCategory).split(";")) {
|
|
||||||
const category = value.trim().toLowerCase();
|
const category = value.trim().toLowerCase();
|
||||||
if (category !== "")
|
if (category !== "")
|
||||||
categories.push(category);
|
categories.push(category);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const metadata = [entry.name, entry.genericName]
|
const metadata = [entry.name, entry.genericName]
|
||||||
.map(value => String(value ?? "").toLowerCase())
|
.map(value => String(value ?? "").toLowerCase())
|
||||||
.join(" ");
|
.join(" ");
|
||||||
@@ -95,14 +116,46 @@ SettingsPage {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
|
id: roleRow
|
||||||
|
|
||||||
|
readonly property bool open: root.expandedRole === roleBlock.modelData.key
|
||||||
|
|
||||||
label: roleBlock.modelData.label
|
label: roleBlock.modelData.label
|
||||||
detail: roleBlock.modelData.detail
|
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
|
roleBlock.selectedEntry
|
||||||
? root.displayName(roleBlock.selectedEntry)
|
? root.displayName(roleBlock.selectedEntry)
|
||||||
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
|
: (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
|
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
|
||||||
onActivated: {
|
onActivated: {
|
||||||
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
root.expandedRole = root.expandedRole === roleBlock.modelData.key
|
||||||
@@ -219,12 +272,50 @@ SettingsPage {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool confirming:
|
||||||
|
root.confirmingAutostartRemoval === String(autostartRow.modelData.id)
|
||||||
|
|
||||||
label: autostartRow.modelData.name
|
label: autostartRow.modelData.name
|
||||||
detail: autostartRow.modelData.id
|
detail: autostartRow.confirming
|
||||||
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
|
? "Removing deletes this entry. Turning it off instead is reversible."
|
||||||
activatable: !DefaultApps.busy
|
: autostartRow.modelData.id
|
||||||
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
|
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"
|
title: "Wired"
|
||||||
visible: Connectivity.wiredDevice !== null
|
visible: Connectivity.wiredDevice !== null
|
||||||
|
|
||||||
TextRow {
|
SwitchRow {
|
||||||
label: "Ethernet"
|
label: "Ethernet"
|
||||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
// Three states worth telling apart: on, off but plugged in, and
|
||||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
// 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
|
divider: false
|
||||||
|
onToggled: value => Connectivity.setWired(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,24 +185,15 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Owned by Fedora"
|
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 {
|
ActionRow {
|
||||||
label: "Network connections"
|
label: "Network connections"
|
||||||
detail: "VPN, proxies, and per-connection settings"
|
detail: "VPN, proxies, and per-connection settings"
|
||||||
action: "Open"
|
action: "Open"
|
||||||
|
divider: false
|
||||||
onTriggered: SystemSettings.openGnomePanel("network")
|
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.
|
// because that genuinely is a Panama preference.
|
||||||
//
|
//
|
||||||
// Changing the timezone or network time needs privilege. timedatectl asks
|
// 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.
|
// that appears to have been accepted.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
@@ -33,7 +33,7 @@ SettingsPage {
|
|||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Set automatically"
|
label: "Set automatically"
|
||||||
detail: DateTime.ntpEnabled
|
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"
|
: "The clock is set by hand"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
divider: false
|
divider: false
|
||||||
@@ -50,8 +50,16 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Timezone"
|
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 {
|
SearchField {
|
||||||
id: zoneSearch
|
id: zoneSearch
|
||||||
@@ -72,7 +80,10 @@ SettingsPage {
|
|||||||
controlWidth: 90
|
controlWidth: 90
|
||||||
divider: index < root.matchingZones.length - 1
|
divider: index < root.matchingZones.length - 1
|
||||||
activatable: true
|
activatable: true
|
||||||
onActivated: DateTime.setTimezone(modelData)
|
onActivated: {
|
||||||
|
DateTime.setTimezone(modelData);
|
||||||
|
zonePicker.collapse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +94,7 @@ SettingsPage {
|
|||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: DateTime.lastError !== ""
|
visible: DateTime.lastError !== ""
|
||||||
|
|||||||
@@ -8,21 +8,104 @@
|
|||||||
// them.
|
// them.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
// Which project is one click from being forgotten. Same in-place confirm the
|
||||||
|
// SSH Keys and Snapshots pages use.
|
||||||
|
property string confirmingProject: ""
|
||||||
|
|
||||||
|
// 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"
|
title: "Desktop & Dock"
|
||||||
lede: "Keep the shell instant, spatial, and out of your way."
|
lede: "Keep the shell instant, spatial, and out of your way."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Dock"
|
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" }
|
ToggleRow { setting: "dockAutohide" }
|
||||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||||
|
SliderRow { setting: "dockIconSize"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -62,7 +145,7 @@ SettingsPage {
|
|||||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
action: "Open Appearance"
|
action: "Open Appearance"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: ShellState.openSettings("appearance")
|
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +189,80 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Saved here, not created here. A layout is worth recording at the moment
|
||||||
|
// you have it right, so saving is a launcher command; this is where the
|
||||||
|
// ones you kept are reviewed and the ones you did not are removed.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Projects"
|
||||||
|
subtitle: Projects.projects.length > 0
|
||||||
|
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
|
||||||
|
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Projects.projects
|
||||||
|
|
||||||
|
delegate: SettingRow {
|
||||||
|
id: projectRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property string projectName: String(projectRow.modelData.name ?? "")
|
||||||
|
readonly property bool confirming: root.confirmingProject === projectRow.projectName
|
||||||
|
|
||||||
|
label: projectRow.projectName
|
||||||
|
detail: projectRow.confirming
|
||||||
|
? "Forgetting this only removes the layout. Nothing that is open closes."
|
||||||
|
: projectRow.modelData.windows + " windows across "
|
||||||
|
+ projectRow.modelData.workspaces + " workspaces — "
|
||||||
|
+ (projectRow.modelData.applications ?? []).join(", ")
|
||||||
|
divider: projectRow.index < Projects.projects.length - 1
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: !projectRow.confirming
|
||||||
|
text: "Open"
|
||||||
|
enabled: !Projects.busy
|
||||||
|
onClicked: Projects.open(projectRow.projectName)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: projectRow.confirming
|
||||||
|
text: "Forget it"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !Projects.busy
|
||||||
|
onClicked: {
|
||||||
|
root.confirmingProject = "";
|
||||||
|
Projects.remove(projectRow.projectName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: projectRow.confirming ? "Keep" : "Forget"
|
||||||
|
enabled: !Projects.busy
|
||||||
|
onClicked: root.confirmingProject =
|
||||||
|
projectRow.confirming ? "" : projectRow.projectName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: Projects.lastError !== ""
|
||||||
|
label: "Problem"
|
||||||
|
detail: Projects.lastError
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Workspaces & focus"
|
title: "Workspaces & focus"
|
||||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
@@ -113,7 +270,8 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "workspaceBackAndForth" }
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
ToggleRow { setting: "focusOnActivate" }
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
|
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||||
|
ToggleRow { setting: "windowSwallow"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
@@ -122,6 +280,71 @@ SettingsPage {
|
|||||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Snapshots"
|
title: "Snapshots"
|
||||||
subtitle: SettingsBackup.lastError !== ""
|
subtitle: SettingsBackup.lastError !== ""
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Rectangle {
|
|||||||
|
|
||||||
// Stands in for the wallpaper. Static: an animated gradient here would
|
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||||
// repaint forever behind a settings page.
|
// 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
|
// around it is light does not read as "your desktop" -- it reads as a
|
||||||
// screenshot of someone else's.
|
// screenshot of someone else's.
|
||||||
gradient: Gradient {
|
gradient: Gradient {
|
||||||
@@ -129,7 +129,7 @@ Rectangle {
|
|||||||
required property bool focused
|
required property bool focused
|
||||||
|
|
||||||
// The gradient border is drawn as a filled rounded rect with the window
|
// 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.
|
// gradient, and the prism border is the whole signature here.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: frame
|
id: frame
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Column {
|
|||||||
property var monitor: null
|
property var monitor: null
|
||||||
property bool enabled: true
|
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
|
spacing: 0
|
||||||
|
|
||||||
readonly property var grouped: {
|
readonly property var grouped: {
|
||||||
@@ -106,11 +109,14 @@ Column {
|
|||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
enabled: root.enabled && !rate.selected
|
enabled: root.enabled && !rate.selected
|
||||||
onTapped: Displays.apply(
|
onTapped: {
|
||||||
|
Displays.apply(
|
||||||
root.monitor.name,
|
root.monitor.name,
|
||||||
rate.modelData.mode,
|
rate.modelData.mode,
|
||||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||||
root.monitor.transform)
|
root.monitor.transform);
|
||||||
|
root.picked();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,24 @@ SettingsPage {
|
|||||||
|
|
||||||
property string selectedOutput: ""
|
property string selectedOutput: ""
|
||||||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null)
|
||||||
readonly property string currentMode: root.monitor
|
readonly property string currentMode: root.monitor
|
||||||
? root.monitor.mode
|
? 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 {
|
function syncSelectedOutput(): void {
|
||||||
if (!Displays.monitorNamed(root.selectedOutput))
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
|
||||||
|
? Displays.primaryFirstMonitors[0].name : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||||
@@ -128,7 +138,7 @@ SettingsPage {
|
|||||||
ChoiceGrid {
|
ChoiceGrid {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Display"
|
label: "Display"
|
||||||
options: Displays.monitors.map(monitor => ({
|
options: Displays.primaryFirstMonitors.map(monitor => ({
|
||||||
value: monitor.name,
|
value: monitor.name,
|
||||||
label: monitor.description || monitor.name
|
label: monitor.description || monitor.name
|
||||||
}))
|
}))
|
||||||
@@ -175,10 +185,61 @@ SettingsPage {
|
|||||||
title: "Resolution"
|
title: "Resolution"
|
||||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
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 {
|
DisplayModePicker {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
monitor: root.monitor
|
monitor: root.monitor
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +321,51 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only with something to spread across. On one screen the choice has no
|
||||||
|
// meaning, the same way the Touchpad card stays hidden without a touchpad.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.monitors.length >= 2
|
||||||
|
title: "Workspaces"
|
||||||
|
subtitle: "GNOME asked this too. Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own."
|
||||||
|
|
||||||
|
SegmentRow {
|
||||||
|
label: "Where workspaces live"
|
||||||
|
detail: Workspaces.applied
|
||||||
|
? (Workspaces.primaryOnly
|
||||||
|
? "Workspaces 1 to 10 are on the primary display."
|
||||||
|
: "Each display has its own workspaces.")
|
||||||
|
: "Chosen, but not in effect yet — the compositor has to reload."
|
||||||
|
options: [
|
||||||
|
{ value: false, label: "All displays" },
|
||||||
|
{ value: true, label: "Primary only" }
|
||||||
|
]
|
||||||
|
value: Workspaces.primaryOnly
|
||||||
|
enabled: !Workspaces.reloading
|
||||||
|
divider: !Workspaces.applied
|
||||||
|
onSelected: value => Workspaces.choose(value === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appears only when the compositor and the preference disagree, which
|
||||||
|
// is also how it disappears: applying makes its own reason to exist go
|
||||||
|
// away. A reload is a whole-session event, so it is asked for rather
|
||||||
|
// than done quietly the moment the switch moves.
|
||||||
|
ActionRow {
|
||||||
|
visible: !Workspaces.applied
|
||||||
|
label: Workspaces.reloading ? "Reloading…" : "Reload to apply"
|
||||||
|
detail: "Re-reads the compositor's configuration. Windows and workspaces stay where they are."
|
||||||
|
action: "Reload"
|
||||||
|
enabled: !Workspaces.reloading
|
||||||
|
divider: false
|
||||||
|
onTriggered: Workspaces.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Workspaces.lastError !== ""
|
||||||
|
title: "Workspace problem"
|
||||||
|
subtitle: Workspaces.lastError
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
title: "Gaming display policy"
|
||||||
subtitle: "Applied immediately and restored when the session starts."
|
subtitle: "Applied immediately and restored when the session starts."
|
||||||
|
|||||||
@@ -45,6 +45,50 @@ Column {
|
|||||||
DesktopPreferences.set("dockPinned", next);
|
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 {
|
function move(from: int, to: int): void {
|
||||||
if (to < 0 || to >= root.pinned.length)
|
if (to < 0 || to >= root.pinned.length)
|
||||||
return;
|
return;
|
||||||
@@ -67,7 +111,7 @@ Column {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.pinned
|
model: root.displayed
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
id: pin
|
id: pin
|
||||||
@@ -76,15 +120,71 @@ Column {
|
|||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
label: root.nameFor(pin.modelData)
|
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
|
divider: pin.index < root.pinned.length - 1
|
||||||
controlWidth: 132
|
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 {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 4
|
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 {
|
SettingsButton {
|
||||||
text: "↑"
|
text: "↑"
|
||||||
enabled: pin.index > 0
|
enabled: pin.index > 0
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user