Source code for simvx.core.ui.colour_picker

"""ColourPicker -- HSV colour picker with alpha slider and hex input."""

import colorsys
import logging

from ..descriptors import Property
from ..input.controls import Key, Pointer
from ..input.enums import PointerPhase
from ..input.events import KeyEvent, PointerEvent, TextEvent
from ..input.state import Input
from ..signals import Signal
from .core import Colour, Control, FocusMode
from .enums import PointerFilter

log = logging.getLogger(__name__)

__all__ = ["ColourPicker"]

#: What the hex field accepts as typed text.
_HEX_CHARS = "0123456789abcdefABCDEF#"

# ============================================================================
# HSV / RGB conversion helpers
# ============================================================================


def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
    """Convert HSV (all 0-1) to RGB (all 0-1)."""
    return colorsys.hsv_to_rgb(h, s, v)


def _rgb_to_hsv(r: float, g: float, b: float) -> tuple[float, float, float]:
    """Convert RGB (all 0-1) to HSV (all 0-1)."""
    return colorsys.rgb_to_hsv(r, g, b)


def _rgb_to_hex(r: float, g: float, b: float) -> str:
    """Convert RGB floats (0-1) to #RRGGBB hex string."""
    ri = max(0, min(255, int(round(r * 255))))
    gi = max(0, min(255, int(round(g * 255))))
    bi = max(0, min(255, int(round(b * 255))))
    return f"#{ri:02X}{gi:02X}{bi:02X}"


def _hex_to_rgb(h: str) -> tuple[float, float, float] | None:
    """Parse #RRGGBB hex string to RGB floats (0-1). Returns None on error."""
    h = h.strip().lstrip("#")
    if len(h) != 6:
        return None
    try:
        ri = int(h[0:2], 16)
        gi = int(h[2:4], 16)
        bi = int(h[4:6], 16)
        return (ri / 255.0, gi / 255.0, bi / 255.0)
    except ValueError:
        return None


# ============================================================================
# ColourPicker -- Full HSV picker with alpha and hex input
# ============================================================================


