Add pulsar-mouse plugin (#318)
* Add pulsar-mouse plugin Battery/charging status and quick sensor/lighting/power controls for a Pulsar gaming mouse (bar widget, desktop/lock-screen widget, and a quick-controls panel), backed by the pulsar-mouse CLI from https://github.com/harveywuk/pulsar-mouse-linux. * pulsar-mouse: sync to v1.6.1 (three panel fixes) The plugin picked up four commits upstream after this PR's initial snapshot was taken. Three of them are bug fixes, two user-facing enough that installing the current snapshot gives a visibly broken panel: - Panel went permanently read-only after a successful profile switch. Every control is `enabled = not busy`, and the profile-switch path set busy = true then relied on readStatus() to clear it, which it did not do on any path. No error was shown, because from the user's point of view nothing had failed. - readStatus() did not guard decoded.dpi being absent, so a malformed response threw before clearing busy - the same lockup by another route. - The panel only re-synced to the mouse's real active profile on the first open of the process, because the profileSynced flag is module- scoped and survived the panel closing. Switching profiles by any other means afterwards (physical profile button, `pulsar-mouse --active-profile`, Fusion on another OS) left every later reopen showing, and writing to, a stale profile. The fourth renames "breath" to "breathe" so the LED effect naming matches the pulsar-mouse CLI's --breathe-speed flag; that accounts for the two README wording changes and part of the panel diff. plugin.toml's version goes 1.6.0 -> 1.6.1 per the contribution rules (patch: fixes and text only, no new capability). Its header comment also now describes the tabbed panel that shipped earlier rather than the original DPI/polling-only one. bar.luau, desktop.luau, translations/en.json and thumbnail.webp are unchanged and untouched. No change to what the plugin shells out to, writes, or reads: still only `pulsar-mouse` (declared in dependencies), still only reading ~/.cache/pulsar-mouse/battery.json. No network access. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d93e7e0b05
commit
73ae491d97
@@ -0,0 +1,95 @@
|
||||
# Pulsar Mouse
|
||||
|
||||
Bar and desktop widgets showing battery percentage and charging status (plus signal strength in the bar widget's tooltip) for a Pulsar gaming mouse (via [pulsar-mouse-linux](https://github.com/harveywuk/pulsar-mouse-linux)), plus a quick-controls panel - with a profile switcher, on a mouse with more than one onboard profile - covering DPI stage, polling rate, debounce, angle snap, ripple control, motion sync, lift-off distance, LED (effect/brightness/speed), and - on wireless mice - power saving/low-battery threshold. Since desktop widgets are shared with Noctalia's lock screen, the desktop widget shows up in both places once added. Works with just the `pulsar-mouse` CLI installed - the GUI/tray isn't required, it's just more efficient if it's running (see Data source).
|
||||
|
||||
Battery/signal only apply to wireless mice - on a wired one, the bar widget shows a plain neutral glyph (still clickable, to reach the panel) and the desktop widget shows a plain "Wired" placeholder instead. The controls panel's Sensor, Advanced, and Lighting tabs work for any mouse; the Power tab (wireless power saving, low-battery threshold) only appears for a wireless one.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `harveywuk/pulsar-mouse` |
|
||||
| Entries | Bar widget: `bar`; panel: `controls`; desktop widget: `battery` |
|
||||
|
||||
## Requirements
|
||||
|
||||
- [pulsar-mouse-linux](https://github.com/harveywuk/pulsar-mouse-linux) installed, with `pulsar-mouse` on `PATH` - every entry in this plugin shells out to it directly (see Notes for the full network/filesystem/process disclosure)
|
||||
- `pulsar-mouse-gui` running is optional but recommended (autostart) - see Data source
|
||||
|
||||
## Usage
|
||||
|
||||
### Updating
|
||||
|
||||
`noctalia msg plugins update <source>` only re-syncs files from a path/git source - it does not re-register a plugin's entries (bar widget, panel, etc). If an update adds or changes an entry, do a full disable/enable cycle afterward to pick it up:
|
||||
|
||||
```sh
|
||||
noctalia msg plugins update pulsar-mouse # or whatever you named the source
|
||||
noctalia msg plugins disable harveywuk/pulsar-mouse
|
||||
noctalia msg plugins enable harveywuk/pulsar-mouse
|
||||
```
|
||||
|
||||
### Bar Widget
|
||||
|
||||
Add the `bar` bar widget to your bar. Shows a battery glyph, icon-only, with a tooltip for the full status (percentage, charging, signal strength when available) - the glyph is the only thing rendered regardless of bar orientation; the glyph turns a secondary accent color while charging, or red once battery drops to the mouse's configured Low Power Mode threshold (see Controls Panel; falls back to 15% on a driver that doesn't expose that threshold, e.g. nordic.py). On a wired mouse (no battery to show) it stays visible as a plain neutral glyph rather than hiding, since it's the only way to open the controls panel - DPI/lighting controls still work on a wired mouse, only the Power tab doesn't apply. If `pulsar-mouse-gui` isn't installed or running at all, this widget still works, it just always reads a fresh value directly from the mouse instead of the GUI's cached one (see Data source).
|
||||
|
||||
### Controls Panel
|
||||
|
||||
Click the bar widget to open the `controls` panel. A **Profile** dropdown at the top (hidden on a single-profile mouse) switches the mouse's active profile - picking one calls `--active-profile N` and refreshes every field below, since profile switches affect essentially everything shown, not just DPI/LED. Below that, tabs (Noctalia has no native tabs control - these are just segmented buttons swapping which section renders):
|
||||
|
||||
**Sensor**
|
||||
- **DPI** - a slider stepping through the active profile's configured DPI stages (not a raw DPI range - every position lands on an actual configured value, like a notch per stage)
|
||||
- **Polling Rate** - a slider the same way, stepping through the device's supported rates
|
||||
- **Lift-off Distance** - a slider, in mm (only shown for a driver with a continuous LOD range, like the Feinmann 8K's 0.7-2.0mm in 0.1mm steps - a driver with a fixed discrete LOD list instead isn't currently supported by this panel)
|
||||
|
||||
**Advanced**
|
||||
- **Debounce** - a slider, in ms
|
||||
- **Angle Snap**, **Ripple Control**, **Motion Sync** - toggles
|
||||
|
||||
**Lighting**
|
||||
- **LED Effect** - a dropdown (off/steady/breathe, or whatever the device supports)
|
||||
- **Brightness** - a 0-100% slider
|
||||
- **Speed** - a 0-100 slider for the LED effect's speed, shown only when the current effect actually has one (e.g. breathe, not steady)
|
||||
|
||||
**Power** (wireless mice only)
|
||||
- **Wireless Power Saving** - a slider, 30s-15min, shown as M:SS
|
||||
- **Low Power Mode** - a slider, 0-100% battery threshold
|
||||
|
||||
All of these write straight through the `pulsar-mouse` CLI immediately on release (sliders commit on drag-end, not live per pixel) - unlike the battery reading, none of this lives in the cached state file, so the panel always talks to the mouse directly.
|
||||
|
||||
You can also open the panel over IPC:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle harveywuk/pulsar-mouse:controls
|
||||
```
|
||||
|
||||
### Desktop Widget
|
||||
|
||||
Add the `battery` desktop widget from Noctalia's desktop-widget editor (it's then also available for the lock-screen widget editor).
|
||||
|
||||
### Data source
|
||||
|
||||
The `bar` and `battery` entries both read battery percentage/charging/low-power-threshold from the reading `pulsar-mouse-gui` already writes on its periodic poll (`~/.cache/pulsar-mouse/battery.json`) rather than polling the mouse themselves, so they don't double up on USB traffic. If that file is missing or older than 3 minutes - the GUI isn't installed, isn't running, or was just closed - each falls back to its own direct `pulsar-mouse --battery-json` call instead, so both still work standalone, just with their own USB round-trip each tick. Signal strength is bar-only (the desktop widget doesn't currently show it) and has no synchronous getter besides - it only ever arrives via an async event the GUI listens for, so it's only available through the cached file, never the CLI fallback. The low-power threshold, unlike signal, does have a synchronous getter, so it's available via both paths, for both entries.
|
||||
|
||||
## Settings
|
||||
|
||||
### Bar Widget
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `glyph_size` | `int` | `16` | Glyph size, 10-32. |
|
||||
|
||||
### Desktop Widget
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `color` | `color` | `primary` | Accent color for the percentage text and progress bar. |
|
||||
| `show_progress` | `bool` | `true` | Shows or hides the battery-level progress bar. |
|
||||
| `show_percent` | `bool` | `true` | Turn off to rely on just the glyph and progress bar. |
|
||||
| `glyph_size` | `int` | `28` | Glyph size, 16-64. |
|
||||
|
||||
## Notes
|
||||
|
||||
- **No network access.** Every entry talks only to the local `pulsar-mouse` CLI (a spawned process, never a daemon) and, for the bar/desktop widgets, reads a local cache file.
|
||||
- **Spawns `pulsar-mouse`** for every read and write - DPI/polling/LED/etc. changes in the controls panel, and the battery-json fallback read in the bar/desktop widgets when the cache is stale or missing.
|
||||
- **Writes nothing itself.** `~/.cache/pulsar-mouse/battery.json` is written by `pulsar-mouse-gui` (a separate program from this plugin, part of the same `pulsar-mouse-linux` project) on its own periodic poll - this plugin only ever reads that file, never writes it.
|
||||
- Requires Wayland/Hyprland (or another wlroots-based compositor) like the rest of Noctalia - no compositor-specific behavior beyond that in this plugin itself.
|
||||
@@ -0,0 +1,196 @@
|
||||
--!nonstrict
|
||||
-- Pulsar Mouse battery/charging status - [[widget]] (bar).
|
||||
--
|
||||
-- For anyone who only has the `pulsar-mouse` CLI installed, not the GUI/
|
||||
-- tray - see desktop.luau's header for the read strategy (same here): reads
|
||||
-- pulsar-mouse-gui's ~/.cache/pulsar-mouse/battery.json when it's fresh, or
|
||||
-- falls back to a direct `pulsar-mouse --battery-json` call otherwise. A
|
||||
-- CLI-only setup just always takes the fallback path, no special-casing
|
||||
-- needed - the file will never exist, so every tick reads live from the
|
||||
-- mouse instead.
|
||||
--
|
||||
-- Stays visible for a wired mouse (no battery to report - --battery-json
|
||||
-- returns {"wireless": false} for those rather than an error, a normal, not
|
||||
-- a failure, state) - shows a plain neutral glyph instead of vanishing, so
|
||||
-- it's still clickable to reach the controls panel (DPI/lighting work on any
|
||||
-- mouse, only the panel's Power tab is wireless-only - see panel.luau).
|
||||
--
|
||||
-- Icon-only (no percentage label): battery level and charging state are
|
||||
-- already in the hover tooltip, so a bar-row number would just be the same
|
||||
-- fact twice. Just the Tabler "mouse-filled" glyph, tinted red by
|
||||
-- statusColor() same as before, swapping to "mouse-off" for a genuine
|
||||
-- error/no-mouse state.
|
||||
--
|
||||
-- Signal quality has no synchronous getter (see gui.py's own comment on
|
||||
-- this) - it only arrives via an async hidraw event the GUI listens for, so
|
||||
-- it's only ever available via the cached state file (when the GUI wrote
|
||||
-- one after seeing an event, i.e. pulsar-mouse-gui/tray is running), never
|
||||
-- from the CLI fallback read - the tooltip's signal line just doesn't
|
||||
-- appear when only that path is available.
|
||||
--
|
||||
-- Click opens the "controls" panel (panel.luau) for quick DPI/polling rate
|
||||
-- changes - that one always talks to the CLI directly, since neither of
|
||||
-- those live in battery.json and writes can't be cached anyway.
|
||||
--
|
||||
-- barWidget.render(tree) declarative UI tree
|
||||
-- barWidget.isVertical() branch on bar orientation
|
||||
-- barWidget.setTooltip(text) hover detail
|
||||
-- barWidget.setVisible(bool) hover/click surface toggle - always kept true now
|
||||
-- noctalia.readFile(path) cheap path - reads the GUI's last reading
|
||||
-- noctalia.runAsync(cmd, cb) fallback path - direct CLI read
|
||||
-- noctalia.json.decode(str) both paths return JSON on stdout/in the file
|
||||
-- noctalia.togglePanel(id) open/close the controls panel on click
|
||||
|
||||
local STATE_PATH = "~/.cache/pulsar-mouse/battery.json"
|
||||
local STALE_AFTER = 180 -- seconds - 3x the GUI's own 60s poll interval
|
||||
local LOW_POWER_DEFAULT = 15 -- used when the driver has no low-power-threshold
|
||||
-- getter (e.g. nordic.py) or it hasn't been read yet
|
||||
|
||||
local wireless = true -- assume true until a read says otherwise, so the
|
||||
-- widget doesn't flash hidden then visible on load
|
||||
local percent = nil
|
||||
local charging = false
|
||||
local signalPercent = nil
|
||||
local lowPowerThreshold = nil
|
||||
local errorText = nil
|
||||
local checkingCli = false
|
||||
local isVertical = false
|
||||
local glyphSize = noctalia.getConfig("glyph_size")
|
||||
|
||||
local function statusGlyph(color)
|
||||
local name = (errorText ~= nil or percent == nil) and "mouse-off" or "mouse-filled"
|
||||
return ui.glyph({ name = name, size = glyphSize, color = color })
|
||||
end
|
||||
|
||||
local function statusColor()
|
||||
if errorText ~= nil then
|
||||
return "error"
|
||||
end
|
||||
if charging then
|
||||
return "secondary"
|
||||
end
|
||||
if percent ~= nil and percent <= (lowPowerThreshold or LOW_POWER_DEFAULT) then
|
||||
return "error"
|
||||
end
|
||||
return "on_surface"
|
||||
end
|
||||
|
||||
local function tooltipText()
|
||||
if errorText ~= nil then
|
||||
return errorText
|
||||
end
|
||||
if percent == nil then
|
||||
return noctalia.tr("ui.no-mouse")
|
||||
end
|
||||
local text = tostring(percent) .. "%"
|
||||
if charging then
|
||||
text = text .. " " .. noctalia.tr("ui.charging")
|
||||
end
|
||||
if signalPercent ~= nil then
|
||||
text = text .. " · " .. noctalia.tr("ui.signal-label") .. " " .. tostring(signalPercent) .. "%"
|
||||
end
|
||||
return text
|
||||
end
|
||||
|
||||
local function render()
|
||||
barWidget.setVisible(true)
|
||||
|
||||
local glyph
|
||||
if not wireless then
|
||||
-- No battery to show, but still clickable - a wired mouse still has
|
||||
-- DPI/lighting controls in the panel, just not the Power tab.
|
||||
glyph = ui.glyph({ name = "mouse-filled", size = glyphSize, color = "on_surface" })
|
||||
barWidget.setTooltip(noctalia.tr("ui.wired"))
|
||||
else
|
||||
glyph = statusGlyph(statusColor())
|
||||
barWidget.setTooltip(tooltipText())
|
||||
end
|
||||
|
||||
local container = isVertical and ui.column or ui.row
|
||||
barWidget.render(container({ align = "center" }, { glyph }))
|
||||
end
|
||||
|
||||
local function readViaCli()
|
||||
if checkingCli then
|
||||
return
|
||||
end
|
||||
if not noctalia.commandExists("pulsar-mouse") then
|
||||
errorText = noctalia.tr("ui.not-installed")
|
||||
render()
|
||||
return
|
||||
end
|
||||
checkingCli = true
|
||||
noctalia.runAsync("pulsar-mouse --battery-json", function(result)
|
||||
checkingCli = false
|
||||
if type(result) ~= "table" or result.timedOut or result.exitCode ~= 0 then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
local decoded = noctalia.json.decode(result.stdout or "")
|
||||
if type(decoded) ~= "table" then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.wireless == false then
|
||||
wireless = false
|
||||
errorText = nil
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.battery_percent == nil then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
-- Not available via this path - see header comment.
|
||||
signalPercent = nil
|
||||
-- Unlike signal, this has a synchronous getter, so it IS available here
|
||||
-- (nil on a driver without one, e.g. nordic.py - falls back to LOW_POWER_DEFAULT).
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
local function readState()
|
||||
local contents = noctalia.readFile(STATE_PATH)
|
||||
if type(contents) ~= "string" or contents == "" then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local decoded = noctalia.json.decode(contents)
|
||||
if type(decoded) ~= "table" or decoded.battery_percent == nil then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local age = os.time() - (decoded.updated_at or 0)
|
||||
if age > STALE_AFTER then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
signalPercent = decoded.signal_percent
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end
|
||||
|
||||
function update()
|
||||
isVertical = barWidget.isVertical()
|
||||
noctalia.setUpdateInterval(30000)
|
||||
readState()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("harveywuk/pulsar-mouse:controls")
|
||||
end
|
||||
@@ -0,0 +1,185 @@
|
||||
--!nonstrict
|
||||
-- Pulsar Mouse battery/charging status - [[desktop_widget]].
|
||||
--
|
||||
-- Data source: ~/.cache/pulsar-mouse/battery.json, written by
|
||||
-- pulsar-mouse-gui's tray/Home page poll (every 60s while it's running) -
|
||||
-- reading that costs nothing extra on the mouse's wireless link. If that
|
||||
-- file is missing or older than STALE_AFTER (the GUI/tray isn't running),
|
||||
-- falls back to a direct `pulsar-mouse --battery-json` read instead, so the
|
||||
-- widget still works standalone, just with its own USB round-trip.
|
||||
--
|
||||
-- For a wired mouse (no battery to report), --battery-json returns
|
||||
-- {"wireless": false} rather than an error - renders a minimal "Wired"
|
||||
-- placeholder instead of battery info. Unlike bar.luau, there's no
|
||||
-- desktopWidget.setVisible() to hide entirely, so this is the closest
|
||||
-- available equivalent.
|
||||
--
|
||||
-- noctalia.readFile(path) cheap path - reads the GUI's last reading
|
||||
-- noctalia.runAsync(cmd, cb) fallback path - direct CLI read
|
||||
-- noctalia.json.decode(str) both paths return JSON on stdout/in the file
|
||||
-- desktopWidget.render(tree) declarative UI tree
|
||||
|
||||
local STATE_PATH = "~/.cache/pulsar-mouse/battery.json"
|
||||
local STALE_AFTER = 180 -- seconds - 3x the GUI's own 60s poll interval
|
||||
local LOW_POWER_DEFAULT = 15 -- used when the driver has no low-power-threshold
|
||||
-- getter (e.g. nordic.py) or it hasn't been read yet
|
||||
|
||||
local color = noctalia.getConfig("color")
|
||||
local showProgress = noctalia.getConfig("show_progress")
|
||||
local showPercent = noctalia.getConfig("show_percent")
|
||||
local glyphSize = noctalia.getConfig("glyph_size")
|
||||
|
||||
local wireless = true -- assume true until a read says otherwise
|
||||
local percent = nil
|
||||
local charging = false
|
||||
local lowPowerThreshold = nil
|
||||
local errorText = nil
|
||||
local checkingCli = false
|
||||
|
||||
local function statusColor()
|
||||
if errorText ~= nil then
|
||||
return "error"
|
||||
end
|
||||
if charging then
|
||||
return "secondary"
|
||||
end
|
||||
if percent ~= nil and percent <= (lowPowerThreshold or LOW_POWER_DEFAULT) then
|
||||
return "error"
|
||||
end
|
||||
return color
|
||||
end
|
||||
|
||||
local function statusGlyph()
|
||||
local name = (errorText ~= nil or percent == nil) and "mouse-off" or "mouse-filled"
|
||||
return ui.glyph({ key = "glyph", name = name, size = glyphSize, color = statusColor() })
|
||||
end
|
||||
|
||||
local function render()
|
||||
if not wireless then
|
||||
desktopWidget.render(ui.column({ gap = 6, align = "center" }, {
|
||||
ui.glyph({ name = "plug", size = glyphSize, color = "outline" }),
|
||||
ui.label({ text = noctalia.tr("ui.wired"), fontSize = 12, color = "outline" }),
|
||||
}))
|
||||
return
|
||||
end
|
||||
|
||||
-- Every row below gets an explicit, semantic `key` - this list isn't
|
||||
-- structurally stable (the 2nd slot alone can hold an error label, a
|
||||
-- "--" placeholder, or a percent label depending on state; the progress
|
||||
-- row appears/disappears independently), so without a key the reconciler
|
||||
-- matches purely by (type, position) and can attach a stale node to a
|
||||
-- different logical row across renders - same bug class fixed in
|
||||
-- panel.luau, just never applied here.
|
||||
local rows = {
|
||||
statusGlyph(),
|
||||
}
|
||||
|
||||
if errorText ~= nil then
|
||||
-- Diagnostic, not decorative - stays visible even with show_percent off,
|
||||
-- since otherwise a broken setup would just silently show a bare glyph.
|
||||
table.insert(rows, ui.label({ key = "status", text = errorText, fontSize = 12, color = "error" }))
|
||||
elseif showPercent and percent == nil then
|
||||
table.insert(rows, ui.label({ key = "status", text = "--", fontSize = 20, color = "outline" }))
|
||||
elseif showPercent then
|
||||
local text = tostring(percent) .. "%"
|
||||
if charging then
|
||||
text = text .. " " .. noctalia.tr("ui.charging")
|
||||
end
|
||||
table.insert(rows, ui.label({ key = "status", text = text, fontSize = 18, fontWeight = "bold", color = statusColor() }))
|
||||
end
|
||||
|
||||
if showProgress and percent ~= nil then
|
||||
table.insert(rows, ui.progress({
|
||||
key = "progress",
|
||||
progress = percent / 100,
|
||||
fill = statusColor(),
|
||||
width = 120,
|
||||
height = 6,
|
||||
radius = 3,
|
||||
}))
|
||||
end
|
||||
|
||||
desktopWidget.render(ui.column({ gap = 6, align = "center" }, rows))
|
||||
end
|
||||
|
||||
-- Fallback: no fresh state file, so ask the driver directly. Only one of
|
||||
-- these in flight at a time - update() ticks every 30s and a CLI round-trip
|
||||
-- can occasionally take longer than that if the mouse's RF link is asleep.
|
||||
local function readViaCli()
|
||||
if checkingCli then
|
||||
return
|
||||
end
|
||||
if not noctalia.commandExists("pulsar-mouse") then
|
||||
errorText = noctalia.tr("ui.not-installed")
|
||||
render()
|
||||
return
|
||||
end
|
||||
checkingCli = true
|
||||
noctalia.runAsync("pulsar-mouse --battery-json", function(result)
|
||||
checkingCli = false
|
||||
if type(result) ~= "table" or result.timedOut or result.exitCode ~= 0 then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
local decoded = noctalia.json.decode(result.stdout or "")
|
||||
if type(decoded) ~= "table" then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.wireless == false then
|
||||
wireless = false
|
||||
errorText = nil
|
||||
render()
|
||||
return
|
||||
end
|
||||
if decoded.battery_percent == nil then
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
-- Unlike signal, this has a synchronous getter, so it IS available here
|
||||
-- (nil on a driver without one, e.g. nordic.py - falls back to LOW_POWER_DEFAULT).
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
local function readState()
|
||||
local contents = noctalia.readFile(STATE_PATH)
|
||||
if type(contents) ~= "string" or contents == "" then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local decoded = noctalia.json.decode(contents)
|
||||
if type(decoded) ~= "table" or decoded.battery_percent == nil then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
local age = os.time() - (decoded.updated_at or 0)
|
||||
if age > STALE_AFTER then
|
||||
readViaCli()
|
||||
return
|
||||
end
|
||||
|
||||
wireless = true
|
||||
errorText = nil
|
||||
percent = decoded.battery_percent
|
||||
charging = decoded.power_connected == true
|
||||
lowPowerThreshold = decoded.low_power_threshold
|
||||
render()
|
||||
end
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(30000)
|
||||
readState()
|
||||
end
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,739 @@
|
||||
--!nonstrict
|
||||
-- Pulsar Mouse quick controls - [[panel]]. Opened from the bar widget.
|
||||
--
|
||||
-- Unlike the battery read path (bar.luau/desktop.luau), this always talks
|
||||
-- to the mouse directly via the CLI - none of DPI/polling/LED state lives
|
||||
-- in the cached battery.json, and writes obviously can't be cached at all.
|
||||
--
|
||||
-- Sliders (DPI stage, polling rate, brightness, breathe speed) commit on
|
||||
-- onDragEnd, not onChange - onChange only updates the live label as you
|
||||
-- drag, so a drag gesture doesn't fire a USB write per pixel/step crossed.
|
||||
-- DPI and polling rate are index-based sliders over their configured/
|
||||
-- supported values (not a raw DPI/Hz range), so every position lands on
|
||||
-- an actual valid value - equivalent to a slider with a notch per option.
|
||||
--
|
||||
-- Root is a ui.scroll, not a ui.column - the panel surface already insets
|
||||
-- its own content (Style::panelPadding on the host side), so an outer
|
||||
-- `padding` prop here would double up on that, and scrolling is a safety
|
||||
-- net against the fixed panel height (declared in plugin.toml) not fitting
|
||||
-- every combination of rows (the speed slider alone changes content height
|
||||
-- depending on which LED effect is selected).
|
||||
--
|
||||
-- No native tabs control exists (checked noctalia.d.luau) - "Sensor" and
|
||||
-- "Lighting" are two ui.button()s whose variant swaps between primary/
|
||||
-- outline based on activeTab, same segmented-button pattern the DPI/polling
|
||||
-- stage buttons used before they became sliders. Each section is just
|
||||
-- conditionally included in the rendered tree rather than actually hidden.
|
||||
--
|
||||
-- panel.render(tree) declarative UI tree
|
||||
-- panel.close() close the panel surface
|
||||
-- noctalia.runAsync(cmd, cb) CLI read/write
|
||||
-- noctalia.json.decode(str) status comes back as JSON on stdout
|
||||
|
||||
-- Which profile is targeted for --profile N reads/writes AND, in lockstep,
|
||||
-- the mouse's actual active profile (this panel always keeps the two the
|
||||
-- same - picking a profile in the UI calls --active-profile, not just a
|
||||
-- local view change, so every field shown always belongs to one coherent
|
||||
-- profile). Starts at 1 as a bootstrap placeholder only - the first
|
||||
-- readStatus() call of each open corrects it to whatever's actually active
|
||||
-- on the mouse (see profileSynced below), since a physical profile button
|
||||
-- or Fusion on another OS could have left a different profile active than
|
||||
-- this panel's default guess, or than whatever it was last left showing.
|
||||
local profile = 1
|
||||
-- Cleared by onOpen() on every open, not just once per process - see the
|
||||
-- bootstrap block in readStatus().
|
||||
local profileSynced = false
|
||||
local numProfiles = 0
|
||||
local pollingRate = nil
|
||||
local pollingRates = {}
|
||||
local dpiActive = nil
|
||||
local dpiStages = {}
|
||||
local dpiPreviewIndex = nil -- live drag preview, separate from the committed dpiActive
|
||||
local pollPreviewIndex = nil
|
||||
local ledEffects = {}
|
||||
local ledEffect = nil
|
||||
local brightnessPct = nil
|
||||
local brightnessPreview = nil
|
||||
local breatheSpeed = nil
|
||||
local breatheSpeedPreview = nil
|
||||
local wireless = false
|
||||
local debounce = nil -- {value, min, max}
|
||||
local debouncePreview = nil
|
||||
local angleSnap = nil
|
||||
local rippleControl = nil
|
||||
local motionSync = nil
|
||||
local lod = nil -- {value, min, max, step}
|
||||
local lodPreview = nil
|
||||
local powerSaving = nil -- {value, min, max}
|
||||
local powerSavingPreview = nil
|
||||
local lowPower = nil -- {value, min, max}
|
||||
local lowPowerPreview = nil
|
||||
local errorText = nil
|
||||
local busy = false
|
||||
local activeTab = "sensor" -- "sensor" | "advanced" | "lighting" | "power"
|
||||
|
||||
-- Forward-declared: sliderRow/toggleRow below build closures (onChange/
|
||||
-- onDragEnd) that call render() before its definition is reached in the
|
||||
-- file. A `local function render()` at that later point would create a
|
||||
-- new local invisible to those already-defined closures, which would
|
||||
-- instead close over the (nil) global - this forward declaration is what
|
||||
-- lets them share the one local.
|
||||
local render
|
||||
|
||||
-- Matches the GUI's own convention (see gui.py) for when breathe speed is
|
||||
-- relevant: the device's last LED effect in its list, checked by position
|
||||
-- rather than a literal string match, in case a future driver ever needs
|
||||
-- a different name.
|
||||
local function breatheSpeedRelevant()
|
||||
return breatheSpeed ~= nil and ledEffect == ledEffects[#ledEffects]
|
||||
end
|
||||
|
||||
local function pollingRateIndex()
|
||||
for i, hz in ipairs(pollingRates) do
|
||||
if hz == pollingRate then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Generic value slider (label + slider) for the four new global/per-profile
|
||||
-- settings below - DPI/polling/brightness/breathe speed above predate this
|
||||
-- and stay as they are (index-based lookups for the first two, already
|
||||
-- working) rather than being retrofitted for the sake of it. get/setPreview
|
||||
-- hold the live-drag value separately from the committed one, same
|
||||
-- preview-then-commit-on-drag-end split as everywhere else in this file.
|
||||
-- `key` gives this row's label/slider a stable identity across renders -
|
||||
-- without it, Noctalia's UI reconciler matches purely by (type, position),
|
||||
-- and this panel's row list shifts constantly (the error label appearing/
|
||||
-- disappearing, tabs swapping which rows are present, the breathe-speed row
|
||||
-- coming and going) - two different sliderRow() calls landing on the same
|
||||
-- structural slot across renders can end up with one's onDragEnd bound to
|
||||
-- the wrong control, so a drag on one setting silently writes another.
|
||||
local function sliderRow(key, labelText, formatValue, min, max, step, get, getPreview, setPreview, buildCmd, onSuccess)
|
||||
local shown = getPreview() or get()
|
||||
return {
|
||||
ui.label({
|
||||
key = key .. "-label",
|
||||
text = `{labelText} — {formatValue(shown)}`,
|
||||
fontSize = 13,
|
||||
color = "on_surface_variant",
|
||||
}),
|
||||
ui.slider({
|
||||
key = key,
|
||||
min = min,
|
||||
max = max,
|
||||
step = step,
|
||||
value = shown,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
setPreview(tonumber(value) or get())
|
||||
render()
|
||||
end,
|
||||
onDragEnd = function(value)
|
||||
-- onDragEnd's own `value` arg unreliably reports the pre-drag value,
|
||||
-- not where the drag actually ended (confirmed empirically - onChange
|
||||
-- during the drag gets the correct live value every time, onDragEnd's
|
||||
-- own argument does not) - getPreview() holds the last-known-good
|
||||
-- value onChange already captured, so prefer that and only fall back
|
||||
-- to onDragEnd's argument for the rare case there was no onChange
|
||||
-- (e.g. a tap without any drag).
|
||||
local target = getPreview() or tonumber(value) or get()
|
||||
setPreview(nil)
|
||||
if target == get() then
|
||||
render()
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(buildCmd(target), function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
onSuccess(target)
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end, 10000)
|
||||
end,
|
||||
}),
|
||||
}
|
||||
end
|
||||
|
||||
-- Tried surfacing an extra hint for these three toggles three ways
|
||||
-- (2026-08-09): a static description row (worked, but pushed panel height
|
||||
-- back up), `tooltip` directly on ui.toggle (silently unsupported -
|
||||
-- Noctalia's own source shows kToggle's prop allowlist has no `tooltip`,
|
||||
-- unlike kButton), and a small ui.button({glyph="help-circle", tooltip=...})
|
||||
-- next to the toggle (matches kButton's confirmed-working tooltip wiring
|
||||
-- exactly, icon rendered correctly, but the hover popup itself never
|
||||
-- appeared in practice - live-tested, no fix found). Dropped all three -
|
||||
-- not worth chasing further. Revisit if this becomes reliable.
|
||||
local function toggleRow(key, labelText, checked, buildCmd, onSuccess)
|
||||
return ui.row({ key = key, gap = 8, align = "center" }, {
|
||||
ui.toggle({
|
||||
key = key .. "-toggle",
|
||||
checked = checked,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
local target = value == "true"
|
||||
if target == checked then
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(buildCmd(target), function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
onSuccess(target)
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end, 10000)
|
||||
end,
|
||||
}),
|
||||
ui.label({ key = key .. "-label", text = labelText, flexGrow = 1 }),
|
||||
})
|
||||
end
|
||||
|
||||
function render()
|
||||
local rows = {}
|
||||
|
||||
if errorText ~= nil then
|
||||
table.insert(rows, ui.label({ key = "error", text = errorText, color = "error" }))
|
||||
end
|
||||
|
||||
local loaded = #dpiStages > 0 or #pollingRates > 0 or #ledEffects > 0
|
||||
|
||||
if not loaded and errorText == nil then
|
||||
table.insert(rows, ui.label({ key = "loading", text = noctalia.tr("ui.loading"), color = "outline" }))
|
||||
panel.render(ui.scroll({ flexGrow = 1, gap = 10 }, rows))
|
||||
return
|
||||
end
|
||||
|
||||
if numProfiles > 1 then
|
||||
-- No separate label row above this - "Profile" prefixes each option
|
||||
-- text instead ("Profile 1", "Profile 2", ...), saving a whole row's
|
||||
-- height (this panel was fighting its own scroll safety net at 320px
|
||||
-- wide with a standalone label here).
|
||||
local profileOptions = {}
|
||||
for i = 1, numProfiles do
|
||||
table.insert(profileOptions, `{noctalia.tr("ui.profile-label")} {i}`)
|
||||
end
|
||||
table.insert(rows, ui.select({
|
||||
key = "profile",
|
||||
options = profileOptions,
|
||||
selectedIndex = profile - 1,
|
||||
enabled = not busy,
|
||||
onChange = "onProfileChange",
|
||||
}))
|
||||
end
|
||||
|
||||
-- No native tabs control - segmented buttons standing in for one, same
|
||||
-- variant-swap pattern as the DPI/polling stage buttons used to use
|
||||
-- before they became sliders. Power only applies to wireless mice (power
|
||||
-- saving timeout, low-battery threshold) - hidden entirely on a wired
|
||||
-- one. Four tabs no longer fit legibly in one row at this panel's 320px
|
||||
-- width (confirmed by eye once Advanced was added) - split into two rows
|
||||
-- of two instead of shrinking the buttons.
|
||||
local function tabButton(tab, labelKey)
|
||||
return ui.button({
|
||||
key = `tab-{tab}`,
|
||||
text = noctalia.tr(labelKey),
|
||||
variant = (activeTab == tab) and "primary" or "outline",
|
||||
flexGrow = 1,
|
||||
onClick = function()
|
||||
activeTab = tab
|
||||
render()
|
||||
end,
|
||||
})
|
||||
end
|
||||
table.insert(rows, ui.row({ key = "tabs-1", gap = 6 }, {
|
||||
tabButton("sensor", "ui.tab-sensor"),
|
||||
tabButton("advanced", "ui.tab-advanced"),
|
||||
}))
|
||||
local tabButtons2 = { tabButton("lighting", "ui.tab-lighting") }
|
||||
if wireless then
|
||||
table.insert(tabButtons2, tabButton("power", "ui.tab-power"))
|
||||
end
|
||||
table.insert(rows, ui.row({ key = "tabs-2", gap = 6 }, tabButtons2))
|
||||
|
||||
if activeTab == "sensor" and #dpiStages > 0 then
|
||||
local shownIndex = dpiPreviewIndex or dpiActive
|
||||
local shownDpi = dpiStages[shownIndex]
|
||||
table.insert(rows, ui.label({
|
||||
key = "dpi-label",
|
||||
text = `{noctalia.tr("ui.dpi-label")} — {shownDpi}`,
|
||||
fontSize = 13,
|
||||
color = "on_surface_variant",
|
||||
}))
|
||||
table.insert(rows, ui.slider({
|
||||
key = "dpi",
|
||||
min = 1,
|
||||
max = #dpiStages,
|
||||
step = 1,
|
||||
value = shownIndex,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
dpiPreviewIndex = math.floor(tonumber(value) or dpiActive)
|
||||
render()
|
||||
end,
|
||||
onDragEnd = function(value)
|
||||
-- Prefer the live-drag preview over onDragEnd's own `value` arg - see
|
||||
-- sliderRow()'s onDragEnd for why (empirically unreliable, always
|
||||
-- reports the pre-drag value here too).
|
||||
local target = dpiPreviewIndex or math.floor(tonumber(value) or dpiActive)
|
||||
dpiPreviewIndex = nil
|
||||
if target == dpiActive then
|
||||
render()
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(
|
||||
`pulsar-mouse --profile {profile} --active-stage {target}`,
|
||||
function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
dpiActive = target
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end,
|
||||
10000
|
||||
)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
if activeTab == "sensor" and #pollingRates > 0 then
|
||||
local shownPollIndex = pollPreviewIndex or pollingRateIndex()
|
||||
local shownHz = pollingRates[shownPollIndex]
|
||||
table.insert(rows, ui.label({
|
||||
key = "polling-label",
|
||||
text = `{noctalia.tr("ui.polling-label")} — {shownHz} Hz`,
|
||||
fontSize = 13,
|
||||
color = "on_surface_variant",
|
||||
}))
|
||||
table.insert(rows, ui.slider({
|
||||
key = "polling",
|
||||
min = 1,
|
||||
max = #pollingRates,
|
||||
step = 1,
|
||||
value = shownPollIndex,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
pollPreviewIndex = math.floor(tonumber(value) or pollingRateIndex())
|
||||
render()
|
||||
end,
|
||||
onDragEnd = function(value)
|
||||
-- Prefer the live-drag preview over onDragEnd's own `value` arg - see
|
||||
-- sliderRow()'s onDragEnd for why.
|
||||
local targetIndex = pollPreviewIndex or math.floor(tonumber(value) or pollingRateIndex())
|
||||
pollPreviewIndex = nil
|
||||
local hz = pollingRates[targetIndex]
|
||||
if hz == nil or hz == pollingRate then
|
||||
render()
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(`pulsar-mouse --poll {hz}`, function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
pollingRate = hz
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end, 10000)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
if activeTab == "sensor" and lod ~= nil then
|
||||
for _, node in ipairs(sliderRow(
|
||||
"lod",
|
||||
noctalia.tr("ui.lod-label"),
|
||||
function(v) return `{string.format("%.1f", v)}mm` end,
|
||||
lod.min, lod.max, lod.step,
|
||||
function() return lod.value end,
|
||||
function() return lodPreview end,
|
||||
function(v) lodPreview = v end,
|
||||
function(v) return `pulsar-mouse --profile {profile} --lod {string.format("%.1f", v)}` end,
|
||||
function(v) lod.value = v end
|
||||
)) do
|
||||
table.insert(rows, node)
|
||||
end
|
||||
end
|
||||
|
||||
if activeTab == "advanced" and debounce ~= nil then
|
||||
for _, node in ipairs(sliderRow(
|
||||
"debounce",
|
||||
noctalia.tr("ui.debounce-label"),
|
||||
function(v) return `{math.floor(v)}ms` end,
|
||||
debounce.min, debounce.max, 1,
|
||||
function() return debounce.value end,
|
||||
function() return debouncePreview end,
|
||||
function(v) debouncePreview = v end,
|
||||
function(v) return `pulsar-mouse --debounce {math.floor(v)}` end,
|
||||
function(v) debounce.value = math.floor(v) end
|
||||
)) do
|
||||
table.insert(rows, node)
|
||||
end
|
||||
end
|
||||
|
||||
if activeTab == "advanced" and angleSnap ~= nil then
|
||||
table.insert(rows, toggleRow(
|
||||
"angle-snap",
|
||||
noctalia.tr("ui.angle-snap-label"),
|
||||
angleSnap,
|
||||
function(v) return `pulsar-mouse --angle-snap {v and "on" or "off"}` end,
|
||||
function(v) angleSnap = v end
|
||||
))
|
||||
end
|
||||
|
||||
if activeTab == "advanced" and rippleControl ~= nil then
|
||||
table.insert(rows, toggleRow(
|
||||
"ripple",
|
||||
noctalia.tr("ui.ripple-label"),
|
||||
rippleControl,
|
||||
function(v) return `pulsar-mouse --ripple {v and "on" or "off"}` end,
|
||||
function(v) rippleControl = v end
|
||||
))
|
||||
end
|
||||
|
||||
if activeTab == "advanced" and motionSync ~= nil then
|
||||
table.insert(rows, toggleRow(
|
||||
"motion-sync",
|
||||
noctalia.tr("ui.motion-sync-label"),
|
||||
motionSync,
|
||||
function(v) return `pulsar-mouse --motion-sync {v and "on" or "off"}` end,
|
||||
function(v) motionSync = v end
|
||||
))
|
||||
end
|
||||
|
||||
if activeTab == "power" and powerSaving ~= nil then
|
||||
for _, node in ipairs(sliderRow(
|
||||
"power-saving",
|
||||
noctalia.tr("ui.power-saving-label"),
|
||||
function(v) return string.format("%d:%02d", math.floor(v / 60), math.floor(v) % 60) end,
|
||||
powerSaving.min, powerSaving.max, 30,
|
||||
function() return powerSaving.value end,
|
||||
function() return powerSavingPreview end,
|
||||
function(v) powerSavingPreview = v end,
|
||||
function(v) return `pulsar-mouse --power-saving {math.floor(v)}` end,
|
||||
function(v) powerSaving.value = math.floor(v) end
|
||||
)) do
|
||||
table.insert(rows, node)
|
||||
end
|
||||
end
|
||||
|
||||
if activeTab == "power" and lowPower ~= nil then
|
||||
for _, node in ipairs(sliderRow(
|
||||
"low-power",
|
||||
noctalia.tr("ui.low-power-label"),
|
||||
function(v) return `{math.floor(v)}%` end,
|
||||
lowPower.min, lowPower.max, 5,
|
||||
function() return lowPower.value end,
|
||||
function() return lowPowerPreview end,
|
||||
function(v) lowPowerPreview = v end,
|
||||
function(v) return `pulsar-mouse --low-power {math.floor(v)}` end,
|
||||
function(v) lowPower.value = math.floor(v) end
|
||||
)) do
|
||||
table.insert(rows, node)
|
||||
end
|
||||
end
|
||||
|
||||
if activeTab == "lighting" and #ledEffects > 0 then
|
||||
table.insert(rows, ui.label({ key = "led-label", text = noctalia.tr("ui.led-label"), fontSize = 13, color = "on_surface_variant" }))
|
||||
|
||||
local effectOptions = {}
|
||||
local effectSelectedIndex = 0
|
||||
for i, effect in ipairs(ledEffects) do
|
||||
table.insert(effectOptions, effect)
|
||||
if effect == ledEffect then
|
||||
effectSelectedIndex = i - 1
|
||||
end
|
||||
end
|
||||
table.insert(rows, ui.select({
|
||||
key = "led-effect",
|
||||
options = effectOptions,
|
||||
selectedIndex = effectSelectedIndex,
|
||||
enabled = not busy,
|
||||
onChange = "onEffectChange",
|
||||
}))
|
||||
|
||||
local shownBrightness = brightnessPreview or brightnessPct
|
||||
table.insert(rows, ui.label({
|
||||
key = "brightness-label",
|
||||
text = `{noctalia.tr("ui.brightness-label")} — {shownBrightness}%`,
|
||||
fontSize = 13,
|
||||
color = "on_surface_variant",
|
||||
}))
|
||||
table.insert(rows, ui.slider({
|
||||
key = "brightness",
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
value = shownBrightness,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
brightnessPreview = math.floor(tonumber(value) or brightnessPct)
|
||||
render()
|
||||
end,
|
||||
onDragEnd = function(value)
|
||||
-- Prefer the live-drag preview over onDragEnd's own `value` arg - see
|
||||
-- sliderRow()'s onDragEnd for why.
|
||||
local target = brightnessPreview or math.floor(tonumber(value) or brightnessPct)
|
||||
brightnessPreview = nil
|
||||
if target == brightnessPct then
|
||||
render()
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(
|
||||
`pulsar-mouse --profile {profile} --brightness-percent {target}`,
|
||||
function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
brightnessPct = target
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end,
|
||||
10000
|
||||
)
|
||||
end,
|
||||
}))
|
||||
|
||||
if breatheSpeedRelevant() then
|
||||
local shownSpeed = breatheSpeedPreview or breatheSpeed
|
||||
table.insert(rows, ui.label({
|
||||
key = "speed-label",
|
||||
text = `{noctalia.tr("ui.speed-label")} — {shownSpeed}`,
|
||||
fontSize = 13,
|
||||
color = "on_surface_variant",
|
||||
}))
|
||||
table.insert(rows, ui.slider({
|
||||
key = "speed",
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
value = shownSpeed,
|
||||
enabled = not busy,
|
||||
onChange = function(value)
|
||||
breatheSpeedPreview = math.floor(tonumber(value) or breatheSpeed)
|
||||
render()
|
||||
end,
|
||||
onDragEnd = function(value)
|
||||
-- Prefer the live-drag preview over onDragEnd's own `value` arg -
|
||||
-- see sliderRow()'s onDragEnd for why.
|
||||
local target = breatheSpeedPreview or math.floor(tonumber(value) or breatheSpeed)
|
||||
breatheSpeedPreview = nil
|
||||
if target == breatheSpeed then
|
||||
render()
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(
|
||||
`pulsar-mouse --profile {profile} --breathe-speed {target}`,
|
||||
function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
breatheSpeed = target
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end,
|
||||
10000
|
||||
)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
panel.render(ui.scroll({ flexGrow = 1, gap = 10 }, rows))
|
||||
end
|
||||
|
||||
-- Clears `busy` on every terminal path (all the early returns below, and
|
||||
-- the final success path at the end) - NOT on the bootstrap-resync path,
|
||||
-- which recurses into another readStatus() call that owns clearing it
|
||||
-- once that one actually finishes. Bug, found in review: onProfileChange
|
||||
-- sets busy=true then calls this expecting it to clear busy on success,
|
||||
-- but until this fix it never did on any path - after a successful
|
||||
-- profile switch the whole panel (every slider/toggle/dropdown, since
|
||||
-- they're all `enabled = not busy`) went permanently read-only, with no
|
||||
-- error shown, since nothing was ever wrong from the user's perspective.
|
||||
local function readStatus()
|
||||
if not noctalia.commandExists("pulsar-mouse") then
|
||||
busy = false
|
||||
errorText = noctalia.tr("ui.not-installed")
|
||||
render()
|
||||
return
|
||||
end
|
||||
noctalia.runAsync(
|
||||
`pulsar-mouse --status-json --profile {profile}`,
|
||||
function(result)
|
||||
if type(result) ~= "table" or result.timedOut or result.exitCode ~= 0 then
|
||||
busy = false
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
local decoded = noctalia.json.decode(result.stdout or "")
|
||||
-- type(decoded.dpi) checked here too, not just polling_rate - cli.py
|
||||
-- always emits it today so this is theoretical, but decoded.dpi.active
|
||||
-- below would otherwise throw on a malformed response and skip
|
||||
-- clearing `busy`, the exact failure mode the rest of this function
|
||||
-- was just fixed to avoid.
|
||||
if type(decoded) ~= "table" or decoded.polling_rate == nil or type(decoded.dpi) ~= "table" then
|
||||
busy = false
|
||||
errorText = noctalia.tr("ui.no-mouse")
|
||||
render()
|
||||
return
|
||||
end
|
||||
numProfiles = decoded.num_profiles or numProfiles
|
||||
-- Once-per-open bootstrap: this query targeted --profile {profile}
|
||||
-- (the value left over from the last time the panel was open, or the
|
||||
-- placeholder 1 on first load), but decoded.active_profile is the
|
||||
-- ground truth of what's actually live on the mouse regardless of
|
||||
-- that. Resync `profile` to it and re-fetch once so every field below
|
||||
-- reflects the real active profile from the start, not some other
|
||||
-- profile's stored settings - see `profile`'s own comment above for
|
||||
-- why this can legitimately differ.
|
||||
--
|
||||
-- `profileSynced` is module-scoped and so survives the panel closing,
|
||||
-- which used to make this a once-per-*process* bootstrap: only the
|
||||
-- very first open ever re-synced. Switching profiles by any other
|
||||
-- means afterwards (the physical profile button, `pulsar-mouse
|
||||
-- --active-profile`, Fusion on another OS) then left every reopen
|
||||
-- showing - and writing to - the stale profile this panel last knew
|
||||
-- about. onOpen() clears the flag so each open re-syncs.
|
||||
if not profileSynced then
|
||||
profileSynced = true
|
||||
if decoded.active_profile ~= nil and decoded.active_profile ~= profile then
|
||||
profile = decoded.active_profile
|
||||
readStatus()
|
||||
return
|
||||
end
|
||||
end
|
||||
errorText = nil
|
||||
wireless = decoded.wireless == true
|
||||
pollingRate = decoded.polling_rate
|
||||
pollingRates = decoded.polling_rates or {}
|
||||
dpiActive = decoded.dpi.active
|
||||
dpiStages = decoded.dpi.stages or {}
|
||||
if type(decoded.debounce) == "table" then
|
||||
debounce = decoded.debounce
|
||||
end
|
||||
if type(decoded.angle_snap) == "boolean" then
|
||||
angleSnap = decoded.angle_snap
|
||||
end
|
||||
if type(decoded.ripple_control) == "boolean" then
|
||||
rippleControl = decoded.ripple_control
|
||||
end
|
||||
if type(decoded.motion_sync) == "boolean" then
|
||||
motionSync = decoded.motion_sync
|
||||
end
|
||||
if type(decoded.lod) == "table" and decoded.lod.step ~= nil then
|
||||
-- Only the continuous-slider shape (lod_step set) is handled here -
|
||||
-- a driver with a discrete lod_values list instead (no "step") isn't
|
||||
-- supported by this panel, same as the DPI/polling stage sliders
|
||||
-- assume their own value lists are always present and non-empty.
|
||||
lod = decoded.lod
|
||||
end
|
||||
if type(decoded.power_saving) == "table" then
|
||||
powerSaving = decoded.power_saving
|
||||
end
|
||||
if type(decoded.low_power) == "table" then
|
||||
lowPower = decoded.low_power
|
||||
end
|
||||
if type(decoded.led) == "table" then
|
||||
ledEffects = decoded.led.effects or {}
|
||||
ledEffect = decoded.led.effect
|
||||
brightnessPct = decoded.led.brightness_percent
|
||||
breatheSpeed = decoded.led.breathe_speed
|
||||
end
|
||||
busy = false
|
||||
render()
|
||||
end,
|
||||
10000
|
||||
)
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
-- Re-arm the active-profile resync on every open, not just the first
|
||||
-- one of the process - see readStatus()'s own comment for what went
|
||||
-- wrong without this. Deliberately not reset anywhere else: by the time
|
||||
-- onProfileChange runs, this open's resync has already happened, and
|
||||
-- re-arming it there would let a not-yet-settled --status-json read
|
||||
-- (still reporting the outgoing profile) undo the switch the user just
|
||||
-- made.
|
||||
profileSynced = false
|
||||
render() -- immediate placeholder while the read below is in flight
|
||||
readStatus()
|
||||
end
|
||||
|
||||
function onProfileChange(index, label)
|
||||
local optionIndex = tonumber(index)
|
||||
if optionIndex == nil then
|
||||
return
|
||||
end
|
||||
local target = optionIndex + 1
|
||||
if target == profile then
|
||||
return
|
||||
end
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(`pulsar-mouse --active-profile {target}`, function(result)
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
-- Switching the active profile changes essentially every field this
|
||||
-- panel shows (DPI/LOD/LED belong to the newly-active profile now,
|
||||
-- and the poll/debounce/etc. group tracks whichever profile is
|
||||
-- active directly - see `profile`'s comment above) - a single
|
||||
-- optimistic field update like onEffectChange's isn't enough, this
|
||||
-- needs a full re-fetch. readStatus() clears `busy` and re-renders
|
||||
-- itself once that completes, so neither happens here.
|
||||
profile = target
|
||||
errorText = nil
|
||||
readStatus()
|
||||
else
|
||||
busy = false
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
render()
|
||||
end
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
function onEffectChange(index, label)
|
||||
local optionIndex = tonumber(index)
|
||||
if optionIndex == nil or ledEffects[optionIndex + 1] == nil then
|
||||
return
|
||||
end
|
||||
local effect = ledEffects[optionIndex + 1]
|
||||
busy = true
|
||||
render()
|
||||
noctalia.runAsync(`pulsar-mouse --profile {profile} --led {effect}`, function(result)
|
||||
busy = false
|
||||
if type(result) == "table" and result.exitCode == 0 then
|
||||
ledEffect = effect
|
||||
errorText = nil
|
||||
else
|
||||
errorText = noctalia.tr("ui.write-failed")
|
||||
end
|
||||
render()
|
||||
end, 10000)
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
# Pulsar Mouse battery/charging status - bar widget and desktop widget - plus
|
||||
# a quick-controls panel (profile switching, DPI/polling/lighting/power
|
||||
# settings across Sensor/Advanced/Lighting/Power tabs) opened by clicking
|
||||
# the bar widget.
|
||||
#
|
||||
# The battery-reading entries (bar, battery) read the reading pulsar-mouse-gui
|
||||
# already writes on every poll (~/.cache/pulsar-mouse/battery.json) rather
|
||||
# than doing their own USB read - avoids duplicate polling of the mouse's
|
||||
# wireless link. Each falls back to invoking the `pulsar-mouse` CLI directly
|
||||
# if that file is missing or stale (e.g. the GUI/tray isn't running, or was
|
||||
# never installed at all - CLI-only setups just always take this path), so
|
||||
# both still work standalone. The controls panel always talks to the CLI
|
||||
# directly (DPI/polling rate aren't in that cached file, and writes can't be
|
||||
# cached at all).
|
||||
#
|
||||
# Desktop widgets are shared with the lock screen (LockscreenWidgetsHost
|
||||
# reuses the same widget type registry as the desktop), so the desktop_widget
|
||||
# entry shows up in both places once added via the widget editor.
|
||||
|
||||
id = "harveywuk/pulsar-mouse"
|
||||
name = "Pulsar Mouse"
|
||||
version = "1.6.1"
|
||||
plugin_api = 9
|
||||
author = "harveywuk"
|
||||
license = "MIT"
|
||||
dependencies = ["pulsar-mouse"]
|
||||
tags = ["hardware", "system"]
|
||||
icon = "mouse"
|
||||
description = "Battery status and quick sensor/lighting/power controls for a Pulsar gaming mouse - bar, desktop, and lock screen."
|
||||
|
||||
[[widget]]
|
||||
id = "bar"
|
||||
entry = "bar.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph_size"
|
||||
type = "int"
|
||||
label_key = "settings.glyph_size.label"
|
||||
default = 16
|
||||
min = 10
|
||||
max = 32
|
||||
step = 1
|
||||
|
||||
[[panel]]
|
||||
id = "controls"
|
||||
entry = "panel.luau"
|
||||
width = 320
|
||||
height = 380
|
||||
placement = "attached"
|
||||
position = "auto"
|
||||
open_near_click = true
|
||||
|
||||
[[desktop_widget]]
|
||||
id = "battery"
|
||||
entry = "desktop.luau"
|
||||
|
||||
[[desktop_widget.setting]]
|
||||
key = "color"
|
||||
type = "color"
|
||||
label_key = "settings.color.label"
|
||||
description_key = "settings.color.description"
|
||||
default = "primary"
|
||||
|
||||
[[desktop_widget.setting]]
|
||||
key = "show_progress"
|
||||
type = "bool"
|
||||
label_key = "settings.show_progress.label"
|
||||
default = true
|
||||
|
||||
[[desktop_widget.setting]]
|
||||
key = "show_percent"
|
||||
type = "bool"
|
||||
label_key = "settings.show_percent.label"
|
||||
description_key = "settings.show_percent.description"
|
||||
default = true
|
||||
|
||||
[[desktop_widget.setting]]
|
||||
key = "glyph_size"
|
||||
type = "int"
|
||||
label_key = "settings.glyph_size.label"
|
||||
default = 28
|
||||
min = 16
|
||||
max = 64
|
||||
step = 2
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"settings": {
|
||||
"color": {
|
||||
"label": "Color",
|
||||
"description": "Accent color for the percentage text and progress bar"
|
||||
},
|
||||
"show_progress": {
|
||||
"label": "Show Progress Bar"
|
||||
},
|
||||
"show_percent": {
|
||||
"label": "Show Percentage Text",
|
||||
"description": "Turn off to rely on just the glyph (and progress bar, if shown)"
|
||||
},
|
||||
"glyph_size": {
|
||||
"label": "Glyph Size"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"charging": "(charging)",
|
||||
"not-installed": "pulsar-mouse not found",
|
||||
"no-mouse": "Mouse not found",
|
||||
"profile-label": "Profile",
|
||||
"dpi-label": "DPI",
|
||||
"polling-label": "Polling Rate",
|
||||
"led-label": "LED Effect",
|
||||
"brightness-label": "Brightness",
|
||||
"speed-label": "Speed",
|
||||
"write-failed": "Failed to apply - is the mouse connected?",
|
||||
"loading": "Loading...",
|
||||
"wired": "Wired",
|
||||
"signal-label": "Signal",
|
||||
"tab-sensor": "Sensor",
|
||||
"tab-advanced": "Advanced",
|
||||
"tab-lighting": "Lighting",
|
||||
"tab-power": "Power",
|
||||
"debounce-label": "Debounce",
|
||||
"angle-snap-label": "Angle Snap",
|
||||
"ripple-label": "Ripple Control",
|
||||
"motion-sync-label": "Motion Sync",
|
||||
"lod-label": "Lift-off Distance",
|
||||
"power-saving-label": "Wireless Power Saving",
|
||||
"low-power-label": "Low Power Mode"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user