Files
asepharyana 1cdb82a76f Sync config from arch
- 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
2026-09-23 15:19:12 +07:00

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()
}
}
}
}