Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 278 additions & 0 deletions battery-wireless-devices/BarWidget.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Widgets
import qs.Services.UI

// One pill per enabled device: a device icon inside a circular battery ring.
// Live battery data comes from Main.qml via pluginApi.mainInstance.devices;
// per-device appearance/behaviour comes from pluginApi.pluginSettings.devices.
Item {
id: root

// Plugin API (injected by PluginService).
property var pluginApi: null

// Required properties for bar widgets.
property ShellScreen screen
property string widgetId: ""
property string section: ""
property int sectionWidgetIndex: -1
property int sectionWidgetsCount: 0

readonly property string screenName: screen ? screen.name : ""
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
// Pills are sized to the capsule height (smaller than the full bar height)
// so the bar centers them, matching the core widgets.
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)

// Live data from Main.qml.
readonly property var liveDevices: (pluginApi && pluginApi.mainInstance) ? (pluginApi.mainInstance.devices || []) : []

// Devices the user has enabled, merged with their live battery readings.
readonly property var shownDevices: {
var settings = pluginApi ? pluginApi.pluginSettings : null // dependency for re-eval
var out = []
for (var i = 0; i < liveDevices.length; i++) {
var d = liveDevices[i]
var cfg = cfgFor(d.id)
if (cfg && cfg.enabled === true)
out.push(d)
}
// Order to match the settings list (top = left-most / top-most).
var order = (settings && settings.deviceOrder) ? settings.deviceOrder : []
out.sort(function (a, b) {
var ia = order.indexOf(a.id)
var ib = order.indexOf(b.id)
return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib)
})
return out
}

readonly property bool showPercentage: pluginApi && pluginApi.pluginSettings
&& pluginApi.pluginSettings.showPercentage === true

readonly property bool showCharging: pluginApi && pluginApi.pluginSettings
&& pluginApi.pluginSettings.showCharging === true

readonly property real barFontSize: Style.fontSizeXS

implicitWidth: layout.implicitWidth
implicitHeight: layout.implicitHeight
visible: shownDevices.length > 0

// ---- Helpers -----------------------------------------------------------

function cfgFor(id) {
var all = (pluginApi && pluginApi.pluginSettings) ? pluginApi.pluginSettings.devices : null
return (all && all[id]) ? all[id] : null
}

function defaultIconForType(type) {
switch (type) {
case "mouse": return "mouse"
case "keyboard": return "keyboard"
case "headset": return "headphones"
case "gamepad": return "device-gamepad"
default: return "battery"
}
}

// Theme color-role keys ("none"/"primary"/"secondary"/"tertiary"/"error"),
// resolved with the same helper the core widgets use.
function ringColorFor(device, cfg) {
var key = cfg ? cfg.ringColor : ""
if (key && key !== "" && key !== "none")
return Color.resolveColorKey(key)
// Auto: colour by charge state / level.
if (device.charging)
return Color.mTertiary
var b = (typeof device.battery === "number") ? device.battery : 100
if (b <= 15) return Color.mError
if (b <= 35) return Color.mSecondary
return Color.mPrimary
}

function launch(cfg) {
if (cfg && cfg.launchCmd && cfg.launchCmd.trim() !== "")
Quickshell.execDetached(["sh", "-lc", cfg.launchCmd])
}

// ---- Right-click menu (shared) -----------------------------------------

NPopupContextMenu {
id: contextMenu
model: [
{ "label": pluginApi?.tr("menu.settings"), "action": "settings", "icon": "settings" },
{ "label": pluginApi?.tr("menu.refresh"), "action": "refresh", "icon": "refresh" }
]
onTriggered: action => {
contextMenu.close()
PanelService.closeContextMenu(root.screen)
if (action === "settings")
BarService.openPluginSettings(root.screen, pluginApi.manifest)
else if (action === "refresh" && pluginApi && pluginApi.mainInstance)
pluginApi.mainInstance.refresh()
}
}

// ---- Layout ------------------------------------------------------------

