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