// 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: [] // name -> reachable, from `hosts.py --probe`; a host missing from it is // not probed yet and its dot stays grey. property var up: ({}) 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, h.group] .concat(h.aliases).join(" ").toLowerCase(); return words.every(w => hay.includes(w)); }); } onShownChanged: root.current = 0 // The grid's rows: a group starts a new row, which carries the group's // title, and no row holds more than `columns` cards. `start` is the // index in `shown` of the row's first card. readonly property int columns: 5 readonly property var rows: { const out = []; root.shown.forEach((h, i) => { const last = out[out.length - 1]; if (last && last.group === h.group && last.items.length < root.columns) last.items.push(h); else out.push({ group: h.group, title: last && last.group === h.group ? "" : h.group, start: i, items: [h] }); }); return out; } function rowOf(i) { for (let r = root.rows.length - 1; r >= 0; r--) if (root.rows[r].start <= i) return r; return 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)); } // Up and Down keep the column, clamped to a shorter row's last card. function moveRow(step) { const r = root.rowOf(root.current); const to = root.rows[r + step]; if (to) root.current = to.start + Math.min(root.current - root.rows[r].start, to.items.length - 1); } 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 = ""; root.up = {}; // A Process already running ignores running = true. proc.running = false; proc.running = true; probe.running = false; probe.running = true; } // Takes about a second, every host at once, so it runs beside the list // rather than holding it up. Process { id: probe command: ["/usr/bin/python3", `${Quickshell.shellDir}/hosts.py`, "--probe"] stdout: StdioCollector { onStreamFinished: { try { root.up = JSON.parse(text); } catch (e) {} } } } 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.moveRow(-1) Keys.onDownPressed: root.moveRow(1) 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 grouped by node, then Devices and // Remote, 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: root.columns 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(root.rowOf(i)); 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: root.rows Column { id: section required property var modelData spacing: 12 // A group's first row opens it with a // title and a rule; later rows are bare. topPadding: modelData.title && modelData.start > 0 ? 20 : 0 Column { visible: section.modelData.title !== "" spacing: 6 Text { text: section.modelData.title color: section.modelData.group === "Devices" || section.modelData.group === "Remote" ? Theme.subtext : Theme.accent font.family: Theme.fontFamily font.pixelSize: 18 font.bold: true } Rectangle { width: rows.width height: 1 color: Theme.overlay } } Row { id: row readonly property var modelData: section.modelData.items readonly property int start: section.modelData.start 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.start + 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) } // Reachable now: green, not: red, // not probed yet: grey. Rectangle { readonly property var state: root.up[card.modelData.name] anchors.top: parent.top anchors.right: parent.right anchors.margins: 14 width: 10 height: 10 radius: 5 color: state === true ? Theme.green : state === false ? Theme.red : Theme.overlay } Column { id: info x: 16 y: 12 width: parent.width - 48 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) } } } } } } } } } } } } } } } } }