# SPDX-FileCopyrightText: 2020 Tim C, 2021 Jeff Epler for Adafruit Industries # # SPDX-License-Identifier: MIT """ `adafruit_display_text` ======================= """ try: from typing import Optional, Union, List, Tuple from fontio import BuiltinFont from adafruit_bitmap_font.bdf import BDF from adafruit_bitmap_font.pcf import PCF except ImportError: pass from displayio import Group, Palette def wrap_text_to_pixels( string: str, max_width: int, font: Optional[Union[BuiltinFont, BDF, PCF]] = None, indent0: str = "", indent1: str = "", ) -> List[str]: # pylint: disable=too-many-branches, too-many-locals, too-many-nested-blocks, too-many-statements """wrap_text_to_pixels function A helper that will return a list of lines with word-break wrapping. Leading and trailing whitespace in your string will be removed. If you wish to use leading whitespace see ``indent0`` and ``indent1`` parameters. :param str string: The text to be wrapped. :param int max_width: The maximum number of pixels on a line before wrapping. :param font: The font to use for measuring the text. :type font: ~BuiltinFont, ~BDF, or ~PCF :param str indent0: Additional character(s) to add to the first line. :param str indent1: Additional character(s) to add to all other lines. :return: A list of the lines resulting from wrapping the input text at ``max_width`` pixels size :rtype: List[str] """ if font is None: def measure(text): return len(text) else: if hasattr(font, "load_glyphs"): font.load_glyphs(string) def measure(text): return sum(font.get_glyph(ord(c)).shift_x for c in text) lines = [] partial = [indent0] width = measure(indent0) swidth = measure(" ") firstword = True for line_in_input in string.split("\n"): newline = True for index, word in enumerate(line_in_input.split(" ")): wwidth = measure(word) word_parts = [] cur_part = "" if wwidth > max_width: for char in word: if newline: extraspace = 0 leadchar = "" else: extraspace = swidth leadchar = " " if ( measure("".join(partial)) + measure(cur_part) + measure(char) + measure("-") + extraspace > max_width ): if cur_part: word_parts.append( "".join(partial) + leadchar + cur_part + "-" ) else: word_parts.append("".join(partial)) cur_part = char partial = [indent1] newline = True else: cur_part += char if cur_part: word_parts.append(cur_part) for line in word_parts[:-1]: lines.append(line) partial.append(word_parts[-1]) width = measure(word_parts[-1]) if firstword: firstword = False else: if firstword: partial.append(word) firstword = False width += wwidth elif width + swidth + wwidth < max_width: if index > 0: partial.append(" ") partial.append(word) width += wwidth + swidth else: lines.append("".join(partial)) partial = [indent1, word] width = measure(indent1) + wwidth if newline: newline = False lines.append("".join(partial)) partial = [indent1] width = measure(indent1) return lines def wrap_text_to_lines(string: str, max_chars: int) -> List[str]: """wrap_text_to_lines function A helper that will return a list of lines with word-break wrapping :param str string: The text to be wrapped :param int max_chars: The maximum number of characters on a line before wrapping :return: A list of lines where each line is separated based on the amount of ``max_chars`` provided :rtype: List[str] """ def chunks(lst, n): """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i : i + n] string = string.replace("\n", "").replace("\r", "") # Strip confusing newlines words = string.split(" ") the_lines = [] the_line = "" for w in words: if len(w) > max_chars: if the_line: # add what we had stored the_lines.append(the_line) parts = [] for part in chunks(w, max_chars - 1): parts.append("{}-".format(part)) the_lines.extend(parts[:-1]) the_line = parts[-1][:-1] continue if len(the_line + " " + w) <= max_chars: the_line += " " + w elif not the_line and len(w) == max_chars: the_lines.append(w) else: the_lines.append(the_line) the_line = "" + w if the_line: # Last line remaining the_lines.append(the_line) # Remove any blank lines while not the_lines[0]: del the_lines[0] # Remove first space from first line: if the_lines[0][0] == " ": the_lines[0] = the_lines[0][1:] return the_lines class LabelBase(Group): # pylint: disable=too-many-instance-attributes """Superclass that all other types of labels will extend. This contains all of the properties and functions that work the same way in all labels. **Note:** This should be treated as an abstract base class. Subclasses should implement ``_set_text``, ``_set_font``, and ``_set_line_spacing`` to have the correct behavior for that type of label. :param font: A font class that has ``get_bounding_box`` and ``get_glyph``. Must include a capital M for measuring character size. :type font: ~BuiltinFont, ~BDF, or ~PCF :param str text: Text to display :param int color: Color of all text in RGB hex :param int background_color: Color of the background, use `None` for transparent :param float line_spacing: Line spacing of text to display :param bool background_tight: Set `True` only if you want background box to tightly surround text. When set to 'True' Padding parameters will be ignored. :param int padding_top: Additional pixels added to background bounding box at top :param int padding_bottom: Additional pixels added to background bounding box at bottom :param int padding_left: Additional pixels added to background bounding box at left :param int padding_right: Additional pixels added to background bounding box at right :param (float,float) anchor_point: 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.) :param (int,int) anchored_position: Position relative to the anchor_point. Tuple containing x,y pixel coordinates. :param int scale: Integer value of the pixel scaling :param bool base_alignment: when True allows to align text label to the baseline. This is helpful when two or more labels need to be aligned to the same baseline :param (int,str) tab_replacement: tuple with tab character replace information. When (4, " ") will indicate a tab replacement of 4 spaces, defaults to 4 spaces by tab character :param str label_direction: string defining the label text orientation. See the subclass documentation for the possible values.""" def __init__( self, font: Union[BuiltinFont, BDF, PCF], x: int = 0, y: int = 0, text: str = "", color: int = 0xFFFFFF, background_color: int = None, line_spacing: float = 1.25, background_tight: bool = False, padding_top: int = 0, padding_bottom: int = 0, padding_left: int = 0, padding_right: int = 0, anchor_point: Tuple[float, float] = None, anchored_position: Tuple[int, int] = None, scale: int = 1, base_alignment: bool = False, tab_replacement: Tuple[int, str] = (4, " "), label_direction: str = "LTR", **kwargs, # pylint: disable=unused-argument ) -> None: # pylint: disable=too-many-arguments, too-many-locals super().__init__(x=x, y=y, scale=1) self._font = font self._text = text self._palette = Palette(2) self._color = 0xFFFFFF self._background_color = None self._line_spacing = line_spacing self._background_tight = background_tight self._padding_top = padding_top self._padding_bottom = padding_bottom self._padding_left = padding_left self._padding_right = padding_right self._anchor_point = anchor_point self._anchored_position = anchored_position self._base_alignment = base_alignment self._label_direction = label_direction self._tab_replacement = tab_replacement self._tab_text = self._tab_replacement[1] * self._tab_replacement[0] if "max_glyphs" in kwargs: print("Please update your code: 'max_glyphs' is not needed anymore.") self._ascent, self._descent = self._get_ascent_descent() self._bounding_box = None self.color = color self.background_color = background_color # local group will hold background and text # the self group scale should always remain at 1, the self._local_group will # be used to set the scale of the label self._local_group = Group(scale=scale) self.append(self._local_group) self._baseline = -1.0 if self._base_alignment: self._y_offset = 0 else: self._y_offset = self._ascent // 2 def _get_ascent_descent(self) -> Tuple[int, int]: """Private function to calculate ascent and descent font values""" if hasattr(self.font, "ascent") and hasattr(self.font, "descent"): 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 @property def font(self) -> Union[BuiltinFont, BDF, PCF]: """Font to use for text display.""" return self._font def _set_font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: raise NotImplementedError("{} MUST override '_set_font'".format(type(self))) @font.setter def font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: self._set_font(new_font) @property def color(self) -> int: """Color of the text as an RGB hex number.""" return self._color @color.setter def color(self, new_color: int): 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) -> int: """Color of the background as an RGB hex number.""" return self._background_color def _set_background_color(self, new_color): raise NotImplementedError( "{} MUST override '_set_background_color'".format(type(self)) ) @background_color.setter def background_color(self, new_color: int) -> None: self._set_background_color(new_color) @property def anchor_point(self) -> Tuple[float, float]: """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: Tuple[float, float]) -> None: if new_anchor_point[1] == self._baseline: self._anchor_point = (new_anchor_point[0], -1.0) else: self._anchor_point = new_anchor_point # update the anchored_position using setter self.anchored_position = self._anchored_position @property def anchored_position(self) -> Tuple[int, int]: """Position relative to the anchor_point. Tuple containing x,y pixel coordinates.""" return self._anchored_position @anchored_position.setter def anchored_position(self, new_position: Tuple[int, int]) -> None: self._anchored_position = new_position # Calculate (x,y) position if (self._anchor_point is not None) and (self._anchored_position is not None): self.x = int( new_position[0] - (self._bounding_box[0] * self.scale) - round(self._anchor_point[0] * (self._bounding_box[2] * self.scale)) ) if self._anchor_point[1] == self._baseline: self.y = int(new_position[1] - (self._y_offset * self.scale)) else: self.y = int( new_position[1] - (self._bounding_box[1] * self.scale) - round(self._anchor_point[1] * self._bounding_box[3] * self.scale) ) @property def scale(self) -> int: """Set the scaling of the label, in integer values""" return self._local_group.scale @scale.setter def scale(self, new_scale: int) -> None: self._local_group.scale = new_scale self.anchored_position = self._anchored_position # update the anchored_position def _set_text(self, new_text: str, scale: int) -> None: raise NotImplementedError("{} MUST override '_set_text'".format(type(self))) @property def text(self) -> str: """Text to be displayed.""" return self._text @text.setter # Cannot set color or background color with text setter, use separate setter def text(self, new_text: str) -> None: self._set_text(new_text, self.scale) @property def bounding_box(self) -> Tuple[int, int]: """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._bounding_box) @property def height(self) -> int: """The height of the label determined from the bounding box.""" return self._bounding_box[3] - self._bounding_box[1] @property def width(self) -> int: """The width of the label determined from the bounding box.""" return self._bounding_box[2] - self._bounding_box[0] @property def line_spacing(self) -> float: """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 def _set_line_spacing(self, new_line_spacing: float) -> None: raise NotImplementedError( "{} MUST override '_set_line_spacing'".format(type(self)) ) @line_spacing.setter def line_spacing(self, new_line_spacing: float) -> None: self._set_line_spacing(new_line_spacing) @property def label_direction(self) -> str: """Set the text direction of the label""" return self._label_direction def _set_label_direction(self, new_label_direction: str) -> None: raise NotImplementedError( "{} MUST override '_set_label_direction'".format(type(self)) ) def _get_valid_label_directions(self) -> Tuple[str, ...]: raise NotImplementedError( "{} MUST override '_get_valid_label_direction'".format(type(self)) ) @label_direction.setter def label_direction(self, new_label_direction: str) -> None: """Set the text direction of the label""" if new_label_direction not in self._get_valid_label_directions(): raise RuntimeError("Please provide a valid text direction") self._set_label_direction(new_label_direction) def _replace_tabs(self, text: str) -> str: return text if text.find("\t") < 0 else self._tab_text.join(text.split("\t"))