From 73ae491d9798f04e52e089304327bf5d142de797 Mon Sep 17 00:00:00 2001 From: harveywuk Date: Sun, 9 Aug 2026 15:01:34 +0100 Subject: [PATCH] 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 --------- Co-authored-by: Claude Sonnet 5 --- pulsar-mouse/README.md | 95 ++++ pulsar-mouse/bar.luau | 196 ++++++++ pulsar-mouse/desktop.luau | 185 ++++++++ pulsar-mouse/panel.luau | 739 ++++++++++++++++++++++++++++++ pulsar-mouse/plugin.toml | 84 ++++ pulsar-mouse/thumbnail.webp | Bin 0 -> 20620 bytes pulsar-mouse/translations/en.json | 44 ++ 7 files changed, 1343 insertions(+) create mode 100644 pulsar-mouse/README.md create mode 100644 pulsar-mouse/bar.luau create mode 100644 pulsar-mouse/desktop.luau create mode 100644 pulsar-mouse/panel.luau create mode 100644 pulsar-mouse/plugin.toml create mode 100644 pulsar-mouse/thumbnail.webp create mode 100644 pulsar-mouse/translations/en.json diff --git a/pulsar-mouse/README.md b/pulsar-mouse/README.md new file mode 100644 index 0000000..ba6522d --- /dev/null +++ b/pulsar-mouse/README.md @@ -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 ` 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. diff --git a/pulsar-mouse/bar.luau b/pulsar-mouse/bar.luau new file mode 100644 index 0000000..da39082 --- /dev/null +++ b/pulsar-mouse/bar.luau @@ -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 diff --git a/pulsar-mouse/desktop.luau b/pulsar-mouse/desktop.luau new file mode 100644 index 0000000..5d760bf --- /dev/null +++ b/pulsar-mouse/desktop.luau @@ -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() diff --git a/pulsar-mouse/panel.luau b/pulsar-mouse/panel.luau new file mode 100644 index 0000000..9c1d06f --- /dev/null +++ b/pulsar-mouse/panel.luau @@ -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 diff --git a/pulsar-mouse/plugin.toml b/pulsar-mouse/plugin.toml new file mode 100644 index 0000000..073d7c1 --- /dev/null +++ b/pulsar-mouse/plugin.toml @@ -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 diff --git a/pulsar-mouse/thumbnail.webp b/pulsar-mouse/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..8937c164fc1739c34aa7e0adcc3d4d1d849fc172 GIT binary patch literal 20620 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 literal 0 HcmV?d00001 diff --git a/pulsar-mouse/translations/en.json b/pulsar-mouse/translations/en.json new file mode 100644 index 0000000..6ee8788 --- /dev/null +++ b/pulsar-mouse/translations/en.json @@ -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" + } +}