// Copyright (C) 2026 Danilo M. // // 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) } } } } } } } } } } } } } } } }