"""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))