Skip to content

Commit f3387e9

Browse files
committed
Add support for Gradients
Signed-off-by: martinRenou <[email protected]>
1 parent 9b1bdf3 commit f3387e9

File tree

4 files changed

+372
-17
lines changed

4 files changed

+372
-17
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,4 @@ jobs:
4343

4444
- name: Test flake8
4545
shell: bash -l {0}
46-
run: flake8 ipycanvas --ignore=E501
46+
run: flake8 ipycanvas --ignore=E501,W504,W503

examples/Gradients.ipynb

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"metadata": {},
7+
"outputs": [],
8+
"source": [
9+
"from ipycanvas import Canvas"
10+
]
11+
},
12+
{
13+
"cell_type": "markdown",
14+
"metadata": {},
15+
"source": [
16+
"# Gradients\n",
17+
"\n",
18+
"Unlike the Web2DCanvas, the color stops must be passed as a list of color stops when creating the gradient. Once created, the gradient is read-only, it cannot be modified.\n",
19+
"\n",
20+
"## LinearGradient\n",
21+
"\n"
22+
]
23+
},
24+
{
25+
"cell_type": "code",
26+
"execution_count": null,
27+
"metadata": {},
28+
"outputs": [],
29+
"source": [
30+
"canvas = Canvas(width=700, height=50)"
31+
]
32+
},
33+
{
34+
"cell_type": "code",
35+
"execution_count": null,
36+
"metadata": {},
37+
"outputs": [],
38+
"source": [
39+
"gradient = canvas.create_linear_gradient(\n",
40+
" 0, 0, # Start position (x0, y0)\n",
41+
" 700, 0, # End position (x1, y1)\n",
42+
" # List of color stops\n",
43+
" [\n",
44+
" (0, 'red'),\n",
45+
" (1 / 6, 'orange'), \n",
46+
" (2 / 6, 'yellow'),\n",
47+
" (3 / 6, 'green'),\n",
48+
" (4 / 6, 'blue'),\n",
49+
" (5 / 6, '#4B0082'),\n",
50+
" (1, 'violet')\n",
51+
" ]\n",
52+
")"
53+
]
54+
},
55+
{
56+
"cell_type": "code",
57+
"execution_count": null,
58+
"metadata": {},
59+
"outputs": [],
60+
"source": [
61+
"canvas.fill_style = gradient"
62+
]
63+
},
64+
{
65+
"cell_type": "code",
66+
"execution_count": null,
67+
"metadata": {},
68+
"outputs": [],
69+
"source": [
70+
"canvas.fill_rect(0, 0, 700, 50)"
71+
]
72+
},
73+
{
74+
"cell_type": "code",
75+
"execution_count": null,
76+
"metadata": {},
77+
"outputs": [],
78+
"source": [
79+
"canvas"
80+
]
81+
},
82+
{
83+
"cell_type": "markdown",
84+
"metadata": {},
85+
"source": [
86+
"## RadialGradient"
87+
]
88+
},
89+
{
90+
"cell_type": "code",
91+
"execution_count": null,
92+
"metadata": {},
93+
"outputs": [],
94+
"source": [
95+
"canvas2 = Canvas(width=570, height=200)"
96+
]
97+
},
98+
{
99+
"cell_type": "code",
100+
"execution_count": null,
101+
"metadata": {},
102+
"outputs": [],
103+
"source": [
104+
"radial_gradient = canvas2.create_radial_gradient(\n",
105+
" 238, 50, 10, # Start circle (x0, y0, r0)\n",
106+
" 238, 50, 300, # End circle (x1, y1, r1)\n",
107+
" [\n",
108+
" (0, '#8ED6FF'),\n",
109+
" (1, '#004CB3'),\n",
110+
" ]\n",
111+
")"
112+
]
113+
},
114+
{
115+
"cell_type": "code",
116+
"execution_count": null,
117+
"metadata": {},
118+
"outputs": [],
119+
"source": [
120+
"canvas2.fill_style = radial_gradient"
121+
]
122+
},
123+
{
124+
"cell_type": "code",
125+
"execution_count": null,
126+
"metadata": {},
127+
"outputs": [],
128+
"source": [
129+
"canvas2.fill_rect(0, 0, 570, 200)"
130+
]
131+
},
132+
{
133+
"cell_type": "code",
134+
"execution_count": null,
135+
"metadata": {},
136+
"outputs": [],
137+
"source": [
138+
"canvas2"
139+
]
140+
}
141+
],
142+
"metadata": {
143+
"kernelspec": {
144+
"display_name": "Python 3",
145+
"language": "python",
146+
"name": "python3"
147+
},
148+
"language_info": {
149+
"codemirror_mode": {
150+
"name": "ipython",
151+
"version": 3
152+
},
153+
"file_extension": ".py",
154+
"mimetype": "text/x-python",
155+
"name": "python",
156+
"nbconvert_exporter": "python",
157+
"pygments_lexer": "ipython3",
158+
"version": "3.9.0"
159+
}
160+
},
161+
"nbformat": 4,
162+
"nbformat_minor": 4
163+
}

