Add a displays widget for monitor layout and per-monitor wallpapers
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
signal activated()
|
||||
|
||||
implicitWidth: text.implicitWidth + Style.spaceReal(16)
|
||||
implicitHeight: Style.spaceReal(22)
|
||||
radius: Style.cardRadius
|
||||
opacity: enabled ? 1 : 0.4
|
||||
color: root.active
|
||||
? Color.accent
|
||||
: (hover.hovered ? Util.alpha(Color.blue, 0.25) : Util.alpha(Color.background, Style.cardFillAlpha))
|
||||
border.width: 1
|
||||
border.color: root.active ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
|
||||
|
||||
Text {
|
||||
id: text
|
||||
anchors.centerIn: parent
|
||||
text: root.label
|
||||
textFormat: Text.PlainText
|
||||
color: root.active ? Color.background : Color.foreground
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
.pragma library
|
||||
|
||||
// hyprctl reports logical size as the raw mode divided by scale, so a 1920x1200
|
||||
// panel at 1.5 occupies 1280x800 of layout space. Positions are in that same
|
||||
// logical space, which is what the canvas has to draw.
|
||||
function logicalSize(monitor) {
|
||||
var scale = Number(monitor.scale) || 1
|
||||
var width = Number(monitor.width) || 0
|
||||
var height = Number(monitor.height) || 0
|
||||
if (isRotated(monitor)) {
|
||||
var swap = width
|
||||
width = height
|
||||
height = swap
|
||||
}
|
||||
return {
|
||||
width: Math.round(width / scale),
|
||||
height: Math.round(height / scale)
|
||||
}
|
||||
}
|
||||
|
||||
function isRotated(monitor) {
|
||||
var transform = Number(monitor.transform) || 0
|
||||
return transform === 1 || transform === 3 || transform === 5 || transform === 7
|
||||
}
|
||||
|
||||
function parseMonitors(raw) {
|
||||
var parsed
|
||||
try {
|
||||
parsed = JSON.parse(String(raw || "[]"))
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(parsed)) return []
|
||||
|
||||
var monitors = []
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
var m = parsed[i] || {}
|
||||
var size = logicalSize(m)
|
||||
monitors.push({
|
||||
name: String(m.name || ""),
|
||||
description: String(m.description || ""),
|
||||
mode: modeStringOf(m),
|
||||
modes: Array.isArray(m.availableModes) ? m.availableModes : [],
|
||||
x: Number(m.x) || 0,
|
||||
y: Number(m.y) || 0,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
scale: Number(m.scale) || 1,
|
||||
transform: Number(m.transform) || 0,
|
||||
disabled: m.disabled === true,
|
||||
focused: m.focused === true,
|
||||
mirrorOf: String(m.mirrorOf || "none")
|
||||
})
|
||||
}
|
||||
monitors.sort(function(a, b) { return a.x - b.x })
|
||||
return monitors
|
||||
}
|
||||
|
||||
function modeStringOf(monitor) {
|
||||
var width = Number(monitor.width) || 0
|
||||
var height = Number(monitor.height) || 0
|
||||
var rate = Number(monitor.refreshRate) || 0
|
||||
if (!width || !height) return "preferred"
|
||||
return width + "x" + height + "@" + rate.toFixed(2)
|
||||
}
|
||||
|
||||
// The canvas is a scaled-down picture of the desktop. Monitors can sit at
|
||||
// negative coordinates, so the bounding box is translated to the origin before
|
||||
// a single scale factor is chosen for both axes.
|
||||
function layoutBounds(monitors) {
|
||||
if (!monitors.length) return { x: 0, y: 0, width: 1, height: 1 }
|
||||
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (var i = 0; i < monitors.length; i++) {
|
||||
var m = monitors[i]
|
||||
if (m.disabled) continue
|
||||
minX = Math.min(minX, m.x)
|
||||
minY = Math.min(minY, m.y)
|
||||
maxX = Math.max(maxX, m.x + m.width)
|
||||
maxY = Math.max(maxY, m.y + m.height)
|
||||
}
|
||||
if (minX === Infinity) return { x: 0, y: 0, width: 1, height: 1 }
|
||||
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
|
||||
}
|
||||
|
||||
function canvasScale(bounds, availableWidth, availableHeight) {
|
||||
return Math.min(availableWidth / bounds.width, availableHeight / bounds.height)
|
||||
}
|
||||
|
||||
// Snap a dragged edge to a neighbour's edge when it lands within the threshold,
|
||||
// so monitors end up touching exactly rather than a few pixels apart.
|
||||
function snapPosition(monitors, name, x, y, threshold) {
|
||||
var snappedX = x
|
||||
var snappedY = y
|
||||
var self = null
|
||||
for (var i = 0; i < monitors.length; i++) {
|
||||
if (monitors[i].name === name) { self = monitors[i]; break }
|
||||
}
|
||||
if (!self) return { x: Math.round(x), y: Math.round(y) }
|
||||
|
||||
for (var j = 0; j < monitors.length; j++) {
|
||||
var other = monitors[j]
|
||||
if (other.name === name || other.disabled) continue
|
||||
|
||||
var candidatesX = [
|
||||
other.x + other.width,
|
||||
other.x - self.width,
|
||||
other.x
|
||||
]
|
||||
for (var cx = 0; cx < candidatesX.length; cx++) {
|
||||
if (Math.abs(snappedX - candidatesX[cx]) <= threshold) {
|
||||
snappedX = candidatesX[cx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var candidatesY = [
|
||||
other.y + other.height,
|
||||
other.y - self.height,
|
||||
other.y
|
||||
]
|
||||
for (var cy = 0; cy < candidatesY.length; cy++) {
|
||||
if (Math.abs(snappedY - candidatesY[cy]) <= threshold) {
|
||||
snappedY = candidatesY[cy]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: Math.round(snappedX), y: Math.round(snappedY) }
|
||||
}
|
||||
|
||||
// Hyprland's keyword form: monitor = name,mode,position,scale[,transform,N]
|
||||
function monitorKeyword(monitor) {
|
||||
if (monitor.disabled) return monitor.name + ",disable"
|
||||
var parts = [
|
||||
monitor.name,
|
||||
monitor.mode,
|
||||
monitor.x + "x" + monitor.y,
|
||||
String(monitor.scale)
|
||||
]
|
||||
var keyword = parts.join(",")
|
||||
if (monitor.transform && monitor.transform !== 0)
|
||||
keyword += ",transform," + monitor.transform
|
||||
if (monitor.mirrorOf && monitor.mirrorOf !== "none")
|
||||
keyword += ",mirror," + monitor.mirrorOf
|
||||
return keyword
|
||||
}
|
||||
|
||||
function keywordsFor(monitors) {
|
||||
var out = []
|
||||
for (var i = 0; i < monitors.length; i++) out.push(monitorKeyword(monitors[i]))
|
||||
return out
|
||||
}
|
||||
|
||||
// Hyprland rejects a fractional scale that does not land on a whole number of
|
||||
// physical pixels. It steps in 1/120, so a scale is valid when both axes come
|
||||
// out integral at that granularity.
|
||||
function scaleIsValid(monitor, scale) {
|
||||
if (!scale || scale <= 0) return false
|
||||
var stepped = Math.round(scale * 120) / 120
|
||||
var width = Number(monitor.width) * Number(monitor.scale)
|
||||
var height = Number(monitor.height) * Number(monitor.scale)
|
||||
var logicalWidth = width / stepped
|
||||
var logicalHeight = height / stepped
|
||||
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.001
|
||||
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.001
|
||||
}
|
||||
|
||||
function nearbyScales(monitor) {
|
||||
var options = []
|
||||
for (var step = 60; step <= 300; step += 6) {
|
||||
var candidate = step / 120
|
||||
if (scaleIsValid(monitor, candidate)) options.push(candidate)
|
||||
}
|
||||
if (!options.length) options.push(1)
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "DisplayModel.js" as DisplayModel
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var shell: null
|
||||
property var manifest: null
|
||||
property bool opened: false
|
||||
|
||||
readonly property string pluginId: (manifest && manifest.id) || "blob.displays"
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
|
||||
property var monitors: []
|
||||
property string selectedName: ""
|
||||
property string activeTab: "monitors"
|
||||
property var assignments: ({})
|
||||
property bool layoutDirty: false
|
||||
|
||||
readonly property var selectedMonitor: {
|
||||
for (var i = 0; i < root.monitors.length; i++)
|
||||
if (root.monitors[i].name === root.selectedName) return root.monitors[i]
|
||||
return null
|
||||
}
|
||||
|
||||
function open(payloadJson) {
|
||||
root.opened = true
|
||||
root.refresh()
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.opened = false
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
root.opened = false
|
||||
if (root.shell && typeof root.shell.hide === "function")
|
||||
root.shell.hide(root.pluginId)
|
||||
}
|
||||
|
||||
function run(command) {
|
||||
Quickshell.execDetached(["bash", "-c", command])
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!monitorProc.running) monitorProc.running = true
|
||||
if (!assignmentProc.running) assignmentProc.running = true
|
||||
}
|
||||
|
||||
function loadMonitors(raw) {
|
||||
var parsed = DisplayModel.parseMonitors(raw)
|
||||
root.monitors = parsed
|
||||
root.layoutDirty = false
|
||||
if (root.selectedName.length > 0) {
|
||||
for (var i = 0; i < parsed.length; i++)
|
||||
if (parsed[i].name === root.selectedName) return
|
||||
}
|
||||
root.selectedName = parsed.length > 0 ? parsed[0].name : ""
|
||||
}
|
||||
|
||||
function loadAssignments(raw) {
|
||||
var next = ({})
|
||||
var lines = String(raw || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim()
|
||||
if (!line) continue
|
||||
var split = line.indexOf("\t")
|
||||
if (split <= 0) continue
|
||||
next[line.substring(0, split)] = line.substring(split + 1)
|
||||
}
|
||||
root.assignments = next
|
||||
}
|
||||
|
||||
// Mutating the array in place would not retrigger the bindings the canvas
|
||||
// reads, so every edit rebuilds it.
|
||||
function replaceMonitor(name, changes) {
|
||||
var next = []
|
||||
for (var i = 0; i < root.monitors.length; i++) {
|
||||
var monitor = root.monitors[i]
|
||||
if (monitor.name !== name) {
|
||||
next.push(monitor)
|
||||
continue
|
||||
}
|
||||
var copy = ({})
|
||||
for (var key in monitor) copy[key] = monitor[key]
|
||||
for (var change in changes) copy[change] = changes[change]
|
||||
next.push(copy)
|
||||
}
|
||||
root.monitors = next
|
||||
root.layoutDirty = true
|
||||
}
|
||||
|
||||
function moveMonitor(name, x, y) {
|
||||
var snapped = DisplayModel.snapPosition(root.monitors, name, x, y, 60)
|
||||
root.replaceMonitor(name, { x: snapped.x, y: snapped.y })
|
||||
}
|
||||
|
||||
function applyMonitor(name) {
|
||||
for (var i = 0; i < root.monitors.length; i++) {
|
||||
if (root.monitors[i].name !== name) continue
|
||||
root.run("blob-display-arrange apply " + Util.shellQuote(DisplayModel.monitorKeyword(root.monitors[i])))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayout() {
|
||||
var keywords = DisplayModel.keywordsFor(root.monitors)
|
||||
var quoted = []
|
||||
for (var i = 0; i < keywords.length; i++) quoted.push(Util.shellQuote(keywords[i]))
|
||||
root.run("blob-display-arrange apply " + quoted.join(" "))
|
||||
root.layoutDirty = false
|
||||
reloadTimer.restart()
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
root.run("blob-display-arrange reset")
|
||||
reloadTimer.restart()
|
||||
}
|
||||
|
||||
function assignWallpaper(path) {
|
||||
if (root.selectedName.length === 0) return
|
||||
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName) + " " + Util.shellQuote(path))
|
||||
var next = ({})
|
||||
for (var key in root.assignments) next[key] = root.assignments[key]
|
||||
next[root.selectedName] = path
|
||||
root.assignments = next
|
||||
}
|
||||
|
||||
function clearAssignment() {
|
||||
if (root.selectedName.length === 0) return
|
||||
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName) + " --clear")
|
||||
var next = ({})
|
||||
for (var key in root.assignments)
|
||||
if (key !== root.selectedName) next[key] = root.assignments[key]
|
||||
root.assignments = next
|
||||
}
|
||||
|
||||
function setGlobalWallpaper(path) {
|
||||
root.run("blob-bg-set " + Util.shellQuote(path))
|
||||
}
|
||||
|
||||
Process {
|
||||
id: monitorProc
|
||||
command: ["hyprctl", "monitors", "all", "-j"]
|
||||
stdout: StdioCollector { onStreamFinished: root.loadMonitors(text) }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: assignmentProc
|
||||
command: ["bash", "-c",
|
||||
'dir="$HOME/.local/state/blob/backgrounds"; [[ -d $dir ]] || exit 0; ' +
|
||||
'for link in "$dir"/*; do [[ -e $link ]] || continue; ' +
|
||||
'printf "%s\\t%s\\n" "${link##*/}" "$(readlink -f "$link")"; done']
|
||||
stdout: StdioCollector { onStreamFinished: root.loadAssignments(text) }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: reloadTimer
|
||||
interval: 400
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 4000
|
||||
running: root.opened && !root.layoutDirty
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
visible: root.opened
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "blob-displays"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.dismiss()
|
||||
}
|
||||
|
||||
Card {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: Style.spaceReal(760)
|
||||
implicitHeight: Style.spaceReal(560)
|
||||
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
|
||||
spacing: Style.spaceReal(10)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(title.implicitHeight, tabs.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: title
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Displays"
|
||||
textFormat: Text.PlainText
|
||||
color: Color.foreground
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Row {
|
||||
id: tabs
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.spaceReal(6)
|
||||
|
||||
Chip {
|
||||
label: "Monitors"
|
||||
active: root.activeTab === "monitors"
|
||||
onActivated: root.activeTab = "monitors"
|
||||
}
|
||||
|
||||
Chip {
|
||||
label: "Wallpapers"
|
||||
active: root.activeTab === "wallpapers"
|
||||
onActivated: root.activeTab = "wallpapers"
|
||||
}
|
||||
|
||||
CardIconButton {
|
||||
icon: ""
|
||||
onActivated: root.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MonitorCanvas {
|
||||
width: parent.width
|
||||
height: Style.spaceReal(200)
|
||||
monitors: root.monitors
|
||||
selectedName: root.selectedName
|
||||
onSelected: function(name) { root.selectedName = name }
|
||||
onMonitorMoved: function(name, x, y) { root.moveMonitor(name, x, y) }
|
||||
onLayoutSettled: root.applyMonitor(root.selectedName)
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Style.spaceReal(250)
|
||||
|
||||
MonitorControls {
|
||||
anchors.fill: parent
|
||||
visible: root.activeTab === "monitors"
|
||||
monitor: root.selectedMonitor
|
||||
monitors: root.monitors
|
||||
onScaleRequested: function(scale) {
|
||||
root.replaceMonitor(root.selectedName, { scale: scale })
|
||||
root.applyMonitor(root.selectedName)
|
||||
}
|
||||
onModeRequested: function(mode) {
|
||||
root.replaceMonitor(root.selectedName, { mode: mode })
|
||||
root.applyMonitor(root.selectedName)
|
||||
}
|
||||
onEnabledRequested: function(enabled) {
|
||||
root.replaceMonitor(root.selectedName, { disabled: !enabled })
|
||||
root.applyMonitor(root.selectedName)
|
||||
}
|
||||
onMirrorRequested: function(target) {
|
||||
root.replaceMonitor(root.selectedName, { mirrorOf: target })
|
||||
root.applyMonitor(root.selectedName)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
visible: root.activeTab === "wallpapers"
|
||||
spacing: Style.spaceReal(8)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: assignedLabel.implicitHeight
|
||||
|
||||
Text {
|
||||
id: assignedLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.selectedName.length > 0
|
||||
? (root.assignments[root.selectedName]
|
||||
? root.selectedName + ": " + String(root.assignments[root.selectedName]).replace(/^.*\//, "")
|
||||
: root.selectedName + ": following the global wallpaper")
|
||||
: "Select a monitor above"
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.8)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Chip {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: "Clear"
|
||||
enabled: root.selectedName.length > 0 && !!root.assignments[root.selectedName]
|
||||
onActivated: root.clearAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
WallpaperGrid {
|
||||
width: parent.width
|
||||
height: parent.height - assignedLabel.implicitHeight - Style.spaceReal(8)
|
||||
polling: root.opened && root.activeTab === "wallpapers"
|
||||
assignedPath: root.selectedName.length > 0
|
||||
? String(root.assignments[root.selectedName] || "") : ""
|
||||
onChosen: function(path) { root.assignWallpaper(path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.spaceReal(6)
|
||||
|
||||
Chip {
|
||||
label: root.layoutDirty ? "Apply all" : "Re-apply all"
|
||||
active: root.layoutDirty
|
||||
onActivated: root.applyLayout()
|
||||
}
|
||||
|
||||
Chip {
|
||||
label: "Reset to monitors.lua"
|
||||
onActivated: root.resetLayout()
|
||||
}
|
||||
|
||||
Chip {
|
||||
label: "Set globally"
|
||||
enabled: root.activeTab === "wallpapers" && root.selectedName.length > 0
|
||||
&& !!root.assignments[root.selectedName]
|
||||
onActivated: root.setGlobalWallpaper(String(root.assignments[root.selectedName]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpenedChanged: if (root.opened) root.refresh()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "DisplayModel.js" as DisplayModel
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var monitors: []
|
||||
property string selectedName: ""
|
||||
readonly property int snapThreshold: 60
|
||||
|
||||
signal selected(string name)
|
||||
signal monitorMoved(string name, int x, int y)
|
||||
signal layoutSettled()
|
||||
|
||||
readonly property var bounds: DisplayModel.layoutBounds(root.monitors)
|
||||
readonly property real fitScale: DisplayModel.canvasScale(
|
||||
bounds,
|
||||
Math.max(1, width - Style.spaceReal(24)),
|
||||
Math.max(1, height - Style.spaceReal(24)))
|
||||
|
||||
color: Util.alpha(Color.background, 0.35)
|
||||
radius: Style.cardRadius
|
||||
border.width: 1
|
||||
border.color: Util.alpha(Color.foreground, 0.15)
|
||||
clip: true
|
||||
|
||||
Item {
|
||||
id: stage
|
||||
width: root.bounds.width * root.fitScale
|
||||
height: root.bounds.height * root.fitScale
|
||||
anchors.centerIn: parent
|
||||
|
||||
Repeater {
|
||||
model: root.monitors
|
||||
|
||||
MonitorCard {
|
||||
required property var modelData
|
||||
monitor: modelData
|
||||
canvasScale: root.fitScale
|
||||
originX: root.bounds.x
|
||||
originY: root.bounds.y
|
||||
selected: modelData.name === root.selectedName
|
||||
onPicked: root.selected(modelData.name)
|
||||
onMoved: function(x, y) { root.monitorMoved(modelData.name, x, y) }
|
||||
onDropped: root.layoutSettled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.monitors.length === 0
|
||||
text: "No monitors reported"
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.6)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var monitor: null
|
||||
property bool selected: false
|
||||
property real canvasScale: 1
|
||||
property real originX: 0
|
||||
property real originY: 0
|
||||
|
||||
signal picked()
|
||||
signal moved(int x, int y)
|
||||
signal dropped()
|
||||
|
||||
readonly property string name: monitor ? monitor.name : ""
|
||||
readonly property bool disabled: monitor ? monitor.disabled === true : false
|
||||
|
||||
visible: !!monitor && !disabled
|
||||
width: monitor ? Math.max(Style.spaceReal(24), monitor.width * canvasScale) : 0
|
||||
height: monitor ? Math.max(Style.spaceReal(18), monitor.height * canvasScale) : 0
|
||||
x: monitor ? (monitor.x - originX) * canvasScale : 0
|
||||
y: monitor ? (monitor.y - originY) * canvasScale : 0
|
||||
|
||||
radius: Style.cardRadius
|
||||
color: root.selected
|
||||
? Util.alpha(Color.accent, 0.35)
|
||||
: Util.alpha(Color.background, Style.cardFillAlpha)
|
||||
border.width: Style.cardBorderWidth
|
||||
border.color: root.selected ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.spaceReal(2)
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.name
|
||||
textFormat: Text.PlainText
|
||||
color: Color.foreground
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
visible: root.height > Style.spaceReal(44)
|
||||
text: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.7)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
visible: root.monitor && root.monitor.focused && root.height > Style.spaceReal(58)
|
||||
text: "focused"
|
||||
textFormat: Text.PlainText
|
||||
color: Color.blue
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.picked()
|
||||
}
|
||||
|
||||
property int dragOriginX: 0
|
||||
property int dragOriginY: 0
|
||||
|
||||
DragHandler {
|
||||
id: drag
|
||||
target: null
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
root.picked()
|
||||
root.dragOriginX = root.monitor ? root.monitor.x : 0
|
||||
root.dragOriginY = root.monitor ? root.monitor.y : 0
|
||||
return
|
||||
}
|
||||
root.dropped()
|
||||
}
|
||||
onTranslationChanged: {
|
||||
if (!active || !root.monitor) return
|
||||
root.moved(
|
||||
Math.round(root.dragOriginX + translation.x / root.canvasScale),
|
||||
Math.round(root.dragOriginY + translation.y / root.canvasScale))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "DisplayModel.js" as DisplayModel
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property var monitor: null
|
||||
property var monitors: []
|
||||
|
||||
signal scaleRequested(real scale)
|
||||
signal modeRequested(string mode)
|
||||
signal enabledRequested(bool enabled)
|
||||
signal mirrorRequested(string target)
|
||||
|
||||
readonly property var scaleOptions: monitor ? DisplayModel.nearbyScales(monitor) : []
|
||||
|
||||
spacing: Style.spaceReal(8)
|
||||
visible: !!monitor
|
||||
|
||||
Text {
|
||||
text: root.monitor ? root.monitor.name : ""
|
||||
textFormat: Text.PlainText
|
||||
color: Color.foreground
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.monitor ? root.monitor.description : ""
|
||||
textFormat: Text.PlainText
|
||||
elide: Text.ElideRight
|
||||
color: Util.alpha(Color.foreground, 0.65)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
StatRow {
|
||||
width: parent.width
|
||||
icon: ""
|
||||
label: "Position"
|
||||
value: root.monitor ? root.monitor.x + ", " + root.monitor.y : ""
|
||||
}
|
||||
|
||||
StatRow {
|
||||
width: parent.width
|
||||
icon: ""
|
||||
label: "Logical size"
|
||||
value: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Mode"
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.75)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.spaceReal(4)
|
||||
|
||||
Repeater {
|
||||
model: root.monitor ? root.monitor.modes : []
|
||||
|
||||
Chip {
|
||||
required property var modelData
|
||||
label: String(modelData).replace("Hz", "")
|
||||
active: root.monitor && String(modelData).indexOf(root.monitor.mode) === 0
|
||||
onActivated: root.modeRequested(String(modelData).replace("Hz", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Scale"
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.75)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Style.spaceReal(4)
|
||||
|
||||
Repeater {
|
||||
model: root.scaleOptions
|
||||
|
||||
Chip {
|
||||
required property var modelData
|
||||
label: String(modelData)
|
||||
active: root.monitor && Math.abs(root.monitor.scale - modelData) < 0.001
|
||||
onActivated: root.scaleRequested(Number(modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Only scales that land on whole pixels are offered. Hyprland steps in 1/120 and rejects the rest."
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.55)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.spaceReal(6)
|
||||
|
||||
Chip {
|
||||
label: root.monitor && root.monitor.disabled ? "Enable" : "Disable"
|
||||
onActivated: root.enabledRequested(root.monitor ? root.monitor.disabled === true : true)
|
||||
}
|
||||
|
||||
Chip {
|
||||
label: root.monitor && root.monitor.mirrorOf !== "none" ? "Unmirror" : "Mirror"
|
||||
active: root.monitor && root.monitor.mirrorOf !== "none"
|
||||
enabled: root.monitors.length > 1
|
||||
onActivated: {
|
||||
if (!root.monitor) return
|
||||
if (root.monitor.mirrorOf !== "none") {
|
||||
root.mirrorRequested("none")
|
||||
return
|
||||
}
|
||||
for (var i = 0; i < root.monitors.length; i++) {
|
||||
if (root.monitors[i].name !== root.monitor.name) {
|
||||
root.mirrorRequested(root.monitors[i].name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string directory: Quickshell.env("HOME") + "/wallpapers"
|
||||
property string assignedPath: ""
|
||||
property bool polling: false
|
||||
property var wallpapers: []
|
||||
|
||||
signal chosen(string path)
|
||||
|
||||
function refresh() {
|
||||
if (!listProc.running) listProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listProc
|
||||
command: ["bash", "-c",
|
||||
'find "$1" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" ' +
|
||||
'-o -iname "*.png" -o -iname "*.webp" -o -iname "*.gif" \\) | sort',
|
||||
"--", root.directory]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var out = []
|
||||
var lines = String(text).split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim()
|
||||
if (line.length > 0) out.push(line)
|
||||
}
|
||||
root.wallpapers = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPollingChanged: if (root.polling && root.wallpapers.length === 0) root.refresh()
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
GridView {
|
||||
id: grid
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
cellWidth: Style.spaceReal(120)
|
||||
cellHeight: Style.spaceReal(80)
|
||||
model: root.wallpapers
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
width: grid.cellWidth
|
||||
height: grid.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.spaceReal(3)
|
||||
radius: Style.cardRadius
|
||||
color: Util.alpha(Color.background, Style.cardFillAlpha)
|
||||
border.width: Style.cardBorderWidth
|
||||
border.color: modelData === root.assignedPath
|
||||
? Color.accent
|
||||
: (hover.hovered ? Color.blue : Util.alpha(Color.blue, Style.cardBorderAlpha))
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.cardBorderWidth
|
||||
source: Util.fileUrl(modelData)
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
sourceSize.width: Math.round(Style.spaceReal(120) * 1.5)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Style.cardBorderWidth
|
||||
height: caption.implicitHeight + Style.spaceReal(4)
|
||||
visible: hover.hovered || modelData === root.assignedPath
|
||||
color: Util.alpha(Color.background, 0.8)
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Style.spaceReal(6)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideMiddle
|
||||
text: String(modelData).replace(/^.*\//, "")
|
||||
textFormat: Text.PlainText
|
||||
color: Color.foreground
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.chosen(String(modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.wallpapers.length === 0
|
||||
text: "No images in " + root.directory
|
||||
textFormat: Text.PlainText
|
||||
color: Util.alpha(Color.foreground, 0.6)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"name": "Displays",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Arrange monitors and assign wallpapers per monitor",
|
||||
"id": "blob.displays",
|
||||
"kinds": [
|
||||
"overlay"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"overlay": "Displays.qml"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user