|
| 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 |
0 commit comments