Source code for simvx.core.ui.colour_picker

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


from __future__ import annotations

import colorsys
import logging

from ..descriptors import Property, Signal
from ..math.types import Vec2
from .core import Colour, Control

log = logging.getLogger(__name__)

__all__ = ["ColourPicker"]


# ============================================================================
# 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 """ # 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") # Layout constants _HUE_BAR_WIDTH = 25 _HUE_BAR_GAP = 5 _ALPHA_HEIGHT = 15 _ALPHA_GAP = 5 _HEX_ROW_HEIGHT = 30 _BOTTOM_MARGIN = 60 # alpha + gap + hex row def __init__(self, **kwargs): super().__init__(**kwargs) # Internal HSV + alpha state (all 0-1) self._hue = 0.0 self._saturation = 1.0 self._value = 1.0 self._alpha = 1.0 # Drag state self._dragging_sv = False self._dragging_hue = False self._dragging_alpha = False # Hex input state self._hex_text = "#FF0000" self._hex_editing = False self._hex_cursor = 7 # Signals self.colour_changed = Signal() # Default size self.size = Vec2(250, 300) # ----------------------------------------------------------------- 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) @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 _sv_rect(self) -> tuple[float, float, float, float]: """Get the saturation-value rectangle in screen coords.""" x, y, w, h = self.get_global_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 screen coords.""" x, y, w, h = self.get_global_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 screen coords.""" x, y, w, h = self.get_global_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 screen coords.""" x, y, w, h = self.get_global_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 def _on_gui_input(self, event): # Mouse input if event.button == 1: px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] if event.pressed: # Start drag or focus hex input 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) elif self._point_in_rect(px, py, *self._hue_rect()): self._dragging_hue = True self._hex_editing = False self._update_hue_from_mouse(py) elif self._point_in_rect(px, py, *self._alpha_rect()): self._dragging_alpha = True self._hex_editing = False self._update_alpha_from_mouse(px) elif self._point_in_rect(px, py, *self._hex_rect()): self._hex_editing = True self._hex_cursor = len(self._hex_text) self.set_focus() else: self._hex_editing = False else: # Release all drags self._dragging_sv = False self._dragging_hue = False self._dragging_alpha = False # Drag motion (mouse move with button held) if event.button == 0 and event.position: px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] 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) # Keyboard input for hex editing if self._hex_editing and self.focused: if event.key == "enter" and not event.pressed: self._commit_hex() elif event.key == "escape" and not event.pressed: self._hex_editing = False self.release_focus() elif event.key == "backspace" and not event.pressed: 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 == "left" and not event.pressed: self._hex_cursor = max(0, self._hex_cursor - 1) elif event.key == "right" and not event.pressed: self._hex_cursor = min(len(self._hex_text), self._hex_cursor + 1) elif event.char and event.char in "0123456789abcdefABCDEF#": if len(self._hex_text) < 7: self._hex_text = ( self._hex_text[: self._hex_cursor] + event.char + self._hex_text[self._hex_cursor :] ) self._hex_cursor += 1 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 draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_filled_rect(x, y, w, h, (0.08, 0.08, 0.09, 1.0)) renderer.draw_rect_coloured(x, y, w, h, (0.32, 0.32, 0.34, 1.0)) self._draw_sv_area(renderer) self._draw_hue_bar(renderer) 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.draw_filled_rect(cx, cy, cell_w + 1, cell_h + 1, (r, g, b, 1.0)) # Border around SV area renderer.draw_rect_coloured(sx, sy, sw, sh, (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.draw_line_coloured(marker_x - marker_size, marker_y, marker_x + marker_size, marker_y, marker_colour) renderer.draw_line_coloured(marker_x, marker_y - marker_size, marker_x, marker_y + marker_size, 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.draw_filled_rect(hx, hy + i * step_h, hw, step_h + 1, (r, g, b, 1.0)) # Border renderer.draw_rect_coloured(hx, hy, hw, hh, (0.38, 0.38, 0.40, 1.0)) # Hue marker (horizontal line indicator) marker_y = hy + self._hue * hh renderer.draw_filled_rect(hx - 2, marker_y - 2, hw + 4, 4, Colour.WHITE) renderer.draw_rect_coloured(hx - 2, marker_y - 2, hw + 4, 4, 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.draw_filled_rect(ax + col * check_size, ay + row * check_size, cw, ch, 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.draw_filled_rect(ax + i * step_w, ay, step_w + 1, ah, (r, g, b, t)) # Border renderer.draw_rect_coloured(ax, ay, aw, ah, (0.38, 0.38, 0.40, 1.0)) # Alpha position marker marker_x = ax + self._alpha * aw renderer.draw_filled_rect(marker_x - 2, ay - 2, 4, ah + 4, Colour.WHITE) renderer.draw_rect_coloured(marker_x - 2, ay - 2, 4, ah + 4, 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.draw_filled_rect(hx, hy, field_w, hh, 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.draw_rect_coloured(hx, hy, field_w, hh, border) # Hex text scale = 12.0 / 14.0 text_x = hx + 5 text_y = hy + (hh - 12.0) / 2 renderer.draw_text_coloured(self._hex_text, text_x, text_y, scale, Colour.WHITE) # 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.draw_line_coloured(cursor_x, hy + 4, cursor_x, hy + hh - 4, Colour.WHITE) # Colour preview swatch with checkerboard behind for alpha sx = swatch_x sy = hy + 2 # Checkerboard half = swatch_size // 2 renderer.draw_filled_rect(sx, sy, half, half, (0.6, 0.6, 0.6, 1.0)) renderer.draw_filled_rect(sx + half, sy, half, half, (0.32, 0.32, 0.34, 1.0)) renderer.draw_filled_rect(sx, sy + half, half, half, (0.32, 0.32, 0.34, 1.0)) renderer.draw_filled_rect(sx + half, sy + half, half, half, (0.6, 0.6, 0.6, 1.0)) # Colour overlay renderer.draw_filled_rect(sx, sy, swatch_size, swatch_size, self.colour) renderer.draw_rect_coloured(sx, sy, swatch_size, swatch_size, (0.38, 0.38, 0.40, 1.0))