[docs] class ColourPicker(Control): """HSV colour picker with saturation-value area, hue bar, alpha slider, and editable hex input. Layout: - Main SV rectangle (left area) - Vertical hue bar (right strip) - Horizontal alpha slider (bottom strip) - Hex text input with colour preview swatch (bottom row) Example: picker = ColourPicker() picker.colour_changed.connect(lambda c: print("Colour:", c)) picker.colour = (1.0, 0.5, 0.0, 1.0) # orange """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") # A click on the hex field edits it, which needs the keyboard: the picker # takes focus from a press and from code, and is not a Tab stop. focus_mode = Property(FocusMode.CLICK, hint="Whether this control can take keyboard focus")
[docs] @property def wants_text_input(self) -> bool: """Whether the picker takes typing: only while its hex field is being edited.""" return self._hex_editing
# Editor-visible settings default_hue = Property(0.0, range=(0, 1), hint="Initial hue") default_saturation = Property(1.0, range=(0, 1), hint="Initial saturation") default_value = Property(1.0, range=(0, 1), hint="Initial value/brightness") default_alpha = Property(1.0, range=(0, 1), hint="Initial alpha") size_x = Property(250.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(300.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") # Layout constants _HUE_BAR_WIDTH = 25 _HUE_BAR_GAP = 5 _ALPHA_HEIGHT = 15 _ALPHA_GAP = 5 _HEX_ROW_HEIGHT = 30 _BOTTOM_MARGIN_WITH_ALPHA = 60 # alpha + gap + hex row _BOTTOM_MARGIN_NO_ALPHA = 40 # hex row only (no alpha slider) def __init__(self, *, has_alpha: bool = True, **kwargs): super().__init__(**kwargs) self.has_alpha = has_alpha # Internal HSV + alpha state (all 0-1), seeded from the declared starting # values so a picker authored on a colour opens on that colour. The # defaults reproduce the pure red the picker has always opened on. self._hue = float(self.default_hue) self._saturation = float(self.default_saturation) self._value = float(self.default_value) self._alpha = float(self.default_alpha) # Drag state self._dragging_sv = False self._dragging_hue = False self._dragging_alpha = False # Hex input state self._hex_text = _rgb_to_hex(*_hsv_to_rgb(self._hue, self._saturation, self._value)) self._hex_editing = False self._hex_cursor = 7 # Signals self.colour_changed = Signal() # ----------------------------------------------------------------- colour property @property def colour(self) -> tuple[float, float, float, float]: """Current colour as RGBA float tuple (0-1).""" r, g, b = _hsv_to_rgb(self._hue, self._saturation, self._value) return (r, g, b, self._alpha)
[docs] @colour.setter def colour(self, rgba: tuple[float, float, float, float]): """Set colour from RGBA float tuple (0-1).""" r, g, b = rgba[0], rgba[1], rgba[2] a = rgba[3] if len(rgba) > 3 else 1.0 h, s, v = _rgb_to_hsv(r, g, b) self._hue = h self._saturation = s self._value = v self._alpha = max(0.0, min(1.0, a)) self._hex_text = _rgb_to_hex(r, g, b) self.colour_changed.emit(self.colour)
# ----------------------------------------------------------------- layout helpers def _bottom_margin(self) -> int: return self._BOTTOM_MARGIN_WITH_ALPHA if self.has_alpha else self._BOTTOM_MARGIN_NO_ALPHA def _sv_rect(self) -> tuple[float, float, float, float]: """Get the saturation-value rectangle in local space.""" x, y, w, h = self._local_rect() sv_w = w - self._HUE_BAR_WIDTH - self._HUE_BAR_GAP sv_h = h - self._bottom_margin() return (x, y, sv_w, sv_h) def _hue_rect(self) -> tuple[float, float, float, float]: """Get the hue bar rectangle in local space.""" x, y, w, h = self._local_rect() bar_x = x + w - self._HUE_BAR_WIDTH bar_h = h - self._bottom_margin() return (bar_x, y, self._HUE_BAR_WIDTH, bar_h) def _alpha_rect(self) -> tuple[float, float, float, float]: """Get the alpha slider rectangle in local space. Returns a zero-sized rect when :attr:`has_alpha` is ``False``. """ if not self.has_alpha: return (0.0, 0.0, 0.0, 0.0) x, y, w, h = self._local_rect() slider_y = y + h - self._bottom_margin() + self._ALPHA_GAP return (x, slider_y, w, self._ALPHA_HEIGHT) def _hex_rect(self) -> tuple[float, float, float, float]: """Get the hex input row rectangle in local space.""" x, y, w, h = self._local_rect() row_y = y + h - self._HEX_ROW_HEIGHT return (x, row_y, w, self._HEX_ROW_HEIGHT) # ----------------------------------------------------------------- hit testing def _point_in_rect(self, px: float, py: float, rx: float, ry: float, rw: float, rh: float) -> bool: return rx <= px <= rx + rw and ry <= py <= ry + rh # ----------------------------------------------------------------- input
[docs] def on_gui_input(self, event): if isinstance(event, (KeyEvent, TextEvent)): return self._on_hex_key(event) if not isinstance(event, PointerEvent): return None # Carried rather than returned: a press that starts a drag is also its # first sample, which the motion block below reads. claimed = False # Mouse input if event.button in Pointer.PRIMARY: px, py = self.to_local(event.position) if event.phase is PointerPhase.DOWN: # Start drag or focus hex input. Each of these belongs to the # picker, so the press stops here rather than reaching past it. if self._point_in_rect(px, py, *self._sv_rect()): self._dragging_sv = True self._hex_editing = False self._update_sv_from_mouse(px, py) claimed = True elif self._point_in_rect(px, py, *self._hue_rect()): self._dragging_hue = True self._hex_editing = False self._update_hue_from_mouse(py) claimed = True elif self._point_in_rect(px, py, *self._alpha_rect()): self._dragging_alpha = True self._hex_editing = False self._update_alpha_from_mouse(px) claimed = True elif self._point_in_rect(px, py, *self._hex_rect()): self._hex_editing = True self._hex_cursor = len(self._hex_text) if self.focused: # Focus came before the edit began, so the platform's # text input is opened here rather than by a focus change. Input.begin_text_input(self.get_global_rect()) else: self.grab_focus() claimed = True else: self._hex_editing = False else: # Release all drags if self._dragging_sv or self._dragging_hue or self._dragging_alpha: claimed = True self._dragging_sv = False self._dragging_hue = False self._dragging_alpha = False # Drag motion (mouse move with button held) if event.phase is PointerPhase.MOVE: px, py = self.to_local(event.position) if self._dragging_sv: self._update_sv_from_mouse(px, py) elif self._dragging_hue: self._update_hue_from_mouse(py) elif self._dragging_alpha: self._update_alpha_from_mouse(px) return claimed
def _on_hex_key(self, event: KeyEvent | TextEvent) -> bool | None: """The keyboard half: only live while the hex field is being edited.""" if not (self._hex_editing and self.focused): return None if isinstance(event, TextEvent): typed = "".join(c for c in event.text if c in _HEX_CHARS)[: 7 - len(self._hex_text)] if typed and not event.composing: self._hex_text = self._hex_text[: self._hex_cursor] + typed + self._hex_text[self._hex_cursor :] self._hex_cursor += len(typed) return True # A key acts as it goes down and the press is claimed, so its release # comes back here rather than to whatever holds focus by then. if not event.pressed: return None if event.key is Key.ENTER: self._commit_hex() elif event.key is Key.ESCAPE: self._hex_editing = False self.release_focus() elif event.key is Key.BACKSPACE: if self._hex_cursor > 0: self._hex_text = self._hex_text[: self._hex_cursor - 1] + self._hex_text[self._hex_cursor :] self._hex_cursor -= 1 elif event.key is Key.LEFT: self._hex_cursor = max(0, self._hex_cursor - 1) elif event.key is Key.RIGHT: self._hex_cursor = min(len(self._hex_text), self._hex_cursor + 1) else: return None return True def _update_sv_from_mouse(self, px: float, py: float): """Update saturation and value from mouse position in SV rect.""" sx, sy, sw, sh = self._sv_rect() s = max(0.0, min(1.0, (px - sx) / max(sw, 1))) v = max(0.0, min(1.0, 1.0 - (py - sy) / max(sh, 1))) self._saturation = s self._value = v self._sync_hex() self.colour_changed.emit(self.colour) def _update_hue_from_mouse(self, py: float): """Update hue from mouse position in hue bar.""" _, hy, _, hh = self._hue_rect() self._hue = max(0.0, min(1.0, (py - hy) / max(hh, 1))) self._sync_hex() self.colour_changed.emit(self.colour) def _update_alpha_from_mouse(self, px: float): """Update alpha from mouse position in alpha slider.""" ax, _, aw, _ = self._alpha_rect() self._alpha = max(0.0, min(1.0, (px - ax) / max(aw, 1))) self.colour_changed.emit(self.colour) def _sync_hex(self): """Update hex text from current HSV state.""" r, g, b = _hsv_to_rgb(self._hue, self._saturation, self._value) self._hex_text = _rgb_to_hex(r, g, b) self._hex_cursor = len(self._hex_text) def _commit_hex(self): """Apply hex text input as new colour.""" rgb = _hex_to_rgb(self._hex_text) if rgb is not None: h, s, v = _rgb_to_hsv(*rgb) self._hue = h self._saturation = s self._value = v self._hex_text = _rgb_to_hex(*rgb) self.colour_changed.emit(self.colour) else: # Revert to current colour on invalid input self._sync_hex() self._hex_editing = False self.release_focus() # ----------------------------------------------------------------- drawing
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() # Background renderer.rect((x, y), (w, h), fill=(0.08, 0.08, 0.09, 1.0)) renderer.rect((x, y), (w, h), stroke=(0.32, 0.32, 0.34, 1.0)) self._draw_sv_area(renderer) self._draw_hue_bar(renderer) if self.has_alpha: self._draw_alpha_slider(renderer) self._draw_hex_row(renderer)
def _draw_sv_area(self, renderer): """Draw the saturation-value gradient grid with crosshair marker.""" sx, sy, sw, sh = self._sv_rect() grid = 16 cell_w = sw / grid cell_h = sh / grid for row in range(grid): for col in range(grid): s = (col + 0.5) / grid v = 1.0 - (row + 0.5) / grid r, g, b = _hsv_to_rgb(self._hue, s, v) cx = sx + col * cell_w cy = sy + row * cell_h renderer.rect((cx, cy), (cell_w + 1, cell_h + 1), fill=(r, g, b, 1.0)) # Border around SV area renderer.rect((sx, sy), (sw, sh), stroke=(0.38, 0.38, 0.40, 1.0)) # Crosshair marker at selected position marker_x = sx + self._saturation * sw marker_y = sy + (1.0 - self._value) * sh marker_size = 5 # Use contrasting colour for visibility based on underlying colour r, g, b = _hsv_to_rgb(self._hue, self._saturation, self._value) lum = 0.299 * r + 0.587 * g + 0.114 * b marker_colour = Colour.BLACK if lum > 0.5 else Colour.WHITE renderer.line((marker_x - marker_size, marker_y), (marker_x + marker_size, marker_y), stroke=marker_colour) renderer.line((marker_x, marker_y - marker_size), (marker_x, marker_y + marker_size), stroke=marker_colour) def _draw_hue_bar(self, renderer): """Draw the vertical hue spectrum bar with position marker.""" hx, hy, hw, hh = self._hue_rect() steps = 32 step_h = hh / steps for i in range(steps): hue = (i + 0.5) / steps r, g, b = _hsv_to_rgb(hue, 1.0, 1.0) renderer.rect((hx, hy + i * step_h), (hw, step_h + 1), fill=(r, g, b, 1.0)) # Border renderer.rect((hx, hy), (hw, hh), stroke=(0.38, 0.38, 0.40, 1.0)) # Hue marker (horizontal line indicator) marker_y = hy + self._hue * hh renderer.rect((hx - 2, marker_y - 2), (hw + 4, 4), fill=Colour.WHITE) renderer.rect((hx - 2, marker_y - 2), (hw + 4, 4), stroke=Colour.BLACK) def _draw_alpha_slider(self, renderer): """Draw the horizontal alpha slider with checkerboard background.""" ax, ay, aw, ah = self._alpha_rect() # Checkerboard pattern to indicate transparency check_size = 7 cols = max(1, int(aw / check_size)) rows = max(1, int(ah / check_size)) for row in range(rows): for col in range(cols): is_light = (row + col) % 2 == 0 shade = (0.6, 0.6, 0.6, 1.0) if is_light else (0.32, 0.32, 0.34, 1.0) cw = min(check_size, aw - col * check_size) ch = min(check_size, ah - row * check_size) renderer.rect((ax + col * check_size, ay + row * check_size), (cw, ch), fill=shade) # Gradient overlay from transparent to current colour r, g, b = _hsv_to_rgb(self._hue, self._saturation, self._value) grad_steps = 32 step_w = aw / grad_steps for i in range(grad_steps): t = (i + 0.5) / grad_steps renderer.rect((ax + i * step_w, ay), (step_w + 1, ah), fill=(r, g, b, t)) # Border renderer.rect((ax, ay), (aw, ah), stroke=(0.38, 0.38, 0.40, 1.0)) # Alpha position marker marker_x = ax + self._alpha * aw renderer.rect((marker_x - 2, ay - 2), (4, ah + 4), fill=Colour.WHITE) renderer.rect((marker_x - 2, ay - 2), (4, ah + 4), stroke=Colour.BLACK) def _draw_hex_row(self, renderer): """Draw the hex input field and colour preview swatch.""" hx, hy, hw, hh = self._hex_rect() swatch_size = hh - 4 swatch_x = hx + hw - swatch_size - 4 field_w = hw - swatch_size - 12 # Hex text field background bg = (0.07, 0.07, 0.08, 1.0) renderer.rect((hx, hy), (field_w, hh), fill=bg) # Border (highlight if editing) border = (0.5, 0.8, 1.0, 1.0) if self._hex_editing else (0.32, 0.32, 0.34, 1.0) renderer.rect((hx, hy), (field_w, hh), stroke=border) # Hex text scale = 12.0 / 16.0 text_x = hx + 5 text_y = hy + (hh - 12.0) / 2 renderer.text(self._hex_text, (text_x, text_y), fill=Colour.WHITE, scale=scale) # Cursor when editing if self._hex_editing and self.focused: cursor_x = text_x + renderer.text_width(self._hex_text[: self._hex_cursor], scale) renderer.line((cursor_x, hy + 4), (cursor_x, hy + hh - 4), stroke=Colour.WHITE) # Colour preview swatch with checkerboard behind for alpha sx = swatch_x sy = hy + 2 # Checkerboard half = swatch_size // 2 renderer.rect((sx, sy), (half, half), fill=(0.6, 0.6, 0.6, 1.0)) renderer.rect((sx + half, sy), (half, half), fill=(0.32, 0.32, 0.34, 1.0)) renderer.rect((sx, sy + half), (half, half), fill=(0.32, 0.32, 0.34, 1.0)) renderer.rect((sx + half, sy + half), (half, half), fill=(0.6, 0.6, 0.6, 1.0)) # Colour overlay renderer.rect((sx, sy), (swatch_size, swatch_size), fill=self.colour) renderer.rect((sx, sy), (swatch_size, swatch_size), stroke=(0.38, 0.38, 0.40, 1.0))