Add a displays widget for monitor layout and per-monitor wallpapers
This commit is contained in:
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Assign a wallpaper to one monitor, leaving the others alone
|
||||||
|
# blob:args=<--list|--clear-all|<output> <image>|<output> --clear>
|
||||||
|
# blob:examples=blob bg monitor DP-1 ~/wallpapers/astronaut2.jpg
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
state_dir="$HOME/.local/state/blob/backgrounds"
|
||||||
|
mkdir -p "$state_dir"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: blob-bg-monitor <output> <image> assign a wallpaper to one monitor
|
||||||
|
blob-bg-monitor <output> --clear fall back to the global wallpaper
|
||||||
|
blob-bg-monitor --list show current assignments
|
||||||
|
blob-bg-monitor --clear-all drop every assignment
|
||||||
|
|
||||||
|
Monitors with no assignment show the global wallpaper set by blob-bg-set.
|
||||||
|
Output names are what 'hyprctl monitors' reports, such as DP-1 or eDP-1.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_shell() {
|
||||||
|
blob-shell -q background refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
known_output() {
|
||||||
|
hyprctl monitors all -j 2>/dev/null | jq -e --arg name "$1" 'any(.[]; .name == $name)' >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1-}" in
|
||||||
|
--list)
|
||||||
|
found=false
|
||||||
|
for link in "$state_dir"/*; do
|
||||||
|
[[ -e $link ]] || continue
|
||||||
|
found=true
|
||||||
|
printf '%-12s %s\n' "$(basename "$link")" "$(readlink -f "$link")"
|
||||||
|
done
|
||||||
|
[[ $found == true ]] || echo "No per-monitor wallpapers assigned."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--clear-all)
|
||||||
|
rm -f "$state_dir"/*
|
||||||
|
notify_shell
|
||||||
|
echo "Cleared every per-monitor wallpaper."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
""|--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
output="$1"
|
||||||
|
image="${2-}"
|
||||||
|
|
||||||
|
if [[ -z $image ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! known_output "$output"; then
|
||||||
|
echo "Warning: '$output' is not a connected monitor right now." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $image == --clear ]]; then
|
||||||
|
rm -f "$state_dir/$output"
|
||||||
|
notify_shell
|
||||||
|
echo "Cleared $output; it follows the global wallpaper again."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f $image ]]; then
|
||||||
|
echo "Not a file: $image" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sfn "$(realpath "$image")" "$state_dir/$output"
|
||||||
|
notify_shell
|
||||||
|
echo "Set $output to $(basename "$image")"
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Apply a monitor layout at runtime without touching monitors.lua
|
||||||
|
# blob:args=<list|apply <keyword>...|reset>
|
||||||
|
# blob:examples=blob display arrange apply 'DP-1,1920x1080@60,0x0,1'
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
action="${1-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: blob-display-arrange <command>
|
||||||
|
|
||||||
|
list print the current layout as monitor keywords
|
||||||
|
apply <keyword>... apply one or more monitor keywords now
|
||||||
|
reset reload the Hyprland config, restoring monitors.lua
|
||||||
|
|
||||||
|
A keyword is Hyprland's own monitor form:
|
||||||
|
|
||||||
|
<name>,<mode>,<x>x<y>,<scale>[,transform,<n>][,mirror,<name>]
|
||||||
|
<name>,disable
|
||||||
|
|
||||||
|
Changes made with apply are runtime only. They last until the config is
|
||||||
|
reloaded or the session ends; monitors.lua is never written.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
list)
|
||||||
|
hyprctl monitors all -j | jq -r '
|
||||||
|
.[]
|
||||||
|
| if .disabled then "\(.name),disable"
|
||||||
|
else
|
||||||
|
"\(.name),\(.width)x\(.height)@\((.refreshRate * 100 | round) / 100),\(.x)x\(.y),\(.scale)"
|
||||||
|
+ (if .transform != 0 then ",transform,\(.transform)" else "" end)
|
||||||
|
+ (if .mirrorOf != "none" then ",mirror,\(.mirrorOf)" else "" end)
|
||||||
|
end
|
||||||
|
'
|
||||||
|
;;
|
||||||
|
apply)
|
||||||
|
if (( $# == 0 )); then
|
||||||
|
echo "apply needs at least one monitor keyword" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for keyword in "$@"; do
|
||||||
|
if [[ $keyword != *,* ]]; then
|
||||||
|
echo "Not a monitor keyword: $keyword" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
hyprctl keyword monitor "$keyword" >/dev/null
|
||||||
|
echo "applied $keyword"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
reset)
|
||||||
|
hyprctl reload >/dev/null
|
||||||
|
echo "reloaded; monitors.lua is authoritative again"
|
||||||
|
;;
|
||||||
|
""|--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $action" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
|
|
||||||
// Blob
|
// Blob
|
||||||
"blob.wallpaper": {"icon":"","label":"Wallpaper","action":"blob-wallpaper-set --menu","description":"Pick a wallpaper from ~/wallpapers"},
|
"blob.wallpaper": {"icon":"","label":"Wallpaper","action":"blob-wallpaper-set --menu","description":"Pick a wallpaper from ~/wallpapers"},
|
||||||
|
"blob.displays": {"icon":"","label":"Displays","action":"blob-shell shell toggle blob.displays","description":"Arrange monitors and assign wallpapers"},
|
||||||
"blob.theme": {"icon":"","label":"Theme","action":"blob-theme-menu","description":"Pick a local or bundled color theme"},
|
"blob.theme": {"icon":"","label":"Theme","action":"blob-theme-menu","description":"Pick a local or bundled color theme"},
|
||||||
"blob.dynamic": {"icon":"","label":"Dynamic theme","action":"blob-theme-dynamic","description":"Recolor from the current wallpaper"},
|
"blob.dynamic": {"icon":"","label":"Dynamic theme","action":"blob-theme-dynamic","description":"Recolor from the current wallpaper"},
|
||||||
"blob.glass": {"icon":"","label":"Toggle glass","action":"blob-toggle-glass toggle","checked":"test -f $HOME/.local/state/blob/toggles/hypr/blob-glass.lua"},
|
"blob.glass": {"icon":"","label":"Toggle glass","action":"blob-toggle-glass toggle","checked":"test -f $HOME/.local/state/blob/toggles/hypr/blob-glass.lua"},
|
||||||
|
|||||||
+3
-1
@@ -43,6 +43,7 @@ of the `blob` listing.
|
|||||||
| `blob-bg-cache` | Cache background switcher thumbnails for the current theme | - |
|
| `blob-bg-cache` | Cache background switcher thumbnails for the current theme | - |
|
||||||
| `blob-bg-current` | Show current background | - |
|
| `blob-bg-current` | Show current background | - |
|
||||||
| `blob-bg-install` | Open the current theme's user background folder | - |
|
| `blob-bg-install` | Open the current theme's user background folder | - |
|
||||||
|
| `blob-bg-monitor` | Assign a wallpaper to one monitor, leaving the others alone | <--list\|--clear-all\|<output> <image>\|<output> --clear> |
|
||||||
| `blob-bg-next` | Cycle to the next background for the current theme | - |
|
| `blob-bg-next` | Cycle to the next background for the current theme | - |
|
||||||
| `blob-bg-set` | Set the current background image | <path-to-image> |
|
| `blob-bg-set` | Set the current background image | <path-to-image> |
|
||||||
| `blob-bg-switcher` | Open the Blob background switcher | - |
|
| `blob-bg-switcher` | Open the Blob background switcher | - |
|
||||||
@@ -138,6 +139,7 @@ of the `blob` listing.
|
|||||||
|
|
||||||
| Command | Does | Arguments |
|
| Command | Does | Arguments |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `blob-display-arrange` | Apply a monitor layout at runtime without touching monitors.lua | <list\|apply <keyword>...\|reset> |
|
||||||
| `blob-display-size` | Scale text everywhere — blob shell, GTK apps, and terminals | [size\|reset] |
|
| `blob-display-size` | Scale text everywhere — blob shell, GTK apps, and terminals | [size\|reset] |
|
||||||
| `blob-display-state` | Print monitor panel state for the shell | - |
|
| `blob-display-state` | Print monitor panel state for the shell | - |
|
||||||
|
|
||||||
@@ -620,4 +622,4 @@ of the `blob` listing.
|
|||||||
|
|
||||||
## Totals
|
## Totals
|
||||||
|
|
||||||
291 commands.
|
293 commands.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Personal overrides in `hypr/bindings.lua` win over the defaults, and
|
|||||||
| `SUPER + ALT + W` | Wallpaper picker |
|
| `SUPER + ALT + W` | Wallpaper picker |
|
||||||
| `SUPER + CTRL + M` | System monitor |
|
| `SUPER + CTRL + M` | System monitor |
|
||||||
| `SUPER + CTRL + Q` | Quick settings |
|
| `SUPER + CTRL + Q` | Quick settings |
|
||||||
|
| `SUPER + SHIFT + CTRL + D` | Displays |
|
||||||
| `SUPER + SPACE` | Apps menu |
|
| `SUPER + SPACE` | Apps menu |
|
||||||
|
|
||||||
## Applications
|
## Applications
|
||||||
|
|||||||
@@ -64,6 +64,35 @@ Two are worth knowing about:
|
|||||||
|
|
||||||
`quickshell` never came from the Omarchy repository - it is in official `extra`.
|
`quickshell` never came from the Omarchy repository - it is in official `extra`.
|
||||||
|
|
||||||
|
## Installing packages
|
||||||
|
|
||||||
|
`install.sh` installs what is missing before it writes any config:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/blob.packages 117 packages from core, extra, multilib
|
||||||
|
packages/blob-aur.packages 11 packages with no official equivalent
|
||||||
|
```
|
||||||
|
|
||||||
|
Only missing packages are touched, through `pacman -S --needed` and
|
||||||
|
`yay -S --needed`. `./install.sh --check` lists what it would install without
|
||||||
|
installing anything, and `--skip-packages` does the directories and config only.
|
||||||
|
|
||||||
|
An entry may be written `wanted|already-fine`, which asks for the first name but
|
||||||
|
accepts either as satisfying the requirement. That is how
|
||||||
|
`hyprland-preview-share-picker-git` avoids conflicting with the non-git build
|
||||||
|
that the Omarchy repository still ships.
|
||||||
|
|
||||||
|
Three of the AUR entries are load-bearing for this fork and were not in
|
||||||
|
Omarchy's own package list, because they were installed by hand on this machine:
|
||||||
|
|
||||||
|
| Package | Needed by |
|
||||||
|
| --- | --- |
|
||||||
|
| `python-pywal` | `blob-wallpaper-set`, palette extraction |
|
||||||
|
| `awww` | the wallpaper daemon `blob-wallpaper-set` and `blob-theme-menu` drive |
|
||||||
|
| `playerctl` | the quick settings media row |
|
||||||
|
|
||||||
|
`playerctl` is in official `extra`, so it sits in the repo list.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -52,3 +52,51 @@ binding to the shell's own audio, brightness and media services. Polling only
|
|||||||
runs while a panel is open, so the cost is bounded, but wiring these to the
|
runs while a panel is open, so the cost is bounded, but wiring these to the
|
||||||
services is the obvious next refinement and would remove the last per-tick
|
services is the obvious next refinement and would remove the last per-tick
|
||||||
process spawns.
|
process spawns.
|
||||||
|
|
||||||
|
## Displays
|
||||||
|
|
||||||
|
`blob.displays` arranges monitors and assigns wallpapers per monitor. Opened
|
||||||
|
with `Super + Shift + Ctrl + D` or the Blob menu's Displays row.
|
||||||
|
|
||||||
|
### Monitors tab
|
||||||
|
|
||||||
|
A scaled picture of the desktop, one draggable rectangle per monitor. Dropping
|
||||||
|
one applies the new position immediately. Edges snap to a neighbour's edge
|
||||||
|
within 60 logical pixels, so monitors end up touching exactly.
|
||||||
|
|
||||||
|
Per-monitor controls: mode, scale, enable or disable, and mirror.
|
||||||
|
|
||||||
|
**Scale options are filtered, not free.** Hyprland steps fractional scaling in
|
||||||
|
1/120 and rejects a scale that does not land on a whole number of physical
|
||||||
|
pixels on both axes. `DisplayModel.scaleIsValid` applies that rule, so only
|
||||||
|
usable scales are offered. On a 1920x1080 panel that means 1.0 and 1.2 are
|
||||||
|
adjacent with nothing between them, which is the constraint `hypr/monitors.lua`
|
||||||
|
documents by hand.
|
||||||
|
|
||||||
|
**Nothing is written to `monitors.lua`.** Every change goes through
|
||||||
|
`hyprctl keyword monitor` and lasts until the config reloads or the session
|
||||||
|
ends. "Reset to monitors.lua" runs `hyprctl reload` and makes the checked-in
|
||||||
|
config authoritative again. To keep a layout, edit `monitors.lua` yourself -
|
||||||
|
`blob display arrange list` prints the current layout in the exact keyword form,
|
||||||
|
ready to copy.
|
||||||
|
|
||||||
|
### Wallpapers tab
|
||||||
|
|
||||||
|
A thumbnail grid of `~/wallpapers`. Clicking one assigns it to the selected
|
||||||
|
monitor through `blob-bg-monitor`, which symlinks it under
|
||||||
|
`~/.local/state/blob/backgrounds/<output>`.
|
||||||
|
|
||||||
|
`blob.background` already drew one window per screen but pointed them all at the
|
||||||
|
same image. It now resolves per screen: a monitor with an assignment shows it,
|
||||||
|
and a monitor without one shows the global wallpaper from `blob-bg-set`. The
|
||||||
|
crossfade machinery stays on the global path only, so an assigned monitor swaps
|
||||||
|
without the reveal animation rather than risking that code.
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `blob bg monitor <output> <image>` | assign a wallpaper to one monitor |
|
||||||
|
| `blob bg monitor <output> --clear` | fall back to the global wallpaper |
|
||||||
|
| `blob bg monitor --list` | show assignments |
|
||||||
|
| `blob display arrange list` | print the layout as monitor keywords |
|
||||||
|
| `blob display arrange apply <kw>` | apply a keyword now |
|
||||||
|
| `blob display arrange reset` | reload, restoring monitors.lua |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
o.bind("SUPER + ALT + W", "Wallpaper picker", "blob-wallpaper-set --menu")
|
o.bind("SUPER + ALT + W", "Wallpaper picker", "blob-wallpaper-set --menu")
|
||||||
o.bind("SUPER + CTRL + M", "System monitor", "blob-shell shell toggle blob.sysmon")
|
o.bind("SUPER + CTRL + M", "System monitor", "blob-shell shell toggle blob.sysmon")
|
||||||
|
o.bind("SUPER + SHIFT + CTRL + D", "Displays", "blob-shell shell toggle blob.displays")
|
||||||
o.bind("SUPER + CTRL + Q", "Quick settings", "blob-shell shell toggle blob.quick-settings")
|
o.bind("SUPER + CTRL + Q", "Quick settings", "blob-shell shell toggle blob.quick-settings")
|
||||||
|
|
||||||
-- Apps on SUPER + SPACE, root menu on ALT, swapping the shipped defaults back.
|
-- Apps on SUPER + SPACE, root menu on ALT, swapping the shipped defaults back.
|
||||||
|
|||||||
+111
-6
@@ -11,6 +11,7 @@ font_dir="$HOME/.local/share/fonts"
|
|||||||
|
|
||||||
force=false
|
force=false
|
||||||
check=false
|
check=false
|
||||||
|
skip_packages=false
|
||||||
changes=0
|
changes=0
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -19,6 +20,7 @@ Usage: ./install.sh [OPTIONS]
|
|||||||
|
|
||||||
--check Report what would change, write nothing
|
--check Report what would change, write nothing
|
||||||
--force Overwrite files that have local changes
|
--force Overwrite files that have local changes
|
||||||
|
--skip-packages Do not install packages, only directories and config
|
||||||
--help Show this message
|
--help Show this message
|
||||||
USAGE
|
USAGE
|
||||||
exit 0
|
exit 0
|
||||||
@@ -28,6 +30,7 @@ while (( $# > 0 )); do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--force) force=true; shift ;;
|
--force) force=true; shift ;;
|
||||||
--check) check=true; shift ;;
|
--check) check=true; shift ;;
|
||||||
|
--skip-packages) skip_packages=true; shift ;;
|
||||||
--help) usage ;;
|
--help) usage ;;
|
||||||
*) echo "Unknown option: $1" >&2; usage ;;
|
*) echo "Unknown option: $1" >&2; usage ;;
|
||||||
esac
|
esac
|
||||||
@@ -41,6 +44,112 @@ note_change() {
|
|||||||
changes=$((changes + 1))
|
changes=$((changes + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
directories=(
|
||||||
|
"$HOME/.config/blob/hooks"
|
||||||
|
"$HOME/.config/blob/extensions"
|
||||||
|
"$HOME/.config/blob/plugins"
|
||||||
|
"$HOME/.config/blob/themed"
|
||||||
|
"$HOME/.config/blob/themes"
|
||||||
|
"$HOME/.config/blob/branding"
|
||||||
|
"$HOME/.config/hypr"
|
||||||
|
"$HOME/.config/uwsm/env.d"
|
||||||
|
"$HOME/.local/state/blob/toggles/hypr"
|
||||||
|
"$HOME/.local/state/blob/indicators"
|
||||||
|
"$HOME/.local/state/blob/notifications/history"
|
||||||
|
"$HOME/.local/state/blob/notifications/images"
|
||||||
|
"$HOME/.local/state/blob/backgrounds"
|
||||||
|
"$HOME/.local/state/blob/current"
|
||||||
|
"$HOME/.local/share/fonts"
|
||||||
|
"$HOME/.local/share/applications"
|
||||||
|
"$HOME/.local/share/icons/hicolor/256x256/apps"
|
||||||
|
"$HOME/wallpapers"
|
||||||
|
)
|
||||||
|
|
||||||
|
create_directories() {
|
||||||
|
local missing=()
|
||||||
|
local dir
|
||||||
|
for dir in "${directories[@]}"; do
|
||||||
|
[[ -d $dir ]] || missing+=("$dir")
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#missing[@]} == 0 )); then
|
||||||
|
say "ok directories"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
note_change
|
||||||
|
if [[ $check == true ]]; then
|
||||||
|
say "would create ${#missing[@]} directory(ies):"
|
||||||
|
printf ' %s\n' "${missing[@]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${missing[@]}"
|
||||||
|
say "mkdir ${#missing[@]} directory(ies)"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_package_list() {
|
||||||
|
[[ -f $1 ]] || return 0
|
||||||
|
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A list entry may be "wanted|already-fine", which asks for the first name but
|
||||||
|
# treats either as satisfying the requirement. That is how the AUR replacement
|
||||||
|
# for a package still shipped by another repository avoids a file conflict.
|
||||||
|
missing_packages() {
|
||||||
|
local entry wanted alternative
|
||||||
|
for entry in $(read_package_list "$1"); do
|
||||||
|
wanted=${entry%%|*}
|
||||||
|
alternative=${entry#*|}
|
||||||
|
pacman -Q "$wanted" &>/dev/null && continue
|
||||||
|
[[ $alternative != "$entry" ]] && pacman -Q "$alternative" &>/dev/null && continue
|
||||||
|
printf '%s\n' "$wanted"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
install_packages() {
|
||||||
|
if [[ $skip_packages == true ]]; then
|
||||||
|
say "skip packages (--skip-packages)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a repo_missing aur_missing
|
||||||
|
mapfile -t repo_missing < <(missing_packages "$repo_dir/packages/blob.packages")
|
||||||
|
mapfile -t aur_missing < <(missing_packages "$repo_dir/packages/blob-aur.packages")
|
||||||
|
|
||||||
|
if (( ${#repo_missing[@]} == 0 && ${#aur_missing[@]} == 0 )); then
|
||||||
|
say "ok packages"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
note_change
|
||||||
|
if [[ $check == true ]]; then
|
||||||
|
(( ${#repo_missing[@]} )) && say "would install ${#repo_missing[@]} repo package(s): ${repo_missing[*]}"
|
||||||
|
(( ${#aur_missing[@]} )) && say "would install ${#aur_missing[@]} AUR package(s): ${aur_missing[*]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
blob_sudo_keepalive
|
||||||
|
|
||||||
|
if (( ${#repo_missing[@]} )); then
|
||||||
|
say "install ${#repo_missing[@]} repo package(s)"
|
||||||
|
sudo pacman -S --needed --noconfirm "${repo_missing[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#aur_missing[@]} )); then
|
||||||
|
if ! command -v yay &>/dev/null; then
|
||||||
|
say "WARN yay is not installed; skipping ${#aur_missing[@]} AUR package(s)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
say "install ${#aur_missing[@]} AUR package(s)"
|
||||||
|
yay -S --needed --noconfirm "${aur_missing[@]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
blob_sudo_keepalive() {
|
||||||
|
sudo -v 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# BLOB_PATH is a symlink to this checkout rather than a copy, so bin/, shell/,
|
# BLOB_PATH is a symlink to this checkout rather than a copy, so bin/, shell/,
|
||||||
# themes/ and default/ are always the working tree. Every path the shell
|
# themes/ and default/ are always the working tree. Every path the shell
|
||||||
# resolves as $BLOB_PATH/... therefore needs no install step at all.
|
# resolves as $BLOB_PATH/... therefore needs no install step at all.
|
||||||
@@ -144,12 +253,8 @@ fi
|
|||||||
say ""
|
say ""
|
||||||
|
|
||||||
link_blob_path
|
link_blob_path
|
||||||
|
create_directories
|
||||||
if [[ $check == false ]]; then
|
install_packages
|
||||||
mkdir -p "$state_dir"/{toggles/hypr,indicators,notifications}
|
|
||||||
mkdir -p "$config_dir/blob"/{hooks,extensions,plugins,themed,themes}
|
|
||||||
mkdir -p "$HOME/wallpapers"
|
|
||||||
fi
|
|
||||||
|
|
||||||
install_tree "$repo_dir/hypr" "$config_dir/hypr" "hypr"
|
install_tree "$repo_dir/hypr" "$config_dir/hypr" "hypr"
|
||||||
# config/ is the shipped-defaults directory. Everything in it is reachable as
|
# config/ is the shipped-defaults directory. Everything in it is reachable as
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Packages this desktop needs from the AUR.
|
||||||
|
# Installed by install.sh with: yay -S --needed
|
||||||
|
#
|
||||||
|
# These have no official-repo equivalent. See docs/packages.md.
|
||||||
|
|
||||||
|
# Wallpaper daemon used by blob-wallpaper-set and blob-theme-menu
|
||||||
|
awww
|
||||||
|
|
||||||
|
# Palette extraction behind blob-wallpaper-set
|
||||||
|
python-pywal
|
||||||
|
|
||||||
|
# Terminal dispatch; every TUI launcher's Exec= line calls it
|
||||||
|
xdg-terminal-exec
|
||||||
|
|
||||||
|
# Screenshare picker used by the Hyprland portal
|
||||||
|
hyprland-preview-share-picker-git|hyprland-preview-share-picker
|
||||||
|
|
||||||
|
# Theming GUI that writes the same colors.toml the CLI does
|
||||||
|
aether
|
||||||
|
|
||||||
|
# Fonts and icons with no official build
|
||||||
|
ttf-ia-writer
|
||||||
|
woff2-font-awesome
|
||||||
|
yaru-icon-theme
|
||||||
|
|
||||||
|
# Tooling
|
||||||
|
mise-bin
|
||||||
|
ufw-docker
|
||||||
|
lazydocker
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Packages this desktop needs from the official Arch repositories.
|
||||||
|
# Installed by install.sh with: pacman -S --needed
|
||||||
|
#
|
||||||
|
# Derived from Omarchy's base list, minus its own software and the features
|
||||||
|
# this fork dropped, plus what the fork's own scripts and widgets need.
|
||||||
|
|
||||||
|
# Compositor and shell
|
||||||
|
hyprland
|
||||||
|
quickshell
|
||||||
|
uwsm
|
||||||
|
sddm
|
||||||
|
hyprland-guiutils
|
||||||
|
hyprpicker
|
||||||
|
hyprsunset
|
||||||
|
xdg-desktop-portal-hyprland
|
||||||
|
xdg-desktop-portal-gtk
|
||||||
|
gtk4-layer-shell
|
||||||
|
qt6-wayland
|
||||||
|
qt6-imageformats
|
||||||
|
wl-clipboard
|
||||||
|
wtype
|
||||||
|
socat
|
||||||
|
inotify-tools
|
||||||
|
plymouth
|
||||||
|
polkit
|
||||||
|
|
||||||
|
# Shell widget backends
|
||||||
|
playerctl
|
||||||
|
pamixer
|
||||||
|
brightnessctl
|
||||||
|
ddcutil
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
networkmanager
|
||||||
|
nss-mdns
|
||||||
|
avahi
|
||||||
|
wireless-regdb
|
||||||
|
|
||||||
|
# Bluetooth
|
||||||
|
bluez
|
||||||
|
bluez-utils
|
||||||
|
bluez-tools
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
pipewire
|
||||||
|
pipewire-alsa
|
||||||
|
pipewire-jack
|
||||||
|
pipewire-pulse
|
||||||
|
wireplumber
|
||||||
|
alsa-utils
|
||||||
|
lsp-plugins-lv2
|
||||||
|
|
||||||
|
# Power and thermals
|
||||||
|
power-profiles-daemon
|
||||||
|
thermald
|
||||||
|
|
||||||
|
# Storage and devices
|
||||||
|
udiskie
|
||||||
|
gvfs-mtp
|
||||||
|
gvfs-nfs
|
||||||
|
gvfs-smb
|
||||||
|
dosfstools
|
||||||
|
exfatprogs
|
||||||
|
btrfs-progs
|
||||||
|
bolt
|
||||||
|
kernel-modules-hook
|
||||||
|
gnome-disk-utility
|
||||||
|
|
||||||
|
# Printing
|
||||||
|
cups
|
||||||
|
cups-filters
|
||||||
|
cups-pk-helper
|
||||||
|
system-config-printer
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ufw
|
||||||
|
gnome-keyring
|
||||||
|
libsecret
|
||||||
|
|
||||||
|
# Boot and snapshots
|
||||||
|
limine
|
||||||
|
snapper
|
||||||
|
zram-generator
|
||||||
|
|
||||||
|
# System utilities
|
||||||
|
plocate
|
||||||
|
man-db
|
||||||
|
tzupdate
|
||||||
|
expac
|
||||||
|
pacman-contrib
|
||||||
|
|
||||||
|
# Terminals
|
||||||
|
foot
|
||||||
|
kitty
|
||||||
|
|
||||||
|
# Fonts and icons
|
||||||
|
ttf-jetbrains-mono-nerd
|
||||||
|
noto-fonts
|
||||||
|
noto-fonts-cjk
|
||||||
|
noto-fonts-emoji
|
||||||
|
fontconfig
|
||||||
|
gnome-themes-extra
|
||||||
|
|
||||||
|
# Image and video handling
|
||||||
|
imagemagick
|
||||||
|
libvips
|
||||||
|
webp-pixbuf-loader
|
||||||
|
ffmpegthumbnailer
|
||||||
|
|
||||||
|
# Capture
|
||||||
|
grim
|
||||||
|
slurp
|
||||||
|
gpu-screen-recorder
|
||||||
|
tesseract
|
||||||
|
tesseract-data-eng
|
||||||
|
qrencode
|
||||||
|
zbar
|
||||||
|
|
||||||
|
# Media
|
||||||
|
mpv
|
||||||
|
mpv-mpris
|
||||||
|
imv
|
||||||
|
evince
|
||||||
|
|
||||||
|
# Files
|
||||||
|
nautilus
|
||||||
|
nautilus-python
|
||||||
|
sushi
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
neovim
|
||||||
|
|
||||||
|
# Browser for web apps
|
||||||
|
chromium
|
||||||
|
|
||||||
|
# Command line
|
||||||
|
bat
|
||||||
|
eza
|
||||||
|
fd
|
||||||
|
ripgrep
|
||||||
|
fzf
|
||||||
|
zoxide
|
||||||
|
jq
|
||||||
|
starship
|
||||||
|
git
|
||||||
|
lazygit
|
||||||
|
btop
|
||||||
|
fastfetch
|
||||||
|
inxi
|
||||||
|
dua-cli
|
||||||
|
tldr
|
||||||
|
whois
|
||||||
|
inetutils
|
||||||
|
unzip
|
||||||
|
yt-dlp
|
||||||
|
gum
|
||||||
|
less
|
||||||
|
python-gobject
|
||||||
|
|
||||||
|
# Containers
|
||||||
|
docker
|
||||||
|
docker-compose
|
||||||
|
docker-buildx
|
||||||
|
|
||||||
|
# Sharing
|
||||||
|
localsend
|
||||||
@@ -13,6 +13,17 @@ Item {
|
|||||||
readonly property string home: Quickshell.env("HOME")
|
readonly property string home: Quickshell.env("HOME")
|
||||||
readonly property string stateHome: home + "/.local/state"
|
readonly property string stateHome: home + "/.local/state"
|
||||||
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
|
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
|
||||||
|
readonly property string monitorBackgroundDir: stateHome + "/blob/backgrounds"
|
||||||
|
|
||||||
|
// Per-monitor assignments, keyed by output name, written by blob-bg-monitor.
|
||||||
|
// A monitor with no entry falls back to the global background, so the
|
||||||
|
// transition machinery below stays on the single-image path it was built for.
|
||||||
|
property var monitorBackgrounds: ({})
|
||||||
|
|
||||||
|
function monitorBackgroundFor(name) {
|
||||||
|
var assigned = root.monitorBackgrounds[String(name || "")]
|
||||||
|
return assigned ? String(assigned) : ""
|
||||||
|
}
|
||||||
|
|
||||||
property string currentBackground: ""
|
property string currentBackground: ""
|
||||||
property string displayedBackground: ""
|
property string displayedBackground: ""
|
||||||
@@ -32,6 +43,20 @@ Item {
|
|||||||
|
|
||||||
function refreshBackground() {
|
function refreshBackground() {
|
||||||
if (!readlinkProc.running) readlinkProc.running = true
|
if (!readlinkProc.running) readlinkProc.running = true
|
||||||
|
if (!monitorBackgroundProc.running) monitorBackgroundProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMonitorBackgrounds(raw) {
|
||||||
|
var next = ({})
|
||||||
|
var lines = String(raw || "").split("\n")
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim()
|
||||||
|
if (!line) continue
|
||||||
|
var split = line.indexOf("\t")
|
||||||
|
if (split <= 0) continue
|
||||||
|
next[line.substring(0, split)] = line.substring(split + 1)
|
||||||
|
}
|
||||||
|
root.monitorBackgrounds = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackground(path, instant) {
|
function setBackground(path, instant) {
|
||||||
@@ -128,6 +153,18 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: monitorBackgroundProc
|
||||||
|
command: ["bash", "-c",
|
||||||
|
'dir="$1"; [[ -d $dir ]] || exit 0; ' +
|
||||||
|
'for link in "$dir"/*; do [[ -e $link ]] || continue; ' +
|
||||||
|
'printf "%s\\t%s\\n" "${link##*/}" "$(readlink -f "$link")"; done',
|
||||||
|
"--", root.monitorBackgroundDir]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.loadMonitorBackgrounds(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "background"
|
target: "background"
|
||||||
|
|
||||||
@@ -187,6 +224,9 @@ Item {
|
|||||||
|
|
||||||
screen: modelData
|
screen: modelData
|
||||||
visible: !remapGuard.remapping
|
visible: !remapGuard.remapping
|
||||||
|
|
||||||
|
readonly property string assignedBackground: root.monitorBackgroundFor(modelData ? modelData.name : "")
|
||||||
|
readonly property bool hasAssignment: assignedBackground.length > 0
|
||||||
anchors { top: true; bottom: true; left: true; right: true }
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
|
||||||
ScreenMoveRemap {
|
ScreenMoveRemap {
|
||||||
@@ -221,7 +261,7 @@ Item {
|
|||||||
Image {
|
Image {
|
||||||
id: base
|
id: base
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
source: root.imageUrl(root.displayedBackground)
|
source: root.imageUrl(panel.hasAssignment ? panel.assignedBackground : root.displayedBackground)
|
||||||
fillMode: Image.PreserveAspectCrop
|
fillMode: Image.PreserveAspectCrop
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
cache: true
|
cache: true
|
||||||
@@ -243,14 +283,14 @@ Item {
|
|||||||
cache: false
|
cache: false
|
||||||
smooth: true
|
smooth: true
|
||||||
mipmap: true
|
mipmap: true
|
||||||
visible: root.oldBackground !== "" && root.revealProgress < 1
|
visible: !panel.hasAssignment && root.oldBackground !== "" && root.revealProgress < 1
|
||||||
onStatusChanged: panel.maybeStartReveal()
|
onStatusChanged: panel.maybeStartReveal()
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: incomingLayer
|
id: incomingLayer
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
|
visible: !panel.hasAssignment && root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
|
||||||
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
|
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
|
||||||
layer.smooth: true
|
layer.smooth: true
|
||||||
layer.effect: MultiEffect {
|
layer.effect: MultiEffect {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property bool active: false
|
||||||
|
signal activated()
|
||||||
|
|
||||||
|
implicitWidth: text.implicitWidth + Style.spaceReal(16)
|
||||||
|
implicitHeight: Style.spaceReal(22)
|
||||||
|
radius: Style.cardRadius
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
color: root.active
|
||||||
|
? Color.accent
|
||||||
|
: (hover.hovered ? Util.alpha(Color.blue, 0.25) : Util.alpha(Color.background, Style.cardFillAlpha))
|
||||||
|
border.width: 1
|
||||||
|
border.color: root.active ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: text
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.label
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: root.active ? Color.background : Color.foreground
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.activated()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
.pragma library
|
||||||
|
|
||||||
|
// hyprctl reports logical size as the raw mode divided by scale, so a 1920x1200
|
||||||
|
// panel at 1.5 occupies 1280x800 of layout space. Positions are in that same
|
||||||
|
// logical space, which is what the canvas has to draw.
|
||||||
|
function logicalSize(monitor) {
|
||||||
|
var scale = Number(monitor.scale) || 1
|
||||||
|
var width = Number(monitor.width) || 0
|
||||||
|
var height = Number(monitor.height) || 0
|
||||||
|
if (isRotated(monitor)) {
|
||||||
|
var swap = width
|
||||||
|
width = height
|
||||||
|
height = swap
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: Math.round(width / scale),
|
||||||
|
height: Math.round(height / scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRotated(monitor) {
|
||||||
|
var transform = Number(monitor.transform) || 0
|
||||||
|
return transform === 1 || transform === 3 || transform === 5 || transform === 7
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMonitors(raw) {
|
||||||
|
var parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(String(raw || "[]"))
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
|
||||||
|
var monitors = []
|
||||||
|
for (var i = 0; i < parsed.length; i++) {
|
||||||
|
var m = parsed[i] || {}
|
||||||
|
var size = logicalSize(m)
|
||||||
|
monitors.push({
|
||||||
|
name: String(m.name || ""),
|
||||||
|
description: String(m.description || ""),
|
||||||
|
mode: modeStringOf(m),
|
||||||
|
modes: Array.isArray(m.availableModes) ? m.availableModes : [],
|
||||||
|
x: Number(m.x) || 0,
|
||||||
|
y: Number(m.y) || 0,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
scale: Number(m.scale) || 1,
|
||||||
|
transform: Number(m.transform) || 0,
|
||||||
|
disabled: m.disabled === true,
|
||||||
|
focused: m.focused === true,
|
||||||
|
mirrorOf: String(m.mirrorOf || "none")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
monitors.sort(function(a, b) { return a.x - b.x })
|
||||||
|
return monitors
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeStringOf(monitor) {
|
||||||
|
var width = Number(monitor.width) || 0
|
||||||
|
var height = Number(monitor.height) || 0
|
||||||
|
var rate = Number(monitor.refreshRate) || 0
|
||||||
|
if (!width || !height) return "preferred"
|
||||||
|
return width + "x" + height + "@" + rate.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas is a scaled-down picture of the desktop. Monitors can sit at
|
||||||
|
// negative coordinates, so the bounding box is translated to the origin before
|
||||||
|
// a single scale factor is chosen for both axes.
|
||||||
|
function layoutBounds(monitors) {
|
||||||
|
if (!monitors.length) return { x: 0, y: 0, width: 1, height: 1 }
|
||||||
|
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
for (var i = 0; i < monitors.length; i++) {
|
||||||
|
var m = monitors[i]
|
||||||
|
if (m.disabled) continue
|
||||||
|
minX = Math.min(minX, m.x)
|
||||||
|
minY = Math.min(minY, m.y)
|
||||||
|
maxX = Math.max(maxX, m.x + m.width)
|
||||||
|
maxY = Math.max(maxY, m.y + m.height)
|
||||||
|
}
|
||||||
|
if (minX === Infinity) return { x: 0, y: 0, width: 1, height: 1 }
|
||||||
|
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasScale(bounds, availableWidth, availableHeight) {
|
||||||
|
return Math.min(availableWidth / bounds.width, availableHeight / bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap a dragged edge to a neighbour's edge when it lands within the threshold,
|
||||||
|
// so monitors end up touching exactly rather than a few pixels apart.
|
||||||
|
function snapPosition(monitors, name, x, y, threshold) {
|
||||||
|
var snappedX = x
|
||||||
|
var snappedY = y
|
||||||
|
var self = null
|
||||||
|
for (var i = 0; i < monitors.length; i++) {
|
||||||
|
if (monitors[i].name === name) { self = monitors[i]; break }
|
||||||
|
}
|
||||||
|
if (!self) return { x: Math.round(x), y: Math.round(y) }
|
||||||
|
|
||||||
|
for (var j = 0; j < monitors.length; j++) {
|
||||||
|
var other = monitors[j]
|
||||||
|
if (other.name === name || other.disabled) continue
|
||||||
|
|
||||||
|
var candidatesX = [
|
||||||
|
other.x + other.width,
|
||||||
|
other.x - self.width,
|
||||||
|
other.x
|
||||||
|
]
|
||||||
|
for (var cx = 0; cx < candidatesX.length; cx++) {
|
||||||
|
if (Math.abs(snappedX - candidatesX[cx]) <= threshold) {
|
||||||
|
snappedX = candidatesX[cx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidatesY = [
|
||||||
|
other.y + other.height,
|
||||||
|
other.y - self.height,
|
||||||
|
other.y
|
||||||
|
]
|
||||||
|
for (var cy = 0; cy < candidatesY.length; cy++) {
|
||||||
|
if (Math.abs(snappedY - candidatesY[cy]) <= threshold) {
|
||||||
|
snappedY = candidatesY[cy]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: Math.round(snappedX), y: Math.round(snappedY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's keyword form: monitor = name,mode,position,scale[,transform,N]
|
||||||
|
function monitorKeyword(monitor) {
|
||||||
|
if (monitor.disabled) return monitor.name + ",disable"
|
||||||
|
var parts = [
|
||||||
|
monitor.name,
|
||||||
|
monitor.mode,
|
||||||
|
monitor.x + "x" + monitor.y,
|
||||||
|
String(monitor.scale)
|
||||||
|
]
|
||||||
|
var keyword = parts.join(",")
|
||||||
|
if (monitor.transform && monitor.transform !== 0)
|
||||||
|
keyword += ",transform," + monitor.transform
|
||||||
|
if (monitor.mirrorOf && monitor.mirrorOf !== "none")
|
||||||
|
keyword += ",mirror," + monitor.mirrorOf
|
||||||
|
return keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
function keywordsFor(monitors) {
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < monitors.length; i++) out.push(monitorKeyword(monitors[i]))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland rejects a fractional scale that does not land on a whole number of
|
||||||
|
// physical pixels. It steps in 1/120, so a scale is valid when both axes come
|
||||||
|
// out integral at that granularity.
|
||||||
|
function scaleIsValid(monitor, scale) {
|
||||||
|
if (!scale || scale <= 0) return false
|
||||||
|
var stepped = Math.round(scale * 120) / 120
|
||||||
|
var width = Number(monitor.width) * Number(monitor.scale)
|
||||||
|
var height = Number(monitor.height) * Number(monitor.scale)
|
||||||
|
var logicalWidth = width / stepped
|
||||||
|
var logicalHeight = height / stepped
|
||||||
|
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.001
|
||||||
|
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearbyScales(monitor) {
|
||||||
|
var options = []
|
||||||
|
for (var step = 60; step <= 300; step += 6) {
|
||||||
|
var candidate = step / 120
|
||||||
|
if (scaleIsValid(monitor, candidate)) options.push(candidate)
|
||||||
|
}
|
||||||
|
if (!options.length) options.push(1)
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "DisplayModel.js" as DisplayModel
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var shell: null
|
||||||
|
property var manifest: null
|
||||||
|
property bool opened: false
|
||||||
|
|
||||||
|
readonly property string pluginId: (manifest && manifest.id) || "blob.displays"
|
||||||
|
readonly property string home: Quickshell.env("HOME")
|
||||||
|
|
||||||
|
property var monitors: []
|
||||||
|
property string selectedName: ""
|
||||||
|
property string activeTab: "monitors"
|
||||||
|
property var assignments: ({})
|
||||||
|
property bool layoutDirty: false
|
||||||
|
|
||||||
|
readonly property var selectedMonitor: {
|
||||||
|
for (var i = 0; i < root.monitors.length; i++)
|
||||||
|
if (root.monitors[i].name === root.selectedName) return root.monitors[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(payloadJson) {
|
||||||
|
root.opened = true
|
||||||
|
root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
root.opened = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
root.opened = false
|
||||||
|
if (root.shell && typeof root.shell.hide === "function")
|
||||||
|
root.shell.hide(root.pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command) {
|
||||||
|
Quickshell.execDetached(["bash", "-c", command])
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!monitorProc.running) monitorProc.running = true
|
||||||
|
if (!assignmentProc.running) assignmentProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMonitors(raw) {
|
||||||
|
var parsed = DisplayModel.parseMonitors(raw)
|
||||||
|
root.monitors = parsed
|
||||||
|
root.layoutDirty = false
|
||||||
|
if (root.selectedName.length > 0) {
|
||||||
|
for (var i = 0; i < parsed.length; i++)
|
||||||
|
if (parsed[i].name === root.selectedName) return
|
||||||
|
}
|
||||||
|
root.selectedName = parsed.length > 0 ? parsed[0].name : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAssignments(raw) {
|
||||||
|
var next = ({})
|
||||||
|
var lines = String(raw || "").split("\n")
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim()
|
||||||
|
if (!line) continue
|
||||||
|
var split = line.indexOf("\t")
|
||||||
|
if (split <= 0) continue
|
||||||
|
next[line.substring(0, split)] = line.substring(split + 1)
|
||||||
|
}
|
||||||
|
root.assignments = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutating the array in place would not retrigger the bindings the canvas
|
||||||
|
// reads, so every edit rebuilds it.
|
||||||
|
function replaceMonitor(name, changes) {
|
||||||
|
var next = []
|
||||||
|
for (var i = 0; i < root.monitors.length; i++) {
|
||||||
|
var monitor = root.monitors[i]
|
||||||
|
if (monitor.name !== name) {
|
||||||
|
next.push(monitor)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var copy = ({})
|
||||||
|
for (var key in monitor) copy[key] = monitor[key]
|
||||||
|
for (var change in changes) copy[change] = changes[change]
|
||||||
|
next.push(copy)
|
||||||
|
}
|
||||||
|
root.monitors = next
|
||||||
|
root.layoutDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveMonitor(name, x, y) {
|
||||||
|
var snapped = DisplayModel.snapPosition(root.monitors, name, x, y, 60)
|
||||||
|
root.replaceMonitor(name, { x: snapped.x, y: snapped.y })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMonitor(name) {
|
||||||
|
for (var i = 0; i < root.monitors.length; i++) {
|
||||||
|
if (root.monitors[i].name !== name) continue
|
||||||
|
root.run("blob-display-arrange apply " + Util.shellQuote(DisplayModel.monitorKeyword(root.monitors[i])))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout() {
|
||||||
|
var keywords = DisplayModel.keywordsFor(root.monitors)
|
||||||
|
var quoted = []
|
||||||
|
for (var i = 0; i < keywords.length; i++) quoted.push(Util.shellQuote(keywords[i]))
|
||||||
|
root.run("blob-display-arrange apply " + quoted.join(" "))
|
||||||
|
root.layoutDirty = false
|
||||||
|
reloadTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
root.run("blob-display-arrange reset")
|
||||||
|
reloadTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignWallpaper(path) {
|
||||||
|
if (root.selectedName.length === 0) return
|
||||||
|
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName) + " " + Util.shellQuote(path))
|
||||||
|
var next = ({})
|
||||||
|
for (var key in root.assignments) next[key] = root.assignments[key]
|
||||||
|
next[root.selectedName] = path
|
||||||
|
root.assignments = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAssignment() {
|
||||||
|
if (root.selectedName.length === 0) return
|
||||||
|
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName) + " --clear")
|
||||||
|
var next = ({})
|
||||||
|
for (var key in root.assignments)
|
||||||
|
if (key !== root.selectedName) next[key] = root.assignments[key]
|
||||||
|
root.assignments = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalWallpaper(path) {
|
||||||
|
root.run("blob-bg-set " + Util.shellQuote(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: monitorProc
|
||||||
|
command: ["hyprctl", "monitors", "all", "-j"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.loadMonitors(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: assignmentProc
|
||||||
|
command: ["bash", "-c",
|
||||||
|
'dir="$HOME/.local/state/blob/backgrounds"; [[ -d $dir ]] || exit 0; ' +
|
||||||
|
'for link in "$dir"/*; do [[ -e $link ]] || continue; ' +
|
||||||
|
'printf "%s\\t%s\\n" "${link##*/}" "$(readlink -f "$link")"; done']
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.loadAssignments(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: reloadTimer
|
||||||
|
interval: 400
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 4000
|
||||||
|
running: root.opened && !root.layoutDirty
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
visible: root.opened
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
color: "transparent"
|
||||||
|
WlrLayershell.namespace: "blob-displays"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: root.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
Card {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
implicitWidth: Style.spaceReal(760)
|
||||||
|
implicitHeight: Style.spaceReal(560)
|
||||||
|
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
|
||||||
|
spacing: Style.spaceReal(10)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: Math.max(title.implicitHeight, tabs.implicitHeight)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: title
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Displays"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Color.foreground
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: tabs
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Style.spaceReal(6)
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: "Monitors"
|
||||||
|
active: root.activeTab === "monitors"
|
||||||
|
onActivated: root.activeTab = "monitors"
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: "Wallpapers"
|
||||||
|
active: root.activeTab === "wallpapers"
|
||||||
|
onActivated: root.activeTab = "wallpapers"
|
||||||
|
}
|
||||||
|
|
||||||
|
CardIconButton {
|
||||||
|
icon: ""
|
||||||
|
onActivated: root.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorCanvas {
|
||||||
|
width: parent.width
|
||||||
|
height: Style.spaceReal(200)
|
||||||
|
monitors: root.monitors
|
||||||
|
selectedName: root.selectedName
|
||||||
|
onSelected: function(name) { root.selectedName = name }
|
||||||
|
onMonitorMoved: function(name, x, y) { root.moveMonitor(name, x, y) }
|
||||||
|
onLayoutSettled: root.applyMonitor(root.selectedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: Style.spaceReal(250)
|
||||||
|
|
||||||
|
MonitorControls {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.activeTab === "monitors"
|
||||||
|
monitor: root.selectedMonitor
|
||||||
|
monitors: root.monitors
|
||||||
|
onScaleRequested: function(scale) {
|
||||||
|
root.replaceMonitor(root.selectedName, { scale: scale })
|
||||||
|
root.applyMonitor(root.selectedName)
|
||||||
|
}
|
||||||
|
onModeRequested: function(mode) {
|
||||||
|
root.replaceMonitor(root.selectedName, { mode: mode })
|
||||||
|
root.applyMonitor(root.selectedName)
|
||||||
|
}
|
||||||
|
onEnabledRequested: function(enabled) {
|
||||||
|
root.replaceMonitor(root.selectedName, { disabled: !enabled })
|
||||||
|
root.applyMonitor(root.selectedName)
|
||||||
|
}
|
||||||
|
onMirrorRequested: function(target) {
|
||||||
|
root.replaceMonitor(root.selectedName, { mirrorOf: target })
|
||||||
|
root.applyMonitor(root.selectedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: root.activeTab === "wallpapers"
|
||||||
|
spacing: Style.spaceReal(8)
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
implicitHeight: assignedLabel.implicitHeight
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: assignedLabel
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.selectedName.length > 0
|
||||||
|
? (root.assignments[root.selectedName]
|
||||||
|
? root.selectedName + ": " + String(root.assignments[root.selectedName]).replace(/^.*\//, "")
|
||||||
|
: root.selectedName + ": following the global wallpaper")
|
||||||
|
: "Select a monitor above"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.8)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
label: "Clear"
|
||||||
|
enabled: root.selectedName.length > 0 && !!root.assignments[root.selectedName]
|
||||||
|
onActivated: root.clearAssignment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WallpaperGrid {
|
||||||
|
width: parent.width
|
||||||
|
height: parent.height - assignedLabel.implicitHeight - Style.spaceReal(8)
|
||||||
|
polling: root.opened && root.activeTab === "wallpapers"
|
||||||
|
assignedPath: root.selectedName.length > 0
|
||||||
|
? String(root.assignments[root.selectedName] || "") : ""
|
||||||
|
onChosen: function(path) { root.assignWallpaper(path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.spaceReal(6)
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: root.layoutDirty ? "Apply all" : "Re-apply all"
|
||||||
|
active: root.layoutDirty
|
||||||
|
onActivated: root.applyLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: "Reset to monitors.lua"
|
||||||
|
onActivated: root.resetLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: "Set globally"
|
||||||
|
enabled: root.activeTab === "wallpapers" && root.selectedName.length > 0
|
||||||
|
&& !!root.assignments[root.selectedName]
|
||||||
|
onActivated: root.setGlobalWallpaper(String(root.assignments[root.selectedName]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenedChanged: if (root.opened) root.refresh()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
import "DisplayModel.js" as DisplayModel
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitors: []
|
||||||
|
property string selectedName: ""
|
||||||
|
readonly property int snapThreshold: 60
|
||||||
|
|
||||||
|
signal selected(string name)
|
||||||
|
signal monitorMoved(string name, int x, int y)
|
||||||
|
signal layoutSettled()
|
||||||
|
|
||||||
|
readonly property var bounds: DisplayModel.layoutBounds(root.monitors)
|
||||||
|
readonly property real fitScale: DisplayModel.canvasScale(
|
||||||
|
bounds,
|
||||||
|
Math.max(1, width - Style.spaceReal(24)),
|
||||||
|
Math.max(1, height - Style.spaceReal(24)))
|
||||||
|
|
||||||
|
color: Util.alpha(Color.background, 0.35)
|
||||||
|
radius: Style.cardRadius
|
||||||
|
border.width: 1
|
||||||
|
border.color: Util.alpha(Color.foreground, 0.15)
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: stage
|
||||||
|
width: root.bounds.width * root.fitScale
|
||||||
|
height: root.bounds.height * root.fitScale
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.monitors
|
||||||
|
|
||||||
|
MonitorCard {
|
||||||
|
required property var modelData
|
||||||
|
monitor: modelData
|
||||||
|
canvasScale: root.fitScale
|
||||||
|
originX: root.bounds.x
|
||||||
|
originY: root.bounds.y
|
||||||
|
selected: modelData.name === root.selectedName
|
||||||
|
onPicked: root.selected(modelData.name)
|
||||||
|
onMoved: function(x, y) { root.monitorMoved(modelData.name, x, y) }
|
||||||
|
onDropped: root.layoutSettled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: root.monitors.length === 0
|
||||||
|
text: "No monitors reported"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.6)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property bool selected: false
|
||||||
|
property real canvasScale: 1
|
||||||
|
property real originX: 0
|
||||||
|
property real originY: 0
|
||||||
|
|
||||||
|
signal picked()
|
||||||
|
signal moved(int x, int y)
|
||||||
|
signal dropped()
|
||||||
|
|
||||||
|
readonly property string name: monitor ? monitor.name : ""
|
||||||
|
readonly property bool disabled: monitor ? monitor.disabled === true : false
|
||||||
|
|
||||||
|
visible: !!monitor && !disabled
|
||||||
|
width: monitor ? Math.max(Style.spaceReal(24), monitor.width * canvasScale) : 0
|
||||||
|
height: monitor ? Math.max(Style.spaceReal(18), monitor.height * canvasScale) : 0
|
||||||
|
x: monitor ? (monitor.x - originX) * canvasScale : 0
|
||||||
|
y: monitor ? (monitor.y - originY) * canvasScale : 0
|
||||||
|
|
||||||
|
radius: Style.cardRadius
|
||||||
|
color: root.selected
|
||||||
|
? Util.alpha(Color.accent, 0.35)
|
||||||
|
: Util.alpha(Color.background, Style.cardFillAlpha)
|
||||||
|
border.width: Style.cardBorderWidth
|
||||||
|
border.color: root.selected ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Style.spaceReal(2)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: root.name
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Color.foreground
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
visible: root.height > Style.spaceReal(44)
|
||||||
|
text: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.7)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
visible: root.monitor && root.monitor.focused && root.height > Style.spaceReal(58)
|
||||||
|
text: "focused"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Color.blue
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.picked()
|
||||||
|
}
|
||||||
|
|
||||||
|
property int dragOriginX: 0
|
||||||
|
property int dragOriginY: 0
|
||||||
|
|
||||||
|
DragHandler {
|
||||||
|
id: drag
|
||||||
|
target: null
|
||||||
|
onActiveChanged: {
|
||||||
|
if (active) {
|
||||||
|
root.picked()
|
||||||
|
root.dragOriginX = root.monitor ? root.monitor.x : 0
|
||||||
|
root.dragOriginY = root.monitor ? root.monitor.y : 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.dropped()
|
||||||
|
}
|
||||||
|
onTranslationChanged: {
|
||||||
|
if (!active || !root.monitor) return
|
||||||
|
root.moved(
|
||||||
|
Math.round(root.dragOriginX + translation.x / root.canvasScale),
|
||||||
|
Math.round(root.dragOriginY + translation.y / root.canvasScale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Ui
|
||||||
|
import "DisplayModel.js" as DisplayModel
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property var monitors: []
|
||||||
|
|
||||||
|
signal scaleRequested(real scale)
|
||||||
|
signal modeRequested(string mode)
|
||||||
|
signal enabledRequested(bool enabled)
|
||||||
|
signal mirrorRequested(string target)
|
||||||
|
|
||||||
|
readonly property var scaleOptions: monitor ? DisplayModel.nearbyScales(monitor) : []
|
||||||
|
|
||||||
|
spacing: Style.spaceReal(8)
|
||||||
|
visible: !!monitor
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.monitor ? root.monitor.name : ""
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Color.foreground
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.body
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.monitor ? root.monitor.description : ""
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: Util.alpha(Color.foreground, 0.65)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
StatRow {
|
||||||
|
width: parent.width
|
||||||
|
icon: ""
|
||||||
|
label: "Position"
|
||||||
|
value: root.monitor ? root.monitor.x + ", " + root.monitor.y : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
StatRow {
|
||||||
|
width: parent.width
|
||||||
|
icon: ""
|
||||||
|
label: "Logical size"
|
||||||
|
value: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Mode"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.75)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.spaceReal(4)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.monitor ? root.monitor.modes : []
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
required property var modelData
|
||||||
|
label: String(modelData).replace("Hz", "")
|
||||||
|
active: root.monitor && String(modelData).indexOf(root.monitor.mode) === 0
|
||||||
|
onActivated: root.modeRequested(String(modelData).replace("Hz", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Scale"
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.75)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Style.spaceReal(4)
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.scaleOptions
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
required property var modelData
|
||||||
|
label: String(modelData)
|
||||||
|
active: root.monitor && Math.abs(root.monitor.scale - modelData) < 0.001
|
||||||
|
onActivated: root.scaleRequested(Number(modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
text: "Only scales that land on whole pixels are offered. Hyprland steps in 1/120 and rejects the rest."
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.55)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Style.spaceReal(6)
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: root.monitor && root.monitor.disabled ? "Enable" : "Disable"
|
||||||
|
onActivated: root.enabledRequested(root.monitor ? root.monitor.disabled === true : true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Chip {
|
||||||
|
label: root.monitor && root.monitor.mirrorOf !== "none" ? "Unmirror" : "Mirror"
|
||||||
|
active: root.monitor && root.monitor.mirrorOf !== "none"
|
||||||
|
enabled: root.monitors.length > 1
|
||||||
|
onActivated: {
|
||||||
|
if (!root.monitor) return
|
||||||
|
if (root.monitor.mirrorOf !== "none") {
|
||||||
|
root.mirrorRequested("none")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (var i = 0; i < root.monitors.length; i++) {
|
||||||
|
if (root.monitors[i].name !== root.monitor.name) {
|
||||||
|
root.mirrorRequested(root.monitors[i].name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string directory: Quickshell.env("HOME") + "/wallpapers"
|
||||||
|
property string assignedPath: ""
|
||||||
|
property bool polling: false
|
||||||
|
property var wallpapers: []
|
||||||
|
|
||||||
|
signal chosen(string path)
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!listProc.running) listProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: listProc
|
||||||
|
command: ["bash", "-c",
|
||||||
|
'find "$1" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" ' +
|
||||||
|
'-o -iname "*.png" -o -iname "*.webp" -o -iname "*.gif" \\) | sort',
|
||||||
|
"--", root.directory]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
var out = []
|
||||||
|
var lines = String(text).split("\n")
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].trim()
|
||||||
|
if (line.length > 0) out.push(line)
|
||||||
|
}
|
||||||
|
root.wallpapers = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPollingChanged: if (root.polling && root.wallpapers.length === 0) root.refresh()
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: grid
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
cellWidth: Style.spaceReal(120)
|
||||||
|
cellHeight: Style.spaceReal(80)
|
||||||
|
model: root.wallpapers
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
required property var modelData
|
||||||
|
width: grid.cellWidth
|
||||||
|
height: grid.cellHeight
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Style.spaceReal(3)
|
||||||
|
radius: Style.cardRadius
|
||||||
|
color: Util.alpha(Color.background, Style.cardFillAlpha)
|
||||||
|
border.width: Style.cardBorderWidth
|
||||||
|
border.color: modelData === root.assignedPath
|
||||||
|
? Color.accent
|
||||||
|
: (hover.hovered ? Color.blue : Util.alpha(Color.blue, Style.cardBorderAlpha))
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Style.cardBorderWidth
|
||||||
|
source: Util.fileUrl(modelData)
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
cache: true
|
||||||
|
sourceSize.width: Math.round(Style.spaceReal(120) * 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: Style.cardBorderWidth
|
||||||
|
height: caption.implicitHeight + Style.spaceReal(4)
|
||||||
|
visible: hover.hovered || modelData === root.assignedPath
|
||||||
|
color: Util.alpha(Color.background, 0.8)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - Style.spaceReal(6)
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
text: String(modelData).replace(/^.*\//, "")
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Color.foreground
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: root.chosen(String(modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: root.wallpapers.length === 0
|
||||||
|
text: "No images in " + root.directory
|
||||||
|
textFormat: Text.PlainText
|
||||||
|
color: Util.alpha(Color.foreground, 0.6)
|
||||||
|
font.family: Style.font.family
|
||||||
|
font.pixelSize: Style.font.bodySmall
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"name": "Displays",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Blob",
|
||||||
|
"description": "Arrange monitors and assign wallpapers per monitor",
|
||||||
|
"id": "blob.displays",
|
||||||
|
"kinds": [
|
||||||
|
"overlay"
|
||||||
|
],
|
||||||
|
"keepLoaded": true,
|
||||||
|
"entryPoints": {
|
||||||
|
"overlay": "Displays.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user