ISO keyboards have two physical keys that ANSI does not — POUND (#) at
row 3 col 12 between the right-of-quote position and Enter, and
ISO_BACKSLASH (<) at row 4 col 1 between LShift and Z. The firmware
reports them as zones 47 and 97 on G915 ISO models, but MAIN_ISO only
*subtracted* the ANSI backslash at row 2 col 13 without ever adding
those two cells back. They fell through to the unmapped pool and got
dropped by the EXTRAS_ALLOWLIST phantom-zone filter, so PerKey lighting
silently left them undrawable (issue #3239 — German G915).
Add both cells to MAIN_ISO with the UK QWERTY labels (# and \\) as the
default, and override them in the regional layouts: # / < on QWERTZ DE,
* / < on AZERTY FR. UK QWERTY inherits the defaults.
ANSI is unaffected — MAIN_ANSI still omits 47/97 so they keep getting
filtered as phantoms on ANSI keyboards like the G515.
_cell_at now returns a phantom unbound BoundCell when the click lands
in a matrix-grid gap. Endpoint tools (rect/gradient) accept these as
anchors; brush/bucket still require a bound cell. No painting happens
on phantoms — they only place the corner/endpoint.
Software-managed LED persistence and power management for devices that
expose RGBEffects (0x8071) — primarily G515 LIGHTSPEED TKL, but the same
infrastructure works on any 0x8071 device that supports SW takeover.
Core mechanism: RGBControl toggle drives a Set SWControl(mode=3, flags)
handshake. While SW control is held, the host owns the LED pipeline: zone
effects, per-key paint, idle/sleep transitions, and the NvConfig boot/exit
animations. On release, the firmware resumes its onboard profile.
Major pieces:
- rgb_power.py — new module hosting the software RGB power manager:
ACTIVE / DIMMING / IDLE / SLEEPING state machine driven by firmware
onUserActivity events, smooth 5-second dim ramp (zone or per-key), idle
Static color snap, software sleep timer, wake handler that re-pushes
saved state. Includes the cleanup hook that runs on device close (and,
optionally, fires the cap 0x0040 shutdown trigger).
- RGBControl (settings_templates) — switch-style render via BooleanValidator
(true_value=3 / false_value=0) plus a full _claim_sw_control /
_release_sw_control pair: profile-management mode, SetSWControl, per-key
flag reset, manager start, cleanup registration, and a post-claim
repaint pass so the device immediately reflects Solaar's saved zone +
per-key state.
- RGBEffectSetting — zone-effect Setting subclass for 0x8071. Handles
per-key/zone coexistence: per-key paint dominates only when zone is
Static and the user has explicitly opted in via the lock icon; under
animations or before opt-in, the zone wire push is the visible layer.
- RGBIdleEffect / RGBIdleTimeout / RGBSleepTimeout — Solaar-managed idle
behavior. Choice list: "No change" → Dim → Static (snap to color) →
device-specific animations. Static idle substitutes the idle color for
unset per-key cells via effective_zone_base_color's state-aware lookup.
- RgbStartupAnimation / RgbShutdownAnimation — toggle-and-color rows for
RGBEffects NvConfig caps 0x0001 and 0x0040, exposed only on devices
that answer the probe. Shutdown trigger fires SetRgbPowerMode(0) at
cleanup time so the firmware plays the configured animation on exit.
- PerKeyLighting — per-key painter improvements: explicit-opt-in
dominance over zone effects, BUSY retry, FrameEnd suppression on
per-cell failure, single-shot prep sequence (SetEffectByIndex into the
out-of-range slot) on mice with firmware effect cards.
- device_quirks.py — small per-model quirks table keyed by device.modelId
(stable across USB/BLE/wireless). Currently used to mark RGBEffects
NvConfig color slots as inert on devices where the firmware accepts
but ignores the bytes (G502 X PLUS startup colors).
- config_panel.py — HeteroKeyControl gains a TOGGLE field kind that
renders as a Gtk.Switch (used by the boot-effect rows). Visual gate
greys out RGB settings whose prerequisites aren't met: rgb_zone_* /
rgb_idle_* / rgb_sleep_timeout require LED Control = Solaar; per-key
additionally requires every zone effect to be Static. Visual-only —
doesn't touch the persister's user lock-icon state.
- tests/test_rgb_power.py — coverage for the power manager state
machine, dim ramp, idle effects, wake path, and per-key/zone
coexistence.
Closespwr-Solaar/Solaar#3149.
OpenRGB labels each LED with a letter (A..H) for display; we inherited
that convention when porting the G502 X grid. For LEDs without physical
keycaps, letters add a mental translation step ("A is zone 1, B is zone
2...") with no visual benefit. Switch to bare zone numbers so the painter
cell labels match what shows up in logs, the persister, and rule
arguments.
Cairo linear-gradient endpoints were placed at the rounded rect's
geometric corners (3,3) and (21,21), but the *visible* corner pixel
of a rounded rect with radius 2 is the outermost point of the arc,
inset along the diagonal by r * (1 - 1/sqrt(2)) ≈ 0.586 units.
That meant t=0 and t=1 of the gradient landed in the cut-off corner
regions, and the rendered corners sampled at t≈0.033 / 0.967 — about
3.3% in from each endpoint, ~8 RGB units short of the true previous /
active colors (visible on saturated pairs like pure red → pure blue:
the "pure red" corner rendered as ~rgb(194, 10, 0)).
Shift the gradient endpoints inward by the arc inset so t=0 maps to
the visible TL corner pixel and t=1 to the visible BR pixel. The
gradient now spans the visually rendered area exactly; saturated
endpoints render flush.
The gradient swatch on the gradient-tool button drew a flat
rectangular fill with a 1px translucent-black border. Visually it
sat as an odd-one-out next to the rounded-square Tabler outline
icons on the rest of the toolbar — a sharp-cornered patch flanked
by rounded-corner icons.
Render the gradient inside the same path Tabler "square" uses
(rounded rect from (3,3) to (21,21), corner radius 2, stroke 2 in
a 24x24 viewBox; cairo scale into the swatch's pixel size) and
stroke the outline in the GTK theme's foreground color. The
swatch now reads as one of the icon family — same outline style,
same line weight, same theme-following color — with the gradient
filling the inside.
Connect style-updated to queue_draw so the outline color tracks
runtime theme switches alongside the icon buttons.
The editor was a process-wide singleton: opening it on a different
device replaced the content in the existing window. A user with two
perkey-capable devices (e.g. a G915 keyboard and a G502 mouse) had to
context-switch between them, losing any in-progress edits on the
device they switched away from.
Replace the singleton with a `_dialogs` dict in dialog.py keyed by a
stable per-device identifier. control.py builds the key from
`device.unitId` first — read from the device firmware via the
DeviceInformation feature, the same regardless of whether the device
is currently on a receiver or plugged directly via USB — so the same
physical device on different transports shares one dialog instead of
opening two windows. Falls back to `serial`, `hid_serial`, `codename`,
and finally `id(sink)` for the pathological case where nothing else
identifies the device.
If the dialog is already open for a given device and `present()` is
called with the same sink instance, the window is just raised — no
flicker, in-progress interaction state preserved. A different sink
under the same key (transport change for the same physical device)
rebuilds the window content under the existing dialog slot, so the
window position is preserved across transport switches.
Closing a window via the WM tears down only that dialog and pops it
from the registry; other open editors stay up. As a side cleanup the
unused `inset` ScrolledWindow shadow already added in editor.py and
the per-device sizing logic in dialog.py remain in place.
The dialog used hardcoded offsets to compute its target size from the
canvas's size_request:
target_w = canvas_w + 32 # 8 wrapper border * 2 + ~16 scrollbar slack
target_h = canvas_h + 80 # 8 wrapper border * 2 + 50 toolbar + slack
Two problems with that:
1. The "+32" only covered the canvas's width plus borders, not the
toolbar's width. Small layouts (e.g. an 8-LED mouse: canvas ~172px)
produced a window narrower than the toolbar wanted (~261px with
the icon buttons + palette + color picker + unset toggle), causing
toolbar overflow / clipping.
2. The "+80" assumed a fixed toolbar height and scrollbar slack —
wrong on themes with chunkier buttons or different scrollbar
metrics, and brittle to any future toolbar additions.
Replace with `wrapper.get_preferred_size()`. GTK already aggregates
the canvas's size_request through ScrolledWindow + the editor VBox +
the wrapper's border into a natural size that accounts for every
contribution, including the toolbar's width. Drop the now-unused
`canvas_size()` shim from PerKeyEditor.
Previously the editor and palette listened to Gtk.Settings
notify::gtk-theme-name (and notify::gtk-application-prefer-dark-theme)
to re-render their themed icons on theme switch. Two problems:
1. The initial icon load happened during widget construction, before
the buttons were attached to the toolbar — so the style context
resolved to a default (often white) foreground rather than the
actual theme text color. Icons showed up white until the first
theme-change event.
2. Settings notify fires *before* GTK's CSS engine re-resolves styles
for the new theme. Reading the style context's foreground from
that handler returned the previous theme's color, so toggling
light <-> dark left both states settling on the same shade.
Move both responsibilities into a new attach_themed_icon helper in
_icons.py: it does the initial load, connects to the *button's own*
style-updated signal, and rebuilds the icon on each emission. That
signal fires *after* CSS resolution (both on first realize and on
runtime theme switches), so the foreground we read is always the
current one.
A per-button color-key guard skips the rebuild when the resolved
foreground hasn't changed, so unrelated style-updated emissions
(hover, focus, active) don't trigger needless re-renders.
The handler is connected to the button itself, so GTK cleans it up
when the button is destroyed; both editor.py and palette.py drop
their bespoke Gtk.Settings handler bookkeeping.
The "no change" hash overlay used a single black-or-white stripe
color picked by the base luminance, so the perceived average of an
unset cell was uniformly biased toward black or white instead of
sitting on the actual base color. That made dark base cells look
darker than they really are on the keyboard, and light ones lighter.
Draw two interleaved stripe sets at base ± offset (per channel),
spaced by half-period so the dark and light stripes alternate
evenly across the cell. Equal coverage of the two stripe colors
keeps the perceived average at base.
When a channel is too close to 0 or 1 to fit the full offset
(±0.22), halve the offset on the constrained side. The cell's
average then drifts at the limits but stays centered on base
everywhere else — verified visually across mid-tones, primaries,
and near-black/white bases.
The "no zone base color known" path keeps the previous neutral-grey
look unchanged; the average-preservation property only applies when
there is a base color to preserve.
The "Unset" toggle button next to the color picker used a custom
HashSwatch drawing area that mirrored the canvas's diagonal-hash
pattern for unset cells. The pattern was visually noisy at button
size and tied the button's appearance to the current zone base
color (the swatch had to be told the base color via
set_zone_base_color so it could redraw).
Use the Tabler "palette-off" symbolic icon instead. It reads as
"clear / no paint" at a glance, is independent of the zone base
color, and matches the icon style of the tool buttons on the other
end of the toolbar.
Extract the icon loader (themed_icon_image, ensure_icon_path) from
editor.py into a new private module _icons.py so palette.py can
reuse it; the new palette tracks its own Gtk.Settings notify signals
to re-render the icon on theme switches, and disconnects them via a
new Palette.shutdown() called from the editor's shutdown.
Adds MIT-licensed Tabler palette-off icon under share/solaar/icons/,
see THIRD_PARTY.md.
Tool icon pixbufs are baked at construction time using the button's
current style context foreground color. Without this, switching the
desktop theme (e.g. light <-> dark) at runtime leaves the editor's
icons stuck in the previous color while the rest of the UI updates.
Subscribe to Gtk.Settings notify::gtk-theme-name and
notify::gtk-application-prefer-dark-theme; on either, replace each
themed button's child Gtk.Image with a freshly-recolored one.
Disconnect both signals from PerKeyEditor.shutdown so the editor
doesn't leak handlers across openings.
The Brush / Rect / Fill tool buttons in the per-key color editor now
show icons instead of text labels, with the original labels retained
as tooltips and accessible names.
Icons are recolored at load time to match the active GTK theme's text
foreground (light grey on dark themes, dark on light), by reading the
button's style context and substituting currentColor in the SVG before
loading the pixbuf. GTK's stock symbolic loader is bypassed because it
only recolors fill palette stand-ins and ignores stroke="currentColor",
which is what the source SVGs use.
Adds MIT-licensed Tabler icons under share/solaar/icons/, see
THIRD_PARTY.md.
Read HID++ feature 0x4540 KeyboardLayout to detect the device's country
code, then route the per-key painter to a matching regional layout.
Changes:
- lib/logitech_receiver/hidpp20.py: new get_keyboard_layout() returning the
HID Usage Table country code from feature 0x4540's first response byte.
- lib/logitech_receiver/device.py: lazy device.keyboard_layout property,
guarded by feature presence so devices without 0x4540 don't pay a query
cost on access.
- lib/solaar/ui/perkey/control.py: thread the country code into the editor
hint dict.
- lib/solaar/ui/perkey/layouts/_keyboard_base.py (new): factor out the
function row, nav cluster, and numpad block as shared building blocks.
Two main-block variants (ANSI with row 2 col 13 backslash, ISO without)
cover all five regions. build_layout() applies per-zone label overrides
on top of either main block.
- lib/solaar/ui/perkey/layouts/keyboard_ansi.py: refactored to use the
builder; same LAYOUT_FULL/LAYOUT_TKL exports.
- lib/solaar/ui/perkey/layouts/keyboard_iso_qwerty.py (new): UK English
ISO. Same shape as DE/FR/JIS but no label overrides.
- lib/solaar/ui/perkey/layouts/keyboard_iso_qwertz.py (new): DE/Swiss --
Y/Z swap, Ü/Ö/Ä/ß placement.
- lib/solaar/ui/perkey/layouts/keyboard_iso_azerty.py (new): FR -- A↔Q,
W↔Z, French digit-row symbols, M repositioning.
- lib/solaar/ui/perkey/layouts/keyboard_jis.py (new): JP -- @ / [ / :
bracket-row relabels.
- lib/solaar/ui/perkey/layouts/__init__.py: country-code-aware matchers,
five families × two sizes (full/TKL). Defaults to ANSI when 0x4540 is
unsupported or returns an unknown code.
POUND, ISO_BACKSLASH, and the L-shape Enter top half (zone 46) are
intentionally omitted from the ISO layouts -- same coverage as OpenRGB.
ABNT2 (Brazilian) deferred until a confirmed Logitech BR RGB device shows
up; adding it later is one new layout file plus a country-code entry.
Also fix copyright headers on all new lib/solaar/ui/perkey/ files: the
files were created in 2026, not 2024 as the headers said.
Replace the per-key dropdown UI (MapChoiceControl) with a Cairo-rendered
keyboard canvas where users can paint colors directly onto keys.
Editor (lib/solaar/ui/perkey/):
- Cairo DrawingArea renders cells from a Layout dataclass; bound cells
take their painted color, unset cells show a diagonal hash whose base
color matches the device's rgb_zone_* setting.
- Tools: brush, drag-rectangle, flood-fill (4-adjacent, Paint-style),
and a directional gradient (line A->B projected across the matrix
with cells past the endpoints clamped to the endpoint colors).
- GradientSwatch is the single source of truth for the gradient's two
colors; the canvas reads from it on each gradient stroke.
- Palette: GTK ColorButton plus an unset toggle that paints the
"no change" sentinel (-1).
- PerKeyEditorDialog auto-sizes from the canvas's size_request, so a
104-key keyboard opens wide and a 8-LED mouse opens compact.
- Editor consumes only a narrow PerKeyColorSink protocol; never imports
from lib/logitech_receiver, preserving the FE/BE seam.
- Per-device palette state (active + previous color) persists via the
existing persister under a _palette: prefixed key.
Layouts:
- ANSI 104-key full-size and TKL keyboard layouts.
- G502 X family mouse layout (zones 1-8 -> labels A-H).
- Generic registry: register_layout(feature, matcher, layout). A
_name_contains() helper builds case-insensitive substring matchers
against device codename / name. Unknown devices fall back to a flat
strip of all reported zones.
Validator (open value space):
- New Range dataclass and MapRangeValidator extending Validator
directly (kind=MAP_CHOICE for dispatch compatibility). Replaces the
ChoicesMapValidator on PerKeyLighting -- the named-color universe
(COLORSPLUS) was rejecting any picker color outside its ~20 entries.
Other MAP_CHOICE settings are untouched.
Integration:
- Setting base gains an editor_class string attribute. config_panel's
_create_sbox resolves it via importlib before the kind dispatch, so
PerKeyLighting routes to the new editor without a new Kind value.
- CLI gains a hex/dec parser for open-value MAP_CHOICE settings:
solaar config <dev> per-key-lighting A 0xFF00FF
- Diversion rule editor skips Range-valued MAP_CHOICE settings'
value-selector instead of crashing on the open value space.
- pycairo declared in install_requires; transitively present on most
systems but now explicit for pip-from-source installs.
Tests in test_setting_templates.py updated for the new validator.