Adafruit_CircuitPython_Disp.../adafruit_display_text/label.py
2021-01-07 18:21:25 -06:00

458 lines
16 KiB
Python
Executable file

# The MIT License (MIT)
#
# Copyright (c) 2019 Scott Shawcroft for Adafruit Industries LLC
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
`adafruit_display_text.label`
====================================================
Displays text labels using CircuitPython's displayio.
* Author(s): Scott Shawcroft
Implementation Notes
--------------------
**Hardware:**
**Software and Dependencies:**
* Adafruit CircuitPython firmware for the supported boards:
https://github.com/adafruit/circuitpython/releases
"""
import displayio
__version__ = "0.0.0-auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git"
class Label(displayio.Group):
"""A label displaying a string of text. The origin point set by ``x`` and ``y``
properties will be the left edge of the bounding box, and in the center of a M
glyph (if its one line), or the (number of lines * linespacing + M)/2. That is,
it will try to have it be center-left as close as possible.
:param Font font: A font class that has ``get_bounding_box`` and ``get_glyph``.
Must include a capital M for measuring character size.
:param str text: Text to display
:param int max_glyphs: The largest quantity of glyphs we will display
:param int color: Color of all text in RGB hex
:param double line_spacing: Line spacing of text to display"""
# pylint: disable=too-many-instance-attributes, too-many-locals
# This has a lot of getters/setters, maybe it needs cleanup.
def __init__(
self,
font,
*,
x=0,
y=0,
text="",
max_glyphs=None,
color=0xFFFFFF,
background_color=None,
line_spacing=1.25,
background_tight=False,
padding_top=0,
padding_bottom=0,
padding_left=0,
padding_right=0,
anchor_point=None,
anchored_position=None,
scale=1,
**kwargs
):
if not max_glyphs and not text:
raise RuntimeError("Please provide a max size, or initial text")
if not max_glyphs:
max_glyphs = len(text)
# add one to max_size for the background bitmap tileGrid
# instance the Group
# self Group will contain a single local_group which contains a Group (self.local_group)
# which contains a TileGrid
super().__init__(
max_size=1, scale=1, **kwargs
) # The self scale should always be 1
self.local_group = displayio.Group(
max_size=max_glyphs + 1, scale=scale
) # local_group will set the scale
self.append(self.local_group)
self.width = max_glyphs
self._font = font
self._text = None
self._anchor_point = anchor_point
self.x = x
self.y = y
self.height = self._font.get_bounding_box()[1]
self._line_spacing = line_spacing
self._boundingbox = None
self._background_tight = (
background_tight # sets padding status for text background box
)
# Create the two-color text palette
self.palette = displayio.Palette(2)
self.palette[0] = 0
self.palette.make_transparent(0)
self.color = color
self._background_color = background_color
self._background_palette = displayio.Palette(1)
self._added_background_tilegrid = False
self._padding_top = padding_top
self._padding_bottom = padding_bottom
self._padding_left = padding_left
self._padding_right = padding_right
self._scale = scale
if text is not None:
self._update_text(str(text))
if (anchored_position is not None) and (anchor_point is not None):
self.anchored_position = anchored_position
def _create_background_box(self, lines, y_offset):
left = self._boundingbox[0]
if self._background_tight: # draw a tight bounding box
box_width = self._boundingbox[2]
box_height = self._boundingbox[3]
x_box_offset = 0
y_box_offset = self._boundingbox[1]
else: # draw a "loose" bounding box to include any ascenders/descenders.
ascent, descent = self._get_ascent_descent()
box_width = self._boundingbox[2] + self._padding_left + self._padding_right
x_box_offset = -self._padding_left
box_height = (
(ascent + descent)
+ int((lines - 1) * self.height * self._line_spacing)
+ self._padding_top
+ self._padding_bottom
)
y_box_offset = -ascent + y_offset - self._padding_top
box_width = max(0, box_width) # remove any negative values
box_height = max(0, box_height) # remove any negative values
background_bitmap = displayio.Bitmap(box_width, box_height, 1)
tile_grid = displayio.TileGrid(
background_bitmap,
pixel_shader=self._background_palette,
x=left + x_box_offset,
y=y_box_offset,
)
return tile_grid
def _get_ascent_descent(self):
if hasattr(self.font, "ascent"):
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
def _get_ascent(self):
return self._get_ascent_descent()[0]
def _update_background_color(self, new_color):
if new_color is None:
self._background_palette.make_transparent(0)
if self._added_background_tilegrid:
self.local_group.pop(0)
self._added_background_tilegrid = False
else:
self._background_palette.make_opaque(0)
self._background_palette[0] = new_color
self._background_color = new_color
lines = self._text.rstrip("\n").count("\n") + 1
y_offset = self._get_ascent() // 2
if not self._added_background_tilegrid: # no bitmap is in the self Group
# add bitmap if text is present and bitmap sizes > 0 pixels
if (
(len(self._text) > 0)
and (
self._boundingbox[2] + self._padding_left + self._padding_right > 0
)
and (
self._boundingbox[3] + self._padding_top + self._padding_bottom > 0
)
):
if (
len(self.local_group) > 0
): # This can be simplified in CP v6.0, when group.append(0) bug is corrected
self.local_group.insert(
0, self._create_background_box(lines, y_offset)
)
else:
self.local_group.append(
self._create_background_box(lines, y_offset)
)
self._added_background_tilegrid = True
else: # a bitmap is present in the self Group
# update bitmap if text is present and bitmap sizes > 0 pixels
if (
(len(self._text) > 0)
and (
self._boundingbox[2] + self._padding_left + self._padding_right > 0
)
and (
self._boundingbox[3] + self._padding_top + self._padding_bottom > 0
)
):
self.local_group[0] = self._create_background_box(lines, y_offset)
else: # delete the existing bitmap
self.local_group.pop(0)
self._added_background_tilegrid = False
def _update_text(
self, new_text
): # pylint: disable=too-many-locals ,too-many-branches, too-many-statements
x = 0
y = 0
if self._added_background_tilegrid:
i = 1
else:
i = 0
tilegrid_count = i
y_offset = self._get_ascent() // 2
right = top = bottom = 0
left = None
for character in new_text:
if character == "\n":
y += int(self.height * self._line_spacing)
x = 0
continue
glyph = self._font.get_glyph(ord(character))
if not glyph:
continue
right = max(right, x + glyph.shift_x, x + glyph.width + glyph.dx)
if x == 0:
if left is None:
left = glyph.dx
else:
left = min(left, glyph.dx)
if y == 0: # first line, find the Ascender height
top = min(top, -glyph.height - glyph.dy + y_offset)
bottom = max(bottom, y - glyph.dy + y_offset)
position_y = y - glyph.height - glyph.dy + y_offset
position_x = x + glyph.dx
if glyph.width > 0 and glyph.height > 0:
try:
# pylint: disable=unexpected-keyword-arg
face = displayio.TileGrid(
glyph.bitmap,
pixel_shader=self.palette,
default_tile=glyph.tile_index,
tile_width=glyph.width,
tile_height=glyph.height,
position=(position_x, position_y),
)
except TypeError:
face = displayio.TileGrid(
glyph.bitmap,
pixel_shader=self.palette,
default_tile=glyph.tile_index,
tile_width=glyph.width,
tile_height=glyph.height,
x=position_x,
y=position_y,
)
if tilegrid_count < len(self.local_group):
self.local_group[tilegrid_count] = face
else:
self.local_group.append(face)
tilegrid_count += 1
x += glyph.shift_x
i += 1
# Remove the rest
if left is None:
left = 0
while len(self.local_group) > tilegrid_count: # i:
self.local_group.pop()
self._text = new_text
self._boundingbox = (left, top, right - left, bottom - top)
if self.background_color is not None:
self._update_background_color(self._background_color)
@property
def bounding_box(self):
"""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._boundingbox)
@property
def line_spacing(self):
"""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
@line_spacing.setter
def line_spacing(self, spacing):
self._line_spacing = spacing
self.text = self._text # redraw the box
@property
def color(self):
"""Color of the text as an RGB hex number."""
return self.palette[1]
@color.setter
def color(self, new_color):
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):
"""Color of the background as an RGB hex number."""
return self._background_color
@background_color.setter
def background_color(self, new_color):
self._update_background_color(new_color)
@property
def text(self):
"""Text to display."""
return self._text
@text.setter
def text(self, new_text):
try:
current_anchored_position = self.anchored_position
self._update_text(str(new_text))
self.anchored_position = current_anchored_position
except RuntimeError as run_error:
raise RuntimeError("Text length exceeds max_glyphs") from run_error
@property
def scale(self):
"""Set the scaling of the label, in integer values"""
return self._scale
@scale.setter
def scale(self, new_scale):
current_anchored_position = self.anchored_position
self._scale = new_scale
self.local_group.scale = new_scale
self.anchored_position = current_anchored_position
@property
def font(self):
"""Font to use for text display."""
return self._font
@font.setter
def font(self, new_font):
old_text = self._text
current_anchored_position = self.anchored_position
self._text = ""
self._font = new_font
self.height = self._font.get_bounding_box()[1]
self._update_text(str(old_text))
self.anchored_position = current_anchored_position
@property
def anchor_point(self):
"""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):
if self._anchor_point is not None:
current_anchored_position = self.anchored_position
self._anchor_point = new_anchor_point
self.anchored_position = current_anchored_position
else:
self._anchor_point = new_anchor_point
@property
def anchored_position(self):
"""Position relative to the anchor_point. Tuple containing x,y
pixel coordinates."""
if self._anchor_point is None:
return None
return (
int(
self.x
+ (self._boundingbox[0] * self._scale)
+ round(self._anchor_point[0] * self._boundingbox[2] * self._scale)
),
int(
self.y
+ (self._boundingbox[1] * self._scale)
+ round(self._anchor_point[1] * self._boundingbox[3] * self._scale)
),
)
@anchored_position.setter
def anchored_position(self, new_position):
if (self._anchor_point is None) or (new_position is None):
return # Note: anchor_point must be set before setting anchored_position
self.x = int(
new_position[0]
- (self._boundingbox[0] * self._scale)
- round(self._anchor_point[0] * (self._boundingbox[2] * self._scale))
)
self.y = int(
new_position[1]
- (self._boundingbox[1] * self._scale)
- round(self._anchor_point[1] * self._boundingbox[3] * self._scale)
)