Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ~10–20 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
+ "¤t=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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user