# SPDX-FileCopyrightText: 2024 Tim Cocks for Adafruit Industries # # SPDX-License-Identifier: MIT """ `adafruit_display_text.text_box` ================================================================================ Text graphics handling for CircuitPython, including text boxes * Author(s): Tim Cocks Implementation Notes -------------------- **Hardware:** **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: https://circuitpython.org/downloads """ __version__ = "3.2.2" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git" import displayio from micropython import const from adafruit_display_text import wrap_text_to_pixels from adafruit_display_text import bitmap_label try: from typing import Optional, Tuple from fontio import FontProtocol except ImportError: pass # pylint: disable=too-many-instance-attributes, duplicate-code class TextBox(bitmap_label.Label): """ TextBox has a constrained width and optionally height. You set the desired size when it's initialized it will automatically wrap text to fit it within the allotted size. Left, Right, and Center alignment of the text within the box are supported. :param font: The font to use for the TextBox. :param width: The width of the TextBox in pixels. :param height: The height of the TextBox in pixels. :param align: How to align the text within the box, valid values are ``ALIGN_LEFT``, ``ALIGN_CENTER``, ``ALIGN_RIGHT``. """ ALIGN_LEFT = const(0) ALIGN_CENTER = const(1) ALIGN_RIGHT = const(2) DYNAMIC_HEIGHT = const(-1) def __init__( self, font: FontProtocol, width: int, height: int, align=ALIGN_LEFT, **kwargs ) -> None: self._bitmap = None self._tilegrid = None self._prev_label_direction = None self._width = width if height != TextBox.DYNAMIC_HEIGHT: self._height = height self.dynamic_height = False else: self.dynamic_height = True if align not in (TextBox.ALIGN_LEFT, TextBox.ALIGN_CENTER, TextBox.ALIGN_RIGHT): raise ValueError( "Align must be one of: ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT" ) self._align = align self._padding_left = kwargs.get("padding_left", 0) self._padding_right = kwargs.get("padding_right", 0) self.lines = wrap_text_to_pixels( kwargs.get("text", ""), self._width - self._padding_left - self._padding_right, font, ) super(bitmap_label.Label, self).__init__(font, **kwargs) print(f"before reset: {self._text}") self._text = "\n".join(self.lines) self._text = self._replace_tabs(self._text) self._original_text = self._text # call the text updater with all the arguments. self._reset_text( font=font, text=self._text, line_spacing=self._line_spacing, scale=self.scale, ) print(f"after reset: {self._text}") def _place_text( self, bitmap: displayio.Bitmap, text: str, font: FontProtocol, xposition: int, yposition: int, skip_index: int = 0, # set to None to write all pixels, other wise skip this palette index # when copying glyph bitmaps (this is important for slanted text # where rectangular glyph boxes overlap) ) -> Tuple[int, int, int, int]: # pylint: disable=too-many-arguments, too-many-locals, too-many-statements, too-many-branches # placeText - Writes text into a bitmap at the specified location. # # Note: scale is pushed up to Group level original_xposition = xposition cur_line_index = 0 cur_line_width = self._text_bounding_box(self.lines[0], self.font)[0] if self.align == self.ALIGN_LEFT: x_start = original_xposition # starting x position (left margin) if self.align == self.ALIGN_CENTER: unused_space = self._width - cur_line_width x_start = original_xposition + unused_space // 2 if self.align == self.ALIGN_RIGHT: unused_space = self._width - cur_line_width x_start = original_xposition + unused_space - self._padding_right xposition = x_start # pylint: disable=used-before-assignment y_start = yposition # print(f"start loc {x_start}, {y_start}") left = None right = x_start top = bottom = y_start line_spacing = self._line_spacing # print(f"cur_line width: {cur_line_width}") for char in text: if char == "\n": # newline cur_line_index += 1 cur_line_width = self._text_bounding_box( self.lines[cur_line_index], self.font )[0] # print(f"cur_line width: {cur_line_width}") if self.align == self.ALIGN_LEFT: x_start = original_xposition # starting x position (left margin) if self.align == self.ALIGN_CENTER: unused_space = self._width - cur_line_width x_start = original_xposition + unused_space // 2 if self.align == self.ALIGN_RIGHT: unused_space = self._width - cur_line_width x_start = original_xposition + unused_space - self._padding_right xposition = x_start yposition = yposition + self._line_spacing_ypixels( font, line_spacing ) # Add a newline else: my_glyph = font.get_glyph(ord(char)) if my_glyph is None: # Error checking: no glyph found print("Glyph not found: {}".format(repr(char))) else: if xposition == x_start: if left is None: left = 0 else: left = min(left, my_glyph.dx) right = max( right, xposition + my_glyph.shift_x, xposition + my_glyph.width + my_glyph.dx, ) if yposition == y_start: # first line, find the Ascender height top = min(top, -my_glyph.height - my_glyph.dy) bottom = max(bottom, yposition - my_glyph.dy) glyph_offset_x = ( my_glyph.tile_index * my_glyph.width ) # for type BuiltinFont, this creates the x-offset in the glyph bitmap. # for BDF loaded fonts, this should equal 0 y_blit_target = yposition - my_glyph.height - my_glyph.dy # Clip glyph y-direction if outside the font ascent/descent metrics. # Note: bitmap.blit will automatically clip the bottom of the glyph. y_clip = 0 if y_blit_target < 0: y_clip = -y_blit_target # clip this amount from top of bitmap y_blit_target = 0 # draw the clipped bitmap at y=0 if self._verbose: print( 'Warning: Glyph clipped, exceeds Ascent property: "{}"'.format( char ) ) if (y_blit_target + my_glyph.height) > bitmap.height: if self._verbose: print( 'Warning: Glyph clipped, exceeds descent property: "{}"'.format( char ) ) try: self._blit( bitmap, max(xposition + my_glyph.dx, 0), y_blit_target, my_glyph.bitmap, x_1=glyph_offset_x, y_1=y_clip, x_2=glyph_offset_x + my_glyph.width, y_2=my_glyph.height, skip_index=skip_index, # do not copy over any 0 background pixels ) except ValueError: # ignore index out of bounds error break xposition = xposition + my_glyph.shift_x # bounding_box return left, top, right - left, bottom - top def _reset_text( self, font: Optional[FontProtocol] = None, text: Optional[str] = None, line_spacing: Optional[float] = None, scale: Optional[int] = None, ) -> None: # pylint: disable=too-many-branches, too-many-statements, too-many-locals # Store all the instance variables if font is not None: self._font = font if line_spacing is not None: self._line_spacing = line_spacing # if text is not provided as a parameter (text is None), use the previous value. if text is None: text = self._text self._text = self._replace_tabs(text) print(f"inside reset_text text: {text}") # Check for empty string if (text == "") or ( text is None ): # If empty string, just create a zero-sized bounding box and that's it. self._bounding_box = ( 0, 0, 0, # zero width with text == "" 0, # zero height with text == "" ) # Clear out any items in the self._local_group Group, in case this is an # update to the bitmap_label for _ in self._local_group: self._local_group.pop(0) # Free the bitmap and tilegrid since they are removed self._bitmap = None self._tilegrid = None else: # The text string is not empty, so create the Bitmap and TileGrid and # append to the self Group # Calculate the text bounding box # Calculate both "tight" and "loose" bounding box dimensions to match label for # anchor_position calculations ( box_x, tight_box_y, x_offset, tight_y_offset, loose_box_y, loose_y_offset, ) = self._text_bounding_box( text, self._font, ) # calculate the box size for a tight and loose backgrounds if self._background_tight: box_y = tight_box_y y_offset = tight_y_offset self._padding_left = 0 self._padding_right = 0 self._padding_top = 0 self._padding_bottom = 0 else: # calculate the box size for a loose background box_y = loose_box_y y_offset = loose_y_offset # Calculate the background size including padding tight_box_x = box_x box_x = box_x + self._padding_left + self._padding_right box_y = box_y + self._padding_top + self._padding_bottom if self.dynamic_height: print(f"dynamic height, box_y: {box_y}") self._height = box_y # Create the Bitmap unless it can be reused new_bitmap = None if ( self._bitmap is None or self._bitmap.width != self._width or self._bitmap.height != self._height ): new_bitmap = displayio.Bitmap( self._width, self._height, len(self._palette) ) self._bitmap = new_bitmap else: self._bitmap.fill(0) # Place the text into the Bitmap self._place_text( self._bitmap, text, self._font, self._padding_left - x_offset, self._padding_top + y_offset, ) if self._base_alignment: label_position_yoffset = 0 else: label_position_yoffset = self._ascent // 2 # Create the TileGrid if not created bitmap unchanged if self._tilegrid is None or new_bitmap: self._tilegrid = displayio.TileGrid( self._bitmap, pixel_shader=self._palette, width=1, height=1, tile_width=self._width, tile_height=self._height, default_tile=0, x=-self._padding_left + x_offset, y=label_position_yoffset - y_offset - self._padding_top, ) # Clear out any items in the local_group Group, in case this is an update to # the bitmap_label for _ in self._local_group: self._local_group.pop(0) self._local_group.append( self._tilegrid ) # add the bitmap's tilegrid to the group self._bounding_box = ( self._tilegrid.x + self._padding_left, self._tilegrid.y + self._padding_top, tight_box_x, tight_box_y, ) print(f"end of reset_text bounding box: {self._bounding_box}") if ( scale is not None ): # Scale will be defined in local_group (Note: self should have scale=1) self.scale = scale # call the setter # set the anchored_position with setter after bitmap is created, sets the # x,y positions of the label self.anchored_position = self._anchored_position @property def height(self) -> int: """The height of the label determined from the bounding box.""" return self._height @property def width(self) -> int: """The width of the label determined from the bounding box.""" return self._width @width.setter def width(self, width: int) -> None: self._width = width self.text = self._text @height.setter def height(self, height: int) -> None: if height != TextBox.DYNAMIC_HEIGHT: self._height = height self.dynamic_height = False else: self.dynamic_height = True self.text = self._text @bitmap_label.Label.text.setter def text(self, text: str) -> None: self.lines = wrap_text_to_pixels( text, self._width - self._padding_left - self._padding_right, self.font ) self._text = self._replace_tabs(text) self._original_text = self._text self._text = "\n".join(self.lines) self._set_text(self._text, self.scale) @property def align(self): """Alignment of the text within the TextBox""" return self._align @align.setter def align(self, align: int) -> None: if align not in (TextBox.ALIGN_LEFT, TextBox.ALIGN_CENTER, TextBox.ALIGN_RIGHT): raise ValueError( "Align must be one of: ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT" ) self._align = align