458 lines
16 KiB
Python
Executable file
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)
|
|
)
|