Files
community-plugins/pulsar-mouse/desktop.luau
T
13bde174ad pulsar-mouse: make the per-state widget colors configurable (#336)
* pulsar-mouse: make the per-state widget colors configurable

Both widgets hardcoded their state colors: error for a fault, secondary
while charging, error at the mouse's low-power threshold. The desktop
widget's one `color` setting only covered the normal state, and the bar
widget had no color setting at all.

Expose all four states (normal/charging/low/error) as `type = "color"`
settings on each widget, in the same shape battery-widget uses. Defaults
reproduce exactly what was hardcoded, so an existing install renders
identically until someone changes one.

The new settings are `advanced`; the desktop widget's pre-existing
`color` stays where it was so it does not disappear behind the toggle
for anyone already using it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* pulsar-mouse: rename the bar's normal-state key to normal_color

A bar widget's plugin settings share one TOML table with Noctalia's own
per-widget presentation settings, where `color` already exists ("Color
role for this widget's icon and label"). A plugin declaring `color`
there does not shadow it, it aliases it - one `color = "primary"` under
[widget.<id>] drove both pickers at once, so setting either silently
moved the other.

Renamed to `normal_color`, which also makes the retest unambiguous: the
glyph follows the plugin setting while Presentation's own Color picker
stays on Default.

The desktop widget keeps plain `color` - no clash there, and renaming it
would break existing configs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:28:30 -04:00

191 lines
6.5 KiB
Luau

--!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")
-- The other three states. Defaulted in plugin.toml to the values these were
-- previously hardcoded to, so an existing install renders identically.
local chargingColor = noctalia.getConfig("charging_color")
local warningColor = noctalia.getConfig("warning_color")
local errorColor = noctalia.getConfig("error_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 errorColor
end
if charging then
return chargingColor
end
if percent ~= nil and percent <= (lowPowerThreshold or LOW_POWER_DEFAULT) then
return warningColor
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 = errorColor }))
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()