basic tailscale widget
This commit is contained in:
596
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
596
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Io
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
|
||||||
|
// anchor to the bar itself rather than to this icon, keeping it a fixed
|
||||||
|
// distance from the bar and the screen edge regardless of where this
|
||||||
|
// icon sits in the row.
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
// Quickshell has no native Tailscale API — this shells out to the
|
||||||
|
// `tailscale` CLI, the same way NetworkIndicator.qml gets its live
|
||||||
|
// stats. `status --json` gives a full snapshot in one call.
|
||||||
|
readonly property var defaultStatus: ({
|
||||||
|
ok: false, backendState: "Unknown", running: false, needsLogin: false,
|
||||||
|
authUrl: "", selfName: "", selfIp: "", tailnetName: "",
|
||||||
|
peers: [], exitNodePeers: [], activeExitNode: null
|
||||||
|
})
|
||||||
|
property var status: defaultStatus
|
||||||
|
property bool busy: false
|
||||||
|
|
||||||
|
function parseTailscaleStatus(text) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
const backendState = data.BackendState || "Stopped"
|
||||||
|
const self = data.Self || {}
|
||||||
|
const selfIps = (self.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
|
||||||
|
const rawPeers = data.Peer || {}
|
||||||
|
const peers = []
|
||||||
|
for (const id in rawPeers) {
|
||||||
|
const p = rawPeers[id]
|
||||||
|
if (!p || !p.Online) continue
|
||||||
|
const name = (p.HostName || "").trim()
|
||||||
|
if (!name) continue
|
||||||
|
const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
|
||||||
|
peers.push({
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
ip: ips.length > 0 ? ips[0] : "",
|
||||||
|
os: (p.OS || "").toLowerCase(),
|
||||||
|
exitNodeOption: !!p.ExitNodeOption,
|
||||||
|
exitNodeActive: !!p.ExitNode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
peers.sort(function(a, b) { return a.name.localeCompare(b.name) })
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
backendState: backendState,
|
||||||
|
running: backendState === "Running",
|
||||||
|
needsLogin: backendState === "NeedsLogin",
|
||||||
|
authUrl: data.AuthURL || "",
|
||||||
|
selfName: self.HostName || "",
|
||||||
|
selfIp: selfIps.length > 0 ? selfIps[0] : "",
|
||||||
|
tailnetName: (data.CurrentTailnet && data.CurrentTailnet.Name) || "",
|
||||||
|
peers: peers,
|
||||||
|
exitNodePeers: peers.filter(function(p) { return p.exitNodeOption }),
|
||||||
|
activeExitNode: peers.find(function(p) { return p.exitNodeActive }) || null
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return root.defaultStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function osIcon(os) {
|
||||||
|
if (os === "windows") return "desktop_windows"
|
||||||
|
if (os === "macos" || os === "ios") return "desktop_mac"
|
||||||
|
if (os === "linux") return "terminal"
|
||||||
|
return "devices_other"
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTailscale() {
|
||||||
|
if (root.busy) return
|
||||||
|
root.busy = true
|
||||||
|
actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"]
|
||||||
|
actionProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExitNode(peer) {
|
||||||
|
if (root.busy) return
|
||||||
|
root.busy = true
|
||||||
|
exitNodeProc.command = ["tailscale", "set", "--exit-node=" + (peer ? peer.ip : "")]
|
||||||
|
exitNodeProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
if (text) Quickshell.clipboardText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statusProc
|
||||||
|
command: ["tailscale", "status", "--json"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: statusCollector
|
||||||
|
onStreamFinished: root.status = root.parseTailscaleStatus(statusCollector.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: actionProc
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.busy = false
|
||||||
|
if (!statusProc.running) statusProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: exitNodeProc
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.busy = false
|
||||||
|
if (!statusProc.running) statusProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 3000
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
running: panel.visible
|
||||||
|
onTriggered: if (!statusProc.running) statusProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real Tailscale mark: a 3x3 grid of dots where the center dot and
|
||||||
|
// its four edge-neighbors sit at full color (a "+" shape) and the four
|
||||||
|
// corners sit at a distinctly darker shade — not a flat monochrome
|
||||||
|
// glyph. Built from plain circles rather than the brand SVG (which
|
||||||
|
// encodes that dimming as fixed opacity on specific dots, unrelated to
|
||||||
|
// app state) so the full-color dots can still recolor with connection
|
||||||
|
// state.
|
||||||
|
component TailscaleLogo: Item {
|
||||||
|
id: logo
|
||||||
|
property color color: Colors.foreground
|
||||||
|
property real size: 16
|
||||||
|
implicitWidth: size
|
||||||
|
implicitHeight: size
|
||||||
|
|
||||||
|
readonly property real dotSize: logo.size / 4.5
|
||||||
|
readonly property real dotGap: (logo.size - dotSize * 3) / 2
|
||||||
|
// A real darker shade for the corner dots rather than partial
|
||||||
|
// transparency — opacity over a dark bar background nearly
|
||||||
|
// disappears; a distinct tone stays visible at any size.
|
||||||
|
readonly property color dimColor: Qt.darker(logo.color, 1.8)
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
columns: 3
|
||||||
|
rows: 3
|
||||||
|
spacing: logo.dotGap
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: [0, 0, 0, 1, 1, 1, 0, 1, 0]
|
||||||
|
Rectangle {
|
||||||
|
required property int modelData
|
||||||
|
width: logo.dotSize
|
||||||
|
height: logo.dotSize
|
||||||
|
radius: width / 2
|
||||||
|
color: modelData ? '#c9f0f0f0' : logo.dimColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TailscaleLogo {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
size: 16
|
||||||
|
color: root.status.running ? Colors.foreground : Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces ---------------------------------------------------
|
||||||
|
|
||||||
|
component ToggleSwitch: Item {
|
||||||
|
id: toggleRoot
|
||||||
|
property bool checked: false
|
||||||
|
signal toggled()
|
||||||
|
implicitWidth: 34
|
||||||
|
implicitHeight: 18
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
|
opacity: toggleRoot.checked ? 1 : 0.35
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.height - 4
|
||||||
|
height: parent.height - 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||||
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: toggleRoot.toggled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A selectable exit-node row (including the "None" entry that clears
|
||||||
|
// it) — highlighted like the active state elsewhere in this project's
|
||||||
|
// panels, click to activate.
|
||||||
|
component ExitNodeRow: Rectangle {
|
||||||
|
id: exitRow
|
||||||
|
property string label: ""
|
||||||
|
property string subIcon: "lan"
|
||||||
|
property bool active: false
|
||||||
|
signal activate()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
|
color: exitRow.active
|
||||||
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||||
|
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: exitRow.subIcon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: exitRow.label
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: exitRow.active
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: exitRow.active
|
||||||
|
text: "check_circle"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: exitRow.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -------------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
readonly property int contentWidth: 244
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
readonly property int infoLabelWidth: 70
|
||||||
|
readonly property int maxListHeight: 220
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Header
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
TailscaleLogo {
|
||||||
|
size: 30
|
||||||
|
color: label.color
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Tailscale"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
checked: root.status.running
|
||||||
|
onToggled: root.toggleTailscale()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign-in prompt — only ever appears if the daemon reports
|
||||||
|
// NeedsLogin with a real auth URL to open.
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.status.needsLogin && root.status.authUrl !== ""
|
||||||
|
radius: 0
|
||||||
|
color: signInMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 1
|
||||||
|
implicitHeight: signInRow.implicitHeight + 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: signInRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
Text {
|
||||||
|
text: "open_in_new"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Sign in to Tailscale"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: signInMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: Qt.openUrlExternally(root.status.authUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info grid — tailnet, your IP (click to copy), status.
|
||||||
|
GridLayout {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.status.running
|
||||||
|
columns: 2
|
||||||
|
columnSpacing: 12
|
||||||
|
rowSpacing: 5
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Tailnet"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
Layout.preferredWidth: panel.infoLabelWidth
|
||||||
|
Layout.minimumWidth: panel.infoLabelWidth
|
||||||
|
Layout.maximumWidth: panel.infoLabelWidth
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
elide: Text.ElideRight
|
||||||
|
text: root.status.tailnetName || "—"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Your IP"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
Layout.preferredWidth: panel.infoLabelWidth
|
||||||
|
Layout.minimumWidth: panel.infoLabelWidth
|
||||||
|
Layout.maximumWidth: panel.infoLabelWidth
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Text {
|
||||||
|
text: root.status.selfIp || "—"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: root.status.selfIp !== ""
|
||||||
|
text: "content_copy"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 12
|
||||||
|
color: copyMouse.containsMouse ? Colors.primary : Colors.muted
|
||||||
|
MouseArea {
|
||||||
|
id: copyMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -4
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.copyToClipboard(root.status.selfIp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: !root.status.running && !root.status.needsLogin
|
||||||
|
text: "Tailscale is off"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peers + exit-node picker — capped height, scrolls once
|
||||||
|
// the tailnet has more devices than fit, same convention as
|
||||||
|
// NetworkIndicator.qml's Known/Other Networks list.
|
||||||
|
Flickable {
|
||||||
|
id: listFlick
|
||||||
|
width: parent.width
|
||||||
|
visible: root.status.running && listColumn.implicitHeight > 0
|
||||||
|
height: Math.min(listColumn.implicitHeight, panel.maxListHeight)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: listColumn.implicitHeight
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: listColumn
|
||||||
|
width: listFlick.width
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.status.exitNodePeers.length > 0 || root.status.activeExitNode !== null
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Exit Node"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
ExitNodeRow {
|
||||||
|
label: "None"
|
||||||
|
subIcon: "link_off"
|
||||||
|
active: root.status.activeExitNode === null
|
||||||
|
onActivate: root.setExitNode(null)
|
||||||
|
}
|
||||||
|
Repeater {
|
||||||
|
model: root.status.exitNodePeers
|
||||||
|
ExitNodeRow {
|
||||||
|
required property var modelData
|
||||||
|
label: modelData.name
|
||||||
|
subIcon: "shield"
|
||||||
|
active: modelData.exitNodeActive
|
||||||
|
onActivate: root.setExitNode(modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 4
|
||||||
|
visible: root.status.peers.length > 0
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Peers"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.status.peers
|
||||||
|
Rectangle {
|
||||||
|
id: peerRow
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
radius: 0
|
||||||
|
color: peerMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
implicitHeight: peerContent.implicitHeight + 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: peerContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: root.osIcon(peerRow.modelData.os)
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: peerRow.modelData.name
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: peerRow.modelData.ip
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: peerMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: peerMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: root.copyToClipboard(peerRow.modelData.ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user