Skip to content

Commit 70a1f0e

Browse files
authored
Merge pull request #18 from kmatch98/icon_palette
Add zoom animation to icon_widget presses
2 parents f33dfa9 + 949d379 commit 70a1f0e

File tree

7 files changed

+405
-3
lines changed

7 files changed

+405
-3
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
# SPDX-FileCopyrightText: 2021 Kevin Matocha
2+
#
3+
# SPDX-License-Identifier: MIT
4+
"""
5+
6+
`icon_animated`
7+
================================================================================
8+
A touch enabled widget that includes an animated icon image with a small text label
9+
centered below it.
10+
11+
* Author(s): Kevin Matocha
12+
13+
Implementation Notes
14+
--------------------
15+
16+
**Hardware:**
17+
18+
**Software and Dependencies:**
19+
20+
* Adafruit CircuitPython firmware for the supported boards:
21+
https://github.com/adafruit/circuitpython/releases
22+
23+
"""
24+
import gc
25+
import time
26+
from math import pi
27+
import bitmaptools
28+
from displayio import TileGrid, Bitmap, Palette
29+
import adafruit_imageload
30+
from adafruit_displayio_layout.widgets.icon_widget import IconWidget
31+
from adafruit_displayio_layout.widgets.easing import quadratic_easeout as easein
32+
from adafruit_displayio_layout.widgets.easing import quadratic_easein as easeout
33+
34+
35+
class IconAnimated(IconWidget):
36+
37+
"""
38+
An animated touch enabled widget that holds an icon image loaded with
39+
OnDiskBitmap and a text label centered beneath it. Includes optional
40+
animation to increase the icon size when pressed.
41+
42+
.. Warning:: The `init_class` class function must be called before instancing any
43+
IconAnimated widgets.
44+
45+
:param str label_text: the text that will be shown beneath the icon image.
46+
:param str icon: the filepath of the bmp image to be used as the icon.
47+
:param bool on_disk: if True use OnDiskBitmap instead of imageload to load static
48+
icon image. This can be helpful to save memory. (default: False) Note: Bitmap
49+
file must use indexed colors to allow animations in the IconAnimated widget.
50+
51+
:param float scale: the maximum zoom during animation, set 1.0 for no zoom.
52+
A value of 1.5 is a good starting point. The ``scale`` can be less than
53+
1.0 for shrink animations. (default: same as ``max_scale`` set in ``init_class``),
54+
55+
:param float angle: the maximum degrees of rotation during animation, positive values
56+
are clockwise, set 0 for no rotation, in degrees (default: 4 degrees)
57+
:param float animation_time: the time for the animation in seconds, set to 0.0 for
58+
no animation, a value of 0.15 is a good starting point (default: 0.15 seconds)
59+
60+
:param int x: x location the icon widget should be placed. Pixel coordinates.
61+
:param int y: y location the icon widget should be placed. Pixel coordinates.
62+
:param anchor_point: (X,Y) values from 0.0 to 1.0 to define the anchor point relative to the
63+
widget bounding box
64+
:type anchor_point: Tuple[float,float]
65+
:param int anchored_position: (x,y) pixel value for the location of the anchor_point
66+
:type anchored_position: Tuple[int, int]
67+
:param int max_size: (Optional) this will get passed through to the
68+
displayio.Group constructor. ``max_size`` should be set to the maximum number of
69+
graphical elements that will be held within the Group of this widget.
70+
"""
71+
72+
# pylint: disable=bad-super-call, too-many-instance-attributes, too-many-locals
73+
# pylint: disable=too-many-arguments, unused-argument
74+
75+
display = None
76+
# The other Class variables are created in Class method `init_class`:
77+
# max_scale, bitmap_buffer, palette_buffer
78+
79+
@classmethod
80+
def init_class(
81+
cls, display=None, max_scale=1.5, max_icon_size=(80, 80), max_color_depth=256
82+
):
83+
"""
84+
Initializes the IconAnimated Class variables, including preallocating memory
85+
buffers for the icon zoom bitmap and icon zoom palette.
86+
87+
.. Note:: The `init_class` class function must be called before instancing any
88+
IconAnimated widgets. Usage example:
89+
``IconAnimated.init_class(display=board.DISPLAY, max_scale=1.5,
90+
max_icon_size=(80,80), max_color_depth=256)``
91+
92+
:param displayio.Display display: The display where the icons will be displayed.
93+
:param float max_scale: The maximum zoom of the any of the icons, should be >= 1.0,
94+
(default: 1.5)
95+
:param max_icon_size: The maximum (x,y) pixel dimensions of any `IconAnimated` bitmap size
96+
that will be created (default: (80,80)). Note: This is the original pixel size,
97+
before scaling
98+
:type max_icon_size: Tuple[int,int]
99+
:param int max_color_depth: The maximum color depth of any `IconAnimated`
100+
bitmap that will be created (default: 256)
101+
"""
102+
if display is None:
103+
raise ValueError(
104+
"IconAninmated.init_class: Must provide display parameter for IconAnimated."
105+
)
106+
107+
if (
108+
isinstance(max_icon_size, tuple)
109+
and len(max_icon_size) == 2 # validate max_icon_size input
110+
and isinstance(max_icon_size[0], int)
111+
and isinstance(max_icon_size[1], int)
112+
):
113+
pass
114+
else:
115+
raise ValueError(
116+
"IconAninmated.init_class: max_icon_size must be an (x,y) "
117+
"tuple of integer pixel sizes."
118+
)
119+
120+
cls.display = display
121+
if max_scale < 1.0:
122+
print(
123+
"Warning: IconAnimated.init_class - max_scale value was "
124+
"constrained to minimum of 1.0"
125+
)
126+
cls.max_scale = max(1.0, max_scale)
127+
cls.bitmap_buffer = Bitmap(
128+
round(cls.max_scale * max_icon_size[0]),
129+
round(cls.max_scale * max_icon_size[1]),
130+
max_color_depth + 1,
131+
)
132+
cls.palette_buffer = Palette(max_color_depth + 1)
133+
134+
def __init__(
135+
self,
136+
label_text,
137+
icon,
138+
on_disk=False,
139+
scale=None,
140+
angle=4,
141+
animation_time=0.15,
142+
**kwargs,
143+
):
144+
145+
if self.__class__.display is None:
146+
raise ValueError(
147+
"Must initialize class using\n"
148+
"`IconAnimated.init_class(display, max_scale, max_size, max_color_depth)`\n"
149+
"prior to instancing IconAnimated widgets."
150+
)
151+
152+
super().__init__(label_text, icon, on_disk, **kwargs) # initialize superclasses
153+
154+
# constrain instance's maximum_scaling between 1.0 and the Class's max_scale
155+
if scale is None:
156+
self._scale = self.__class__.max_scale
157+
else:
158+
if scale > self.__class__.max_scale:
159+
print(
160+
"Warning - IconAnimated: max_scale is constrained by value of "
161+
"IconAnimated.max_scale set by IconAnimated.init_class(): {}".format(
162+
self.__class__.max_scale
163+
)
164+
)
165+
self._scale = max(0, min(scale, self.__class__.max_scale))
166+
167+
self._animation_time = animation_time # in seconds
168+
self._angle = (angle / 360) * 2 * pi # in degrees, convert to radians
169+
self._zoomed = False # state variable for zoom status
170+
171+
def zoom_animation(self, touch_point):
172+
"""Performs zoom animation when icon is pressed.
173+
174+
:param touch_point: x,y location of the screen.
175+
:type touch_point: Tuple[x,y]
176+
:return: None
177+
"""
178+
179+
if self._animation_time > 0:
180+
try:
181+
_image, _palette = adafruit_imageload.load(self._icon)
182+
183+
if len(self.__class__.palette_buffer) < len(_palette) + 1:
184+
self._animation_time = 0 # skip any animation
185+
print(
186+
"Warning: IconAnimated - icon bitmap exceeds IconAnimated.max_color_depth;"
187+
" defaulting to no animation"
188+
)
189+
190+
except NotImplementedError:
191+
self._animation_time = 0 # skip any animation
192+
print(
193+
"Warning: IconAnimated - True color BMP unsupported for animation;"
194+
" defaulting to no animation"
195+
)
196+
197+
if self._animation_time > 0:
198+
199+
animation_bitmap = self.__class__.bitmap_buffer
200+
animation_palette = self.__class__.palette_buffer
201+
202+
# store the current display refresh setting
203+
refresh_status = self.__class__.display.auto_refresh
204+
205+
###
206+
## Update the zoom palette and bitmap buffers and append the tilegrid
207+
###
208+
209+
# copy the image palette, add a transparent color at the end
210+
for i, color in enumerate(_palette):
211+
animation_palette[i] = color
212+
animation_palette[len(animation_palette) - 1] = 0x000000
213+
animation_palette.make_transparent(len(animation_palette) - 1)
214+
215+
# create the zoom bitmap larger than the original image to allow for zooming
216+
animation_bitmap.fill(len(animation_palette) - 1) # transparent fill
217+
animation_bitmap.blit(
218+
(animation_bitmap.width - _image.width) // 2,
219+
(animation_bitmap.height - _image.height) // 2,
220+
_image,
221+
) # blit the image into the center of the zoom_bitmap
222+
223+
# place zoom_bitmap at same location as image
224+
animation_tilegrid = TileGrid(
225+
animation_bitmap, pixel_shader=animation_palette
226+
)
227+
animation_tilegrid.x = -(animation_bitmap.width - _image.width) // 2
228+
animation_tilegrid.y = -(animation_bitmap.height - _image.height) // 2
229+
230+
self.__class__.display.auto_refresh = False # set auto_refresh off
231+
self[0].hidden = True # hide the original icon
232+
self.append(animation_tilegrid) # add to the self group.
233+
234+
# Animation: zoom larger
235+
start_time = time.monotonic()
236+
237+
while True:
238+
elapsed_time = time.monotonic() - start_time
239+
position = min(
240+
1.0, easein(elapsed_time / self._animation_time)
241+
) # fractional position
242+
animation_bitmap.fill(len(animation_palette) - 1)
243+
bitmaptools.rotozoom(
244+
dest_bitmap=animation_bitmap,
245+
ox=animation_bitmap.width // 2,
246+
oy=animation_bitmap.height // 2,
247+
source_bitmap=_image,
248+
px=_image.width // 2,
249+
py=_image.height // 2,
250+
scale=1.0 + position * (self._scale - 1.0), # start scaling at 1.0
251+
angle=position * self._angle,
252+
)
253+
self.__class__.display.refresh()
254+
if elapsed_time > self._animation_time:
255+
break
256+
257+
# set display.auto_refresh back to original value
258+
self.__class__.display.auto_refresh = refresh_status
259+
260+
del _image
261+
del _palette
262+
gc.collect()
263+
264+
self._zoomed = True
265+
266+
def zoom_out_animation(self, touch_point):
267+
"""Performs un-zoom animation when icon is released.
268+
269+
:param touch_point: x,y location of the screen.
270+
:type touch_point: Tuple[x,y]
271+
:return: None
272+
"""
273+
274+
if (self._animation_time > 0) and self._zoomed:
275+
_image, _palette = adafruit_imageload.load(self._icon)
276+
animation_bitmap = self.__class__.bitmap_buffer
277+
animation_palette = self.__class__.palette_buffer
278+
279+
# store the current display refresh setting
280+
refresh_status = self.__class__.display.auto_refresh
281+
282+
self.__class__.display.auto_refresh = False # set auto_refresh off
283+
284+
# Animation: shrink down to the original size
285+
start_time = time.monotonic()
286+
while True:
287+
elapsed_time = time.monotonic() - start_time
288+
position = max(0.0, easeout(1 - (elapsed_time / self._animation_time)))
289+
animation_bitmap.fill(len(animation_palette) - 1)
290+
bitmaptools.rotozoom(
291+
dest_bitmap=animation_bitmap,
292+
ox=animation_bitmap.width // 2,
293+
oy=animation_bitmap.height // 2,
294+
source_bitmap=_image,
295+
px=_image.width // 2,
296+
py=_image.height // 2,
297+
scale=1.0 + position * (self._scale - 1.0),
298+
angle=position * self._angle,
299+
)
300+
self.__class__.display.refresh()
301+
if elapsed_time > self._animation_time:
302+
break
303+
304+
# clean up the zoom display elements
305+
self[0].hidden = False # unhide the original icon
306+
self.pop(-1) # remove zoom tilegrid from the group
307+
self.__class__.display.refresh()
308+
309+
# set display.auto_refresh back to original value
310+
self.__class__.display.auto_refresh = refresh_status
311+
312+
del _image
313+
del _palette
314+
gc.collect()
315+
316+
self._zoomed = False

