Solaar/lib/solaar/ui/perkey/canvas.py

453 lines
17 KiB
Python

## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Cairo-rendered keyboard canvas. Renders a BoundLayout as colored rectangles
and dispatches paint events through a configurable Tool to the editor.
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Callable
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
from gi.repository import GObject # NOQA: E402
from gi.repository import Gtk # NOQA: E402
from .layout import BoundCell # NOQA: E402
from .layout import BoundLayout # NOQA: E402
from .layout import Cell # NOQA: E402
from .tools import TOOLS # NOQA: E402
from .tools import ToolContext # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
DRAW = "draw"
BUTTON_PRESS_EVENT = "button-press-event"
BUTTON_RELEASE_EVENT = "button-release-event"
MOTION_NOTIFY_EVENT = "motion-notify-event"
LEAVE_NOTIFY_EVENT = "leave-notify-event"
CELL_PX = 36
GUTTER_PX = 4
STRIP_GAP_PX = 16
PADDING_PX = 8
class KeyboardCanvas(Gtk.DrawingArea):
__gsignals__ = {
# Emitted on stroke release. delta is dict[zone_id, color].
"paint": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
}
def __init__(self) -> None:
super().__init__()
self._bound: BoundLayout | None = None
self._colors: dict[int, int] = {} # zone_id -> packed RGB or -1 (unset)
self._active_color: int = 0xFF0000
self._gradient_colors_source: Callable[[], tuple[int, int]] | None = None
self._zone_base_color: int | None = None
self._tool_name: str = "single"
self._press_cell: BoundCell | None = None
self._motion_cell: BoundCell | None = None
self._brush_path: list[int] = []
self._dragging: bool = False
self.set_can_focus(True)
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.connect(GtkSignal.DRAW.value, self._on_draw)
self.connect(GtkSignal.BUTTON_PRESS_EVENT.value, self._on_press)
self.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._on_release)
self.connect(GtkSignal.MOTION_NOTIFY_EVENT.value, self._on_motion)
self.connect(GtkSignal.LEAVE_NOTIFY_EVENT.value, self._on_leave)
# ---- public API ----
def set_layout(self, bound: BoundLayout) -> None:
self._bound = bound
self._update_size()
self.queue_draw()
def set_colors(self, colors: dict[int, int]) -> None:
self._colors = dict(colors)
self.queue_draw()
def update_colors(self, deltas: dict[int, int]) -> None:
self._colors.update(deltas)
self.queue_draw()
def set_active_color(self, color: int) -> None:
self._active_color = int(color)
def set_gradient_colors_source(self, source: Callable[[], tuple[int, int]] | None) -> None:
self._gradient_colors_source = source
def set_zone_base_color(self, color: int | None) -> None:
self._zone_base_color = None if color is None else int(color)
self.queue_draw()
def set_tool(self, name: str) -> None:
if name in TOOLS:
self._tool_name = name
# ---- size / hit-test ----
def _matrix_size(self) -> tuple[int, int]:
if not self._bound:
return 0, 0
max_col = 0
max_row = 0
for bc in self._bound.matrix:
c = bc.cell
max_col = max(max_col, c.col + int(round(c.width)))
max_row = max(max_row, c.row + int(round(c.height)))
return max_row, max_col
def _strip_size(self) -> int:
if not self._bound:
return 0
return len(self._bound.strip)
def _update_size(self) -> None:
rows, cols = self._matrix_size()
strip_n = self._strip_size()
w = PADDING_PX * 2 + cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_h = (CELL_PX + STRIP_GAP_PX) if strip_n else 0
h = PADDING_PX * 2 + matrix_h + strip_h
# widen if strip is wider than matrix
if strip_n:
sw = PADDING_PX * 2 + strip_n * CELL_PX + max(0, strip_n - 1) * GUTTER_PX
w = max(w, sw)
self.set_size_request(w, h)
def _cell_rect(self, bc: BoundCell) -> tuple[float, float, float, float]:
c = bc.cell
if self._bound is not None and bc in self._bound.strip:
# strip cells: laid out in a flat row beneath the matrix
rows, _cols = self._matrix_size()
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_idx = self._bound.strip.index(bc)
x = PADDING_PX + strip_idx * (CELL_PX + GUTTER_PX)
y = PADDING_PX + matrix_h + STRIP_GAP_PX
return (x, y, CELL_PX, CELL_PX)
x = PADDING_PX + c.col * (CELL_PX + GUTTER_PX)
y = PADDING_PX + c.row * (CELL_PX + GUTTER_PX)
w = c.width * CELL_PX + max(0.0, c.width - 1.0) * GUTTER_PX
h = c.height * CELL_PX + max(0.0, c.height - 1.0) * GUTTER_PX
return (x, y, w, h)
def _cell_at(self, x: float, y: float) -> BoundCell | None:
if not self._bound:
return None
for bc in list(self._bound.matrix) + list(self._bound.strip):
cx, cy, cw, ch = self._cell_rect(bc)
if cx <= x < cx + cw and cy <= y < cy + ch:
return bc
# Phantom anchor for gaps in the matrix grid — gives rect/gradient
# drags a valid endpoint where no real cell exists.
rows, cols = self._matrix_size()
matrix_w = cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
if PADDING_PX <= x < PADDING_PX + matrix_w and PADDING_PX <= y < PADDING_PX + matrix_h:
col = int((x - PADDING_PX) // (CELL_PX + GUTTER_PX))
row = int((y - PADDING_PX) // (CELL_PX + GUTTER_PX))
if 0 <= col < cols and 0 <= row < rows:
return BoundCell(cell=Cell(zone_id=-1, row=row, col=col), bound=False)
return None
# ---- draw ----
def _on_draw(self, _widget, cr) -> bool:
if not self._bound:
return False
for bc in self._bound.matrix:
self._draw_cell(cr, bc)
for bc in self._bound.strip:
self._draw_cell(cr, bc)
if self._dragging and self._press_cell and self._motion_cell:
tool = TOOLS.get(self._tool_name)
if tool is not None:
if tool.overlay_shape == "rect":
self._draw_rect_overlay(cr, self._press_cell, self._motion_cell)
elif tool.overlay_shape == "line":
self._draw_line_overlay(cr, self._press_cell, self._motion_cell)
return False
def _draw_cell(self, cr, bc: BoundCell) -> None:
x, y, w, h = self._cell_rect(bc)
color = self._colors.get(bc.cell.zone_id, -1)
# background
if not bc.bound:
cr.set_source_rgba(0.18, 0.18, 0.20, 1.0)
elif color is None or color < 0:
self._fill_checker(cr, x, y, w, h)
cr.set_source_rgba(0, 0, 0, 0) # no overlay fill
else:
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
cr.set_source_rgba(r, g, b, 1.0)
if bc.bound and (color is not None and color >= 0):
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
elif not bc.bound:
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
else:
self._round_rect(cr, x, y, w, h, 4)
# border
cr.set_source_rgba(0, 0, 0, 0.55)
cr.set_line_width(1.0)
cr.stroke()
# label
label = bc.cell.label or str(bc.cell.zone_id)
cr.set_source_rgba(*self._label_color(color, bc.bound))
cr.select_font_face("Sans")
cr.set_font_size(11.0 if len(label) <= 3 else 9.0)
try:
extents = cr.text_extents(label)
tx = x + (w - extents.width) / 2 - extents.x_bearing
ty = y + (h + extents.height) / 2 - extents.y_bearing - extents.height
cr.move_to(tx, ty)
cr.show_text(label)
except Exception as e:
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. 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()
base = self._zone_base_color
if base is not None and base >= 0:
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:
# 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.restore()
def _round_rect(self, cr, x, y, w, h, r) -> None:
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -1.5708, 0)
cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
cr.arc(x + r, y + r, r, 3.1416, 4.7124)
cr.close_path()
def _label_color(self, color: int, bound: bool) -> tuple[float, float, float, float]:
if not bound:
return (0.50, 0.50, 0.52, 1.0)
if color is None or color < 0:
return (0.85, 0.85, 0.88, 1.0)
# luminance heuristic
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
lum = 0.299 * r + 0.587 * g + 0.114 * b
return (0, 0, 0, 1.0) if lum > 0.55 else (1, 1, 1, 1.0)
def _draw_rect_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
x0 = min(ax, bx) - 2
y0 = min(ay, by) - 2
x1 = max(ax + aw, bx + bw) + 2
y1 = max(ay + ah, by + bh) + 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.85)
cr.set_line_width(1.5)
cr.set_dash([4.0, 3.0])
cr.rectangle(x0, y0, x1 - x0, y1 - y0)
cr.stroke()
cr.set_dash([])
def _draw_line_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
ax_c, ay_c = ax + aw / 2, ay + ah / 2
bx_c, by_c = bx + bw / 2, by + bh / 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.95)
cr.set_line_width(2.0)
cr.set_dash([5.0, 3.0])
cr.move_to(ax_c, ay_c)
cr.line_to(bx_c, by_c)
cr.stroke()
cr.set_dash([])
# endpoint dots — solid so the anchors read clearly
for cx, cy in ((ax_c, ay_c), (bx_c, by_c)):
cr.arc(cx, cy, 4.0, 0, 6.283)
cr.fill()
# ---- input ----
def _on_press(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
# Endpoint tools (rect/gradient) anchor on cell centers regardless of
# bind state; brush/bucket need a real key to paint/flood.
if not (tool and tool.overlay_shape) and not bc.bound:
return False
self._press_cell = bc
self._motion_cell = bc
self._dragging = True
self._brush_path = [bc.cell.zone_id]
if tool is not None and tool.is_brush:
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_motion(self, _w, event: Gdk.EventMotion) -> bool:
if not self._dragging:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
if not (tool and tool.overlay_shape) and not bc.bound:
return False
if bc is self._motion_cell:
return False
self._motion_cell = bc
if tool is not None and tool.is_brush:
if bc.cell.zone_id not in self._brush_path:
self._brush_path.append(bc.cell.zone_id)
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_release(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1 or not self._dragging:
return False
self._dragging = False
if self._press_cell is None:
return False
if self._bound:
bound_zones = {bc.cell.zone_id: bc for bc in list(self._bound.matrix) + list(self._bound.strip)}
strip_zones = frozenset(bc.cell.zone_id for bc in self._bound.strip)
else:
bound_zones = {}
strip_zones = frozenset()
if self._tool_name == "gradient" and self._gradient_colors_source is not None:
grad_active, grad_previous = self._gradient_colors_source()
ctx = ToolContext(
active_color=int(grad_active),
last_color=int(grad_previous),
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
else:
ctx = ToolContext(
active_color=self._active_color,
last_color=self._active_color,
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
tool = TOOLS.get(self._tool_name)
delta: dict[int, int] = {}
if tool is not None:
delta = tool.compute(self._press_cell, self._motion_cell, list(self._brush_path), ctx)
self._press_cell = None
self._motion_cell = None
self._brush_path = []
self.queue_draw()
if delta:
self.update_colors(delta)
self.emit("paint", delta)
return True
def _on_leave(self, _w, _event) -> bool:
# don't cancel drags on leave; let the user re-enter
return False