Files
community-plugins/pulsar-mouse/panel.luau
T
73ae491d97 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>
2026-08-09 10:01:34 -04:00

740 lines
27 KiB
Luau

--!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