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

559 lines
21 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
// The Nexthop panel: header verdict, seven tabs, one alive at a time.
//
// All data arrives through files the daemon writes, but never through a
// file handle the shell holds: `nexthop stream` reads live.json (the
// always-current numbers), apps.json and recent.json (the default graphs)
// and feeds them here as lines, and `nexthop query` runs on demand when a
// tab asks for a longer window. The panel never probes anything itself.
Panel {
id: root
moduleName: "io.github.x3me.nexthop"
ipcTarget: "io.github.x3me.nexthop"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
// ---- palette -------------------------------------------------------------
readonly property color fg: bar ? bar.foreground : Color.popups.text
readonly property color dim: Color.muted
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property color okTone: "#9ece6a"
readonly property color warnTone: "#e0af68"
// Right-now congestion, from score.pressure: the fast channel the index
// cannot be. Empty unless it is worth saying.
readonly property string pressureSuffix: {
var p = live && live.pressure ? live.pressure : null
if (!p || !p.state || p.state === "clear") return ""
return " \u00b7 " + p.state.toUpperCase()
}
// Disclosure state for the panel's optional detail blocks. Lives here, not
// on the tab, so moving between tabs does not fold them shut again. Resets
// with the shell, which is the right lifetime for a view preference.
property bool instrumentsExpanded: false
property bool wanDetailOpen: false
property bool underLoadExpanded: false
// A newer version is published. The daemon only ever reports this; the
// panel only ever mentions it. Updating stays with `omarchy plugin update`,
// which shows the diff and asks.
readonly property bool updateAvailable:
!!(live && live.update && live.update.available)
function updateTip() {
return "A newer version of Nexthop is published.\n\n"
+ "omarchy plugin update io.github.x3me.nexthop\n\n"
+ "That shows you what changed before applying it. "
+ "These checks can be turned off in the panel's Setup tab (the cog)."
}
function bandColor(idx) {
if (idx === null || idx === undefined) return dim
if (idx >= 80) return okTone
if (idx >= 50) return warnTone
return Color.urgent
}
// ---- state files ---------------------------------------------------------
readonly property string stateDir: {
var base = Quickshell.env("XDG_STATE_HOME")
if (!base || base.length === 0) base = Quickshell.env("HOME") + "/.local/state"
return base + "/nexthop"
}
// A URL, not a path: percent-encoded, so decode before it is used as a
// filesystem path or a space in the way becomes %20 and cd fails — the
// same form BarWidget and Service already use.
readonly property string pluginDir:
decodeURIComponent(Qt.resolvedUrl(".").toString())
.replace(/^file:\/\//, "").replace(/\/$/, "")
// The panel opens no file of its own. The bar widget that creates it
// owns the single `nexthop stream` reader — a bounded, non-blocking,
// no-follow, regular-file-only read out in a small helper — and the
// panel binds to what it already parsed.
readonly property var live: hostWidget ? hostWidget.live : null
readonly property var recent: hostWidget ? hostWidget.recent : null
readonly property var appsData: hostWidget ? hostWidget.appsData : null
// The bar widget keeps the clock; a snapshot it calls stale is history,
// not a verdict, and the header must not present it as one. The tabs
// keep drawing it — history is what they show anyway.
readonly property bool stale: hostWidget ? hostWidget.stale === true : false
readonly property int staleForS: hostWidget ? hostWidget.staleForS : 0
// recent.json points, trimmed to the slots that actually have data.
readonly property var recentPoints: {
if (!recent || !recent.points) return []
var pts = recent.points
var first = -1
for (var i = 0; i < pts.length; i++) {
if (pts[i].total !== null) { first = i; break }
}
return first < 0 ? [] : pts.slice(first)
}
// ---- daemon config -------------------------------------------------------
// Settings live in shell.json; the daemon can't read that, so mirror the
// keys it cares about into its config file whenever they change.
onSettingsChanged: writeDaemonConfig()
Component.onCompleted: writeDaemonConfig()
function writeDaemonConfig() {
var cfg = {
internetAnchor: setting("internetAnchor", "1.1.1.1"),
probeIntervalMs: setting("probeIntervalMs", 500),
contentSpeed: setting("contentSpeed", true),
contentSpeedIntervalMin: setting("contentSpeedIntervalMin", 60),
peakEngine: setting("peakEngine", "Auto"),
planDownMbps: setting("planDownMbps", 0),
planUpMbps: setting("planUpMbps", 0),
notifyOutage: setting("notifyOutage", true),
updateCheck: setting("updateCheck", true),
meteredCare: setting("meteredCare", true),
historyDays: setting("historyDays", 7),
throughputWindowS: setting("throughputWindowS", 3),
}
// Written atomically via mkstemp + rename — the same discipline the
// daemon's own writers use. A shell redirection here would be a
// truncating, symlink-following write at a predictable path: exactly
// the class the security review flagged on the lock file.
configWriter.command = ["python3", "-c",
"import os, sys, tempfile\n" +
"d = sys.argv[2]\n" +
"os.makedirs(d, mode=0o700, exist_ok=True)\n" +
"fd, tmp = tempfile.mkstemp(dir=d)\n" +
"try:\n" +
" os.write(fd, sys.argv[1].encode())\n" +
" os.close(fd)\n" +
" os.replace(tmp, os.path.join(d, 'config.json'))\n" +
"except BaseException:\n" +
" os.unlink(tmp)\n" +
" raise\n",
JSON.stringify(cfg), stateDir]
configWriter.running = true
}
Process { id: configWriter }
// ---- history queries -----------------------------------------------------
// Tabs ask for a window; results land in `history` tagged by the request.
property var history: null
property string historyWindow: ""
property bool historyLoading: false
function requestHistory(window) {
historyWindow = window
historyLoading = true
historyProc.command = ["sh", "-c",
"cd \"$1\" && exec python3 -m nexthopd.cli query --window \"$2\"",
"sh", pluginDir, window]
historyProc.running = true
}
Process {
id: historyProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.historyLoading = false
try { root.history = JSON.parse(text) } catch (e) { root.history = null }
}
}
}
property var testsData: null
function requestTests() {
testsProc.command = ["sh", "-c",
"cd \"$1\" && exec python3 -m nexthopd.cli tests --limit 24",
"sh", pluginDir]
testsProc.running = true
}
Process {
id: testsProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: { try { root.testsData = JSON.parse(text) } catch (e) {} }
}
}
property var eventsData: null
function requestEvents(window) {
eventsProc.command = ["sh", "-c",
"cd \"$1\" && exec python3 -m nexthopd.cli events --window \"$2\"",
"sh", pluginDir, window || "7d"]
eventsProc.running = true
}
Process {
id: eventsProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: { try { root.eventsData = JSON.parse(text) } catch (e) {} }
}
}
// A phone sharing its data, and whether we are being careful with it.
readonly property var metered: live && live.metered ? live.metered : null
readonly property bool meteredCare: !!(metered && metered.care)
// The peak test is sized to saturate the link for a fixed duration, not
// to a fixed size, so it costs whatever the connection can carry: 285 MB
// measured on a fibre line, tens of megabytes on a phone. It also fires
// from a middle-click on the bar, which is easy to hit by accident. So on
// a metered link the first press arms it and the second runs it — never
// blocked, because measuring the cellular link is sometimes exactly what
// you want.
property bool peakArmed: false
Timer {
id: peakDisarm
interval: 8000
onTriggered: root.peakArmed = false
}
function runPeakTest() {
if (meteredCare && !peakArmed) {
peakArmed = true
peakDisarm.restart()
return
}
peakArmed = false
peakDisarm.stop()
peakProc.command = ["sh", "-c",
"cd \"$1\" && exec python3 -m nexthopd.cli peak", "sh", pluginDir]
peakProc.running = true
}
Process { id: peakProc }
function copyReport(window) {
reportProc.command = ["sh", "-c",
"cd \"$1\" && python3 -m nexthopd.cli report --window \"$2\" | wl-copy",
"sh", pluginDir, window || "24h"]
reportProc.running = true
}
Process { id: reportProc }
// ---- open / close --------------------------------------------------------
function open() {
root.controller.show()
setCenterHoverRevealSuppressed(false)
}
function openFromHotkey() {
root.controller.show()
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
// Hide first, and never the other way round. This used to call the bar
// before hiding, so when 4.0.3 made that call throw, the exception took
// the hide with it: the panel could not be closed by click, hotkey, IPC
// or a popout switch, and a KeyboardPanel's dismissal layer covers the
// whole screen. What the user can see must not depend on a host call we
// do not own.
root.controller.hide()
setCenterHoverRevealSuppressed(false)
}
function toggle() { root.opened ? root.close() : root.openFromHotkey() }
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
// 4.0.3 hands third-party widgets a PluginBarApi facade where this is a
// readonly property fed by a setter; the property still answers `in`, so
// asking whether it exists says nothing about whether it can be written.
function setCenterHoverRevealSuppressed(value) {
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
onOpenedChanged: {
if (opened) Qt.callLater(function() {
if (keyCatcher) keyCatcher.forceActiveFocus()
})
}
// ---- tabs ----------------------------------------------------------------
readonly property var tabNames: ["Overview", "Latency", "Speed", "Wi-Fi",
"Apps", "Events", "Setup"]
// Setup carries a glyph rather than a word: it is a destination you visit
// rarely, and giving it an equal seventh of the strip would cost the six
// tabs that are read constantly. Its name stays in tabNames so the IPC
// route (`showTab Setup`) and the arrow keys treat it like any other.
readonly property int setupTab: tabNames.length - 1
property int currentTab: 0
IpcHandler {
target: root.ipcTarget
function open(): void { root.openFromHotkey() }
function close(): void { root.close() }
function show(): void { root.openFromHotkey() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function speedTest(): void { root.runPeakTest() }
function showTab(name: string): void {
var i = root.tabNames.indexOf(name)
if (i >= 0) root.currentTab = i
root.openFromHotkey()
}
}
// ---- surface -------------------------------------------------------------
KeyboardPanel {
id: panelCard
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panelCard.fittedContentWidth(Style.space(524))
contentHeight: panelCard.fittedContentHeight(bodyColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Keys.onPressed: function(event) {
// Left/right walk the tab strip; 1-6 jump straight to a tab (Setup,
// the seventh, has no digit — it is reached by arrow, click or IPC).
if (event.key === Qt.Key_Left) {
root.currentTab = (root.currentTab + root.tabNames.length - 1) % root.tabNames.length
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.currentTab = (root.currentTab + 1) % root.tabNames.length
event.accepted = true
} else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_6) {
root.currentTab = event.key - Qt.Key_1
event.accepted = true
}
}
Column {
id: bodyColumn
width: parent.width
spacing: Style.space(12)
// ---- header: identity + verdict ---------------------------------
Item {
width: parent.width
height: Math.max(headerLeft.implicitHeight, headerRight.implicitHeight)
Row {
id: headerLeft
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
text: {
var s = root.live ? root.live.state : ""
if (s === "captive") return "󰦝" // nf-md-shield_lock: a gate, not a fault
if (s === "local-down" || s === "wan-down") return "󱚵"
return "󰓅"
}
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
font.family: root.fontFamily
font.pixelSize: Style.fontPx(1.6)
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Style.space(2)
anchors.verticalCenter: parent.verticalCenter
Text {
textFormat: Text.PlainText
text: {
var l = root.live
if (!l) return "Nexthop"
var name = l.link
? (l.link.ssid || l.link.name || l.link.iface || "") : ""
if (name) return name
// The link is gone, so there is no network to name. The
// app's own name sat here reading like a network called
// Nexthop; say what is true instead.
return l.state === "local-down" || l.state === "wan-down"
? "No network" : "Nexthop"
}
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.heading
font.weight: Font.Bold
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: {
var l = root.live
if (!l) return "WAITING FOR DAEMON"
if (root.stale) return "NO DATA FOR " + root.staleForS + " S"
if (l.state === "captive") return "SIGN-IN REQUIRED"
if (l.state === "local-down") return "ROUTER UNREACHABLE"
if (l.state === "wan-down") return "NO INTERNET · ROUTER OK"
// The index is a weakest-link score whose slowest
// component can pin it, so it answers "how has this
// connection been" and not "is it bad right now".
// Queueing answers the second, and only earns a word
// here when it has one to say — clear adds nothing, so
// the common case costs no space at all.
var band = (l.band || "").toUpperCase()
return band + root.pressureSuffix
}
color: root.stale ? root.warnTone
: (root.live && root.live.state !== "online"
? Color.urgent : root.dim)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
}
// A newer version exists. Deliberately the quietest thing
// that can still be found: one dim glyph beside the verdict,
// the command on hover, and nothing that acts on its own.
// Same grammar as the bench glyph on the Overview.
Text {
visible: root.updateAvailable
textFormat: Text.PlainText
text: "󰚰" // nf-md-update
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
anchors.verticalCenter: parent.verticalCenter
HoverHandler { id: updateHover }
PanelToolTip {
visible: updateHover.hovered && root.updateAvailable
text: root.updateTip()
}
}
}
}
}
Item {
id: headerRight
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: indexText.implicitWidth + experienceLabel.implicitWidth
+ Style.space(8)
height: indexText.implicitHeight
Text {
id: indexText
textFormat: Text.PlainText
anchors.right: parent.right
text: root.live && !root.stale && root.live.index !== null
&& root.live.index !== undefined ? String(root.live.index) : "--"
color: root.bandColor(root.live && !root.stale ? root.live.index : null)
font.family: root.fontFamily
font.pixelSize: Style.fontPx(2.4)
font.weight: Font.Bold
}
// On the number's baseline rather than under it — the label is
// one word, and a whole row of header for it was dead space.
Text {
id: experienceLabel
textFormat: Text.PlainText
anchors.right: indexText.left
anchors.rightMargin: Style.space(8)
anchors.baseline: indexText.baseline
text: "EXPERIENCE"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
}
}
}
// ---- tab strip ---------------------------------------------------
Row {
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.tabNames
Rectangle {
id: tabButton
required property string modelData
required property int index
readonly property bool selected: root.currentTab === index
readonly property bool isSetup: index === root.setupTab
readonly property real setupWidth: Style.space(34)
width: isSetup ? setupWidth
: (parent.width - Style.space(6) * (root.tabNames.length - 1)
- setupWidth) / (root.tabNames.length - 1)
height: Style.space(26)
color: selected
? Style.selectedFillFor(root.fg, Color.accent)
: (tabHover.hovered
? Style.hoverFillFor(root.fg, Color.accent)
: Style.normalFillFor(root.fg, Color.accent))
border.width: selected ? 0 : Style.normalBorderWidth
border.color: Style.normalBorderFor(root.fg, Color.accent)
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: tabButton.isSetup ? "󰒓" : tabButton.modelData // nf-md-cog
color: tabButton.selected ? root.fg : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
HoverHandler { id: tabHover }
TapHandler { onTapped: root.currentTab = tabButton.index }
// A glyph does not name itself.
PanelToolTip {
visible: tabButton.isSetup && tabHover.hovered
text: "Settings"
}
}
}
}
PanelSeparator { width: parent.width }
// ---- the selected tab -------------------------------------------
Loader {
width: parent.width
active: root.opened
sourceComponent: [overviewTab, latencyTab, speedTab, wifiTab,
appsTab, eventsTab, settingsTab][root.currentTab]
}
}
}
}
Component { id: overviewTab; OverviewTab { panel: root } }
Component { id: latencyTab; LatencyTab { panel: root } }
Component { id: speedTab; SpeedTab { panel: root } }
Component { id: wifiTab; WifiTab { panel: root } }
Component { id: appsTab; AppsTab { panel: root } }
Component { id: eventsTab; EventsTab { panel: root } }
Component { id: settingsTab; SettingsTab { panel: root } }
}