Initial commit

This commit is contained in:
2026-06-09 12:27:52 +02:00
commit b057986bee
12 changed files with 1211 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alexandru-Victor Macocian
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# Maintainer: Alex Macocian <amacocian@yahoo.com>
pkgname=quick-visor
pkgver=0.0.1
pkgrel=1
pkgdesc="Quickshell-based display manager overlay for Hyprland"
arch=('any')
url="https://git.estatecloud.org/radumaco/quick-visor"
license=('MIT')
depends=('quickshell' 'qt6-declarative' 'hyprland')
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('SKIP')
package() {
cd "$srcdir/$pkgname"
install -dm755 "$pkgdir/usr/share/$pkgname"
install -m644 qml/* "$pkgdir/usr/share/$pkgname/"
install -Dm755 bin/quick-visor "$pkgdir/usr/bin/quick-visor"
install -Dm755 bin/quick-visor-toggle "$pkgdir/usr/bin/quick-visor-toggle"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}
+59
View File
@@ -0,0 +1,59 @@
# quick-visor
A Quickshell-based display manager overlay for Hyprland.
## Usage
Start the resident Quickshell instance once per session:
```conf
exec-once = quick-visor
```
Bind a key to toggle the overlay:
```conf
bind = SUPER, M, exec, quick-visor-toggle
```
For local development, run directly from the clone:
```sh
quickshell -n -p qml/shell.qml
quickshell ipc -p qml/shell.qml call quick-visor toggle
```
## Configuration
`~/.config/quick-visor/config.json` controls panel dimensions. Missing keys fall
back to built-in defaults, so the file can be omitted.
```json
{
"panelWidth": 760,
"panelHeight": 520
}
```
## Theming
`~/.config/quick-visor/theme.jsonc` controls fonts and colors. It supports JSONC
comments and reloads while quick-visor is running.
```jsonc
{
"fontFamily": "JetBrainsMono Nerd Font",
"fontSize": 14,
"background": "#101010",
"foreground": "#e6e6e6",
"idle": "#9a9a9a",
"accent": "#7aa2f7",
"warning": "#f7768e",
"overlayStrong": "#26344d",
"overlayWeak": "#202020",
"border": "#3a3a3a",
"padding": 8,
"spacing": 8,
"radius": 12
}
```
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# Launches the quick-visor Quickshell instance. Run once at session start.
exec quickshell -n -p /usr/share/quick-visor/shell.qml "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# Toggle the quick-visor overlay from a compositor keybinding.
exec quickshell ipc -p /usr/share/quick-visor/shell.qml call quick-visor toggle
+39
View File
@@ -0,0 +1,39 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
property string fontFamily: "JetBrainsMono Nerd Font"
property int fontSize: 14
property int panelWidth: 760
property int panelHeight: 520
property bool loaded: false
function _apply(obj) {
if (typeof obj.panelWidth === "number" && obj.panelWidth > 0) panelWidth = obj.panelWidth;
if (typeof obj.panelHeight === "number" && obj.panelHeight > 0) panelHeight = obj.panelHeight;
}
property Process _loader: Process {
running: true
command: ["sh", "-c", "cat \"${XDG_CONFIG_HOME:-$HOME/.config}/quick-visor/config.json\" 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
const text = (this.text || "").trim();
if (text.length > 0) {
try {
root._apply(JSON.parse(text));
} catch (e) {
console.log("quick-visor: config.json parse error:", e);
}
}
root.loaded = true;
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
PanelWindow {
id: root
property var modelData
screen: root.modelData
visible: VisorService.visible && root.modelData !== undefined
color: "transparent"
exclusiveZone: 0
implicitWidth: badge.implicitWidth
implicitHeight: badge.implicitHeight
anchors {
left: true
bottom: true
}
margins {
left: 24
bottom: 24
}
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Rectangle {
id: badge
implicitWidth: content.implicitWidth + Theme.padding * 3
implicitHeight: content.implicitHeight + Theme.padding * 2
radius: Theme.radius
color: Theme.background
border.color: Theme.accent
border.width: 2
RowLayout {
id: content
anchors.centerIn: parent
spacing: Theme.spacing
Text {
text: root.modelData ? root.modelData.name : ""
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 1.2
font.bold: true
}
Text {
visible: root.modelData && root.modelData.model.length > 0
text: root.modelData ? root.modelData.model : ""
color: Theme.foreground
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
readonly property string _configDir: (Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/quick-visor"
readonly property string _themePath: _configDir + "/theme.jsonc"
property color background: "#101010"
property color foreground: "#e6e6e6"
property color idle: "#9a9a9a"
property color accent: "#7aa2f7"
property color warning: "#f7768e"
property color overlayStrong: "#26344d"
property color overlayWeak: "#202020"
property color border: "#3a3a3a"
property string fontFamily: Config.fontFamily
property int fontSize: Config.fontSize
property int padding: 8
property int spacing: 8
property int radius: 12
readonly property var _defaults: ({
background: "#101010",
foreground: "#e6e6e6",
idle: "#9a9a9a",
accent: "#7aa2f7",
warning: "#f7768e",
overlayStrong: "#26344d",
overlayWeak: "#202020",
border: "#3a3a3a",
fontFamily: "JetBrainsMono Nerd Font",
fontSize: 14,
padding: 8,
spacing: 8,
radius: 12
})
Component.onCompleted: {
_load();
_startWatcher();
}
function _load() {
loadProcess.command = ["cat", _themePath];
loadProcess.running = true;
}
function _startWatcher() {
watchProcess.command = [
"bash", "-c",
"while inotifywait -q -e close_write '" + _themePath + "' 2>/dev/null; do echo CHANGED; done"
];
watchProcess.running = true;
}
function _apply(json) {
if (typeof json.background === "string") background = json.background;
if (typeof json.foreground === "string") foreground = json.foreground;
if (typeof json.idle === "string") idle = json.idle;
if (typeof json.accent === "string") accent = json.accent;
if (typeof json.warning === "string") warning = json.warning;
if (typeof json.overlayStrong === "string") overlayStrong = json.overlayStrong;
if (typeof json.overlayWeak === "string") overlayWeak = json.overlayWeak;
if (typeof json.border === "string") border = json.border;
if (typeof json.fontFamily === "string" && json.fontFamily.length > 0) fontFamily = json.fontFamily;
if (typeof json.fontSize === "number" && json.fontSize > 0) fontSize = json.fontSize;
if (typeof json.padding === "number" && json.padding > 0) padding = json.padding;
if (typeof json.spacing === "number" && json.spacing > 0) spacing = json.spacing;
if (typeof json.radius === "number" && json.radius >= 0) radius = json.radius;
console.log("quick-visor: theme loaded from", _themePath);
}
function _writeDefaults() {
writeProcess.command = [
"bash", "-c",
"mkdir -p '" + _configDir + "' && cat > '" + _themePath + "'"
];
writeProcess.stdinEnabled = true;
writeProcess._content = "// Quick Visor theme file (JSONC)\n"
+ "// Auto-generated by ThemeEngine when applying shell-dev themes.\n"
+ JSON.stringify(_defaults, null, 2) + "\n";
writeProcess.running = true;
}
property Process watchProcess: Process {
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
if (line.indexOf("CHANGED") >= 0) root._load();
}
}
onExited: root._startWatcher()
}
property Process loadProcess: Process {
running: false
stdout: StdioCollector { id: loadStdout; waitForEnd: true }
onExited: exitCode => {
if (exitCode === 0 && loadStdout.text.length > 0) {
try {
const stripped = loadStdout.text
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
root._apply(JSON.parse(stripped));
} catch (e) {
console.log("quick-visor: theme.jsonc parse error:", e);
}
} else {
root._writeDefaults();
}
}
}
property Process writeProcess: Process {
running: false
property string _content: ""
onStarted: {
writeProcess.write(writeProcess._content);
writeProcess.stdinEnabled = false;
}
}
}
+500
View File
@@ -0,0 +1,500 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
FloatingWindow {
id: root
visible: VisorService.visible
color: "transparent"
implicitWidth: Config.panelWidth
implicitHeight: Config.panelHeight
title: "Quick Visor"
Shortcut {
sequence: "Esc"
context: Qt.ApplicationShortcut
onActivated: VisorService.close()
}
Shortcut {
sequence: "Q"
context: Qt.ApplicationShortcut
onActivated: VisorService.close()
}
Shortcut {
sequence: "E"
context: Qt.ApplicationShortcut
onActivated: VisorService.applyLayout()
}
onVisibleChanged: {
if (visible) {
VisorService.refresh();
Qt.callLater(() => workspace.forceActiveFocus());
} else if (VisorService.visible) {
VisorService.close();
}
}
Rectangle {
anchors.fill: parent
color: Theme.background
radius: Theme.radius
border.color: Theme.border
border.width: 1
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
onPressed: mouse => mouse.accepted = true
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.padding * 3
spacing: Theme.spacing * 2
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: "Display layout"
color: Theme.foreground
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 1.6
font.bold: true
}
Text {
text: VisorService.status
color: VisorService.dirty ? Theme.accent : Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
ActionButton {
label: "Reset"
enabled: VisorService.dirty
onClicked: VisorService.resetLayout()
}
ActionButton {
label: VisorService.refreshing ? "Refreshing..." : "Refresh"
enabled: !VisorService.refreshing
onClicked: VisorService.refresh()
}
ActionButton {
label: "Apply"
primary: true
enabled: VisorService.dirty
onClicked: VisorService.applyLayout()
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.border
opacity: 0.75
}
FocusScope {
id: workspace
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
focus: true
readonly property var bounds: VisorService.layoutBounds()
readonly property real usableWidth: Math.max(1, width - Theme.padding * 4)
readonly property real usableHeight: Math.max(1, height - Theme.padding * 4)
readonly property real layoutScale: Math.max(0.05, Math.min(
usableWidth / bounds.width,
usableHeight / bounds.height
))
readonly property real contentWidth: bounds.width * layoutScale
readonly property real contentHeight: bounds.height * layoutScale
readonly property real originX: (width - contentWidth) / 2
readonly property real originY: (height - contentHeight) / 2
Rectangle {
anchors.fill: parent
color: Theme.overlayWeak
radius: Theme.radius
border.color: Theme.border
border.width: 1
opacity: 0.75
}
Text {
visible: VisorService.monitors.length === 0
anchors.centerIn: parent
text: "No displays detected"
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Repeater {
model: VisorService.monitors
Rectangle {
id: monitorCard
required property int index
required property var modelData
readonly property bool selected: VisorService.selectedIndex === index
property real pressSceneX: 0
property real pressSceneY: 0
property real pressLogicalX: 0
property real pressLogicalY: 0
x: workspace.originX + (VisorService.monitorX(modelData) - workspace.bounds.minX) * workspace.layoutScale
y: workspace.originY + (VisorService.monitorY(modelData) - workspace.bounds.minY) * workspace.layoutScale
width: Math.max(96, VisorService.monitorWidth(modelData) * workspace.layoutScale)
height: Math.max(64, VisorService.monitorHeight(modelData) * workspace.layoutScale)
radius: Theme.radius / 1.5
color: dragArea.containsMouse ? Theme.overlayWeak : Theme.background
border.color: selected ? Theme.accent : Theme.border
border.width: selected ? 3 : 1
Rectangle {
anchors.fill: parent
anchors.margins: 4
radius: Math.max(0, monitorCard.radius - 4)
color: "transparent"
border.color: modelData.focused ? Theme.accent : "transparent"
border.width: 1
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.padding * 1.5
spacing: 2
Text {
Layout.fillWidth: true
text: modelData.name || "Display"
color: Theme.foreground
font.family: Theme.fontFamily
font.pixelSize: Math.max(11, Theme.fontSize * 1.05)
font.bold: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: modelData.model || modelData.description || ""
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Math.max(10, Theme.fontSize * 0.8)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Item { Layout.fillHeight: true }
Text {
Layout.fillWidth: true
text: VisorService.formatMode(modelData)
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Math.max(10, Theme.fontSize * 0.85)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: VisorService.formatGeometry(modelData)
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Math.max(10, Theme.fontSize * 0.8)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
MouseArea {
id: dragArea
anchors.fill: parent
hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor
onPressed: mouse => {
VisorService.selectedIndex = monitorCard.index;
const p = monitorCard.mapToItem(workspace, mouse.x, mouse.y);
monitorCard.pressSceneX = p.x;
monitorCard.pressSceneY = p.y;
monitorCard.pressLogicalX = VisorService.monitorX(monitorCard.modelData);
monitorCard.pressLogicalY = VisorService.monitorY(monitorCard.modelData);
}
onPositionChanged: mouse => {
if (!pressed) return;
const p = monitorCard.mapToItem(workspace, mouse.x, mouse.y);
const dx = (p.x - monitorCard.pressSceneX) / workspace.layoutScale;
const dy = (p.y - monitorCard.pressSceneY) / workspace.layoutScale;
const snapped = VisorService.snappedPosition(
monitorCard.modelData.name,
monitorCard.pressLogicalX + dx,
monitorCard.pressLogicalY + dy
);
VisorService.setMonitorPosition(
monitorCard.modelData.name,
snapped.x,
snapped.y
);
}
}
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Q) {
VisorService.close();
event.accepted = true;
} else if (event.key === Qt.Key_R) {
VisorService.refresh();
event.accepted = true;
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_E) {
VisorService.applyLayout();
event.accepted = true;
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Right || event.key === Qt.Key_Down || event.key === Qt.Key_J) {
VisorService.select(1);
event.accepted = true;
} else if (event.key === Qt.Key_Left || event.key === Qt.Key_Up || event.key === Qt.Key_K) {
VisorService.select(-1);
event.accepted = true;
}
}
}
Rectangle {
id: selectedPanel
Layout.fillWidth: true
Layout.preferredHeight: 112
visible: VisorService.selected !== null
color: Theme.overlayWeak
radius: Theme.radius
border.color: Theme.border
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: Theme.padding * 2
spacing: Theme.spacing * 2
ColumnLayout {
Layout.preferredWidth: 190
Layout.fillHeight: true
spacing: 2
Text {
Layout.fillWidth: true
text: VisorService.selected ? VisorService.selected.name : ""
color: Theme.foreground
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 1.1
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: VisorService.selected ? (VisorService.selected.model || VisorService.selected.description || "") : ""
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 0.9
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: VisorService.selected ? VisorService.formatGeometry(VisorService.selected) : ""
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 0.85
elide: Text.ElideRight
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: "Refresh rate / mode"
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 0.85
}
ComboBox {
id: modeCombo
Layout.fillWidth: true
model: VisorService.selected && Array.isArray(VisorService.selected.availableModes)
? VisorService.selected.availableModes
: []
currentIndex: VisorService.selectedModeIndex(VisorService.selected)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
onActivated: index => {
if (VisorService.selected && index >= 0) {
VisorService.setMonitorMode(VisorService.selected.name, model[index]);
}
}
popup: Popup {
y: modeCombo.height
width: modeCombo.width
implicitHeight: Math.min(modeList.contentHeight, 240)
padding: 1
background: Rectangle {
color: Theme.background
border.color: Theme.border
border.width: 1
radius: Theme.radius / 2
}
contentItem: ListView {
id: modeList
clip: true
implicitHeight: contentHeight
model: modeCombo.popup.visible ? modeCombo.delegateModel : null
currentIndex: modeCombo.highlightedIndex
boundsBehavior: Flickable.StopAtBounds
ScrollIndicator.vertical: ScrollIndicator {}
}
}
}
}
ColumnLayout {
Layout.preferredWidth: 180
spacing: 4
Text {
text: "DPI / scale"
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 0.85
}
RowLayout {
spacing: Theme.spacing
ActionButton {
label: "-"
enabled: VisorService.selected !== null
onClicked: VisorService.adjustMonitorScale(VisorService.selected.name, -0.05)
}
TextField {
id: scaleInput
Layout.preferredWidth: 70
text: VisorService.selected ? String(Math.round(VisorService.monitorScale(VisorService.selected) * 100)) : ""
enabled: VisorService.selected !== null
color: Theme.foreground
selectedTextColor: Theme.background
selectionColor: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
horizontalAlignment: Text.AlignHCenter
validator: IntValidator { bottom: 50; top: 400 }
background: Rectangle {
color: Theme.background
radius: Theme.radius / 3
border.color: scaleInput.activeFocus ? Theme.accent : Theme.border
border.width: 1
}
onEditingFinished: {
if (VisorService.selected && acceptableInput) {
VisorService.setMonitorScale(VisorService.selected.name, parseInt(text, 10) / 100);
}
}
}
Text {
text: "%"
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
horizontalAlignment: Text.AlignHCenter
}
ActionButton {
label: "+"
enabled: VisorService.selected !== null
onClicked: VisorService.adjustMonitorScale(VisorService.selected.name, 0.05)
}
}
}
}
}
Text {
Layout.fillWidth: true
text: "Drag displays to arrange them | select a display to set refresh and scale | Enter apply | Q/Esc close"
color: Theme.idle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize * 0.9
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
maximumLineCount: 2
}
}
}
component ActionButton: Rectangle {
id: button
property string label: ""
property bool primary: false
signal clicked()
implicitWidth: buttonLabel.implicitWidth + Theme.padding * 3
implicitHeight: buttonLabel.implicitHeight + Theme.padding
color: !enabled ? Theme.background : (buttonMouse.containsMouse || primary ? Theme.overlayStrong : Theme.overlayWeak)
opacity: enabled ? 1.0 : 0.45
radius: Theme.radius / 2
border.color: primary && enabled ? Theme.accent : Theme.border
border.width: 1
Text {
id: buttonLabel
anchors.centerIn: parent
text: button.label
color: button.primary && button.enabled ? Theme.accent : Theme.foreground
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: button.primary
}
MouseArea {
id: buttonMouse
anchors.fill: parent
hoverEnabled: true
enabled: button.enabled
onClicked: button.clicked()
}
}
}
+344
View File
@@ -0,0 +1,344 @@
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()
}
+6
View File
@@ -0,0 +1,6 @@
module QuickVisor
singleton Config 1.0 Config.qml
singleton Theme 1.0 Theme.qml
singleton VisorService 1.0 VisorService.qml
Visor 1.0 Visor.qml
ScreenLabel 1.0 ScreenLabel.qml
+20
View File
@@ -0,0 +1,20 @@
import Quickshell
import Quickshell.Io
ShellRoot {
IpcHandler {
target: "quick-visor"
function toggle(): void { VisorService.toggle(); }
function open(): void { VisorService.open(); }
function close(): void { VisorService.close(); }
function refresh(): void { VisorService.refresh(); }
}
Visor {}
Variants {
model: Quickshell.screens
ScreenLabel {}
}
}