GridLayout {
id: layout
anchors.centerIn: parent
columns: root.isBarVertical ? 1 : Math.max(1, shownDevices.length)
rowSpacing: Style.marginXS
columnSpacing: Style.marginXS

Repeater {
model: root.shownDevices

delegate: Rectangle {
id: pill

required property var modelData
readonly property var cfg: root.cfgFor(modelData.id)
readonly property int battery: (typeof modelData.battery === "number") ? modelData.battery : -1
readonly property color ringColor: root.ringColorFor(modelData, cfg)
readonly property color iconColor: Color.resolveColorKey((cfg && cfg.iconColor) ? cfg.iconColor : "none")
readonly property string iconName: (cfg && cfg.icon) ? cfg.icon : root.defaultIconForType(modelData.type)

readonly property string tooltipLabel: modelData.name
+ (pill.battery >= 0 ? " — " + pill.battery + "%" : "")
+ (modelData.charging ? " ⚡" : "")

implicitWidth: pillRow.implicitWidth + Style.marginM * 2
implicitHeight: root.capsuleHeight
radius: Style.radiusM
color: pillMouse.containsMouse ? Color.mHover : Style.capsuleColor

Behavior on color {
enabled: !Color.isTransitioning
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}

RowLayout {
id: pillRow
anchors.centerIn: parent
spacing: Style.marginXS

// Circular battery ring with the device icon in the centre.
Item {
id: ring
readonly property real size: Math.round(root.capsuleHeight * 0.82)
implicitWidth: size
implicitHeight: size
Layout.alignment: Qt.AlignVCenter

Canvas {
id: ringCanvas
anchors.fill: parent
renderStrategy: Canvas.Cooperative
renderTarget: Canvas.FramebufferObject

readonly property real ratio: pill.battery >= 0 ? Math.max(0, Math.min(1, pill.battery / 100)) : 0
// Repaint when any visual input changes.
onRatioChanged: requestPaint()
property color trackColor: Qt.alpha(Color.mOnSurface, 0.22)
property color valueColor: pill.ringColor
onValueColorChanged: requestPaint()
onTrackColorChanged: requestPaint()

onPaint: {
var ctx = getContext("2d")
ctx.reset()
var lw = Math.max(2, Math.round(width * 0.11))
var cx = width / 2
var cy = height / 2
var r = (width - lw) / 2
var start = -Math.PI / 2 // 12 o'clock

ctx.lineWidth = lw
ctx.lineCap = "round"

// Track
ctx.strokeStyle = trackColor
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.stroke()

// Value arc
if (ratio > 0.001) {
ctx.strokeStyle = valueColor
ctx.beginPath()
ctx.arc(cx, cy, r, start, start + ratio * 2 * Math.PI)
ctx.stroke()
}
}
}

NIcon {
anchors.centerIn: parent
icon: pill.iconName
color: pill.iconColor
applyUiScale: false // ring.size already accounts for UI scale
pointSize: Math.round(ring.size * 0.5)
}
}

NIcon {
visible: root.showCharging && modelData.charging
icon: "bolt"
color: Color.mTertiary
applyUiScale: false
pointSize: Math.round(root.capsuleHeight * 0.4)
Layout.alignment: Qt.AlignVCenter
}

NText {
visible: root.showPercentage && pill.battery >= 0
text: pill.battery + "%"
color: Color.mOnSurface
pointSize: root.barFontSize
Layout.alignment: Qt.AlignVCenter
}
}

MouseArea {
id: pillMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
TooltipService.hide()
if (mouse.button === Qt.RightButton)
PanelService.showContextMenu(contextMenu, pill, root.screen)
else
root.launch(pill.cfg)
}
onEntered: {
TooltipService.show(pill, pill.tooltipLabel, BarService.getTooltipDirection(root.screenName))
tooltipRefreshTimer.start()
}
onExited: {
tooltipRefreshTimer.stop()
TooltipService.hide()
}

// Keep the tooltip percentage current while hovering.
Timer {
id: tooltipRefreshTimer
interval: 1000
repeat: true
onTriggered: {
if (pillMouse.containsMouse)
TooltipService.updateText(pill.tooltipLabel)
}
}
}
}
}
}
}
69 changes: 69 additions & 0 deletions battery-wireless-devices/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A plugin for **Noctalia** (a Quickshell-based desktop shell, `noctalia-shell`). It is pure QML loaded at runtime by the running shell — there is **no build step, no compiler, and no test framework** in this repo. The plugin's purpose is a bar widget that shows battery indicators for connected peripherals (e.g. mouse, keyboard, headphones). Device data does **not** come from any Noctalia service — it is gathered by `scripts/scan.py` (see below).

