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 ..signals import Signal
from ..math.types import Vec2
from .core import Colour, Control
from ..input.enums import MouseButton

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_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) 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)
[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 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. 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.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 == MouseButton.LEFT: 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 is None 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 on_draw(self, renderer): x, y, w, h = self.get_global_rect() # Background renderer.draw_rect((x, y), (w, h), colour=(0.08, 0.08, 0.09, 1.0), filled=True) renderer.draw_rect((x, y), (w, h), colour=(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.draw_rect((cx, cy), (cell_w + 1, cell_h + 1), colour=(r, g, b, 1.0), filled=True) # Border around SV area renderer.draw_rect((sx, sy), (sw, sh), colour=(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((marker_x - marker_size, marker_y), (marker_x + marker_size, marker_y), colour=marker_colour) renderer.draw_line((marker_x, marker_y - marker_size), (marker_x, marker_y + marker_size), colour=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_rect((hx, hy + i * step_h), (hw, step_h + 1), colour=(r, g, b, 1.0), filled=True) # Border renderer.draw_rect((hx, hy), (hw, hh), colour=(0.38, 0.38, 0.40, 1.0)) # Hue marker (horizontal line indicator) marker_y = hy + self._hue * hh renderer.draw_rect((hx - 2, marker_y - 2), (hw + 4, 4), colour=Colour.WHITE, filled=True) renderer.draw_rect((hx - 2, marker_y - 2), (hw + 4, 4), colour=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_rect((ax + col * check_size, ay + row * check_size), (cw, ch), colour=shade, filled=True) # 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_rect((ax + i * step_w, ay), (step_w + 1, ah), colour=(r, g, b, t), filled=True) # Border renderer.draw_rect((ax, ay), (aw, ah), colour=(0.38, 0.38, 0.40, 1.0)) # Alpha position marker marker_x = ax + self._alpha * aw renderer.draw_rect((marker_x - 2, ay - 2), (4, ah + 4), colour=Colour.WHITE, filled=True) renderer.draw_rect((marker_x - 2, ay - 2), (4, ah + 4), colour=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_rect((hx, hy), (field_w, hh), colour=bg, filled=True) # 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((hx, hy), (field_w, hh), colour=border) # Hex text scale = 12.0 / 14.0 text_x = hx + 5 text_y = hy + (hh - 12.0) / 2 renderer.draw_text(self._hex_text, (text_x, text_y), colour=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.draw_line((cursor_x, hy + 4), (cursor_x, hy + hh - 4), colour=Colour.WHITE) # Colour preview swatch with checkerboard behind for alpha sx = swatch_x sy = hy + 2 # Checkerboard half = swatch_size // 2 renderer.draw_rect((sx, sy), (half, half), colour=(0.6, 0.6, 0.6, 1.0), filled=True) renderer.draw_rect((sx + half, sy), (half, half), colour=(0.32, 0.32, 0.34, 1.0), filled=True) renderer.draw_rect((sx, sy + half), (half, half), colour=(0.32, 0.32, 0.34, 1.0), filled=True) renderer.draw_rect((sx + half, sy + half), (half, half), colour=(0.6, 0.6, 0.6, 1.0), filled=True) # Colour overlay renderer.draw_rect((sx, sy), (swatch_size, swatch_size), colour=self.colour, filled=True) renderer.draw_rect((sx, sy), (swatch_size, swatch_size), colour=(0.38, 0.38, 0.40, 1.0))