Files
quick-visor/qml/VisorService.qml
T
2026-06-09 12:27:52 +02:00

345 lines
11 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
property bool visible: false
property int selectedIndex: 0
property var monitors: []
property var arrangedPositions: ({})
property var monitorSettings: ({})
readonly property var selected: monitors.length > 0 ? monitors[selectedIndex] : null
property string status: "Loading displays..."
property bool refreshing: false
property bool dirty: false
function open() {
visible = true;
refresh();
}
function close() {
visible = false;
}
function toggle() {
visible ? close() : open();
}
function refresh() {
if (refreshing) return;
refreshing = true;
status = "Refreshing displays...";
monitorProc.running = true;
}
function select(delta) {
if (monitors.length === 0) return;
selectedIndex = Math.max(0, Math.min(monitors.length - 1, selectedIndex + delta));
}
function selectedMonitor() {
return selected;
}
function monitorX(m) {
const pos = arrangedPositions[m.name];
return pos ? pos.x : m.x;
}
function monitorY(m) {
const pos = arrangedPositions[m.name];
return pos ? pos.y : m.y;
}
function monitorMode(m) {
const settings = monitorSettings[m.name];
return settings && settings.mode ? settings.mode : currentModeString(m);
}
function monitorScale(m) {
const settings = monitorSettings[m.name];
return settings && typeof settings.scale === "number" ? settings.scale : m.scale;
}
function parsedMode(mode) {
const match = String(mode || "").match(/^(\d+)x(\d+)(?:@([\d.]+)(?:Hz)?)?$/);
if (!match) return null;
return {
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
refresh: match[3] ? parseFloat(match[3]) : null
};
}
function monitorWidth(m) {
const parsed = parsedMode(monitorMode(m));
return parsed ? parsed.width : m.width;
}
function monitorHeight(m) {
const parsed = parsedMode(monitorMode(m));
return parsed ? parsed.height : m.height;
}
function currentModeString(m) {
const modes = Array.isArray(m.availableModes) ? m.availableModes : [];
let best = "";
let bestDelta = Number.POSITIVE_INFINITY;
for (let i = 0; i < modes.length; i++) {
const parsed = parsedMode(modes[i]);
if (!parsed || parsed.width !== m.width || parsed.height !== m.height || parsed.refresh === null) continue;
const delta = Math.abs(parsed.refresh - m.refreshRate);
if (delta < bestDelta) {
bestDelta = delta;
best = modes[i];
}
}
if (best.length > 0) return best;
const refresh = typeof m.refreshRate === "number"
? "@" + (Math.round(m.refreshRate * 1000) / 1000) : "";
return m.width + "x" + m.height + refresh;
}
function modeForHyprland(mode) {
return String(mode || "").replace(/Hz$/, "");
}
function setMonitorMode(name, mode) {
if (!mode || mode.length === 0) return;
const next = Object.assign({}, monitorSettings);
const current = Object.assign({}, next[name] || {});
current.mode = mode;
next[name] = current;
monitorSettings = next;
dirty = true;
status = "Display mode changed - apply to update Hyprland";
}
function setMonitorScale(name, scale) {
const clamped = Math.max(0.5, Math.min(4.0, Math.round(scale * 100) / 100));
const next = Object.assign({}, monitorSettings);
const current = Object.assign({}, next[name] || {});
current.scale = clamped;
next[name] = current;
monitorSettings = next;
dirty = true;
status = "Display scale changed - apply to update Hyprland";
}
function adjustMonitorScale(name, delta) {
for (let i = 0; i < monitors.length; i++) {
const m = monitors[i];
if (m.name === name) {
setMonitorScale(name, monitorScale(m) + delta);
return;
}
}
}
function selectedModeIndex(m) {
if (!m || !Array.isArray(m.availableModes)) return -1;
const mode = monitorMode(m);
for (let i = 0; i < m.availableModes.length; i++) {
if (m.availableModes[i] === mode) return i;
}
return -1;
}
function setMonitorPosition(name, x, y) {
const next = Object.assign({}, arrangedPositions);
next[name] = { x: Math.round(x), y: Math.round(y) };
arrangedPositions = next;
dirty = true;
status = "Layout changed - apply to update Hyprland";
}
function snappedPosition(name, x, y) {
let monitor = null;
for (let i = 0; i < monitors.length; i++) {
if (monitors[i].name === name) {
monitor = monitors[i];
break;
}
}
if (!monitor) return { x, y };
const snapDistance = 48;
let bestX = x;
let bestY = y;
let bestDx = snapDistance + 1;
let bestDy = snapDistance + 1;
const left = x;
const width = monitorWidth(monitor);
const height = monitorHeight(monitor);
const right = x + width;
const top = y;
const bottom = y + height;
for (let i = 0; i < monitors.length; i++) {
const other = monitors[i];
if (other.name === name) continue;
const otherLeft = monitorX(other);
const otherRight = otherLeft + monitorWidth(other);
const otherTop = monitorY(other);
const otherBottom = otherTop + monitorHeight(other);
const xCandidates = [
{ value: otherLeft, delta: Math.abs(left - otherLeft) },
{ value: otherRight, delta: Math.abs(left - otherRight) },
{ value: otherLeft - width, delta: Math.abs(right - otherLeft) },
{ value: otherRight - width, delta: Math.abs(right - otherRight) }
];
for (let xi = 0; xi < xCandidates.length; xi++) {
const candidate = xCandidates[xi];
if (candidate.delta < bestDx && candidate.delta <= snapDistance) {
bestDx = candidate.delta;
bestX = candidate.value;
}
}
const yCandidates = [
{ value: otherTop, delta: Math.abs(top - otherTop) },
{ value: otherBottom, delta: Math.abs(top - otherBottom) },
{ value: otherTop - height, delta: Math.abs(bottom - otherTop) },
{ value: otherBottom - height, delta: Math.abs(bottom - otherBottom) }
];
for (let yi = 0; yi < yCandidates.length; yi++) {
const candidate = yCandidates[yi];
if (candidate.delta < bestDy && candidate.delta <= snapDistance) {
bestDy = candidate.delta;
bestY = candidate.value;
}
}
}
return { x: bestX, y: bestY };
}
function resetLayout() {
const nextPositions = {};
const nextSettings = {};
for (let i = 0; i < monitors.length; i++) {
const m = monitors[i];
nextPositions[m.name] = { x: m.x, y: m.y };
nextSettings[m.name] = { mode: currentModeString(m), scale: m.scale };
}
arrangedPositions = nextPositions;
monitorSettings = nextSettings;
dirty = false;
status = monitors.length + " display" + (monitors.length === 1 ? "" : "s") + " connected";
}
function layoutBounds() {
if (monitors.length === 0) return { minX: 0, minY: 0, maxX: 1, maxY: 1, width: 1, height: 1 };
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (let i = 0; i < monitors.length; i++) {
const m = monitors[i];
const x = monitorX(m);
const y = monitorY(m);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + monitorWidth(m));
maxY = Math.max(maxY, y + monitorHeight(m));
}
return {
minX,
minY,
maxX,
maxY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY)
};
}
function shQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}
function applyLayout() {
if (!dirty || monitors.length === 0) return;
const commands = [];
for (let i = 0; i < monitors.length; i++) {
const m = monitors[i];
if (m.disabled) continue;
const spec = m.name + "," + modeForHyprland(monitorMode(m)) + ","
+ monitorX(m) + "x" + monitorY(m) + "," + monitorScale(m);
commands.push("hyprctl keyword monitor " + shQuote(spec));
}
if (commands.length === 0) return;
status = "Applying layout...";
applyProc.command = ["sh", "-c", commands.join(" && ")];
applyProc.running = true;
}
function formatGeometry(m) {
if (!m) return "";
const scale = typeof monitorScale(m) === "number" ? " @ " + monitorScale(m) + "x" : "";
return monitorWidth(m) + "x" + monitorHeight(m) + "+"
+ monitorX(m) + "+" + monitorY(m) + scale;
}
function formatMode(m) {
if (!m) return "";
return monitorMode(m);
}
property Process monitorProc: Process {
command: ["sh", "-c", "hyprctl monitors -j 2>/dev/null || printf '[]'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root.refreshing = false;
try {
const parsed = JSON.parse(this.text || "[]");
root.monitors = Array.isArray(parsed) ? parsed : [];
if (root.selectedIndex >= root.monitors.length) {
root.selectedIndex = Math.max(0, root.monitors.length - 1);
}
root.resetLayout();
if (root.monitors.length === 0) {
root.status = "No Hyprland displays reported";
}
} catch (e) {
root.monitors = [];
root.arrangedPositions = {};
root.monitorSettings = {};
root.dirty = false;
root.status = "Could not read Hyprland displays";
console.log("quick-visor: hyprctl monitors parse error:", e);
}
}
}
}
property Process applyProc: Process {
command: []
running: false
stdout: StdioCollector {
onStreamFinished: {
root.dirty = false;
root.status = "Layout applied";
root.refresh();
}
}
}
Component.onCompleted: refresh()
}