'''
Color Picker
============
.. versionadded:: 1.7.0
.. warning::
This widget is experimental. Its use and API can change at any time until
this warning is removed.
.. image:: images/colorpicker.png
:align: right
The ColorPicker widget allows a user to select a color from a chromatic
wheel where pinch and zoom can be used to change the wheel's saturation.
Sliders and TextInputs are also provided for entering the RGBA/HSV/HEX values
directly.
Usage::
clr_picker = ColorPicker()
parent.add_widget(clr_picker)
# To monitor changes, we can bind to color property changes
def on_color(instance, value):
print("RGBA = ", str(value)) # or instance.color
print("HSV = ", str(instance.hsv))
print("HEX = ", str(instance.hex_color))
clr_picker.bind(color=on_color)
'''
__all__ = ('ColorPicker', 'ColorWheel')
from math import cos, sin, pi, sqrt, atan
from colorsys import rgb_to_hsv, hsv_to_rgb
from string import hexdigits
from kivy.clock import Clock
from kivy.graphics import Mesh, InstructionGroup, Color
from kivy.logger import Logger
from kivy.properties import (NumericProperty, BoundedNumericProperty,
ListProperty, ObjectProperty,
ReferenceListProperty, StringProperty,
AliasProperty)
from kivy.uix.relativelayout import RelativeLayout
from kivy.uix.textinput import TextInput
from kivy.uix.widget import Widget
from kivy.utils import get_color_from_hex, get_hex_from_color
class ColorHexInput(TextInput):
# TextInput specialized for hex color codes (#RRGGBBAA).
# Automatically prepends '#' and limits input to 8 hexadecimal characters.
color_picker = ObjectProperty(None) # Reference to parent ColorPicker
def _filter_hex(self, substring, undo):
"""Filter input to allow only '#' + up to 8 hex digits."""
# Allow undo operations to proceed without filtering
if undo:
return substring
current_text = self.text
# Already at max length (9 = '#' + 8 hex chars)
if len(current_text) >= 9:
return ''
result = ''
for char in substring:
if char == '#':
# Only allow '#' at the beginning
if not current_text and not result:
result = char
elif char in hexdigits:
# Auto-add '#' if first character is a hex digit
if not current_text and not result:
result = '#'
result += char
# Stop if we've reached max length
if len(current_text) + len(result) >= 9:
break
else:
# Stop on invalid character
break
return result
def on_focus(self, instance, focus):
# When focus is lost, restore valid hex color if text is incomplete.
if not focus and len(self.text) < 9:
# Restore text to valid hex color from ColorPicker
if self.color_picker:
self.text = self.color_picker.hex_color
# Trigger color update in ColorPicker (in case text didn't change)
self.color_picker._trigger_update_hex(self.color_picker.hex_color)
class ColorNumericInput(TextInput):
# TextInput specialized for numeric color values (0-255).
#
# Limits input to integers from 0 to 255.
# Leading zeros are rejected.
def _filter_numeric(self, substring, undo):
"""Filter input to allow only integers 0-255."""
# Allow undo operations to proceed without filtering
if undo:
return substring
current_text = self.text
result = substring if substring.isdecimal() else ''
# 1) Check for leading zeros in the pasted content
# '01', '001', etc. should be rejected
# 2) Check if current text is '0' and we're trying to add more digits
# This would create a leading zero: '0' + '5' = '05'
# 3) Check if we're trying to insert a zero at the start of text
# 4) Check if there's no text to add
if any((len(result) > 1 and result[0] == '0', #1 ) no pasted leading zeros
current_text == '0' and result and self.cursor_col != 0, # 2)
result == '0' and current_text and self.cursor_col == 0, # 3)
not result)): # 4) nothing to add
return ''
# Validate the individual value
value = int(result)
if value > 255:
return ''
# Calculate what the final text will be after inserting at cursor position
final_text = (current_text[:self.cursor_col] +
result +
current_text[self.cursor_col:])
# Validate the final value wouldn't exceed 255
if final_text:
if int(final_text) > 255:
return ''
return result
def distance(pt1, pt2):
return sqrt((pt1[0] - pt2[0]) ** 2. + (pt1[1] - pt2[1]) ** 2.)
def polar_to_rect(origin, r, theta):
return origin[0] + r * cos(theta), origin[1] + r * sin(theta)
def rect_to_polar(origin, x, y):
if x == origin[0]:
if y == origin[1]:
return 0, 0
elif y > origin[1]:
return y - origin[1], pi / 2
else:
return origin[1] - y, 3 * pi / 2
t = atan(float((y - origin[1])) / (x - origin[0]))
if x - origin[0] < 0:
t += pi
if t < 0:
t += 2 * pi
return distance((x, y), origin), t
[docs]
class ColorWheel(Widget):
'''Chromatic wheel for the ColorPicker.
.. versionchanged:: 1.7.1
`font_size`, `font_name` and `foreground_color` have been removed. The
sizing is now the same as others widget, based on 'sp'. Orientation is
also automatically determined according to the width/height ratio.
'''
r = BoundedNumericProperty(0, min=0, max=1)
'''The Red value of the color currently selected.
:attr:`r` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
g = BoundedNumericProperty(0, min=0, max=1)
'''The Green value of the color currently selected.
:attr:`g` is a :class:`~kivy.properties.BoundedNumericProperty`
and can be a value from 0 to 1. It defaults to 0.
'''
b = BoundedNumericProperty(0, min=0, max=1)
'''The Blue value of the color currently selected.
:attr:`b` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
a = BoundedNumericProperty(0, min=0, max=1)
'''The Alpha value of the color currently selected.
:attr:`a` is a :class:`~kivy.properties.BoundedNumericProperty` and
can be a value from 0 to 1. It defaults to 0.
'''
color = ReferenceListProperty(r, g, b, a)
'''The holds the color currently selected.
:attr:`color` is a :class:`~kivy.properties.ReferenceListProperty` and
contains a list of `r`, `g`, `b`, `a` values. It defaults to `[0, 0, 0, 0]`.
'''
_origin = ListProperty((100, 100))
_radius = NumericProperty(100)
_piece_divisions = NumericProperty(10)
_pieces_of_pie = NumericProperty(16)
_inertia_slowdown = 1.25
_inertia_cutoff = .25
_num_touches = 0
_pinch_flag = False
def __init__(self, **kwargs):
self.arcs = []
self.sv_idx = 0
pdv = self._piece_divisions
self.sv_s = [(float(x) / pdv, 1) for x in range(pdv)] + [
(1, float(y) / pdv) for y in reversed(range(pdv))]
super(ColorWheel, self).__init__(**kwargs)
def on__origin(self, _instance, _value):
self._reset_canvas()
def on__radius(self, _instance, _value):
self._reset_canvas()
def _reset_canvas(self):
# initialize list to hold all meshes
self.canvas.clear()
self.arcs = []
self.sv_idx = 0
pdv = self._piece_divisions
ppie = self._pieces_of_pie
for r in range(pdv):
for t in range(ppie):
self.arcs.append(
_ColorArc(
self._radius * (float(r) / float(pdv)),
self._radius * (float(r + 1) / float(pdv)),
2 * pi * (float(t) / float(ppie)),
2 * pi * (float(t + 1) / float(ppie)),
origin=self._origin,
color=(float(t) / ppie,
self.sv_s[self.sv_idx + r][0],
self.sv_s[self.sv_idx + r][1],
1)))
self.canvas.add(self.arcs[-1])
def recolor_wheel(self):
ppie = self._pieces_of_pie
for idx, segment in enumerate(self.arcs):
segment.change_color(
sv=self.sv_s[int(self.sv_idx + idx / ppie)])
def change_alpha(self, val):
for idx, segment in enumerate(self.arcs):
segment.change_color(a=val)
def inertial_incr_sv_idx(self, dt):
# if its already zoomed all the way out, cancel the inertial zoom
if self.sv_idx == len(self.sv_s) - self._piece_divisions:
return False
self.sv_idx += 1
self.recolor_wheel()
if dt * self._inertia_slowdown > self._inertia_cutoff:
return False
else:
Clock.schedule_once(self.inertial_incr_sv_idx,
dt * self._inertia_slowdown)
def inertial_decr_sv_idx(self, dt):
# if its already zoomed all the way in, cancel the inertial zoom
if self.sv_idx == 0:
return False
self.sv_idx -= 1
self.recolor_wheel()
if dt * self._inertia_slowdown > self._inertia_cutoff:
return False
else:
Clock.schedule_once(self.inertial_decr_sv_idx,
dt * self._inertia_slowdown)
[docs]
def on_touch_down(self, touch):
r = self._get_touch_r(touch.pos)
if r > self._radius:
return False
# code is still set up to allow pinch to zoom, but this is
# disabled for now since it was fiddly with small wheels.
# Comment out these lines and adjust on_touch_move to reenable
# this.
if self._num_touches != 0:
return False
touch.grab(self)
self._num_touches += 1
touch.ud['anchor_r'] = r
touch.ud['orig_sv_idx'] = self.sv_idx
touch.ud['orig_time'] = Clock.get_time()
[docs]
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
r = self._get_touch_r(touch.pos)
goal_sv_idx = (touch.ud['orig_sv_idx'] -
int((r - touch.ud['anchor_r']) /
(float(self._radius) / self._piece_divisions)))
if (
goal_sv_idx != self.sv_idx and
0 <= goal_sv_idx <= len(self.sv_s) - self._piece_divisions
):
# this is a pinch to zoom
self._pinch_flag = True
self.sv_idx = goal_sv_idx
self.recolor_wheel()
[docs]
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
self._num_touches -= 1
if self._pinch_flag:
if self._num_touches == 0:
# user was pinching, and now both fingers are up. Return
# to normal
if self.sv_idx > touch.ud['orig_sv_idx']:
Clock.schedule_once(
self.inertial_incr_sv_idx,
(Clock.get_time() - touch.ud['orig_time']) /
(self.sv_idx - touch.ud['orig_sv_idx']))
if self.sv_idx < touch.ud['orig_sv_idx']:
Clock.schedule_once(
self.inertial_decr_sv_idx,
(Clock.get_time() - touch.ud['orig_time']) /
(self.sv_idx - touch.ud['orig_sv_idx']))
self._pinch_flag = False
return
else:
# user was pinching, and at least one finger remains. We
# don't want to treat the remaining fingers as touches
return
else:
r, theta = rect_to_polar(self._origin, *touch.pos)
# if touch up is outside the wheel, ignore
if r >= self._radius:
return
# compute which ColorArc is being touched (they aren't
# widgets so we don't get collide_point) and set
# _hsv based on the selected ColorArc
piece = int((theta / (2 * pi)) * self._pieces_of_pie)
division = int((r / self._radius) * self._piece_divisions)
hsva = list(
self.arcs[self._pieces_of_pie * division + piece].color)
self.color = list(hsv_to_rgb(*hsva[:3])) + hsva[-1:]
def _get_touch_r(self, pos):
return distance(pos, self._origin)
class _ColorArc(InstructionGroup):
def __init__(self, r_min, r_max, theta_min, theta_max,
color=(0, 0, 1, 1), origin=(0, 0), **kwargs):
super(_ColorArc, self).__init__(**kwargs)
self.origin = origin
self.r_min = r_min
self.r_max = r_max
self.theta_min = theta_min
self.theta_max = theta_max
self.color = color
self.color_instr = Color(*color, mode='hsv')
self.add(self.color_instr)
self.mesh = self.get_mesh()
self.add(self.mesh)
def __str__(self):
return "r_min: %s r_max: %s theta_min: %s theta_max: %s color: %s" % (
self.r_min, self.r_max, self.theta_min, self.theta_max, self.color
)
def get_mesh(self):
v = []
# first calculate the distance between endpoints of the outer
# arc, so we know how many steps to use when calculating
# vertices
theta_step_outer = 0.1
theta = self.theta_max - self.theta_min
d_outer = int(theta / theta_step_outer)
theta_step_outer = theta / d_outer
if self.r_min == 0:
for x in range(0, d_outer, 2):
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + x * theta_step_outer
) * 2)
v += polar_to_rect(self.origin, 0, 0) * 2
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + (x + 1) * theta_step_outer
) * 2)
if not d_outer & 1: # add a last point if d_outer is even
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + d_outer * theta_step_outer
) * 2)
else:
for x in range(d_outer + 1):
v += (polar_to_rect(self.origin, self.r_min,
self.theta_min + x * theta_step_outer
) * 2)
v += (polar_to_rect(self.origin, self.r_max,
self.theta_min + x * theta_step_outer
) * 2)
return Mesh(vertices=v, indices=range(int(len(v) / 4)),
mode='triangle_strip')
def change_color(self, color=None, color_delta=None, sv=None, a=None):
self.remove(self.color_instr)
if color is not None:
self.color = color
elif color_delta is not None:
self.color = [self.color[i] + color_delta[i] for i in range(4)]
elif sv is not None:
self.color = (self.color[0], sv[0], sv[1], self.color[3])
elif a is not None:
self.color = (self.color[0], self.color[1], self.color[2], a)
self.color_instr = Color(*self.color, mode='hsv')
self.insert(0, self.color_instr)
[docs]
class ColorPicker(RelativeLayout):
'''
See module documentation.
'''
font_name = StringProperty('data/fonts/RobotoMono-Regular.ttf')
'''Specifies the font used on the ColorPicker.
:attr:`font_name` is a :class:`~kivy.properties.StringProperty` and
defaults to 'data/fonts/RobotoMono-Regular.ttf'.
'''
color = ListProperty((1, 1, 1, 1))
'''The :attr:`color` holds the color currently selected in rgba format.
:attr:`color` is a :class:`~kivy.properties.ListProperty` and defaults to
(1, 1, 1, 1).
'''
def _get_hsv(self):
return rgb_to_hsv(*self.color[:3])
def _set_hsv(self, value):
if self._updating_clr:
return
self.set_color(value)
hsv = AliasProperty(_get_hsv, _set_hsv, bind=('color', ))
'''The :attr:`hsv` holds the color currently selected in hsv format.
:attr:`hsv` is a :class:`~kivy.properties.ListProperty` and defaults to
(1, 1, 1).
'''
def _get_hex(self):
return get_hex_from_color(self.color)
def _set_hex(self, value):
if self._updating_clr:
return
self.set_color(get_color_from_hex(value)[:4])
hex_color = AliasProperty(_get_hex, _set_hex, bind=('color',), cache=True)
'''The :attr:`hex_color` holds the currently selected color in hex.
:attr:`hex_color` is an :class:`~kivy.properties.AliasProperty` and
defaults to `#ffffffff`.
'''
wheel = ObjectProperty(None)
'''The :attr:`wheel` holds the color wheel.
:attr:`wheel` is an :class:`~kivy.properties.ObjectProperty` and
defaults to None.
'''
_update_clr_ev = _update_hex_ev = None
# now used only internally.
foreground_color = ListProperty((1, 1, 1, 1))
def _trigger_update_clr(self, mode, clr_idx, text):
# Always update the pending value (handle rapid on_text events during paste)
# Normalize: slider sends float, TextInput sends string
self._upd_clr_list = mode, clr_idx, str(text)
if self._updating_clr:
# Update already scheduled, just updated the value
return
self._updating_clr = True
ev = self._update_clr_ev
if ev is None:
ev = self._update_clr_ev = Clock.create_trigger(self._update_clr)
ev()
def _update_clr(self, dt):
# to prevent interaction between hsv/rgba, we work internally using rgba
mode, clr_idx, text = self._upd_clr_list
try:
# Treat empty string as 0
if not text:
text = 0.0
else:
text = min(255.0, max(0.0, float(text)))
if mode == 'rgb':
self.color[clr_idx] = text / 255
else:
hsv = list(self.hsv[:])
hsv[clr_idx] = text / 255
self.color[:3] = hsv_to_rgb(*hsv)
except ValueError:
# Invalid value - default to 0
if mode == 'rgb':
self.color[clr_idx] = 0.0
else:
hsv = list(self.hsv[:])
hsv[clr_idx] = 0.0
self.color[:3] = hsv_to_rgb(*hsv)
finally:
self._updating_clr = False
def _update_hex(self, dt):
try:
if len(self._upd_hex_list) != 9:
return
self._updating_clr = False
self.hex_color = self._upd_hex_list
finally:
self._updating_clr = False
def _trigger_update_hex(self, text):
# Always update the pending value (handle rapid on_text events during paste)
self._upd_hex_list = text
if self._updating_clr:
# Update already scheduled, just updated the value
return
self._updating_clr = True
ev = self._update_hex_ev
if ev is None:
ev = self._update_hex_ev = Clock.create_trigger(self._update_hex)
ev()
def set_color(self, color):
self._updating_clr = True
if len(color) == 3:
self.color[:3] = color
else:
self.color = color
self._updating_clr = False
def __init__(self, **kwargs):
self._updating_clr = False
super(ColorPicker, self).__init__(**kwargs)
if __name__ in ('__android__', '__main__'):
from kivy.app import App
class ColorPickerApp(App):
def build(self):
cp = ColorPicker(pos_hint={'center_x': .5, 'center_y': .5},
size_hint=(1, 1))
return cp
ColorPickerApp().run()