diff --git a/adafruit_display_text/text_box.py b/adafruit_display_text/text_box.py new file mode 100644 index 0000000..7f3d39d --- /dev/null +++ b/adafruit_display_text/text_box.py @@ -0,0 +1,435 @@ +# 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__ = "0.0.0+auto.0" +__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 diff --git a/docs/api.rst b/docs/api.rst index 00350e7..bb959a9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -19,3 +19,6 @@ .. automodule:: adafruit_display_text.outlined_label :members: + +.. automodule:: adafruit_display_text.text_box + :members: diff --git a/examples/display_text_text_box_simpletest.py b/examples/display_text_text_box_simpletest.py new file mode 100644 index 0000000..1512f7c --- /dev/null +++ b/examples/display_text_text_box_simpletest.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2024 foamyguy for Adafruit Industries +# SPDX-License-Identifier: MIT + +import board +import displayio +import terminalio +from adafruit_display_text.text_box import TextBox + +main_group = displayio.Group() + +left_text = ("Left left left left " * 2).rstrip() +left_text_area = TextBox( + terminalio.FONT, + 190, + TextBox.DYNAMIC_HEIGHT, + align=TextBox.ALIGN_LEFT, + text=left_text, + background_color=0xFF00FF, + color=0x000000, + scale=1, +) + +left_text_area.x = 10 +left_text_area.y = 10 +main_group.append(left_text_area) + + +center_text = ("center center center " * 2).rstrip() +center_text_area = TextBox( + terminalio.FONT, + 190, + TextBox.DYNAMIC_HEIGHT, + align=TextBox.ALIGN_CENTER, + text=center_text, + background_color=0x00FF00, + color=0x000000, + scale=1, +) + +center_text_area.x = 10 +center_text_area.y = 10 + left_text_area.height + 10 +main_group.append(center_text_area) + + +right_text = ("right right right right " * 2).rstrip() +right_text_area = TextBox( + terminalio.FONT, + 190, + TextBox.DYNAMIC_HEIGHT, + align=TextBox.ALIGN_RIGHT, + text=right_text, + background_color=0xFFFF00, + color=0x000000, + scale=1, +) + +right_text_area.x = 10 +right_text_area.y = center_text_area.y + center_text_area.height + 10 +main_group.append(right_text_area) + +board.DISPLAY.root_group = main_group +while True: + pass