Add a displays widget for monitor layout and per-monitor wallpapers

This commit is contained in:
2026-09-20 04:07:26 -04:00
parent 1cd50be73a
commit a32254c398
20 changed files with 1570 additions and 13 deletions
+81
View File
@@ -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")"
+68
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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
+29
View File
@@ -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
``` ```
+48
View File
@@ -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 |
+1
View File
@@ -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.
+114 -9
View File
@@ -11,15 +11,17 @@ font_dir="$HOME/.local/share/fonts"
force=false force=false
check=false check=false
skip_packages=false
changes=0 changes=0
usage() { usage() {
cat <<USAGE cat <<USAGE
Usage: ./install.sh [OPTIONS] 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
--help Show this message --skip-packages Do not install packages, only directories and config
--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
+29
View File
@@ -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
+166
View File
@@ -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
+43 -3
View File
@@ -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 {
+38
View File
@@ -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
}
+344
View File
@@ -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"
}
}