Requires `minNoctaliaVersion: 4.6.6`.

## This implementation's architecture

The plugin is split across three entry points plus one helper script:

- **`scripts/scan.py`** — the data layer and the only place that knows how to talk to devices. It probes every supported source and prints a **normalized JSON array** on stdout: `[{ id, name, type, battery, charging, source }]`. `id` is `"<source>:<stable-key>"`. Each source is a `scan_<source>()` function that swallows its own errors (missing tool → `[]`). Current sources: `scan_openrazer()` (via the `openrazer` Python client / running `openrazer-daemon`) and `scan_solaar()` (parses `solaar show`). **To add a new device source, add a `scan_*()` and append it to `SOURCES` — nothing in the QML changes.**
- **`Main.qml`** (`main` entry point) — runs `scan.py` on a `Timer` (interval = `pluginSettings.refreshInterval`, default 60s), parses the JSON, and exposes the live list as `property var devices`. Also exposes `refresh()` and an `IpcHandler` (`plugin:battery-wireless-devices` → `refresh`).
- **`BarWidget.qml`** — reads live data from `pluginApi.mainInstance.devices` and per-device config from `pluginApi.pluginSettings.devices`. Renders one pill per device the user has **enabled**, each a device icon inside a circular battery ring (a `Canvas`). Ring colour is auto-by-level unless overridden; click runs the device's `launchCmd` via `Quickshell.execDetached`.
- **`Settings.qml`** (`settings` entry point) — lists detected + previously-configured devices (drag the handle to reorder; top = left-most / top-most in the bar) and lets the user set per device: `enabled`, `icon`, `iconColor`, `ringColor`, `launchCmd`. Settings shape: `pluginSettings.devices` is a map keyed by device `id`, `pluginSettings.deviceOrder` is an ordered array of ids; globals are `refreshInterval` and `showPercentage`. Edits apply **live** (debounced `commit()` rebuilds `devices` with fresh object identities so the bar's bindings re-fire); the popup's Apply button is redundant. `populateList()` also auto-heals stale duplicate entries (same source + name, different id).

Data flow: `scan.py` → `Main.qml.devices` → (`pluginApi.mainInstance`) → `BarWidget`. Config flow: `Settings.qml` → `pluginSettings.devices` → `BarWidget`.

## Layout & how a plugin is structured

- `manifest.json` — the contract. `id` must be unique; `entryPoints` maps roles to QML files. Each role is optional; this plugin currently only defines `barWidget`. Other available roles (see the `arch-updater` reference plugin): `main` (background singleton/logic, instantiated once), `panel`, `settings`, `controlCenterWidget`, `desktopWidget`, `launcherProvider`.
- `metadata.defaultSettings` in the manifest defines the plugin's persisted settings schema and defaults. User overrides are merged on top at load.
- Entry-point `.qml` files live at the repo root. `i18n/<lang>.json` files provide translations (`en.json` is the fallback); this plugin ships `i18n/en.json` and routes every user-facing string through `pluginApi.tr("dot.path.key")` — no inline literals or post-`tr()` fallbacks (a Noctalia plugin convention; see the repo-root `AGENTS.md`).

## Installation / runtime model

The plugin runs from `~/.config/noctalia/plugins/<id>/`. During development this is a **symlink** to this repo, so edits here are live. Enablement and source are tracked in `~/.config/noctalia/plugins.json` (`states.<id>.enabled`).

The shell itself runs as `qs -c noctalia-shell`. The Noctalia source (read-only, useful as API reference) is installed at `/etc/xdg/quickshell/noctalia-shell/` — `Commons/`, `Widgets/`, and `Services/` there define everything importable below.

## Imports available to plugin QML

```qml
import qs.Commons // singletons: Style, Color, Settings, I18n, Logger, Icons, Time, ShellState
import qs.Widgets // N-prefixed components: NIcon, NText, NButton, NComboBox, NColorChoice, NToggle, ...
import qs.Services.UI // TooltipService, PanelService, BarService (used by BarWidget.qml)
```

Service singletons live in subfolders under `Services/` (UI, Networking, Control, Noctalia, SystemInfo, ...). This plugin only uses `qs.Services.UI`; it does **not** read battery data from any service (that comes from `scan.py`). Grep the shell source to find the right import path for any other service.

## Key conventions (follow these — don't reinvent)

- **Never hardcode sizes/colors.** Use `Style.*` (`marginS/M`, `barHeight`, `capsuleColor`, `radiusM`, `fontSizeS/M/L`) and `Color.*` (`mPrimary`, `mOnSurface`, `mSurface`, ...) so the widget respects the user's theme and UI scale. `NIcon`/`NText` already apply UI scaling.
- **Icons** are Tabler icon names passed to `NIcon { icon: "..." }` (e.g. `"heart"`); the full set is in `Commons/IconsTabler.qml`.
- **Bar widgets** must declare the properties the loader injects: `screen` (ShellScreen), `widgetId`, `section`, `sectionWidgetIndex`, `sectionWidgetsCount`, plus `property var pluginApi`.

## The `pluginApi` object

`PluginService` injects a `pluginApi` into every entry point. It exposes:
- `pluginId`, `pluginDir`, `manifest`
- `pluginSettings` — merged defaults + user settings. Mutate it, then call `saveSettings()` to persist (the call also replaces the object so QML bindings re-fire).
- Panel control: `openPanel(screen, buttonItem)`, `closePanel(screen)`, `togglePanel(...)`, and launcher equivalents.
- i18n: `tr(key, interpolations)`, `trp(key, count, interpolations)`, `hasTranslation(key)`; depend on `translationVersion` to react to language changes.
- Instance references once loaded: `mainInstance`, `barWidget`, `panel`, etc.

## Where device battery data comes from

**`scripts/scan.py` only** — not `BluetoothService`, `BatteryService`, or any other Noctalia service. The script probes each supported source (`scan_openrazer()` via the openrazer Python client, `scan_solaar()` by parsing `solaar show`) and prints the normalized JSON array consumed by `Main.qml`. The devices here (Razer dongle, Logitech receiver) aren't Bluetooth, so the shell's Bluetooth/UPower services don't see them — that's the whole reason the plugin shells out to vendor tools.

## Developing & verifying changes

There are no unit tests. To see changes: enable Noctalia **debug mode** (`Settings.isDebug`, or launch with `NOCTALIA_DEBUG=1`) — `PluginService` then sets up file watchers and **hot-reloads** the plugin on save. Watch the shell's stdout / `Logger` output (run `qs -c noctalia-shell` from a terminal) for QML errors and plugin load failures, which `PluginService` records in `pluginErrors`.

**Manifest changes require a full shell restart, not hot reload.** `PluginRegistry.getPluginManifest()` returns an in-memory cache (`installedPlugins`) populated by a disk scan at startup. Hot reload re-instantiates QML against that *cached* manifest, so adding/removing `entryPoints` (or changing `metadata.defaultSettings`) is invisible until the shell restarts. Symptom: a newly-added `main` entry point's `IpcHandler` returns "Target not found". Editing existing `.qml` files hot-reloads fine.

Smoke test that `Main.qml` loaded: `qs -c noctalia-shell ipc call plugin:battery-wireless-devices refresh` (returns a success string). Verify `scan.py` independently with `python3 scripts/scan.py`. The bar widget renders nothing until the user adds the widget to a bar section **and** enables at least one device in the plugin settings.
Loading