ipycanvas/canvas.py

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99

1010
import numpy as np
1111

12-
from traitlets import Bool, Bytes, CInt, Enum, Float, Instance, List, Unicode
12+
from traitlets import Bool, Bytes, CInt, Enum, Float, Instance, List, Unicode, TraitError, Union
1313

1414
from ipywidgets import CallbackDispatcher, Color, DOMWidget, Image, Widget, widget_serialization
15-
from ipywidgets.widgets.trait_types import bytes_serialization
15+
from ipywidgets.widgets.trait_types import (
16+
bytes_serialization, _color_names, _color_hex_re, _color_hexa_re, _color_rgbhsl_re
17+
)
1618

1719
from ._frontend import module_name, module_version
1820

@@ -31,6 +33,26 @@
3133
}
3234

3335

36+
# Traitlets does not allow validating without creating a trait class, so we need this
37+
def _validate_color(value):
38+
if isinstance(value, str):
39+
if (value.lower() in _color_names or _color_hex_re.match(value)
40+
or _color_hexa_re.match(value) or _color_rgbhsl_re.match(value)):
41+
return value
42+
raise TraitError('{} is not a valid HTML Color'.format(value))
43+
44+
45+
def _validate_number(value, min_val, max_val):
46+
try:
47+
number = float(value)
48+
49+
if number >= min_val and number <= max_val:
50+
return number
51+
except ValueError:
52+
raise TraitError('{} is not a number'.format(value))
53+
raise TraitError('{} is not in the range [{}, {}]'.format(value, min_val, max_val))
54+
55+
3456
class Path2D(Widget):
3557
"""Create a Path2D.
3658
@@ -40,11 +62,8 @@ class Path2D(Widget):
4062

4163
_model_module = Unicode(module_name).tag(sync=True)
4264
_model_module_version = Unicode(module_version).tag(sync=True)
43-
_view_module = Unicode(module_name).tag(sync=True)
44-
_view_module_version = Unicode(module_version).tag(sync=True)
4565

4666
_model_name = Unicode('Path2DModel').tag(sync=True)
47-
_view_name = Unicode('Path2DView').tag(sync=True)
4867

4968
value = Unicode(allow_none=False, read_only=True).tag(sync=True)
5069

@@ -55,6 +74,76 @@ def __init__(self, value):
5574
super(Path2D, self).__init__()
5675

5776

77+
class _CanvasGradient(Widget):
78+
_model_module = Unicode(module_name).tag(sync=True)
79+
_model_module_version = Unicode(module_version).tag(sync=True)
80+
81+
x0 = Float(allow_none=False, read_only=True).tag(sync=True)
82+
y0 = Float(allow_none=False, read_only=True).tag(sync=True)
83+
x1 = Float(allow_none=False, read_only=True).tag(sync=True)
84+
y1 = Float(allow_none=False, read_only=True).tag(sync=True)
85+
86+
color_stops = List(allow_none=False, read_only=True).tag(sync=True)
87+
88+
def __init__(self, x0, y0, x1, y1, color_stops):
89+
self.set_trait('x0', x0)
90+
self.set_trait('y0', y0)
91+
self.set_trait('x1', x1)
92+
self.set_trait('y1', y1)
93+
94+
for color_stop in color_stops:
95+
_validate_number(color_stop[0], 0, 1)
96+
_validate_color(color_stop[1])
97+
self.set_trait('color_stops', color_stops)
98+
99+
super(_CanvasGradient, self).__init__()
100+
101+
102+
class LinearGradient(_CanvasGradient):
103+
"""Create a LinearGradient."""
104+
_model_name = Unicode('LinearGradientModel').tag(sync=True)
105+
106+
def __init__(self, x0, y0, x1, y1, color_stops):
107+
"""Create a LinearGradient object given the start point, end point and color stops.
108+
109+
Args:
110+
x0 (float): The x-axis coordinate of the start point.
111+
y0 (float): The y-axis coordinate of the start point.
112+
x1 (float): The x-axis coordinate of the end point.
113+
y1 (float): The y-axis coordinate of the end point.
114+
color_stops (list): The list of color stop tuples (offset, color) defining the gradient.
115+
"""
116+
super(LinearGradient, self).__init__(x0, y0, x1, y1, color_stops)
117+
118+
119+
class RadialGradient(_CanvasGradient):
120+
"""Create a RadialGradient."""
121+
_model_name = Unicode('RadialGradientModel').tag(sync=True)
122+
123+
r0 = Float(allow_none=False, read_only=True).tag(sync=True)
124+
r1 = Float(allow_none=False, read_only=True).tag(sync=True)
125+
126+
def __init__(self, x0, y0, r0, x1, y1, r1, color_stops):
127+
"""Create a RadialGradient object given the start circle, end circle and color stops.
128+
129+
Args:
130+
x0 (float): The x-axis coordinate of the start circle.
131+
y0 (float): The y-axis coordinate of the start circle.
132+
r0 (float): The radius of the start circle.
133+
x1 (float): The x-axis coordinate of the end circle.
134+
y1 (float): The y-axis coordinate of the end circle.
135+
r1 (float): The radius of the end circle.
136+
color_stops (list): The list of color stop tuples (offset, color) defining the gradient.
137+
"""
138+
_validate_number(r0, 0, float('inf'))
139+
_validate_number(r1, 0, float('inf'))
140+
141+
self.set_trait('r0', r0)
142+
self.set_trait('r1', r1)
143+
144+
super(RadialGradient, self).__init__(x0, y0, x1, y1, color_stops)
145+
146+
58147
class _CanvasBase(DOMWidget):
59148
_model_module = Unicode(module_name).tag(sync=True)
60149
_model_module_version = Unicode(module_version).tag(sync=True)
@@ -138,7 +227,7 @@ class Canvas(_CanvasBase):
138227
_view_name = Unicode('CanvasView').tag(sync=True)
139228

140229
#: (valid HTML color) The color for filling rectangles and paths. Default to ``'black'``.
141-
fill_style = Color('black')
230+
fill_style = Union((Color(), Instance(_CanvasGradient)), default_value='black')
142231

143232
#: (valid HTML color) The color for rectangles and paths stroke. Default to ``'black'``.
144233
stroke_style = Color('black')
@@ -257,6 +346,33 @@ def sleep(self, time):
257346
"""Make the Canvas sleep for `time` milliseconds."""
258347
self._send_canvas_command(COMMANDS['sleep'], [time])
259348

349+
# Gradient methods
350+
def create_linear_gradient(self, x0, y0, x1, y1, color_stops):
351+
"""Create a LinearGradient object given the start point, end point, and color stops.
352+
353+
Args:
354+
x0 (float): The x-axis coordinate of the start point.
355+
y0 (float): The y-axis coordinate of the start point.
356+
x1 (float): The x-axis coordinate of the end point.
357+
y1 (float): The y-axis coordinate of the end point.
358+
color_stops (list): The list of color stop tuples (offset, color) defining the gradient.
359+
"""
360+
return LinearGradient(x0, y0, x1, y1, color_stops)
361+
362+
def create_radial_gradient(self, x0, y0, r0, x1, y1, r1, color_stops):
363+
"""Create a RadialGradient object given the start circle, end circle and color stops.
364+
365+
Args:
366+
x0 (float): The x-axis coordinate of the start circle.
367+
y0 (float): The y-axis coordinate of the start circle.
368+
r0 (float): The radius of the start circle.
369+
x1 (float): The x-axis coordinate of the end circle.
370+
y1 (float): The y-axis coordinate of the end circle.
371+
r1 (float): The radius of the end circle.
372+
color_stops (list): The list of color stop tuples (offset, color) defining the gradient.
373+
"""
374+
return RadialGradient(x0, y0, r0, x1, y1, r1, color_stops)
375+
260376
# Rectangles methods
261377
def fill_rect(self, x, y, width, height=None):
262378
"""Draw a filled rectangle of size ``(width, height)`` at the ``(x, y)`` position."""
@@ -661,6 +777,10 @@ def __setattr__(self, name, value):
661777
super(Canvas, self).__setattr__(name, value)
662778

663779
if name in self.ATTRS:
780+
# If it's a Widget we need to serialize it
781+
if isinstance(value, Widget):
782+
value = widget_serialization['to_json'](value, None)
783+
664784
self._send_command([COMMANDS['set'], [self.ATTRS[name], value]])
665785

666786
def _send_canvas_command(self, name, args=[], buffers=[]):

0 commit comments

Comments
 (0)