cardiograph-computer/python-microcontrollers/meowbit/adafruit_display_text/text_box.py

436 lines
15 KiB
Python

# 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