aboutsummaryrefslogtreecommitdiffstats
path: root/hosts/Hosts.qml
diff options
context:
space:
mode:
Diffstat (limited to 'hosts/Hosts.qml')
-rw-r--r--hosts/Hosts.qml361
1 files changed, 361 insertions, 0 deletions
diff --git a/hosts/Hosts.qml b/hosts/Hosts.qml
new file mode 100644
index 0000000..fb2c0e1
--- /dev/null
+++ b/hosts/Hosts.qml
@@ -0,0 +1,361 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ property bool open: false
+ property string monitor: "DP-1"
+ property var hosts: []
+ property string query: ""
+ property int current: 0
+ // Non-empty while loading or after a failure; the list shows only when
+ // this is empty, so a stale list from the last opening never shows.
+ property string status: "Loading..."
+
+ // Every word must appear somewhere in the host's fields.
+ readonly property var shown: {
+ const words = root.query.toLowerCase().split(/\s+/).filter(w => w);
+ return root.hosts.filter(h => {
+ const hay = [h.name, h.ip, h.port, h.hostname, h.node, h.ct_id]
+ .concat(h.aliases).join(" ").toLowerCase();
+ return words.every(w => hay.includes(w));
+ });
+ }
+ onShownChanged: root.current = 0
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function toggle() { root.open = !root.open; }
+ function close() { root.open = false; }
+
+ function address(h) { return h.port ? `${h.ip}:${h.port}` : h.ip; }
+
+ // What a host offers, from what its note and the ssh config carry. The
+ // first is what Enter does.
+ function actions(h) {
+ const out = h.aliases.map(a => ({ label: `ssh ${a}`,
+ cmd: [`${Quickshell.shellDir}/ssh-tab`, a] }));
+ if (h.url)
+ out.push({ label: "open", cmd: ["xdg-open", h.url] });
+ if (h.ip)
+ out.push({ label: `copy ${root.address(h)}`,
+ cmd: ["wl-copy", "--", root.address(h)] });
+ return out;
+ }
+
+ function run(action) {
+ Quickshell.execDetached(action.cmd);
+ root.close();
+ }
+
+ function move(step) {
+ root.current = Math.max(0, Math.min(root.shown.length - 1, root.current + step));
+ }
+
+ function runFirst(h) {
+ const a = h ? root.actions(h) : [];
+ if (a.length) root.run(a[0]);
+ }
+
+ // Read afresh on every opening, so a note edited in the vault or an alias
+ // added to the ssh config shows the next time, with no cache to go stale.
+ onOpenChanged: if (open) {
+ root.status = "Loading...";
+ root.query = "";
+ // A Process already running ignores running = true.
+ proc.running = false;
+ proc.running = true;
+ }
+
+ Process {
+ id: proc
+ command: ["/usr/bin/python3", `${Quickshell.shellDir}/hosts.py`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ root.hosts = JSON.parse(text);
+ root.status = "";
+ } catch (e) {
+ root.status = "hosts.py gave nothing readable";
+ }
+ }
+ }
+ }
+
+ // A config with no visible window exits, reporting nothing. This 1x1
+ // click-through window holds the shell open while the overlay is hidden.
+ PanelWindow {
+ anchors { top: true; left: true }
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ screen: root.screenObj
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "keystone-hosts"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82)
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.close()
+ }
+
+ Column {
+ id: panel
+ anchors.fill: parent
+ anchors.margins: 40
+ spacing: 20
+
+ Rectangle {
+ width: 720
+ anchors.horizontalCenter: parent.horizontalCenter
+ height: 52
+ radius: 14
+ color: Theme.surface
+
+ // Keys reach a focused item, not a window, so the
+ // field takes all of them.
+ TextInput {
+ id: field
+ anchors.fill: parent
+ anchors.leftMargin: 18
+ anchors.rightMargin: 18
+ verticalAlignment: TextInput.AlignVCenter
+ focus: true
+ activeFocusOnTab: true
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 20
+ text: root.query
+ onTextEdited: root.query = text
+
+ Keys.onEscapePressed: root.close()
+ // The arrows move through the grid, not the
+ // text: a query is short enough to retype.
+ Keys.onLeftPressed: root.move(-1)
+ Keys.onRightPressed: root.move(1)
+ Keys.onUpPressed: root.move(-grid.columns)
+ Keys.onDownPressed: root.move(grid.columns)
+ Keys.onReturnPressed: root.runFirst(root.shown[root.current])
+ Keys.onEnterPressed: root.runFirst(root.shown[root.current])
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: field.text === ""
+ text: "host, ip, alias, node..."
+ color: Theme.overlay
+ font: field.font
+ }
+ }
+ }
+
+ Text {
+ visible: root.status !== ""
+ text: root.status
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 18
+ }
+
+ // Five columns in rows, each row as tall as its
+ // busiest card, so one with many chips grows its own row
+ // and no other. The margins keep the selected card's
+ // glow inside the clip. Rows rather than a GridView,
+ // whose cells all share one height.
+ Flickable {
+ id: grid
+ readonly property int columns: 5
+ visible: root.status === ""
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ contentHeight: rows.height + 48
+ boundsBehavior: Flickable.StopAtBounds
+
+ // Keep the selected card in view as the arrows move.
+ function reveal(i) {
+ const r = rowRepeater.itemAt(Math.floor(i / columns));
+ if (!r) return;
+ const top = rows.y + r.y - 24;
+ const bottom = rows.y + r.y + r.height + 24;
+ if (top < contentY)
+ contentY = top;
+ else if (bottom > contentY + height)
+ contentY = bottom - height;
+ }
+
+ Connections {
+ target: root
+ function onCurrentChanged() { grid.reveal(root.current); }
+ function onShownChanged() { grid.contentY = 0; }
+ }
+
+ Column {
+ id: rows
+ x: 24
+ y: 24
+ width: grid.width - 48
+ spacing: 28
+
+ Repeater {
+ id: rowRepeater
+ model: {
+ const out = [];
+ for (let i = 0; i < root.shown.length; i += grid.columns)
+ out.push(root.shown.slice(i, i + grid.columns));
+ return out;
+ }
+
+ Row {
+ id: row
+ required property var modelData
+ required property int index
+ readonly property real tallest: {
+ let m = 0;
+ for (const c of children)
+ if (c.need) m = Math.max(m, c.need);
+ return m;
+ }
+ spacing: rows.spacing
+
+ Repeater {
+ model: row.modelData
+
+ Rectangle {
+ id: card
+ required property var modelData
+ required property int index
+ readonly property int at: row.index * grid.columns + index
+ readonly property bool selected: at === root.current
+ // What this card needs; the row takes the most.
+ readonly property real need: info.implicitHeight + 24
+
+ width: (rows.width - rows.spacing * (grid.columns - 1)) / grid.columns
+ height: row.tallest
+ radius: 14
+ color: selected ? Theme.surfaceAlt : Theme.surface
+
+ Glow {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ on: card.selected
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: root.current = card.at
+ onClicked: root.runFirst(card.modelData)
+ }
+
+ Column {
+ id: info
+ x: 16
+ y: 12
+ width: parent.width - 32
+ spacing: 4
+
+ Text {
+ text: card.modelData.name
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 16
+ font.bold: true
+ }
+ Text {
+ text: [root.address(card.modelData),
+ card.modelData.hostname !== card.modelData.ip
+ ? card.modelData.hostname : ""]
+ .filter(s => s).join(" · ")
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+ Text {
+ visible: card.modelData.node !== ""
+ text: card.modelData.node
+ + (card.modelData.ct_id ? ` · CT ${card.modelData.ct_id}` : "")
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+
+ Flow {
+ width: parent.width
+ spacing: 8
+ topPadding: 4
+
+ Repeater {
+ model: root.actions(card.modelData)
+
+ Rectangle {
+ id: chip
+ required property var modelData
+ required property int index
+ width: label.width + 20
+ height: 24
+ radius: 12
+ color: chipMouse.containsMouse ? Theme.accent : Theme.base
+ border.width: card.selected && index === 0 ? 1 : 0
+ border.color: Theme.accent
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ text: chip.modelData.label
+ color: chipMouse.containsMouse ? Theme.base : Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+ MouseArea {
+ id: chipMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: root.run(chip.modelData)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}