Split display layout into its own widget and fix installer early exit

This commit is contained in:
2026-09-20 04:12:56 -04:00
parent a32254c398
commit 9e34929e48
15 changed files with 378 additions and 242 deletions
@@ -0,0 +1,237 @@
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.display-layout"
readonly property int snapThreshold: 60
property var monitors: []
property string selectedName: ""
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
}
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 : ""
}
// 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, root.snapThreshold)
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])))
reloadTimer.restart()
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()
}
Process {
id: monitorProc
command: ["hyprctl", "monitors", "all", "-j"]
stdout: StdioCollector { onStreamFinished: root.loadMonitors(text) }
}
Timer {
id: reloadTimer
interval: 400
onTriggered: root.refresh()
}
Timer {
interval: 4000
running: root.opened && !root.layoutDirty
repeat: true
onTriggered: root.refresh()
}
onOpenedChanged: if (root.opened) root.refresh()
PanelWindow {
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-display-layout"
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(720)
implicitHeight: Style.spaceReal(540)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(10)
Item {
width: parent.width
implicitHeight: Math.max(title.implicitHeight, closeButton.implicitHeight)
Text {
id: title
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Display layout"
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
CardIconButton {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
icon: ""
onActivated: root.dismiss()
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: "Drag a monitor to move it. Edges snap to a neighbour. Changes apply as you drop and last until the session ends."
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.6)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
MonitorCanvas {
width: parent.width
height: Style.spaceReal(220)
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)
}
MonitorControls {
width: parent.width
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)
}
}
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()
}
}
}
}
}
@@ -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,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,15 @@
{
"schemaVersion": 1,
"name": "Display layout",
"version": "1.0.0",
"author": "Blob",
"description": "Arrange monitors by dragging, and set their mode, scale, and mirroring",
"id": "blob.display-layout",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "DisplayLayout.qml"
}
}