From a32254c3985735108bf8b308716dfe46a16faf95 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 20 Sep 2026 04:07:26 -0400 Subject: [PATCH] Add a displays widget for monitor layout and per-monitor wallpapers --- bin/blob-bg-monitor | 81 +++++ bin/blob-display-arrange | 68 ++++ default/blob/blob-menu.jsonc | 1 + docs/commands.md | 4 +- docs/keybinds.md | 1 + docs/packages.md | 29 ++ docs/widgets.md | 48 +++ hypr/bindings.lua | 1 + install.sh | 123 ++++++- packages/blob-aur.packages | 29 ++ packages/blob.packages | 166 +++++++++ shell/plugins/background/Background.qml | 46 ++- shell/plugins/panels/displays/Chip.qml | 38 ++ shell/plugins/panels/displays/DisplayModel.js | 176 +++++++++ shell/plugins/panels/displays/Displays.qml | 344 ++++++++++++++++++ .../plugins/panels/displays/MonitorCanvas.qml | 60 +++ shell/plugins/panels/displays/MonitorCard.qml | 94 +++++ .../panels/displays/MonitorControls.qml | 140 +++++++ .../plugins/panels/displays/WallpaperGrid.qml | 119 ++++++ shell/plugins/panels/displays/manifest.json | 15 + 20 files changed, 1570 insertions(+), 13 deletions(-) create mode 100755 bin/blob-bg-monitor create mode 100755 bin/blob-display-arrange create mode 100644 packages/blob-aur.packages create mode 100644 packages/blob.packages create mode 100644 shell/plugins/panels/displays/Chip.qml create mode 100644 shell/plugins/panels/displays/DisplayModel.js create mode 100644 shell/plugins/panels/displays/Displays.qml create mode 100644 shell/plugins/panels/displays/MonitorCanvas.qml create mode 100644 shell/plugins/panels/displays/MonitorCard.qml create mode 100644 shell/plugins/panels/displays/MonitorControls.qml create mode 100644 shell/plugins/panels/displays/WallpaperGrid.qml create mode 100644 shell/plugins/panels/displays/manifest.json diff --git a/bin/blob-bg-monitor b/bin/blob-bg-monitor new file mode 100755 index 0000000..b8eae2f --- /dev/null +++ b/bin/blob-bg-monitor @@ -0,0 +1,81 @@ +#!/bin/bash + +# blob:summary=Assign a wallpaper to one monitor, leaving the others alone +# blob:args=<--list|--clear-all| | --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 < assign a wallpaper to one monitor + blob-bg-monitor --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")" diff --git a/bin/blob-display-arrange b/bin/blob-display-arrange new file mode 100755 index 0000000..7a03ef5 --- /dev/null +++ b/bin/blob-display-arrange @@ -0,0 +1,68 @@ +#!/bin/bash + +# blob:summary=Apply a monitor layout at runtime without touching monitors.lua +# blob:args=...|reset> +# blob:examples=blob display arrange apply 'DP-1,1920x1080@60,0x0,1' + +set -e + +action="${1-}" +shift || true + +usage() { + cat < + + list print the current layout as monitor keywords + apply ... apply one or more monitor keywords now + reset reload the Hyprland config, restoring monitors.lua + +A keyword is Hyprland's own monitor form: + + ,,x,[,transform,][,mirror,] + ,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 diff --git a/default/blob/blob-menu.jsonc b/default/blob/blob-menu.jsonc index a09d5d6..563498b 100644 --- a/default/blob/blob-menu.jsonc +++ b/default/blob/blob-menu.jsonc @@ -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"}, diff --git a/docs/commands.md b/docs/commands.md index ba7fdb2..d081a37 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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\| \| --clear> | | `blob-bg-next` | Cycle to the next background for the current theme | - | | `blob-bg-set` | Set the current background 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 | ...\|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. diff --git a/docs/keybinds.md b/docs/keybinds.md index 8a19a38..343cdfd 100644 --- a/docs/keybinds.md +++ b/docs/keybinds.md @@ -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 diff --git a/docs/packages.md b/docs/packages.md index a6aeacc..cd6d341 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -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 ``` diff --git a/docs/widgets.md b/docs/widgets.md index a8827d2..e460075 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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/`. + +`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 ` | assign a wallpaper to one monitor | +| `blob bg monitor --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 ` | apply a keyword now | +| `blob display arrange reset` | reload, restoring monitors.lua | diff --git a/hypr/bindings.lua b/hypr/bindings.lua index 85dca35..714e6cb 100644 --- a/hypr/bindings.lua +++ b/hypr/bindings.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. diff --git a/install.sh b/install.sh index 424ee36..0f97a3d 100755 --- a/install.sh +++ b/install.sh @@ -11,15 +11,17 @@ font_dir="$HOME/.local/share/fonts" force=false check=false +skip_packages=false changes=0 usage() { cat < 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 diff --git a/packages/blob-aur.packages b/packages/blob-aur.packages new file mode 100644 index 0000000..8dff8e2 --- /dev/null +++ b/packages/blob-aur.packages @@ -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 diff --git a/packages/blob.packages b/packages/blob.packages new file mode 100644 index 0000000..aa8033e --- /dev/null +++ b/packages/blob.packages @@ -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 diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index a7dc8ab..226ce59 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -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 { diff --git a/shell/plugins/panels/displays/Chip.qml b/shell/plugins/panels/displays/Chip.qml new file mode 100644 index 0000000..924dc8d --- /dev/null +++ b/shell/plugins/panels/displays/Chip.qml @@ -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() + } +} diff --git a/shell/plugins/panels/displays/DisplayModel.js b/shell/plugins/panels/displays/DisplayModel.js new file mode 100644 index 0000000..bf0191f --- /dev/null +++ b/shell/plugins/panels/displays/DisplayModel.js @@ -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 +} diff --git a/shell/plugins/panels/displays/Displays.qml b/shell/plugins/panels/displays/Displays.qml new file mode 100644 index 0000000..0f537b7 --- /dev/null +++ b/shell/plugins/panels/displays/Displays.qml @@ -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() +} diff --git a/shell/plugins/panels/displays/MonitorCanvas.qml b/shell/plugins/panels/displays/MonitorCanvas.qml new file mode 100644 index 0000000..c552e2e --- /dev/null +++ b/shell/plugins/panels/displays/MonitorCanvas.qml @@ -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 + } +} diff --git a/shell/plugins/panels/displays/MonitorCard.qml b/shell/plugins/panels/displays/MonitorCard.qml new file mode 100644 index 0000000..8054425 --- /dev/null +++ b/shell/plugins/panels/displays/MonitorCard.qml @@ -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)) + } + } +} diff --git a/shell/plugins/panels/displays/MonitorControls.qml b/shell/plugins/panels/displays/MonitorControls.qml new file mode 100644 index 0000000..566e893 --- /dev/null +++ b/shell/plugins/panels/displays/MonitorControls.qml @@ -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 + } + } + } + } + } +} diff --git a/shell/plugins/panels/displays/WallpaperGrid.qml b/shell/plugins/panels/displays/WallpaperGrid.qml new file mode 100644 index 0000000..b78979e --- /dev/null +++ b/shell/plugins/panels/displays/WallpaperGrid.qml @@ -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 + } +} diff --git a/shell/plugins/panels/displays/manifest.json b/shell/plugins/panels/displays/manifest.json new file mode 100644 index 0000000..05548f1 --- /dev/null +++ b/shell/plugins/panels/displays/manifest.json @@ -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" + } +}