From b057986bee92dfc822929d8e759a15aaeeb00868 Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Tue, 9 Jun 2026 12:27:52 +0200 Subject: [PATCH] Initial commit --- LICENSE | 21 ++ PKGBUILD | 23 ++ README.md | 59 +++++ bin/quick-visor | 3 + bin/quick-visor-toggle | 3 + qml/Config.qml | 39 ++++ qml/ScreenLabel.qml | 63 ++++++ qml/Theme.qml | 130 +++++++++++ qml/Visor.qml | 500 +++++++++++++++++++++++++++++++++++++++++ qml/VisorService.qml | 344 ++++++++++++++++++++++++++++ qml/qmldir | 6 + qml/shell.qml | 20 ++ 12 files changed, 1211 insertions(+) create mode 100644 LICENSE create mode 100644 PKGBUILD create mode 100644 README.md create mode 100755 bin/quick-visor create mode 100755 bin/quick-visor-toggle create mode 100644 qml/Config.qml create mode 100644 qml/ScreenLabel.qml create mode 100644 qml/Theme.qml create mode 100644 qml/Visor.qml create mode 100644 qml/VisorService.qml create mode 100644 qml/qmldir create mode 100644 qml/shell.qml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c608906 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..b778f8c --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,23 @@ +# Maintainer: Alex Macocian +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" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5eecb3 --- /dev/null +++ b/README.md @@ -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 +} +``` diff --git a/bin/quick-visor b/bin/quick-visor new file mode 100755 index 0000000..7ab6693 --- /dev/null +++ b/bin/quick-visor @@ -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 "$@" diff --git a/bin/quick-visor-toggle b/bin/quick-visor-toggle new file mode 100755 index 0000000..4e96760 --- /dev/null +++ b/bin/quick-visor-toggle @@ -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 diff --git a/qml/Config.qml b/qml/Config.qml new file mode 100644 index 0000000..f5a06a6 --- /dev/null +++ b/qml/Config.qml @@ -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; + } + } + } +} diff --git a/qml/ScreenLabel.qml b/qml/ScreenLabel.qml new file mode 100644 index 0000000..323fb6c --- /dev/null +++ b/qml/ScreenLabel.qml @@ -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 + } + } + } +} diff --git a/qml/Theme.qml b/qml/Theme.qml new file mode 100644 index 0000000..ecc993e --- /dev/null +++ b/qml/Theme.qml @@ -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; + } + } +} diff --git a/qml/Visor.qml b/qml/Visor.qml new file mode 100644 index 0000000..0b640bd --- /dev/null +++ b/qml/Visor.qml @@ -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() + } + } +} diff --git a/qml/VisorService.qml b/qml/VisorService.qml new file mode 100644 index 0000000..47533e3 --- /dev/null +++ b/qml/VisorService.qml @@ -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() +} diff --git a/qml/qmldir b/qml/qmldir new file mode 100644 index 0000000..d1d5eb6 --- /dev/null +++ b/qml/qmldir @@ -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 diff --git a/qml/shell.qml b/qml/shell.qml new file mode 100644 index 0000000..54d7357 --- /dev/null +++ b/qml/shell.qml @@ -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 {} + } +}