Files
arthur-os/shell/plugins/tailscale/Widget.qml
T
2026-05-26 11:27:11 -04:00

638 lines
22 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "omarchy.tailscale"
property bool popupOpen: false
property string focusSection: "header"
property int headerIndex: 0
property int accountIndex: 0
property int peerIndex: 0
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showAccounts: tailscale.accounts.length > 1
readonly property bool showPeers: tailscale.peers.length > 0
readonly property color iconColor: tailscale.running ? foreground : (tailscale.needsLogin ? urgent : dim)
function close() { popupOpen = false }
function openPanel() {
popupOpen = true
tailscale.refresh()
}
function togglePanel() {
if (popupOpen) close()
else openPanel()
}
function tooltipText() {
var lines = ["Tailscale: " + tailscale.statusText]
if (tailscale.selfIp !== "") lines.push(tailscale.selfName + " · " + tailscale.selfIp)
if (tailscale.selectedAccountLabel !== "") lines.push(tailscale.selectedAccountLabel)
if (tailscale.running) lines.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
return lines.join("\n")
}
function selectedPeer() {
if (tailscale.peers.length === 0) return null
return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))]
}
function selectedAccount() {
if (tailscale.accounts.length === 0) return null
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
}
function ensureCursor() {
if (headerIndex < 0) headerIndex = 0
if (headerIndex > 1) headerIndex = 1
if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1)
if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1)
if (focusSection === "accounts" && !showAccounts) focusSection = showPeers ? "peers" : "header"
if (focusSection === "peers" && !showPeers) focusSection = showAccounts ? "accounts" : "header"
}
function moveCursor(dx, dy) {
ensureCursor()
if (dy !== 0) {
if (focusSection === "header") {
if (dy > 0) {
if (showAccounts) focusSection = "accounts"
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "accounts") {
if (dy < 0) {
if (accountIndex <= 0) focusSection = "header"
else accountIndex--
} else {
if (accountIndex < tailscale.accounts.length - 1) accountIndex++
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "peers") {
if (dy < 0) {
if (peerIndex <= 0) focusSection = showAccounts ? "accounts" : "header"
else peerIndex--
} else if (peerIndex < tailscale.peers.length - 1) {
peerIndex++
}
}
}
if (dx !== 0 && focusSection === "header") headerIndex = (headerIndex + dx + 2) % 2
ensureCursor()
scrollPeerIntoView()
}
function activateCursor() {
ensureCursor()
if (focusSection === "header") {
if (headerIndex === 0) tailscale.toggleTailscale()
else tailscale.refresh()
} else if (focusSection === "accounts") {
var account = selectedAccount()
if (account) tailscale.switchAccount(account.id)
} else if (focusSection === "peers") {
tailscale.copyPeerIp(selectedPeer())
}
}
function scrollPeerIntoView() {
if (focusSection !== "peers" || !peerFlick || !peerColumn) return
Qt.callLater(function() {
if (root.focusSection !== "peers" || root.peerIndex < 0 || root.peerIndex >= peerColumn.children.length) return
var item = peerColumn.children[root.peerIndex]
if (!item) return
var margin = Style.space(6)
var top = item.y
var bottom = top + item.height
var viewTop = peerFlick.contentY
var viewBottom = viewTop + peerFlick.height
var maxY = Math.max(0, peerFlick.contentHeight - peerFlick.height)
if (top < viewTop + margin) peerFlick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin) peerFlick.contentY = Math.min(maxY, bottom + margin - peerFlick.height)
})
}
function setPeerCursor(index) {
focusSection = "peers"
peerIndex = index
scrollPeerIntoView()
}
function setAccountCursor(index) {
focusSection = "accounts"
accountIndex = index
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onPopupOpenChanged: if (popupOpen) Qt.callLater(function() { keyCatcher.forceActiveFocus() })
onPeerIndexChanged: scrollPeerIntoView()
onShowAccountsChanged: ensureCursor()
onShowPeersChanged: ensureCursor()
Main {
id: tailscale
settings: root.settings
onPeersChanged: root.ensureCursor()
onAccountsChanged: root.ensureCursor()
}
Item {
id: button
anchors.fill: parent
implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(26)
implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26))
property var registeredBar: null
readonly property bool tooltipHovered: mouseArea.containsMouse
function triggerPress(buttonCode) {
if (root.bar) root.bar.hideTooltip(button)
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
else root.togglePanel()
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button)
}
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
Connections {
target: root
function onBarChanged() { button.syncClickRegistration() }
}
TailscaleIcon {
anchors.centerIn: parent
iconSize: Style.space(12)
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: if (root.bar) root.bar.showTooltip(button, root.tooltipText())
onExited: if (root.bar) root.bar.hideTooltip(button)
onClicked: function(mouse) { button.triggerPress(mouse.button) }
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(400))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(580))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
onActivateRequested: root.activateCursor()
onCloseRequested: root.close()
onTextKey: function(t) {
if (t === "r" || t === "R") tailscale.refresh()
else if (t === "t" || t === "T") tailscale.toggleTailscale()
else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer())
else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: column
width: panelFlick.width
spacing: Style.space(12)
Item {
id: header
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroText.implicitHeight, headerActions.implicitHeight)
TailscaleIcon {
id: heroIcon
iconSize: Style.font.display
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Column {
id: heroText
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(12)
anchors.right: headerActions.left
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
width: parent.width
text: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
var parts = [tailscale.statusText]
if (tailscale.selfIp !== "") parts.push(tailscale.selfIp)
if (tailscale.running) parts.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
return parts.join(" · ").toUpperCase()
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
}
}
Row {
id: headerActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(5)
PanelActionButton {
id: toggleBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "⏻"
fontSize: Style.font.heading
size: Style.space(30)
tooltipText: tailscale.running ? "Disconnect Tailscale" : (tailscale.needsLogin ? "Log in to Tailscale" : "Connect Tailscale")
foreground: root.foreground
hoverColor: tailscale.running ? root.urgent : root.foreground
fontFamily: root.fontFamily
hasCursor: root.focusSection === "header" && root.headerIndex === 0
enabled: tailscale.installed && !tailscale.busy
onHovered: function(h) {
if (!h) return
root.focusSection = "header"
root.headerIndex = 0
}
onClicked: tailscale.toggleTailscale()
}
PanelActionButton {
id: refreshBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑐"
fontSize: Style.font.heading
size: Style.space(30)
tooltipText: "Refresh"
foreground: root.foreground
fontFamily: root.fontFamily
hasCursor: root.focusSection === "header" && root.headerIndex === 1
enabled: !tailscale.busy
onHovered: function(h) {
if (!h) return
root.focusSection = "header"
root.headerIndex = 1
}
onClicked: tailscale.refresh()
}
}
}
Text {
visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
width: parent.width
text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError
color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Rectangle {
visible: !tailscale.installed
width: parent.width
implicitHeight: missingText.implicitHeight + Style.space(24)
color: root.card
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06)
border.width: Style.normalBorderWidth
radius: Style.cornerRadius
Text {
id: missingText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(12)
text: "Tailscale CLI is not installed or not on PATH."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
}
PanelSeparator {
visible: root.showAccounts
foreground: root.foreground
}
Column {
visible: root.showAccounts
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "ACCOUNTS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Repeater {
model: tailscale.accounts
AccountRow {
required property var modelData
required property int index
width: parent.width
account: modelData
rowIndex: index
}
}
}
PanelSeparator {
visible: tailscale.installed
foreground: root.foreground
}
Column {
visible: tailscale.installed
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "PEERS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0
width: parent.width
text: "No peers found on this tailnet."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
horizontalAlignment: Text.AlignHCenter
}
Text {
visible: tailscale.installed && !tailscale.running
width: parent.width
text: tailscale.needsLogin ? "Log in to see tailnet peers." : "Turn Tailscale on to see tailnet peers."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
horizontalAlignment: Text.AlignHCenter
}
Flickable {
id: peerFlick
visible: tailscale.peers.length > 0
width: parent.width
height: Math.min(peerColumn.implicitHeight, Style.space(340))
contentWidth: width
contentHeight: peerColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: peerColumn
width: peerFlick.width
spacing: Style.space(6)
Repeater {
model: tailscale.peers
PeerRow {
required property var modelData
required property int index
width: peerColumn.width
peer: modelData
rowIndex: index
}
}
}
}
}
}
}
}
}
component AccountRow: Rectangle {
id: accountRow
property var account: null
property int rowIndex: 0
readonly property bool selectedAccount: account && account.selected === true
readonly property bool hasCursor: root.focusSection === "accounts" && root.accountIndex === rowIndex
implicitHeight: row.implicitHeight + Style.space(12)
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : (selectedAccount ? root.card : "transparent")
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : "transparent"
border.width: hasCursor ? Style.hoverBorderWidth : 0
radius: Style.cornerRadius
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
text: accountRow.selectedAccount ? "●" : "○"
color: accountRow.selectedAccount ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: account ? (account.nickname || account.tailnet || account.account || account.id || "Account") : "Account"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: account ? [account.tailnet || "", account.account || ""].filter(function(x) { return String(x) !== "" }).join(" · ") : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.setAccountCursor(accountRow.rowIndex)
onClicked: if (accountRow.account) tailscale.switchAccount(accountRow.account.id)
}
}
component PeerRow: Rectangle {
id: peerRow
property var peer: null
property int rowIndex: 0
readonly property bool online: peer && peer.Online === true
readonly property bool hasCursor: root.focusSection === "peers" && root.peerIndex === rowIndex
readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown"
readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : ""
readonly property string peerDns: peer ? String(peer.DNSName || "") : ""
implicitHeight: Math.max(peerContent.implicitHeight, actions.implicitHeight) + Style.space(12)
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : root.card
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
border.width: hasCursor ? Style.hoverBorderWidth : Style.normalBorderWidth
radius: Style.cornerRadius
opacity: online ? 1.0 : 0.62
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(8)
spacing: Style.space(8)
Text {
text: tailscale.osIcon(peer ? peer.OS : "")
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.icon
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
id: peerContent
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: peerRow.peerName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: {
var parts = []
if (peerRow.peerIp !== "") parts.push(peerRow.peerIp)
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
if (!peerRow.online) parts.push("offline")
return parts.join(" · ")
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
Row {
id: actions
spacing: Style.space(2)
Layout.alignment: Qt.AlignVCenter
PanelActionButton {
iconText: "󰆏"
tooltipText: "Copy IP"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: peerRow.peerIp !== ""
onClicked: tailscale.copyPeerIp(peerRow.peer)
}
PanelActionButton {
iconText: "󰉿"
tooltipText: "Copy name"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: peerRow.peerName !== ""
onClicked: tailscale.copyPeerName(peerRow.peer)
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
z: -1
onEntered: root.setPeerCursor(peerRow.rowIndex)
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) tailscale.copyPeerName(peerRow.peer)
else if (mouse.button === Qt.MiddleButton) tailscale.copyPeerDnsName(peerRow.peer)
else tailscale.copyPeerIp(peerRow.peer)
}
}
}
}