## 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. """Paint tools for the per-key editor. Each tool is a stateless policy object. The Canvas owns per-stroke state (press cell, motion cell, brush path) and asks the active tool for the final delta on release. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable from typing import Protocol from .layout import BoundCell from .layout import Cell @dataclass class ToolContext: active_color: int last_color: int cells_by_zone: dict[int, BoundCell] # zone ids that live in the bottom strip (e.g. logo, G-keys); kept separate # because their on-screen position is decoupled from the matrix grid. strip_zones: frozenset = frozenset() # zone_id -> current packed RGB (or -1 sentinel for unset). Used by tools # that need to compare colors, like the flood-fill bucket. current_colors: dict = None # type: ignore[assignment] def __post_init__(self) -> None: if self.current_colors is None: self.current_colors = {} def bound_cells(self) -> list[BoundCell]: return list(self.cells_by_zone.values()) def matrix_cells(self) -> list[BoundCell]: cells = [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.zone_id not in self.strip_zones] if cells: return cells # No matrix region (e.g. a mouse, where every zone lives in the # strip). Fall back to all bound cells so directional tools still # have something to project across. return [bc for bc in self.cells_by_zone.values() if bc.bound] def cells_in_bbox(self, a: BoundCell, b: BoundCell) -> list[BoundCell]: cx_a, cy_a = _cell_center(a.cell) cx_b, cy_b = _cell_center(b.cell) x0, x1 = (cx_a, cx_b) if cx_a <= cx_b else (cx_b, cx_a) y0, y1 = (cy_a, cy_b) if cy_a <= cy_b else (cy_b, cy_a) result = [] for bc in self.cells_by_zone.values(): if not bc.bound: continue cx, cy = _cell_center(bc.cell) if x0 <= cx <= x1 and y0 <= cy <= y1: result.append(bc) return result def cells_in_bbox_ordered(self, a: BoundCell, b: BoundCell) -> list[BoundCell]: cells = self.cells_in_bbox(a, b) return sorted(cells, key=lambda c: (c.cell.row, c.cell.col)) def cells_in_group(self, group: str) -> list[BoundCell]: return [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.group == group] def _cell_center(cell: Cell) -> tuple[float, float]: return (cell.col + cell.width / 2.0, cell.row + cell.height / 2.0) def _cells_touch(a: Cell, b: Cell) -> bool: """Bounding-box edge adjacency in grid units. Handles variable widths (Space spans multiple cols, Numpad+ spans multiple rows). """ a_c1, a_r1 = a.col + a.width, a.row + a.height b_c1, b_r1 = b.col + b.width, b.row + b.height rows_overlap = a.row < b_r1 and b.row < a_r1 cols_overlap = a.col < b_c1 and b.col < a_c1 if (a_c1 == b.col or b_c1 == a.col) and rows_overlap: return True if (a_r1 == b.row or b_r1 == a.row) and cols_overlap: return True return False class Tool(Protocol): name: str is_brush: bool overlay_shape: str # "" | "rect" def compute( self, start: BoundCell | None, end: BoundCell | None, path: Iterable[int], ctx: ToolContext, ) -> dict[int, int]: ... class SingleTool: name = "single" is_brush = True overlay_shape = "" def compute(self, start, end, path, ctx): return {z: ctx.active_color for z in path} class RectTool: name = "rect" is_brush = False overlay_shape = "rect" def compute(self, start, end, path, ctx): if start is None or end is None: return {} return {bc.cell.zone_id: ctx.active_color for bc in ctx.cells_in_bbox(start, end)} class BucketTool: """Flood-fill: replace the clicked cell's color in every connected cell of the same color (4-adjacent on the matrix grid). Strip cells aren't on the matrix grid, so clicking one paints just that cell. """ name = "bucket" is_brush = False overlay_shape = "" def compute(self, start, end, path, ctx): if start is None: return {} new_color = ctx.active_color target = ctx.current_colors.get(start.cell.zone_id, -1) if target == new_color: return {} if start.cell.zone_id in ctx.strip_zones: return {start.cell.zone_id: new_color} # BFS over matrix cells cells_by_id = {z: bc for z, bc in ctx.cells_by_zone.items() if bc.bound and z not in ctx.strip_zones} visited = {start.cell.zone_id} stack = [start] result: dict[int, int] = {} while stack: bc = stack.pop() result[bc.cell.zone_id] = new_color for other in cells_by_id.values(): if other.cell.zone_id in visited: continue if ctx.current_colors.get(other.cell.zone_id, -1) != target: continue if not _cells_touch(bc.cell, other.cell): continue visited.add(other.cell.zone_id) stack.append(other) return result class GradientTool: """Directional gradient: drag a line from A to B, the whole matrix gets a gradient at that angle. Cells projecting before A clamp to the previous color; cells past B clamp to the active color. """ name = "gradient" is_brush = False overlay_shape = "line" def compute(self, start, end, path, ctx): if start is None or end is None: return {} ax, ay = _cell_center(start.cell) bx, by = _cell_center(end.cell) vx, vy = bx - ax, by - ay length_sq = vx * vx + vy * vy if length_sq == 0: return {start.cell.zone_id: ctx.active_color} result: dict[int, int] = {} for bc in ctx.matrix_cells(): cx, cy = _cell_center(bc.cell) t = ((cx - ax) * vx + (cy - ay) * vy) / length_sq t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t result[bc.cell.zone_id] = _lerp_rgb(ctx.last_color, ctx.active_color, t) return result def _lerp_rgb(c0: int, c1: int, t: float) -> int: if c0 < 0: c0 = c1 if c1 < 0: c1 = c0 r0, g0, b0 = (c0 >> 16) & 0xFF, (c0 >> 8) & 0xFF, c0 & 0xFF r1, g1, b1 = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF r = int(round(r0 + (r1 - r0) * t)) g = int(round(g0 + (g1 - g0) * t)) b = int(round(b0 + (b1 - b0) * t)) return (r << 16) | (g << 8) | b TOOLS: dict[str, Tool] = { "single": SingleTool(), "rect": RectTool(), "bucket": BucketTool(), "gradient": GradientTool(), }