diff --git a/adafruit_displayio_layout/widgets/dial.py b/adafruit_displayio_layout/widgets/dial.py new file mode 100755 index 0000000..09e294a --- /dev/null +++ b/adafruit_displayio_layout/widgets/dial.py @@ -0,0 +1,1120 @@ +# SPDX-FileCopyrightText: 2021 Kevin Matocha +# +# SPDX-License-Identifier: MIT +""" + +`dial` +================================================================================ +A dial gauge widget for displaying graphical information. + +* Author(s): Kevin Matocha + +Implementation Notes +-------------------- + +**Hardware:** + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +# pylint: disable=too-many-lines, too-many-instance-attributes, too-many-arguments +# pylint: disable=too-many-locals, too-many-statements + + +import math +import displayio +import vectorio + +try: + import bitmaptools +except NameError: + pass # utilize the blit_rotate_scale function defined herein + + +from terminalio import FONT as terminalio_FONT +from adafruit_display_text import bitmap_label +from adafruit_displayio_layout.widgets.widget import Widget + + +class Dial(Widget): + """A dial widget. The origin is set using ``x`` and ``y``. + + :param int x: pixel position + :param int y: pixel position + + :param int width: requested width, in pixels + :param int height: requested height, in pixels + :param int padding: keepout padding amount around the border, in pixels + + :param float sweep_angle: dial rotation, in degrees, maximum value is 360 degrees + :param float start_angle: starting angle, in degrees. Defaults + to `None` for symmetry along vertical axis. Vertical is defined as 0 degrees. + Negative values are counter-clockwise degrees; positive values + are clockwise degrees. + + :param float min_value: the minimum value displayed on the dial + :param float max_value: the maximum value displayed the dial + :param float value: the value to display (if None, defaults to ``min_value``) + + :param Boolean display_value: set `True` to display a value label on the dial + :param Font value_font: the font for the value label + :param int value_color: the color for the value label + :param str value_format_string: the format string for displaying the value label + (defaults to ':0.0f' to show the value rounded to the nearest whole number) + :param (float,float) value_label_anchor_point: anchor point on the label + :param (float,float) value_label_anchor_point_on_widget: anchor point on the widget where the + label will be placed + + :param int needle_width: requested pixel width of the triangular needle + :param int needle_color: color value for the needle, defaults to red (0xFF0000) + + :param int tick_color: tick line color (24-bit hex value) + :param int major_ticks: number of major ticks + :param int major_tick_stroke: major tick line stroke width, in pixels + :param int major_tick_length: major tick length, in pixels + :param str major_tick_labels: array of strings for the major tick labels + :param float tick_label_scale: the scaling of the tick labels, default = 1.0 + :param Font tick_label_font: font to be used for major tick labels + :param int tick_label_color: color for the major tick labels + :param Boolean angle_tick_labels: set True to rotate the major tick labels to + match the tick angle + + :param int minor_ticks: number of minor ticks (per major tick) + :param int minor_tick_stroke: minor tick line stroke width, in pixels + :param int minor_tick_length: minor tick length, in pixels + + :param int background_color: background color (RGB tuple + or 24-bit hex value), set None for transparent + + + :param (float,float) anchor_point: (X,Y) values from 0.0 to 1.0 to define the dial's + anchor point relative to the dial's bounding box + :param (int,int) anchored_position: (x,y) pixel value for the location + of the `anchor_point` + + + **Simple example of dial and moving needle** + + See file: ``examples/displayio_layout_dial_simpletest.py`` + + .. figure:: dial.gif + :scale: 100 % + :figwidth: 50% + :align: center + :alt: Diagram of the dial widget with needle in motion. + + This is a diagram of a dial widget with the needle moving from its + minimum to maximum positions. + + .. figure:: dial_variables_angles.png + :scale: 50 % + :figwidth: 70% + :align: center + :alt: Diagram showing the definition of ``start_angle`` and ``sweep_angle``, + both are in units of degrees. + + Diagram showing the definition of ``start_angle`` and ``sweep_angle``, + both are in units of degrees. + + .. figure:: dial_variables_min_max_values.png + :scale: 50 % + :figwidth: 70% + :align: center + :alt: Diagram showing the defintion of ``min_value`` and ``max_value``. + + Diagram showing the defintion of ``min_value`` and ``max_value``. + + .. figure:: dial_variables_ticks.png + :scale: 50 % + :figwidth: 70% + :align: center + :alt: Diagram showing the various parameters for setting the dial labels + and major and minor tick marks. + + Diagram showing the various parameters for setting the dial labels + and major and minor tick marks. + + .. figure:: dial_variables_clip_needle.png + :scale: 35 % + :figwidth: 70% + :align: center + :alt: Diagram showing the impact of ``clip_needle`` Boolean value. + + Diagram showing the impact of the ``clip_needle`` input parameter, + with the dial's boundary shown. For ``sweep_angle`` values less than + 180 degrees, the needle can protrude a long way from the dial ticks. By + setting ``clip_needle = True``, the needle graphic will be clipped at the edge + of the dial boundary (see comparison in the graphic above). The left dial is + created with ``clip_needle = False``, meaning that the dial is not clipped. The + right dial is created with ``clip_needle = True`` and the needle is clipped at + the edge of the dial. Use additional ``padding`` to expose more length of + needle, even when clipped. + """ + + # The dial is a subclass of Group->Widget. + + def __init__( + self, + width=100, + height=100, + padding=12, # keepout amount around border, in pixels + sweep_angle=90, # maximum value is 180 degrees + start_angle=None, + clip_needle=False, + # trims off the needle outside of the dial region, used for sweep_angles < 180 + needle_width=7, + # triangle with this base width, best if this is odd + needle_color=0x880000, + value=None, + value_font=None, + display_value=False, + value_color=0xFF0000, + value_format_string=":0.0f", + min_value=0.0, + max_value=100.0, + anchor_point=None, + anchored_position=None, + tick_color=0xFFFFFF, + major_ticks=5, # if int, the total number of major ticks + major_tick_stroke=3, + major_tick_length=10, + major_tick_labels=("0", "25", "50", "75", "100"), + minor_ticks=5, # if int, the number of minor ticks per major tick + minor_tick_stroke=1, + minor_tick_length=5, + tick_label_font=None, + tick_label_color=0xFFFFFF, + rotate_tick_labels=True, + tick_label_scale=1.0, + background_color=None, + value_label_anchor_point=( + 0.5, + -1.0, + ), # default label position uses (x-center point, y-text baseline) + value_label_anchor_on_widget=(0.5, 0.5), # default label position on widget + **kwargs, + ): + + # initialize the Widget superclass (x, y, scale) + super().__init__(**kwargs, max_size=3) + # Define how many graphical elements will be in this group + # using "max_size=XX" + # + # Group elements for SwitchRoundHorizontal: + # 0. TileGrid holding bitmap with ticks and tick label text + # 1. Value label (optional) + # 2. Needle bitmap + + self._min_value = min_value + self._max_value = max_value + if value is None: # if none, set to the minimum value + self._value = self._min_value + else: + self._value = value + + if value_font is None: + self._value_font = terminalio_FONT + else: + self._value_font = value_font + self._value_color = value_color + self._display_value = display_value + self._value_format_string = value_format_string + + self._anchor_point = anchor_point + self._anchored_position = anchored_position + + # validate sweep_angle and start_angle + if sweep_angle > 360: + raise ValueError("sweep_angle must be <= 360 degrees") + + sweep_angle = max( + 1, sweep_angle + ) # constrain to >= 1 to avoid divide by zero errors + self._sweep_angle = sweep_angle + + if start_angle is None: + start_angle = -sweep_angle / 2 + elif not -360 <= start_angle <= 360: + raise ValueError("start_angle must be between -360 and +360 degrees") + self._start_angle = start_angle + + self._clip_needle = clip_needle + self._needle_width_requested = needle_width + self._needle_color = needle_color + self._background_color = background_color + + self._major_tick_labels = major_tick_labels + + self._tick_color = tick_color + self._tick_label_color = tick_label_color + if tick_label_font is None: + self._tick_label_font = terminalio_FONT + else: + self._tick_label_font = tick_label_font + self._tick_label_scale = tick_label_scale + self._rotate_tick_labels = rotate_tick_labels + + self._major_ticks = major_ticks + self._major_tick_stroke = major_tick_stroke + self._major_tick_length = major_tick_length + self._minor_ticks = minor_ticks + self._minor_tick_stroke = minor_tick_stroke + self._minor_tick_length = minor_tick_length + + self._label_anchor_point = value_label_anchor_point + self._label_anchor_on_widget = value_label_anchor_on_widget + + self._padding = padding + + # initialize variables before creating the dial + self._dial_center = None + self._dial_radius = None + self._trim_line = None + self._needle_palette = None + self._needle = None + self._needle_vector_shape = None + self._needle_width = None + + self._initialize_dial(width, height) + + def _initialize_dial(self, width, height): + + for _ in range(len(self)): + self.pop() + + # get the tick label font height + self._font_height = self._get_font_height( + font=self._tick_label_font, scale=self._tick_label_scale + ) + + # update the dial dimensions to fit inside the requested width and height + self._adjust_dimensions(width, height) + self._bounding_box = [0, 0, self._width, self._height] + self._update_position() + + # create the dial palette and bitmaps + self.dial_bitmap = displayio.Bitmap( + self._width, self._height, 3 + ) # 3 colors: background, ticks, tick label text + + # paint the dial major and minor ticks and labels + draw_ticks( # major ticks + target_bitmap=self.dial_bitmap, + dial_center=self._dial_center, + dial_radius=self._dial_radius, + tick_count=self._major_ticks, + tick_stroke=self._major_tick_stroke, + tick_length=self._major_tick_length, + start_angle=self._start_angle, + sweep_angle=self._sweep_angle, + tick_color_index=2, + ) + + draw_ticks( # minor ticks + target_bitmap=self.dial_bitmap, + dial_center=self._dial_center, + dial_radius=self._dial_radius, + tick_count=self._minor_ticks * (self._major_ticks - 1) + 1, + tick_stroke=self._minor_tick_stroke, + tick_length=self._minor_tick_length, + start_angle=self._start_angle, + sweep_angle=self._sweep_angle, + tick_color_index=2, + ) + + draw_labels( + target_bitmap=self.dial_bitmap, + font=self._tick_label_font, + tick_labels=self._major_tick_labels, + dial_center=self._dial_center, + dial_radius=self._dial_radius, + start_angle=self._start_angle, + sweep_angle=self._sweep_angle, + rotate_labels=self._rotate_tick_labels, + font_height=self._font_height, + tick_label_scale=self._tick_label_scale, + ) + + # create the dial palette + self.dial_palette = displayio.Palette(4) + if self._background_color is None: + self.dial_palette.make_transparent(0) + self.dial_palette[0] = 0x000000 + else: + self.dial_palette[0] = self._background_color + self.dial_palette[1] = self._tick_label_color + self.dial_palette[2] = self._tick_color + + # create the dial tilegrid and append to the self Widget->Group + self.dial_tilegrid = displayio.TileGrid( + self.dial_bitmap, pixel_shader=self.dial_palette + ) + self.append(self.dial_tilegrid) + + # create the label for the display_value + if self._display_value: + self._value_label = bitmap_label.Label( + self._value_font, + text="", + color=self._value_color, + baseline_alignment=True, + ) + self._value_label.anchor_point = self._label_anchor_point + self._value_label.anchored_position = [ + round(self._width * self._label_anchor_on_widget[0]), + round(self._height * self._label_anchor_on_widget[1]), + ] + self._update_value() + self.append(self._value_label) + + # create the needle + self._create_needle() + self.append(self._needle_vector_shape) + self._update_needle(self._value) + + def _adjust_dimensions(self, width, height): + # get normalized dimensions of the dial based on start_angle and sweep_angle + # in units of diameter + + # if the sweep angle is < 180, then adjust size if needle should clipped: + if (self._sweep_angle < 180) and (self._clip_needle): + [left, top, right, bottom, x_center_calc, y_center_calc] = _getCoords( + [self._start_angle, self._start_angle + self._sweep_angle], + ignore_center=True, + ) + else: + [left, top, right, bottom, x_center_calc, y_center_calc] = _getCoords( + [self._start_angle, self._start_angle + self._sweep_angle] + ) + + # calculate the pixel dimension to fit within width/height (including padding) + if (width - 2 * self._padding < 0) or (height - 2 * self._padding < 0): + raise ValueError("Width, height, or padding size makes zero sized box") + requested_aspect_ratio = (width - 2 * self._padding) / ( + height - 2 * self._padding + ) + box_aspect_ratio = (right - left) / (bottom - top) + + if box_aspect_ratio >= requested_aspect_ratio: + # keep width and adjust the width + self._width = width + self._height = math.ceil((width - 2 * self._padding) / box_aspect_ratio) + ( + 2 * self._padding + ) + radius = round((width - 2 * self._padding) / (2 * (right - left))) + + else: + # keep height and adjust the width + self._height = height + self._width = math.ceil( + ((height - 2 * self._padding) * box_aspect_ratio) + ) + (2 * self._padding) + radius = round((height - 2 * self._padding) / (2 * (bottom - top))) + + center_x = round(x_center_calc * radius * 2) + self._padding + center_y = round(y_center_calc * radius * 2) + self._padding + self._dial_center = (center_x, center_y) + self._dial_radius = radius + + if self._clip_needle: # define the line endpoints that will trim off the needle + trim_x1 = round( + center_x + + math.sin(self._start_angle * 2 * math.pi / 360) + * (self._dial_radius - self._padding) + ) + trim_y1 = round( + center_y + - math.cos(self._start_angle * 2 * math.pi / 360) + * (self._dial_radius - self._padding) + ) + trim_x2 = round( + center_x + + math.sin((self._start_angle + self._sweep_angle) * 2 * math.pi / 360) + * (self._dial_radius - self._padding) + ) + trim_y2 = round( + center_y + - math.cos((self._start_angle + self._sweep_angle) * 2 * math.pi / 360) + * (self._dial_radius - self._padding) + ) + self._trim_line = [(trim_x1, trim_y1), (trim_x2, trim_y2)] + else: + self._trim_line = None + + def _get_font_height(self, font, scale): + if (self._major_tick_labels == []) or (font is None): + font_height = 0 + else: + if hasattr(font, "get_bounding_box"): + font_height = int(scale * font.get_bounding_box()[1]) + elif hasattr(font, "ascent"): + font_height = int(scale * font.ascent + font.ascent) + return font_height + + def _create_needle(self): + # Create the needle + self._needle_palette = displayio.Palette(2) + self._needle_palette.make_transparent(0) + self._needle_palette[1] = self._needle_color + + self._needle = vectorio.Polygon( + points=[(100, 100), (100, 50), (50, 50), (50, 100)] + ) + self._needle_vector_shape = vectorio.VectorShape( + shape=self._needle, + pixel_shader=self._needle_palette, + x=0, + y=0, + ) + + # if clipped, adjust the needle width up according to the clip amount + if ( + (self._sweep_angle < 180) + and (self._clip_needle) + and (self._trim_line is not None) + ): + # calculate the line where the needle is most visible + max_visible_angle = (2 * math.pi / 360) * ( + self._start_angle + self._sweep_angle / 2 + ) + while True: + if max_visible_angle > math.pi: + max_visible_angle -= 2 * math.pi + elif max_visible_angle < -math.pi: + max_visible_angle += 2 * math.pi + else: + break + + temp_x = self._dial_center[0] + self._dial_radius * math.sin( + max_visible_angle + ) + temp_y = self._dial_center[1] - self._dial_radius * math.cos( + max_visible_angle + ) + + temp_line = [self._dial_center, (temp_x, temp_y)] + + x, y = _line_intersection(temp_line, self._trim_line) + + needle_length_showing = math.sqrt((x - temp_x) ** 2 + (y - temp_y) ** 2) + self._needle_width = round( + self._needle_width_requested * self._dial_radius / needle_length_showing + ) + else: + self._needle_width = self._needle_width_requested + + def _update_value(self): + + if self._display_value: + format_string = ("{" + self._value_format_string + "}").format(self._value) + self._value_label.text = format_string + + def _update_position(self): + if self._anchor_point is None or self._anchored_position is None: + pass + else: + self.x = ( + -self._bounding_box[0] + + self._anchored_position[0] + - int(self._anchor_point[0] * self._bounding_box[2]) + ) + self.y = ( + -self._bounding_box[1] + + self._anchored_position[1] + - int(self._anchor_point[1] * self._bounding_box[3]) + ) + + def _get_offset_position(self, position): + # Function to calculate the offset position (x, y, angle) of the moving + # elements of an animated widget + # input parameter `position` is a value from 0.0 to 1.0 indicating start + # and end position + # + # Designed to be flexible depending upon the widget's response + # + # values should be set in the __init__ function: + # self._x_motion: x-direction movement in pixels + # self._y_motion: y-direction movement in pixels + # self._angle_motion: angle movement + # + # A linear movement function (but can be modified with "easing functions" + # for motion acceleration). + + # if multiple elements are present, they could each have their own movement functions. + angle_offset = (2 * math.pi / 360) * ( + self._start_angle + self._sweep_angle * position + ) + + return angle_offset + + def _update_needle(self, value): + self._draw_position( + value / (self._max_value - self._min_value) + ) # convert to position (0.0 to 1.0) + + def _draw_position(self, position): + # Draw the position of the needle. + # The position parameter is a float between 0 and 1 (0= off, 1= on). + + # Get the position offset from the motion function + angle_offset = self._get_offset_position(position) + + d_x = (self._needle_width / 2) * math.cos(angle_offset) + d_y = (self._needle_width / 2) * math.sin(angle_offset) + + x_0 = round(self._dial_center[0] - d_x) + y_0 = round(self._dial_center[1] - d_y) + + x_1 = round(self._dial_center[0] + d_x) + y_1 = round(self._dial_center[1] + d_y) + + x_2 = round(self._dial_center[0] + self._dial_radius * math.sin(angle_offset)) + y_2 = round(self._dial_center[1] - self._dial_radius * math.cos(angle_offset)) + + if (((2 * math.pi / 360) * self._sweep_angle) < math.pi) and self._clip_needle: + # clip the needle points by adjusting (x0,y0) and (x1,y1) + x_0, y_0 = _line_intersection(self._trim_line, [(x_0, y_0), (x_2, y_2)]) + x_1, y_1 = _line_intersection(self._trim_line, [(x_1, y_1), (x_2, y_2)]) + + if (x_0 == x_1) and (y_0 == y_1): + x_1 += 1 + y_1 += 1 + + self._needle.points = [(x_0, y_0), (x_1, y_1), (x_2, y_2)] + + def resize(self, new_width, new_height): + """Resizes the dial dimensions to the maximum size that will + fit within the requested bounding box size (``new_width``, ``new_height``) + + :param int new_width: requested width, in pixels + :param int new_height: requested height, in pixels + """ + self._initialize_dial(new_width, new_height) + + @property + def value(self): + """The dial's value.""" + return self._value + + @value.setter + def value(self, new_value): + + if new_value != self._value: + self._value = new_value + self._update_value() + self._update_needle(self._value) + + @property + def value_font(self): + """The font used for the value's label.""" + return self._value_font + + @value_font.setter + def value_font(self, new_font): + if self._display_value: + self._value_label.font = new_font + self._value_font = new_font + + @property + def value_color(self): + """The font color used for the value's label.""" + return self._value_color + + @value_color.setter + def value_color(self, new_color): + if self._display_value: + self._value_label.color = new_color + self._value_color = new_color + + @property + def dial_center(self): + """The (x,y) pixel location of the dial's center of rotation.""" + return self._dial_center + + @property + def dial_radius(self): + """The length of the dial's radius, in pixels.""" + return self._dial_radius + + @property + def start_angle(self): + """The starting angle of the dial, in degrees.""" + return self._start_angle + + @property + def sweep_angle(self): + """The sweep angle of the dial, in degrees.""" + return self._sweep_angle + + +def draw_ticks( + target_bitmap, + *, + dial_center, + dial_radius, + tick_count, + tick_stroke, + tick_length, + start_angle, + sweep_angle, + tick_color_index=2, +): + """Helper function for drawing ticks on the dial widget. Can be used to + customize the dial face. + + :param displayio.Bitmap target_bitmap: Bitmap where ticks will be drawn into + :param (int,int) dial_center: the (x,y) pixel location in the bitmap of + the dial's center of rotation + :param int dial_radius: the radius of the dial (not including padding), in pixels + :param int tick_count: number of ticks to be drawn + :param int tick_stroke: the pixel width of the line used to draw the tick + :param float start_angle: starting angle of the dial, in degrees + :param float sweep_angle: total sweep angle of the dial, in degrees + :param int tick_color_index: the bitmap's color index that should be used for + drawing the tick marks + """ + + if tick_count <= 1: + pass + else: + tick_bitmap = displayio.Bitmap( + tick_stroke, tick_length, tick_color_index + 1 + ) # make a tick line bitmap for blitting + tick_bitmap.fill( + tick_color_index + ) # initialize the tick bitmap with the tick_color_index + + for i in range(tick_count): + this_angle = round( + (start_angle + ((i * sweep_angle / (tick_count - 1)))) + * (2 * math.pi / 360), + 4, + ) # in radians + target_position_x = dial_center[0] + dial_radius * math.sin(this_angle) + target_position_y = dial_center[1] - dial_radius * math.cos(this_angle) + + if "rotozoom" in dir(bitmaptools): # if core function is available + bitmaptools.rotozoom( + target_bitmap, + ox=round(target_position_x), + oy=round(target_position_y), + source_bitmap=tick_bitmap, + px=round(tick_bitmap.width / 2), + py=0, + angle=this_angle, # in radians + ) + + else: + _blit_rotate_scale( # translate and rotate the tick into the target_bitmap + destination=target_bitmap, + ox=target_position_x, + oy=target_position_y, + source=tick_bitmap, + px=int(tick_bitmap.width / 2), + py=0, + angle=this_angle, # in radians + ) + + +def draw_labels( + target_bitmap, + *, + font, + font_height, + tick_labels, + dial_center, + dial_radius, + start_angle, + sweep_angle, + rotate_labels=True, + tick_label_scale=1.0, +): + """Helper function for drawing text labels on the dial widget. Can be used + to customize the dial face. + + :param displayio.Bitmap target_bitmap: Bitmap where ticks will be drawn into + :param Font font: the font to be used to draw the tick mark text labels + :param int font_height: the height of the font, used for text placement + :param List[str] tick_labels: a list of strings for the tick text labels + :param (int,int) dial_center: the (x,y) pixel location in the bitmap of + the dial's center of rotation + :param int dial_radius: the radius of the dial (not including padding), in pixels + :param int tick_count: number of ticks to be drawn + :param int tick_stroke: the pixel width of the line used to draw the tick + :param float start_angle: starting angle of the dial, in degrees + :param float sweep_angle: total sweep angle of the dial, in degrees + :param bool rotate_labels: set to True if you want the label text to be rotated + to align with the tick marks + :param float tick_label_scale: scale factor for the tick text labels, default is 1.0 + """ + + label_count = len(tick_labels) + + for i, this_label_text in enumerate(tick_labels): + + temp_label = bitmap_label.Label( + font, text=this_label_text + ) # make a tick line bitmap for blitting + + this_angle = (2 * math.pi / 360) * ( + start_angle + i * sweep_angle / (label_count - 1) + ) # in radians + + target_position_x = dial_center[0] + ( + dial_radius + font_height // 2 + ) * math.sin(this_angle) + target_position_y = dial_center[1] - ( + dial_radius + font_height // 2 + ) * math.cos(this_angle) + + if rotate_labels: + pass + else: + this_angle = 0 + + if "rotozoom" in dir(bitmaptools): # if core function is available + bitmaptools.rotozoom( + target_bitmap, + ox=round(target_position_x), + oy=round(target_position_y), + source_bitmap=temp_label.bitmap, + px=round(temp_label.bitmap.width // 2), + py=round(temp_label.bitmap.height // 2), + angle=this_angle, + scale=tick_label_scale, + ) + + else: + _blit_rotate_scale( # translate and rotate the tick into the target_bitmap + destination=target_bitmap, + ox=round(target_position_x), + oy=round(target_position_y), + source=temp_label.bitmap, + px=round(temp_label.bitmap.width // 2), + py=round(temp_label.bitmap.height // 2), + angle=this_angle, + scale=tick_label_scale, + ) + + +# * Copyright (c) 2017 Werner Stoop +# * +# * Permission is hereby granted, free of charge, to any person obtaining a copy +# * of this software and associated documentation files (the "Software"), to deal +# * in the Software without restriction, including without limitation the rights +# * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# * copies of the Software, and to permit persons to whom the Software is +# * furnished to do so, subject to the following conditions: +# * +# * The above copyright notice and this permission notice shall be included in all +# * copies or substantial portions of the Software. +# * +# * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# * SOFTWARE. + + +# Credit from https://github.com/wernsey/bitmap +# MIT License from +# * Copyright (c) 2017 Werner Stoop +# +# /** +# * #### `void bm_rotate_blit(Bitmap *dst, int ox, int oy, Bitmap *src, int px, +# * int py, double angle, double scale);` +# * +# * Rotates a source bitmap `src` around a pivot point `px,py` and blits it +# * onto a destination bitmap `dst`. +# * +# * The bitmap is positioned such that the point `px,py` on the source is at +# * the offset `ox,oy` on the destination. +# * +# * The `angle` is clockwise, in radians. The bitmap is also scaled by the +# * factor `scale`. +# */ +# void bm_rotate_blit(Bitmap *dst, int ox, int oy, Bitmap *src, int px, +# int py, double angle, double scale); + + +# /* +# Reference: +# "Fast Bitmap Rotation and Scaling" By Steven Mortimer, Dr Dobbs' Journal, July 01, 2001 +# http://www.drdobbs.com/architecture-and-design/fast-bitmap-rotation-and-scaling/184416337 +# See also http://www.efg2.com/Lab/ImageProcessing/RotateScanline.htm +# */ + +# pylint: disable=invalid-name, too-many-branches, too-many-statements + +# This function is provided in case the bitmaptools.rotozoom function is not available +def _blit_rotate_scale( + destination, # destination bitmap + ox=None, + oy=None, # (ox, oy) is the destination point where the source (px,py) is placed + dest_clip0=None, + dest_clip1=None, # clip0,1 is (x,y) corners of clip window on the destination bitmap + source=None, # source bitmap + px=None, + py=None, # (px, py) is the rotation point of the source bitmap + source_clip0=None, + source_clip1=None, # clip0,1 is (x,y) corners of clip window on the source bitmap + angle=0, # in radians, clockwise + scale=1.0, # scale factor (float) + skip_index=None, # color index to ignore +): + + if source is None: + pass + + # Check the input limits + + if ox is None: + ox = destination.width / 2 + if oy is None: + oy = destination.height / 2 + if px is None: + px = source.width / 2 + if py is None: + py = source.height / 2 + + if dest_clip0 is None: + dest_clip0 = (0, 0) + if dest_clip1 is None: + dest_clip1 = (destination.width, destination.height) + + if source_clip0 is None: + source_clip0 = (0, 0) + if source_clip1 is None: + source_clip1 = (source.width, source.height) + + minx = dest_clip1[0] + miny = dest_clip1[1] + maxx = dest_clip0[0] + maxy = dest_clip0[1] + + sinAngle = math.sin(angle) + cosAngle = math.cos(angle) + + dx = -cosAngle * px * scale + sinAngle * py * scale + ox + dy = -sinAngle * px * scale - cosAngle * py * scale + oy + if dx < minx: + minx = int(round(dx)) + if dx > maxx: + maxx = int(round(dx)) + if dy < miny: + miny = int(round(dy)) + if dy > maxy: + maxy = int(dy) + dx = cosAngle * (source.width - px) * scale + sinAngle * py * scale + ox + dy = sinAngle * (source.width - px) * scale - cosAngle * py * scale + oy + if dx < minx: + minx = int(round(dx)) + if dx > maxx: + maxx = int(round(dx)) + if dy < miny: + miny = int(round(dy)) + if dy > maxy: + maxy = int(round(dy)) + + dx = ( + cosAngle * (source.width - px) * scale + - sinAngle * (source.height - py) * scale + + ox + ) + dy = ( + sinAngle * (source.width - px) * scale + + cosAngle * (source.height - py) * scale + + oy + ) + if dx < minx: + minx = int(round(dx)) + if dx > maxx: + maxx = int(round(dx)) + if dy < miny: + miny = int(round(dy)) + if dy > maxy: + maxy = int(round(dy)) + + dx = -cosAngle * px * scale - sinAngle * (source.height - py) * scale + ox + dy = -sinAngle * px * scale + cosAngle * (source.height - py) * scale + oy + if dx < minx: + minx = int(round(dx)) + if dx > maxx: + maxx = int(round(dx)) + if dy < miny: + miny = int(round(dy)) + if dy > maxy: + maxy = int(round(dy)) + + # /* Clipping */ + if minx < dest_clip0[0]: + minx = dest_clip0[0] + if maxx > dest_clip1[0] - 1: + maxx = dest_clip1[0] - 1 + if miny < dest_clip0[1]: + miny = dest_clip0[1] + if maxy > dest_clip1[1] - 1: + maxy = dest_clip1[1] - 1 + + dvCol = math.cos(angle) / scale + duCol = math.sin(angle) / scale + + duRow = dvCol + dvRow = -duCol + + startu = px - (ox * dvCol + oy * duCol) + startv = py - (ox * dvRow + oy * duRow) + + rowu = startu + miny * duCol + rowv = startv + miny * dvCol + + for y in range(miny, maxy + 1): # (y = miny, y <= maxy, y++) + u = rowu + minx * duRow + v = rowv + minx * dvRow + for x in range(minx, maxx + 1): # (x = minx, x <= maxx, x++) + if (source_clip0[0] <= u < source_clip1[0]) and ( + source_clip0[1] <= v < source_clip1[1] + ): + # get the pixel color (c) from the source bitmap at (u,v) + c = source[ + int(u) + source.width * int(v) + ] # direct index into bitmap is faster than tuple + # c = source[int(u), int(v)] + + if c != skip_index: # ignore any pixels with skip_index + # place the pixel color (c) into the destination bitmap at (x,y) + destination[ + x + y * destination.width + ] = c # direct index into bitmap is faster than tuple + # destination[x,y] = c + u += duRow + v += dvRow + + rowu += duCol + rowv += dvCol + + +# Circle size calculations based on the angle intervals requested +# Algorithm source +# https://math.stackexchange.com/questions/45303/how-to-get-rectangular-size-of-arbitrary-circular-sector +def _isInInterval(theta, interval): + theta = theta % 360 + i = interval[0] % 360 + f = interval[1] % 360 + if i < f: + return (i <= theta <= f) and (theta <= f) + return not f < theta < i + + +def _getXcoord(theta): + return (1 + math.cos(theta * 2 * math.pi / 360)) / 2 + + +def _getYcoord(theta): + return (1 + math.sin(theta * 2 * math.pi / 360)) / 2 + + +def _getCoords(interval, ignore_center=False): + # This functions gets the maximum bounary dimensions of + # a rectangle required to contain a partial circle with + # the interval of (start_angle, end_angle) + # + # Parameter: + # interval = [start_angle, end_angle] + # ignore_center = Set True to exclude the centerpoint from the boundary + # + # Coordinates for calculations + # 0 degrees is up + # Circle diameter = 1.0 + # circle center is always at (0.5, 0.5) + # upper left direction is (0.0, 0.0) + # dimensions are in units of the circle's diameter (1.0 = diameter) + # + # Returns: + # (left, top, right, bottom, xCenter_offet, yCenter_offset) + # coordinates of the minimum bounding box + # and the xCenter_offset, yCenter_offset distance between + # the upper left corner and the circle center + + i = interval[0] + f = interval[1] + + xi = _getXcoord(i) + yi = _getYcoord(i) + xf = _getXcoord(f) + yf = _getYcoord(f) + + is0 = _isInInterval(0, interval) + is90 = _isInInterval(90, interval) + is180 = _isInInterval(180, interval) + is270 = _isInInterval(270, interval) + + if is0: + top = 1.0 + else: + if ignore_center: + top = max(xi, xf) + else: + top = max(xi, xf, 0.5) + + if is90: + right = 1.0 + else: + if ignore_center: + right = max(yi, yf) + else: + right = max(yi, yf, 0.5) + + if is180: + bottom = 0 + else: + if ignore_center: + bottom = min(xi, xf) + else: + bottom = min(xi, xf, 0.5) + + if is270: + left = 0 + else: + if ignore_center: + left = min(yi, yf) + else: + left = min(yi, yf, 0.5) + + xCenter_offset = 0.5 - left + yCenter_offset = 0.5 - top + + # Correct coordinates so that upper left corner is (0,0) + # Center is always at coordinate (0.5, 0.5) + # All coordinates are in units of the circle's diameter + # x,y Center_offset is the center point's offset relative to the upper left corner + # (left, top, right, bottom, xCenter_offet, yCenter_offset) + return [left, 1 - top, right, 1 - bottom, xCenter_offset, -yCenter_offset] + + +# Calculate the intersection point between two lines +# Source: +# https://stackoverflow.com/questions/20677795/how-do-i-compute-the-intersection-point-of-two-lines + + +def _line_intersection(line1, line2): + xdiff = (line1[0][0] - line1[1][0], line2[0][0] - line2[1][0]) + ydiff = (line1[0][1] - line1[1][1], line2[0][1] - line2[1][1]) + + def _det(a, b): + return a[0] * b[1] - a[1] * b[0] + + div = _det(xdiff, ydiff) + if div == 0: + raise Exception("lines do not intersect") + + d = (_det(*line1), _det(*line2)) + x = _det(d, xdiff) / div + y = _det(d, ydiff) / div + return round(x), round(y) diff --git a/docs/api.rst b/docs/api.rst index 0ab5305..8f3cdae 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -23,6 +23,13 @@ .. inheritance-diagram:: adafruit_displayio_layout.widgets.switch_round +.. automodule:: adafruit_displayio_layout.widgets.dial + :members: + :member-order: bysource + :inherited-members: + +.. inheritance-diagram:: adafruit_displayio_layout.widgets.dial + .. automodule:: adafruit_displayio_layout.widgets.icon_widget :members: :member-order: bysource diff --git a/docs/conf.py b/docs/conf.py index fca1f66..2389a35 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,8 @@ autodoc_mock_imports = [ "displayio", "adafruit_display_shapes", + "vectorio", + "bitmaptools", "terminalio", "adafruit_imageload", "adafruit_display_text", @@ -43,7 +45,7 @@ } # Show the docstring from both the class and its __init__() method. -autoclass_content = "both" +autoclass_content = "init" # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/dial.gif b/docs/dial.gif new file mode 100644 index 0000000..778c4ea Binary files /dev/null and b/docs/dial.gif differ diff --git a/docs/dial.gif.license b/docs/dial.gif.license new file mode 100644 index 0000000..74baa7b --- /dev/null +++ b/docs/dial.gif.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 Kevin Matocha + +SPDX-License-Identifier: MIT diff --git a/docs/dial_variables_angles.png b/docs/dial_variables_angles.png new file mode 100755 index 0000000..9441274 Binary files /dev/null and b/docs/dial_variables_angles.png differ diff --git a/docs/dial_variables_angles.png.license b/docs/dial_variables_angles.png.license new file mode 100644 index 0000000..74baa7b --- /dev/null +++ b/docs/dial_variables_angles.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 Kevin Matocha + +SPDX-License-Identifier: MIT diff --git a/docs/dial_variables_clip_needle.png b/docs/dial_variables_clip_needle.png new file mode 100644 index 0000000..c1e1163 Binary files /dev/null and b/docs/dial_variables_clip_needle.png differ diff --git a/docs/dial_variables_clip_needle.png.license b/docs/dial_variables_clip_needle.png.license new file mode 100644 index 0000000..74baa7b --- /dev/null +++ b/docs/dial_variables_clip_needle.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 Kevin Matocha + +SPDX-License-Identifier: MIT diff --git a/docs/dial_variables_min_max_values.png b/docs/dial_variables_min_max_values.png new file mode 100755 index 0000000..f110b6a Binary files /dev/null and b/docs/dial_variables_min_max_values.png differ diff --git a/docs/dial_variables_min_max_values.png.license b/docs/dial_variables_min_max_values.png.license new file mode 100644 index 0000000..74baa7b --- /dev/null +++ b/docs/dial_variables_min_max_values.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 Kevin Matocha + +SPDX-License-Identifier: MIT diff --git a/docs/dial_variables_ticks.png b/docs/dial_variables_ticks.png new file mode 100755 index 0000000..35aec45 Binary files /dev/null and b/docs/dial_variables_ticks.png differ diff --git a/docs/dial_variables_ticks.png.license b/docs/dial_variables_ticks.png.license new file mode 100644 index 0000000..74baa7b --- /dev/null +++ b/docs/dial_variables_ticks.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2021 Kevin Matocha + +SPDX-License-Identifier: MIT diff --git a/examples/displayio_layout_dial_simpletest.py b/examples/displayio_layout_dial_simpletest.py new file mode 100644 index 0000000..8ca487b --- /dev/null +++ b/examples/displayio_layout_dial_simpletest.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2021 Kevin Matocha +# +# SPDX-License-Identifier: MIT +############################# +""" +This is a basic demonstration of a Dial widget. +""" + +import time +import board +import displayio +import terminalio +from adafruit_displayio_layout.widgets.dial import Dial + +# Fonts used for the Dial tick labels +tick_font = terminalio.FONT + +display = board.DISPLAY # create the display on the PyPortal or Clue (for example) +# otherwise change this to setup the display +# for display chip driver and pinout you have (e.g. ILI9341) + + +# Define the minimum and maximum values for the dial +minimum_value = 0 +maximum_value = 100 + +# Create a Dial widget +my_dial = Dial( + x=20, # set x-position of the dial inside of my_group + y=20, # set y-position of the dial inside of my_group + width=180, # requested width of the dial + height=180, # requested height of the dial + padding=25, # add 25 pixels around the dial to make room for labels + start_angle=-120, # left angle position at -120 degrees + sweep_angle=240, # total sweep angle of 240 degrees + min_value=minimum_value, # set the minimum value shown on the dial + max_value=maximum_value, # set the maximum value shown on the dial + tick_label_font=tick_font, # the font used for the tick labels + tick_label_scale=2.0, # the scale factor for the tick label font +) + +my_group = displayio.Group(max_size=1) +my_group.append(my_dial) + +display.show(my_group) # add high level Group to the display + +step_size = 1 + +while True: + + # run the dial from minimum to maximum + for this_value in range(minimum_value, maximum_value + 1, step_size): + my_dial.value = this_value + display.refresh() # force the display to refresh + time.sleep(0.5) + + # run the dial from maximum to minimum + for this_value in range(maximum_value, minimum_value - 1, -step_size): + my_dial.value = this_value + display.refresh() # force the display to refresh + time.sleep(0.5)