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