adafruit_displayio_layout/widgets/icon_widget.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,18 @@ class IconWidget(Widget, Control):
5050
:param int anchored_position: (x,y) pixel value for the location of the anchor_point
5151
:type anchored_position: Tuple[int, int]
5252
:param int max_size: (Optional) this will get passed through to the
53-
displayio.Group constructor. If omitted we default to
54-
grid_size width * grid_size height to make room for all (1, 1) sized cells.
53+
displayio.Group constructor. ``max_size`` should be set to the maximum number of
54+
graphical elements that will be held within the Group of this widget.
5555
5656
"""
5757

5858
def __init__(self, label_text, icon, on_disk=False, **kwargs):
5959
super().__init__(**kwargs)
6060

61+
self._icon = icon
62+
6163
if on_disk:
62-
self._file = open(icon, "rb")
64+
self._file = open(self._icon, "rb")
6365
image = OnDiskBitmap(self._file)
6466
tile_grid = TileGrid(image, pixel_shader=ColorConverter())
6567
else:

docs/api.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@
3333
.. automodule:: adafruit_displayio_layout.widgets.icon_widget
3434
:members:
3535
:member-order: bysource
36+
37+
.. automodule:: adafruit_displayio_layout.widgets.icon_animated
38+
:members:
39+
:member-order: bysource

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"terminalio",
3737
"adafruit_imageload",
3838
"adafruit_display_text",
39+
"bitmaptools",
3940
]
4041

4142

0 commit comments

Comments
 (0)