# The MIT License (MIT) # # Copyright (c) 2019 Scott Shawcroft for Adafruit Industries LLC # # 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. """ `adafruit_display_text.label` ==================================================== Displays text labels using CircuitPython's displayio. * Author(s): Scott Shawcroft Implementation Notes -------------------- **Hardware:** **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://github.com/adafruit/circuitpython/releases """ import displayio __version__ = "0.0.0-auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git" class Label(displayio.Group): """A label displaying a string of text. The origin point set by ``x`` and ``y`` properties will be the left edge of the bounding box, and in the center of a M glyph (if its one line), or the (number of lines * linespacing + M)/2. That is, it will try to have it be center-left as close as possible. :param Font font: A font class that has ``get_bounding_box`` and ``get_glyph``. Must include a capital M for measuring character size. :param str text: Text to display :param int max_glyphs: The largest quantity of glyphs we will display :param int color: Color of all text in RGB hex :param double line_spacing: Line spacing of text to display""" # pylint: disable=too-many-instance-attributes, too-many-locals # This has a lot of getters/setters, maybe it needs cleanup. def __init__( self, font, *, x=0, y=0, text="", max_glyphs=None, color=0xFFFFFF, background_color=None, line_spacing=1.25, background_tight=False, padding_top=0, padding_bottom=0, padding_left=0, padding_right=0, anchor_point=None, anchored_position=None, scale=1, **kwargs ): if not max_glyphs and not text: raise RuntimeError("Please provide a max size, or initial text") if not max_glyphs: max_glyphs = len(text) # add one to max_size for the background bitmap tileGrid # instance the Group # self Group will contain a single local_group which contains a Group (self.local_group) # which contains a TileGrid super().__init__( max_size=1, scale=1, **kwargs ) # The self scale should always be 1 self.local_group = displayio.Group( max_size=max_glyphs + 1, scale=scale ) # local_group will set the scale self.append(self.local_group) self.width = max_glyphs self._font = font self._text = None self._anchor_point = anchor_point self.x = x self.y = y self.height = self._font.get_bounding_box()[1] self._line_spacing = line_spacing self._boundingbox = None self._background_tight = ( background_tight # sets padding status for text background box ) # Create the two-color text palette self.palette = displayio.Palette(2) self.palette[0] = 0 self.palette.make_transparent(0) self.color = color self._background_color = background_color self._background_palette = displayio.Palette(1) self._added_background_tilegrid = False self._padding_top = padding_top self._padding_bottom = padding_bottom self._padding_left = padding_left self._padding_right = padding_right self._scale = scale if text is not None: self._update_text(str(text)) if (anchored_position is not None) and (anchor_point is not None): self.anchored_position = anchored_position def _create_background_box(self, lines, y_offset): left = self._boundingbox[0] if self._background_tight: # draw a tight bounding box box_width = self._boundingbox[2] box_height = self._boundingbox[3] x_box_offset = 0 y_box_offset = self._boundingbox[1] else: # draw a "loose" bounding box to include any ascenders/descenders. ascent, descent = self._get_ascent_descent() box_width = self._boundingbox[2] + self._padding_left + self._padding_right x_box_offset = -self._padding_left box_height = ( (ascent + descent) + int((lines - 1) * self.height * self._line_spacing) + self._padding_top + self._padding_bottom ) y_box_offset = -ascent + y_offset - self._padding_top box_width = max(0, box_width) # remove any negative values box_height = max(0, box_height) # remove any negative values background_bitmap = displayio.Bitmap(box_width, box_height, 1) tile_grid = displayio.TileGrid( background_bitmap, pixel_shader=self._background_palette, x=left + x_box_offset, y=y_box_offset, ) return tile_grid def _get_ascent_descent(self): if hasattr(self.font, "ascent"): return self.font.ascent, self.font.descent # check a few glyphs for maximum ascender and descender height glyphs = "M j'" # choose glyphs with highest ascender and lowest try: self._font.load_glyphs(glyphs) except AttributeError: # Builtin font doesn't have or need load_glyphs pass # descender, will depend upon font used ascender_max = descender_max = 0 for char in glyphs: this_glyph = self._font.get_glyph(ord(char)) if this_glyph: ascender_max = max(ascender_max, this_glyph.height + this_glyph.dy) descender_max = max(descender_max, -this_glyph.dy) return ascender_max, descender_max def _get_ascent(self): return self._get_ascent_descent()[0] def _update_background_color(self, new_color): if new_color is None: self._background_palette.make_transparent(0) if self._added_background_tilegrid: self.local_group.pop(0) self._added_background_tilegrid = False else: self._background_palette.make_opaque(0) self._background_palette[0] = new_color self._background_color = new_color lines = self._text.rstrip("\n").count("\n") + 1 y_offset = self._get_ascent() // 2 if not self._added_background_tilegrid: # no bitmap is in the self Group # add bitmap if text is present and bitmap sizes > 0 pixels if ( (len(self._text) > 0) and ( self._boundingbox[2] + self._padding_left + self._padding_right > 0 ) and ( self._boundingbox[3] + self._padding_top + self._padding_bottom > 0 ) ): if ( len(self.local_group) > 0 ): # This can be simplified in CP v6.0, when group.append(0) bug is corrected self.local_group.insert( 0, self._create_background_box(lines, y_offset) ) else: self.local_group.append( self._create_background_box(lines, y_offset) ) self._added_background_tilegrid = True else: # a bitmap is present in the self Group # update bitmap if text is present and bitmap sizes > 0 pixels if ( (len(self._text) > 0) and ( self._boundingbox[2] + self._padding_left + self._padding_right > 0 ) and ( self._boundingbox[3] + self._padding_top + self._padding_bottom > 0 ) ): self.local_group[0] = self._create_background_box(lines, y_offset) else: # delete the existing bitmap self.local_group.pop(0) self._added_background_tilegrid = False def _update_text( self, new_text ): # pylint: disable=too-many-locals ,too-many-branches, too-many-statements x = 0 y = 0 if self._added_background_tilegrid: i = 1 else: i = 0 tilegrid_count = i y_offset = self._get_ascent() // 2 right = top = bottom = 0 left = None for character in new_text: if character == "\n": y += int(self.height * self._line_spacing) x = 0 continue glyph = self._font.get_glyph(ord(character)) if not glyph: continue right = max(right, x + glyph.shift_x, x + glyph.width + glyph.dx) if x == 0: if left is None: left = glyph.dx else: left = min(left, glyph.dx) if y == 0: # first line, find the Ascender height top = min(top, -glyph.height - glyph.dy + y_offset) bottom = max(bottom, y - glyph.dy + y_offset) position_y = y - glyph.height - glyph.dy + y_offset position_x = x + glyph.dx if glyph.width > 0 and glyph.height > 0: try: # pylint: disable=unexpected-keyword-arg face = displayio.TileGrid( glyph.bitmap, pixel_shader=self.palette, default_tile=glyph.tile_index, tile_width=glyph.width, tile_height=glyph.height, position=(position_x, position_y), ) except TypeError: face = displayio.TileGrid( glyph.bitmap, pixel_shader=self.palette, default_tile=glyph.tile_index, tile_width=glyph.width, tile_height=glyph.height, x=position_x, y=position_y, ) if tilegrid_count < len(self.local_group): self.local_group[tilegrid_count] = face else: self.local_group.append(face) tilegrid_count += 1 x += glyph.shift_x i += 1 # Remove the rest if left is None: left = 0 while len(self.local_group) > tilegrid_count: # i: self.local_group.pop() self._text = new_text self._boundingbox = (left, top, right - left, bottom - top) if self.background_color is not None: self._update_background_color(self._background_color) @property def bounding_box(self): """An (x, y, w, h) tuple that completely covers all glyphs. The first two numbers are offset from the x, y origin of this group""" return tuple(self._boundingbox) @property def line_spacing(self): """The amount of space between lines of text, in multiples of the font's bounding-box height. (E.g. 1.0 is the bounding-box height)""" return self._line_spacing @line_spacing.setter def line_spacing(self, spacing): self._line_spacing = spacing self.text = self._text # redraw the box @property def color(self): """Color of the text as an RGB hex number.""" return self.palette[1] @color.setter def color(self, new_color): self._color = new_color if new_color is not None: self.palette[1] = new_color self.palette.make_opaque(1) else: self.palette[1] = 0 self.palette.make_transparent(1) @property def background_color(self): """Color of the background as an RGB hex number.""" return self._background_color @background_color.setter def background_color(self, new_color): self._update_background_color(new_color) @property def text(self): """Text to display.""" return self._text @text.setter def text(self, new_text): try: current_anchored_position = self.anchored_position self._update_text(str(new_text)) self.anchored_position = current_anchored_position except RuntimeError as run_error: raise RuntimeError("Text length exceeds max_glyphs") from run_error @property def scale(self): """Set the scaling of the label, in integer values""" return self._scale @scale.setter def scale(self, new_scale): current_anchored_position = self.anchored_position self._scale = new_scale self.local_group.scale = new_scale self.anchored_position = current_anchored_position @property def font(self): """Font to use for text display.""" return self._font @font.setter def font(self, new_font): old_text = self._text current_anchored_position = self.anchored_position self._text = "" self._font = new_font self.height = self._font.get_bounding_box()[1] self._update_text(str(old_text)) self.anchored_position = current_anchored_position @property def anchor_point(self): """Point that anchored_position moves relative to. Tuple with decimal percentage of width and height. (E.g. (0,0) is top left, (1.0, 0.5): is middle right.)""" return self._anchor_point @anchor_point.setter def anchor_point(self, new_anchor_point): if self._anchor_point is not None: current_anchored_position = self.anchored_position self._anchor_point = new_anchor_point self.anchored_position = current_anchored_position else: self._anchor_point = new_anchor_point @property def anchored_position(self): """Position relative to the anchor_point. Tuple containing x,y pixel coordinates.""" if self._anchor_point is None: return None return ( int( self.x + (self._boundingbox[0] * self._scale) + round(self._anchor_point[0] * self._boundingbox[2] * self._scale) ), int( self.y + (self._boundingbox[1] * self._scale) + round(self._anchor_point[1] * self._boundingbox[3] * self._scale) ), ) @anchored_position.setter def anchored_position(self, new_position): if (self._anchor_point is None) or (new_position is None): return # Note: anchor_point must be set before setting anchored_position self.x = int( new_position[0] - (self._boundingbox[0] * self._scale) - round(self._anchor_point[0] * (self._boundingbox[2] * self._scale)) ) self.y = int( new_position[1] - (self._boundingbox[1] * self._scale) - round(self._anchor_point[1] * self._boundingbox[3] * self._scale) )