Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
# First-party plugins
These plugins ship with Blob and are discovered by the shell at startup.
They use the same `manifest.json` contract as third-party plugins; the
only difference is that the shell flags them with `__isFirstParty: true`.
First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`;
`blob.bar` is the default bar option and becomes inactive only while another
`kind: "bar"` plugin is selected. Services and keep-loaded panels are mounted
at startup; other panels, overlays, and menus are loaded on demand.
User-installed plugins live alongside these conceptually but on disk under
`~/.config/blob/plugins/<plugin-id>/` rather than in this directory.
| Plugin | id | kinds | entry point |
|---------------|---------------------------|-------------------------|---------------------------------------|
| Bar | `blob.bar` | `bar` | `bar/Bar.qml` |
| Image picker | `blob.image-picker` | `overlay` | `image-picker/ImagePicker.qml` |
| Emojis | `blob.emojis` | `overlay` | `emojis/Emojis.qml` |
| Clipboard mgr | `blob.clipboard` | `overlay` | `clipboard/Clipboard.qml` |
| Reminders | `blob.reminders` | `overlay` | `reminders/ReminderFlow.qml` |
| Blob menu | `blob.menu` | `menu`, `bar-widget` | `menu/Menu.qml`, `menu/BarWidget.qml` |
| Notifications | `blob.notifications` | `service` | `notifications/Service.qml` |
| Audio | `blob.audio` | `bar-widget` | `panels/audio/Panel.qml` |
| Bluetooth | `blob.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
| Clock | `blob.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
| Monitor | `blob.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
| Network | `blob.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `blob.power` | `bar-widget` | `panels/power/Panel.qml` |
| Weather | `blob.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
| Media | `blob.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
| Battery | `blob.battery` | `service` | `services/battery/Service.qml` |
| Idle | `blob.idle` | `service` | `services/idle/Service.qml` |
| Night light | `blob.nightlight` | `service` | `services/nightlight/Service.qml` |
| Lock screen | `blob.lock` | `service` | `lock/Service.qml` |
| OSD | `blob.osd` | `panel` | `osd/Osd.qml` |
| Polkit agent | `blob.polkit` | `service` | `polkit/PolkitAgent.qml` |
First-party bar-only widgets also carry manifests next to their QML files,
e.g. `bar/widgets/Workspaces.manifest.json`. Rich popup widgets live in their
own plugin directories, each with its own `manifest.json`.
## Bar
The built-in status bar and default full-bar option. Layout lives in the
top-level `bar:` subtree of `~/.config/blob/shell.json` (with the shell
providing [`config/blob/shell.json`](../../config/blob/shell.json) when
the user has no file). See [`bar/README.md`](bar/README.md) for the widget catalogue
and customization schema.
## Image picker
Fullscreen image-grid selector overlay. Used by `blob-menu-images`
(wallpaper picker) and `blob-theme-switcher` (theme picker) and any
other caller that wants to present a directory of images with previews.
Two ways to drive it:
- Shell-level summon: `blob-shell shell summon blob.image-picker '<jsonPayload>'`.
The payload can carry `imageDirs`, `imageRows`, `selectedImage`,
`selectionFile`, `doneFile`, `showLabels`, `filterable`. Best for
in-shell callers that already speak JSON.
- Direct IPC target: `blob-shell image-selector open <imageDirs> <imageRowsB64> <selectedImage> <selectionFile> <doneFile> <showLabels> <filterable>`.
Positional args; `imageRowsB64` is base64-encoded so embedded newlines /
tabs survive the bash argv handoff. This is what `blob-menu-images`
uses. Colors come from the central shell theme singleton; there is no
per-call override surface.
The selection round-trip remains file-based: callers create a
`selection_file` and `done_file` (both `mktemp`), pass the paths, and
poll `done_file` for existence. The plugin writes the chosen path into
`selection_file` and touches `done_file` when it's done. `cancel` IPC
clears it without writing a selection.
The plugin has `keepLoaded: true` so the layer-shell window survives
between summons within a single shell session.
## Lock screen
Session-lock surface using Quickshell's native `WlSessionLock` and two
separate PAM services: `blob-lock-password` for password auth and,
only when fingerprints are enrolled, `blob-lock-fingerprint` for
fingerprint auth. It mirrors the previous lock screen field dimensions,
colors, blurred wallpaper, placeholder, and Hyprland-driven corners.
The plugin sets `keepLoaded: true` so a plugin hot-reload (for example
an installed bar widget changing on disk) does not destroy the lock
client while Hyprland still holds the session lock.
## Polkit agent
Theme-aware authentication dialog for privileged actions. It uses
Quickshell's native `Quickshell.Services.Polkit.PolkitAgent` backend and
runs inside the long-lived `blob-shell` process, replacing the old
`polkit-gnome-authentication-agent-1` autostart.
## Blob menu
Quickshell-powered Blob command menu.
The menu UI lives in `menu/Menu.qml` as a first-party `menu` plugin and is
summoned through the shell (`blob-shell shell summon blob.menu ...`),
so it shares the long-running `blob-shell` process instead of starting a
second Quickshell instance.
The menu definition lives outside the shell host code:
- defaults: `default/blob/blob-menu.jsonc`
- user extensions: `~/.config/blob/extensions/blob-menu.jsonc`
The shell parses both JSONC files at startup (with `watchChanges: true`
so edits take effect without a restart), evaluates `when:` / `checked:`
bash expressions in a single batched subprocess, and executes the
selected `action:` string directly via `Quickshell.execDetached`. The
long-running shell process keeps the parsed menu in memory, so the
keybind → IPC → visible path costs ~30ms cold.
## Coming soon
- `blob.theme-switcher` — folds theme switching into the shell.
+323
View File
@@ -0,0 +1,323 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
import qs.Ui
Item {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
property string currentBackground: ""
property string displayedBackground: ""
property string incomingBackground: ""
property string oldBackground: ""
property bool finishingTransition: false
property int backgroundVersion: 0
property int revealStartedVersion: -1
property int pendingThemeVersion: -1
property string pendingColorsRaw: ""
property string pendingShellRaw: ""
property real revealProgress: 1
function imageUrl(path) {
return Util.fileUrl(path)
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function setBackground(path, instant) {
transitionBackground("", path, path, instant, false)
}
function transitionBackground(fromPath, path, finalPath, instant, force) {
path = String(path || "").trim()
finalPath = String(finalPath || path).trim()
fromPath = String(fromPath || "").trim()
if (!path || (!force && finalPath === currentBackground)) return
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
revealAnimation.stop()
finishingTransition = false
if (instant || !displayedBackground) {
oldBackground = ""
incomingBackground = ""
displayedBackground = path
revealProgress = 1
return
}
oldBackground = fromPath || displayedBackground
incomingBackground = path
revealProgress = 0
}
function setPendingTheme(colorsB64, shellB64) {
pendingColorsRaw = Util.decodeBase64(colorsB64)
pendingShellRaw = Util.decodeBase64(shellB64)
pendingThemeVersion = backgroundVersion
pendingThemeFallbackTimer.restart()
}
function applyPendingTheme() {
// Background polling can advance backgroundVersion while a theme switch is
// pending; the latest theme payload should still apply.
if (pendingThemeVersion < 0) return
pendingThemeFallbackTimer.stop()
Color.loadColors(pendingColorsRaw)
// Color.loadShell also refreshes Style so the type scale flips with the
// background reveal instead of waiting for a separate reload path.
Color.loadShell(pendingShellRaw)
Style.scheduleRefresh()
pendingThemeVersion = -1
pendingColorsRaw = ""
pendingShellRaw = ""
}
function transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64) {
transitionBackground(fromPath, path, finalPath, false, true)
setPendingTheme(colorsB64, shellB64)
if (!incomingBackground || revealProgress >= 1) applyPendingTheme()
}
function startReveal(panel) {
if (!incomingBackground) return
panel.maskReady = true
if (revealStartedVersion === backgroundVersion) return
revealStartedVersion = backgroundVersion
applyPendingTheme()
revealAnimation.restart()
}
function openSelector() {
if (!bgSwitchProc.running) bgSwitchProc.running = true
}
function openThemeSwitcher() {
if (!themeSwitchProc.running) themeSwitchProc.running = true
}
Process {
id: bgSwitchProc
command: ["bash", "-c", "background=$(blob-bg-switcher); [[ -n $background ]] && blob-bg-set \"$background\""]
onExited: root.refreshBackground()
}
Process {
id: themeSwitchProc
command: ["bash", "-c", "theme=$(blob-theme-switcher); [[ -n $theme ]] && blob-theme-set \"$theme\" >/dev/null 2>&1 &"]
onExited: root.refreshBackground()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
onStreamFinished: root.setBackground(String(text || "").trim(), false)
}
}
IpcHandler {
target: "background"
function refresh(): void {
root.refreshBackground()
}
function set(path: string): void {
root.setBackground(path, false)
}
function setInstant(path: string): void {
root.setBackground(path, true)
}
function transition(fromPath: string, path: string): void {
root.transitionBackground(fromPath, path, path, false, false)
}
function themeTransition(fromPath: string, path: string, finalPath: string, colorsB64: string, shellB64: string): void {
root.transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64)
}
}
Timer {
id: pendingThemeFallbackTimer
interval: 300
repeat: false
onTriggered: root.applyPendingTheme()
}
NumberAnimation {
id: revealAnimation
target: root
property: "revealProgress"
from: 0
to: 1
duration: 420
easing.type: Easing.InOutCubic
onFinished: {
if (root.incomingBackground) {
root.displayedBackground = root.currentBackground || root.incomingBackground
root.finishingTransition = true
}
root.revealProgress = 1
}
}
Component.onCompleted: refreshBackground()
Variants {
model: Quickshell.screens
PanelWindow {
id: panel
required property var modelData
screen: modelData
visible: !remapGuard.remapping
anchors { top: true; bottom: true; left: true; right: true }
ScreenMoveRemap {
id: remapGuard
window: panel
}
color: "transparent"
// Keep render updates enabled. The background layer has been observed to
// lose its committed buffer while parked with updatesEnabled=false,
// leaving a black desktop until blob-shell is restarted. The wallpaper
// itself is static, so this favors correctness over a small render-loop
// optimization.
updatesEnabled: true
property bool maskReady: false
function maybeStartReveal() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
root.startReveal(panel)
})
}
WlrLayershell.namespace: "blob-background"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Image {
id: base
anchors.fill: parent
source: root.imageUrl(root.displayedBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
onStatusChanged: {
if (status === Image.Ready && root.finishingTransition) {
root.incomingBackground = ""
root.oldBackground = ""
root.finishingTransition = false
}
}
}
Image {
id: oldFrame
anchors.fill: parent
source: root.imageUrl(root.oldBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
visible: root.oldBackground !== "" && root.revealProgress < 1
onStatusChanged: panel.maybeStartReveal()
}
Item {
id: incomingLayer
anchors.fill: parent
visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: revealMask
maskThresholdMin: 0.5
maskSpreadAtMin: 0.02
}
Image {
id: incomingFrame
anchors.fill: parent
source: root.imageUrl(root.incomingBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
smooth: true
mipmap: true
onStatusChanged: panel.maybeStartReveal()
}
}
Item {
id: revealMask
anchors.fill: parent
visible: false
layer.enabled: true
readonly property real slant: -0.18
readonly property real centerTop: width / 2 - slant * height / 2
readonly property real centerBottom: width / 2 + slant * height / 2
readonly property real reach: width / 2 + Math.abs(slant) * height / 2 + 4
readonly property real spread: reach * root.revealProgress
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: revealMask.centerTop - revealMask.spread; startY: 0
PathLine { x: revealMask.centerTop + revealMask.spread; y: 0 }
PathLine { x: revealMask.centerBottom + revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerBottom - revealMask.spread; y: revealMask.height }
PathLine { x: revealMask.centerTop - revealMask.spread; y: 0 }
}
}
}
Connections {
target: root
function onIncomingBackgroundChanged() {
panel.maskReady = false
panel.maybeStartReveal()
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onDoubleClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.openThemeSwitcher()
else root.openSelector()
mouse.accepted = true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.background",
"name": "Background",
"version": "1.0.0",
"author": "Blob",
"description": "Desktop background renderer with click handling and transitions",
"kinds": [
"service"
],
"entryPoints": {
"service": "Background.qml"
}
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizePosition(value) {
var next = String(value || "").trim()
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
}
function entrySettings(entry) {
if (!isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function pinTrayToInner(entries, section) {
var trayEntry = null
var result = []
var values = Array.isArray(entries) ? entries : []
for (var i = 0; i < values.length; i++) {
if (entryId(values[i]) === "blob.tray") trayEntry = values[i]
else result.push(values[i])
}
if (trayEntry) {
if (section === "right") result.unshift(trayEntry)
else result.push(trayEntry)
}
return result
}
function moduleString(entry, key, fallback) {
var settings = entrySettings(entry)
var value = settings[key]
return value === undefined || value === null ? fallback : String(value)
}
function entryIndex(entries, name) {
if (!Array.isArray(entries)) return -1
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === name) return i
}
return -1
}
function entriesBefore(entries, name) {
var index = entryIndex(entries, name)
return index <= 0 ? [] : entries.slice(0, index)
}
function entriesAfter(entries, name) {
var index = entryIndex(entries, name)
return index === -1 ? [] : entries.slice(index + 1)
}
// A shell.json write that only changes inline widget settings (the battery
// percentage toggle, a clock format change) must not rebuild the bar.
// Compare two normalized layouts: when the structure is unchanged — same
// entry ids in the same order per region — return the settings-only changes
// as {region, index, entry}. Return null when the change is structural, or
// touches an entry a live settings push cannot safely reach: custom modules
// read their entry directly rather than an injected settings property, and
// a duplicated id makes the push ambiguous.
function inlineSettingsDelta(current, next) {
if (!isPlainObject(current) || !isPlainObject(next)) return null
var regions = ["left", "center", "right"]
var counts = {}
for (var r = 0; r < regions.length; r++) {
var entries = Array.isArray(next[regions[r]]) ? next[regions[r]] : []
for (var i = 0; i < entries.length; i++) {
var id = entryId(entries[i])
counts[id] = (counts[id] || 0) + 1
}
}
var changes = []
for (var s = 0; s < regions.length; s++) {
var region = regions[s]
var a = Array.isArray(current[region]) ? current[region] : []
var b = Array.isArray(next[region]) ? next[region] : []
if (a.length !== b.length) return null
for (var j = 0; j < a.length; j++) {
if (entryId(a[j]) !== entryId(b[j])) return null
if (JSON.stringify(a[j]) === JSON.stringify(b[j])) continue
if (customModuleType(a[j]) || customModuleType(b[j])) return null
if (counts[entryId(b[j])] > 1) return null
changes.push({ region: region, index: j, entry: b[j] })
}
}
return changes
}
function expandPath(value, home) {
var path = String(value || "")
if (path === "") return ""
if (path.indexOf("~/") === 0) return home + path.substring(1)
if (path.indexOf("$HOME/") === 0) return home + path.substring(5)
return path
}
function customModuleSafeName(name) {
var value = String(name || "")
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
}
function customModuleType(entry) {
var settings = entrySettings(entry)
var type = String(settings.type || "")
if (type) return type
if (settings.exec) return "command"
if (settings.source) return "qml"
return ""
}
function customModulePath(entry, home, configDir) {
var settings = entrySettings(entry)
var name = entryId(entry)
var source = settings.source ? expandPath(settings.source, home) : ""
if (!source && customModuleSafeName(name))
source = String(configDir || "") + "/bar/modules/" + String(name) + ".qml"
return source
}
// A center module is mounted twice once an anchor is set: the copy that is
// actually drawn, and a zero-size placeholder holding its place in the flow
// beside the anchor. Panel routing has to pick the drawn one — it is the only
// one that can anchor a popup, carry the open-panel mark, or be found again
// by switchPanelFrom — and fall back to the placeholder only when nothing is
// on screen. The order the two are registered in is not stable across a live
// bar reconfiguration, so picking the first match is not good enough.
function isDrawnSlot(slot) {
return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0
}
function pickDrawnSlot(slots) {
var placeholder = null
var list = slots || []
for (var i = 0; i < list.length; i++) {
if (!list[i]) continue
if (isDrawnSlot(list[i])) return list[i]
if (!placeholder) placeholder = list[i]
}
return placeholder
}
// A bar surface is built per monitor, so a panel hotkey has several live
// copies of the same widget to route to, and the panel opens on whichever
// monitor's copy answers. Candidates are `{ slot, screenName, opened }`.
//
// An open copy wins first: hide and toggle have to reach the panel the user
// can actually see, wherever it was opened from. Otherwise the focused
// monitor's copy wins, so a summon lands where the user is working instead of
// on whichever output registered its slot first. Neither narrowing applies on
// a single monitor, or when the focused output has no bar of its own.
function pickPanelSlot(candidates, focusedScreen) {
var rows = Array.isArray(candidates) ? candidates : []
var pool = rows.filter(function(row) { return row && row.opened === true })
if (pool.length === 0) pool = rows.filter(function(row) { return !!row })
var focused = String(focusedScreen || "")
if (focused) {
var onFocused = pool.filter(function(row) { return row.screenName === focused })
if (onFocused.length > 0) pool = onFocused
}
return pickDrawnSlot(pool.map(function(row) { return row.slot }))
}
// Resolve a pointer anywhere along the bar to the closest insertion edge.
// Requiring the pointer to sit inside another widget makes the empty space
// around a centered group a dead zone, even though it visually reads as the
// most natural place to drop.
function nearestDropTarget(candidates, point, vertical) {
var rows = Array.isArray(candidates) ? candidates : []
var axis = vertical ? Number(point && point.y) : Number(point && point.x)
if (!isFinite(axis)) return null
var best = null
var bestDistance = Infinity
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
if (!row || !row.slot) continue
var start = Number(vertical ? row.y : row.x)
var size = Number(vertical ? row.height : row.width)
if (!isFinite(start) || !isFinite(size) || size <= 0) continue
var beforeDistance = Math.abs(axis - start)
var afterDistance = Math.abs(axis - (start + size))
var after = afterDistance < beforeDistance
var distance = after ? afterDistance : beforeDistance
if (distance < bestDistance) {
best = { slot: row.slot, after: after }
bestDistance = distance
}
}
return best
}
if (typeof module !== "undefined") {
module.exports = {
isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
nearestDropTarget: nearestDropTarget,
normalizePosition: normalizePosition,
entrySettings: entrySettings,
entryId: entryId,
pinTrayToInner: pinTrayToInner,
moduleString: moduleString,
entryIndex: entryIndex,
entriesBefore: entriesBefore,
entriesAfter: entriesAfter,
inlineSettingsDelta: inlineSettingsDelta,
expandPath: expandPath,
customModuleSafeName: customModuleSafeName,
customModuleType: customModuleType,
customModulePath: customModulePath
}
}
+181
View File
@@ -0,0 +1,181 @@
# Blob bar
This is the Quickshell implementation of the Blob status bar. It is
shipped as a first-party plugin of [`blob-shell`](../../README.md), the
long-running shell host. The bar is mounted at startup and lives inside
the shell for its whole session.
- `manifest.json` declares the plugin (`id: blob.bar`, `kind: bar`) and points at `Bar.qml` as the entry point.
- `Bar.qml` is Blob-owned bar engine code, loaded by the blob-shell host. Users should not edit it directly.
- `widgets/` holds simple first-party bar widgets with sibling manifests.
- Feature plugins such as `../panels/audio/`, `../panels/network/`, and `../panels/power/` provide richer popup bar plugins.
- The bar receives its config from the host shell as a `barConfig` property; the host loads it from `~/.config/blob/shell.json` (or `config/blob/shell.json` when the user has no file).
- `blob bar position` updates only the user shell.json file.
## Customizing
The bar config lives under the `bar:` key of [`~/.config/blob/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`config/blob/shell.json`](../../../config/blob/shell.json). Once you customize anything via the bar gestures, `blob bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge.
The bar is configured directly on the bar itself: drag empty bar space (or click-and-hold) to move the bar to another screen edge, double-left-click empty center-bar space to toggle transparency, and drag widgets to reorder them. The `blob bar position`, `blob bar transparent`, `blob bar move`, and `blob bar set` commands do the same from scripts. Enable or disable widgets with `blob plugin enable` and `blob plugin disable` (widget ids come from `blob plugin list`).
Example `shell.json` (bar subtree only shown):
```json
{
"version": 1,
"bar": {
"position": "top",
"transparent": false,
"centerAnchor": "blob.clock",
"layout": {
"left": [
{ "id": "blob.menu" },
{ "id": "blob.spacer", "size": 12 },
{ "id": "blob.workspaces" }
],
"center": [
{ "id": "blob.media" },
{ "id": "blob.clock", "format": "HH:mm" }
],
"right": [
{ "id": "blob.audio" },
{ "id": "blob.power" }
]
}
}
}
```
`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. Set to an empty string to disable anchoring (the center list is centered as a group).
## Module catalogue
### First-party interactive widgets
| Name | What it does | Interactions |
|---|---|---|
| `blob.menu` | Blob menu launcher | left = menu · right = terminal |
| `blob.workspaces` | Hyprland workspace switcher | left = focus workspace |
| `blob.clock` | Date/time label + popup with a month grid, ISO week numbers, and month stepping | left = popup · right = cycle label format · middle = timezone selector |
| `blob.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup |
| `blob.indicators` | Manual state indicators | left = indicator action |
| `blob.system-update` | Available update indicator | left = update |
| `blob.tray` | System tray | hover = reveal drawer · right on chevron = manage |
| `blob.weather` | Weather icon + popup with forecast | left = popup · right = full notification |
| `blob.microphone` | Mic icon + scroll volume | left = mute toggle · middle = audio panel · scroll = source volume |
| `blob.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
| `blob.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup |
| `blob.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup · right = toggle percentage |
| `blob.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio |
| `blob.monitor` | Brightness and laptop display controls | left = popup |
The `blob.indicators` widget loads individual bar indicators from `indicators/`. Omit `items` (or set it to an empty array) to show all indicators in the default order, or set `items` to a subset such as `["Dnd", "Reminder", "NightLight"]`. Set `alwaysShow` to `true` to keep inactive indicators visible instead of revealing them only on hover. Multiple `blob.indicators` instances are allowed, so different sections can show different subsets.
## Orientation
All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups anchor on the side opposite the bar edge, sliding into the workspace. Vertical bars use 28px width; widgets that show text fall back to compact icon-only forms (e.g. `media` hides its scrolling label).
## Custom user modules
The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. Both still go under `bar.layout.<section>` in `shell.json`.
Command module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "blob.tray" },
{ "id": "vpn", "type": "command", "exec": "~/.config/blob/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" },
{ "id": "blob.audio" }
]
}
}
}
```
The command may print plain text or Waybar-style JSON, for example:
```json
{"text":"󰌆","tooltip":"Work VPN","class":"active"}
```
QML module:
```json
{
"version": 1,
"bar": {
"layout": {
"right": [
{ "id": "gpu", "type": "qml" },
{ "id": "blob.audio" }
]
}
}
}
```
Then create `~/.config/blob/bar/modules/gpu.qml`. If you want to store it elsewhere, add a `source` path.
Custom QML modules should be an `Item` with `implicitWidth` and `implicitHeight`. They may optionally define these properties, which the bar fills after loading:
```qml
import QtQuick
Item {
property var bar
property string moduleName
property var settings
implicitWidth: 28
implicitHeight: bar ? bar.barSize : 26
Text {
anchors.centerIn: parent
text: "GPU"
color: bar ? bar.foreground : "white"
font.family: bar ? bar.fontFamily : "monospace"
font.pixelSize: 12
}
MouseArea {
anchors.fill: parent
onClicked: if (bar) bar.run("blob-launch-or-focus-tui btop")
}
}
```
## Bar properties available to widgets
Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (object) injected at load time. The bar exposes:
- `bar.foreground`, `bar.background`, `bar.urgent` — theme colors (live-updated)
- `bar.fontFamily` — current monospace family
- `bar.position``"top" | "bottom" | "left" | "right"`
- `bar.vertical` — boolean shortcut
- `bar.barSize` — 26 horizontal / 28 vertical
- `bar.run(command)` — fire-and-forget bash exec
- `bar.shellQuote(value)` — safe shell-quote a string
- `bar.showTooltip(target, text)` / `bar.hideTooltip(target)` — shared tooltip popup
- `bar.requestPopout(owner)` / `bar.releasePopout(owner)` — one-popup-at-a-time coordinator
First-party bar widgets are manifest-backed just like third-party widgets.
Simple widgets carry sibling manifests such as `widgets/Workspaces.manifest.json`;
richer popup plugins live in feature directories such as `../panels/audio/`,
and `../panels/network/`; and feature plugins such as
`blob.menu` and `blob.media` declare their bar-widget entry points in their own
`manifest.json`. Bar layout ids are namespaced, e.g. `blob.audio`,
`blob.network`, and `blob.clock`. Older UpperCamelCase ids such as
`AudioPanel` and `Clock` are migrated forward; new configs should use the
namespaced ids.
Third-party widgets ship as separate plugins under
`~/.config/blob/plugins/<plugin-id>/` with their own `manifest.json`
declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See
[../../README.md](../../README.md) for the manifest schema. Rescan, enable,
and place third-party plugins with `blob-shell shell rescanPlugins`,
`blob plugin enable`, and `blob bar move`.
@@ -0,0 +1,38 @@
import QtQuick
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property string state: "idle"
property string icon: ""
active: state === "recording"
activeText: icon
inactiveText: "󰍬"
activeTooltipText: state
inactiveTooltipText: "Dictate"
function update(raw) {
var data = extractData(raw)
state = String(data.alt || data.class || "idle")
if (state === "recording") icon = "󰍬"
else if (state === "transcribing") icon = "󰔟"
else icon = ""
}
Process {
command: ["bash", "-c", "blob-voxtype-status"]
running: true
stdout: SplitParser {
onRead: function(data) { root.update(data) }
}
}
onPressed: function() {
if (!root.bar) return
root.bar.run("blob-voxtype-config")
}
}
+22
View File
@@ -0,0 +1,22 @@
import QtQuick
import qs.Commons
import qs.Ui
BarIndicator {
id: root
readonly property var notificationService: bar?.shell?.firstPartyServiceFor("blob.notifications")
readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false
active: dnd
activeText: "󰂛"
inactiveText: "󰂛"
activeTooltipText: "Allow Notifications"
inactiveTooltipText: "Silence Notifications"
onPressed: function() {
if (root.notificationService) {
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
}
}
@@ -0,0 +1,20 @@
import QtQuick
import qs.Ui
BarIndicator {
id: root
readonly property var nightlightService: bar?.shell?.firstPartyServiceFor("blob.nightlight")
active: nightlightService ? nightlightService.enabled : false
activeText: "󰔎"
inactiveText: "󰔎"
activeTooltipText: "Day Light"
inactiveTooltipText: "Night Light"
function toggle() {
if (root.nightlightService) root.nightlightService.setNightlight(!root.active)
}
onPressed: function() { root.toggle() }
}
+59
View File
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property int reminderCount: 0
property string tooltip: ""
active: reminderCount > 0
activeText: "󰢌"
inactiveText: "󰢌"
activeTooltipText: tooltip
inactiveTooltipText: tooltip
function refresh() {
if (!jsonProc.running) jsonProc.running = true
}
function openReminderFlow() {
Quickshell.execDetached(["blob-reminder", "-i"])
}
function update(raw) {
var data = extractData(raw)
reminderCount = Number(data.count || 0)
tooltip = String(data.tooltip || "")
}
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Process {
id: jsonProc
command: ["blob-reminder", "show", "--json"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.update(text)
}
onExited: function(exitCode) {
if (exitCode !== 0) {
root.reminderCount = 0
root.tooltip = ""
}
}
}
onPressed: function() {
if (root.reminderCount > 0) Quickshell.execDetached(["blob-reminder", "show"])
else root.openReminderFlow()
}
}
@@ -0,0 +1,43 @@
import QtQuick
import Quickshell.Io
import qs.Ui
BarIndicator {
id: root
property bool recording: false
active: recording
activeText: "󰻂"
inactiveText: "󰻂"
activeTooltipText: "Stop recording"
inactiveTooltipText: "Screen Recording"
function refresh() {
if (!root.bar || statusProc.running) return
statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"]
statusProc.running = true
}
onBarChanged: refresh()
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Process {
id: statusProc
onExited: function(exitCode) {
root.recording = exitCode === 0
}
}
onPressed: function() {
if (root.bar) {
root.bar.run(root.recording ? "blob-capture-record --stop-recording" : "blob-menu toggle trigger.capture.screenrecord")
}
}
}
@@ -0,0 +1,20 @@
import QtQuick
import qs.Ui
BarIndicator {
id: root
readonly property var idleService: bar?.shell?.firstPartyServiceFor("blob.idle")
active: idleService ? idleService.stayAwake : false
activeText: "󰅶"
inactiveText: "󰅶"
activeTooltipText: "Allow Idle Lock & Screensaver"
inactiveTooltipText: "Stay Awake"
function toggle() {
if (root.idleService) root.idleService.setIdleEnabled(root.active)
}
onPressed: function() { root.toggle() }
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.bar",
"name": "Bar",
"version": "1.0.0",
"author": "Blob",
"description": "Status bar with widgets",
"kinds": [
"bar"
],
"entryPoints": {
"bar": "Bar.qml"
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.active-window",
"name": "Active window",
"version": "1.0.0",
"author": "Blob",
"description": "Title of the focused window",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "ActiveWindow.qml"
},
"barWidget": {
"displayName": "Active window",
"description": "Title of the focused window",
"category": "Compositor",
"allowMultiple": false,
"defaultSection": "left"
}
}
@@ -0,0 +1,64 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.active-window"
readonly property var toplevel: ToplevelManager.activeToplevel
readonly property string title: toplevel ? (toplevel.title || toplevel.appId || "") : ""
readonly property int maxLabelWidth: Number(setting("maxWidth", 280))
visible: title !== "" && !vertical
implicitWidth: visible ? Math.min(maxLabelWidth, labelText.implicitWidth) + Style.spacing.controlPaddingX * 2 : 0
implicitHeight: barSize
Behavior on implicitWidth {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
Item {
anchors.fill: parent
anchors.leftMargin: Style.space(8)
anchors.rightMargin: Style.space(8)
clip: true
Text {
id: labelText
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: parent.width
text: root.title
color: root.bar ? root.bar.barForeground : Color.foreground
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.font.body
elide: Text.ElideRight
opacity: 0.85
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) {
if (!root.toplevel) return
if (mouse.button === Qt.MiddleButton) {
root.toplevel.close()
} else if (mouse.button === Qt.RightButton) {
root.toplevel.close()
} else {
root.toplevel.activate()
}
}
onEntered: if (root.bar) root.bar.showTooltip(root, root.title)
onExited: if (root.bar) root.bar.hideTooltip(root)
}
}
@@ -0,0 +1,78 @@
{
"schemaVersion": 1,
"id": "blob.indicators",
"name": "Indicators",
"version": "1.0.0",
"author": "Blob",
"description": "Manual state indicators",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Indicators.qml"
},
"barWidget": {
"displayName": "Indicators",
"description": "Manual state indicators",
"category": "Status",
"allowMultiple": true,
"schema": [
{
"key": "items",
"type": "multiselect",
"label": "Indicators",
"description": "Choose which indicators this widget instance should show. Leave empty to show all indicators.",
"noSelectionText": "All indicators",
"placeholderText": "Search indicators...",
"emptyText": "No indicators",
"options": [
{
"value": "Dictation",
"label": "Dictation",
"description": "Voice typing status"
},
{
"value": "ScreenRecording",
"label": "Screen recording",
"description": "GPU screen recorder status"
},
{
"value": "Reminder",
"label": "Reminder",
"description": "Queued reminder status"
},
{
"value": "NightLight",
"label": "Night light",
"description": "Blue-light filter"
},
{
"value": "Dnd",
"label": "Do not disturb",
"description": "Notification silencing"
},
{
"value": "StayAwake",
"label": "Stay awake",
"description": "Idle lock and screensaver override"
}
]
},
{
"key": "alwaysShow",
"type": "boolean",
"label": "Always Show",
"description": "Show inactive indicators without waiting for hover.",
"defaultValue": false
}
]
},
"blob": {
"clonePaths": [
{
"source": "../indicators",
"target": "indicators"
}
]
}
}
+471
View File
@@ -0,0 +1,471 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.indicators"
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: []
property var indicatorActiveStates: ({})
property bool indicatorAreaHovered: false
property bool indicatorItemHovered: false
readonly property bool alwaysShowIndicators: setting("alwaysShow", false) === true
readonly property bool revealInactiveIndicators: alwaysShowIndicators || indicatorAreaHovered || indicatorItemHovered || (bar && bar.centerSectionRevealHeld === true && bar.centerHoverRevealSuppressed !== true)
signal refreshRequested()
ListModel { id: activeIndicatorModel }
function entryId(entry) {
if (typeof entry === "string") return entry
if (Util.isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function entrySettings(entry) {
if (!Util.isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
}
function indicatorEntriesFromSettings(settings) {
var source = defaultIndicatorEntries
if (settings.items && typeof settings.items.length === "number" && settings.items.length > 0) source = settings.items
else if (settings.indicators && typeof settings.indicators.length === "number" && settings.indicators.length > 0) source = settings.indicators
var result = []
for (var i = 0; i < source.length; i++) {
var item = source[i]
if (typeof item !== "string" && item !== null && typeof item === "object") {
try {
item = JSON.parse(JSON.stringify(item))
} catch (error) {
}
}
var id = entryId(item)
if (id !== "") result.push(item)
}
return result
}
function setIndicatorAreaHovered(hovered) {
indicatorAreaHovered = hovered
if (hovered) indicatorHideTimer.stop()
else indicatorHideTimer.restart()
}
function setIndicatorItemHovered(hovered) {
if (hovered) {
indicatorItemHovered = true
indicatorHideTimer.stop()
} else {
indicatorHideTimer.restart()
}
}
function hasIndicatorId(id) {
for (var i = 0; i < indicatorEntries.length; i++) {
if (entryId(indicatorEntries[i]) === id) return true
}
return false
}
function entryForId(id) {
for (var i = 0; i < indicatorEntries.length; i++) {
var entry = indicatorEntries[i]
if (entryId(entry) === id) return entry
}
return { id: id }
}
function activeModelIndex(id) {
for (var i = 0; i < activeIndicatorModel.count; i++) {
if (activeIndicatorModel.get(i).activeId === id) return i
}
return -1
}
function copyActiveStates() {
var states = {}
for (var id in indicatorActiveStates) {
if (indicatorActiveStates[id] === true) states[id] = true
}
return states
}
function orderedActiveIds(states, preferredOrder) {
var ids = []
for (var i = 0; i < preferredOrder.length; i++) {
var id = preferredOrder[i]
if (ids.indexOf(id) === -1 && hasIndicatorId(id) && states[id] === true) ids.push(id)
}
return ids
}
function syncActiveIndicatorModel() {
for (var i = activeIndicatorModel.count - 1; i >= 0; i--) {
if (activeIndicatorIds.indexOf(activeIndicatorModel.get(i).activeId) === -1)
activeIndicatorModel.remove(i)
}
for (var j = 0; j < activeIndicatorIds.length; j++) {
var id = activeIndicatorIds[j]
var index = activeModelIndex(id)
if (index === -1) activeIndicatorModel.insert(j, { activeId: id })
else if (index !== j) activeIndicatorModel.move(index, j, 1)
}
}
function setIndicatorActive(entry, active) {
var id = entryId(entry)
if (id === "") return
var states = copyActiveStates()
if (active) states[id] = true
else delete states[id]
indicatorActiveStates = states
var ids = orderedActiveIds(states, activeIndicatorIds)
// The active block sits closest to the clock, so newcomers go on the far
// side of it. Appending would shove everything already showing sideways.
if (active && ids.indexOf(id) === -1 && hasIndicatorId(id)) ids.unshift(id)
activeIndicatorIds = ids
syncActiveIndicatorModel()
}
function syncActiveIndicatorOrder() {
activeIndicatorIds = orderedActiveIds(indicatorActiveStates, activeIndicatorIds)
syncActiveIndicatorModel()
}
function refresh() { root.refreshRequested() }
onIndicatorEntriesChanged: syncActiveIndicatorOrder()
implicitWidth: root.vertical
? Math.max(activeVerticalBlock.implicitWidth, inactiveVerticalArea.implicitWidth)
: activeHorizontalBlock.implicitWidth + inactiveHorizontalArea.implicitWidth
implicitHeight: root.vertical
? activeVerticalBlock.implicitHeight + inactiveVerticalArea.implicitHeight
: Math.max(activeHorizontalBlock.implicitHeight, inactiveHorizontalArea.implicitHeight)
IpcHandler {
target: "blob.indicators"
function refresh(): void {
root.broadcast("refresh")
}
}
Timer {
id: indicatorHideTimer
interval: 120
onTriggered: {
if (!root.indicatorAreaHovered)
root.indicatorItemHovered = false
}
}
Component.onCompleted: root.refreshRequested()
Row {
id: horizontalIndicators
visible: !root.vertical
spacing: 0
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
Item {
id: inactiveHorizontalArea
implicitWidth: root.revealInactiveIndicators ? inactiveHorizontalBlock.implicitWidth : 0
implicitHeight: Math.max(inactiveHorizontalBlock.implicitHeight, root.barSize)
width: implicitWidth
height: implicitHeight
clip: true
IndicatorBlock {
id: inactiveHorizontalBlock
anchors.verticalCenter: parent.verticalCenter
indicatorsModule: root
indicatorEntries: root.indicatorEntries
indicatorBlock: "inactive"
horizontal: true
reportActiveState: !root.vertical
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
}
ActiveIndicatorBlock {
id: activeHorizontalBlock
indicatorsModule: root
indicatorModel: activeIndicatorModel
horizontal: true
reportActiveState: !root.vertical
}
}
Column {
id: verticalIndicators
visible: root.vertical
spacing: 0
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
Item {
id: inactiveVerticalArea
implicitWidth: Math.max(inactiveVerticalBlock.implicitWidth, root.barSize)
implicitHeight: root.revealInactiveIndicators ? inactiveVerticalBlock.implicitHeight : 0
width: implicitWidth
height: implicitHeight
clip: true
IndicatorBlock {
id: inactiveVerticalBlock
anchors.horizontalCenter: parent.horizontalCenter
indicatorsModule: root
indicatorEntries: root.indicatorEntries
indicatorBlock: "inactive"
horizontal: false
reportActiveState: root.vertical
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
}
ActiveIndicatorBlock {
id: activeVerticalBlock
indicatorsModule: root
indicatorModel: activeIndicatorModel
horizontal: false
reportActiveState: root.vertical
}
}
HoverHandler {
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
}
component ActiveIndicatorBlock: Item {
id: activeIndicatorBlockRoot
property var indicatorModel: null
property var indicatorsModule: null
property bool horizontal: true
property bool reportActiveState: false
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
Loader {
id: blockLoader
anchors.centerIn: parent
sourceComponent: activeIndicatorBlockRoot.horizontal ? horizontalActiveIndicatorBlock : verticalActiveIndicatorBlock
}
Component {
id: horizontalActiveIndicatorBlock
Row {
spacing: 0
Repeater {
model: activeIndicatorBlockRoot.indicatorModel
IndicatorLoader {
required property string activeId
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
indicatorBlock: "active"
reportActiveState: activeIndicatorBlockRoot.reportActiveState
}
}
}
}
Component {
id: verticalActiveIndicatorBlock
Column {
spacing: 0
Repeater {
model: activeIndicatorBlockRoot.indicatorModel
IndicatorLoader {
required property string activeId
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
indicatorBlock: "active"
reportActiveState: activeIndicatorBlockRoot.reportActiveState
}
}
}
}
}
component IndicatorBlock: Item {
id: indicatorBlockRoot
property var indicatorEntries: []
property var indicatorsModule: null
property string indicatorBlock: "active"
property bool horizontal: true
property bool reportActiveState: false
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
Loader {
id: blockLoader
anchors.centerIn: parent
sourceComponent: indicatorBlockRoot.horizontal ? horizontalIndicatorBlock : verticalIndicatorBlock
}
Component {
id: horizontalIndicatorBlock
Row {
spacing: 0
Repeater {
model: indicatorBlockRoot.indicatorEntries
IndicatorLoader {
required property var modelData
indicatorsModule: indicatorBlockRoot.indicatorsModule
entry: modelData
indicatorBlock: indicatorBlockRoot.indicatorBlock
reportActiveState: indicatorBlockRoot.reportActiveState
}
}
}
}
Component {
id: verticalIndicatorBlock
Column {
spacing: 0
Repeater {
model: indicatorBlockRoot.indicatorEntries
IndicatorLoader {
required property var modelData
indicatorsModule: indicatorBlockRoot.indicatorsModule
entry: modelData
indicatorBlock: indicatorBlockRoot.indicatorBlock
reportActiveState: indicatorBlockRoot.reportActiveState
}
}
}
}
}
component IndicatorLoader: Item {
id: indicatorSlot
required property var entry
property var indicatorsModule: null
required property string indicatorBlock
property bool reportActiveState: false
property bool activeStateObserved: false
readonly property string indicatorId: root.entryId(entry)
readonly property var indicatorSettings: root.entrySettings(entry)
readonly property var barRef: root.bar
implicitWidth: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitWidth : 0
implicitHeight: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitHeight : 0
width: implicitWidth
height: implicitHeight
onEntryChanged: {
activeStateObserved = false
injectProps()
syncActiveState()
}
onIndicatorBlockChanged: injectProps()
onIndicatorSettingsChanged: injectProps()
onIndicatorsModuleChanged: {
injectProps()
syncActiveState()
}
onReportActiveStateChanged: syncActiveState()
onBarRefChanged: injectProps()
Loader {
id: indicatorSource
anchors.fill: parent
source: indicatorSlot.indicatorId ? Qt.resolvedUrl("../indicators/" + indicatorSlot.indicatorId + ".qml") : ""
onLoaded: {
indicatorSlot.injectProps()
indicatorSlot.syncActiveState()
}
onStatusChanged: if (status === Loader.Error) console.warn("Indicator loader error", indicatorSlot.indicatorId, source)
}
Connections {
target: indicatorSource.item
ignoreUnknownSignals: true
function onActiveChanged() { indicatorSlot.syncActiveState() }
}
function injectProps() {
var target = indicatorSource.item
if (!target) return
if ("bar" in target) target.bar = root.bar
if ("moduleName" in target) target.moduleName = indicatorId
if ("settings" in target) target.settings = indicatorSettings
if ("indicatorBlock" in target) target.indicatorBlock = indicatorBlock
if ("indicatorHost" in target) target.indicatorHost = root
if ("activeOverride" in target) target.activeOverride = indicatorBlock === "active" ? true : null
}
function syncActiveState() {
if (!reportActiveState || !indicatorsModule || !indicatorsModule.setIndicatorActive) return
var active = !!indicatorSource.item && indicatorSource.item.active === true
if (indicatorBlock === "active") {
if (active) activeStateObserved = true
else if (!activeStateObserved) return
}
indicatorsModule.setIndicatorActive(entry, active)
}
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "blob.keyboard-layout",
"name": "Keyboard layout",
"version": "1.0.0",
"author": "Blob",
"description": "Current xkb layout, click cycles",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "KeyboardLayout.qml"
},
"barWidget": {
"displayName": "Keyboard layout",
"description": "Current xkb layout, click cycles",
"category": "Compositor",
"allowMultiple": false
},
"blob": {
"clonePaths": [
{
"source": "KeyboardLayoutModel.js",
"target": "KeyboardLayoutModel.js"
}
]
}
}
@@ -0,0 +1,218 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import qs.Ui
import qs.Commons
import "KeyboardLayoutModel.js" as KeyboardLayoutModel
BarWidget {
id: root
moduleName: "blob.keyboard-layout"
property string layoutFull: ""
// The keyboard the last reading spoke for, which is the one a click switches,
// and separately the one activelayout named as being typed on. A reading
// confirms the first is really there, so the click has a keyboard to reach
// from the first reading onwards rather than only after a switch, and stops
// naming one that has been unplugged.
property string keyboardName: ""
property string typedKeyboardName: ""
// Keyboards on the seat, buttons and virtual ones excluded, and whether the
// last reading left that shape in doubt.
property int keyboardCount: 0
property bool keyboardUnresolved: false
// Nothing to read or switch on the single-layout install most people run, so
// the widget ships on the bar and stays out of the way until there are two.
// An older Hyprland that doesn't report the list keeps showing the label.
property bool multipleLayouts: true
// Short language code per layout description ("English (US)": "en"), read from
// xkb's own table rather than maintained by hand.
property var layoutBriefs: ({})
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
// A query already in flight was started before this event, so it may read the
// layout the switch replaced. Remember the request and re-run once it lands
// rather than dropping it; nothing else would correct the label afterwards.
property bool refreshPending: false
function refresh() {
if (queryProc.running) {
refreshPending = true
return
}
refreshPending = false
queryProc.running = true
}
// Keyboards someone can actually type on, which is not everything Hyprland
// calls a keyboard.
function typedKeyboards(keyboards) {
return keyboards.filter(k => KeyboardLayoutModel.isTypedKeyboard(k.name))
}
// The main flag names no keyboard for long: fcitx5 takes it with the virtual
// keyboard it binds to inject, which leaves no typed keyboard holding it and
// nothing to read at all, and once that unbinds it lands on whichever device
// Hyprland saw last, a power button included. Go by layout progress instead,
// and by the keyboard activelayout named.
function selectKeyboard(typed) {
return KeyboardLayoutModel.selectKeyboard(typed, root.typedKeyboardName)
}
// switchxkblayout is a hyprctl command rather than a dispatcher, so it has to
// be run rather than sent over the dispatch socket. It switches the keyboard
// the last reading spoke for, so a click always advances the device the label
// is describing. Switching the seat together would reach the typed keyboard
// without having to name it, but it would also carry the buttons along, and
// the whole read depends on those staying where they started: once a button
// has been advanced too, a toggle that wraps the keyboard back to the first
// layout leaves the button reading as the furthest along, and the label
// follows the button.
function cycleLayout() {
if (!root.keyboardName || !root.bar) return
root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next")
refreshTimer.restart()
}
Component.onCompleted: {
briefsProc.running = true
refresh()
}
Connections {
target: Hyprland
function onRawEvent(event) {
if (!event || !event.name) return
var name = String(event.name)
// The event names the keyboard that switched ahead of the layout it moved
// to, and that is the keyboard being typed on whatever holds the main flag.
if (name === "activelayout") {
const named = KeyboardLayoutModel.eventKeyboardName(event)
if (named) root.typedKeyboardName = named
}
// A reload that adds a layout to kb_layout decides whether the widget
// shows at all, and leaves every keyboard on the layout it was already
// reading, so it raises no activelayout to notice it by.
if (name.indexOf("activelayout") !== -1 || name === "configreloaded") root.refresh()
}
}
Process {
id: queryProc
command: ["hyprctl", "-j", "devices"]
onRunningChanged: {
if (running) {
stallTimer.restart()
return
}
stallTimer.stop()
if (root.refreshPending) root.refresh()
}
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
let listed
try {
listed = JSON.parse(text || "{}").keyboards
} catch (e) {
return
}
// A query the watchdog killed reports nothing at all, and an empty
// string parses into the same shape a seat with no keyboards would.
// Tell them apart by the list itself, so only a reading that reached
// hyprctl gets to speak for the seat.
if (!Array.isArray(listed)) return
const typed = root.typedKeyboards(listed)
const kb = root.selectKeyboard(typed)
if (!kb || !kb.active_keymap) {
// Either the last keyboard has been unplugged, which the label has to
// stop describing and the click has to stop naming, or keyboards are
// there and none of them reports a keymap. Both leave the shape in
// doubt, so keep asking rather than letting a count from before it
// changed settle the poll.
root.keyboardUnresolved = true
if (typed.length === 0) {
root.layoutFull = ""
root.keyboardName = ""
}
return
}
root.keyboardUnresolved = false
root.keyboardCount = typed.length
root.keyboardName = String(kb.name || "")
root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1
root.layoutFull = kb.active_keymap
}
}
}
// The table only changes when xkb data is upgraded, so read it at startup and
// leave it alone. The bar is built per monitor, so this runs once per widget.
// The exotic rulesets cover layouts like trans (IPA) that ship in the same xkb
// package and set just as well, so load them or those labels lose their code.
Process {
id: briefsProc
command: ["xkbcli", "list", "--load-exotic"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.layoutBriefs = KeyboardLayoutModel.layoutBriefs(text)
}
}
Timer {
id: refreshTimer
interval: 600
onTriggered: root.refresh()
}
// A query that never returns would freeze the label until the shell restarts,
// since a Process that is already running can't be re-run. Give up on one that
// overstays so the next refresh gets through, and ask again: the reading it
// never delivered may have been the only one due on a settled seat, and
// nothing else would come back for it.
Timer {
id: stallTimer
interval: 5000
onTriggered: {
queryProc.running = false
refreshTimer.restart()
}
}
// Which keyboard on a crowded seat the label is describing can change without
// Hyprland announcing it, since a device arriving or leaving raises no event
// of its own, and that can only be learned by asking. Poll while there is that
// ambiguity, until a first reading lands so a query that failed at login still
// recovers, and while a reading has left the seat's shape in doubt. The
// one-keyboard install has none of those, and is left alone rather than
// spawning hyprctl forever for an answer that cannot change.
Timer {
interval: 10000
running: !root.keyboardName || root.keyboardUnresolved || root.keyboardCount > 1
repeat: true
onTriggered: root.refresh()
}
visible: layoutLabel !== "" && multipleLayouts
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.layoutLabel
fontSize: Style.font.caption
horizontalMargin: 6
tooltipText: root.layoutFull
onPressed: function() { root.cycleLayout() }
}
}
@@ -0,0 +1,125 @@
// Label math for the keyboard layout widget, kept Qt-free so it can be unit
// tested under node (test/shell.d/keyboard-layout-test.sh).
// xkbcli list prints YAML, and every layout and variant block pairs a brief with
// the description hyprctl reports as the active keymap:
//
// - layout: 'us'
// variant: ''
// brief: 'en'
// description: English (US)
//
// The models and option groups it also prints carry no brief of their own, and
// a brief never carries past the block it was printed in, so neither reaches
// the table.
function layoutBriefs(text) {
var briefs = {}
var brief = ""
String(text || "").split("\n").forEach(function (line) {
if (/^\s*- /.test(line)) brief = ""
var field = line.match(/^ (brief|description): (.*)$/)
if (!field) return
if (field[1] === "brief") {
brief = field[2].replace(/^'|'$/g, "")
} else if (brief) {
briefs[field[2]] = brief
brief = ""
}
})
return briefs
}
// The brief is a short language code rather than a country one, which keeps the
// label sensible for the layouts named after a language: Esperanto reads EO and
// Arabic reads AR. It is the same code GNOME shows in its own indicator.
//
// Layouts missing from the table fall back to the first word of the description,
// which reads as ENG/POR but at least says something.
//
// Nearly every brief is a bare two-letter code, but a few tack a script onto it
// (Burmese (Zawgyi) is my-zwg) and the custom layout's is a word, so drop the
// script and cap the result at the same three characters the fallback gets.
// The widget sits between fixed neighbours on the bar and has no room to grow.
function shortLabel(description, briefs) {
if (!description) return ""
// A description like "constructor" reaches an inherited member rather than a
// brief, so take the lookup only when it hands back the string it promises.
var brief = (briefs || {})[description]
var label = typeof brief === "string" && brief ? brief.split("-")[0] : description.split(/\s+/)[0]
return label.substring(0, 3).toUpperCase()
}
// Hyprland's activelayout event pairs the keyboard that switched with the layout
// it moved to. Quickshell cuts the event into that many fields, so a description
// carrying a comma of its own stays in one piece; a binding old enough to hand
// back only the raw string gets split by hand. The virtual keyboard fcitx5 binds
// to inject announces switches too, and names a keyboard nobody types on.
function eventKeyboardName(event) {
var parts
try {
if (event && event.parse) parts = event.parse(2)
} catch (error) {
}
if (!parts) parts = String(event && event.data ? event.data : "").split(",")
var name = String(parts[0] || "")
return name.indexOf("hl-virtual-keyboard") === 0 ? "" : name
}
// Hyprland reports more than keyboards as keyboards. fcitx5 binds a virtual one
// to inject through, which keeps the us layout the input method gave it, and the
// ACPI power button, lid switch and sleep key each arrive carrying the seat's
// layout list without anyone ever typing on them. Both answer to switchxkblayout
// and both can hold the main flag, so a widget that reads or switches whatever
// the seat hands it ends up describing a button. Leave them out and what remains
// is keyboards, which is what the rest of this file can then assume.
//
// Missing a name here costs the accuracy the seat had before, never a keyboard:
// anything unrecognised stays in the list.
var UNTYPED_KEYBOARDS = /^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)/
function isTypedKeyboard(name) {
return !UNTYPED_KEYBOARDS.test(String(name || ""))
}
// Every keyboard on the seat carries the same layout list unless one was given
// its own, but only the one being typed on advances through it. So the
// furthest-advanced is the one worth reading, and a switch names the keyboard it
// moved, which settles a seat holding two real keyboards outright.
//
// The name is taken whenever a keyboard still answers to it, wherever that
// keyboard sits in the list. Comparing positions instead would read the wrong
// keyboard the moment one wrapped from the last layout back to the first, which
// is the ordinary way round a pair of them. Applying a layout to the whole seat
// names a keyboard too, but leaves every one of them on the same layout, so the
// label reads the same whichever of them the name settles on.
function selectKeyboard(typed, namedByEvent) {
var keyboards = typed || []
return keyboards.find(function (keyboard) {
return keyboard.name === namedByEvent
}) || keyboards.reduce(function (furthest, keyboard) {
return layoutIndex(keyboard) > layoutIndex(furthest) ? keyboard : furthest
}, keyboards[0])
}
function layoutIndex(keyboard) {
return (keyboard && keyboard.active_layout_index) || 0
}
if (typeof module !== "undefined") {
module.exports = {
eventKeyboardName: eventKeyboardName,
isTypedKeyboard: isTypedKeyboard,
layoutBriefs: layoutBriefs,
selectKeyboard: selectKeyboard,
shortLabel: shortLabel
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.microphone",
"name": "Microphone",
"version": "1.0.0",
"author": "Blob",
"description": "Mic input state and mute toggle",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Microphone.qml"
},
"barWidget": {
"displayName": "Microphone",
"description": "Mic input state and mute toggle",
"category": "Audio",
"allowMultiple": false
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Ui
BarWidget {
id: root
moduleName: "blob.microphone"
readonly property var source: Pipewire.defaultAudioSource
readonly property bool muted: source && source.audio ? source.audio.muted : true
readonly property real volume: source && source.audio ? source.audio.volume : 0
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var activeStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
}
return list
}
readonly property bool inUse: activeStreams.length > 0 && !muted
visible: source !== null
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
function toggleMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
}
PwObjectTracker { objects: root.source ? [root.source] : [] }
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.muted ? "󰍭" : "󰍬"
active: root.inUse
tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live")
onPressed: function(b) {
if (b === Qt.MiddleButton) root.bar.run("blob-shell shell toggle blob.audio")
else root.toggleMute()
}
onWheelMoved: function(delta) {
if (!root.source || !root.source.audio) return
var step = 0.05
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
}
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.spacer",
"name": "Spacer",
"version": "1.0.0",
"author": "Blob",
"description": "Configurable blank space",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Spacer.qml"
},
"barWidget": {
"displayName": "Spacer",
"description": "Configurable blank space",
"category": "Layout",
"allowMultiple": true,
"settingsForm": "spacerSettings"
}
}
+13
View File
@@ -0,0 +1,13 @@
import QtQuick
import qs.Ui
BarWidget {
id: root
moduleName: "blob.spacer"
readonly property int span: settings && settings.size !== undefined ? Number(settings.size) : 12
implicitWidth: vertical ? barSize : span
implicitHeight: vertical ? span : barSize
visible: span > 0
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.system-update",
"name": "Blob update",
"version": "1.0.0",
"author": "Blob",
"description": "Indicates available Blob updates",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "SystemUpdate.qml"
},
"barWidget": {
"displayName": "Blob update",
"description": "Indicates available Blob updates",
"category": "System",
"allowMultiple": false
}
}
@@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.system-update"
property bool updateAvailable: false
function refresh() {
if (!updateProc.running) updateProc.running = true
}
function clear() { updateAvailable = false }
function runUpdate() {
if (root.bar) root.bar.run("blob-launch-floating blob-update")
}
visible: updateAvailable
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
IpcHandler {
target: "blob.system-update"
function refresh(): void {
root.broadcast("refresh")
}
function clear(): void {
root.broadcast("clear")
}
}
Process {
id: updateProc
command: ["blob-update-available"]
onExited: function(exitCode) {
root.updateAvailable = exitCode === 0
}
}
Timer {
interval: 21600000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: "\uf021"
slotSize: Style.bar.statusSlot
fontSize: Style.font.caption
tooltipText: "Pending Blob Updates"
onPressed: root.runUpdate()
}
}
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "blob.tray",
"name": "System tray",
"version": "1.0.0",
"author": "Blob",
"description": "Status notifier items",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Tray.qml"
},
"barWidget": {
"displayName": "System tray",
"description": "Status notifier items",
"category": "Status",
"allowMultiple": false
},
"blob": {
"clonePaths": [
{
"source": "TrayModel.js",
"target": "TrayModel.js"
}
]
}
}
+850
View File
@@ -0,0 +1,850 @@
import Quickshell
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell.Services.SystemTray
import qs.Commons
import qs.Ui
import "TrayModel.js" as TrayModel
BarWidget {
id: root
moduleName: "blob.tray"
property bool expanded: false
property bool managePopupOpen: false
property bool trayMenuOpen: false
property var activeTrayItem: null
property var activeTrayAnchor: null
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property var pinnedIds: settings.pinned instanceof Array ? settings.pinned : []
readonly property var hiddenIds: settings.hidden instanceof Array ? settings.hidden : []
readonly property var pinnedItems: bucket("pinned")
readonly property var drawerItems: bucket("drawer")
readonly property var allItems: bucket("all")
readonly property int drawerCount: drawerItems.length
readonly property int trayItemExtent: Style.bar.iconSlot
readonly property int trayItemGap: 0
readonly property int trayJoinGap: 0
readonly property int drawerExtent: drawerCount > 0 ? drawerCount * trayItemExtent + (drawerCount - 1) * trayItemGap : 0
// Match Waybar's group/tray-expander drawer transition-duration.
readonly property int animationDuration: 600
property real revealProgress: expanded ? 1 : 0
readonly property real revealExtent: drawerExtent * revealProgress
// Submenu drill-down state. QsMenuEntry.display() renders a *platform* menu,
// which Quickshell refuses unless the shell root sets `//@ pragma
// UseQApplication` - blob's shell.qml does not, so every submenu click was
// a silent no-op ("Cannot display PlatformMenuEntry as quickshell was not
// started in QApplication mode" in the shell log) and apps whose whole UI is
// submenus, e.g. radiotray-ng's station list, were unusable. QsMenuEntry
// inherits QsMenuHandle, so a child entry can feed a nested QsMenuOpener and
// render inside this popup instead of going through the platform. Each level
// keeps its own live opener: a child entry is owned by its parent opener's
// model, so collapsing the stack to a single opener would destroy the very
// entry being displayed (submenu turns up empty).
property var submenuStack: []
readonly property int submenuDepth: submenuStack.length
readonly property string currentTitle: submenuDepth > 0 ? submenuStack[submenuDepth - 1].title : ""
readonly property var currentChildren: submenuDepth > 0
? submenuStack[submenuDepth - 1].opener.children
: trayMenuOpener.children
// Changing level rebuilds the row delegates synchronously, so the next
// row lands under a cursor that hasn't moved. Submenu clicks used to be
// silent no-ops, which trained users to click them twice, and that second
// click would now fire whatever entry took the spot. Ignore row clicks for
// a beat after each level change; a deliberate follow-up click is slower.
property bool menuLevelSettling: false
Component {
id: submenuOpenerComponent
QsMenuOpener {}
}
Timer {
id: menuLevelSettleTimer
interval: 250
onTriggered: root.menuLevelSettling = false
}
function settleMenuLevel() {
menuLevelSettling = true
menuLevelSettleTimer.restart()
}
function resetTrayMenu() {
menuLevelSettling = false
menuLevelSettleTimer.stop()
// Flickable keeps its offset across a model swap whenever the new content
// is still tall enough to hold it, so a menu dismissed while scrolled
// would otherwise reopen part-way down with its first entries off screen.
trayMenuFlick.contentY = 0
// Clear the reactive stack before tearing anything down, so no binding can
// read a partially-destroyed opener while this runs. Then destroy deepest
// first: an inner opener's menu entry is owned by its parent's children
// model, so destroying a parent first would invalidate an entry a still-
// live child opener references.
var openers = submenuStack
submenuStack = []
for (var i = openers.length - 1; i >= 0; i--) openers[i].opener.destroy()
}
function enterSubmenu(entry, title) {
var opener = submenuOpenerComponent.createObject(root, { menu: entry })
if (!opener) return
var stack = submenuStack.slice()
stack.push({ opener: opener, title: title })
submenuStack = stack
settleMenuLevel()
}
function leaveSubmenu() {
if (submenuStack.length === 0) return
var stack = submenuStack.slice()
var top = stack.pop()
submenuStack = stack
top.opener.destroy()
settleMenuLevel()
}
function close() {
managePopupOpen = false
trayMenuOpen = false
}
function openTrayMenu(item, anchorItem, mouse) {
if (!item || !item.menu) {
var point = anchorItem.QsWindow.contentItem.mapFromItem(anchorItem, mouse.x, mouse.y)
item.display(anchorItem.QsWindow.window, point.x, point.y)
return
}
// Reset before switching items: trayMenuOpener.menu binds to
// activeTrayItem.menu, so assigning a new item invalidates the old root's
// children immediately, before any nested opener referencing them would
// otherwise get torn down.
resetTrayMenu()
activeTrayItem = item
activeTrayAnchor = anchorItem
trayMenuOpen = true
}
function trayIconSource(icon) {
// Quickshell already resolves the tray icon into a ready-to-use image://
// URL, including a "?path=" fallback search dir for apps that ship their
// tray icon outside a standard theme (e.g. Steam's flat public/ dir). Hand
// it straight to IconImage; guessing a theme sub-directory here only broke
// apps whose layout didn't match the guess.
return String(icon || "")
}
// Symbolic icons ship a fixed fill (often near-white) that the host is meant
// to recolor to its foreground; detect them by the freedesktop "-symbolic"
// name suffix so they can be tinted instead of rendered as-is.
function iconIsSymbolic(icon) {
var name = String(icon || "").split("?")[0]
return name.slice(-9) === "-symbolic"
}
function trayTooltip(item) {
return item.tooltipTitle || item.title || item.id || ""
}
function classifyItem(item) {
var iid = String(item.id || "")
if (hiddenIds.indexOf(iid) !== -1) return "hidden"
if (pinnedIds.indexOf(iid) !== -1) return "pinned"
return "drawer"
}
function ownedByBlob(item) {
var layout = root.bar && root.bar.layoutConfig ? root.bar.layoutConfig : null
return TrayModel.ownedByBlob(item, layout)
}
function bucket(category) {
var values = SystemTray.items.values
var result = []
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (item.status === Status.Passive) continue
if (ownedByBlob(item)) continue
if (category === "all") {
result.push(item)
continue
}
if (classifyItem(item) === category) result.push(item)
}
return result
}
function persistTrayState(pinned, hidden) {
if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return
var id = root.moduleName || "blob.tray"
root.bar.shell.updateEntryInline(id, { id: id, pinned: pinned, hidden: hidden })
}
function togglePin(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = p.indexOf(iid)
if (idx !== -1) p.splice(idx, 1)
else {
p.push(iid)
var hi = h.indexOf(iid)
if (hi !== -1) h.splice(hi, 1)
}
persistTrayState(p, h)
}
function toggleHide(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = h.indexOf(iid)
if (idx !== -1) h.splice(idx, 1)
else {
h.push(iid)
var pi = p.indexOf(iid)
if (pi !== -1) p.splice(pi, 1)
}
persistTrayState(p, h)
}
visible: pinnedItems.length > 0 || drawerCount > 0
clip: false
implicitWidth: root.vertical ? root.barSize : trayContent.implicitWidth
implicitHeight: root.vertical ? trayContent.implicitHeight : root.barSize
Behavior on revealProgress {
NumberAnimation { duration: root.animationDuration; easing.type: Easing.OutCubic }
}
Loader {
id: trayContent
anchors.fill: parent
sourceComponent: root.vertical ? verticalTray : horizontalTray
}
Component {
id: horizontalTray
Item {
id: horizontalTrayRoot
readonly property int pinnedWidth: pinnedRow.implicitWidth
readonly property int drawerBlockWidth: root.allItems.length > 0 ? expandIcon.implicitWidth + root.drawerExtent : 0
implicitWidth: pinnedWidth + drawerBlockWidth
implicitHeight: root.barSize
// Mask out the empty area the collapsed drawer reserves for its slide-in,
// so hovering it doesn't trigger expand and clicks pass through.
containmentMask: QtObject {
function contains(point: point): bool {
if (point.y < 0 || point.y > horizontalTrayRoot.height) return false
// Drawer reveals leftward; chevron sits at the right end when collapsed
// and slides left as it opens. The visible region starts at the chevron.
var chevronX = root.drawerExtent - root.revealExtent
if (point.x >= chevronX && point.x <= horizontalTrayRoot.drawerBlockWidth) return true
// Pinned items, placed to the right of the drawer block.
var pinnedStart = horizontalTrayRoot.drawerBlockWidth
return point.x >= pinnedStart && point.x <= horizontalTrayRoot.implicitWidth
}
}
Item {
id: drawerArea
x: 0
width: horizontalTrayRoot.drawerBlockWidth
height: root.barSize
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
x: root.drawerExtent - root.revealExtent
text: "\uf053"
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
x: expandIcon.width
anchors.verticalCenter: parent.verticalCenter
width: root.drawerExtent
height: root.barSize
clip: true
Row {
id: trayIcons
x: root.drawerExtent - root.revealExtent
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Row {
id: pinnedRow
x: drawerArea.x + horizontalTrayRoot.drawerBlockWidth
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
leftPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
Component {
id: verticalTray
Item {
id: verticalTrayRoot
readonly property int pinnedHeight: pinnedCol.implicitHeight
readonly property int drawerBlockHeight: root.allItems.length > 0 ? expandIcon.implicitHeight + root.drawerExtent : 0
implicitWidth: root.barSize
implicitHeight: pinnedHeight + drawerBlockHeight
containmentMask: QtObject {
function contains(point: point): bool {
if (point.x < 0 || point.x > verticalTrayRoot.width) return false
var chevronY = root.drawerExtent - root.revealExtent
if (point.y >= chevronY && point.y <= verticalTrayRoot.drawerBlockHeight) return true
var pinnedStart = verticalTrayRoot.drawerBlockHeight
return point.y >= pinnedStart && point.y <= verticalTrayRoot.implicitHeight
}
}
Item {
id: drawerArea
y: 0
width: root.barSize
height: verticalTrayRoot.drawerBlockHeight
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
y: root.drawerExtent - root.revealExtent
text: "\uf053"
textRotation: 90
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
y: expandIcon.height
anchors.horizontalCenter: parent.horizontalCenter
width: root.barSize
height: root.drawerExtent
clip: true
Column {
id: trayIcons
y: root.drawerExtent - root.revealExtent
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Column {
id: pinnedCol
y: drawerArea.y + verticalTrayRoot.drawerBlockHeight
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
topPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
PopupCard {
id: managePopup
anchorItem: root
owner: root
bar: root.bar
open: root.managePopupOpen
contentWidth: managePopup.fittedContentWidth(Style.space(300))
contentHeight: managePopup.fittedContentHeight(manageColumn.implicitHeight)
Column {
id: manageColumn
anchors.fill: parent
spacing: Style.space(8)
Text {
text: "Tray icons"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
Text {
text: "Pinned icons stay visible. Hidden icons never show."
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
width: parent.width
}
Text {
visible: root.allItems.length === 0
text: "No tray items reporting."
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
Repeater {
model: root.allItems
delegate: Item {
id: rowRoot
required property var modelData
required property int index
width: manageColumn.width
implicitHeight: 28
readonly property string itemId: String(modelData.id || "")
readonly property string displayName: {
var t = String(modelData.title || "").trim()
if (t) return t
var tt = String(modelData.tooltipTitle || "").trim()
if (tt) return tt
var id = String(modelData.id || "")
var slash = id.lastIndexOf("/")
return slash !== -1 ? id.substring(slash + 1) : (id || "Unknown")
}
readonly property bool isPinned: root.pinnedIds.indexOf(itemId) !== -1
readonly property bool isHidden: root.hiddenIds.indexOf(itemId) !== -1
TrayIcon {
id: rowIcon
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: 16
height: 16
icon: rowRoot.modelData.icon
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: rowIcon.right
anchors.leftMargin: Style.space(10)
anchors.right: rowHideBtn.left
anchors.rightMargin: Style.space(8)
text: rowRoot.displayName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Button {
id: rowPinBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
iconText: "\uf08d"
text: rowRoot.isPinned ? "Unpin" : "Pin"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.togglePin(rowRoot.itemId)
}
Button {
id: rowHideBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: rowPinBtn.left
anchors.rightMargin: Style.space(6)
iconText: "\uf06e"
text: rowRoot.isHidden ? "Show" : "Hide"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.toggleHide(rowRoot.itemId)
}
}
}
}
}
QsMenuOpener {
id: trayMenuOpener
menu: root.activeTrayItem ? root.activeTrayItem.menu : null
}
PopupCard {
id: trayMenuPopup
anchorItem: root.activeTrayAnchor || root
owner: root
bar: root.bar
open: root.trayMenuOpen
// The card fades out over 140ms (visible stays true for that whole time --
// see PopupCard's own visible: open || card.opacity > 0), so resetting on
// "open" would swap a live submenu for the root menu mid-fade: a visible
// flash, and a resize/reposition if the two have different geometry. Wait
// for the fade to actually finish. Switching to a different tray item
// still resets immediately, from openTrayMenu() itself.
onVisibleChanged: if (!visible) root.resetTrayMenu()
padding: Style.space(8)
borderColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.45)
contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232))
contentHeight: trayMenuPopup.fittedContentHeight(menuHeaderHeight + trayMenuColumn.implicitHeight, Style.space(420))
// Column skips invisible children but keeps reporting their height, so
// read the header's extent through its own visibility.
readonly property int menuHeaderHeight: menuHeader.visible ? menuHeader.implicitHeight : 0
Column {
id: trayMenuLayout
anchors.fill: parent
spacing: 0
// Header for a drilled-into submenu: names where we are and walks back
// out. Pinned above the Flickable rather than scrolling with the rows,
// so the way back stays reachable in a submenu taller than the card.
// Only present below the root level, so the root menu is unchanged.
Column {
id: menuHeader
visible: root.submenuDepth > 0
width: trayMenuLayout.width
spacing: 0
Item {
id: menuBackRow
width: menuHeader.width
implicitHeight: Style.space(30)
Rectangle {
anchors.fill: parent
radius: Math.max(2, Style.cornerRadius)
color: backMouse.containsMouse ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
text: "\u2039"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Style.space(28)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
text: root.currentTitle
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
MouseArea {
id: backMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.menuLevelSettling) return
// Reset before the model swap so the parent level shows from
// the top (same ordering as the row delegate below).
trayMenuFlick.contentY = 0
root.leaveSubmenu()
}
}
}
Item {
width: menuHeader.width
implicitHeight: Style.space(11)
Rectangle {
anchors.left: parent.left
anchors.leftMargin: Style.space(10)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Color.popups.border
opacity: 0.45
}
}
}
Flickable {
id: trayMenuFlick
width: trayMenuLayout.width
height: trayMenuLayout.height - trayMenuPopup.menuHeaderHeight
contentWidth: width
contentHeight: trayMenuColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: trayMenuColumn
width: trayMenuFlick.width
spacing: 0
Repeater {
model: root.currentChildren
delegate: Item {
id: menuRow
required property var modelData
required property int index
readonly property string rowText: String(modelData.text || "")
readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : ""
// Both only ever describe the root menu; inside a submenu the first
// rows are real entries and must not be swallowed.
readonly property bool atRoot: root.submenuDepth === 0
readonly property bool rootTitleEntry: atRoot && index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase()
readonly property bool leadingSeparator: atRoot && modelData.isSeparator && index <= 1
readonly property bool hiddenRow: rootTitleEntry || leadingSeparator
visible: !hiddenRow
width: trayMenuColumn.width
implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30))
opacity: modelData.enabled ? 1.0 : 0.45
Rectangle {
visible: menuRow.modelData.isSeparator
anchors.left: parent.left
anchors.leftMargin: Style.space(10)
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Color.popups.border
opacity: 0.45
}
Rectangle {
visible: !menuRow.modelData.isSeparator
anchors.fill: parent
radius: Math.max(2, Style.cornerRadius)
color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
}
Text {
textFormat: Text.PlainText
visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Image {
id: menuIcon
visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== ""
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Style.space(24)
width: Style.space(16)
height: Style.space(16)
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: width * Screen.devicePixelRatio
sourceSize.height: height * Screen.devicePixelRatio
source: menuRow.modelData.icon
}
Text {
textFormat: Text.PlainText
visible: !menuRow.modelData.isSeparator
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28)
anchors.right: submenuGlyph.left
anchors.rightMargin: Style.space(8)
text: menuRow.rowText
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Text {
id: submenuGlyph
visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: Style.space(10)
text: "\u203a"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.menuLevelSettling) return
if (menuRow.modelData.hasChildren) {
// Reset scroll BEFORE swapping the model: the swap destroys
// this delegate synchronously and ids stop resolving after.
trayMenuFlick.contentY = 0
root.enterSubmenu(menuRow.modelData, menuRow.rowText)
} else {
menuRow.modelData.triggered()
root.close()
}
}
}
}
}
}
}
}
}
// Renders a tray icon, recoloring symbolic icons to the bar foreground so
// they stay visible on any theme (a raw symbolic icon keeps its baked-in
// fill and disappears against a matching background).
component TrayIcon: Item {
id: trayIconRoot
required property var icon
readonly property bool symbolic: root.iconIsSymbolic(icon)
Image {
id: trayIconImage
anchors.fill: parent
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
sourceSize.height: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
source: root.trayIconSource(trayIconRoot.icon)
// Kept as a hidden layer so the effect can sample it as a texture.
visible: !trayIconRoot.symbolic
layer.enabled: trayIconRoot.symbolic
}
MultiEffect {
anchors.fill: trayIconImage
source: trayIconImage
visible: trayIconRoot.symbolic
colorization: 1.0
colorizationColor: root.foreground
}
}
component TrayItem: Item {
id: trayItemRoot
required property var modelData
visible: modelData.status !== Status.Passive
implicitWidth: visible ? root.trayItemExtent : 0
implicitHeight: visible ? root.trayItemExtent : 0
function displayMenu(mouse) {
root.openTrayMenu(trayItemRoot.modelData, trayItemRoot, mouse)
}
TrayIcon {
anchors.centerIn: parent
width: Style.space(12)
height: Style.space(12)
icon: trayItemRoot.modelData.icon
}
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(trayItemRoot, root.trayTooltip(modelData))
onExited: if (root.bar) root.bar.hideTooltip(trayItemRoot)
onPressed: function(mouse) {
if (mouse.button === Qt.RightButton) {
trayItemRoot.displayMenu(mouse)
mouse.accepted = true
}
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
mouse.accepted = true
} else if (mouse.button === Qt.MiddleButton) {
trayItemRoot.modelData.secondaryActivate()
} else if (trayItemRoot.modelData.onlyMenu) {
trayItemRoot.displayMenu(mouse)
} else {
trayItemRoot.modelData.activate()
}
}
onWheel: function(wheel) {
trayItemRoot.modelData.scroll(wheel.angleDelta.y, false)
}
}
readonly property bool tooltipHovered: visible && opacity > 0 && mouseArea.containsMouse
}
}
+47
View File
@@ -0,0 +1,47 @@
function text(value) {
return String(value || "").toLowerCase()
}
function itemNamed(item, name) {
if (!item) return false
return text(item.id).indexOf(name) !== -1
|| text(item.title).indexOf(name) !== -1
|| text(item.tooltipTitle).indexOf(name) !== -1
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (entry && typeof entry === "object") {
var id = entry.id
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function layoutHasWidget(layout, id) {
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
var entries = layout && layout[sections[s]]
if (!Array.isArray(entries)) continue
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === id) return true
}
}
return false
}
// LocalSend's item shows no state, offers only Open and Quit, and its primary
// click is a no-op, so Share > Receive is the whole surface. Hiding it by hand
// doesn't stick either: LocalSend picks a fresh tray id every launch.
function ownedByBlob(item, layout) {
return itemNamed(item, "localsend")
}
if (typeof module !== "undefined") {
module.exports = {
itemNamed: itemNamed,
entryId: entryId,
layoutHasWidget: layoutHasWidget,
ownedByBlob: ownedByBlob
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.workspaces",
"name": "Workspaces",
"version": "1.0.0",
"author": "Blob",
"description": "Workspace number indicators",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Workspaces.qml"
},
"barWidget": {
"displayName": "Workspaces",
"description": "Workspace number indicators",
"category": "Compositor",
"allowMultiple": false
}
}
+72
View File
@@ -0,0 +1,72 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Hyprland
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.workspaces"
function workspaceById(id) {
var values = Hyprland.workspaces.values
for (var i = 0; i < values.length; i++) {
if (values[i].id === id) return values[i]
}
return null
}
function workspaceIds() {
var ids = [1, 2, 3, 4, 5, 6, 7, 8, 9]
var values = Hyprland.workspaces.values
for (var i = 0; i < values.length; i++) {
var id = values[i].id
if (id > 0 && id <= 10 && ids.indexOf(id) === -1) ids.push(id)
}
ids.sort(function(left, right) { return left - right })
return ids
}
function focusWorkspace(id) {
if (!root.bar) return
root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
}
readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
implicitWidth: grid.implicitWidth + trailingGap
implicitHeight: grid.implicitHeight
GridLayout {
id: grid
anchors.fill: parent
anchors.rightMargin: root.trailingGap
columns: root.vertical ? 1 : root.workspaceIds().length
columnSpacing: root.vertical ? 0 : Style.space(1)
rowSpacing: root.vertical ? Style.space(2) : 0
Repeater {
model: root.workspaceIds()
WidgetButton {
required property int modelData
readonly property var workspace: root.workspaceById(modelData)
readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0
readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
bar: root.bar
text: focused ? "\uDB85\uDCFB" : (modelData === 10 ? "0" : String(modelData))
opacity: occupied || focused ? 1 : 0.5
horizontalMargin: 6
verticalPadding: 6
fixedWidth: root.vertical ? root.barSize : Style.space(20)
fixedHeight: root.barSize
onPressed: function() { root.focusWorkspace(modelData) }
}
}
}
}
+613
View File
@@ -0,0 +1,613 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "ClipboardHistory.js" as ClipboardHistory
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property bool clearConfirmOpen: false
property var history: []
property string historyPath: Quickshell.env("HOME") + "/.local/state/blob/clipboard-history.json"
property string captureScript: root.blobPath + "/shell/plugins/clipboard/capture.sh"
// Shares the [menu] surface tokens — themes that style the menu also
// style the clipboard. Selected-row colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(875), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2)
property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
property int historyLimit: 300
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.cancelClearHistory()
root.opened = false
}
function toggle() {
if (root.opened) root.close()
else root.open("{}")
}
function normalizeEntry(value) {
return ClipboardHistory.normalizeEntry(value)
}
function entryKey(entry) {
return ClipboardHistory.entryKey(entry)
}
function loadHistory(raw) {
root.history = ClipboardHistory.parseHistory(raw)
if (root.opened) root.rebuildDisplay()
}
function saveHistory() {
historyFile.setText(JSON.stringify(root.history.slice(0, root.historyLimit), null, 2) + "\n")
}
function addClipboardEntry(entry) {
var normalized = ClipboardHistory.normalizeEntry(entry)
if (!normalized) return
root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit)
root.saveHistory()
if (root.opened) root.rebuildDisplay()
}
function addClipboardJson(line) {
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
}
function requestClearHistory() {
if (root.history.length === 0) return
clearConfirm.selectedIndex = 1
root.clearConfirmOpen = true
}
function cancelClearHistory() {
root.clearConfirmOpen = false
root.disarmPointer()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmClearHistory() {
root.history = ClipboardHistory.clearHistory()
root.saveHistory()
root.selectedIndex = 0
root.cursorActive = false
root.disarmPointer()
root.clearConfirmOpen = false
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function removeDisplayIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex)
root.saveHistory()
if (displayModel.count <= 1) {
root.selectedIndex = 0
root.cursorActive = false
} else if (root.selectedIndex >= displayModel.count - 1) {
root.selectedIndex = displayModel.count - 2
}
root.disarmPointer()
root.rebuildDisplay()
}
function rebuildDisplay() {
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
displayModel.clear()
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
displayModel.append({
entryType: row.entryType,
fullText: row.fullText,
previewText: row.previewText,
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
path: row.path,
mime: row.mime,
historyIndex: row.index
})
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
root.disarmPointer()
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function selectAbsolute(index) {
if (displayModel.count === 0) return
root.disarmPointer()
root.cursorActive = true
root.selectedIndex = Math.max(0, Math.min(index, displayModel.count - 1))
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuildDisplay()
}
function disarmPointer() {
pointerGate.reset()
}
function selectFromPointer(index, item, mouse) {
if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true
root.selectedIndex = index
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row)
}
function copyIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.copySelected(row)
}
function openIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.openSelected(row)
}
function applySelected(row) {
if (!row) return
root.opened = false
if (row.entryType === "image") {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", row.mime, row.path])
} else if (row.fullText) {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--shift-insert", "--history-index", String(row.historyIndex)])
}
}
function copySelected(row) {
if (!row) return
root.opened = false
if (row.entryType === "image") {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", "--copy-only", row.mime, row.path])
} else if (row.fullText) {
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--copy-only", "--history-index", String(row.historyIndex)])
}
}
function openSelected(row) {
if (!row) return
root.opened = false
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-open", "--history-index", String(row.historyIndex)])
}
Component.onCompleted: initProc.running = true
ListModel { id: displayModel }
PointerMoveGate {
id: pointerGate
referenceItem: card
}
FileView {
id: historyFile
path: root.historyPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.loadHistory(text())
onLoadFailed: root.loadHistory("[]")
onFileChanged: reload()
}
// Reap watchers left behind by a previous shell instance, then start our
// own. The pdeathsig on the watchers makes the kernel kill them whenever
// the shell exits, however it exits, so no further lifecycle management.
Process {
id: initProc
command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\.sh"]
onExited: {
currentProc.running = true
textWatchProc.running = true
imageWatchProc.running = true
}
}
Process {
id: currentProc
command: [root.captureScript]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.addClipboardJson(text)
}
}
Process {
id: textWatchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
Process {
id: imageWatchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
// A watcher that dies takes clipboard history with it, silently: copying still
// works, the picker still opens, and the old entries are all still there, so
// nothing recorded until the next shell reload. Bring it back instead.
Timer {
id: watchRestartTimer
interval: 1000
repeat: false
onTriggered: {
if (!textWatchProc.running) textWatchProc.running = true
if (!imageWatchProc.running) imageWatchProc.running = true
}
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-clipboard"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
z: root.clearConfirmOpen ? 20 : 0
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (root.clearConfirmOpen) {
if (clearConfirm.handleKey(event)) event.accepted = true
return
}
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.close()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
else root.removeDisplayIndex(root.selectedIndex)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-6)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(6)
event.accepted = true
} else if (event.key === Qt.Key_Home) {
root.selectAbsolute(0)
event.accepted = true
} else if (event.key === Qt.Key_End) {
root.selectAbsolute(displayModel.count - 1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.cursorActive && (event.modifiers & Qt.AltModifier)) root.openIndex(root.selectedIndex)
else if (root.cursorActive && (event.modifiers & Qt.ShiftModifier)) root.copyIndex(root.selectedIndex)
else if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
ConfirmDialog {
id: clearConfirm
anchors.fill: parent
opened: root.clearConfirmOpen
z: 10
message: "Delete entire clipboard history?"
confirmText: "Delete"
background: root.background
foreground: root.foreground
scrim: root.scrim
selectedBackground: root.selectedBackground
selectedText: root.selectedText
fontFamily: root.fontFamily
cornerRadius: root.cornerRadius
onCanceled: root.cancelClearHistory()
onConfirmed: root.confirmClearHistory()
}
}
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search clipboard…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
Row {
anchors.fill: parent
spacing: 0
Item {
width: parent.width / 2
height: parent.height
clip: true
ListView {
id: resultList
anchors.fill: parent
anchors.rightMargin: root.contentMargin
model: displayModel
clip: true
spacing: Style.space(4)
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: row
required property int index
required property string entryType
required property string previewText
required property string fullText
required property string previewImage
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: ListView.view.width
height: root.rowHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.topMargin: Style.space(8)
anchors.bottomMargin: Style.space(8)
spacing: Style.space(10)
Image {
visible: parent.parent.previewImage.length > 0
width: visible ? parent.height : 0
height: parent.height
source: parent.parent.previewImage
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
Text {
textFormat: Text.PlainText
width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0)
height: parent.height
text: parent.parent.previewText
color: parent.parent.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
opacity: parent.parent.entryType === "image" || parent.parent.entryType === "file" ? 0.72 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: function(mouse) {
root.selectFromPointer(row.index, row, mouse)
}
onClicked: {
root.cursorActive = true
root.selectedIndex = row.index
root.activateIndex(row.index)
}
}
}
}
}
Item {
width: parent.width / 2
height: parent.height
clip: true
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: Style.normalBorderWidth
color: Util.alpha(root.border, 0.28)
}
Text {
textFormat: Text.PlainText
visible: parent.activeRow && !parent.activeRow.previewImage
anchors.fill: parent
anchors.leftMargin: root.contentMargin
anchors.rightMargin: 0
anchors.topMargin: 0
anchors.bottomMargin: 0
text: parent.activeRow ? parent.activeRow.fullText : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
wrapMode: Text.WrapAnywhere
elide: Text.ElideRight
verticalAlignment: Text.AlignTop
}
Image {
visible: parent.activeRow && parent.activeRow.previewImage
anchors.fill: parent
anchors.leftMargin: root.contentMargin
anchors.rightMargin: 0
anchors.topMargin: 0
anchors.bottomMargin: 0
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
verticalAlignment: Image.AlignTop
asynchronous: true
smooth: true
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰅌"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
textFormat: Text.PlainText
text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
function normalizeEntry(value) {
if (typeof value === "string")
return value.trim().length > 0 ? { type: "text", text: value } : null
if (!value || typeof value !== "object") return null
var type = String(value.type || value.kind || "")
if (type === "text") {
var text = String(value.text || "")
return text.trim().length > 0 ? { type: "text", text: text } : null
}
if (type === "image") {
var path = String(value.path || "")
if (!path) return null
var entry = {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
if (value.capturedAt !== undefined && value.capturedAt !== null)
entry.capturedAt = String(value.capturedAt)
return entry
}
return null
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
return "text:" + String(entry.text || "")
}
function parseHistory(raw) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (!Array.isArray(parsed)) return next
for (var i = 0; i < parsed.length; i++) {
var entry = normalizeEntry(parsed[i])
if (entry) next.push(entry)
}
return next
} catch (e) {
return []
}
}
function addEntry(history, entry, limit) {
var normalized = normalizeEntry(entry)
var max = limit === undefined || limit === null ? 100 : Number(limit)
if (isNaN(max)) max = 100
max = Math.max(0, max)
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
if (max === 0) return []
var key = entryKey(normalized)
var next = [normalized]
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length && next.length < max; i++) {
var existing = normalizeEntry(values[i])
if (!existing || entryKey(existing) === key) continue
next.push(existing)
}
return next
}
function removeEntryAt(history, index) {
var values = Array.isArray(history) ? history : []
var target = Number(index)
if (isNaN(target) || target < 0 || target >= values.length) return values.slice()
var next = values.slice()
next.splice(target, 1)
return next
}
function clearHistory() {
return []
}
function parseEntryJson(line) {
var raw = String(line || "").trim()
if (!raw) return null
try { return normalizeEntry(JSON.parse(raw)) } catch (e) { return null }
}
function searchableText(entry) {
if (!entry) return ""
if (entry.type === "image") return "image screenshot " + String(entry.mime || "") + " " + String(entry.capturedAt || "")
return String(entry.text || "") + " " + fileEntryText(entry)
}
function decodeFileUri(uri) {
var value = String(uri || "").trim()
if (value.indexOf("file://") !== 0) return ""
var path = value.substring(7)
if (path.indexOf("localhost/") === 0) path = path.substring(9)
if (path.charAt(0) !== "/") return ""
try { return decodeURIComponent(path) } catch (e) { return path }
}
function filePaths(entry) {
if (!entry || entry.type !== "text") return []
var lines = String(entry.text || "").split(/\r?\n/)
var paths = []
for (var i = 0; i < lines.length; i++) {
var path = decodeFileUri(lines[i])
if (path) paths.push(path)
}
return paths
}
function fileName(path) {
var parts = String(path || "").split("/")
return parts.length > 0 ? parts[parts.length - 1] : String(path || "")
}
function isImagePath(path) {
return /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(String(path || ""))
}
function fileEntryText(entry) {
var paths = filePaths(entry)
if (paths.length === 0) return ""
if (paths.length === 1) return fileName(paths[0])
return paths.length + " files"
}
function imagePreviewText(entry) {
var timestamp = String(entry && entry.capturedAt || "")
if (!timestamp) return "Image"
var label = String(entry && entry.mime || "") === "image/png" ? "Screenshot" : "Image"
return label + " from " + timestamp
}
function previewText(entry) {
if (!entry) return ""
if (entry.type === "image") return imagePreviewText(entry)
var fileText = fileEntryText(entry)
if (fileText) return fileText
return String(entry.text || "").replace(/\s+/g, " ")
}
function fullText(entry) {
if (!entry) return ""
var paths = filePaths(entry)
if (paths.length > 0) return paths.join("\n")
return String(entry.text || "")
}
// The picker only ever searches and renders a prefix of an entry, so scan and
// render just that much. A single huge paste otherwise costs hundreds of
// megabytes of string work on every keystroke and stalls the whole shell.
// Pasting reads the full entry back from history by index, so nothing is lost.
var displayTextLimit = 8192
function cappedEntry(entry) {
if (!entry || entry.type !== "text" || entry.text.length <= displayTextLimit) return entry
// Cut on a line break so a file:// URI never truncates into a bogus path.
var cut = entry.text.lastIndexOf("\n", displayTextLimit)
return { type: "text", text: entry.text.slice(0, cut > 0 ? cut : displayTextLimit) }
}
function displayRows(history, query, limit) {
var values = Array.isArray(history) ? history : []
var needle = String(query || "").trim().toLowerCase()
var max = limit === undefined || limit === null ? 50 : Number(limit)
if (isNaN(max)) max = 50
max = Math.max(0, max)
if (max === 0) return []
var rows = []
for (var i = 0; i < values.length; i++) {
var entry = cappedEntry(normalizeEntry(values[i]))
if (!entry) continue
if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue
var paths = filePaths(entry)
var isFile = paths.length > 0
var isImage = entry.type === "image"
var previewPath = isImage ? String(entry.path || "") : (isFile && paths.length === 1 && isImagePath(paths[0]) ? paths[0] : "")
rows.push({
entryType: isFile ? "file" : entry.type,
fullText: isImage ? "" : fullText(entry),
previewText: previewText(entry),
previewImage: previewPath,
path: isImage ? String(entry.path || "") : (isFile && paths.length === 1 ? paths[0] : ""),
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
index: i
})
if (rows.length >= max) break
}
return rows
}
if (typeof module !== "undefined") {
module.exports = {
normalizeEntry: normalizeEntry,
entryKey: entryKey,
parseHistory: parseHistory,
addEntry: addEntry,
removeEntryAt: removeEntryAt,
clearHistory: clearHistory,
parseEntryJson: parseEntryJson,
searchableText: searchableText,
previewText: previewText,
imagePreviewText: imagePreviewText,
filePaths: filePaths,
fileEntryText: fileEntryText,
fullText: fullText,
displayRows: displayRows
}
}
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# Captures the current clipboard as a JSON entry on stdout. In watch mode,
# wl-paste invokes this with the payload on stdin and the mime as $1. Without
# arguments, it snapshots the current selection itself.
set -o pipefail
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blob"
IMAGE_DIR="$STATE_DIR/clipboard-images"
mkdir -p "$IMAGE_DIR"
types=$(wl-paste --list-types 2>/dev/null || true)
if [[ ${CLIPBOARD_STATE:-} == "sensitive" ]] || grep -qx 'x-kde-passwordManagerHint' <<<"$types"; then
exit 0
fi
emit_image() {
local mime="$1"
local ext tmp hash file
ext=${mime#image/}
[[ $ext == jpeg ]] && ext=jpg
tmp=$(mktemp --tmpdir="$IMAGE_DIR" clipboard.XXXXXX) || return 0
cat >"$tmp"
if [[ ! -s $tmp ]]; then
rm -f "$tmp"
return 0
fi
hash=$(sha256sum "$tmp" | awk '{print $1}')
file="$IMAGE_DIR/$hash.$ext"
if [[ -e $file ]]; then
rm -f "$tmp"
else
mv "$tmp" "$file"
fi
jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \
'{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}'
}
emit_text() {
perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
my $raw = <STDIN>;
exit unless length $raw;
my $encoding;
my $heuristic_encoding = 0;
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
$encoding = "UTF-16";
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
my $units = length($raw) / 2;
my $nuls = $raw =~ tr/\0/\0/;
# Neither byte lane can reach the padding threshold when the entire
# payload contains fewer NULs than that, so avoid two full string passes.
if ($nuls * 4 >= $units * 3) {
my $even_bytes = $raw;
$even_bytes =~ s/(.)./$1/sg;
my $even_nuls = $even_bytes =~ tr/\0/\0/;
undef $even_bytes;
my $odd_bytes = $raw;
$odd_bytes =~ s/.(.)/$1/sg;
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
# only when at least three quarters of the code units have consistent
# padding and fewer than one quarter have NULs in the opposite byte.
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
$encoding = "UTF-16LE";
$heuristic_encoding = 1;
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
$encoding = "UTF-16BE";
$heuristic_encoding = 1;
}
}
}
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
$text = undef;
}
$text = decode("UTF-8", $raw) unless defined $text;
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
'
}
case "${1:-}" in
text) emit_text; exit 0 ;;
image/*) emit_image "$1"; exit 0 ;;
esac
for mime in image/png image/jpeg image/webp image/gif image/bmp image/tiff; do
if grep -qx "$mime" <<<"$types"; then
timeout 2s wl-paste --type "$mime" 2>/dev/null | emit_image "$mime"
exit 0
fi
done
if grep -q '^text/' <<<"$types" || grep -qx 'UTF8_STRING' <<<"$types" || grep -qx 'STRING' <<<"$types"; then
wl-paste --type text --no-newline 2>/dev/null | emit_text
fi
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.clipboard",
"name": "Clipboard",
"version": "1.0.0",
"author": "Blob",
"description": "A clipboard manager to view and paste history",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Clipboard.qml"
}
}
+46
View File
@@ -0,0 +1,46 @@
function parseEmojis(raw) {
try {
var data = JSON.parse(String(raw || ""))
return Array.isArray(data) ? data : []
} catch (e) {
return []
}
}
function normalizedQuery(query) {
return String(query || "").trim().toLowerCase()
}
function keywordText(item) {
return String((item && item.k) || "").toLowerCase()
}
function filterEmojis(emojis, query, limit) {
var values = Array.isArray(emojis) ? emojis : []
var needle = normalizedQuery(query)
var max = limit === undefined || limit === null ? 1000 : Number(limit)
if (isNaN(max)) max = 1000
max = Math.max(0, max)
if (max === 0) return []
var out = []
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (!item || !item.e) continue
if (!needle || keywordText(item).indexOf(needle) >= 0) {
out.push(item)
if (out.length >= max) break
}
}
return out
}
if (typeof module !== "undefined") {
module.exports = {
parseEmojis: parseEmojis,
normalizedQuery: normalizedQuery,
filterEmojis: filterEmojis
}
}
+345
View File
@@ -0,0 +1,345 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "EmojiSearch.js" as EmojiSearch
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property var emojis: []
property var filteredEmojis: []
// Shares the [menu] surface tokens — themes that style the menu also
// style emojis. Selected-cell colors composed in the
// singleton so consumers drop them straight into Rectangle bindings.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int cardWidth: Math.min(Style.space(400), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(Style.space(500), panel.height - Style.gapsOut * 2)
property int cellWidth: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int cellHeight: Math.max(Style.space(44), Style.font.display + Style.spacing.md)
property int columns: Math.floor((cardWidth - contentMargin * 2) / cellWidth)
function open(payloadJson) {
root.opened = true
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.emojis")
}
function toggle() {
if (root.opened) root.dismiss()
else root.open("{}")
}
function loadEmojis(raw) {
root.emojis = EmojiSearch.parseEmojis(raw)
if (root.opened) root.rebuildDisplay()
}
function rebuildDisplay() {
var out = EmojiSearch.filterEmojis(root.emojis, root.filterText, 1000)
root.filteredEmojis = out
displayModel.clear()
for (var j = 0; j < out.length; j++) {
displayModel.append({ emoji: out[j].e, index: j })
}
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
cursorActive = displayModel.count > 0
Qt.callLater(function() {
if (displayModel.count > 0) resultGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectRow(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var newIndex = selectedIndex + delta * columns
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function selectPage(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
return
}
var visibleRows = Math.max(1, Math.floor(resultGrid.height / cellHeight))
var newIndex = selectedIndex + delta * columns * visibleRows
if (newIndex < 0) newIndex = 0
if (newIndex >= displayModel.count) newIndex = displayModel.count - 1
selectedIndex = newIndex
resultGrid.positionViewAtIndex(selectedIndex, GridView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.emoji)
}
function applySelected(emoji) {
if (!emoji) return
root.dismiss()
Quickshell.execDetached([root.blobPath + "/bin/blob-menu-emoji", emoji])
}
ListModel { id: displayModel }
FileView {
path: root.blobPath + "/shell/plugins/emojis/emojis.json"
onLoaded: root.loadEmojis(text())
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-emojis"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Left) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.selectRow(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.selectRow(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.selectPage(-1)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.selectPage(1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || "Search emojis…"
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.headerHeight - root.contentSpacing
GridView {
id: resultGrid
anchors.fill: parent
model: displayModel
clip: true
cellWidth: root.cellWidth
cellHeight: root.cellHeight
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
required property int index
required property string emoji
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
width: root.cellWidth
height: root.cellHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
Text {
textFormat: Text.PlainText
text: parent.emoji
font.family: root.fontFamily
font.pixelSize: Style.font.display
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) {
root.cursorActive = true
root.selectedIndex = index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = index
root.activateIndex(index)
}
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰈉"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
textFormat: Text.PlainText
text: "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
}
}
}
}
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.emojis",
"name": "Emojis",
"version": "1.0.0",
"author": "Blob",
"description": "Search, copy, and type emojis",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Emojis.qml"
}
}
+582
View File
@@ -0,0 +1,582 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
import "ImagePickerModel.js" as ImagePickerModel
Item {
id: root
// Injected by blob-shell; defaults to the session BLOB_PATH.
property string blobPath: Quickshell.env("BLOB_PATH")
property string stateHome: Quickshell.env("HOME") + "/.local/state"
property string imageDirs: Quickshell.env("BLOB_IMAGE_SELECTOR_DIRS") || Quickshell.env("BLOB_IMAGE_SELECTOR_DIR") || Quickshell.env("BLOB_STOCK_BACKGROUNDS_DIR") || (stateHome + "/blob/current/theme/backgrounds")
property string imageRows: ""
property string loadedImageRows: ""
property string selectionFile: Quickshell.env("BLOB_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("BLOB_BACKGROUND_SELECTION_FILE")
property string selectedImage: Quickshell.env("BLOB_IMAGE_SELECTOR_SELECTED")
property int selectedIndex: 0
property bool imagesLoaded: false
property bool opened: false
property bool showLabels: false
property bool filterable: false
property bool layoutSettled: false
property bool requestActive: false
property int requestSerial: 0
property int applySerial: 0
property string doneFile: ""
property string filterText: ""
property var doneFilesToRelease: []
// Bound to the central [image-picker] section in shell.toml via Color.qml.
// `dimColor` tints unselected slices and text outlines on top of the scrim;
// it intentionally tracks the foundational background, not a surface role.
property color dimColor: Color.background
property color foreground: Color.imagePicker.text
property color scrim: Color.imagePicker.scrim
property color selectedBorder: Color.imagePicker.selectedBorder
property color unselectedBorder: Color.imagePicker.unselectedBorder
property int expandedWidth: 768
property int expandedHeight: 475
property int sliceWidth: 108
property int sliceHeight: 432
property int sliceSpacing: -30
property int skewOffset: 28
property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30)
onOpenedChanged: if (!opened) layoutSettled = false
function scriptPath(name) {
return blobPath + "/shell/plugins/image-picker/" + name
}
function focusPicker() {
if (root.opened && root.imagesLoaded && root.layoutSettled)
carousel.forceActiveFocus()
}
function revealWhenSettled(serial) {
Qt.callLater(function() {
if (serial === root.requestSerial && root.opened && root.imagesLoaded && root.imageArray.length > 0) {
root.layoutSettled = true
root.focusPicker()
}
})
}
function currentPath() {
if (imageArray.length === 0 || !itemMatches(selectedIndex)) return ""
return imageArray[selectedIndex].filePath
}
function nameForPath(path) {
return ImagePickerModel.nameForPath(path)
}
function labelForPath(path) {
return ImagePickerModel.labelForPath(path)
}
function currentLabel() {
var path = currentPath()
if (!path) return filterText ? "No matches" : ""
return labelForPath(path)
}
function itemMatches(index) {
return ImagePickerModel.itemMatches(imageArray, index, filterText)
}
function firstMatchingIndex() {
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
}
function filteredPosition(index) {
return ImagePickerModel.filteredPosition(imageArray, index, filterText)
}
function selectedFilteredPosition() {
return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText)
}
function select(index, immediate) {
if (imageArray.length === 0) return
if (index < 0) index = 0
else if (index >= imageArray.length) index = imageArray.length - 1
if (!itemMatches(index)) return
if (index === selectedIndex && immediate !== true) return
selectedIndex = index
}
function selectAdjacent(direction) {
var count = imageArray.length
if (count === 0) return
var index = selectedIndex
for (var i = 0; i < count; i++) {
index = (index + direction + count) % count
if (itemMatches(index)) {
select(index)
return
}
}
}
function updateFilter(nextFilterText) {
filterText = nextFilterText
if (!itemMatches(selectedIndex)) {
var first = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText)
if (first >= 0) selectedIndex = first
}
}
function releaseNextDoneFile() {
if (releaseProc.running || doneFilesToRelease.length === 0) return
var path = doneFilesToRelease.shift()
releaseProc.command = ["bash", "-c", ": > " + Util.shellQuote(path)]
releaseProc.running = true
}
function finishDoneFile(path) {
if (!path) return
doneFilesToRelease.push(path)
releaseNextDoneFile()
}
function applySelected() {
var path = currentPath()
if (!path || !selectionFile) {
cancel()
return
}
var activeSelectionFile = selectionFile
var activeDoneFile = doneFile
applySerial = requestSerial
requestActive = false
selectionFile = ""
doneFile = ""
applyProc.command = ["bash", "-c", "printf '%s\\n' " + Util.shellQuote(path) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)]
applyProc.running = true
}
function cancel() {
if (requestActive)
finishDoneFile(doneFile)
requestActive = false
selectionFile = ""
doneFile = ""
root.opened = false
}
function closeSelector(nextDoneFile) {
requestSerial += 1
if (requestActive)
finishDoneFile(doneFile)
if (nextDoneFile && nextDoneFile !== doneFile)
finishDoneFile(nextDoneFile)
requestActive = false
selectionFile = ""
doneFile = ""
filterText = ""
root.opened = false
}
function loadRows(rows, reveal) {
var newImages = ImagePickerModel.loadRows(rows)
root.loadedImageRows = rows
root.selectedIndex = root.indexForSelectedImage(newImages)
root.imageArray = newImages
root.imagesLoaded = true
if (reveal !== false) {
root.opened = true
root.revealWhenSettled(root.requestSerial)
}
}
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
if (requestActive && doneFile && doneFile !== nextDoneFile)
finishDoneFile(doneFile)
requestSerial += 1
imageDirs = nextImageDirs
imageRows = nextImageRows
selectedImage = nextSelectedImage
selectionFile = nextSelectionFile
doneFile = nextDoneFile
requestActive = !!doneFile
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
root.select(root.selectedImageIndex(), true)
imagesLoaded = true
opened = true
root.revealWhenSettled(requestSerial)
return
}
if (imageRows) {
var rowsToLoad = imageRows
var rowsSerial = requestSerial
imageArray = []
selectedIndex = 0
imagesLoaded = true
opened = true
Qt.callLater(function() {
if (rowsSerial === root.requestSerial)
root.loadRows(rowsToLoad, true)
})
return
}
imageArray = []
selectedIndex = 0
imagesLoaded = false
opened = false
startImageScan(requestSerial, imageDirs)
}
property var imageArray: []
function startImageScan(serial, dirs) {
if (loadImagesProc.running) {
loadImagesProc.queuedSerial = serial
loadImagesProc.queuedDirs = dirs
return
}
loadImagesProc.activeSerial = serial
loadImagesProc.queuedSerial = 0
loadImagesProc.queuedDirs = ""
loadImagesProc.command = [root.scriptPath("list.sh"), dirs]
loadImagesProc.running = true
}
function indexForSelectedImage(images) {
return ImagePickerModel.indexForSelectedImage(images, selectedImage)
}
function selectedImageIndex() {
return indexForSelectedImage(imageArray)
}
Process {
id: loadImagesProc
property int activeSerial: 0
property int queuedSerial: 0
property string queuedDirs: ""
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (loadImagesProc.activeSerial === root.requestSerial)
root.loadRows(String(text || ""), true)
}
}
onExited: {
var serial = queuedSerial
var dirs = queuedDirs
activeSerial = 0
queuedSerial = 0
queuedDirs = ""
if (serial > 0 && serial === root.requestSerial)
root.startImageScan(serial, dirs)
}
}
// Lifecycle hooks invoked by blob-shell summon/hide. shell.summon(id,
// payloadJson) hands the JSON to open() here; shell.hide(id) calls close().
// The shell host owns the stable `image-selector` IPC target and forwards
// those lower-level positional calls here.
function open(payload) {
var args = {}
if (payload) {
try { args = JSON.parse(payload) || {} } catch (e) { args = {} }
}
var dirs = String(args.imageDirs || imageDirs)
var rows = String(args.imageRows || "")
var sel = String(args.selectedImage || selectedImage)
var selFile = String(args.selectionFile || "")
var doneF = String(args.doneFile || "")
var labels = args.showLabels === true || args.showLabels === "true"
var filter = args.filterable === true || args.filterable === "true"
openSelector(dirs, rows, sel, selFile, doneF, labels, filter)
}
function close() {
cancel()
}
function preloadRows(nextImageRows, nextSelectedImage, nextShowLabels, nextFilterable) {
// Theme/background set hooks can warm selector rows after a picker was
// dismissed. Ignore those preloads while a user-visible request is open;
// otherwise the preload resets layoutSettled without revealing again,
// leaving only the fullscreen scrim.
if (opened || requestActive) return
requestSerial += 1
imageRows = nextImageRows
selectedImage = nextSelectedImage
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
layoutSettled = false
if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) {
selectedIndex = selectedImageIndex()
imagesLoaded = true
} else if (imageRows) {
loadRows(imageRows, false)
}
}
Process {
id: applyProc
onExited: {
if (root.applySerial === root.requestSerial)
root.opened = false
}
}
Process {
id: releaseProc
onExited: root.releaseNextDoneFile()
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-image-selector"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.opened && root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
visible: root.opened && root.imagesLoaded
color: root.scrim
}
MouseArea {
anchors.fill: parent
enabled: root.opened && root.imagesLoaded
onClicked: root.cancel()
}
Item {
id: card
visible: root.opened && root.imagesLoaded && root.layoutSettled && root.imageArray.length > 0
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
height: root.expandedHeight + Style.space(30) + root.bottomChromeHeight
anchors.centerIn: parent
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: carousel
anchors.top: parent.top
anchors.topMargin: Style.space(30)
anchors.bottom: parent.bottom
anchors.bottomMargin: root.bottomChromeHeight
anchors.horizontalCenter: parent.horizontalCenter
width: root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing)
clip: false
focus: true
readonly property real itemStep: root.sliceWidth + root.sliceSpacing
readonly property real previewX: (width - root.expandedWidth) / 2
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) {
root.updateFilter("")
} else {
root.cancel()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.applySelected()
event.accepted = true
} else if (root.filterable && Util.editsFilter(event, root.filterText)) {
root.updateFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Left || (event.key === Qt.Key_Tab && event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab) {
root.selectAdjacent(-1)
event.accepted = true
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Tab) {
root.selectAdjacent(1)
event.accepted = true
} else if (root.filterable && event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
root.updateFilter(root.filterText + event.text)
event.accepted = true
}
}
Component.onCompleted: forceActiveFocus()
Repeater {
model: root.imageArray.length
delegate: Item {
id: item
required property int index
readonly property var imageData: root.imageArray[index]
readonly property string filePath: imageData ? imageData.filePath : ""
readonly property string fileName: imageData ? imageData.fileName : ""
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
readonly property bool matched: root.itemMatches(index)
readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition()
readonly property bool selected: matched && index === root.selectedIndex
readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16
property bool sourceActivated: nearby
onNearbyChanged: if (nearby) sourceActivated = true
visible: nearby
x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep)
width: selected ? root.expandedWidth : root.sliceWidth
height: selected ? root.expandedHeight : root.sliceHeight
y: selected ? 0 : (root.expandedHeight - root.sliceHeight) / 2
z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40)
readonly property real skAbs: Math.abs(root.skewOffset)
readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0
readonly property real topRight: root.skewOffset >= 0 ? width : width - skAbs
readonly property real bottomRight: root.skewOffset >= 0 ? width - skAbs : width
readonly property real bottomLeft: root.skewOffset >= 0 ? 0 : skAbs
Item {
id: maskShape
anchors.fill: parent
visible: false
layer.enabled: true
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "white"
strokeColor: "transparent"
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
}
Item {
anchors.fill: parent
layer.enabled: true
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: maskShape
maskThresholdMin: 0.3
maskSpreadAtMin: 0.3
}
Image {
id: image
anchors.fill: parent
// Load only the initial/visited nearby images, but keep the
// source once activated so Qt does not tear textures down as
// selection moves through the carousel.
source: item.sourceActivated && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: false
cache: true
smooth: true
}
Rectangle {
anchors.fill: parent
color: Util.alpha(root.dimColor, item.selected ? 0 : 0.42)
}
}
Shape {
anchors.fill: parent
antialiasing: true
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "transparent"
strokeColor: item.selected ? root.selectedBorder : root.unselectedBorder
strokeWidth: item.selected ? 3 : 1
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
PathLine { x: item.bottomRight; y: item.height }
PathLine { x: item.bottomLeft; y: item.height }
PathLine { x: item.topLeft; y: 0 }
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: item.selected ? root.applySelected() : root.select(index)
}
}
}
}
Text {
id: selectedLabel
textFormat: Text.PlainText
visible: root.showLabels
anchors.top: carousel.bottom
anchors.topMargin: Style.space(16)
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.currentLabel()
color: root.foreground
style: Text.Outline
styleColor: Util.alpha(root.dimColor, 0.7)
font.pixelSize: Style.font.display
font.weight: Font.DemiBold
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
Text {
textFormat: Text.PlainText
visible: root.filterable && root.filterText
anchors.top: selectedLabel.bottom
anchors.topMargin: Style.space(8)
anchors.horizontalCenter: carousel.horizontalCenter
width: root.expandedWidth
text: root.filterText
color: root.foreground
opacity: 0.85
style: Text.Outline
styleColor: Util.alpha(root.dimColor, 0.7)
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
}
@@ -0,0 +1,97 @@
function nameForPath(path) {
return String(path || "").split("/").pop().replace(/\.[^/.]+$/, "")
}
function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
}
function loadRows(rows) {
var images = []
var seen = {}
var paths = String(rows || "").split("\n")
for (var i = 0; i < paths.length; i++) {
var row = paths[i]
if (!row) continue
var columns = row.split("\t")
var path = columns[0]
if (!path) continue
var fileName = path.split("/").pop()
if (seen[fileName]) continue
seen[fileName] = true
images.push({
filePath: path,
fileName: fileName,
thumbnailPath: columns[1] || path
})
}
return images
}
function itemMatches(images, index, filterText) {
if (!Array.isArray(images) || index < 0 || index >= images.length) return false
var needle = String(filterText || "").toLowerCase()
if (!needle) return true
var path = String(images[index].filePath || "")
return nameForPath(path).toLowerCase().indexOf(needle) !== -1
|| labelForPath(path).toLowerCase().indexOf(needle) !== -1
}
function firstMatchingIndex(images, filterText) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (itemMatches(values, i, filterText)) return i
}
return -1
}
function filteredPosition(images, index, filterText) {
if (!filterText) return index
var position = 0
for (var i = 0; i < index; i++) {
if (itemMatches(images, i, filterText)) position++
}
return position
}
function selectedFilteredPosition(images, selectedIndex, filterText) {
if (!filterText) return selectedIndex
return itemMatches(images, selectedIndex, filterText) ? filteredPosition(images, selectedIndex, filterText) : 0
}
function indexForSelectedImage(images, selectedImage) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (values[i].filePath === selectedImage) return i
}
return 0
}
function nextSelectedIndexForFilter(images, selectedIndex, filterText) {
if (itemMatches(images, selectedIndex, filterText)) return selectedIndex
return firstMatchingIndex(images, filterText)
}
if (typeof module !== "undefined") {
module.exports = {
nameForPath: nameForPath,
labelForPath: labelForPath,
loadRows: loadRows,
itemMatches: itemMatches,
firstMatchingIndex: firstMatchingIndex,
filteredPosition: filteredPosition,
selectedFilteredPosition: selectedFilteredPosition,
indexForSelectedImage: indexForSelectedImage,
nextSelectedIndexForFilter: nextSelectedIndexForFilter
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
image_dirs=${1:-}
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/blob/image-selector
index_file="$cache_dir/index.tsv"
mkdir -p "$cache_dir"
thumbnail_for() {
local image="$1"
local signature hash thumbnail legacy_hash
signature=$(stat -Lc '%s:%Y' "$image") || return
hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null)
if [[ -z $hash ]]; then
hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1)
fi
thumbnail="$cache_dir/$hash.jpg"
if [[ ! -f $thumbnail ]]; then
# Older on-demand picker code keyed fallback thumbnails by file content.
# Keep finding those if a user still has them cached.
legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1)
[[ -n $legacy_hash && -f $cache_dir/$legacy_hash.jpg ]] && thumbnail="$cache_dir/$legacy_hash.jpg"
fi
if [[ -f $thumbnail ]]; then
printf '%s' "$thumbnail"
else
printf '%s' "$image"
fi
}
while IFS= read -r dir; do
[[ -n $dir && -d $dir ]] || continue
find -L "$dir" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
-print0 2>/dev/null
done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do
thumbnail=$(thumbnail_for "$image")
[[ -n $thumbnail ]] || continue
printf '%s\t%s\n' "$image" "$thumbnail"
done
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.image-picker",
"name": "Image picker",
"version": "1.0.0",
"author": "Blob",
"description": "Image-grid selector overlay used for wallpapers, themes, and any other directory of images",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "ImagePicker.qml"
}
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
readonly property string home: Quickshell.env("HOME")
readonly property string brandingPath: home + "/.config/blob/branding/screensaver.txt"
readonly property string palettePath: home + "/.local/state/blob/current/theme/colors.toml"
property string brandingText: ""
property string paletteColor4: ""
function readPaletteColor4(raw) {
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var match = lines[i].match(/^\s*color4\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
if (match) return match[1]
}
return ""
}
FileView {
path: root.brandingPath
watchChanges: true
printErrors: false
onLoaded: root.brandingText = text()
onLoadFailed: root.brandingText = ""
onFileChanged: reload()
}
FileView {
path: root.palettePath
watchChanges: true
printErrors: false
onLoaded: root.paletteColor4 = root.readPaletteColor4(text())
onLoadFailed: root.paletteColor4 = ""
onFileChanged: reload()
}
}
+249
View File
@@ -0,0 +1,249 @@
import QtQuick
import QtQuick.Effects
import qs.Commons
import qs.Ui
Item {
id: root
property string backgroundPath: ""
property int backgroundVersion: 0
property bool fingerprintConfigured: false
property bool authenticatingPassword: false
property string brandingText: ""
property string paletteColor4: ""
property string failureMessage: ""
property int failedAttempts: 0
property bool inputEnabled: true
property bool loadBackground: true
property string passwordText: ""
property bool syncingPasswordText: false
readonly property string placeholderText: "Enter Password"
readonly property int fieldWidth: 381
readonly property int fieldHeight: 67
readonly property int outlineThickness: 2
readonly property int fieldRadius: 0
readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125)
readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33)
readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19)
// Space to keep clear on each side of the field for the fingerprint icon
// (icon width plus a gap) so the centered dots never run under it.
readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0
// Shrink the dots to fit once the password outgrows the field, so every
// keystroke stays visible — otherwise long passwords clip with no feedback.
readonly property real passwordDotScale: dotMetrics.advanceWidth > 0
? Math.min(1, (passwordInput.width - 4) / dotMetrics.advanceWidth)
: 1
readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0
readonly property bool errorState: failureMessage.length > 0
readonly property int brandingGap: Style.space(48)
readonly property int brandingMaxWidth: 1100
readonly property int brandingMaxFontSize: Math.round(Style.font.heading * 1.5)
readonly property bool inputActive: passwordText.length > 0 || authenticatingPassword
readonly property color inputRestingBorder: paletteColor4.length > 0 ? paletteColor4 : Color.accent
readonly property color inputBorderColor: errorState
? Color.urgent
: (inputActive ? Color.accent : Util.alpha(root.inputRestingBorder, 0.5))
readonly property color inputBackground: Util.alpha(Color.background, 0.6)
readonly property var inputBorderSpec: Border.flat(root.inputBorderColor, root.outlineThickness)
signal submitPassword(string password)
signal passwordTextEdited(string password)
signal clearFailureRequested()
signal wakeRequested()
// Cache-busts the lock background by appending `?v=`. Adding a query
// string keeps Image's loader happy while forcing it to reload when the
// user picks a new background mid-session.
function fileUrl(path) {
if (!path) return ""
var encoded = String(path).split("/").map(encodeURIComponent).join("/")
return "file://" + encoded + "?v=" + backgroundVersion
}
function forcePasswordFocus() {
passwordInput.forceActiveFocus()
}
function clearPassword() {
passwordTextEdited("")
}
function syncPasswordText() {
if (passwordInput.text === passwordText) return
syncingPasswordText = true
passwordInput.text = passwordText
syncingPasswordText = false
}
onPasswordTextChanged: syncPasswordText()
onInputEnabledChanged: {
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
Component.onCompleted: {
syncPasswordText()
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
// Measures the masked password at full size; passwordDotScale compares this
// against the field width to decide how far the dots must shrink to fit.
TextMetrics {
id: dotMetrics
font.family: Style.font.family
font.pixelSize: root.passwordDotFontSize
font.letterSpacing: root.passwordDotLetterSpacing
text: "●".repeat(passwordInput.text.length)
}
Rectangle {
anchors.fill: parent
color: Color.background
Image {
id: wallpaper
anchors.fill: parent
source: root.loadBackground ? root.fileUrl(root.backgroundPath) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: width
sourceSize.height: height
}
MultiEffect {
anchors.fill: wallpaper
source: wallpaper
autoPaddingEnabled: false
blurEnabled: root.loadBackground && wallpaper.status === Image.Ready
blur: 1.0
blurMax: 128
blurMultiplier: 1.25
contrast: -0.08
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: { root.wakeRequested(); root.forcePasswordFocus() }
onPositionChanged: root.wakeRequested()
}
Text {
id: branding
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: inputField.top
anchors.bottomMargin: root.brandingGap
width: Math.min(parent.width * 0.86, root.brandingMaxWidth)
height: Math.max(0, inputField.y - root.brandingGap * 2)
visible: root.brandingText.length > 0 && height > 0
text: root.brandingText
textFormat: Text.PlainText
color: Color.lock.text
font.family: Style.font.family
font.pixelSize: root.brandingMaxFontSize
minimumPixelSize: 4
fontSizeMode: Text.Fit
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignBottom
}
BorderSurface {
id: inputField
width: root.fieldWidth
height: root.fieldHeight
anchors.centerIn: parent
color: root.inputBackground
borderSpec: root.inputBorderSpec
radius: root.fieldRadius
clip: true
TextInput {
id: passwordInput
anchors.fill: parent
anchors.topMargin: inputField.borderTop
// Reserve the fingerprint icon's width on both sides so the centered
// dots stay symmetric and never slide under the icon as they grow.
anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve
anchors.bottomMargin: inputField.borderBottom
anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
activeFocusOnPress: true
clip: true
enabled: root.inputEnabled && !root.authenticatingPassword
readOnly: root.authenticatingPassword
echoMode: TextInput.Password
passwordCharacter: "\u25CF"
passwordMaskDelay: 0
color: Color.lock.text
selectionColor: Color.lock.selection
selectedTextColor: Color.lock.text
font.family: Style.font.family
font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize
font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0
cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0
cursorDelegate: Rectangle {
width: 2
color: Color.lock.text
visible: passwordInput.cursorVisible
}
onTextChanged: {
if (!root.syncingPasswordText) root.passwordTextEdited(text)
if (text.length > 0) {
root.wakeRequested()
}
if (text.length > 0 && root.failureMessage.length > 0) root.clearFailureRequested()
}
onAccepted: {
var submitted = root.passwordText
root.passwordTextEdited("")
if (submitted.length > 0) root.submitPassword(submitted)
}
Keys.onPressed: function(event) {
root.wakeRequested()
if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) {
root.passwordTextEdited("")
event.accepted = true
}
}
}
Text {
textFormat: Text.PlainText
anchors.fill: passwordInput
text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText)
visible: passwordInput.text.length === 0
color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder)
font.family: Style.font.family
font.pixelSize: root.fieldFontSize
font.italic: !root.authenticatingPassword && root.failureMessage.length > 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Fingerprint hint pinned inside the field's right edge when a sensor is
// enrolled, so the user knows they can touch to unlock instead of typing.
// Matches hyprlock, which draws its fingerprint icon in the same spot.
Text {
id: fingerprintIcon
objectName: "fingerprintIndicator"
anchors.right: parent.right
anchors.rightMargin: inputField.borderRight + 18
anchors.verticalCenter: parent.verticalCenter
visible: root.fingerprintConfigured
text: "󰈷"
color: Color.lock.placeholder
font.family: Style.font.family
font.pixelSize: Math.round(root.fieldFontSize * 1.1)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+559
View File
@@ -0,0 +1,559 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import Quickshell.Wayland
import qs.Commons
Item {
id: root
property var shell: null
property string blobPath: ""
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
property bool lockRequested: false
property bool pendingSessionLock: false
property bool authenticatingPassword: false
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
property bool previewVisible: false
property string enteredPassword: ""
property string pendingPassword: ""
property string failureMessage: ""
property int failedAttempts: 0
property string backgroundPath: ""
property int backgroundVersion: 0
property string lastEvent: "init"
property string lastEventAt: ""
property bool strandedLock: false
property bool strandedLockResolved: false
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
function realScreenCount() {
var screens = Quickshell.screens || []
var count = 0
for (var i = 0; i < screens.length; i++) {
var screen = screens[i]
if (screen && screen.name && screen.width > 0 && screen.height > 0) count += 1
}
return count
}
function hasRealScreen() {
return realScreenCount() > 0
}
function queueSessionLock() {
pendingSessionLock = true
if (!sessionLockStabilizeTimer.running) logEvent("lock-pending: screen-stabilizing")
sessionLockStabilizeTimer.restart()
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
}
function requestSessionLock() {
if (!lockRequested || sessionLock.locked || sessionLock.secure) return
if (sessionLockStabilizeTimer.running) return
if (!hasRealScreen()) {
if (!pendingSessionLock || lastEvent !== "lock-pending: no-real-screen") logEvent("lock-pending: no-real-screen")
pendingSessionLock = true
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
return
}
pendingSessionLock = false
pendingSessionLockTimer.stop()
sessionLock.locked = true
}
// ext-session-lock outlives its client, and a restart carries no lock over, so
// a session locked this early is an orphan behind Hyprland's failsafe. Outputs
// are often still absent here, so ask until the answer means something.
function checkStrandedLock() {
if (strandedLockResolved || strandedLockCheckProc.running) return
// A lock this shell took is nobody's orphan.
if (locked || lockRequested) {
strandedLockResolved = true
return
}
strandedLockCheckProc.running = true
}
function recoverStrandedLock() {
if (!strandedLock || locked || !passwordPamConfigured) return
strandedLock = false
logEvent("lock-stranded: recovering")
beginLock()
}
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
}
function refreshFingerprintStatus() {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
}
function logEvent(event) {
lastEvent = event
lastEventAt = new Date().toISOString()
console.log("blob lock " + lastEventAt + " " + event)
}
function resetAuthenticationState() {
enteredPassword = ""
pendingPassword = ""
failureMessage = ""
failedAttempts = 0
authenticatingPassword = false
fingerprintAuthenticating = false
fingerprintRetryTimer.stop()
if (passwordPam.active) passwordPam.abort()
if (fingerprintPam.active) fingerprintPam.abort()
}
function beginLock() {
if (!passwordPamConfigured) {
logEvent("lock-denied: missing-pam")
return false
}
resetAuthenticationState()
lockRequested = true
armBlankTimer()
logEvent("lock-requested")
queueSessionLock()
Qt.callLater(function() {
root.refreshBackground()
root.refreshFingerprintStatus()
})
return true
}
function finishUnlock() {
if (!root.locked && !lockRequested) return
lockRequested = false
pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
resetAuthenticationState()
idleBlankTimer.stop()
sessionLock.locked = false
logEvent("unlocked")
runWake()
}
function armBlankTimer() {
idleBlankTimer.armedAt = Date.now()
idleBlankTimer.restart()
}
function runWake() {
if (!wakeProcess.running) wakeProcess.running = true
if (lockRequested) armBlankTimer()
}
function runBlank() {
if (!blankProcess.running) blankProcess.running = true
}
function submitPassword(value) {
var password = String(value || "")
if (!lockRequested || authenticatingPassword || password.length === 0) return
runWake()
pendingPassword = password
failureMessage = ""
authenticatingPassword = true
if (!passwordPam.start()) {
handlePasswordFailure()
return
}
Qt.callLater(respondToPasswordPrompt)
}
function respondToPasswordPrompt() {
if (!authenticatingPassword || !passwordPam.active || !passwordPam.responseRequired) return
passwordPam.respond(pendingPassword)
}
function handlePasswordFailure() {
if (!lockRequested) return
authenticatingPassword = false
enteredPassword = ""
pendingPassword = ""
failedAttempts += 1
failureMessage = "Authentication failed (" + failedAttempts + ")"
runWake()
}
function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (fingerprintPam.active || fingerprintAuthenticating) return
fingerprintAuthenticating = true
if (!fingerprintPam.start()) {
fingerprintAuthenticating = false
}
}
function handleFingerprintFinished(result) {
fingerprintAuthenticating = false
if (!lockRequested) return
if (result === PamResult.Success) {
finishUnlock()
} else if (fingerprintConfigured) {
fingerprintRetryTimer.restart()
}
}
WlSessionLock {
id: sessionLock
locked: false
onSecureStateChanged: {
root.logEvent("secure=" + secure)
if (secure) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.startFingerprint()
}
}
onLockStateChanged: {
root.logEvent("session-locked=" + locked)
if (locked) {
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
}
if (!locked && root.lockRequested) {
root.lockRequested = false
root.pendingSessionLock = false
sessionLockStabilizeTimer.stop()
pendingSessionLockTimer.stop()
root.resetAuthenticationState()
root.runWake()
}
}
WlSessionLockSurface {
id: lockSurface
color: Color.background
LockView {
id: lockView
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: root.authenticatingPassword
failureMessage: root.failureMessage
failedAttempts: root.failedAttempts
inputEnabled: root.lockRequested
loadBackground: root.locked
brandingText: brandingSource.brandingText
paletteColor4: brandingSource.paletteColor4
passwordText: root.enteredPassword
onPasswordTextEdited: function(password) { root.enteredPassword = password }
onSubmitPassword: function(password) { root.submitPassword(password) }
onClearFailureRequested: root.failureMessage = ""
onWakeRequested: root.runWake()
}
}
}
PanelWindow {
id: previewWindow
visible: root.previewVisible
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-lock-preview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
LockView {
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: false
failureMessage: ""
failedAttempts: 0
inputEnabled: false
loadBackground: root.previewVisible
passwordText: ""
brandingText: brandingSource.brandingText
paletteColor4: brandingSource.paletteColor4
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: root.previewVisible = false
}
}
PamContext {
id: passwordPam
config: "blob-lock-password"
user: root.userName
onResponseRequiredChanged: root.respondToPasswordPrompt()
onPamMessage: root.respondToPasswordPrompt()
onCompleted: function(result) {
root.authenticatingPassword = false
root.pendingPassword = ""
if (!root.lockRequested) return
if (result === PamResult.Success) root.finishUnlock()
else root.handlePasswordFailure()
}
onError: function(error) {
root.handlePasswordFailure()
}
}
PamContext {
id: fingerprintPam
config: "blob-lock-fingerprint"
user: root.userName
onCompleted: function(result) {
root.handleFingerprintFinished(result)
}
onError: function(error) {
root.fingerprintAuthenticating = false
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
}
}
Timer {
id: fingerprintRetryTimer
interval: 250
repeat: false
onTriggered: root.startFingerprint()
}
Process {
id: readlinkProc
command: ["readlink", "-f", root.currentBackgroundLink]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var next = String(text || "").trim()
if (next !== root.backgroundPath) {
root.backgroundPath = next
root.backgroundVersion += 1
}
}
}
}
Process {
id: fingerprintCheckProc
command: ["bash", "-c", "if [[ -f /etc/pam.d/blob-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"]
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
onExited: {
root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes"
if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint()
else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort()
}
}
Process {
id: strandedLockCheckProc
command: ["bash", "-c", "blob-hypr-session-locked"]
onExited: function(exitCode) {
// No output to read the lock off yet.
if (exitCode === 2) return
root.strandedLockResolved = true
// A lock taken while this was in flight is this shell's own.
root.strandedLock = exitCode === 0 && !root.locked && !root.lockRequested
root.recoverStrandedLock()
}
}
Process {
id: wakeProcess
command: ["bash", "-c", "blob-system-wake"]
}
Process {
id: blankProcess
command: ["bash", "-c", "blob-brightness-keyboard off; blob-brightness-display off"]
}
Timer {
id: idleBlankTimer
interval: 5000
repeat: false
property double armedAt: 0
onTriggered: {
// A countdown frozen by suspend fires right after resume, which would
// blank the freshly woken unlock screen under the user. Wall-clock time
// exposes the gap: take a fresh run-up instead of blanking.
if (Date.now() - armedAt > interval + 2000) {
root.armBlankTimer()
return
}
// Only a password check in flight should hold the display up. The
// fingerprint PAM stays armed for the whole lock, so gating on
// `authenticating` here would keep the panel lit until unlock.
if (root.lockRequested && !root.authenticatingPassword) root.runBlank()
}
}
Timer {
id: sessionLockStabilizeTimer
interval: 500
repeat: false
onTriggered: root.requestSessionLock()
}
Timer {
id: pendingSessionLockTimer
interval: 100
repeat: true
onTriggered: root.requestSessionLock()
}
Timer {
id: strandedLockRetryTimer
interval: 500
repeat: true
// Covers the compositor settling; screens coming back re-arm it.
readonly property int budget: 20
property int remaining: 20
running: !root.strandedLockResolved && remaining > 0
function rearm() {
if (!root.strandedLockResolved) remaining = budget
}
onTriggered: {
remaining -= 1
root.checkStrandedLock()
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.requestSessionLock()
// A monitor still coming up has no workspace, so cannot answer yet.
strandedLockRetryTimer.rearm()
root.checkStrandedLock()
}
}
onAuthenticatingPasswordChanged: {
if (!lockRequested) return
if (authenticatingPassword) idleBlankTimer.stop()
else armBlankTimer()
}
BrandingSource {
id: brandingSource
}
FileView {
path: "/etc/pam.d/blob-lock-password"
watchChanges: true
printErrors: false
onLoaded: root.passwordPamConfigured = true
onLoadFailed: root.passwordPamConfigured = false
onFileChanged: reload()
}
// No lock before PAM is known good. An answer from before then may be stale --
// the failsafe can be cleared from a TTY -- so re-ask rather than act on it.
onPasswordPamConfiguredChanged: {
if (!passwordPamConfigured) return
strandedLock = false
strandedLockResolved = false
strandedLockRetryTimer.rearm()
checkStrandedLock()
}
Component.onCompleted: {
refreshBackground()
refreshFingerprintStatus()
checkStrandedLock()
}
IpcHandler {
target: "lock"
function lock(): string {
if (!root.passwordPamConfigured) return "missing-pam"
if (!root.locked && !root.beginLock()) return "failed"
return "ok"
}
function isLocked(): string {
return root.locked ? "true" : "false"
}
function status(): string {
return JSON.stringify({
locked: root.locked,
requested: root.lockRequested,
pending: root.pendingSessionLock,
sessionLocked: sessionLock.locked,
secure: sessionLock.secure,
realScreens: root.realScreenCount(),
passwordPam: root.passwordPamConfigured,
fingerprint: root.fingerprintConfigured,
authenticating: root.authenticating,
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function preview(): string {
root.refreshBackground()
root.refreshFingerprintStatus()
root.previewVisible = true
return "ok"
}
function hidePreview(): string {
root.previewVisible = false
return "ok"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.lock",
"name": "Lock Screen",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
"blob": {
"capabilities": [
"authentication"
]
},
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+67
View File
@@ -0,0 +1,67 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.menu"
readonly property string iconPath: Quickshell.env("HOME") + "/.config/blob/branding/blob_icon.svg"
readonly property int iconSize: Math.round(Style.font.body * 1.35)
property string iconSvg: ""
readonly property string iconColor: hexColor(button.foreground)
readonly property string tintedSvg: iconSvg.replace(/fill="#000000"/g, 'fill="' + iconColor + '"')
readonly property string iconUrl: iconSvg.length > 0 ? "data:image/svg+xml;base64," + Qt.btoa(tintedSvg) : ""
readonly property bool iconReady: icon.status === Image.Ready
function hexColor(value) {
function channel(fraction) {
return ("0" + Math.round(fraction * 255).toString(16)).slice(-2)
}
return "#" + channel(value.r) + channel(value.g) + channel(value.b)
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
FileView {
path: root.iconPath
watchChanges: true
printErrors: false
onLoaded: root.iconSvg = text()
onLoadFailed: root.iconSvg = ""
onFileChanged: reload()
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: ""
fontFamily: "omarchy"
labelVisible: !root.iconReady
fixedWidth: root.iconReady ? root.iconSize + Style.spaceReal(15) : -1
horizontalMargin: 7.5
onPressed: function(button) {
if (!root.bar) return
if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec")
else root.bar.run("blob-shell shell toggle blob.menu '{\"menu\":\"root\"}'")
}
Image {
id: icon
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
source: root.iconUrl
sourceSize.width: root.iconSize * 2
sourceSize.height: root.iconSize * 2
smooth: true
visible: root.iconReady
}
}
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
function stripJsonc(raw) {
return String(raw || "")
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
.replace(/,(\s*[}\]])/g, "$1")
}
function normalizeAliases(value) {
if (Array.isArray(value)) return value.filter(function(v) { return v })
if (typeof value === "string" && value) return [value]
return []
}
function normalizeItem(id, raw) {
var value = raw || {}
var aliases = normalizeAliases(value.aliases)
var parent = value.parent
if (parent === undefined)
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
if (id === "root") parent = ""
var kind = value.action ? "action" : (value.target ? "link" : "menu")
return {
id: id,
parent: parent,
kind: kind,
icon: value.icon || "",
iconFont: value.iconFont || "",
label: value.label || id,
title: value.title || "",
target: value.target || "",
description: value.description || "",
action: value.action || "",
provider: value.provider || "",
aliases: aliases,
when: value.when || "",
checked: value.checked || ""
}
}
function parseMenuJsonc(raw) {
var stripped = stripJsonc(raw)
if (!stripped.trim()) return []
var parsed
try {
parsed = JSON.parse(stripped)
} catch (e) {
return []
}
if (typeof parsed !== "object" || parsed === null) return []
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
? parsed.items
: parsed
var out = []
for (var id in source) {
var entry = source[id]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
out.push(normalizeItem(id, entry))
}
return out
}
function mergeMenuSources(defaultItems, userItems) {
var nextItems = ({})
var nextOrder = []
var sources = [defaultItems || [], userItems || []]
for (var s = 0; s < sources.length; s++) {
var src = sources[s]
for (var i = 0; i < src.length; i++) {
var entry = src[i]
if (!entry || !entry.id) continue
if (!nextItems[entry.id]) nextOrder.push(entry.id)
var prior = nextItems[entry.id] || {}
var merged = {}
for (var k in prior) merged[k] = prior[k]
for (var k2 in entry) merged[k2] = entry[k2]
merged.id = entry.id
nextItems[entry.id] = merged
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
return {
items: nextItems,
itemOrder: nextOrder
}
}
// Both merges below return fresh items/itemOrder objects for the caller to
// assign in one go. They must never write into the maps they are handed: those
// live in QML `var` properties, and an in-place write into such an object is
// occasionally dropped by the engine — the key lands with an undefined value.
// A lost write used to leave an id in itemOrder with no item behind it, and
// the next merge then kept that orphan and appended a second row for the same
// app, so the launcher listed it twice (and again on every later rescan).
// Swaps every app row for the current set. Rows keep the order they arrive in;
// ids already claimed (including duplicate desktop ids) are listed once.
function mergeAppRows(items, itemOrder, appRows) {
var source = items || ({})
var order = Array.isArray(itemOrder) ? itemOrder : []
var rows = Array.isArray(appRows) ? appRows : []
var nextItems = ({})
var nextOrder = []
for (var i = 0; i < order.length; i++) {
var id = order[i]
var existing = source[id]
// Orphans (an id with no item) are dropped rather than carried forward,
// so a single lost write cannot compound into a duplicate row.
if (!existing || existing.kind === "app") continue
nextItems[id] = existing
nextOrder.push(id)
}
for (var j = 0; j < rows.length; j++) {
var row = rows[j]
if (!row || !row.id || nextItems[row.id]) continue
row.order = nextOrder.length
nextItems[row.id] = row
nextOrder.push(row.id)
}
return { items: nextItems, itemOrder: nextOrder }
}
// Swaps the rows one provider contributed, leaving every other item untouched.
// Rows carry the id of the submenu that produced them, so a provider that runs
// again drops its previous batch — a plugin that was just enabled disappears
// from the Enable list — without disturbing static children declared in JSONC.
function swapProviderRows(items, itemOrder, menuId, rows) {
var source = items || ({})
var order = Array.isArray(itemOrder) ? itemOrder : []
var incoming = Array.isArray(rows) ? rows : []
var nextItems = ({})
var nextOrder = []
for (var i = 0; i < order.length; i++) {
var id = order[i]
var existing = source[id]
if (!existing || existing.providerMenu === menuId) continue
nextItems[id] = existing
nextOrder.push(id)
}
for (var j = 0; j < incoming.length; j++) {
var row = incoming[j]
if (!row || !row.id || nextItems[row.id]) continue
row.providerMenu = menuId
row.order = nextOrder.length
nextItems[row.id] = row
nextOrder.push(row.id)
}
return { items: nextItems, itemOrder: nextOrder }
}
function item(items, id) {
return items && items[id] ? items[id] : null
}
// Routes may name a real id (`system`, `setup.power`) or an alias declared in
// JSONC (`power-menu`, `settings`). An exact id beats any alias, and app rows
// are never routable: their aliases carry .desktop Keywords and GenericName
// for search, so an installed application could otherwise shadow a menu route
// (htop ships `Keywords=system;...`). Unknown strings fall through as the
// literal input so misspellings still attempt to open that id.
function resolveRoute(items, itemOrder, input) {
var raw = String(input || "").toLowerCase().replace(/_/g, "-")
if (!raw || raw === "go" || raw === "menu") return "root"
if (item(items, raw)) return raw
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var entry = item(items, order[i])
if (!entry || entry.kind === "app" || !entry.aliases) continue
for (var j = 0; j < entry.aliases.length; j++) {
var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-")
if (alias === raw) return entry.id
}
}
return raw
}
function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
}
function depthFor(items, id) {
var depth = 0
var current = item(items, id)
var guard = 0
while (current && current.parent && current.parent !== "root" && guard < 32) {
depth += 1
current = item(items, current.parent)
guard += 1
}
return depth
}
function pathFor(items, id) {
var labels = []
var current = item(items, id)
var guard = 0
while (current && current.id !== "root" && guard < 32) {
labels.unshift(current.label)
current = item(items, current.parent)
guard += 1
}
return labels.join(" ")
}
function parentPathFor(items, id) {
var entry = item(items, id)
if (!entry || !entry.parent || entry.parent === "root") return ""
return pathFor(items, entry.parent)
}
function isDescendantOf(items, id, ancestorId) {
if (ancestorId === "root") return id !== "root"
var current = item(items, id)
var guard = 0
while (current && current.parent && guard < 32) {
if (current.parent === ancestorId) return true
current = item(items, current.parent)
guard += 1
}
return false
}
function childCount(items, itemOrder, id) {
var count = 0
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var entry = item(items, order[i])
if (entry && entry.parent === id) count += 1
}
return count
}
function isVisible(items, itemOrder, whenResults, entry, depth) {
if (!entry) return false
if (entry.when && whenResults && whenResults[entry.id] === false) return false
if (entry.kind !== "menu" && entry.kind !== "link") return true
if (entry.provider) return true
var guard = depth || 0
if (guard >= 32) return false
var target = entry.kind === "link" ? entry.target : entry.id
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var child = item(items, order[i])
if (child && child.parent === target && isVisible(items, itemOrder, whenResults, child, guard + 1)) return true
}
return false
}
function labelFor(entry, checkedResults) {
if (!entry) return ""
if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓"
return entry.label
}
function searchableToken(value) {
return String(value || "").replace(/[._-]+/g, " ")
}
function leafIdFor(id) {
var parts = String(id || "").split(".")
return parts.length > 0 ? parts[parts.length - 1] : id
}
function nameSearchText(entry) {
if (!entry) return ""
var aliases = []
var values = Array.isArray(entry.aliases) ? entry.aliases : []
for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i]))
return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
}
function termInSearchWords(term, text) {
var words = String(text || "").toLowerCase().split(/\s+/)
for (var i = 0; i < words.length; i++) {
if (words[i] === term) return true
}
return false
}
function descriptionTextMatches(query, text) {
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (terms[i] && !termInSearchWords(terms[i], text)) return false
}
return true
}
function matchesQuery(entry, query, visible) {
if (!entry || entry.id === "root") return false
if (!visible) return false
var nameText = nameSearchText(entry)
var descriptionText = String(entry.description || "").toLowerCase()
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (!terms[i]) continue
if (nameText.indexOf(terms[i]) >= 0) continue
if (termInSearchWords(terms[i], descriptionText)) continue
return false
}
return true
}
function searchScore(items, entry, query) {
var needle = String(query || "").toLowerCase().trim()
var label = entry.label.toLowerCase()
var nameText = nameSearchText(entry)
var descriptionText = String(entry.description || "").toLowerCase()
var score = 80
if (label === needle) score = entry.parent === "root" ? 2 : 0
// An installed app whose name contains the query as a whole word ("zen"
// for Zen Browser) beats exact-labeled menu entries like Install > Zen.
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
else if (nameText.indexOf(needle) >= 0) score = 40
else if (descriptionTextMatches(needle, descriptionText)) score = 60
if (entry.kind === "menu" || entry.kind === "link") score -= 2
// App rows sort after all menu items, so they lose the tiebreak below to an
// equal match. Outrank those, but stay inside the tier so better ones win.
if (entry.kind === "app") score -= 5
return score * 1000 + depthFor(items, entry.id) * 25 + entry.order
}
function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) {
var target = entry.kind === "link" ? entry.target : entry.id
return {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
iconFont: entry.iconFont || "",
appIcon: entry.appIcon || "",
appId: entry.appId || "",
label: labelFor(entry, checkedResults),
target: target,
detail: detail || "",
path: pathFor(items, entry.id),
childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0,
action: entry.action || "",
provider: entry.provider || "",
score: score || 0,
section: section || ""
}
}
// Commands a `checked:` expression reads a value out of. Every sibling row
// asks the same one -- Defaults > Browser has seven rows all comparing
// against `blob-default-browser` -- so the batch runs it once and the rows
// read the captured answer.
//
// The capture has to be eager. These are read inside `$(...)`, and a value
// cached while one expression runs lives in that subshell only, so a lazy
// memo never survives to the expression after it.
var GUARD_READERS = [
"blob-default-browser",
"blob-default-editor",
"blob-default-terminal",
"blob-network-dns"
]
// Package and command presence account for most of what the guards ask, and
// asked one at a time they are almost all fork: the shipped menu spends over
// a second on them. Answer them inside the guard process instead. These
// shadow the real commands for the batch only, so they have to agree with
// them everywhere, including for no arguments at all (present is true of
// nothing, missing is not).
//
// `pacman -Q` resolves a name through what installed packages provide, not
// just what they are called -- with gvim installed it reports `vim` as
// present -- so the set has to carry provides too, or `install.editor.vim`
// comes back and offers to install what is already there. A version
// constraint (`bash>=1`) is not a name any set can answer, so it goes to
// pacman itself; no shipped guard writes one.
//
// `pacman -Qi` wraps a long list across continuation lines whenever COLUMNS
// is set in the environment, which a login shell may well have done, so the
// parser follows the indented lines rather than reading the first one and
// dropping half of what is installed.
function guardHelpers() {
return 'declare -A __blob_pkgs=()\n'
+ 'mapfile -t __blob_pkg_names < <({ pacman -Qq; LC_ALL=C pacman -Qi'
+ " | awk '/^[A-Za-z]/ { provides = ($0 ~ /^Provides/); sub(/^[^:]*: /, \"\") }"
+ ' provides && $0 != "None" { n = split($0, p, " ");'
+ ' for (i = 1; i <= n; i++) { sub(/[<>=].*/, "", p[i]); print p[i] } }\'; } 2>/dev/null)\n'
+ 'for __blob_pkg in "${__blob_pkg_names[@]}"; do __blob_pkgs[$__blob_pkg]=1; done\n'
+ '__blob_pkg_has() { [[ -n ${__blob_pkgs[$1]-} ]] && return 0; '
+ '[[ $1 == *[\\<\\>=]* ]] && { pacman -Q "$1" &>/dev/null; return; }; return 1; }\n'
+ 'blob-pkg-present() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 1; done; return 0; }\n'
+ 'blob-pkg-missing() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 0; done; return 1; }\n'
+ 'blob-cmd-present() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 1; done; return 0; }\n'
+ 'blob-cmd-missing() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 0; done; return 1; }\n'
}
// Substitute the captured answer into the expression rather than shadowing
// the reader with a function. `$(reader)` and the variable holding what it
// printed are interchangeable -- both strip trailing newlines, both split the
// same way unquoted -- while a function would also catch `command -v reader`,
// `VAR=x reader`, and every other form, and answer those wrong. Anything but
// the plain substitution is left alone to run the real command.
function guardPrelude(guards) {
var prelude = guardHelpers()
for (var i = 0; i < GUARD_READERS.length; i++) {
// The guards arrive already substituted, so what marks a reader as wanted
// is the slot standing in for it, not the call it replaced.
if (guards.indexOf(guardReaderSlot(i)) < 0) continue
// `|| :` so a reader that exits nonzero cannot take the batch down with
// it under a login shell that turned on errexit.
prelude += "__blob_read_" + i + "=$(" + GUARD_READERS[i] + " 2>/dev/null) || :\n"
}
return prelude
}
function guardReaderSlot(index) {
return "${__blob_read_" + index + "}"
}
function substituteGuardReaders(expression) {
for (var i = 0; i < GUARD_READERS.length; i++)
expression = expression.split("$(" + GUARD_READERS[i] + ")").join(guardReaderSlot(i))
return expression
}
function guardLine(id, tag, expression) {
return "if { " + substituteGuardReaders(expression) + "; } >/dev/null 2>&1; then echo "
+ id + ":" + tag + ":1; else echo " + id + ":" + tag + ":0; fi\n"
}
// One bash script for every `when:` and `checked:` in the menu, reporting
// `<id>:<w|c>:<0|1>` per line. Speed is the whole point: the menu opens on
// the last evaluation's answers, so however long this takes is how long a row
// can contradict the state it describes.
function guardScript(items) {
var guards = ""
var ids = Object.keys(items || {})
for (var i = 0; i < ids.length; i++) {
var entry = items[ids[i]]
if (!entry) continue
if (entry.when) guards += guardLine(ids[i], "w", entry.when)
if (entry.checked) guards += guardLine(ids[i], "c", entry.checked)
}
return guards ? guardPrelude(guards) + guards : ""
}
if (typeof module !== "undefined") {
module.exports = {
guardReaders: GUARD_READERS,
guardScript: guardScript,
stripJsonc: stripJsonc,
normalizeAliases: normalizeAliases,
normalizeItem: normalizeItem,
parseMenuJsonc: parseMenuJsonc,
mergeMenuSources: mergeMenuSources,
mergeAppRows: mergeAppRows,
swapProviderRows: swapProviderRows,
item: item,
resolveRoute: resolveRoute,
slugify: slugify,
depthFor: depthFor,
pathFor: pathFor,
parentPathFor: parentPathFor,
isDescendantOf: isDescendantOf,
childCount: childCount,
isVisible: isVisible,
labelFor: labelFor,
searchableToken: searchableToken,
leafIdFor: leafIdFor,
nameSearchText: nameSearchText,
termInSearchWords: termInSearchWords,
descriptionTextMatches: descriptionTextMatches,
matchesQuery: matchesQuery,
searchScore: searchScore,
displayRow: displayRow
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "blob.menu",
"name": "Blob menu",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell-powered Blob command menu",
"kinds": [
"menu",
"bar-widget"
],
"keepLoaded": true,
"entryPoints": {
"menu": "Menu.qml",
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Blob menu",
"description": "Launches the Blob menu",
"category": "Compositor",
"allowMultiple": false
}
}
@@ -0,0 +1,479 @@
function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
// True when a `<...>` run is an image tag, so the name is read the way Qt's
// parser reads it: after the `<`, the leading run of letters and digits.
//
// Skip everything up to that run rather than matching the separator, because
// there is no JavaScript expression for what Qt skips. QQuickStyledText calls
// skipSpace(), which is QChar::isSpace(), and that set is not `\s`: Qt counts
// U+0085 NEL and `\s` does not, while `\s` counts U+FEFF and Qt does not. A
// name read with `\s` therefore misses a tag written as `<`, U+0085, `img`:
// Qt skips the NEL, reads `img` and issues the GET, while the regex finds no
// name at all and the tag is kept. Measured against Qt 6.11.2.
//
// Over-skipping is the safe direction. It can only classify more runs as
// images, and dropping a run never manufactures a tag: a dropped run joins two
// stretches of text that each contain no `<`.
function isImageTag(tag) {
var name = /^<[^A-Za-z0-9]*([A-Za-z0-9]+)/.exec(tag)
return !!name && name[1].toLowerCase() === "img"
}
// The body renders as StyledText so notifications can use the markup the
// body-markup capability advertises (see Service.qml). StyledText honours
// <img src>, and a remote src makes the shell issue an unauthenticated GET
// with no user action, so image tags go before the renderer sees them.
//
// Work in whole tags, never in substrings of one. A `<` opens a tag that runs
// to the next `>`, nested `<` and all, and only a tag whose own name is `img`
// is dropped.
//
// That is the conservative bound, not Qt's exact one: Qt lets a `>` inside a
// quoted attribute value pass without closing the tag, so a Qt tag can be
// longer than the run taken here. Do not "correct" this to match Qt. Taking
// the shorter run only ever splits one Qt tag into several, and a split can
// only expose an `<img` to be dropped, never hide one — whereas honouring
// quotes would let `<b title="a>b"><img src="http://host/x.png">` through.
//
// Deleting a substring is what makes a naive `/<img[^>]*>/g` unsafe. Given
//
// <im<img src="http://a/decoy.png">g src="http://a/beacon.png">
//
// Qt reads ONE malformed tag named `im` and renders nothing, but removing the
// inner match closes the surviving halves up into `<img src=".../beacon.png">`
// — a live tag the input never contained. The stripper would be manufacturing
// the very thing it exists to remove.
//
// Because every `<` opens a tag, the text between tags never contains one, so
// dropping a tag cannot splice its neighbours into a new one. That makes a
// single pass sufficient, with no re-scanning and no input bound to police.
function stripImageTags(text) {
var out = ""
var i = 0
while (i < text.length) {
var open = text.indexOf("<", i)
if (open === -1) {
out += text.slice(i)
break
}
out += text.slice(i, open)
// An unterminated tag at the end of the string still reaches the renderer,
// which closes it itself, so treat the remainder as one tag.
var close = text.indexOf(">", open)
var tag = close === -1 ? text.slice(open) : text.slice(open, close + 1)
if (!isImageTag(tag)) out += tag
i = close === -1 ? text.length : close + 1
}
return out
}
// What the card renders, and the last thing to touch the string before Qt parses
// it. The newline rewrite belongs here rather than in the card because it inserts
// `<br/>` into text stripImageTags chose to KEEP, and a kept tag may hold a `<` of
// its own: `<x`, newline, `<img src="http://…">` is one tag named `x` to both the
// stripper and Qt, until the rewrite splits it into `<x<br/>` and a live image tag
// the input never contained. Measured against Qt 6.11.2 — the rewritten form
// fetches, the original does not. So strip again after, and what Qt parses is what
// was checked last.
function styledBody(body, app, appIcon) {
return stripImageTags(sanitizeBody(body, app, appIcon).replace(/\r\n|\r|\n/g, "<br/>"))
}
function sanitizeBody(body, app, appIcon) {
var text = stripImageTags(String(body || ""))
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "blob-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function isEphemeralApp(appName) {
var name = String(appName || "")
return name === "notify-send" || name === "blob-action"
}
function stringHint(hints, name) {
try {
if (hints) {
var value = hints[name]
if (value !== undefined && value !== null) return String(value)
}
} catch (e) {
}
return ""
}
function glyphFromHints(hints) {
return stringHint(hints, "blob-glyph")
}
// The click action: a JSON argv string from blob-notify-send
// --exec. Carried as data so a toast restored after a shell restart stays
// clickable (a libnotify action can't — its sender is gone). Run via
// Util.execArgv as bash positional parameters, never a shell string, so
// attacker-controlled values (a title, a filename) can't become commands.
function execArgvFromHints(hints) {
return stringHint(hints, "blob-exec")
}
// Validate a persisted blob-exec into a runnable argv, or null. This is
// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a
// non-string or empty program, or a leading-dash program that argv would read as
// an option). It does not judge intent — a well-formed ["bash","-c",…] is
// accepted. WHICH senders may set this hint is a separate boundary: any
// session-bus process can, by the freedesktop protocol's design (see
// docs/notifications.md), which is equivalent to same-uid code execution.
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
}
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
var expireTimeout = Number(n.expireTimeout || 0)
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
urgency: n.urgency,
expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp
}
}
// Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
function popupRoles() {
return POPUP_ROLES
}
// Whether a refresh has anything to write. Each property a client updates
// emits its own signal, and the catch-up refresh after a row is inserted
// usually finds the object exactly as it was snapshotted — without this,
// one update would rewrite the file several times over.
function popupRowChanged(row, updated) {
var current = row || {}
var next = updated || {}
for (var i = 0; i < POPUP_ROLES.length; i++) {
var role = POPUP_ROLES[i]
if (current[role] !== next[role]) return true
}
return false
}
// A client updating a notification through replaces_id keeps the identity of
// the popup it took over: the file name is the timestamp and id the popup was
// first persisted under, and the restore, replace and archive paths all key
// off that name. Only what the card draws comes from the updated object.
function replacementSnapshot(notification, originalId, timestamp) {
var updated = snapshotOf(notification, timestamp)
updated.id = originalId
updated.originalId = originalId
return updated
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0,
timestamp: e.timestamp || 0
}
}
// notifications.json holds nothing but the last-set DND preference now that
// history is a directory of files. Older versions kept `pending`/`past`
// (and, older still, `entries`) arrays in there; their presence is reported
// so the service can rewrite the file without the dead payload.
function parseSettings(raw) {
var text = String(raw || "").trim()
if (!text) return { error: false, dnd: null, legacy: false }
try {
var parsed = JSON.parse(text)
return {
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
}
} catch (e) {
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
}
}
// ---------------------------------------------------- popup persistence
//
// Each on-screen popup is mirrored to its own file under
// ~/.local/state/blob/notifications/ so toasts survive shell restarts
// (e.g. the restart `blob-update` performs). The file exists exactly as
// long as the popup is on screen: it is written when the toast appears and
// moved into the history/ subdirectory when the toast expires, is dismissed,
// or its action is invoked. History is those moved files, newest last-10.
function popupEntry(value, normalUrgency) {
var entry = historyEntry(value, normalUrgency)
var expire = Number((value || {}).expireTimeout || 0)
if (!isFinite(expire) || expire < 0) expire = 0
entry.expireTimeout = expire
// Absolute expiry deadline, set only when a restore resets a surviving
// popup's display lifetime. Kept out of the entry entirely when unset so
// restored rows match the roles of freshly received ones.
var deadline = Number((value || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
return entry
}
function popupFileName(entry) {
return imageStem(entry) + ".json"
}
// ---------------------------------------------------- persisted images
//
// A notification's images only exist while it is live: Chromium-family
// senders (all Blob web apps) delete their scoped /tmp files on close,
// and image-data hints surface as in-process image:// URLs that die with
// the server object. Persisted entries therefore reference their own
// copies, named by the entry's file stem so cleanup can find them from
// the JSON file name alone.
var PERSISTED_IMAGE_ROLES = ["appIcon", "image"]
function imageStem(entry) {
var e = entry || {}
return String(e.timestamp || 0) + "-" + String(e.originalId || 0)
}
// The filesystem path behind a file-backed image value, or "" for anything
// a copy can't capture: themed icon names, in-process image:// URLs, empty.
function localImageFile(value) {
var s = String(value || "")
if (s.indexOf("file://") === 0) {
s = s.slice(7)
try { s = decodeURIComponent(s) } catch (e) {}
}
return s.charAt(0) === "/" ? s : ""
}
// The entry as it should hit the disk, plus the copies that make it true.
// File-backed images redirect to their copy under imagesDir; dead image://
// URLs drop to "" (the card falls back to the app icon). Already-redirected
// values map onto themselves and produce no copy, keeping restores no-ops.
function persistablePopup(entry, imagesDir) {
var e = entry || {}
var out = {}
for (var key in e) out[key] = e[key]
var copies = []
for (var i = 0; i < PERSISTED_IMAGE_ROLES.length; i++) {
var role = PERSISTED_IMAGE_ROLES[i]
var value = String(out[role] || "")
if (!value) continue
var source = localImageFile(value)
if (source) {
var copy = String(imagesDir || "") + imageStem(e) + "-" + role
if (source !== copy) copies.push({ from: source, to: copy })
out[role] = "file://" + copy
} else if (value.indexOf("image://") === 0) {
out[role] = ""
}
}
return { entry: out, copies: copies }
}
function serializePopup(entry, normalUrgency) {
// Compact (single-line) on purpose: restore cats every file together and
// parses line by line, which only works when each file is one line.
return JSON.stringify(popupEntry(entry, normalUrgency))
}
// Parse the concatenation of every persisted popup file into entries,
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
// with every server process, so two files sharing an id are usually
// different generations — dropping the older one would silently discard a
// restored critical alert the moment a fresh notification reuses its id.
// The one case that leaves a genuine duplicate (a crash between a
// replacement's write and the replaced file's delete) merely re-shows a
// superseded toast, which expires or is dismissed and cleans itself up.
function parsePopupFiles(raw, normalUrgency) {
var lines = String(raw || "").split("\n")
var entries = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var value = JSON.parse(line)
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
} catch (e) {
// A torn write from a crash mid-save — skip the line, keep the rest.
}
}
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return entries
}
// A persisted popup whose lifetime already ran out would have expired on
// screen had the shell kept running, so it is not restored. duration 0 means
// the popup never expires (critical urgency) and always survives restarts.
// A restore-reset deadline outranks the original timestamp: without it, a
// second restart would judge a re-shown toast by a clock that no longer
// governs its display and drop it while it is still on screen.
function popupExpired(entry, duration, now) {
var deadline = Number((entry || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
var lifetime = Number(duration || 0)
if (!isFinite(lifetime) || lifetime <= 0) return false
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
}
function popupPlacement(barPosition, barClearance, gapsOut) {
var position = String(barPosition || "top")
var clearance = Number(barClearance)
var gap = Number(gapsOut)
if (!isFinite(clearance)) clearance = 0
if (!isFinite(gap)) gap = 0
return {
anchors: { top: true, bottom: false, left: false, right: true },
margins: {
top: position === "top" ? clearance : gap,
bottom: gap,
left: gap,
right: position === "right" ? clearance : gap
}
}
}
// The archived files are the history. They are read back exactly like the
// live popup files, then normalized into history rows: replaying a toast
// must not inherit the original's expire timeout or restore deadline, so it
// gets the standard on-screen lifetime for its urgency instead.
//
// liveRows are the toasts still on screen when the replay was asked for.
// They belong in it — they're the newest notifications there are — but the
// directory read races their archival, so they're carried across by hand and
// keyed by file name (timestamp + id) to drop the copy the read already saw.
function historyRows(raw, liveRows, normalUrgency, limit) {
var max = limit === undefined || limit === null ? 10 : Number(limit)
if (isNaN(max)) max = 10
max = Math.max(0, max)
var out = []
var seen = {}
function collect(rows) {
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
if (!entry) continue
var key = popupFileName(entry)
if (seen[key]) continue
seen[key] = true
out.push(historyEntry(entry, normalUrgency))
}
}
collect(Array.isArray(liveRows) ? liveRows : [])
collect(parsePopupFiles(raw, normalUrgency))
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out.slice(0, max)
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
styledBody: styledBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
isEphemeralApp: isEphemeralApp,
stringHint: stringHint,
glyphFromHints: glyphFromHints,
execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf,
popupRoles: popupRoles,
popupRowChanged: popupRowChanged,
replacementSnapshot: replacementSnapshot,
historyEntry: historyEntry,
parseSettings: parseSettings,
historyRows: historyRows,
popupEntry: popupEntry,
popupFileName: popupFileName,
imageStem: imageStem,
localImageFile: localImageFile,
persistablePopup: persistablePopup,
serializePopup: serializePopup,
parsePopupFiles: parsePopupFiles,
popupExpired: popupExpired,
popupPlacement: popupPlacement
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
// Notification card. Pure presentational — no service, Notification, or
// ListModel references. The popup container drives lifetime; the history
// panel drives static rendering. Both use the same component.
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
import "../NotificationLogic.js" as NotificationLogic
BorderSurface {
id: root
property string app: ""
property string appIcon: ""
property string summary: ""
property string body: ""
property string image: ""
// Nerd Font glyph rendered in the icon slot when no real icon is set.
// Used by blob-notify-send so user-action toasts (`Silenced
// notifications` etc.) show their bell/lock/etc. glyph without leaking
// into the summary text.
property string glyph: ""
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 0
// System monospace font injected by the container.
property string fontFamily: ""
readonly property bool hovered: hoverTracker.hovered
signal closeRequested()
signal cardClicked()
// Prefer per-notification media/avatar data, then fall back to the app icon.
// The `check` flag avoids Qt's missing-texture placeholder for unknown names.
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
readonly property bool hasGlyph: glyph.length > 0
readonly property bool compactGlyph: NotificationLogic.shouldRenderCompactGlyph(glyph, smallIconSource, singleLineToast)
readonly property bool hasSmallIcon: smallIconSource.length > 0
readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary)
readonly property bool singleLineToast: sanitizedBody.length === 0
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
readonly property string sanitizedBody: sanitizeBody(body)
readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon)
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
readonly property var cardBorderSpec: Border.surfaceSpec("notifications", "border", Color.notifications.border, Math.max(1, Style.space(2)))
function sanitizeBody(s) {
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function iconSource(icon) {
var value = String(icon || "")
if (value.length === 0) return ""
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
if (value.charAt(0) === "/") return Util.fileUrl(value)
return Quickshell.iconPath(value, true)
}
implicitWidth: Style.space(380)
// Add vertical border insets so mainColumn (inset by border on top/left/right)
// doesn't push content under the bottom edge.
implicitHeight: mainColumn.implicitHeight + borderTop + borderBottom
radius: cornerRadius
color: Color.notifications.background
borderSpec: cardBorderSpec
clip: true
HoverHandler { id: hoverTracker }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
root.closeRequested()
} else {
root.cardClicked()
}
}
}
ColumnLayout {
id: mainColumn
// Inset by the card border so the content doesn't paint over the card's
// outer border.
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: root.borderTop
anchors.leftMargin: root.borderLeft
anchors.rightMargin: root.borderRight
spacing: 0
// Text content.
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.space(12)
Layout.rightMargin: Style.space(12)
Layout.topMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
Layout.bottomMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
spacing: root.collapseRedundantIcon ? 0 : (root.compactGlyph ? Style.space(8) : Style.space(12))
Item {
id: smallIconSlot
Layout.preferredWidth: visible ? Style.space(40) : 0
Layout.preferredHeight: visible ? Style.space(40) : 0
Layout.alignment: Qt.AlignVCenter
// Hide the slot when the icon failed to resolve (themed-icon name
// not in the user's icon theme) AND we don't have a glyph fallback
// — prevents rendering Qt's pink broken-image placeholder.
visible: !root.collapseRedundantIcon && !root.compactGlyph && (root.hasSmallIcon || root.hasGlyph) && (root.hasGlyph || smallIconImage.status !== Image.Error)
Image {
id: smallIconImage
anchors.fill: parent
source: root.smallIconSource
sourceSize.width: smallIconSlot.width * Screen.devicePixelRatio
sourceSize.height: smallIconSlot.height * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
}
// Glyph fallback (Nerd Font character) when no image icon is
// available. Used by blob-notify-send's `-g` flag.
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
}
}
Text {
textFormat: Text.PlainText
Layout.alignment: Qt.AlignVCenter
visible: root.compactGlyph
text: root.glyph
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: Style.font.icon
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.space(2)
Text {
// The spec defines the summary as a single line of plain text, so
// AutoText could only ever promote a hostile string to rich text.
// The body below is StyledText on purpose — see Service.qml's
// bodyMarkupSupported — and is stripped in NotificationLogic.
textFormat: Text.PlainText
Layout.fillWidth: true
visible: root.summary.length > 0
text: root.summary
font.family: "Liberation Sans"
color: Color.notifications.text
font.pixelSize: Style.font.title
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
Text {
Layout.fillWidth: true
Layout.topMargin: Style.space(2)
visible: root.sanitizedBody.length > 0
text: root.styledBody
textFormat: Text.StyledText
font.family: "Liberation Sans"
color: root.bodyColor
font.pixelSize: Style.font.title
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 3
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.notifications",
"name": "Notifications",
"version": "1.0.0",
"author": "Blob",
"description": "Notification daemon, popups, DND, and history",
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+206
View File
@@ -0,0 +1,206 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "OsdModel.js" as OsdModel
Item {
id: root
property bool opened: false
property string icon: ""
property string message: ""
property string iconKey: ""
property int value: 0
property int maxValue: 100
property bool hasProgress: true
property int duration: 1200
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
// The card is built out of measured columns instead of fixed widths, so it
// keeps exactly `pad` between border and content on every side whatever
// glyph or message it carries. Messages grow with their text up to
// `maxMessageWidth` and elide beyond it.
readonly property int pad: Style.space(16)
readonly property int gap: Style.space(16)
// A glyph next to a message reads airier than it measures: the icon outline
// and the letterforms both fall away from their ink extremes, so the space
// between them opens up well past the nominal gap. Text takes two thirds of
// it; the progress bar's hard edge keeps the full gap.
readonly property int messageGap: Math.round(root.gap * 2 / 3)
readonly property int barWidth: Style.space(142)
readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : Style.space(190)
// Nerd Font glyphs draw well outside their monospace cell, so the icon
// column is measured by ink rather than by advance width. Progress OSDs pin
// it to the widest glyph the model can return, so the bar doesn't shift when
// volume crosses an icon threshold.
readonly property int iconInkWidth: Math.ceil(iconMetrics.tightBoundingRect.width)
readonly property int iconWidth: root.hasProgress
? Math.max(root.iconInkWidth, Math.ceil(widestIconMetrics.tightBoundingRect.width))
: root.iconInkWidth
// Same idea for the readout: it is as wide as the longest percentage so the
// digits don't jitter between 9% and 100%.
readonly property int valueWidth: Math.ceil(Math.max(valueMetrics.advanceWidth, messageMetrics.advanceWidth))
readonly property int messageWidth: Math.min(Math.ceil(messageMetrics.advanceWidth), root.maxMessageWidth)
readonly property int contentWidth: root.hasProgress
? root.iconWidth + root.gap + root.barWidth + root.gap + root.valueWidth
: (root.message === "" ? root.iconWidth : root.iconWidth + root.messageGap + root.messageWidth)
function iconFor(name, percent) {
return OsdModel.iconFor(name, percent)
}
function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration)
// Update before opening so a fresh OSD starts at its new value; only
// subsequent updates while it remains open animate the progress bar.
iconKey = next.iconKey
maxValue = next.maxValue
hasProgress = next.hasProgress
value = next.value
message = next.message
icon = next.icon
duration = next.duration
opened = true
if (duration > 0) hideTimer.restart()
else hideTimer.stop()
}
function open(payloadJson) {
try {
var p = JSON.parse(payloadJson || "{}")
show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration))
} catch (e) {}
}
function close() { opened = false }
Timer {
id: hideTimer
interval: root.duration
onTriggered: root.opened = false
}
TextMetrics {
id: messageMetrics
font.family: Style.font.family
font.bold: true
font.pixelSize: Style.font.title
text: root.message
}
TextMetrics {
id: valueMetrics
font: messageMetrics.font
text: "100%"
}
TextMetrics {
id: iconMetrics
font.family: Style.font.family
font.pixelSize: Style.font.displayLarge
text: root.icon
}
TextMetrics {
id: widestIconMetrics
font: iconMetrics.font
text: OsdModel.widestIcon
}
IpcHandler {
target: "osd"
function show(payloadJson: string): string {
root.open(payloadJson)
return "ok"
}
function close(): string { root.close(); return "ok" }
function state(): string { return root.opened ? "open" : "closed" }
function ping(): string { return "ok" }
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-osd"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
// Visual-only surface: keep the layer-shell input region empty so the OSD
// never blocks clicks to the desktop below it.
mask: Region {}
BorderSurface {
id: card
width: card.borderLeft + root.pad + root.contentWidth + root.pad + card.borderRight
height: card.borderTop + root.pad + Style.font.displayLarge + root.pad + card.borderBottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Style.space(67)
color: Util.alpha(Color.background, 0.97)
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
radius: Style.cornerRadius
opacity: root.opened ? 1 : 0
Row {
anchors.fill: parent
anchors.topMargin: card.borderTop + root.pad
anchors.rightMargin: card.borderRight + root.pad
anchors.bottomMargin: card.borderBottom + root.pad
anchors.leftMargin: card.borderLeft + root.pad
spacing: root.hasProgress ? root.gap : root.messageGap
Item {
width: root.iconWidth
height: parent.height
Text {
textFormat: Text.PlainText
// Sit the glyph's ink flush in the column, centered when the
// column is wider than this particular glyph.
x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x)
anchors.verticalCenter: parent.verticalCenter
text: root.icon
font: iconMetrics.font
color: Color.popups.text
}
}
Rectangle {
visible: root.hasProgress
width: root.barWidth
height: Math.max(Style.space(6), Style.spacing.sm)
anchors.verticalCenter: parent.verticalCenter
color: Util.alpha(Color.popups.text, 0.45)
Rectangle {
height: parent.height
width: parent.width * (root.hasProgress ? root.value / root.maxValue : 0)
color: Color.accent
Behavior on width {
enabled: root.opened
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
}
Text {
textFormat: Text.PlainText
visible: root.message !== ""
width: root.hasProgress ? root.valueWidth : root.messageWidth
// The readout hugs the card edge so a short percentage doesn't leave
// a hole in the padding; the slack lands in the gap after the bar.
horizontalAlignment: root.hasProgress ? Text.AlignRight : Text.AlignLeft
anchors.verticalCenter: parent.verticalCenter
text: root.message
font: messageMetrics.font
color: Color.popups.text
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}
// The widest glyph `iconFor` can return. The progress OSD sizes its icon
// column to it so the bar keeps its place as the icon changes.
var widestIcon = ""
function iconFor(name, percent) {
var n = String(name || "").toLowerCase()
if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return ""
if (n === "volume-low") return ""
if (n === "volume-medium") return ""
if (n === "volume-high" || n === "volume") return ""
if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭"
if (n === "microphone" || n === "mic") return "󰍬"
if (n === "keyboard") return "󰌌"
if (n === "brightness" || n === "display") return "󰍹"
if (n === "touchpad") return "󰟸"
if (n === "touch" || n === "touchscreen") return "󰝁"
if (n === "reboot" || n === "restart") return "󰜉"
if (n === "shutdown" || n === "power" || n === "poweroff") return "󰐥"
if (n === "logout" || n === "sign-out" || n === "leave") return "󰍃"
if (n === "media" || n === "player") return "󰝚"
if (n === "media-source" || n === "player-source") return "󰝚"
if (n === "media-play" || n === "player-play") return "󰐊"
if (n === "media-pause" || n === "player-pause") return "󰏤"
if (n === "media-next" || n === "player-next") return "󰒭"
if (n === "media-previous" || n === "player-previous") return "󰒮"
if (n.length > 0) return name
if (percent <= 0) return ""
if (percent <= 33) return ""
if (percent <= 66) return ""
return ""
}
function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
var maxValue = Math.max(1, parseInt(rawMax || "100", 10))
var parsedValue = parseInt(rawValue || "0", 10)
var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === ""
var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0
var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1
var parsedDuration = parseInt(rawDuration || "1200", 10)
return {
iconKey: String(iconName || "").toLowerCase(),
maxValue: maxValue,
hasProgress: hasProgress,
value: value,
message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")),
icon: iconFor(iconName, percent),
duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration)
}
}
if (typeof module !== "undefined") {
module.exports = {
widestIcon: widestIcon,
iconFor: iconFor,
stateForShow: stateForShow
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.osd",
"name": "On-screen display",
"version": "1.0.0",
"description": "Quickshell volume, brightness, and status overlays.",
"kinds": [
"panel"
],
"keepLoaded": true,
"entryPoints": {
"panel": "Osd.qml"
}
}
+262
View File
@@ -0,0 +1,262 @@
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
}
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
}
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
}
function parseSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
return next
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(node)) return "󰋋"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
}
function sourceGlyph(node) {
if (!node) return "󰍬"
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || ""
].join(" ")).toLowerCase()
if (blob.indexOf("headset") !== -1) return "󰋋"
if (blob.indexOf("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀"
return "󰍬"
}
function friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
}
function mprisLabelsFor(players, predicate) {
var values = Array.isArray(players) ? players : []
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < values.length; i++) {
var player = values[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
}
function matchingMprisStreamLabel(label, players) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
}
function unmatchedMprisStreamLabel(label, players, streams) {
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
var values = Array.isArray(streams) ? streams : []
for (var i = 0; i < values.length; i++) {
var stream = values[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
}
function streamLabel(node, players, streams) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|| unmatchedMprisStreamLabel(label, players, streams)
|| label) || "Stream"
}
function streamRepresentsPlayer(node, player, players, streams) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
isAudioSource: isAudioSource,
listSnapshot: listSnapshot,
outputVolumeName: outputVolumeName,
parseSinkAvailability: parseSinkAvailability,
friendlyDeviceLabel: friendlyDeviceLabel,
nodeProps: nodeProps,
nodeLabel: nodeLabel,
isHeadphones: isHeadphones,
sinkGlyph: sinkGlyph,
sourceGlyph: sourceGlyph,
friendlyStreamLabel: friendlyStreamLabel,
streamLabelKey: streamLabelKey,
streamLabelIsGeneric: streamLabelIsGeneric,
rawStreamLabel: rawStreamLabel,
mprisPlayerLabel: mprisPlayerLabel,
mprisPlayerIsProxy: mprisPlayerIsProxy,
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
mprisLabelsFor: mprisLabelsFor,
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.audio",
"name": "Audio",
"version": "1.0.0",
"author": "Blob",
"description": "Volume slider, output picker, per-app mixer",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Audio",
"description": "Volume slider, output picker, per-app mixer",
"category": "Audio",
"allowMultiple": false
}
}
+177
View File
@@ -0,0 +1,177 @@
function deviceLabel(device) {
if (!device) return ""
return String(device.deviceName || device.name || "").trim()
}
function toArray(values) {
if (!values) return []
if (Array.isArray(values)) return values.slice()
var length = Number(values.length || 0)
if (!isFinite(length) || length <= 0) return []
var list = []
for (var i = 0; i < length; i++) list.push(values[i])
return list
}
function isUuidLike(value) {
var text = String(value || "").trim()
if (text === "") return false
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|| /^[0-9a-f]{32}$/i.test(text)
|| /^0x[0-9a-f]{4,32}$/i.test(text)
|| /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text)
}
function isAddressLike(value) {
var text = String(value || "").trim()
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
}
function normalizedAddress(value) {
return String(value || "").trim().toLowerCase().replace(/[^0-9a-f]/g, "")
}
function hasHumanName(device) {
var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeText(node) {
var props = nodeProps(node)
return [
node ? node.name : "",
node ? node.description : "",
node ? node.nickname : "",
node ? node.nick : "",
props["node.name"],
props["node.description"],
props["node.nick"],
props["device.name"],
props["device.description"],
props["device.product.name"],
props["device.alias"],
props["device.string"],
props["api.bluez5.address"],
props["bluez5.address"],
props["media.name"]
].join(" ").toLowerCase()
}
function bluetoothSinkMatchesDevice(node, device) {
if (!node || !node.isSink || node.isStream || !device) return false
var address = normalizedAddress(device.address)
var text = nodeText(node)
if (address !== "" && normalizedAddress(text).indexOf(address) !== -1) return true
var label = deviceLabel(device).toLowerCase()
return label !== "" && text.indexOf(label) !== -1
}
function sortedByLabel(devices) {
var list = toArray(devices)
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
return list
}
// Primitives-only projection of a BlueZ device for list-model rows. Holding
// the Device QObject in model data puts a live wrapper into every delegate's
// var property, and BlueZ churn (discovery timeouts, unpair) can destroy the
// object while a delegate is still incubating, which segfaults quickshell.
// Actions resolve the backend object via Panel.deviceFor().
function deviceRow(d) {
if (!d) return null
return {
address: d.address || "",
name: d.name || "",
deviceName: d.deviceName || "",
connected: !!d.connected,
state: d.state !== undefined ? d.state : -1,
batteryAvailable: !!d.batteryAvailable,
battery: d.battery !== undefined ? d.battery : 0,
pairing: !!d.pairing
}
}
function deviceLists(devices) {
var values = toArray(devices)
var connected = []
var known = []
var discovered = []
for (var i = 0; i < values.length; i++) {
var d = values[i]
if (!d || !hasHumanName(d)) continue
if (d.connected) connected.push(d)
else if (d.paired || d.bonded || d.trusted) known.push(d)
else discovered.push(d)
}
return {
connected: sortedByLabel(connected),
known: sortedByLabel(known),
discovered: sortedByLabel(discovered)
}
}
function cloneMap(map) {
var next = ({})
for (var key in map || {}) next[key] = map[key]
return next
}
function pendingAction(actions, address) {
return address && actions && actions[address] ? actions[address] : ""
}
function withPendingAction(actions, address, action) {
var next = cloneMap(actions)
if (!address) return next
if (action) next[address] = action
else delete next[address]
return next
}
function visibleSections(lists, discovering) {
var sections = []
if (lists && lists.connected && lists.connected.length > 0) sections.push("connected")
if (lists && lists.known && lists.known.length > 0) sections.push("known")
if (discovering && lists && lists.discovered && lists.discovered.length > 0) sections.push("discovered")
return sections
}
function sectionDevices(lists, section) {
if (!lists) return []
if (section === "connected") return lists.connected || []
if (section === "known") return lists.known || []
if (section === "discovered") return lists.discovered || []
return []
}
if (typeof module !== "undefined") {
module.exports = {
deviceLabel: deviceLabel,
toArray: toArray,
isUuidLike: isUuidLike,
isAddressLike: isAddressLike,
normalizedAddress: normalizedAddress,
hasHumanName: hasHumanName,
nodeProps: nodeProps,
nodeText: nodeText,
bluetoothSinkMatchesDevice: bluetoothSinkMatchesDevice,
sortedByLabel: sortedByLabel,
deviceRow: deviceRow,
deviceLists: deviceLists,
cloneMap: cloneMap,
pendingAction: pendingAction,
withPendingAction: withPendingAction,
visibleSections: visibleSections,
sectionDevices: sectionDevices
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.bluetooth",
"name": "Bluetooth",
"version": "1.0.0",
"author": "Blob",
"description": "Bluetooth device list with connect/disconnect",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Bluetooth",
"description": "Bluetooth device list with connect/disconnect",
"category": "Network",
"allowMultiple": false
}
}
+180
View File
@@ -0,0 +1,180 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Date/time label for the bar, and the host for the calendar popup.
//
// Left click reveals the calendar — asking "what is the date?" is what a
// click on a clock means — right click walks the common label formats, and
// middle click opens the timezone picker.
BarWidget {
id: root
moduleName: "blob.clock"
property date displayDate: clock.date
readonly property string configuredFormat: vertical
? setting("verticalFormat", "HH\n—\nmm")
: setting("format", "dddd HH:mm")
readonly property string configuredAltFormat: vertical
? setting("verticalFormatAlt", "dd\nMMM\n'W'ww\n''yy")
: setting("formatAlt", "d MMMM 'W'ww yyyy")
readonly property var formatRing: Model.clockFormatRing(configuredFormat, configuredAltFormat, Model.clockFormats(vertical))
// What the bar shows is what shell.json stores, so a cycled format is the
// format from then on rather than something that reverts on restart.
readonly property string activeFormat: configuredFormat
readonly property string displayText: formatted(displayDate)
readonly property var verticalLines: displayText.split("\n")
function refresh() {
displayDate = new Date()
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
}
function cycleFormat() {
var current = String(configuredFormat)
var next = Model.nextClockFormat(formatRing, current)
if (next === "" || next === current) return
var entry = { id: root.moduleName }
for (var key in root.settings) if (key !== "id") entry[key] = root.settings[key]
entry[vertical ? "verticalFormat" : "format"] = next
// Applied locally first so the label changes on the click itself; the
// shell.json write comes back through the bar as the same value.
root.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function formatted(date) {
return Qt.formatDateTime(date, activeFormat.replace(/ww/g, Model.isoWeekLiteral(date.getFullYear(), date.getMonth(), date.getDate())))
}
// ---- Calendar popup. Shape contract for shell.summon/hide/toggle
// routing: Bar.findPanelWidget requires open/close/opened on the
// bar-widget root.
readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false
function open() {
if (panelLoader.item) panelLoader.item.open()
}
function close() {
if (panelLoader.item) panelLoader.item.close()
}
function togglePanel() {
if (panelLoader.item) panelLoader.item.toggle()
}
function toggleWeekStart() {
if (panelLoader.item) panelLoader.item.toggleWeekStart()
}
// The clock fills more slot than it paints a mark for, at both
// orientations: horizontally it is a text label in a padded slot, so the
// dot takes the label width; vertically it is a stack of icon-sized lines,
// so the dot takes one line — the same mark every icon widget gets, rather
// than a rule running the height of the whole stack.
readonly property real openPanelIndicatorWidth: button.labelWidth
readonly property real openPanelIndicatorHeight: Math.max(Style.space(10), Math.round(Style.bar.iconSlot * 0.55))
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
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
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onBarChanged: injectPanel()
onSettingsChanged: injectPanel()
SystemClock {
id: clock
precision: SystemClock.Minutes
onDateChanged: root.displayDate = date
}
Loader {
id: panelLoader
active: true
source: Qt.resolvedUrl("Panel.qml")
visible: false
onLoaded: {
root.injectPanel()
Qt.callLater(root.injectPanel)
}
}
IpcHandler {
target: "blob.clock"
function refresh(): void { root.broadcast("refresh") }
function cycleFormat(): void { root.cycleFormat() }
function toggleWeekStart(): void { root.toggleWeekStart() }
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.togglePanel() }
}
WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.vertical ? "" : root.displayText
labelVisible: !root.vertical
hasVisualContent: root.vertical ? root.verticalLines.length > 0 : text !== ""
fixedHeight: root.vertical ? root.verticalLines.length * Style.bar.iconSlot : -1
horizontalMargin: 8.75
verticalPadding: 8.75
onPressed: function(b) {
if (b === Qt.RightButton) root.cycleFormat()
else if (b === Qt.MiddleButton) { if (root.bar) root.bar.run("blob-menu-timezone") }
else root.togglePanel()
}
Column {
visible: root.vertical
anchors.fill: parent
Repeater {
model: root.verticalLines
OpticalGlyph {
required property string modelData
width: button.width
height: Style.bar.iconSlot
text: modelData
fontFamily: button.fontFamily
fontSize: modelData.length > 3
? button.fontSize * 0.9
: button.fontSize
color: button.foreground
}
}
}
}
}
+296
View File
@@ -0,0 +1,296 @@
// Pure date and format math for the clock widget and its calendar panel.
// Everything here is locale- and Qt-free so it can be unit tested under node
// (test/shell.d/clock-test.sh); the QML owns month/weekday naming through
// Qt.locale().
var MS_PER_DAY = 86400000
// Weekday indices match both JS Date.getDay() and QML's Locale.Sunday…
// Locale.Saturday, so a locale's firstDayOfWeek can be passed straight in.
var WEEKDAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]
// ---- Bar label formats. Right-clicking the clock walks these in order and
// writes the result back to shell.json, so the label the bar shows and
// the format the config stores are always the same thing.
//
// The locale-shaped time presets are each followed by their 12-hour twin, so
// the walk from a 24-hour label to the same label in AM/PM is a single right
// click rather than a lap of the ring. The ISO preset is deliberately left
// without one: ISO 8601 writes time on a 24-hour clock, so an AM/PM variant
// would contradict the only thing that format is for.
var CLOCK_FORMATS = [
"dddd HH:mm",
"dddd h:mm AP",
"HH:mm",
"h:mm AP",
"ddd d MMM HH:mm",
"ddd d MMM h:mm AP",
"d MMMM 'W'ww yyyy",
"yyyy-MM-dd HH:mm"
]
// Vertical bars have room for a few stacked lines and nothing else, so the
// ring stays short. AM/PM costs a fourth line, which is why only the plain
// time carries it here.
var VERTICAL_CLOCK_FORMATS = [
"HH\n—\nmm",
"h\n—\nmm\nAP",
"dd\nMMM\n'W'ww\n''yy",
"HH\nmm"
]
function clockFormats(vertical) {
return vertical ? VERTICAL_CLOCK_FORMATS.slice() : CLOCK_FORMATS.slice()
}
// The presets in a fixed order, plus the configured alternate and current
// format when they are something else. The order must not depend on which
// entry is current: cycling writes the result back to shell.json, and a ring
// that reshuffled itself around the current value would bounce between two
// entries instead of walking.
function clockFormatRing(configured, configuredAlt, presets) {
var ring = []
var candidates = (presets || []).concat([configuredAlt, configured])
for (var i = 0; i < candidates.length; i++) {
var format = String(candidates[i] === undefined || candidates[i] === null ? "" : candidates[i])
if (format === "" || ring.indexOf(format) !== -1) continue
ring.push(format)
}
return ring.length > 0 ? ring : ["HH:mm"]
}
// Next entry after `current`. An unknown current format (a hand-written one
// that is not in the ring) starts the walk at the top.
function nextClockFormat(ring, current) {
if (!ring || ring.length === 0) return ""
var index = ring.indexOf(String(current === undefined || current === null ? "" : current))
return ring[(index + 1) % ring.length]
}
// Two-digit ISO week, substituted into a format's 'ww' token before Qt
// formats it -- Qt has no ISO week specifier of its own.
function isoWeekLiteral(year, month, day) {
return pad2(isoWeek(year, month, day))
}
function pad2(value) {
var n = Number(value)
return (n < 10 ? "0" : "") + n
}
// Stable "yyyy-MM-dd" identity for a day, so a grid cell can be compared
// against today without dragging Date objects through bindings.
function dateKey(year, month, day) {
return year + "-" + pad2(Number(month) + 1) + "-" + pad2(day)
}
function keyForDate(date) {
return dateKey(date.getFullYear(), date.getMonth(), date.getDate())
}
function coerceWeekStart(value) {
if (value === undefined || value === null) return null
if (typeof value === "number")
return isFinite(value) ? ((Math.round(value) % 7) + 7) % 7 : null
var text = String(value).replace(/^\s+|\s+$/g, "").toLowerCase()
if (text === "") return null
for (var i = 0; i < WEEKDAY_NAMES.length; i++)
if (WEEKDAY_NAMES[i] === text || WEEKDAY_NAMES[i].substr(0, 3) === text) return i
var parsed = parseInt(text, 10)
return isFinite(parsed) ? ((parsed % 7) + 7) % 7 : null
}
// Configured week start, falling back to the locale's own first day when
// the setting is missing or nonsense.
function normalizedWeekStart(value, fallback) {
var configured = coerceWeekStart(value)
if (configured !== null) return configured
var fallbackStart = coerceWeekStart(fallback)
return fallbackStart === null ? 1 : fallbackStart
}
function weekStartSettingName(index) {
return WEEKDAY_NAMES[normalizedWeekStart(index, 1)]
}
// The toggle flips between the two conventions people actually switch
// between. A calendar configured to any other start (Saturday, say) is
// shown as-is and lands on Monday the first time it is toggled.
function toggledWeekStart(index) {
return normalizedWeekStart(index, 1) === 1 ? 0 : 1
}
function weekdayOrder(weekStart) {
var start = normalizedWeekStart(weekStart, 1)
var out = []
for (var i = 0; i < 7; i++) out.push((start + i) % 7)
return out
}
// ISO-8601 week number: the week owning the Thursday of that date's
// Monday-based week. Mirrors the clock widget's 'ww' format token.
function isoWeek(year, month, day) {
var date = new Date(Date.UTC(year, month, day))
var weekday = date.getUTCDay() || 7
date.setUTCDate(date.getUTCDate() + 4 - weekday)
var yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
return Math.ceil(((date.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7)
}
function dayOfYear(year, month, day) {
return Math.round((Date.UTC(year, month, day) - Date.UTC(year, 0, 1)) / MS_PER_DAY) + 1
}
function daysInYear(year) {
return dayOfYear(year, 11, 31)
}
// Share of the year already behind you: whole days completed over days in
// the year, so January 1 reads 0% and December 31 reads 100%.
function yearProgress(year, month, day) {
var total = daysInYear(year)
if (total <= 0) return 0
return Math.max(0, Math.min(1, (dayOfYear(year, month, day) - 1) / total))
}
function yearProgressPercent(year, month, day) {
return Math.round(yearProgress(year, month, day) * 100)
}
// Memento mori. The default span is a round number rather than anything from
// an actuarial table: the point of the bar is the reminder, not the
// arithmetic, and whoever wants a different number can say so.
var DEFAULT_LIFE_EXPECTANCY = 90
// A birth year rather than an age, so the bar keeps counting on its own
// instead of going stale the moment it is entered. 0 means "not set", which
// is also what a blank, malformed, future, or implausibly distant year means.
function parseBirthYear(value, currentYear) {
var now = Math.round(Number(currentYear))
if (!isFinite(now)) return 0
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d{4}$/.test(text)) return 0
var year = parseInt(text, 10)
if (!isFinite(year) || year > now || year < now - 120) return 0
return year
}
// Whole years, the way people say their age: born in 1979 makes you 47 for
// all of 2026, whichever side of your birthday today falls.
function ageFromBirthYear(birthYear, currentYear) {
var born = parseBirthYear(birthYear, currentYear)
if (born <= 0) return 0
return Math.round(Number(currentYear)) - born
}
// 0 means "not set", which is also what a blank, negative, fractional, or
// absurd entry means — the life bar simply stays hidden.
function parseAge(value) {
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d+$/.test(text)) return 0
var years = parseInt(text, 10)
if (!isFinite(years) || years <= 0 || years > 120) return 0
return years
}
// Unset or nonsense falls back to the default rather than to zero, so the
// bar always has something to measure against.
function parseLifeExpectancy(value) {
var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "")
if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY
var years = parseInt(text, 10)
if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY
return years
}
function lifeProgress(age, expectancy) {
var years = parseAge(age)
var span = parseLifeExpectancy(expectancy)
if (years <= 0 || span <= 0) return 0
return Math.max(0, Math.min(1, years / span))
}
function lifeProgressPercent(age, expectancy) {
return Math.round(lifeProgress(age, expectancy) * 100)
}
// Always six rows of seven days. A fixed grid keeps the popup exactly the
// same height in every month, so stepping through the year never makes the
// panel jump under the pointer.
function monthGrid(year, month, weekStart, todayKey) {
var start = normalizedWeekStart(weekStart, 1)
var leading = (new Date(year, month, 1).getDay() - start + 7) % 7
var cursor = new Date(year, month, 1 - leading)
var today = String(todayKey || "")
var weeks = []
for (var w = 0; w < 6; w++) {
var days = []
var thursday = null
for (var d = 0; d < 7; d++) {
var cellYear = cursor.getFullYear()
var cellMonth = cursor.getMonth()
var cellDay = cursor.getDate()
var weekday = cursor.getDay()
var key = dateKey(cellYear, cellMonth, cellDay)
if (weekday === 4) thursday = { year: cellYear, month: cellMonth, day: cellDay }
days.push({
key: key,
year: cellYear,
month: cellMonth,
day: cellDay,
weekday: weekday,
inMonth: cellMonth === month && cellYear === year,
weekend: weekday === 0 || weekday === 6,
today: key === today
})
cursor.setDate(cursor.getDate() + 1)
}
// Number every row by the ISO week owning its Thursday. That is the
// definition itself for Monday-start weeks, and the only answer that
// stays stable for the other starts, where a row straddles two ISO
// weeks but shares all of Monday through Thursday with one of them.
var anchor = thursday || days[0]
weeks.push({
week: isoWeek(anchor.year, anchor.month, anchor.day),
days: days
})
}
return weeks
}
function stepMonth(year, month, delta) {
var target = new Date(year, Number(month) + Number(delta), 1)
return { year: target.getFullYear(), month: target.getMonth() }
}
if (typeof module !== "undefined") {
module.exports = {
dateKey: dateKey,
keyForDate: keyForDate,
normalizedWeekStart: normalizedWeekStart,
weekStartSettingName: weekStartSettingName,
toggledWeekStart: toggledWeekStart,
weekdayOrder: weekdayOrder,
isoWeek: isoWeek,
dayOfYear: dayOfYear,
daysInYear: daysInYear,
yearProgress: yearProgress,
yearProgressPercent: yearProgressPercent,
parseAge: parseAge,
parseBirthYear: parseBirthYear,
ageFromBirthYear: ageFromBirthYear,
parseLifeExpectancy: parseLifeExpectancy,
lifeProgress: lifeProgress,
lifeProgressPercent: lifeProgressPercent,
monthGrid: monthGrid,
stepMonth: stepMonth,
clockFormats: clockFormats,
clockFormatRing: clockFormatRing,
nextClockFormat: nextClockFormat,
isoWeekLiteral: isoWeekLiteral
}
}
+762
View File
@@ -0,0 +1,762 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Ui
import "Model.js" as Model
// The clock's calendar popup: a month grid with ISO week numbers, built to
// sit beside the weather panel — same hero-over-detail composition, same
// spacing scale, same small-caps labels.
//
// The grid is a read-out rather than a picker: today is the only marked
// day, and the only thing that moves is which month is on screen —
// chevrons, the scroll wheel, and the arrow keys all step it.
//
// BarWidget.qml owns the bar label and hands this panel the button to
// anchor against.
Panel {
id: root
moduleName: "blob.clock"
ipcTarget: "blob.clock"
manageIpc: false
property var anchorItem: null
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
// ---- Today. SystemClock keeps this honest across midnight so the
// highlight rolls over without the panel being reopened.
property date today: new Date()
readonly property string todayKey: Model.keyForDate(today)
// The month on screen. Stepping moves this and nothing else: the grid is
// a read-out, not a picker, so there is no per-day cursor to keep in sync.
property int viewYear: today.getFullYear()
property int viewMonth: today.getMonth()
readonly property date viewDate: new Date(viewYear, viewMonth, 1)
readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth()
// Pinned to today, not to the month being browsed — stepping through the
// calendar does not change how much of the year is gone.
readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate())
readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate())
// Memento mori, for anyone who goes looking: double-tapping the year bar
// asks for a birth year and a life expectancy, and a second bar tracks one
// against the other. A birth year rather than an age, so it keeps counting
// on its own. Without one the bar stays hidden.
readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear())
readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear())
readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0))
readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy)
readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy)
property bool editingLife: false
// Unset falls through to the locale's own first day, so a fresh install
// starts out matching the rest of the desktop rather than a hardcoded
// convention. Clicking the grid's "W" heading writes the choice back to
// shell.json.
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
// The interface is English throughout, so day names are not taken from the
// system locale. Where the week starts still is: that is a regional
// convention rather than a translation, and it stays overridable above.
readonly property var labelLocale: Qt.locale("en_US")
readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
readonly property var weekdays: Model.weekdayOrder(weekStart)
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
// Guarded so the widget renders before the bar is injected (the bar-widget
// contract instantiates it bare).
readonly property color contentForeground: bar ? bar.foreground : Color.foreground
readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family
readonly property int cellWidth: Style.space(52)
readonly property int cellHeight: Style.space(34)
readonly property int cellSpacing: Style.space(2)
readonly property int weekColumnWidth: Style.space(32)
readonly property int gutterWidth: Style.space(14)
function open() {
refresh()
root.controller.show()
// Set after showing, not before: showing hands the popout coordinator
// over, which closes whichever panel was open, and that close clears the
// shared flag. Deferring means the panel taking over always wins, while
// a handoff to a panel that does not manage the flag still leaves it
// cleared rather than stuck on.
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
setCenterHoverRevealSuppressed(false)
// Dismissing the panel mid-edit would otherwise leave the inputs up,
// waiting behind a closed popup for the next time it opens.
if (root.editingLife) root.cancelEditingLife()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.open()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
// Summoning by hotkey moves no pointer, so a hover the bar was still
// holding must not keep the center indicators revealed behind the panel.
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
}
function refresh() {
root.today = new Date()
root.goToToday()
}
function goToToday() {
root.viewYear = today.getFullYear()
root.viewMonth = today.getMonth()
}
function moveMonth(delta) {
var next = Model.stepMonth(viewYear, viewMonth, delta)
root.viewYear = next.year
root.viewMonth = next.month
}
function moveYear(delta) {
moveMonth(delta * 12)
}
// Applied locally first so the panel redraws on the click itself; the
// shell.json write comes back through the bar as the same value. With no
// writable entry (the widget is not in the layout) it stays a session-only
// preference rather than doing nothing. The host widget builds its own
// entry when the label format is cycled, so it has to be kept in step or
// it would write this key straight back out from a stale copy.
function persistSettings(values) {
var entry = { id: root.moduleName }
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
for (var key in values) entry[key] = values[key]
root.settings = entry
if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function setWeekStart(day) {
var next = Model.normalizedWeekStart(day, root.weekStart)
if (next === root.weekStart) return
persistSettings({ weekStartDay: Model.weekStartSettingName(next) })
}
function startEditingLife() {
root.editingLife = true
Qt.callLater(function() {
bornField.text = root.birthYear > 0 ? String(root.birthYear) : ""
expectancyField.text = String(root.lifeExpectancy)
bornField.selectAll()
bornField.forceActiveFocus()
})
}
function cancelEditingLife() {
root.editingLife = false
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
// Shared by both fields: Tab hops to the other one, Enter commits the pair,
// Escape drops the lot.
function handleLifeKey(event, other) {
if (event.key === Qt.Key_Escape) {
root.cancelEditingLife()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.commitLife()
event.accepted = true
} else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
other.selectAll()
other.forceActiveFocus()
event.accepted = true
}
}
// Double-tapping the life bar puts it away again. The expectancy stays in
// the config so setting a birth year again brings your own number back
// rather than the default.
function clearLife() {
if (root.birthYear <= 0) return
persistSettings({ birthYear: 0 })
}
function commitLife() {
var born = Model.parseBirthYear(bornField.text, today.getFullYear())
var span = Model.parseLifeExpectancy(expectancyField.text)
if (born !== root.birthYear || span !== root.lifeExpectancy)
persistSettings({ birthYear: born, lifeExpectancy: span })
cancelEditingLife()
}
function toggleWeekStart() {
setWeekStart(Model.toggledWeekStart(root.weekStart))
}
// English short day names, matching the rest of the interface.
function weekdayLabel(weekday) {
return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase()
}
SystemClock {
id: clock
precision: SystemClock.Minutes
onDateChanged: {
if (Model.keyForDate(clock.date) === String(root.todayKey)) return
var followToday = root.viewingCurrentMonth
root.today = clock.date
if (followToday) root.goToToday()
}
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
centerOnBar: true
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(560))
contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLife
onMoveRequested: function(dx, dy) {
if (dx !== 0) root.moveMonth(dx)
if (dy !== 0) root.moveYear(dy)
}
onActivateRequested: root.goToToday()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "[") root.moveMonth(-1)
else if (t === "]") root.moveMonth(1)
else if (t === "{") root.moveYear(-1)
else if (t === "}") root.moveYear(1)
else if (t === "t" || t === "T") root.goToToday()
else if (t === "w" || t === "W") root.toggleWeekStart()
}
Flickable {
id: calendarScroll
anchors.fill: parent
contentWidth: calendarColumn.width
contentHeight: calendarColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height || contentWidth > width
Column {
id: calendarColumn
// Never narrower than the grid. The popup width is capped to what
// the screen allows, and a fixed seven-column grid would otherwise
// lose its last days off the edge instead of scrolling.
width: Math.max(calendarScroll.width, gridColumn.width)
spacing: Style.space(8)
// ---- Hero: today, centered. Once the view has stepped back
// it is also the way home — clicking the date you are
// looking for beats hunting for a reset button.
Item {
width: parent.width
height: heroRow.height
Row {
id: heroRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(22)
Text {
// Baseline-aligned, not center-aligned: "July 26" carries a
// descender, so centering the two boxes leaves the icon
// sitting visibly low against the digits.
anchors.baseline: heroDate.baseline
text: "󰃭"
color: heroMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: root.contentForeground
font.family: root.contentFontFamily
// Decorative, and deliberately outside the Style.font.*
// scale. Sized so the glyph reads at the cap height of the
// date beside it rather than towering over it.
font.pixelSize: 48
}
Text {
id: heroDate
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDate(root.today, "MMMM d")
color: heroMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: 52
font.bold: true
}
}
MouseArea {
id: heroMouse
x: heroRow.x
y: heroRow.y
width: heroRow.width
height: heroRow.height
enabled: !root.viewingCurrentMonth
hoverEnabled: enabled
cursorShape: Qt.PointingHandCursor
onClicked: root.goToToday()
PanelToolTip {
visible: heroMouse.containsMouse
text: "Back to today"
fontFamily: root.contentFontFamily
}
}
}
// ---- Year progress, doubling as the rule under the hero:
// a plain hairline said nothing, and whole days done
// over days in the year says the same thing louder.
Item {
width: parent.width
height: yearBlock.y + yearBlock.height
Item {
id: yearBlock
y: Style.space(6)
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: Math.max(yearLabel.implicitHeight, Style.space(10))
TapHandler {
enabled: !root.editingLife
onDoubleTapped: root.startEditingLife()
}
Row {
visible: root.editingLife
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(10)
Text {
anchors.verticalCenter: parent.verticalCenter
text: "BORN"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: bornField
width: Style.space(70)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "year"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) }
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 0
leftPadding: Style.space(6)
text: "LIVE TO"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
TextField {
id: expectancyField
width: Style.space(60)
anchors.verticalCenter: parent.verticalCenter
placeholderText: "90"
foreground: root.contentForeground
font.family: root.contentFontFamily
inputMethodHints: Qt.ImhDigitsOnly
Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) }
}
}
Text {
id: yearLabel
textFormat: Text.PlainText
visible: !root.editingLife
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.today.getFullYear()
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: yearPercent
textFormat: Text.PlainText
visible: !root.editingLife
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.yearDonePercent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
id: yearTrack
visible: !root.editingLife
anchors.left: yearLabel.right
anchors.right: yearPercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * root.yearDone)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
}
}
// ---- Memento mori. Only here once someone has gone looking and
// given an age; the same rail as the year above it, measured
// against a nominal lifetime.
Item {
visible: root.birthYear > 0
width: parent.width
height: visible ? lifeBlock.height : 0
Item {
id: lifeBlock
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: Math.max(lifeLabel.implicitHeight, Style.space(10))
Text {
id: lifeLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "LIFE"
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: lifePercent
textFormat: Text.PlainText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.lifeDonePercent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
anchors.left: lifeLabel.right
anchors.right: lifePercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * root.lifeDone)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
TapHandler {
onDoubleTapped: root.clearLife()
}
MouseArea {
id: lifeMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
PanelToolTip {
visible: lifeMouse.containsMouse
text: "Memento Mori"
fontFamily: root.contentFontFamily
}
}
}
}
// ---- Month grid: week numbers down a gutter on the left, then
// the seven day columns. Always six rows, so the popup is
// exactly as tall in February as it is in August.
Item {
width: parent.width
height: gridColumn.y + gridColumn.height
WheelHandler {
onWheel: function(event) {
// Horizontal wheels and touchpad side-scrolls report y === 0;
// without this they would every one read as "next month".
if (event.angleDelta.y === 0) return
root.moveMonth(event.angleDelta.y > 0 ? -1 : 1)
}
}
Column {
id: gridColumn
// The meter above is a solid rule; the grid needs room to
// read as its own block rather than hanging off it.
y: Style.space(18)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(3)
Row {
id: headerRow
spacing: root.cellSpacing
// The week-number heading doubles as the week-start toggle.
// It is the one control in the panel whose meaning is not
// self-evident, so it carries a tooltip naming the day the
// click will switch to.
Rectangle {
width: root.weekColumnWidth
height: Style.space(16)
radius: Style.cornerRadius
color: weekStartMouse.containsMouse
? Style.hoverFillFor(root.contentForeground, Color.accent)
: "transparent"
Text {
anchors.centerIn: parent
text: "W"
color: weekStartMouse.containsMouse
? Style.hoverStateColor(root.contentForeground, Color.accent)
: Qt.darker(root.contentForeground, 1.9)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
font.bold: true
}
MouseArea {
id: weekStartMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleWeekStart()
}
PanelToolTip {
visible: weekStartMouse.containsMouse
text: "Start weeks on " + root.nextWeekStartLabel
fontFamily: root.contentFontFamily
}
}
Item {
width: root.gutterWidth
height: Style.space(16)
}
Repeater {
model: root.weekdays
Text {
textFormat: Text.PlainText
required property var modelData
width: root.cellWidth
height: Style.space(16)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: root.weekdayLabel(modelData)
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
font.bold: true
}
}
}
Repeater {
model: root.weeks
Row {
required property var modelData
spacing: root.cellSpacing
Text {
textFormat: Text.PlainText
width: root.weekColumnWidth
height: root.cellHeight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: modelData.week
color: Qt.darker(root.contentForeground, 1.9)
font.family: root.contentFontFamily
font.pixelSize: Style.font.caption
}
Item {
width: root.gutterWidth
height: root.cellHeight
}
Repeater {
model: modelData.days
Rectangle {
required property var modelData
width: root.cellWidth
height: root.cellHeight
radius: Style.cornerRadius
// Today is outlined, not filled: a lit-up block shouts
// over a grid this quiet.
color: "transparent"
border.width: modelData.today ? Style.spacing.hairline : 0
border.color: Style.normalBorderFor(root.contentForeground, Color.accent)
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: modelData.day
color: modelData.inMonth
? (modelData.weekend ? Qt.darker(root.contentForeground, 1.45) : root.contentForeground)
: Qt.darker(root.contentForeground, 2.2)
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.bold: modelData.today
}
}
}
}
}
}
// Hairline down the week-number gutter, drawn only beside the
// day rows so it does not cut through the header band.
Rectangle {
x: gridColumn.x + root.weekColumnWidth + root.cellSpacing + Math.round((root.gutterWidth - width) / 2)
y: gridColumn.y + headerRow.height + gridColumn.spacing
width: Style.spacing.hairline
height: gridColumn.height - headerRow.height - gridColumn.spacing
color: root.contentForeground
opacity: 0.1
}
}
// ---- Month stepping, spanning the grid it drives. The chevrons
// sit on the grid's outer bounds, the same edges the year
// rail above uses, so the row reads as the panel's other
// full-width rail instead of a cluster floating in space.
// The label is centered and fixed-width, so it holds still
// from "MAY" to "SEPTEMBER".
Item {
width: parent.width
height: monthNav.height
Item {
id: monthNav
anchors.horizontalCenter: parent.horizontalCenter
width: gridColumn.width
height: monthLabel.implicitHeight + Style.space(10)
Text {
id: monthLabel
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
// Fixed width so the chevrons hold still between a
// "MAY 2026" and a "SEPTEMBER 2026".
width: Style.space(130)
horizontalAlignment: Text.AlignHCenter
text: Qt.formatDate(root.viewDate, "MMMM yyyy").toUpperCase()
color: Qt.darker(root.contentForeground, 1.4)
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.letterSpacing: 1
}
PanelActionButton {
// Pulled out by the button's own padding so the glyph, not
// its hit box, lines up with the "2026" on the year rail.
anchors.left: parent.left
anchors.leftMargin: -Style.space(8)
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅁"
tooltipText: "Previous month"
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: root.moveMonth(-1)
}
PanelActionButton {
anchors.right: parent.right
anchors.rightMargin: -Style.space(8)
anchors.verticalCenter: parent.verticalCenter
iconText: "󰅂"
tooltipText: "Next month"
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: root.moveMonth(1)
}
}
}
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.clock",
"name": "Clock",
"version": "1.0.0",
"author": "Blob",
"description": "Date/time label with a calendar popup",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Clock",
"description": "Date/time label with a calendar popup",
"category": "Time",
"allowMultiple": false
}
}
@@ -0,0 +1,151 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
// The shared gauge-cluster overlay dressed for the disk speed test: read and
// write dials in MB/s, titled with the model of the disk under test. One
// blob-disk-speedtest run streams both phases and cleans up after itself,
// so dismissal only has to stop the process.
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
property bool running: false
property bool expectedStop: false
property bool pendingRun: false
property string phase: "" // "read" | "write" | ""
property string diskName: ""
property string writeMBps: ""
property string readMBps: ""
property string error: ""
property string stderrText: ""
function open(payloadJson) {
opened = true
runTest()
}
// Host-initiated close (`shell hide`). The user-initiated paths (Esc, the
// scrim) route through shell.hide so the host's open-panel state stays
// consistent, and land back here.
function close() {
opened = false
pendingRun = false
// Clear the phase before killing the process, so onExited reads the stop
// as a dismissal rather than a failed run.
phase = ""
running = false
if (proc.running) {
expectedStop = true
proc.running = false
}
}
function dismiss() {
if (shell && typeof shell.hide === "function")
shell.hide((manifest && manifest.id) || "blob.disk-speedtest")
else close()
}
function runTest() {
if (proc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (expectedStop) pendingRun = true
return
}
error = ""
diskName = ""
writeMBps = ""
readMBps = ""
stderrText = ""
phase = "read"
running = true
proc.running = true
}
function toRate(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
// Lines are "disk <model>", then "read <MB/s>" once a second, then
// "write <MB/s>". The phase follows whichever figure is streaming, and each
// phase's final line is its steady-state average, which the dial settles on.
function updateLine(line) {
var parts = String(line).trim().split(/\s+/)
if (parts.length < 2) return
if (parts[0] === "disk") {
diskName = parts.slice(1).join(" ")
return
}
var value = parseFloat(parts[1])
if (!isFinite(value) || value < 0) return
if (parts[0] === "write") {
phase = "write"
writeMBps = String(value)
} else if (parts[0] === "read") {
phase = "read"
readMBps = String(value)
}
}
Process {
id: proc
command: ["blob-disk-speedtest"]
stdout: SplitParser { onRead: function(line) { root.updateLine(line) } }
// Exit and stream-finished have no guaranteed order: when a failed exit
// beat the collector and published the generic message, replace it with
// the specific one once it lands.
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.stderrText = String(text || "").trim()
if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText
}
}
onExited: function(exitCode) {
if (root.pendingRun) {
root.pendingRun = false
root.expectedStop = false
if (root.opened) Qt.callLater(root.runTest)
return
}
if (!root.expectedStop && exitCode !== 0) {
root.error = root.stderrText || "Disk speed test failed"
root.phase = ""
root.running = false
return
}
root.expectedStop = false
root.phase = ""
root.running = false
}
}
SpeedTestOverlay {
fontFamily: Style.font.family
layerNamespace: "blob-disk-speedtest"
title: root.diskName
leftLabel: "READ"
rightLabel: "WRITE"
unit: "MB/s"
runAgainTooltip: "Measure again"
running: root.running
leftValue: root.toRate(root.readMBps)
rightValue: root.toRate(root.writeMBps)
leftLive: root.running && root.phase === "read"
rightLive: root.running && root.phase === "write"
error: root.error
open: root.opened
scaleStops: [500, 1000, 2500, 5000, 10000, 15000]
onCloseRequested: root.dismiss()
onRunAgainRequested: root.runTest()
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.disk-speedtest",
"name": "Disk speed test",
"version": "1.0.0",
"author": "Blob",
"description": "Live disk write and read speed dials. Summon with: blob-shell shell summon blob.disk-speedtest",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}
+124
View File
@@ -0,0 +1,124 @@
function clampBrightness(value) {
var n = Number(value)
if (!isFinite(n)) return 1
return Math.max(1, Math.min(100, Math.round(n)))
}
function normalizeScale(scale) {
var n = parseFloat(String(scale || ""))
if (!isFinite(n)) return ""
return String(Math.round(n * 100) / 100)
}
function gcd(a, b) {
while (b) {
var remainder = a % b
a = b
b = remainder
}
return a
}
function cleanScale(scale, width, height) {
var requested = Number(scale)
var modeWidth = Number(width)
var modeHeight = Number(height)
if (!isFinite(requested) || !isFinite(modeWidth) || !isFinite(modeHeight)
|| requested <= 0 || modeWidth <= 0 || modeHeight <= 0) return ""
var divisor = gcd(Math.round(modeWidth * 120), Math.round(modeHeight * 120))
var scaleUnits = Math.round(requested * 120)
if (scaleUnits > divisor) scaleUnits = divisor
while (divisor % scaleUnits !== 0) scaleUnits++
return normalizeScale(scaleUnits / 120)
}
function matchingScaleIndex(scales, currentScale, width, height) {
var current = Number(currentScale)
if (!Array.isArray(scales) || !isFinite(current)) return -1
var bestIndex = -1
var bestDistance = Infinity
var normalizedCurrent = normalizeScale(current)
for (var i = 0; i < scales.length; i++) {
if (cleanScale(scales[i], width, height) !== normalizedCurrent) continue
var distance = Math.abs(Number(scales[i]) - current)
if (distance < bestDistance) {
bestIndex = i
bestDistance = distance
}
}
return bestIndex
}
function availableScales(scales, width, height) {
if (!Array.isArray(scales) || Number(width) <= 0 || Number(height) <= 0) return scales || []
var byEffectiveScale = {}
for (var i = 0; i < scales.length; i++) {
var requested = Number(scales[i])
var effective = Number(cleanScale(requested, width, height))
if (!isFinite(requested) || !isFinite(effective)) continue
var key = normalizeScale(effective)
var existing = byEffectiveScale[key]
if (!existing || Math.abs(requested - effective) < existing.distance) {
byEffectiveScale[key] = {
value: String(scales[i]),
index: i,
distance: Math.abs(requested - effective)
}
}
}
return Object.keys(byEffectiveScale)
.map(function(key) { return byEffectiveScale[key] })
.sort(function(a, b) { return a.index - b.index })
.map(function(candidate) { return candidate.value })
}
function brightnessName(percent) {
var p = Math.round(percent)
if (p >= 95) return "Sun blast"
if (p >= 80) return "Solar flare"
if (p >= 65) return "Golden hour"
if (p >= 45) return "Even day"
if (p >= 30) return "Soft glow"
if (p >= 20) return "Lamp light"
if (p >= 10) return "Candlelit"
return "Night owl"
}
function parseDisplays(raw) {
var displays = []
try {
displays = raw ? JSON.parse(String(raw)) : []
} catch (e) {
displays = []
}
if (!Array.isArray(displays)) displays = []
var count = 0
for (var i = 0; i < displays.length; i++) {
if (displays[i] && displays[i].enabled) count++
}
return {
displays: displays,
enabledDisplayCount: count
}
}
if (typeof module !== "undefined") {
module.exports = {
clampBrightness: clampBrightness,
normalizeScale: normalizeScale,
cleanScale: cleanScale,
matchingScaleIndex: matchingScaleIndex,
availableScales: availableScales,
brightnessName: brightnessName,
parseDisplays: parseDisplays
}
}
+929
View File
@@ -0,0 +1,929 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Ui
import qs.Commons
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.monitor"
ipcTarget: "blob.monitor"
manageIpc: false
// manageIpc: false so this panel can own the single IpcHandler the target
// permits — needed for the brightness + state methods below.
property int brightnessPercent: 0
property int pendingBrightnessPercent: 0
property bool brightnessSetQueued: false
property bool brightnessAvailable: false
property string internalMonitor: ""
property string externalMonitor: ""
property string focusedMonitor: ""
property bool internalEnabled: false
property bool mirrorEnabled: false
property string monitorScale: ""
property var displays: []
property int enabledDisplayCount: 0
// Carry sub-notch touchpad deltas between wheel events.
property real wheelAccumulator: 0
// Cursor model shared by keyboard and mouse. Sections:
// "brightness" - single slider row, selectedIndex = -1 sentinel
// (mirrors Audio's slider rows). Only present if a
// controllable backlight was detected.
// "scale" - 6 Button scale presets; treated as a single
// horizontal row from j/k's perspective. h/l moves
// between presets, identical to bluetooth's header.
// "monitors" - vertical display row list for enabling/disabling displays;
// j/k walks each row.
// Mouse hover on a target updates root state via the components' `hovered`
// signal so keyboard cursor and pointer share one highlight.
readonly property var scalePresets: ["1", "1.25", "1.6", "2", "3", "4"]
readonly property var scaleValues: {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.availableScales(scalePresets, display.width, display.height)
}
return scalePresets
}
property string focusSection: "scale"
property int selectedIndex: 0
property bool cursorActive: false
// Text size slider — curated macOS-style notches (px). The panel snaps to
// these stops; the CLI (blob-display-size) accepts any integer in range.
readonly property var textSizeStops: [9, 10, 11, 12, 14, 16, 20]
// While a change is in flight, the chosen stop index overrides the live
// base-size so the knob doesn't snap back during the file round-trip. -1 =
// no pending change; follow Style.font.baseSize.
property int textSizePreviewIndex: -1
// A text-size change reflows the whole panel (both font and spacing scale),
// which slides rows under a stationary pointer and fires synthetic hover.
// While true, hover is not allowed to hijack the keyboard focus section —
// otherwise h/l on the text-size slider can jump focus to another row.
property bool reflowingText: false
function markReflowing() {
root.reflowingText = true
reflowSettle.restart()
}
readonly property var visibleSections: {
var list = []
if (brightnessAvailable) list.push("brightness")
list.push("textsize")
list.push("scale")
if (displays.length > 1) list.push("monitors")
return list
}
function sectionCount(section) {
if (section === "brightness") return 0 // only the slider sentinel at -1
if (section === "textsize") return 0 // slider sentinel at -1, like brightness
if (section === "scale") return scaleValues.length
if (section === "monitors") return displays.length
return 0
}
function sectionIsSingleRow(section) {
// brightness and text size are lone sliders; scale presets sit horizontally.
return section === "brightness" || section === "textsize" || section === "scale"
}
function sectionFirstIndex(section) {
if (section === "brightness" || section === "textsize") return -1
return 0
}
function moveCursor(delta) {
var sections = visibleSections
if (!sections || sections.length === 0) return
var sIdx = sections.indexOf(focusSection)
if (sIdx < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var inSingleRow = sectionIsSingleRow(focusSection)
var max = inSingleRow ? 0 : sectionCount(focusSection) - 1
if (delta > 0) {
if (!inSingleRow && selectedIndex < max) { selectedIndex = selectedIndex + 1; return }
if (sIdx < sections.length - 1) {
focusSection = sections[sIdx + 1]
selectedIndex = sectionFirstIndex(focusSection)
}
} else {
if (!inSingleRow && selectedIndex > 0) { selectedIndex = selectedIndex - 1; return }
if (sIdx > 0) {
var prev = sections[sIdx - 1]
focusSection = prev
// Coming up from below — land on the last navigable row of the prev
// section, or its sentinel for single-row sections.
selectedIndex = sectionIsSingleRow(prev) ? sectionFirstIndex(prev) : sectionCount(prev) - 1
}
}
}
// h/l: in scale section, walks the preset row; everywhere else, no-op
// because adjustBrightness handles horizontal motion on the brightness
// slider.
function moveCursorH(delta) {
if (focusSection !== "scale") return
var next = selectedIndex + delta
if (next < 0) next = 0
if (next > scaleValues.length - 1) next = scaleValues.length - 1
selectedIndex = next
}
function adjustBrightness(delta) {
if (focusSection !== "brightness") return
if (!brightnessAvailable) return
setBrightness(root.brightnessPercent + delta)
}
function activateCursor() {
if (focusSection === "scale" && selectedIndex >= 0 && selectedIndex < scaleValues.length) {
setScale(scaleValues[selectedIndex])
return
}
if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) {
var d = displays[selectedIndex]
if (d) toggleDisplay(d.name, d.enabled)
}
// brightness: no separate action; the slider value is the action.
}
function clampCursor() {
var sections = visibleSections
if (!sections || !sections.length) return
if (sections.indexOf(focusSection) < 0) {
focusSection = sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
var count = sectionCount(focusSection)
if (sectionIsSingleRow(focusSection)) {
// brightness/text size use the -1 sentinel; scale clamps into the presets.
if (focusSection === "brightness" || focusSection === "textsize") selectedIndex = -1
else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0
return
}
if (count === 0) {
var sIdx = sections.indexOf(focusSection)
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
selectedIndex = sectionFirstIndex(focusSection)
return
}
if (selectedIndex > count - 1) selectedIndex = count - 1
if (selectedIndex < 0) selectedIndex = 0
}
// Keep the keyboard-focused row inside the viewport when the panel grows
// taller than its allotted height (lots of displays). Mirrors audio's
// ensureCursorVisible helper.
function ensureCursorVisible(item) {
if (!item || !scrollArea) return
var flick = scrollArea.contentItem
if (!flick || flick.contentY === undefined) return
var pt = item.mapToItem(flick.contentItem || flick, 0, 0)
var top = pt.y
var bottom = top + (item.height || 0)
var viewTop = flick.contentY
var viewBottom = viewTop + flick.height
var margin = 6
if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin)
flick.contentY = bottom + margin - flick.height
}
function brightnessIpc(percent) {
var value = Number(percent)
root.setBrightness(value)
return "got " + root.pendingBrightnessPercent
}
function stateIpc() {
return JSON.stringify({
brightness: root.brightnessPercent,
brightnessAvailable: root.brightnessAvailable,
focusedMonitor: root.focusedMonitor,
scale: root.monitorScale,
displays: root.displays
})
}
IpcHandler {
target: "blob.monitor"
function brightness(percent: string): string { return root.brightnessIpc(percent) }
function state(): string { return root.stateIpc() }
function open() { root.open() }
function close() { root.close() }
function toggle() { root.toggle() }
function show() { root.open() }
function hide() { root.close() }
}
function refresh() {
if (!stateProc.running) stateProc.running = true
}
function setBrightness(value) {
var percent = Model.clampBrightness(value)
root.brightnessPercent = percent
root.pendingBrightnessPercent = percent
if (setBrightnessProc.running) {
root.brightnessSetQueued = true
return
}
root.brightnessSetQueued = false
setBrightnessProc.command = ["blob-brightness-display", "--no-osd", "--monitor", root.focusedMonitor, percent + "%"]
setBrightnessProc.running = true
}
function previewBrightness(value) {
root.brightnessPercent = Model.clampBrightness(value)
brightnessDebounce.restart()
}
function showBrightnessOsd(percent) {
if (!bar || !bar.shell) return
bar.shell.summon("blob.osd", JSON.stringify({
icon: "brightness",
value: percent
}))
}
function normalizeScale(scale) {
return Model.normalizeScale(scale)
}
function activeScaleIndex() {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height)
}
return -1
}
function effectiveScale(scale) {
for (var i = 0; i < displays.length; i++) {
var display = displays[i]
if (display && display.focused)
return Model.cleanScale(scale, display.width, display.height)
}
return normalizeScale(scale)
}
// Playful mood-name for a given brightness percent. Bands intentionally
// span ~1020 points so casual tweaks change the label, while small
// nudges within one band don't.
function brightnessName(percent) {
return Model.brightnessName(percent)
}
function updateDisplays(displaysJson) {
var parsed = Model.parseDisplays(displaysJson)
root.displays = parsed.displays
root.enabledDisplayCount = parsed.enabledDisplayCount
}
function toggleDisplay(name, enabled) {
if (!name) return
if (enabled && root.enabledDisplayCount <= 1) return
actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")]
if (!actionProc.running) actionProc.running = true
}
function setScale(scale) {
actionProc.command = ["bash", "-c", "blob-hypr-monitor-scaling " + scale]
if (!actionProc.running) actionProc.running = true
}
// ---- Text size (shell base font + GTK text-scaling, via one CLI) ----
function nearestTextStop(px) {
var best = 0
var bestDist = 1e9
for (var i = 0; i < textSizeStops.length; i++) {
var d = Math.abs(textSizeStops[i] - px)
if (d < bestDist) { bestDist = d; best = i }
}
return best
}
// Effective stop index: the pending choice while a change is in flight,
// otherwise whatever Style's live base-size rounds to.
function currentTextIndex() {
return textSizePreviewIndex >= 0 ? textSizePreviewIndex : nearestTextStop(Style.font.baseSize)
}
// px shown in the header: the pending stop if any, else the true base-size
// (which may be an off-notch value set from the CLI).
function displayedTextPx() {
return textSizePreviewIndex >= 0 ? textSizeStops[textSizePreviewIndex] : Style.font.baseSize
}
function setTextSize(px) {
textScaleProc.command = ["blob-display-size", String(px)]
if (!textScaleProc.running) textScaleProc.running = true
}
function adjustTextSize(deltaSteps) {
var idx = currentTextIndex() + deltaSteps
if (idx < 0) idx = 0
if (idx > textSizeStops.length - 1) idx = textSizeStops.length - 1
markReflowing()
textSizePreviewIndex = idx
setTextSize(textSizeStops[idx])
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Component.onCompleted: refresh()
// KeyboardPanel primes focus at open-time, so SUPER-bound IPC summons land
// with j/k ready to navigate. Keep a default landing point, but don't paint
// the cursor until hover or the first navigation key.
onOpenedChanged: {
if (opened) {
refresh()
if (brightnessAvailable) {
focusSection = "brightness"
selectedIndex = -1
} else {
focusSection = "scale"
selectedIndex = 0
}
cursorActive = false
}
}
onBrightnessAvailableChanged: clampCursor()
onDisplaysChanged: clampCursor()
onScaleValuesChanged: clampCursor()
onVisibleSectionsChanged: clampCursor()
// Only poll while the panel is open; the bar glyph tracks monitor count via
// Quickshell.screens, and open-time refresh + Component.onCompleted cover the
// rest. External brightness changes are reflected whenever the panel is open.
Timer {
interval: 5000
running: root.opened
repeat: true
onTriggered: root.refresh()
}
Process {
id: stateProc
command: ["blob-display-state"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var lines = String(text || "").split("\n")
var brightness = String(lines[0] || "").trim()
root.brightnessAvailable = brightness !== "unavailable" && brightness !== ""
root.brightnessPercent = root.brightnessAvailable ? Math.max(0, Math.min(100, parseInt(brightness, 10))) : 0
root.internalMonitor = String(lines[1] || "").trim()
root.externalMonitor = String(lines[2] || "").trim()
root.internalEnabled = String(lines[3] || "").trim() !== ""
root.mirrorEnabled = String(lines[4] || "").trim() === root.externalMonitor && root.externalMonitor !== ""
root.focusedMonitor = String(lines[5] || "").trim()
root.monitorScale = root.normalizeScale(String(lines[6] || "").trim())
root.updateDisplays(String(lines[7] || "[]").trim())
}
}
}
Timer {
id: brightnessDebounce
interval: 180
repeat: false
onTriggered: root.setBrightness(root.brightnessPercent)
}
Process {
id: setBrightnessProc
stdout: StdioCollector { waitForEnd: true }
// Do NOT call refresh() after a brightness set completes. The local
// brightnessPercent we just wrote is authoritative; re-reading via
// `blob-brightness-display` races the hardware/driver and can
// return an empty string, which the parser then coerces to 0 —
// visible as a "bounce to zero" after h/l keypresses. External
// brightness changes are still picked up by the 5s periodic refresh,
// the open-time refresh, and Component.onCompleted.
onRunningChanged: {
if (running) return
if (root.brightnessSetQueued) {
root.setBrightness(root.pendingBrightnessPercent)
}
}
}
Process {
id: actionProc
stdout: StdioCollector { waitForEnd: true }
onRunningChanged: if (!running) root.refresh()
}
// Applies text size via the CLI, which rewrites the shell override file;
// Style picks the new base-size up through its own file watch, so there's
// nothing to refresh here.
Process {
id: textScaleProc
stdout: StdioCollector { waitForEnd: true }
}
// Clears the hover-suppression flag once the reflow triggered by a text-size
// change has settled.
Timer {
id: reflowSettle
interval: 300
repeat: false
onTriggered: root.reflowingText = false
}
// Once Style's base-size catches up to the pending choice, drop the preview
// so the slider tracks the live value again. The change itself reflows the
// panel, so suppress hover for a beat while it lands.
Connections {
target: Style
function onFontBaseSizeChanged() {
root.markReflowing()
if (root.textSizePreviewIndex >= 0
&& root.nearestTextStop(Style.font.baseSize) === root.textSizePreviewIndex)
root.textSizePreviewIndex = -1
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: Quickshell.screens.length > 1 ? "󰍺" : "󰍹"
onPressed: function(b) { root.toggle() }
onWheelMoved: function(delta) {
if (!root.brightnessAvailable) return
var wheel = Util.wheelSteps(root.wheelAccumulator, delta)
root.wheelAccumulator = wheel.remainder
if (wheel.steps === 0) return
root.setBrightness(root.brightnessPercent + wheel.steps * 5)
root.showBrightnessOsd(root.brightnessPercent)
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) {
if (root.focusSection === "brightness") root.adjustBrightness(dx * 5)
else if (root.focusSection === "textsize") root.adjustTextSize(dx)
else if (root.focusSection === "scale") root.moveCursorH(dx)
}
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
ScrollView {
id: scrollArea
anchors.fill: parent
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: panelColumn.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
Binding {
target: scrollArea.contentItem
property: "interactive"
value: panelColumn.implicitHeight > scrollArea.height
}
Column {
id: panelColumn
width: scrollArea.availableWidth
spacing: Style.space(14)
// ---------- Hero: display icon · title/status ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
Text {
id: heroIcon
textFormat: Text.PlainText
text: root.displays.length > 1 ? "󰍺" : "󰍹"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Column {
id: heroLabels
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
text: "Display"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroLabel
textFormat: Text.PlainText
text: {
if (root.brightnessAvailable) {
return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase()
}
return "FIXED BRIGHTNESS"
}
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
}
}
// ---------- Brightness ----------
PanelSeparator {
visible: root.brightnessAvailable
foreground: root.bar.foreground
}
Column {
visible: root.brightnessAvailable
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: Math.max(brightnessHeader.implicitHeight, brightnessPercent.implicitHeight)
PanelSectionHeader {
id: brightnessHeader
text: "BRIGHTNESS"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: brightnessPercent
textFormat: Text.PlainText
text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: brightnessRow
width: parent.width
height: brightnessSlider.implicitHeight + Style.spacing.controlGap
hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow)
foreground: root.bar.foreground
outline: true
PanelSlider {
id: brightnessSlider
bar: root.bar
anchors.fill: parent
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
minimum: 1
maximum: 100
step: 1
value: root.brightnessPercent
integer: true
onMoved: function(v) { root.previewBrightness(v) }
onReleased: function(v) {
brightnessDebounce.stop()
root.setBrightness(v)
}
}
HoverHandler {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "brightness"
root.selectedIndex = -1
}
}
}
}
// ---------- Text size ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: Math.max(textSizeHeader.implicitHeight, textSizePx.implicitHeight)
PanelSectionHeader {
id: textSizeHeader
text: "TEXT SIZE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: textSizePx
textFormat: Text.PlainText
text: (textSizeSlider.dragging
? root.textSizeStops[Math.round(textSizeSlider.liveValue)]
: root.displayedTextPx()) + "px"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
CursorSurface {
id: textSizeRow
width: parent.width
height: textSizeSlider.implicitHeight + Style.spacing.controlGap
hasCursor: root.cursorActive && root.focusSection === "textsize" && root.selectedIndex === -1
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(textSizeRow)
foreground: root.bar.foreground
outline: true
PanelSlider {
id: textSizeSlider
bar: root.bar
anchors.fill: parent
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
minimum: 0
maximum: root.textSizeStops.length - 1
step: 1
integer: true
tickCount: root.textSizeStops.length
value: root.currentTextIndex()
onReleased: function(v) { root.setTextSize(root.textSizeStops[Math.round(v)]) }
}
HoverHandler {
onHoveredChanged: if (hovered && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "textsize"
root.selectedIndex = -1
}
}
}
}
// ---------- Scale ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
Item {
width: parent.width
implicitHeight: Math.max(scaleHeader.implicitHeight, scaleMonitor.implicitHeight)
PanelSectionHeader {
id: scaleHeader
text: "SCALE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
// Name the monitor SCALE targets, since it only applies to the
// focused one.
Text {
id: scaleMonitor
textFormat: Text.PlainText
text: root.focusedMonitor
// Only worth naming when more than one display is in play.
visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
anchors.right: parent.right
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
}
}
Grid {
id: scaleRow
width: parent.width
columns: root.scaleValues.length
spacing: Style.spacing.xs
readonly property real cellWidth: root.scaleValues.length > 0
? (width - spacing * (columns - 1)) / columns
: 0
Repeater {
model: root.scaleValues
ScalePill {
required property string modelData
required property int index
scaleValue: modelData
scaleIndex: index
width: scaleRow.cellWidth
}
}
}
}
// ---------- Monitors ----------
PanelSeparator {
visible: root.displays.length > 1
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
visible: root.displays.length > 1
PanelSectionHeader {
text: "DISPLAYS"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Repeater {
model: root.displays
MonitorRow {
required property var modelData
required property int index
width: panelColumn.width
display: modelData
rowIndex: index
}
}
}
Item {
width: parent.width
height: Style.space(4)
}
}
}
}
}
component ScalePill: Button {
id: pill
required property string scaleValue
required property int scaleIndex
text: root.effectiveScale(scaleValue) + "x"
fontSize: Style.font.caption
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.sm
verticalPadding: Style.spacing.controlPaddingY
bordered: true
active: root.activeScaleIndex() === scaleIndex
hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex
onClicked: root.setScale(scaleValue)
onHovered: function(isHovered) {
if (!isHovered || root.reflowingText) return
root.cursorActive = true
root.focusSection = "scale"
root.selectedIndex = pill.scaleIndex
}
}
component MonitorRow: CursorSurface {
id: monitorRow
required property var display
required property int rowIndex
readonly property bool isFocused: display && display.focused
readonly property bool canToggle: display && (!display.enabled || root.enabledDisplayCount > 1)
hasCursor: root.cursorActive && root.focusSection === "monitors" && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(monitorRow)
current: isFocused
foreground: root.bar.foreground
fill: Style.hoverFillFor(root.bar.foreground, Color.accent)
currentFill: Style.selectedFillFor(root.bar.foreground, Color.accent)
implicitHeight: monitorInner.implicitHeight + Style.spacing.xl
opacity: canToggle ? 1.0 : 0.45
Row {
id: monitorInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
spacing: Style.space(8)
Text {
text: "󰍹"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width - Style.space(22) - Style.space(14) - Style.space(16)
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: monitorRow.display.enabled ? "󰄬" : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.subtitle
width: Style.space(14)
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: monitorRow.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse && !root.reflowingText) {
root.cursorActive = true
root.focusSection = "monitors"
root.selectedIndex = monitorRow.rowIndex
}
onClicked: if (monitorRow.canToggle) root.toggleDisplay(monitorRow.display.name, monitorRow.display.enabled)
}
}
}
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.monitor",
"name": "Display",
"version": "1.0.0",
"author": "Blob",
"description": "Brightness slider and laptop display controls",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Display",
"description": "Brightness slider and laptop display controls",
"category": "System",
"allowMultiple": false
}
}
+380
View File
@@ -0,0 +1,380 @@
function parseNetworkStatus(raw) {
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
return {
kind: parts[0] || "disconnected",
label: parts[1] || "",
signalStrength: parts[2] ? parseInt(parts[2], 10) : -1,
frequency: parts[3] || ""
}
}
function wifiIconFor(strength) {
var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
return icons[index]
}
function connectionIcon(kind, signalStrength) {
if (kind === "wifi") return wifiIconFor(signalStrength)
if (kind === "ethernet") return "󰈀"
return "󰤮"
}
function formatHeaderSpeed(mbps) {
var v = parseInt(mbps, 10)
if (!v || v < 0) return ""
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
return v + "mbit"
}
function formatHeaderFreq(mhz) {
var v = parseFloat(mhz)
if (!v) return ""
if (v >= 2400 && v < 2500) return "2.4ghz"
if (v >= 4900 && v < 5925) return "5ghz"
if (v >= 5925 && v < 7125) return "6ghz"
if (v >= 57000 && v < 71000) return "60ghz"
var ghz = v / 1000
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
}
// Wi-Fi band state belongs in the selector section, not beside the hero name.
// Ethernet has no equivalent selector, so keep its negotiated link speed here.
function headerDetail(info) {
var value = info || {}
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
return ""
}
function bandLabel(band) {
if (band === "auto") return "Auto"
if (!band) return ""
return band + "ghz"
}
// Under Automatic the pills are hidden, so the header carries the live band
// instead -- "WI-FI BAND: 2.4GHZ". Once a band is pinned the pills are on
// screen and say it themselves, so the header drops back to a plain label.
function bandSectionTitle(selected, current) {
if (selected !== "auto") return "WI-FI BAND"
var label = bandLabel(current)
if (label === "") return "WI-FI BAND"
return "WI-FI BAND: " + label.toUpperCase()
}
function bandTooltip(band) {
if (band === "auto") return "Let Wi-Fi pick the band"
if (!band) return ""
return "Stay on " + bandLabel(band)
}
function parseBandStatus(raw) {
var next = parseKeyValue(raw)
var tokens = String(next.available || "").split(" ")
var available = []
for (var i = 0; i < tokens.length; i++) {
if (tokens[i] !== "") available.push(tokens[i])
}
return {
band: next.band || "",
selected: next.selected || "auto",
available: available
}
}
function decodeIwSsid(value) {
var raw = String(value || "")
try {
var encoded = ""
for (var i = 0; i < raw.length; i++) {
if (raw[i] === "\\" && raw[i + 1] === "x" && /^[0-9a-f]{2}$/i.test(raw.substring(i + 2, i + 4))) {
var hex = raw.substring(i + 2, i + 4)
var byte = parseInt(hex, 16)
encoded += byte < 32 || byte === 127 ? encodeURIComponent(raw.substring(i, i + 4)) : "%" + hex
i += 3
} else {
encoded += encodeURIComponent(raw[i])
}
}
return decodeURIComponent(encoded)
} catch (error) {
return raw
}
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (!line) continue
var idx = line.indexOf("\t")
if (idx === -1) continue
var key = line.substring(0, idx)
var value = line.substring(idx + 1)
next[key] = key === "ssid" ? decodeIwSsid(value) : value.trim()
}
return next
}
function throughputState(previous, next, now) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var rx = parseFloat(sample.rx_bytes || "0")
var tx = parseFloat(sample.tx_bytes || "0")
var previousTime = Number(prev.prevSampleTime || 0)
if (iface !== (prev.prevIface || "") || previousTime === 0) {
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: 0,
uploadRate: 0
}
}
var downloadRate = Number(prev.downloadRate || 0)
var uploadRate = Number(prev.uploadRate || 0)
var dt = now - previousTime
if (dt > 0) {
downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt)
uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt)
}
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: downloadRate,
uploadRate: uploadRate
}
}
function pingSampleValue(raw) {
var value = parseFloat(raw)
if (!isFinite(value) || value < 0) return null
return value
}
function appendPingSample(samples, raw, limit) {
var values = Array.isArray(samples) ? samples.slice() : []
values.push(pingSampleValue(raw))
while (values.length > limit) values.shift()
return values
}
function averagePingLatency(samples, limit) {
var values = Array.isArray(samples) ? samples : []
var sampleLimit = Math.max(1, parseInt(limit, 10) || values.length || 1)
var total = 0
var count = 0
for (var i = Math.max(0, values.length - sampleLimit); i < values.length; i++) {
var value = values[i]
if (typeof value !== "number" || !isFinite(value) || value < 0) continue
total += value
count++
}
return count > 0 ? total / count : -1
}
function pingPacketLossPercent(samples) {
var values = Array.isArray(samples) ? samples : []
if (values.length === 0) return 0
var lost = 0
for (var i = 0; i < values.length; i++) {
if (values[i] === null) lost++
}
return Math.round((lost / values.length) * 100)
}
function formatPacketLoss(percent, hasSamples) {
if (hasSamples === false) return "--"
var value = parseInt(percent, 10)
if (!value || value < 0) return "0%"
return value + "%"
}
function pingLatencyState(previous, next, limit, averageLimit) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var window = Math.max(1, parseInt(limit, 10) || 5)
var averageWindow = Math.max(1, parseInt(averageLimit, 10) || window)
var reset = iface === "" || iface !== (prev.pingIface || "")
var routerSamples = reset ? [] : prev.routerPingSamples
var internetSamples = reset ? [] : prev.internetPingSamples
routerSamples = sample.router_ping_ms === undefined ? [] : appendPingSample(routerSamples, sample.router_ping_ms, window)
internetSamples = sample.internet_ping_ms === undefined ? [] : appendPingSample(internetSamples, sample.internet_ping_ms, window)
return {
pingIface: iface,
routerPingSamples: routerSamples,
internetPingSamples: internetSamples,
routerPingLatency: averagePingLatency(routerSamples, averageWindow),
internetPingLatency: averagePingLatency(internetSamples, averageWindow),
internetPingPacketLoss: pingPacketLossPercent(internetSamples)
}
}
function formatBytes(bytes) {
var n = Number(bytes)
if (!isFinite(n) || n < 0) n = 0
if (n < 1024) return Math.round(n) + " B"
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
}
function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s"
}
// `hasSamples` false means no probe has come back yet, which is different from
// a probe that timed out. The rows stay mounted through that gap and read "--"
// so the grid doesn't reflow a second after the panel opens.
function formatPingLatency(ms, hasSamples) {
if (hasSamples === false) return "--"
var value = parseFloat(ms)
if (!isFinite(value) || value < 0) return "Timeout"
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " ms"
}
function wifiRow(network) {
if (!network) return null
// Primitives only: rows become list-model data, so a WifiNetwork here puts a
// live QObject wrapper in every delegate's var property. NetworkManager churn
// (scans, AP removals) can destroy the object while a delegate is still
// incubating, which segfaults quickshell in wrap_slowPath on the dangling
// wrapper. Callers that need the object resolve it via networkForSsid().
return {
connected: !!network.connected,
known: !!network.known,
ssid: network.name || "",
signal: Math.round((network.signalStrength || 0) * 100),
security: network.security
}
}
function sortWifiRows(rows) {
var nets = Array.isArray(rows) ? rows.slice() : []
nets.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
if (a.known !== b.known) return a.known ? -1 : 1
return b.signal - a.signal
})
return nets
}
function wifiSectionTitle(wifiNetworks, index) {
var networks = Array.isArray(wifiNetworks) ? wifiNetworks : []
if (index < 0 || index >= networks.length) return ""
var net = networks[index]
if (!net) return ""
if (net.known && index === 0) return "KNOWN NETWORKS"
if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS"
return ""
}
// OWE (Enhanced Open) encrypts traffic without authenticating the user, so it
// has no credentials to collect. The panel's lock is a credentials-required
// affordance, so OWE should neither show it nor open its attached prompt.
function requiresCredentials(security, openSecurity, oweSecurity) {
// Only explicit passwordless types bypass the prompt. Unknown security
// stays credentialed as the conservative fallback.
return security !== openSecurity && security !== oweSecurity
}
function canForgetNetwork(network) {
return !!(network && network.known && !network.connected)
}
// The password arrives on stdin and reaches nmcli through the scriptable
// `connection edit` editor -- argv is world-readable in /proc, so the secret
// must never be an argument (printf is a bash builtin, so no process spawns
// with it either).
var enterpriseConnectScript =
"u=$(uuidgen); IFS= read -r pw;" +
" nmcli connection add type wifi con-name \"$1\" ssid \"$1\" connection.uuid \"$u\"" +
" wifi-sec.key-mgmt wpa-eap 802-1x.eap peap 802-1x.phase2-auth mschapv2" +
" 802-1x.identity \"$2\" 802-1x.auth-timeout 8 >/dev/null" +
" && printf 'set 802-1x.password %s\\nsave\\nquit\\n' \"$pw\" | nmcli connection edit uuid \"$u\" >/dev/null" +
" && nmcli connection up uuid \"$u\"" +
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
function networkFailureReason(reason, needsCredentials, reasons) {
var r = reasons || {}
if (needsCredentials && reason === r.NoSecrets) return "Passphrase required"
if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password"
if (reason === r.WifiNetworkLost) return "Network lost"
if (reason === r.WifiClientDisconnected) return "Disconnected"
if (reason === r.WifiClientFailed) return "Connection failed"
return "Failed to connect"
}
// Whether a failed connect should reopen the passphrase prompt. NoSecrets
// means credentials are missing only for a network that actually uses them.
// An auth timeout on such a network means the saved passphrase is wrong (the
// same profile a first failed attempt leaves behind as "known"), so the user
// needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on
// submit.
function shouldRepromptPassphrase(reason, needsCredentials, reasons) {
var r = reasons || {}
if (!needsCredentials) return false
return reason === r.NoSecrets || reason === r.WifiAuthTimeout
}
if (typeof module !== "undefined") {
module.exports = {
parseNetworkStatus: parseNetworkStatus,
wifiIconFor: wifiIconFor,
connectionIcon: connectionIcon,
formatHeaderSpeed: formatHeaderSpeed,
formatHeaderFreq: formatHeaderFreq,
headerDetail: headerDetail,
bandLabel: bandLabel,
bandSectionTitle: bandSectionTitle,
bandTooltip: bandTooltip,
parseBandStatus: parseBandStatus,
decodeIwSsid: decodeIwSsid,
parseKeyValue: parseKeyValue,
throughputState: throughputState,
pingLatencyState: pingLatencyState,
pingPacketLossPercent: pingPacketLossPercent,
formatPacketLoss: formatPacketLoss,
formatBytes: formatBytes,
formatRate: formatRate,
formatPingLatency: formatPingLatency,
wifiRow: wifiRow,
sortWifiRows: sortWifiRows,
wifiSectionTitle: wifiSectionTitle,
requiresCredentials: requiresCredentials,
canForgetNetwork: canForgetNetwork,
enterpriseConnectScript: enterpriseConnectScript,
networkFailureReason: networkFailureReason,
shouldRepromptPassphrase: shouldRepromptPassphrase
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.network",
"name": "Network",
"version": "1.0.0",
"author": "Blob",
"description": "Wi-Fi list and connection state",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Network",
"description": "Wi-Fi list and connection state",
"category": "Network",
"allowMultiple": false
}
}
+104
View File
@@ -0,0 +1,104 @@
function clampIndex(index, length) {
if (length <= 0) return 0
return Math.max(0, Math.min(length - 1, index))
}
function selectProfileIndex(index, delta, profiles) {
var values = Array.isArray(profiles) ? profiles : []
if (values.length === 0) return 0
return clampIndex(index + delta, values.length)
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var idx = lines[i].indexOf("\t")
if (idx <= 0) continue
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
}
return next
}
function parseProfiles(raw, previousIndex) {
var lines = String(raw || "").split("\n")
var list = []
var active = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
list.push(parts[0])
if (parts[1] === "1") active = parts[0]
}
return {
profiles: list,
activeProfile: active,
profileIndex: clampIndex(previousIndex || 0, list.length)
}
}
function profileIcon(name) {
if (name === "power-saver") return "󰌪"
if (name === "balanced") return "󰊚"
if (name === "performance") return "󰓅"
return "󰂄"
}
function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
}
function chargeThresholdActive(device, onBattery, states) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false
var fraction = batteryFraction(d)
if (d.state === s.Discharging) return false
if (d.state === s.PendingCharge) return true
if (d.state === s.FullyCharged && fraction < 0.99) return true
if (d.state !== s.Charging || fraction >= 0.99) return false
return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60
}
function batteryIcon(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
var threshold = chargeThresholdActive(d, onBattery, states)
if (threshold) return defaultIcons[index]
if (d.state === states.FullyCharged) return "󰂅"
if (!onBattery) return chargingIcons[index]
return defaultIcons[index]
}
function modeLabel(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var percentage = d.isPresent ? d.percentage : 0
if (chargeThresholdActive(d, onBattery, states)) return "Threshold"
if (onBattery) return "On battery"
if (!onBattery && percentage >= 1) return "Fully charged"
return "Charging"
}
if (typeof module !== "undefined") {
module.exports = {
clampIndex: clampIndex,
selectProfileIndex: selectProfileIndex,
parseKeyValue: parseKeyValue,
parseProfiles: parseProfiles,
profileIcon: profileIcon,
batteryFraction: batteryFraction,
chargeThresholdActive: chargeThresholdActive,
batteryIcon: batteryIcon,
modeLabel: modeLabel
}
}
+536
View File
@@ -0,0 +1,536 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.power"
ipcTarget: "blob.power"
// manageIpc: false so this panel can own the single IpcHandler the target
// permits — needed for the togglePercentage method below.
manageIpc: false
property var batteryInfo: ({})
property var systemInfo: ({})
property var profiles: []
property string activeProfile: ""
property int profileIndex: 0
property bool cursorActive: false
readonly property bool showPercentage: setting("showPercentage", false) === true
// With the percentage shown the button paints a text block wider than an
// icon, so the open-panel mark takes the painted width instead of the
// icon-sized fraction of the slot the fallback assumes.
readonly property real openPanelIndicatorWidth: showPercentage && !button.vertical ? button.glyphPaintedWidth : 0
readonly property bool batteryPresent: {
var device = UPower.displayDevice
return !!(device && device.isPresent)
}
function upowerStates() {
return {
Charging: UPowerDeviceState.Charging,
Discharging: UPowerDeviceState.Discharging,
FullyCharged: UPowerDeviceState.FullyCharged,
PendingCharge: UPowerDeviceState.PendingCharge
}
}
function selectProfileByDelta(delta) {
profileIndex = Model.selectProfileIndex(profileIndex, delta, profiles)
}
function activateSelectedProfile() {
if (profileIndex < 0 || profileIndex >= profiles.length) return
setProfile(profiles[profileIndex])
}
function batteryIcon() {
var device = UPower.displayDevice
return Model.batteryIcon(device, root.discharging, upowerStates())
}
function modeLabel() {
var device = UPower.displayDevice
return Model.modeLabel(device, root.discharging, upowerStates())
}
function profileIcon(name) {
return Model.profileIcon(name)
}
readonly property bool fullyCharged: {
var device = UPower.displayDevice
return device && device.isPresent && device.state === UPowerDeviceState.FullyCharged && !root.chargeThresholdActive
}
readonly property bool discharging: {
var device = UPower.displayDevice
return !!(device && device.isPresent && UPower.onBattery)
}
readonly property bool chargeThresholdActive: {
var device = UPower.displayDevice
return Model.chargeThresholdActive(device, root.discharging, upowerStates())
}
readonly property bool batteryFull: fullyCharged || (!root.discharging && batteryFraction >= 1)
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
// 0..1 charge level, used by the visual progress bar.
readonly property real batteryFraction: {
var d = UPower.displayDevice
return Model.batteryFraction(d)
}
readonly property bool charging: {
var d = UPower.displayDevice
return d && d.isPresent && !UPower.onBattery && !root.batteryFlowIdle
}
readonly property color batteryFillColor: {
return root.bar ? root.bar.foreground : Color.foreground
}
// Cute agent-flavored phrases shown in the hero status line, rotated on a
// timer so the panel feels alive when current is flowing (either direction).
readonly property var chargingPhrases: [
"Pumping power",
"Injecting electrons",
"Pouring juice",
"Amassing watts",
"Hoarding joules",
"Sucking volts",
"Topping reserves",
"Soaking amps",
"Inhaling kilowatts"
]
readonly property var onBatteryPhrases: [
"Slurping power",
"Spending joules",
"Draining watts",
"Burning electrons",
"Sipping juice",
"Spending coulombs",
"Bleeding amps",
"Guzzling volts",
"Munching reserves"
]
property int phraseIndex: 0
// Whichever list is "active" given the current power state.
readonly property var activePhrases: {
if (fullyCharged) return []
if (charging) return chargingPhrases
if (discharging) return onBatteryPhrases
return []
}
readonly property bool rotatingPhrases: activePhrases.length > 0
readonly property string heroStatusText: {
if (fullyCharged) return "Fully charged"
if (rotatingPhrases) return activePhrases[phraseIndex % activePhrases.length]
return modeLabel()
}
function refresh() {
if (!batteryPresent) return
if (!batteryProc.running) batteryProc.running = true
if (!profilesProc.running) profilesProc.running = true
if (!systemProc.running) systemProc.running = true
}
function updateKeyValue(raw, targetName) {
var next = Model.parseKeyValue(raw)
// Keep last known good data if a refresh briefly returns nothing — happens
// around AC plug/unplug events. Avoids the section collapsing mid-transition.
if (Object.keys(next).length === 0) return
if (targetName === "battery") batteryInfo = next
else systemInfo = next
}
function updateProfiles(raw) {
var parsed = Model.parseProfiles(raw, profileIndex)
// Same guard as battery: preserve the last known profile list across
// transient empty payloads so the buttons don't blink out.
if (parsed.profiles.length === 0) return
profiles = parsed.profiles
activeProfile = parsed.activeProfile
profileIndex = parsed.profileIndex
if (opened && !cursorActive) {
var idx = profiles.indexOf(activeProfile)
if (idx >= 0) profileIndex = idx
}
}
function setProfile(profile) {
if (!profile || actionProc.running) return
actionProc.command = ["blob-power-set", root.discharging ? "battery" : "ac", profile]
actionProc.running = true
}
function togglePercentage() {
root.settings = Object.assign({}, root.settings, { showPercentage: !root.showPercentage })
if (root.bar && root.bar.shell) root.bar.shell.updateEntryInline(root.moduleName, root.settings)
}
IpcHandler {
target: "blob.power"
function open() { root.open() }
function close() { root.close() }
function show() { root.open() }
function hide() { root.close() }
function toggle() { root.toggle() }
function togglePercentage() { root.togglePercentage() }
}
onOpenedChanged: {
if (opened) {
if (!batteryPresent) {
close()
return
}
refresh()
var idx = profiles.indexOf(activeProfile)
profileIndex = idx >= 0 ? idx : 0
cursorActive = false
}
}
onBatteryPresentChanged: if (!batteryPresent) close()
visible: batteryPresent
implicitWidth: batteryPresent ? button.implicitWidth : 0
implicitHeight: batteryPresent ? button.implicitHeight : 0
Process {
id: batteryProc
command: ["blob-battery-status", "--shell"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") }
}
Process {
id: profilesProc
command: ["blob-power-list", "--active-state"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateProfiles(text) }
}
Process {
id: systemProc
command: ["blob-system-stats"]
stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "system") }
}
Process {
id: actionProc
onExited: root.refresh()
}
Timer { interval: 5000; running: root.opened; repeat: true; onTriggered: root.refresh() }
// Rotate the status phrase while the panel is open and we're in a
// rotating state (charging or on battery). The text swap is wrapped in a
// fade so the changeover reads as one organism rather than a hard cut.
Timer {
id: phraseTimer
interval: 2800
running: root.opened && root.rotatingPhrases
repeat: true
triggeredOnStart: false
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 0.0; duration: 180; easing.type: Easing.OutQuad
}
ScriptAction {
script: {
var n = root.activePhrases.length
if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n
}
}
PropertyAnimation {
target: heroStatus; property: "opacity"
to: 1.0; duration: 260; easing.type: Easing.InQuad
}
}
// If we leave a rotating state mid-swap, halt the animation and snap back
// to full opacity so "FULLY CHARGED" is legible immediately rather than
// appearing dimmed.
Connections {
target: root
function onRotatingPhrasesChanged() {
if (!root.rotatingPhrases) {
phraseSwap.stop()
heroStatus.opacity = 1.0
}
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.showPercentage && !vertical
? Math.round(root.batteryFraction * 100) + "% " + root.batteryIcon()
: root.batteryIcon()
slotSize: Style.bar.iconSlot * (root.showPercentage && !vertical ? 2 : 1)
tooltipText: ""
onPressed: function(b) {
if (!root.batteryPresent) return
if (b === Qt.RightButton) root.togglePercentage()
else root.toggle()
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened && root.batteryPresent
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
if (dx !== 0) root.selectProfileByDelta(dx)
else if (dy !== 0) root.selectProfileByDelta(dy)
}
onActivateRequested: if (root.cursorActive) root.activateSelectedProfile()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Column {
id: column
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Style.space(14)
// ---------- Hero: battery icon · title/status · percentage ----------
Item {
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, heroPercent.implicitHeight)
Text {
id: heroIcon
textFormat: Text.PlainText
text: root.batteryIcon()
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 200 } }
}
Column {
id: heroLabels
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(14)
anchors.right: heroPercent.left
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
text: "Battery"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
id: heroStatus
textFormat: Text.PlainText
text: root.heroStatusText.toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
width: parent.width
}
}
Text {
id: heroPercent
textFormat: Text.PlainText
text: root.batteryInfo.percentage || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.displayLarge
font.bold: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 200 } }
}
}
// ---------- Battery progress bar ----------
Item {
width: parent.width
implicitHeight: Style.space(8)
Rectangle {
id: barTrack
anchors.fill: parent
radius: height / 2
color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12)
}
Rectangle {
id: barFill
anchors.left: barTrack.left
anchors.verticalCenter: barTrack.verticalCenter
height: barTrack.height
radius: barTrack.radius
color: root.batteryFillColor
width: Math.max(barTrack.height, barTrack.width * root.batteryFraction)
Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 220 } }
// Subtle pulse while charging — visible signal that energy is flowing in.
SequentialAnimation on opacity {
running: root.charging && !root.fullyCharged && root.opened
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { from: 1.0; to: 0.55; duration: 950; easing.type: Easing.InOutSine }
NumberAnimation { from: 0.55; to: 1.0; duration: 950; easing.type: Easing.InOutSine }
}
}
}
// ---------- Stats ----------
// Visibility is intentionally only gated by "we've ever loaded data" so
// the section never collapses mid-transition. fullyCharged is *not* part
// of the condition: UPower briefly reports FullyCharged on plug-in when
// the battery sits above the charge-control start threshold, and we
// refuse to flicker the whole panel for that ~1s window.
Row {
visible: root.batteryInfo.percentage !== undefined
width: parent.width
spacing: Style.space(20)
Column {
width: (parent.width - parent.spacing) / 2
spacing: Style.spacing.labelGap
InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" }
InfoPair { label: "Charge cycles"; value: root.batteryInfo.cycles || "—" }
}
Column {
width: (parent.width - parent.spacing) / 2
spacing: Style.spacing.labelGap
InfoPair {
label: root.chargeThresholdActive ? "Charge limit" : (root.discharging ? "Time left" : "Time to full")
value: root.chargeThresholdActive ? (root.batteryInfo.threshold || "-") : (root.batteryFlowIdle ? "-" : (root.batteryInfo.time || "—"))
}
InfoPair {
label: root.chargeThresholdActive ? "Battery state" : (root.discharging ? "Discharging" : "Charging")
value: root.chargeThresholdActive ? "Holding" : (root.batteryFull ? "-" : (root.batteryInfo.rate || ""))
}
}
}
// ---------- Power profile picker ----------
PanelSeparator {
foreground: root.bar.foreground
}
Column {
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "POWER PROFILE"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Row {
id: profileRow
width: parent.width
spacing: Style.space(6)
readonly property real cellWidth: root.profiles.length > 0
? (width - spacing * (root.profiles.length - 1)) / root.profiles.length
: 0
Repeater {
model: root.profiles
Button {
required property var modelData
required property int index
width: profileRow.cellWidth
iconText: root.profileIcon(String(modelData))
iconSize: Style.font.title
text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1)
fontSize: Style.font.bodySmall
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY + Style.space(2)
bordered: true
active: root.activeProfile === modelData
hasCursor: root.cursorActive && root.profileIndex === index
onClicked: root.setProfile(modelData)
onHovered: function(h) {
if (h) {
root.cursorActive = true
root.profileIndex = index
}
}
}
}
}
}
}
}
}
component InfoPair: Row {
property string label: ""
property string value: ""
width: parent.width
spacing: Style.space(8)
InfoLabel { text: label }
Item { width: Math.max(0, parent.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth - parent.spacing * 2); height: 1 }
InfoValue { text: value }
}
component InfoLabel: Text {
textFormat: Text.PlainText
color: root.bar.foreground
opacity: 0.6
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
component InfoValue: Text {
textFormat: Text.PlainText
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.power",
"name": "Power",
"version": "1.0.0",
"author": "Blob",
"description": "Battery, power profile, and system stats",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Power",
"description": "Battery, power profile, and system stats",
"category": "System",
"allowMultiple": false
}
}
+202
View File
@@ -0,0 +1,202 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
// The shared gauge-cluster overlay (SpeedTestOverlay) dressed for the
// internet speed test: download and upload dials in Mbps, titled with the
// connection under test.
//
// Standalone panel plugin: summoning it starts a fresh run, dismissing it
// stops the traffic, so the download workers never keep saturating the link
// behind a closed overlay. The payload may carry the connection's display
// name -- {"connection": "MyWifi"} -- and the panel looks it up itself via
// blob-network-status when the caller doesn't know it.
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
property string connectionName: ""
property bool running: false
property bool expectedStop: false
property bool pendingRun: false
property string phase: "" // "down" | "up" | ""
property string stderrText: ""
property string downloadMbps: ""
property string uploadMbps: ""
property string error: ""
readonly property real downloadValue: toMbps(downloadMbps)
readonly property real uploadValue: toMbps(uploadMbps)
function toMbps(raw) {
var value = parseFloat(raw)
return isFinite(value) && value > 0 ? value : 0
}
function open(payloadJson) {
var payload = {}
try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {}
if (payload.connection !== undefined) root.connectionName = String(payload.connection)
else refreshConnectionName()
root.opened = true
runSpeedTest()
}
function close() {
root.opened = false
root.pendingRun = false
phaseTimer.stop()
// Clear the phase before killing the process: onExited advances to the
// upload phase when it still reads "down".
root.phase = ""
root.running = false
if (speedTestProc.running) {
root.expectedStop = true
speedTestProc.running = false
}
}
function dismiss() {
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.speedtest")
else close()
}
function refreshConnectionName() {
root.connectionName = ""
statusProc.running = false
statusProc.running = true
}
function updateSpeedTestLine(line) {
var value = parseFloat(line)
if (!isFinite(value) || value < 0) return
if (phase === "down") downloadMbps = String(value)
else if (phase === "up") uploadMbps = String(value)
}
function runSpeedTest() {
if (speedTestProc.running) {
// A dismissal's SIGTERM is still in flight; Process.running stays true
// until the child exits, so queue the fresh run for onExited.
if (expectedStop) pendingRun = true
return
}
error = ""
downloadMbps = ""
uploadMbps = ""
running = true
startPhase("down")
}
function startPhase(nextPhase) {
expectedStop = false
phase = nextPhase
stderrText = ""
speedTestProc.command = ["blob-network-speedtest", nextPhase]
speedTestProc.running = true
phaseTimer.restart()
}
function stopPhase() {
phaseTimer.stop()
if (speedTestProc.running) {
expectedStop = true
speedTestProc.running = false
return
}
finishPhase()
}
function finishPhase() {
if (phase === "down") {
startPhase("up")
return
}
phase = ""
running = false
expectedStop = false
}
Process {
id: speedTestProc
stdout: SplitParser { onRead: function(line) { root.updateSpeedTestLine(line) } }
// Exit and stream-finished have no guaranteed order: when a failed exit
// beat the collector and published the generic message, replace it with
// the specific one once it lands.
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.stderrText = String(text || "").trim()
if (root.error !== "" && root.stderrText !== "") root.error = root.stderrText
}
}
onExited: function(exitCode) {
phaseTimer.stop()
if (root.pendingRun) {
root.pendingRun = false
root.expectedStop = false
if (root.opened) Qt.callLater(root.runSpeedTest)
return
}
if (!root.expectedStop && exitCode !== 0) {
root.error = root.stderrText || "Speed test failed"
root.phase = ""
root.running = false
return
}
root.expectedStop = false
root.finishPhase()
}
}
Timer {
id: phaseTimer
interval: 5000
repeat: false
onTriggered: root.stopPhase()
}
// Names the connection under test when the summoner didn't. First tab
// field is the kind, second the SSID (wifi) or device (ethernet).
Process {
id: statusProc
command: ["blob-network-status"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var fields = String(text || "").trim().split("\t")
if (fields[0] === "wifi") root.connectionName = fields[1] || "Wi-Fi"
else if (fields[0] === "ethernet") root.connectionName = "Ethernet"
}
}
}
SpeedTestOverlay {
fontFamily: Style.font.family
layerNamespace: "blob-network-speedtest"
title: root.connectionName
leftLabel: "DOWNLOAD"
rightLabel: "UPLOAD"
runAgainTooltip: "Measure again via fast.com"
running: root.running
leftValue: root.downloadValue
rightValue: root.uploadValue
leftLive: root.running && root.phase === "down"
rightLive: root.running && root.phase === "up"
error: root.error
open: root.opened
onCloseRequested: root.dismiss()
onRunAgainRequested: root.runSpeedTest()
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.speedtest",
"name": "Speed Test",
"version": "1.0.0",
"author": "Blob",
"description": "Internet speed test with download and upload dials",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}
@@ -0,0 +1,83 @@
import QtQuick
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "blob.weather"
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 refresh() {
if (panelLoader.item && panelLoader.item.refresh) panelLoader.item.refresh()
}
function togglePanel() {
if (panelLoader.item && panelLoader.item.toggle) panelLoader.item.toggle()
}
// Shape contract for shell.summon/hide/toggle routing (Bar.findPanelWidget
// requires open/close/opened on the bar-widget root). Open maps to the
// panel's hotkey path so summoning suppresses the center hover reveal,
// matching what the old per-plugin IpcHandler did.
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()
}
// Forwarded so this widget can stand in for the panel as the bar's popout
// identity: Bar.requestPopout prefers closeForPopoutSwitch over close, and
// KeyboardPanel reads popoutSwitchClosing back off its owner.
readonly property bool popoutSwitchClosing: panelLoader.item ? panelLoader.item.popoutSwitchClosing === true : false
function closeForPopoutSwitch() {
if (panelLoader.item) panelLoader.item.closeForPopoutSwitch()
}
visible: panelLoader.item && panelLoader.item.label !== ""
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)
}
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: panelLoader.item ? panelLoader.item.label : ""
slotSize: Style.bar.statusSlot
// Tooltip suppressed because the panel is the detail view.
tooltipText: ""
onPressed: function(b) {
if (!root.bar) return
if (b === Qt.RightButton) root.bar.run("blob-notify-send \"$(blob-weather-status)\"")
else if (b === Qt.MiddleButton) root.refresh()
else root.togglePanel()
}
}
}
+295
View File
@@ -0,0 +1,295 @@
// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see
// blob-weather-location, which owns the format). Missing, blank, or
// unparseable means the location is auto-detected from the IP address.
function parseLocationFile(raw) {
var unset = { name: "", latitude: null, longitude: null }
try {
var data = JSON.parse(String(raw || ""))
if (!data || typeof data !== "object") return unset
var latitude = parseFloat(data.latitude)
var longitude = parseFloat(data.longitude)
var hasCoordinates = !isNaN(latitude) && !isNaN(longitude)
return {
name: typeof data.name === "string" ? data.name.replace(/^\s+|\s+$/g, "") : "",
latitude: hasCoordinates ? latitude : null,
longitude: hasCoordinates ? longitude : null
}
} catch (e) {
return unset
}
}
// wttr.in path segment for a configured location: exact coordinates when
// both are present, the URL-encoded name as a fallback (hand-edited
// weather.loc files may only carry a name), empty for IP auto-detect.
function wttrLocationQuery(location, latitude, longitude) {
var lat = parseFloat(String(latitude))
var lon = parseFloat(String(longitude))
if (!isNaN(lat) && !isNaN(lon)) return lat + "," + lon
var name = String(location || "").replace(/^\s+|\s+$/g, "")
return name === "" ? "" : encodeURIComponent(name)
}
// Open-Meteo geocoding response → suggestion rows for the location picker.
function parseGeocodingResults(raw) {
try {
var data = JSON.parse(String(raw || "{}"))
var results = data.results
if (!results || !results.length) return []
var out = []
for (var i = 0; i < results.length; i++) {
var r = results[i]
if (!r || !r.name || r.latitude === undefined || r.longitude === undefined) continue
var region = [r.admin1, r.country].filter(function(part) { return !!part }).join(", ")
out.push({
name: String(r.name),
description: region,
latitude: r.latitude,
longitude: r.longitude
})
}
return out
} catch (e) {
return []
}
}
function locationCommit(text, suggestions, selectedIndex) {
var name = String(text || "").replace(/^\s+|\s+$/g, "")
if (name === "") return { name: "", latitude: null, longitude: null }
var choices = suggestions || []
var index = Math.max(0, Math.min(parseInt(selectedIndex, 10) || 0, choices.length - 1))
var suggestion = choices[index]
if (suggestion) return suggestion
return { name: name, latitude: null, longitude: null }
}
function isFutureForecastDate(dateString, todayString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > String(todayString || "")
}
function roundedTemp(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : String(Math.round(n))
}
function celsiusToFahrenheit(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : (n * 9 / 5) + 32
}
function formatTemp(value, useImperial) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function normalizedUnit(value) {
return String(value || "").replace(/^\s+|\s+$/g, "").toLowerCase()
}
function localeUsesImperial(localeName) {
var name = String(localeName || "").replace(".", "_")
return /^en[_-]US($|[_.-])/.test(name) || /^en[_-]LR($|[_.-])/.test(name) || /^my($|[_.-])/.test(name)
}
function countryUsesImperial(countryName) {
var country = String(countryName || "")
.replace(/^\s+|\s+$/g, "")
.replace(/[._-]+/g, " ")
.toLowerCase()
if (!country) return null
if (country === "us" || country === "usa" || country === "united states" || country === "united states of america") return true
if (country === "liberia" || country === "myanmar" || country === "burma") return true
return false
}
function shouldUseImperial(unitOverride, localeName, countryName) {
var unit = normalizedUnit(unitOverride)
if (unit === "imperial") return true
if (unit === "metric") return false
var countryPreference = countryUsesImperial(countryName)
if (countryPreference !== null) return countryPreference
return localeUsesImperial(localeName)
}
function dayName(dateString, formatter) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
if (formatter) return formatter(d)
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]
}
function openMeteoForecastDays(dailyForecastReport, todayString) {
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
if (!daily || !daily.time) return []
var result = []
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
var date = daily.time[i]
if (!isFutureForecastDate(date, todayString)) continue
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
result.push({
date: date,
maxtempC: roundedTemp(maxC),
mintempC: roundedTemp(minC),
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
})
}
return result
}
// Open-Meteo bundles current conditions with the daily forecast request and
// answers far faster than wttr.in. Normalize them to wttr's
// current_condition shape so the panel can use either source
// interchangeably. Open-Meteo reports metric (°C, km/h).
function openMeteoCurrentCondition(dailyForecastReport) {
var current = dailyForecastReport && dailyForecastReport.current ? dailyForecastReport.current : null
if (!current || current.temperature_2m === undefined || current.temperature_2m === null) return null
return {
temp_C: roundedTemp(current.temperature_2m),
temp_F: roundedTemp(celsiusToFahrenheit(current.temperature_2m)),
FeelsLikeC: roundedTemp(current.apparent_temperature),
FeelsLikeF: roundedTemp(celsiusToFahrenheit(current.apparent_temperature)),
windspeedKmph: roundedTemp(current.wind_speed_10m),
windspeedMiles: roundedTemp(current.wind_speed_10m * 0.621371),
humidity: roundedTemp(current.relative_humidity_2m),
openMeteoWeatherCode: current.weather_code,
isDay: current.is_day
}
}
function currentIcon(current, fallback) {
if (!current) return fallback || ""
if (current.openMeteoWeatherCode !== undefined && current.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(current.openMeteoWeatherCode, Number(current.isDay) === 0)
if (current.weatherCode !== undefined && current.weatherCode !== null)
return iconForCode(current.weatherCode, false)
return fallback || ""
}
// wttr.in has no day/night flag. Use its icon only to fill an empty initial
// state, never to replace a day/night-aware icon resolved by Open-Meteo.
function provisionalCurrentIcon(current, resolvedIcon) {
return resolvedIcon || currentIcon(current, "")
}
function weatherResponseCompletesSave(hasConfiguredCoordinates, source) {
return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr"
}
function wttrNextForecastDays(report, todayString) {
var days = report && report.weather ? report.weather : []
var result = []
for (var i = 0; i < days.length && result.length < 3; ++i) {
if (isFutureForecastDate(days[i].date, todayString)) result.push(days[i])
}
return result
}
function buildForecastDays(report, dailyForecastReport, todayString) {
var days = openMeteoForecastDays(dailyForecastReport, todayString)
return days.length > 0 ? days : wttrNextForecastDays(report, todayString)
}
function bareTempForDay(day, kind, useImperial) {
if (!day) return ""
var v = useImperial
? (kind === "max" ? day.maxtempF : day.mintempF)
: (kind === "max" ? day.maxtempC : day.mintempC)
if (v === undefined || v === null || v === "") return ""
return v + "°"
}
function dayIcon(day) {
if (!day) return ""
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null)
return iconForOpenMeteoCode(day.openMeteoWeatherCode)
if (!day.hourly || day.hourly.length === 0) return ""
var best = day.hourly[0]
var bestDist = 9999
for (var i = 0; i < day.hourly.length; ++i) {
var t = parseInt(String(day.hourly[i].time || "0"), 10)
var dist = Math.abs(t - 1200)
if (dist < bestDist) {
bestDist = dist
best = day.hourly[i]
}
}
return iconForCode(best.weatherCode, false)
}
function iconForOpenMeteoCode(code, night) {
var c = parseInt(String(code || "0"), 10)
if (c === 0) return iconForCode(113, night)
if (c === 1 || c === 2) return iconForCode(116, night)
if (c === 3) return iconForCode(119, night)
if (c === 45 || c === 48) return iconForCode(143, night)
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, night)
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, night)
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, night)
if (c === 95 || c === 96 || c === 99) return iconForCode(389, night)
return iconForCode(119, night)
}
function iconForCode(code, night) {
var c = parseInt(String(code || "0"), 10)
switch (c) {
case 113: return night ? "" : ""
case 116: return night ? "" : ""
case 119: case 122: return ""
case 143: case 248: case 260: return night ? "\ue346" : "\ue313"
case 176: case 263: case 353: return night ? "" : ""
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
case 182: case 185: case 281: case 284: case 311: case 314:
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
case 200: case 386: case 389: case 392: case 395: return ""
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
case 329: case 332: case 335: case 338: case 371: return ""
default: return ""
}
}
if (typeof module !== "undefined") {
module.exports = {
parseLocationFile: parseLocationFile,
wttrLocationQuery: wttrLocationQuery,
parseGeocodingResults: parseGeocodingResults,
locationCommit: locationCommit,
isFutureForecastDate: isFutureForecastDate,
roundedTemp: roundedTemp,
celsiusToFahrenheit: celsiusToFahrenheit,
formatTemp: formatTemp,
normalizedUnit: normalizedUnit,
localeUsesImperial: localeUsesImperial,
countryUsesImperial: countryUsesImperial,
shouldUseImperial: shouldUseImperial,
dayName: dayName,
openMeteoForecastDays: openMeteoForecastDays,
openMeteoCurrentCondition: openMeteoCurrentCondition,
currentIcon: currentIcon,
provisionalCurrentIcon: provisionalCurrentIcon,
weatherResponseCompletesSave: weatherResponseCompletesSave,
wttrNextForecastDays: wttrNextForecastDays,
buildForecastDays: buildForecastDays,
bareTempForDay: bareTempForDay,
dayIcon: dayIcon,
iconForOpenMeteoCode: iconForOpenMeteoCode,
iconForCode: iconForCode
}
}
+881
View File
@@ -0,0 +1,881 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "blob.weather"
ipcTarget: "blob.weather"
manageIpc: false
property var anchorItem: null
property bool openedFromHotkey: false
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
function open() {
openedFromHotkey = false
setCenterHoverRevealSuppressed(false)
root.controller.show()
locationFile.reload()
root.refresh()
}
function openFromHotkey() {
openedFromHotkey = true
root.controller.show()
locationFile.reload()
root.refresh()
// Set after showing, not before: showing hands the popout coordinator
// over, which closes whichever panel was open, and that close clears the
// shared flag. Deferring means the panel taking over always wins, while
// a handoff to a panel that does not manage the flag still leaves it
// cleared rather than stuck on.
Qt.callLater(function() {
if (root.opened) setCenterHoverRevealSuppressed(true)
})
}
function close() {
setCenterHoverRevealSuppressed(false)
if (root.editingLocation) root.cancelEditingLocation()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.openFromHotkey()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
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
}
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
property var report: null
property var dailyForecastReport: null
property string wttrLocation: ""
// Configured location, read from the weather.json state file (owned by
// blob-weather-location). The query is the wttr.in path segment
// (coordinates when stored, else the encoded name); empty means IP
// auto-detect. The watch makes hand edits take effect live.
property var configuredLocationState: ({ name: "", latitude: null, longitude: null })
readonly property string configuredLocation: configuredLocationState.name
readonly property string locationQuery: Model.wttrLocationQuery(configuredLocationState.name, configuredLocationState.latitude, configuredLocationState.longitude)
// Keep the previous report visible while the new location loads. The
// editor remains open with a spinner, so stale data is never presented
// under the newly configured location label.
onLocationQueryChanged: {
if (savingLocation) savingLocationQueryStarted = true
forecastRetries = 0
dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(refresh)
}
property FileView locationFile: FileView {
path: Quickshell.env("HOME") + "/.local/state/blob/settings/weather.json"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.configuredLocationState = Model.parseLocationFile(text())
onLoadFailed: root.configuredLocationState = Model.parseLocationFile("")
}
// The first read can race shell startup (observed sporadically), leaving a
// stored location unhonored until the next file write. One delayed reload
// self-corrects; if the first read was fine it's a no-op, since identical
// state doesn't change locationQuery and so triggers no refetch.
Timer {
interval: 1500
running: true
onTriggered: locationFile.reload()
}
property int forecastRetries: 0
property int dailyForecastRetries: 0
// Click-to-edit state for the location label.
property bool editingLocation: false
property bool savingLocation: false
property bool savingLocationQueryStarted: false
property var locationSuggestions: []
property int suggestionIndex: 0
property string geocodePendingQuery: ""
property string geocodeActiveQuery: ""
// Shared hero/bar icon state, updated with each successful weather response.
property string label: ""
// wttr's current conditions when available; open-meteo's (bundled with the
// much faster daily forecast fetch) fill the hero while wttr is in flight.
readonly property bool hasConfiguredCoordinates: !isNaN(parseFloat(String(configuredLocationState.latitude))) && !isNaN(parseFloat(String(configuredLocationState.longitude)))
readonly property var openMeteoCurrent: Model.openMeteoCurrentCondition(dailyForecastReport)
readonly property var current: (hasConfiguredCoordinates && openMeteoCurrent) ? openMeteoCurrent : ((report && report.current_condition && report.current_condition[0]) ? report.current_condition[0] : openMeteoCurrent)
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
readonly property var forecastDays: buildForecastDays()
readonly property string reportCountry: areaInfo && areaInfo.country && areaInfo.country[0] ? areaInfo.country[0].value : ""
readonly property bool useImperial: Model.shouldUseImperial(setting("unit", ""), Qt.locale().name, reportCountry)
// Auto-refresh interval in minutes; clamped to a sane minimum.
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
readonly property string reportLocation: configuredLocation || wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
function refresh() {
// Each full refresh cycle gets a fresh retry budget, so an earlier
// exhausted round (e.g. waking with the network still down) doesn't
// starve retries for the rest of the session.
forecastRetries = 0
dailyForecastRetries = 0
if (!forecastProc.running) forecastProc.running = true
if (root.locationQuery === "" && !locationProc.running) locationProc.running = true
// With stored coordinates this fetches open-meteo right away — no need
// to wait for the slow wttr response. Without them it's a no-op until
// wttr reports the detected area.
refreshDailyForecast(null)
}
function refreshDailyForecast(sourceReport) {
if (dailyForecastProc.running) return
var lat = parseFloat(String(root.configuredLocationState.latitude))
var lon = parseFloat(String(root.configuredLocationState.longitude))
if (isNaN(lat) || isNaN(lon)) {
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
if (!area) return
lat = parseFloat(String(area.latitude || ""))
lon = parseFloat(String(area.longitude || ""))
}
if (isNaN(lat) || isNaN(lon)) return
var url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + encodeURIComponent(String(lat))
+ "&longitude=" + encodeURIComponent(String(lon))
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day"
+ "&forecast_days=4"
+ "&timezone=auto"
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
dailyForecastProc.running = true
}
// ---- Location editing. Clicking the location label swaps it for a search
// field; picking a geocoded suggestion persists name + coordinates to
// the module's shell.json entry. An empty commit returns to auto.
function startEditingLocation() {
editingLocation = true
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
suggestionIndex = 0
Qt.callLater(function() {
locationField.text = root.configuredLocation
locationField.selectAll()
locationField.forceActiveFocus()
})
}
function cancelEditingLocation() {
editingLocation = false
savingLocation = false
savingLocationQueryStarted = false
locationSuggestions = []
geocodeDebounce.stop()
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
}
function commitLocation() {
var location = Model.locationCommit(locationField.text, locationSuggestions, suggestionIndex)
if (location.name === "") {
clearLocation()
return
}
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: location.name,
latitude: location.latitude,
longitude: location.longitude
}
persistLocation(location.name, location.latitude, location.longitude)
}
function clearLocation() {
persistLocation("", null, null)
wttrLocation = ""
cancelEditingLocation()
}
function pickSuggestion(suggestion) {
if (!suggestion) return
savingLocation = true
savingLocationQueryStarted = false
configuredLocationState = {
name: suggestion.name,
latitude: suggestion.latitude,
longitude: suggestion.longitude
}
persistLocation(suggestion.name, suggestion.latitude, suggestion.longitude)
}
function finishSavingLocation() {
if (savingLocation && savingLocationQueryStarted) cancelEditingLocation()
}
function persistLocation(name, latitude, longitude) {
if (name && latitude !== null && longitude !== null)
locationSaveProc.command = ["blob-weather-location", "--set", name, latitude + "," + longitude]
else if (name)
locationSaveProc.command = ["blob-weather-location", "--set", name]
else
locationSaveProc.command = ["blob-weather-location", "--clear"]
locationSaveProc.running = true
}
// Debounced geocoding. Only one curl runs at a time; if the query moved on
// while a fetch was in flight, the latest query is fetched right after.
function requestGeocode() {
var query = locationField.text.trim()
if (query.length < 2) {
locationSuggestions = []
return
}
geocodePendingQuery = query
if (!geocodeProc.running) startGeocode()
}
function startGeocode() {
geocodeActiveQuery = geocodePendingQuery
geocodeProc.command = ["curl", "-fsS", "--max-time", "5",
"https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(geocodeActiveQuery) + "&count=5&language=en&format=json"]
geocodeProc.running = true
}
function buildForecastDays() {
return Model.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function openMeteoForecastDays() {
return Model.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function wttrNextForecastDays() {
return Model.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function isFutureForecastDate(dateString) {
return Model.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function roundedTemp(value) {
return Model.roundedTemp(value)
}
function celsiusToFahrenheit(value) {
return Model.celsiusToFahrenheit(value)
}
function formatTemp(value) {
return Model.formatTemp(value, useImperial)
}
function dayName(dateString) {
return Model.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
}
// Bare degree value (no unit letter), used in the forecast row.
function bareTempForDay(day, kind) {
return Model.bareTempForDay(day, kind, useImperial)
}
// Representative icon for a forecast day: the hourly entry nearest noon.
function dayIcon(day) {
return Model.dayIcon(day)
}
function iconForOpenMeteoCode(code) {
return Model.iconForOpenMeteoCode(code)
}
// Mirrors blob-weather-icon's wttr.in code → nerd-font glyph mapping.
function iconForCode(code, night) {
return Model.iconForCode(code, night)
}
Process {
id: forecastProc
command: ["curl", "-fsS", "--max-time", "10", "https://wttr.in/" + root.locationQuery + "?format=j1"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
root.report = parsed
if (!root.hasConfiguredCoordinates)
root.label = Model.provisionalCurrentIcon(parsed.current_condition && parsed.current_condition[0], root.label)
root.forecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "wttr"))
root.finishSavingLocation()
// Stored coordinates already drove the fast open-meteo fetch from
// refresh(); only auto-detect needs the area wttr reported.
if (isNaN(parseFloat(String(root.configuredLocationState.latitude))))
root.refreshDailyForecast(parsed)
} catch (e) {
// Keep last-good report visible, but try again shortly.
root.scheduleForecastRetry()
}
}
}
}
// wttr.in can be slow or flaky, especially for a location it hasn't
// cached yet. Retry a few times before leaving it to the refresh timer.
function scheduleForecastRetry() {
if (forecastRetries >= 3) return
forecastRetries++
forecastRetryTimer.restart()
}
Timer {
id: forecastRetryTimer
interval: 2500
onTriggered: if (!forecastProc.running) forecastProc.running = true
}
// With configured coordinates this fetch is the only thing that updates the
// bar icon, so a dropped response (e.g. waking before the network is back)
// must retry rather than wait out the refresh timer with a stale icon.
function scheduleDailyForecastRetry() {
if (dailyForecastRetries >= 3) return
dailyForecastRetries++
dailyForecastRetryTimer.restart()
}
Timer {
id: dailyForecastRetryTimer
interval: 2500
onTriggered: root.refreshDailyForecast(null)
}
Process {
id: dailyForecastProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) {
root.scheduleDailyForecastRetry()
return
}
try {
var parsed = JSON.parse(raw)
var parsedCurrent = Model.openMeteoCurrentCondition(parsed)
root.dailyForecastReport = parsed
root.label = Model.currentIcon(parsedCurrent, root.label)
root.dailyForecastRetries = 0
if (Model.weatherResponseCompletesSave(root.hasConfiguredCoordinates, "open-meteo"))
root.finishSavingLocation()
} catch (e) {
// Keep last-good daily forecast visible, but try again shortly.
root.scheduleDailyForecastRetry()
}
}
}
}
Process {
id: geocodeProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.locationSuggestions = root.editingLocation ? Model.parseGeocodingResults(text) : []
root.suggestionIndex = 0
if (root.geocodePendingQuery !== root.geocodeActiveQuery) Qt.callLater(root.startGeocode)
}
}
}
Timer {
id: geocodeDebounce
interval: 300
onTriggered: root.requestGeocode()
}
Process {
id: locationSaveProc
onExited: function(exitCode) {
if (exitCode !== 0 || !root.savingLocation) return
// FileView handles changed locations. Explicitly refresh here too so
// saving the already-active location cannot strand the spinner.
locationFile.reload()
if (!root.savingLocationQueryStarted) {
root.savingLocationQueryStarted = true
root.forecastRetries = 0
root.dailyForecastRetries = 0
forecastProc.running = false
dailyForecastProc.running = false
Qt.callLater(root.refresh)
}
}
}
Process {
id: locationProc
command: ["curl", "-fsS", "--max-time", "4", "https://wttr.in/?format=%l"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var raw = String(text || "").trim()
if (!raw) return
root.wttrLocation = raw.split(",")[0]
}
}
}
Timer {
id: refreshTimer
interval: root.refreshMinutes * 60 * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
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 edit(): void { root.openFromHotkey(); root.startEditingLocation() }
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
centerOnBar: true
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(480))
contentHeight: panel.fittedContentHeight(weatherColumn.implicitHeight)
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.editingLocation
onReturnRequested: root.startEditingLocation()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
Flickable {
id: weatherScroll
anchors.fill: parent
contentWidth: width
contentHeight: weatherColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
Column {
id: weatherColumn
width: weatherScroll.width
spacing: Style.space(14)
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
Item {
width: parent.width
height: Math.max(heroLeft.height, heroRight.height)
Row {
id: heroLeft
anchors.left: parent.left
anchors.leftMargin: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(16)
Text {
id: heroIcon
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 5
text: root.label || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Decorative condition emoji; intentionally larger than the
// Style.font.* scale's displayLarge (28).
font.pixelSize: 64
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
id: tempBig
textFormat: Text.PlainText
text: root.reportTempNum || "—"
color: root.bar.foreground
font.family: root.bar.fontFamily
// Hero temperature read-out; deliberately oversized, outside
// the Style.font.* scale.
font.pixelSize: 56
font.bold: true
}
Text {
textFormat: Text.PlainText
text: root.current ? root.tempUnit : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
anchors.top: tempBig.top
anchors.topMargin: Style.space(10)
}
}
}
Column {
id: heroRight
width: weatherStats.implicitWidth
anchors.right: parent.right
anchors.rightMargin: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(12)
Row {
visible: !root.editingLocation && root.reportLocation !== ""
spacing: Style.space(6)
TapHandler {
onTapped: root.startEditingLocation()
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
Text {
text: "" // nf-fa-map_marker
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
anchors.verticalCenter: parent.verticalCenter
}
Text {
textFormat: Text.PlainText
text: (root.reportLocation || "").toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
font.letterSpacing: 1
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
visible: root.editingLocation
spacing: Style.space(6)
TextField {
id: locationField
width: Style.space(190)
enabled: !root.savingLocation
placeholderText: "Search city"
foreground: root.bar.foreground
font.family: root.bar.fontFamily
onTextChanged: if (root.editingLocation && !root.savingLocation) geocodeDebounce.restart()
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelEditingLocation()
event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (root.suggestionIndex < root.locationSuggestions.length - 1) root.suggestionIndex++
event.accepted = true
} else if (event.key === Qt.Key_Up) {
if (root.suggestionIndex > 0) root.suggestionIndex--
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.commitLocation()
event.accepted = true
}
}
}
// Clear back to IP auto-detect. While a committed location is
// loading, this same compact affordance becomes a spinner.
Rectangle {
width: Style.space(18)
height: Style.space(18)
anchors.verticalCenter: parent.verticalCenter
radius: Math.min(4, Style.cornerRadius)
color: !root.savingLocation && clearLocationArea.containsMouse ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.savingLocation ? "󰦖" : "✕"
font.family: root.bar.fontFamily
color: Qt.darker(root.bar.foreground, 1.4)
font.pixelSize: Style.font.bodySmall
RotationAnimator on rotation {
running: root.savingLocation
from: 0; to: 360
duration: 800
loops: Animation.Infinite
}
}
MouseArea {
id: clearLocationArea
anchors.fill: parent
enabled: !root.savingLocation
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.clearLocation()
}
}
}
Row {
id: weatherStats
visible: !!root.current
spacing: Style.space(36)
Column {
spacing: Style.space(5)
Text {
text: "FEELS"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportFeels
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "WIND"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportWind
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
Column {
spacing: Style.space(5)
Text {
text: "HUMID"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
textFormat: Text.PlainText
text: root.reportHumidity
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.title
}
}
}
}
}
// ---- Geocoding suggestions while the location is being edited.
Column {
visible: root.editingLocation && !root.savingLocation && root.locationSuggestions.length > 0
width: parent.width
spacing: 0
Repeater {
model: root.locationSuggestions
Rectangle {
required property var modelData
required property int index
width: parent.width
height: suggestionRow.implicitHeight + Style.space(12)
radius: Style.cornerRadius
color: index === root.suggestionIndex ? Style.hoverFillFor(root.bar.foreground, Color.accent) : "transparent"
Row {
id: suggestionRow
anchors.left: parent.left
anchors.leftMargin: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: modelData.name
color: index === root.suggestionIndex ? Style.hoverStateColor(root.bar.foreground, Color.accent) : root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: modelData.description
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: root.suggestionIndex = index
onClicked: root.pickSuggestion(modelData)
}
}
}
}
Text {
visible: !root.current
text: "Fetching forecast…"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
// ---- Divider between current conditions and forecast.
Rectangle {
visible: root.forecastDays.length > 0
width: parent.width
height: Style.spacing.hairline
color: root.bar.foreground
opacity: 0.12
}
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
// Wrapped in an Item so the block of cells can be centered within the popup.
Item {
visible: root.forecastDays.length > 0
width: parent.width
height: forecastRow.height
Row {
id: forecastRow
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(44)
Repeater {
model: root.forecastDays
Row {
required property var modelData
required property int index
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.dayIcon(modelData)
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.display
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
text: root.dayName(modelData.date).toUpperCase()
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
font.letterSpacing: 1
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "max")
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
text: root.bareTempForDay(modelData, "min")
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
}
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"id": "blob.weather",
"name": "Weather",
"version": "1.0.0",
"author": "Blob",
"description": "Weather pill with detail popup",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Weather",
"description": "Weather pill with detail popup",
"category": "Info",
"allowMultiple": false,
"settingsForm": "weatherSettings"
}
}
+37
View File
@@ -0,0 +1,37 @@
// Parses blob-network-qr output: a "meta\t<iface>\t<security>\t<ssid>"
// header, then a square 0/1 module matrix. The SSID sits last so it may
// contain tabs. A malformed matrix returns empty rather than rendering a
// code that cannot scan.
function parseQrOutput(raw) {
var lines = String(raw || "").trim().split(/\r?\n/).filter(function(line) { return line !== "" })
var meta = { iface: "", security: "", ssid: "" }
if (lines.length > 0 && lines[0].indexOf("meta\t") === 0) {
var fields = lines.shift().split("\t")
meta.iface = fields[1] || ""
meta.security = fields[2] || ""
meta.ssid = fields.slice(3).join("\t")
}
return { meta: meta, matrix: parseQrMatrix(lines) }
}
function parseQrMatrix(lines) {
if (lines.length === 0) return { rows: [], size: 0 }
var size = lines[0].length
if (size !== lines.length) return { rows: [], size: 0 }
for (var i = 0; i < lines.length; i++) {
if (lines[i].length !== size || !/^[01]+$/.test(lines[i])) return { rows: [], size: 0 }
}
return { rows: lines, size: size }
}
if (typeof module !== "undefined") {
module.exports = {
parseQrOutput: parseQrOutput,
parseQrMatrix: parseQrMatrix
}
}
+369
View File
@@ -0,0 +1,369 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "Model.js" as Model
// Centered Wi-Fi share overlay: no card, just the QR code floating on a
// heavy scrim. Esc or the scrim dismiss it.
//
// Standalone panel plugin: each summon regenerates the code via
// blob-network-qr, which emits the interface, security, and SSID it
// shared ahead of the module matrix — so a bare summon self-detects the
// connection. The payload may pin the interface and pre-title the card:
// {"iface": "wlan0", "ssid": "MyWifi"}.
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string iface: ""
property string ssid: ""
property bool secured: false
property var qrRows: []
property int qrSize: 0
property string error: ""
property bool loading: false
property bool expectedStop: false
property bool pendingShow: false
property string pendingIface: ""
property string password: ""
property bool passwordVisible: false
property string passwordError: ""
property bool pwExpectedStop: false
readonly property bool showingQr: qrSize > 0 && !loading && error === ""
// The scrim below is a fixed near-black regardless of theme, so text on
// it needs a fixed light palette, not the themed foreground.
readonly property color onScrim: "white"
readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55)
readonly property color onScrimUrgent: "#ff6b6b"
readonly property string fontFamily: Style.font.family
function open(payloadJson) {
var payload = {}
try { payload = JSON.parse(payloadJson || "{}") || {} } catch (e) {}
// The payload SSID titles the card during generation; the meta line the
// generator emits is authoritative and overwrites it. A payload without
// one clears the title: a re-summon may be sharing a different
// connection, so the previous card's name must not label this one.
root.ssid = payload.ssid !== undefined ? String(payload.ssid) : ""
generate(String(payload.iface || ""))
root.opened = true
// The window is instantiated hidden, so the content's `focus: true` is
// evaluated before the surface is mapped and Escape would land nowhere.
// Re-acquire after mapping.
Qt.callLater(function() {
if (root.opened) keyCatcher.forceActiveFocus()
})
}
function close() {
root.opened = false
root.pendingShow = false
if (qrProc.running) {
root.expectedStop = true
qrProc.running = false
}
if (pwProc.running) pwProc.running = false
root.qrSize = 0
root.qrRows = []
root.error = ""
root.loading = false
root.iface = ""
root.ssid = ""
root.secured = false
// The Wi-Fi password only enters shell memory while the card is up.
root.password = ""
root.passwordVisible = false
root.passwordError = ""
}
function dismiss() {
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.wifiqr")
else close()
}
function generate(requestedIface) {
if (qrProc.running) {
// Whether the run in flight is a dismissal's SIGTERM still landing or
// a live generation for an earlier summon, the latest request wins:
// queue it for onExited and stop the old process.
pendingShow = true
pendingIface = requestedIface
if (!expectedStop) {
expectedStop = true
qrProc.running = false
}
return
}
qrSize = 0
qrRows = []
error = ""
loading = true
expectedStop = false
// A re-summon while the card is still loaded reaches here without a
// close() in between, and may be sharing a different connection now:
// neither the previous reveal's password nor a reveal still in flight
// may survive onto the new card.
iface = ""
secured = false
password = ""
passwordVisible = false
passwordError = ""
if (pwProc.running) {
pwExpectedStop = true
pwProc.running = false
}
qrProc.command = requestedIface
? ["blob-network-qr", "--meta", requestedIface]
: ["blob-network-qr", "--meta"]
qrProc.running = true
}
function updateQr(raw) {
var parsed = Model.parseQrOutput(raw)
qrRows = parsed.matrix.rows
qrSize = parsed.matrix.size
if (parsed.meta.ssid !== "") ssid = parsed.meta.ssid
if (parsed.meta.iface !== "") iface = parsed.meta.iface
secured = parsed.meta.security !== "" && parsed.meta.security !== "nopass"
// Good output settles the run: a canceled predecessor's stderr may have
// landed after this generation started, and must not shadow its result.
if (qrSize > 0) error = ""
}
function togglePassword() {
if (passwordVisible) { passwordVisible = false; return }
if (password !== "") { passwordVisible = true; return }
if (pwProc.running || !iface) return
passwordError = ""
// Only a deliberate new lookup lowers the canceled-fetch guard, right as
// it launches -- see the pwProc comment.
pwExpectedStop = false
pwProc.command = ["blob-network-password", iface]
pwProc.running = true
}
Process {
id: qrProc
// Both collectors check expectedStop: a dismissal mid-generation kills
// the process, but buffered output still arrives afterwards and would
// repopulate qrSize -- reopening the card the user just closed. The flag
// stays set through onExited (generate resets it) because the exit and
// stream-finished signals have no guaranteed order.
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: if (!root.expectedStop) root.updateQr(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (!root.expectedStop) root.error = String(text || "").trim()
}
onExited: function(exitCode) {
root.loading = false
if (root.pendingShow) {
root.pendingShow = false
// expectedStop stays set until generate() launches the replacement:
// the canceled run's collectors may fire between here and then, and
// must keep being dropped.
Qt.callLater(function() { root.generate(root.pendingIface) })
return
}
if (root.expectedStop) return
if (exitCode !== 0 || root.qrSize === 0) {
root.qrSize = 0
root.qrRows = []
if (root.error === "") root.error = "Could not generate the Wi-Fi QR code"
}
}
}
// The Wi-Fi password only enters shell memory when the user clicks to
// reveal it, and close() drops it again. Both handlers bail when the card
// is gone so a fetch that was in flight during dismissal can't stash the
// secret into a closed panel's state, and check pwExpectedStop so a fetch
// that a regeneration killed can't reveal the previous network's password
// under the new card. The exit and stream-finished signals have no
// guaranteed order, so the flag survives onExited; only togglePassword
// lowers it, as it launches the next deliberate lookup.
Process {
id: pwProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: if (root.opened && !root.pwExpectedStop) root.password = String(text || "").trim()
}
onExited: function(exitCode) {
if (root.pwExpectedStop) return
if (!root.opened) return
if (exitCode === 0 && root.password !== "") root.passwordVisible = true
else root.passwordError = "Could not read the Wi-Fi password"
}
}
PanelWindow {
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-network-qr"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: the floating code needs the backdrop to carry the contrast
// on any wallpaper.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.78)
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
}
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.dismiss()
Item {
anchors.centerIn: parent
width: content.implicitWidth
height: content.implicitHeight
// Narrow or heavily scaled outputs: shrink the whole card rather than
// clipping it at the screen edge.
scale: Math.min(1,
(keyCatcher.width - Style.space(32)) / Math.max(1, width),
(keyCatcher.height - Style.space(32)) / Math.max(1, height))
// Swallow clicks so only the scrim outside the content dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(16)
Text {
textFormat: Text.PlainText
text: (root.ssid || "Wi-Fi").toUpperCase()
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 2
elide: Text.ElideRight
Layout.maximumWidth: Style.space(320)
Layout.alignment: Qt.AlignHCenter
horizontalAlignment: Text.AlignHCenter
}
// Render every QR module as an integer-sized native rectangle. This
// stays crisp and avoids temporary images and file-cache races. Only
// the dark modules paint, so the white canvas can keep its rounded
// corners; the spec quiet zone baked into the matrix keeps the code
// itself clear of them.
Rectangle {
id: qrCanvas
readonly property int moduleSize: root.qrSize > 0
? Math.max(4, Math.floor(Style.space(240) / root.qrSize))
: 0
visible: root.showingQr
width: root.qrSize * moduleSize
height: width
color: "white"
radius: Style.cornerRadius
Layout.alignment: Qt.AlignHCenter
Grid {
anchors.fill: parent
columns: root.qrSize
Repeater {
model: root.qrSize * root.qrSize
Rectangle {
required property int index
readonly property int matrixRow: Math.floor(index / root.qrSize)
readonly property int matrixColumn: index % root.qrSize
width: qrCanvas.moduleSize
height: qrCanvas.moduleSize
color: root.qrRows[matrixRow].charAt(matrixColumn) === "1" ? "#111111" : "transparent"
}
}
}
}
Text {
visible: root.loading
text: "Generating QR code…"
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Text {
textFormat: Text.PlainText
visible: root.error !== ""
text: root.error
color: root.onScrimUrgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.maximumWidth: Style.space(320)
horizontalAlignment: Text.AlignHCenter
}
Text {
visible: root.showingQr
text: "Scan to join this network"
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Text {
textFormat: Text.PlainText
visible: root.showingQr && root.secured
text: root.passwordError !== "" ? root.passwordError
: root.passwordVisible ? root.password
: "Show password"
color: root.passwordError !== "" ? root.onScrimUrgent : root.onScrim
opacity: root.passwordVisible || root.passwordError !== "" ? 1 : 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WrapAnywhere
Layout.fillWidth: true
Layout.maximumWidth: Style.space(320)
horizontalAlignment: Text.AlignHCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.togglePassword()
}
}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.wifiqr",
"name": "Wi-Fi QR",
"version": "1.0.0",
"author": "Blob",
"description": "Share the connected Wi-Fi network as a scannable QR code",
"kinds": [
"panel"
],
"entryPoints": {
"panel": "Panel.qml"
}
}
+392
View File
@@ -0,0 +1,392 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Polkit
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "PolkitModel.js" as PolkitModel
Item {
id: root
property string fontFamily: Style.font.menuFamily
// Bound to the central [polkit] section in shell.toml via Color.qml.
property color accent: Color.polkit.accent
property color background: Color.polkit.background
property color foreground: Color.polkit.text
property color border: Color.polkit.border
property color borderError: Color.polkit.borderError
property var borderSpec: Border.surfaceSpec("polkit", errorFlash ? "border-error" : "border", errorFlash ? borderError : border, Math.max(1, Style.space(2)), "border-alpha")
property color scrim: Color.polkit.scrim
readonly property int cornerRadius: Style.cornerRadius
property int contentMargin: Style.spacing.panelPadding
property int fieldHeight: Math.max(Style.space(42), Style.spacing.controlHeight)
property bool closing: false
property bool submitted: false
property string currentMessage: ""
property string currentPrompt: ""
property string currentSupplementary: ""
property bool responseRequired: false
property bool responseVisible: false
property bool failed: false
property bool errorFlash: false
// pam_fprintd appears in the polkit PAM stack (a sensor is enrolled).
property bool fingerprintConfigured: false
// Lid shut right now — the reader is physically unreachable, so we fall back
// to the password even when a sensor is enrolled. Refreshed per request.
property bool laptopClosed: false
property int shakeOffset: 0
readonly property bool dialogVisible: polkitAgent.isActive || closing
// We show one method at a time. Fingerprint owns the dialog while PAM is
// waiting on the reader (lid open, sensor enrolled); the moment PAM asks for
// a password — including immediately when the lid is shut and the clamshell
// gate skips pam_fprintd — we switch to the password field instead.
readonly property bool fingerprintMode: fingerprintConfigured && !laptopClosed && dialogVisible && !responseRequired && !submitted && !errorFlash
readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2
// Password mode is a wide field; fingerprint mode collapses to a square that
// just frames the centered sensor icon.
readonly property int cardWidth: fingerprintMode ? cardHeight : Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2))
function authorizationLabel(message) {
return PolkitModel.authorizationLabel(message)
}
function loadPamConfig(raw) {
fingerprintConfigured = PolkitModel.fingerprintConfiguredFromPamConfig(raw)
}
function refreshLidState() {
if (!laptopClosedProc.running) laptopClosedProc.running = true
}
function resetSnapshot() {
currentMessage = ""
currentPrompt = ""
currentSupplementary = ""
responseRequired = false
responseVisible = false
failed = false
errorFlash = false
submitted = false
passwordInput.text = ""
}
function syncFromFlow() {
var flow = polkitAgent.flow
if (!flow) return
currentMessage = String(flow.message || "Authentication is needed...")
currentPrompt = String(flow.inputPrompt || "")
currentSupplementary = String(flow.supplementaryMessage || "")
responseRequired = !!flow.isResponseRequired
responseVisible = !!flow.responseVisible
failed = !!flow.failed
if (responseRequired) submitted = false
}
function beginFlow() {
closeTimer.stop()
closing = false
submitted = false
passwordInput.text = ""
refreshLidState()
syncFromFlow()
Qt.callLater(refocus)
}
function refocus() {
if (!dialogVisible) return
// In fingerprint mode there is no field to type into — park focus on the
// key catcher so Escape still cancels; otherwise focus the password field.
if (fingerprintMode) keyCatcher.forceActiveFocus()
else passwordInput.forceActiveFocus()
}
function submitResponse() {
var flow = polkitAgent.flow
if (!flow || !flow.isResponseRequired) return
submitted = true
errorFlash = false
flow.submit(passwordInput.text)
passwordInput.text = ""
keyCatcher.forceActiveFocus()
}
function cancelRequest() {
var flow = polkitAgent.flow
passwordInput.text = ""
submitted = false
closing = true
closeTimer.restart()
if (flow) flow.cancelAuthenticationRequest()
}
function triggerFailureFeedback() {
submitted = false
errorFlash = true
passwordInput.text = ""
errorTimer.restart()
shakeAnimation.restart()
Qt.callLater(refocus)
}
Timer {
id: closeTimer
interval: 300
repeat: false
onTriggered: {
closing = false
resetSnapshot()
}
}
Timer {
id: errorTimer
interval: 1200
repeat: false
onTriggered: root.errorFlash = false
}
SequentialAnimation {
id: shakeAnimation
NumberAnimation { target: root; property: "shakeOffset"; to: -8; duration: 35; easing.type: Easing.OutQuad }
NumberAnimation { target: root; property: "shakeOffset"; to: 8; duration: 50; easing.type: Easing.InOutQuad }
NumberAnimation { target: root; property: "shakeOffset"; to: 0; duration: 55; easing.type: Easing.OutQuad }
}
FileView {
path: "/etc/pam.d/polkit-1"
watchChanges: true
printErrors: false
onLoaded: root.loadPamConfig(text())
onLoadFailed: root.fingerprintConfigured = false
onFileChanged: reload()
}
Process {
id: laptopClosedProc
command: ["bash", "-c", "blob-hw-laptop-closed && echo closed || echo open"]
stdout: StdioCollector { id: laptopClosedOut; waitForEnd: true }
onExited: root.laptopClosed = String(laptopClosedOut.text || "").trim() === "closed"
}
PolkitAgent {
id: polkitAgent
path: "/org/blob/PolkitAgent"
onAuthenticationRequestStarted: root.beginFlow()
onIsActiveChanged: {
if (isActive) root.syncFromFlow()
else if (!root.closing) root.resetSnapshot()
}
onIsRegisteredChanged: {
if (isRegistered) console.log("blob polkit agent registered")
else console.warn("blob polkit agent is not registered; another agent may be running")
}
}
Connections {
target: polkitAgent.flow
function onIsResponseRequiredChanged() {
root.syncFromFlow()
if (!polkitAgent.flow || !polkitAgent.flow.isResponseRequired) passwordInput.text = ""
Qt.callLater(root.refocus)
}
function onInputPromptChanged() { root.syncFromFlow() }
function onResponseVisibleChanged() { root.syncFromFlow() }
function onSupplementaryMessageChanged() { root.syncFromFlow() }
function onFailedChanged() { root.syncFromFlow() }
function onAuthenticationFailed() {
root.syncFromFlow()
root.triggerFailureFeedback()
}
function onAuthenticationSucceeded() {
root.closing = true
closeTimer.restart()
}
function onAuthenticationRequestCancelled() {
root.closing = true
closeTimer.restart()
}
}
PanelWindow {
id: panel
visible: root.dialogVisible
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-polkit"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.refocus()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.shakeOffset
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: root.refocus() }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelRequest()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.responseRequired) root.submitResponse()
event.accepted = true
}
}
}
// Fingerprint mode shows just the sensor icon, centered and alone \u2014 no
// padlock, no field, no prompt text.
OpticalGlyph {
anchors.centerIn: parent
width: Math.round(root.fieldHeight * 0.7)
height: width
visible: root.fingerprintMode
text: "\udb80\ude37"
fontFamily: root.fontFamily
fontSize: Math.round(root.fieldHeight * 0.7)
color: root.errorFlash ? Color.polkit.textError : root.accent
}
Row {
id: cardRow
visible: !root.fingerprintMode
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: Style.space(14)
Text {
text: "\uf023"
color: root.errorFlash ? Color.polkit.textError : root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
width: Style.space(26)
height: root.fieldHeight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Item {
width: parent.width - Style.space(40)
height: root.fieldHeight
TextInput {
id: passwordInput
anchors.fill: parent
verticalAlignment: TextInput.AlignVCenter
activeFocusOnPress: true
clip: true
selectionColor: Util.alpha(root.accent, 0.45)
selectedTextColor: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
echoMode: root.responseVisible ? TextInput.Normal : TextInput.Password
passwordCharacter: "\u2022"
color: root.errorFlash ? Color.polkit.textError : root.foreground
cursorVisible: activeFocus && !root.submitted && !root.errorFlash
readOnly: root.submitted || root.errorFlash
enabled: root.dialogVisible
onAccepted: root.submitResponse()
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.cancelRequest()
event.accepted = true
}
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.errorFlash ? "Wrong" : (root.submitted ? "Checking..." : "Enter password")
color: root.errorFlash ? Color.polkit.textError : root.foreground
opacity: root.errorFlash ? 1 : 0.36
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
elide: Text.ElideRight
visible: passwordInput.text.length === 0
}
Rectangle {
width: Math.max(1, Style.space(2))
height: Style.space(24)
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: root.errorFlash ? Color.polkit.textError : root.foreground
visible: passwordInput.visible && passwordInput.activeFocus && passwordInput.text.length === 0 && !root.submitted && !root.errorFlash
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
enabled: passwordInput.visible
onClicked: passwordInput.forceActiveFocus()
}
}
}
}
Rectangle {
width: Math.min(justificationText.implicitWidth + Style.space(24), panel.width - Style.gapsOut * 2)
height: Style.space(28)
anchors.horizontalCenter: card.horizontalCenter
anchors.bottom: card.top
anchors.bottomMargin: Style.space(10)
radius: root.cornerRadius
color: root.background
Text {
id: justificationText
textFormat: Text.PlainText
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
text: root.authorizationLabel(root.currentMessage)
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideMiddle
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
function promptLooksFingerprint(text) {
var s = String(text || "").toLowerCase()
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
}
function fingerprintConfiguredFromPamConfig(raw) {
// Fingerprint is available whenever pam_fprintd appears anywhere in the auth
// stack — it need not be the first module. A clamshell gate (pam_exec) may
// legitimately precede it to skip fingerprint while the lid is closed.
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
if (!line.match(/^auth\s+/)) continue
if (line.indexOf("pam_fprintd.so") !== -1) return true
}
return false
}
function authorizationLabel(message) {
var text = String(message || "")
var match = text.match(/^Authentication is (?:needed|required) to run [`']([^`']+)[`'] as /i)
return match ? "Authorize running '" + match[1] + "'" : text
}
if (typeof module !== "undefined") {
module.exports = {
promptLooksFingerprint: promptLooksFingerprint,
fingerprintConfiguredFromPamConfig: fingerprintConfiguredFromPamConfig,
authorizationLabel: authorizationLabel
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"id": "blob.polkit",
"name": "Polkit Agent",
"version": "1.0.0",
"author": "Blob",
"description": "Theme-aware authentication dialog for privileged actions.",
"blob": {
"capabilities": [
"authentication"
]
},
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "PolkitAgent.qml"
}
}
+173
View File
@@ -0,0 +1,173 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "ReminderFlowModel.js" as ReminderFlowModel
Item {
id: root
property string blobPath: Quickshell.env("BLOB_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string step: "minutes"
property string minutes: ""
property string filterText: ""
property string fontFamily: Style.font.menuFamily
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
readonly property int cornerRadius: Style.cornerRadius
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int cardWidth: Math.min(Style.space(300), panel.width - Style.gapsOut * 2)
property int cardHeight: Math.min(contentMargin * 2 + headerHeight, panel.height - Style.gapsOut * 2)
readonly property string promptText: root.step === "message" ? "Reminder message" : "Remind in minutes"
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
if (payload.fontFamily) root.fontFamily = payload.fontFamily
root.opened = true
root.step = "minutes"
root.minutes = ""
root.filterText = ""
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "blob.reminders")
}
function toggle() {
if (root.opened) root.dismiss()
else root.open("{}")
}
function setFilter(nextFilter) {
root.filterText = nextFilter
}
function submit() {
var selection = root.filterText
if (root.step === "minutes") {
var nextMinutes = ReminderFlowModel.validMinutes(selection)
if (!selection.trim()) {
root.dismiss()
return
}
if (!nextMinutes) {
Quickshell.execDetached([root.blobPath + "/bin/blob-notify-send", "Invalid reminder", "Enter the number of minutes"])
return
}
root.minutes = nextMinutes
root.step = "message"
root.filterText = ""
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
return
}
if (root.step === "message") {
var args = [root.blobPath + "/bin/blob-reminder"].concat(ReminderFlowModel.reminderArgs(root.minutes, selection))
root.dismiss()
Quickshell.execDetached(args)
}
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-reminders"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
BorderSurface {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.submit()
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Item {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || (root.promptText + "...")
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
}
}
}
@@ -0,0 +1,21 @@
function validMinutes(value) {
var minutes = String(value || "").trim()
return /^[0-9]+$/.test(minutes) && Number(minutes) > 0 ? minutes : ""
}
function reminderArgs(minutes, message) {
var valid = validMinutes(minutes)
if (!valid) return []
var args = [valid]
var text = String(message || "")
if (text.length > 0) args.push(text)
return args
}
if (typeof module !== "undefined") {
module.exports = {
validMinutes: validMinutes,
reminderArgs: reminderArgs
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.reminders",
"name": "Reminders",
"version": "1.0.0",
"author": "Blob",
"description": "Interactive reminder setup flow",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "ReminderFlow.qml"
}
}
@@ -0,0 +1,28 @@
function batteryPercentage(device) {
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
}
function isDischarging(device, onBattery, dischargingState) {
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
}
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
var level = batteryPercentage(device)
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
return {
level: level,
notify: low && !alreadyNotified,
notifiedLowBattery: low
}
}
if (typeof module !== "undefined") {
module.exports = {
batteryPercentage: batteryPercentage,
isDischarging: isDischarging,
shouldWarnLowBattery: shouldWarnLowBattery
}
}
@@ -0,0 +1,78 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "BatteryModel.js" as BatteryModel
Item {
id: root
property var shell: null
property string blobPath: Quickshell.env("BLOB_PATH")
readonly property int batteryThreshold: 10
property string pendingPowerSource: ""
PersistentProperties {
id: persisted
reloadableId: "blob-battery"
property bool notifiedLowBattery: false
}
function batteryPercentage() {
return BatteryModel.batteryPercentage(UPower.displayDevice)
}
function isDischarging() {
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
}
function checkBattery() {
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
persisted.notifiedLowBattery = state.notifiedLowBattery
if (state.notify) sendLowBatteryWarning(state.level)
}
function sendLowBatteryWarning(level) {
if (warningProcess.running) return
warningProcess.command = [
"blob-battery-low",
String(level)
]
warningProcess.running = true
}
function applyPowerProfile() {
pendingPowerSource = UPower.onBattery ? "battery" : "ac"
if (!powerProfileProcess.running) runPendingPowerProfile()
}
function runPendingPowerProfile() {
powerProfileProcess.command = ["blob-power-set", pendingPowerSource]
pendingPowerSource = ""
powerProfileProcess.running = true
}
Process { id: warningProcess }
Process {
id: powerProfileProcess
onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile()
}
Timer {
interval: 30000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.checkBattery()
}
Connections {
target: UPower
function onOnBatteryChanged() {
root.checkBattery()
root.applyPowerProfile()
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.battery",
"name": "Battery",
"version": "1.0.0",
"author": "Blob",
"description": "Low battery warning service",
"kinds": [
"service"
],
"entryPoints": {
"service": "Service.qml"
}
}
+52
View File
@@ -0,0 +1,52 @@
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {
}
return String(event && event.data ? event.data : "").split(",")
}
function screensaverWindowsAfter(windows, address, visible) {
var key = String(address || "")
if (!key) {
var current = windows || {}
var existingCount = 0
for (var currentKey in current) {
if (current[currentKey]) existingCount++
}
return { windows: current, count: existingCount }
}
var next = {}
var count = 0
for (var existing in windows || {}) {
if (existing !== key && windows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
return {
windows: next,
count: count
}
}
if (typeof module !== "undefined") {
module.exports = {
secondsFromConfig: secondsFromConfig,
eventParts: eventParts,
screensaverWindowsAfter: screensaverWindowsAfter
}
}
+361
View File
@@ -0,0 +1,361 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import "IdleModel.js" as IdleModel
Item {
id: root
// Injected by blob-shell (the first-party service loader).
property var shell: null
readonly property string home: Quickshell.env("HOME")
readonly property string stayAwakeStateDir: home + "/.local/state/blob/indicators"
readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake"
readonly property int defaultScreensaverSeconds: 150
readonly property int defaultLockSeconds: 300
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds)
readonly property int screensaverDelaySeconds: Math.max(0, screensaverTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property int lockDelaySeconds: Math.max(0, lockTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property bool idleEnabled: stayAwakeStateLoaded && !stayAwake
readonly property string screensaverClass: "org.blob.screensaver"
property bool stayAwake: false
property bool stayAwakeStateLoaded: false
property bool hasPendingStayAwakePersist: false
property bool pendingStayAwakePersist: false
property bool idledThisCycle: false
property bool screensaverStartedThisCycle: false
property string lastEvent: "starting"
property string lastEventAt: ""
property var screensaverWindows: ({})
property int screensaverWindowCount: 0
function secondsFromConfig(value, fallback) {
return IdleModel.secondsFromConfig(value, fallback)
}
function nowIso() {
return new Date().toISOString()
}
function logEvent(event, details) {
var suffix = details === undefined || details === null || details === "" ? "" : ": " + String(details)
root.lastEventAt = nowIso()
root.lastEvent = event + suffix
console.log("blob idle " + root.lastEventAt + " " + root.lastEvent)
}
function runProcess(process, label, command) {
if (process.running) {
logEvent("process-skip", label + " already running")
return false
}
logEvent("process-start", label + " " + command)
process.command = ["bash", "-lc", command]
process.running = true
return true
}
function launchScreensaver() {
root.screensaverStartedThisCycle = true
screensaverLaunchGraceTimer.restart()
runProcess(screensaverProcess, "screensaver", "[[ $(blob-shell lock isLocked 2>/dev/null) == \"true\" ]] || blob-launch-screensaver")
}
function lockSystem(reason) {
logEvent("lock-system", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
runProcess(lockProcess, "lock", "blob-system-lock")
}
function startIdleCycle() {
if (root.idledThisCycle) {
logEvent("idle-cycle-already-running")
return
}
logEvent("idle-cycle-start", "screensaver=" + root.screensaverTimeoutSeconds + " lock=" + root.lockTimeoutSeconds)
root.idledThisCycle = true
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
if (root.screensaverDelaySeconds === 0) launchScreensaver()
else screensaverTimer.restart()
if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate")
else lockTimer.restart()
}
function cancelIdleCycle(reason) {
logEvent("idle-cycle-cancel", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
if (root.idledThisCycle) runProcess(wakeProcess, "wake", "blob-system-wake")
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
}
function resetScreensaverWindows() {
root.screensaverWindows = ({})
root.screensaverWindowCount = 0
}
function setScreensaverWindow(address, visible) {
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
root.screensaverWindows = next.windows
root.screensaverWindowCount = next.count
}
function handleScreensaverWindowOpened(address) {
setScreensaverWindow(address, true)
screensaverLaunchGraceTimer.stop()
}
function handleScreensaverWindowClosed(address) {
setScreensaverWindow(address, false)
if (!root.idleEnabled || !root.idledThisCycle || !root.screensaverStartedThisCycle) return
if (root.screensaverWindowCount > 0) return
// The user dismissed the screensaver before the lock deadline. Treat that
// as activity and cancel the pending lock; the lock timer is only allowed
// to fire while the screensaver remains up.
root.cancelIdleCycle("screensaver-dismissed")
}
function eventParts(event, count) {
return IdleModel.eventParts(event, count)
}
function handleHyprlandEvent(event) {
var name = String(event && event.name ? event.name : "")
if (name === "openwindow") {
var open = eventParts(event, 4)
if (String(open[2] || "") === root.screensaverClass) root.handleScreensaverWindowOpened(open[0])
} else if (name === "closewindow") {
var close = eventParts(event, 1)
var address = String(close[0] || "")
if (root.screensaverWindows[address]) root.handleScreensaverWindowClosed(address)
}
}
function handleActiveSignal() {
if (!root.idledThisCycle) return
// Starting the screensaver can make the compositor report activity. Keep
// the lock timer running once the screensaver exists (or during its short
// launch grace); Hyprland window events cancel the cycle if it exits before
// the normal lock deadline.
if (root.screensaverStartedThisCycle && (root.screensaverWindowCount > 0 || screensaverLaunchGraceTimer.running)) {
logEvent("idle-monitor-active", "screensaver cycle remains armed")
return
}
cancelIdleCycle("activity")
}
function handleIdleChanged() {
logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active")
if (!root.idleEnabled) return
if (idleMonitor.isIdle) startIdleCycle()
else handleActiveSignal()
}
function statusJson() {
return JSON.stringify({
enabled: root.idleEnabled,
stayAwake: root.stayAwake,
stayAwakeStateLoaded: root.stayAwakeStateLoaded,
stayAwakeStatePath: root.stayAwakeStatePath,
idle: idleMonitor.isIdle,
inIdleCycle: root.idledThisCycle,
screensaverStarted: root.screensaverStartedThisCycle,
screensaver: root.screensaverTimeoutSeconds,
lock: root.lockTimeoutSeconds,
screensaverDelay: root.screensaverDelaySeconds,
lockDelay: root.lockDelaySeconds,
screensaverWindows: root.screensaverWindowCount,
timers: {
screensaver: screensaverTimer.running,
lock: lockTimer.running,
screensaverLaunchGrace: screensaverLaunchGraceTimer.running
},
processes: {
screensaver: screensaverProcess.running,
lock: lockProcess.running,
wake: wakeProcess.running
},
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function persistStayAwake(value) {
var command = value
? "mkdir -p \"$HOME/.local/state/blob/indicators\" && touch \"$HOME/.local/state/blob/indicators/stay-awake\""
: "rm -f \"$HOME/.local/state/blob/indicators/stay-awake\""
if (stayAwakeStateWriter.running) {
root.pendingStayAwakePersist = !!value
root.hasPendingStayAwakePersist = true
return
}
stayAwakeStateWriter.command = ["bash", "-lc", command]
stayAwakeStateWriter.running = true
}
function refreshStayAwakeState() {
if (!stayAwakeStateProbe.running) stayAwakeStateProbe.running = true
}
function applyStayAwake(value, persist, reason) {
var enabled = !!value
var changed = !root.stayAwakeStateLoaded || root.stayAwake !== enabled
if (persist) persistStayAwake(enabled)
root.stayAwake = enabled
root.stayAwakeStateLoaded = true
if (!changed) return enabled ? "disabled" : "enabled"
logEvent("stay-awake", (enabled ? "enabled" : "disabled") + (reason ? " " + reason : ""))
if (enabled) cancelIdleCycle("stay-awake")
else Qt.callLater(root.handleIdleChanged)
return enabled ? "disabled" : "enabled"
}
function setIdleEnabled(value) {
return applyStayAwake(!value, true, "ipc")
}
IdleMonitor {
id: idleMonitor
enabled: root.idleEnabled
timeout: root.firstIdleTimeoutSeconds
respectInhibitors: true
onIsIdleChanged: root.handleIdleChanged()
}
Timer {
id: screensaverTimer
interval: root.screensaverDelaySeconds * 1000
repeat: false
onTriggered: root.launchScreensaver()
}
Timer {
id: lockTimer
interval: root.lockDelaySeconds * 1000
repeat: false
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout")
}
Timer {
id: screensaverLaunchGraceTimer
interval: 3000
repeat: false
onTriggered: {
if (root.idleEnabled && root.idledThisCycle && root.screensaverStartedThisCycle && root.screensaverWindowCount === 0 && !idleMonitor.isIdle) {
root.cancelIdleCycle("screensaver-not-running")
}
}
}
Connections {
target: Hyprland
function onRawEvent(event) { root.handleHyprlandEvent(event) }
}
Process {
id: screensaverProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "screensaver exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: lockProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "lock exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: wakeProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "wake exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: stayAwakeStateProbe
command: ["bash", "-c", "mkdir -p \"$HOME/.local/state/blob/indicators\"; if [[ -f $HOME/.local/state/blob/indicators/stay-awake ]]; then echo yes; else echo no; fi"]
stdout: SplitParser {
onRead: function(line) { root.applyStayAwake(String(line).trim() === "yes", false, "state-file") }
}
onExited: function() { stayAwakeStateDirWatcher.reload() }
}
Process {
id: stayAwakeStateWriter
onExited: function() {
if (root.hasPendingStayAwakePersist) {
var pending = root.pendingStayAwakePersist
root.hasPendingStayAwakePersist = false
root.persistStayAwake(pending)
return
}
root.refreshStayAwakeState()
}
}
FileView {
id: stayAwakeStateDirWatcher
path: root.stayAwakeStateDir
watchChanges: true
printErrors: false
onFileChanged: root.refreshStayAwakeState()
}
Component.onCompleted: {
logEvent("service-ready")
refreshStayAwakeState()
}
IpcHandler {
target: "idle"
function status(): string {
return root.statusJson()
}
function debug(): string {
return root.statusJson()
}
function enable(): string {
return root.setIdleEnabled(true)
}
function disable(): string {
return root.setIdleEnabled(false)
}
function toggle(): string {
return root.setIdleEnabled(!root.idleEnabled)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.idle",
"name": "Idle",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell-based idle detection for screensaver, lock, and display wake.",
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}

Some files were not shown because too many files have changed in this diff Show More