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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy