mirror of
https://github.com/AlexMacocian/quick-visor.git
synced 2026-07-15 15:09:32 +00:00
Initial commit
This commit is contained in:
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
Executable
+3
@@ -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 "$@"
|
||||||
Executable
+3
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user