- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
245 lines
9.3 KiB
QML
245 lines
9.3 KiB
QML
import QtQuick
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
// Nexthop's bar entry: the one always-visible surface. Colour carries the
|
|
// state — the number is detail, the colour is the verdict. Clicking opens
|
|
// the panel; middle-click asks the daemon for a peak speed test.
|
|
BarWidget {
|
|
id: root
|
|
moduleName: "io.github.x3me.nexthop"
|
|
|
|
// ---- live state ----------------------------------------------------------
|
|
//
|
|
// The shell never opens a state file itself. `nexthop stream` performs a
|
|
// bounded, non-blocking, no-follow, regular-file-only read and hands us
|
|
// whole lines, so an oversized file, a FIFO or a symlink swapped in at
|
|
// the predictable path is refused in a small short-lived process instead
|
|
// of allocating or stalling inside the long-lived shell.
|
|
// A URL, not a path: percent-encoded, so a space in the way becomes %20
|
|
// and `cd` fails. Decode before it is used as a filesystem path.
|
|
readonly property string pluginDir:
|
|
decodeURIComponent(Qt.resolvedUrl(".").toString())
|
|
.replace(/^file:\/\//, "").replace(/\/$/, "")
|
|
|
|
// One reader serves the whole widget: the panel below is created by this
|
|
// component and binds to these properties rather than opening anything
|
|
// itself, so the shell runs a single helper, not one per surface.
|
|
property var live: null
|
|
property var recent: null
|
|
property var appsData: null
|
|
|
|
Process {
|
|
id: stateStream
|
|
running: true
|
|
command: ["sh", "-c",
|
|
'cd "$1" && exec python3 -m nexthopd.cli stream live apps recent',
|
|
"sh", root.pluginDir]
|
|
stdout: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: function (line) { root.applyStream(line) }
|
|
}
|
|
onExited: streamRestart.start()
|
|
}
|
|
|
|
// The reader dies with the daemon's package on an update; bring it back.
|
|
Timer {
|
|
id: streamRestart
|
|
interval: 2000
|
|
onTriggered: stateStream.running = true
|
|
}
|
|
|
|
// Each file has a known small size; a line past its bound is not ours.
|
|
// live ~3 KB, apps ~8 KB, recent ~30 KB.
|
|
function applyStream(line) {
|
|
var sp = line ? line.indexOf(" ") : -1
|
|
if (sp <= 0) return
|
|
var key = line.slice(0, sp)
|
|
if (line.length - sp - 1 > (key === "live" ? 262144 : 1048576)) return
|
|
var v
|
|
try { v = JSON.parse(line.slice(sp + 1)) } catch (e) { return }
|
|
if (v === null || v === undefined) return
|
|
if (key === "live") root.live = v
|
|
else if (key === "apps") root.appsData = v
|
|
else if (key === "recent") root.recent = v
|
|
}
|
|
|
|
// ---- freshness -----------------------------------------------------------
|
|
//
|
|
// The stream emits only when live.json changes, so a daemon that has
|
|
// stopped writing — hung, or its reader gone — leaves the last snapshot
|
|
// on screen looking current, and the bar would hold a number from an
|
|
// hour ago as if it were now. The snapshot carries its own timestamp; a
|
|
// clock of our own tells "current" from "last seen". Five seconds is ten
|
|
// missed writes at 2 Hz. After a suspend the first tick can read stale
|
|
// until the next write lands, which is honest for as long as it lasts.
|
|
property real nowS: Date.now() / 1000
|
|
readonly property int staleAfterS: 5
|
|
readonly property bool stale: live !== null && typeof live.t === "number"
|
|
&& (nowS - live.t) > staleAfterS
|
|
readonly property int staleForS: stale ? Math.round(nowS - live.t) : 0
|
|
|
|
Timer {
|
|
interval: 1000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: root.nowS = Date.now() / 1000
|
|
}
|
|
|
|
// ---- derived -------------------------------------------------------------
|
|
readonly property string displayMode: setting("displayMode", "Index")
|
|
readonly property string netState: !live || stale ? "no-daemon" : (live.state || "online")
|
|
readonly property var index: !stale && live && live.index !== null && live.index !== undefined
|
|
? live.index : null
|
|
readonly property var lagNow: !stale && live && live.lag ? live.lag.now : null
|
|
|
|
readonly property color okColor: bar ? bar.foreground : Color.foreground
|
|
// State colours resolve through the theme palette: green/yellow/red exist
|
|
// in every Omarchy theme's colors.toml, surfaced via Color singleton.
|
|
readonly property color stateColor: {
|
|
// A sign-in page is a gate, not a fault: warn, not urgent.
|
|
if (netState === "captive") return "#e0af68"
|
|
if (netState === "local-down" || netState === "wan-down") return Color.urgent
|
|
if (netState === "degraded") return "#e0af68"
|
|
if (index === null) return okColor
|
|
if (index >= 80) return okColor
|
|
if (index >= 50) return "#e0af68"
|
|
return Color.urgent
|
|
}
|
|
|
|
readonly property string glyph: {
|
|
if (netState === "captive") return "" // nf-md-shield_lock: a gate
|
|
if (netState === "local-down") return "" // nf-md-wifi_strength_alert
|
|
if (netState === "wan-down") return "" // nf-md-web_off / broken link
|
|
return "" // nf-md-speedometer
|
|
}
|
|
|
|
readonly property string barText: {
|
|
if (netState === "no-daemon") return glyph
|
|
if (netState === "captive") return glyph
|
|
if (netState === "local-down" || netState === "wan-down") {
|
|
var since = live && live.down_since ? live.down_since : 0
|
|
if (!since) return glyph
|
|
var s = Math.max(0, Math.round(Date.now() / 1000 - since))
|
|
var m = Math.floor(s / 60)
|
|
return glyph + " " + (m > 0 ? m + "m" + (s % 60) + "s" : s + "s")
|
|
}
|
|
if (displayMode === "Icon only") return glyph
|
|
if (displayMode === "Lag")
|
|
return glyph + " " + (lagNow !== null ? Math.round(lagNow) + "ms" : "--")
|
|
return glyph + " " + (index !== null ? index : "--")
|
|
}
|
|
|
|
// ---- panel wiring (same shape contract as weather / vitals) --------------
|
|
function injectPanel() {
|
|
var target = panelLoader.item
|
|
if (!target) return
|
|
if ("bar" in target) target.bar = root.bar
|
|
if ("settings" in target) target.settings = root.settings
|
|
if ("anchorItem" in target) target.anchorItem = button
|
|
if ("hostWidget" in target) target.hostWidget = root
|
|
}
|
|
|
|
function togglePanel() {
|
|
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
|
|
}
|
|
|
|
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
|
|
|
|
function open() {
|
|
if (panelLoader.item && panelLoader.item.openFromHotkey) panelLoader.item.openFromHotkey()
|
|
}
|
|
|
|
function close() {
|
|
if (panelLoader.item && panelLoader.item.close) panelLoader.item.close()
|
|
}
|
|
|
|
readonly property bool popoutSwitchClosing: panelLoader.item
|
|
? panelLoader.item.popoutSwitchClosing === true : false
|
|
|
|
function closeForPopoutSwitch() {
|
|
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
|
|
}
|
|
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
onBarChanged: injectPanel()
|
|
onSettingsChanged: injectPanel()
|
|
|
|
Loader {
|
|
id: panelLoader
|
|
active: true
|
|
source: Qt.resolvedUrl("Panel.qml")
|
|
visible: false
|
|
onLoaded: {
|
|
root.injectPanel()
|
|
Qt.callLater(root.injectPanel)
|
|
}
|
|
}
|
|
|
|
// The path travels as a positional argument, never spliced into the
|
|
// script — the same form every other Process here uses.
|
|
Process {
|
|
id: peakRequest
|
|
command: ["sh", "-c", 'cd "$1" && exec python3 -m nexthopd.cli peak',
|
|
"sh", root.pluginDir]
|
|
}
|
|
|
|
// Why the width is measured here rather than left to the control:
|
|
// BarIconButton is an *icon* button — it pins `fixedWidth` to a
|
|
// single-glyph slot (27 px by default), so its implicitWidth is that slot
|
|
// no matter what text it holds. Our text is variable (" 92", " 1024ms",
|
|
// " 1m3s"), so the bar reserved one icon's worth of space and the text
|
|
// painted straight over the neighbouring widget. Longest during an
|
|
// outage, which is when it was noticed.
|
|
//
|
|
// TextMetrics measures the string against the same font without
|
|
// rendering it, so the width can drive the slot with no binding loop
|
|
// back through the glyph that is being laid out.
|
|
TextMetrics {
|
|
id: textWidth
|
|
font.family: button.fontFamily
|
|
font.pixelSize: button.fontSize
|
|
text: root.barText
|
|
}
|
|
|
|
BarIconButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
text: root.barText
|
|
// Never narrower than a normal icon slot, so an icon-only display mode
|
|
// still lines up with its neighbours.
|
|
slotSize: Math.max(Style.bar.iconSlot,
|
|
Math.ceil(textWidth.advanceWidth) + Style.space(10))
|
|
foreground: root.stateColor
|
|
useActiveColor: false
|
|
tooltipText: {
|
|
if (!root.live) return "Nexthop: waiting for the daemon"
|
|
if (root.stale) return "Nexthop: no data for " + root.staleForS + " s"
|
|
var l = root.live
|
|
var name = l.link && (l.link.ssid || l.link.name) || ""
|
|
var parts = [name, (l.index !== null ? l.index + " " + l.band : "")]
|
|
if (l.local && l.local.p50 !== null && l.wan && l.wan.p50 !== null)
|
|
parts.push("local " + l.local.p50 + " ms · wan " + l.wan.p50 + " ms")
|
|
return parts.filter(function(p) { return p && p.length }).join("\n")
|
|
}
|
|
|
|
onPressed: function(b) {
|
|
if (b === Qt.MiddleButton) {
|
|
// The easiest way to spend a phone's data by accident: a stray
|
|
// middle-click saturating the link. On a metered connection this
|
|
// opens the panel instead, where the button asks twice.
|
|
if (root.live && root.live.metered && root.live.metered.care)
|
|
root.open()
|
|
else
|
|
peakRequest.running = true
|
|
} else {
|
|
root.togglePanel()
|
|
}
|
|
}
|
|
}
|
|
}
|