diff options
Diffstat (limited to 'hosts/Hosts.qml')
| -rw-r--r-- | hosts/Hosts.qml | 361 |
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) + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} |
