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.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.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"},
|
||||
|
||||
+3
-1
@@ -43,6 +43,7 @@ of the `blob` listing.
|
||||
| `blob-bg-cache` | Cache background switcher thumbnails for the current theme | - |
|
||||
| `blob-bg-current` | Show current background | - |
|
||||
| `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-set` | Set the current background image | <path-to-image> |
|
||||
| `blob-bg-switcher` | Open the Blob background switcher | - |
|
||||
@@ -138,6 +139,7 @@ of the `blob` listing.
|
||||
|
||||
| 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-state` | Print monitor panel state for the shell | - |
|
||||
|
||||
@@ -620,4 +622,4 @@ of the `blob` listing.
|
||||
|
||||
## 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 + CTRL + M` | System monitor |
|
||||
| `SUPER + CTRL + Q` | Quick settings |
|
||||
| `SUPER + SHIFT + CTRL + D` | Displays |
|
||||
| `SUPER + SPACE` | Apps menu |
|
||||
|
||||
## Applications
|
||||
|
||||
@@ -64,6 +64,35 @@ Two are worth knowing about:
|
||||
|
||||
`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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
services is the obvious next refinement and would remove the last per-tick
|
||||
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 + 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")
|
||||
|
||||
-- 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
|
||||
check=false
|
||||
skip_packages=false
|
||||
changes=0
|
||||
|
||||
usage() {
|
||||
@@ -19,6 +20,7 @@ Usage: ./install.sh [OPTIONS]
|
||||
|
||||
--check Report what would change, write nothing
|
||||
--force Overwrite files that have local changes
|
||||
--skip-packages Do not install packages, only directories and config
|
||||
--help Show this message
|
||||
USAGE
|
||||
exit 0
|
||||
@@ -28,6 +30,7 @@ while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--force) force=true; shift ;;
|
||||
--check) check=true; shift ;;
|
||||
--skip-packages) skip_packages=true; shift ;;
|
||||
--help) usage ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
@@ -41,6 +44,112 @@ note_change() {
|
||||
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/,
|
||||
# themes/ and default/ are always the working tree. Every path the shell
|
||||
# resolves as $BLOB_PATH/... therefore needs no install step at all.
|
||||
@@ -144,12 +253,8 @@ fi
|
||||
say ""
|
||||
|
||||
link_blob_path
|
||||
|
||||
if [[ $check == false ]]; then
|
||||
mkdir -p "$state_dir"/{toggles/hypr,indicators,notifications}
|
||||
mkdir -p "$config_dir/blob"/{hooks,extensions,plugins,themed,themes}
|
||||
mkdir -p "$HOME/wallpapers"
|
||||
fi
|
||||
create_directories
|
||||
install_packages
|
||||
|
||||
install_tree "$repo_dir/hypr" "$config_dir/hypr" "hypr"
|
||||
# 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 stateHome: home + "/.local/state"
|
||||
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 displayedBackground: ""
|
||||
@@ -32,6 +43,20 @@ Item {
|
||||
|
||||
function refreshBackground() {
|
||||
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) {
|
||||
@@ -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 {
|
||||
target: "background"
|
||||
|
||||
@@ -187,6 +224,9 @@ Item {
|
||||
|
||||
screen: modelData
|
||||
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 }
|
||||
|
||||
ScreenMoveRemap {
|
||||
@@ -221,7 +261,7 @@ Item {
|
||||
Image {
|
||||
id: base
|
||||
anchors.fill: parent
|
||||
source: root.imageUrl(root.displayedBackground)
|
||||
source: root.imageUrl(panel.hasAssignment ? panel.assignedBackground : root.displayedBackground)
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
@@ -243,14 +283,14 @@ Item {
|
||||
cache: false
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: root.oldBackground !== "" && root.revealProgress < 1
|
||||
visible: !panel.hasAssignment && root.oldBackground !== "" && root.revealProgress < 1
|
||||
onStatusChanged: panel.maybeStartReveal()
|
||||
}
|
||||
|
||||
Item {
|
||||
id: incomingLayer
|
||||
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.smooth: true
|
||||
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