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