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
+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"
}
}