From c3382b0ba65324b8f1950b68b10891c15246cad1 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Mon, 11 May 2026 00:44:56 -0700 Subject: [PATCH] PerKey canvas: symmetric hash stripes for unset cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/solaar/ui/perkey/canvas.py | 80 +++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/lib/solaar/ui/perkey/canvas.py b/lib/solaar/ui/perkey/canvas.py index 101f21e6..d7b125f0 100644 --- a/lib/solaar/ui/perkey/canvas.py +++ b/lib/solaar/ui/perkey/canvas.py @@ -229,9 +229,12 @@ class KeyboardCanvas(Gtk.DrawingArea): logger.debug("text rendering failed for %r: %s", label, e) def _fill_checker(self, cr, x, y, w, h) -> None: - # Diagonal hash for "no change" cells. Background uses the zone base - # color (what these cells actually display on the keyboard); stripes - # pick a black or white contrast based on luminance. + # Diagonal hash for "no change" cells. The background is the zone + # base color (what these cells actually display on the keyboard). + # Stripes alternate darker / lighter than base in equal measure so + # the cell's perceived average stays at the base color, instead of + # being uniformly biased toward black or white as a single-overlay + # stripe would. cr.save() self._round_rect(cr, x, y, w, h, 4) cr.clip() @@ -240,26 +243,61 @@ class KeyboardCanvas(Gtk.DrawingArea): r = ((base >> 16) & 0xFF) / 255.0 g = ((base >> 8) & 0xFF) / 255.0 b = (base & 0xFF) / 255.0 + cr.set_source_rgba(r, g, b, 1.0) + cr.rectangle(x, y, w, h) + cr.fill() + # Per-channel ±offset. When a channel is too close to 0 or 1 to + # fit the full offset, halve the offset on the constrained side + # (per spec: average drifts at the limits, but stays centered on + # base elsewhere). + offset = 0.22 + + def _shift(v: float) -> tuple[float, float]: + down_off = offset if (v - offset) >= 0.0 else offset / 2.0 + up_off = offset if (v + offset) <= 1.0 else offset / 2.0 + return max(0.0, v - down_off), min(1.0, v + up_off) + + rd, ru = _shift(r) + gd, gu = _shift(g) + bd, bu = _shift(b) + # Interleave darker and lighter stripes (period = step per set, + # other-color set offset by step/2). Equal coverage of the two + # colors keeps the perceived average at base. + cr.set_line_width(1.5) + step = 6 + half = step // 2 + d_max = int(w + h) + cr.set_source_rgba(rd, gd, bd, 1.0) + d = -int(h) + while d <= d_max: + cr.move_to(x + d, y + h) + cr.line_to(x + d + h, y) + cr.stroke() + d += step + cr.set_source_rgba(ru, gu, bu, 1.0) + d = -int(h) + half + while d <= d_max: + cr.move_to(x + d, y + h) + cr.line_to(x + d + h, y) + cr.stroke() + d += step else: - r = g = 0.30 - b = 0.32 - cr.set_source_rgba(r, g, b, 1.0) - cr.rectangle(x, y, w, h) - cr.fill() - if base is not None and base >= 0: - lum = 0.299 * r + 0.587 * g + 0.114 * b - cr.set_source_rgba(0, 0, 0, 0.45) if lum > 0.55 else cr.set_source_rgba(1, 1, 1, 0.35) - else: + # No zone base color known — fall back to a neutral dark bg with + # medium-gray stripes; "average = base" doesn't apply since there + # is no expected color to preserve. + cr.set_source_rgba(0.30, 0.30, 0.32, 1.0) + cr.rectangle(x, y, w, h) + cr.fill() cr.set_source_rgba(0.55, 0.55, 0.60, 1.0) - cr.set_line_width(1.5) - step = 5 - d_max = int(w + h) - d = -int(h) - while d <= d_max: - cr.move_to(x + d, y + h) - cr.line_to(x + d + h, y) - cr.stroke() - d += step + cr.set_line_width(1.5) + step = 5 + d_max = int(w + h) + d = -int(h) + while d <= d_max: + cr.move_to(x + d, y + h) + cr.line_to(x + d + h, y) + cr.stroke() + d += step cr.restore() def _round_rect(self, cr, x, y, w, h, r) -> None: