configurable text display scroll gap

This commit is contained in:
ChuckBuilds
2025-05-26 10:12:08 -05:00
parent 864b11e1e0
commit b3c32de3ae
2 changed files with 75 additions and 80 deletions

View File

@@ -225,7 +225,8 @@
"scroll": true, "scroll": true,
"scroll_speed": 40, "scroll_speed": 40,
"text_color": [255, 0, 0], "text_color": [255, 0, 0],
"background_color": [0, 0, 0] "background_color": [0, 0, 0],
"scroll_gap_width": 32
}, },
"soccer_scoreboard": { "soccer_scoreboard": {
"enabled": false, "enabled": false,

View File

@@ -19,11 +19,14 @@ class TextDisplay:
self.scroll_enabled = self.config.get('scroll', False) self.scroll_enabled = self.config.get('scroll', False)
self.text_color = tuple(self.config.get('text_color', [255, 255, 255])) self.text_color = tuple(self.config.get('text_color', [255, 255, 255]))
self.bg_color = tuple(self.config.get('background_color', [0, 0, 0])) self.bg_color = tuple(self.config.get('background_color', [0, 0, 0]))
# scroll_gap_width defaults to the width of the display matrix
self.scroll_gap_width = self.config.get('scroll_gap_width', self.display_manager.matrix.width)
self.font = self._load_font() self.font = self._load_font()
self.text_pixel_width = 0 # Authoritative width of the text self.text_content_width = 0 # Pixel width of the actual text string
self.text_image_cache = None # For pre-rendered text self.text_image_cache = None # For pre-rendered text (PIL.Image)
self.cached_total_scroll_width = 0 # Total width of the cache: text_content_width + scroll_gap_width
self._regenerate_renderings() # Initial creation of cache and width calculation self._regenerate_renderings() # Initial creation of cache and width calculation
@@ -34,82 +37,77 @@ class TextDisplay:
def _regenerate_renderings(self): def _regenerate_renderings(self):
"""Calculate text width and attempt to create/update the text image cache.""" """Calculate text width and attempt to create/update the text image cache."""
if not self.text or not self.font: if not self.text or not self.font:
self.text_pixel_width = 0 self.text_content_width = 0
self.text_image_cache = None self.text_image_cache = None
self.cached_total_scroll_width = 0
return return
# Always calculate the authoritative text width
try: try:
self.text_pixel_width = self.display_manager.get_text_width(self.text, self.font) self.text_content_width = self.display_manager.get_text_width(self.text, self.font)
except Exception as e: except Exception as e:
logger.error(f"Error calculating text width: {e}") logger.error(f"Error calculating text content width: {e}")
self.text_pixel_width = 0 self.text_content_width = 0
self.text_image_cache = None self.text_image_cache = None
self.cached_total_scroll_width = 0
return return
self._create_text_image_cache() self._create_text_image_cache()
self.scroll_pos = 0 # Reset scroll position when text changes self.scroll_pos = 0.0 # Reset scroll position when text/font/colors change
def _create_text_image_cache(self): def _create_text_image_cache(self):
"""Pre-render the text onto an image if using a TTF font.""" """Pre-render the text onto an image if using a TTF font. Includes a trailing gap."""
self.text_image_cache = None # Clear previous cache self.text_image_cache = None # Clear previous cache
self.cached_total_scroll_width = 0
if not self.text or not self.font or self.text_pixel_width == 0: if not self.text or not self.font or self.text_content_width == 0:
return return
if isinstance(self.font, freetype.Face): if isinstance(self.font, freetype.Face):
logger.info("TextDisplay: Pre-rendering cache is not used for BDF/freetype fonts. Will use direct drawing.") logger.info("TextDisplay: Pre-rendering cache is not used for BDF/freetype fonts. Will use direct drawing.")
# For BDF, the "scroll width" for reset purposes is handled by the direct drawing logic's conditions
return return
# --- TTF Caching Path --- # --- TTF Caching Path ---
try: try:
# Use a dummy image to get accurate text bounding box for vertical centering
dummy_img = Image.new('RGB', (1, 1)) dummy_img = Image.new('RGB', (1, 1))
dummy_draw = ImageDraw.Draw(dummy_img) dummy_draw = ImageDraw.Draw(dummy_img)
# Pillow's textbbox gives (left, top, right, bottom) relative to anchor (0,0)
bbox = dummy_draw.textbbox((0, 0), self.text, font=self.font) bbox = dummy_draw.textbbox((0, 0), self.text, font=self.font)
actual_text_render_height = bbox[3] - bbox[1] # The actual height of the pixels of the text actual_text_render_height = bbox[3] - bbox[1]
# bbox[1] is the y-offset from the drawing point (where text is anchored) to the top of the text.
# Total width of the cache is the text width plus the configured gap
self.cached_total_scroll_width = self.text_content_width + self.scroll_gap_width
cache_height = self.display_manager.matrix.height
cache_width = self.text_pixel_width self.text_image_cache = Image.new('RGB', (self.cached_total_scroll_width, cache_height), self.bg_color)
cache_height = self.display_manager.matrix.height # Cache is always full panel height
self.text_image_cache = Image.new('RGB', (cache_width, cache_height), self.bg_color)
draw_cache = ImageDraw.Draw(self.text_image_cache) draw_cache = ImageDraw.Draw(self.text_image_cache)
# Calculate y-position to draw the text on the cache for vertical centering.
# The drawing point for PIL's draw.text is typically the baseline.
# y_draw_on_cache = (desired_top_edge_of_text_on_cache) - bbox[1]
desired_top_edge = (cache_height - actual_text_render_height) // 2 desired_top_edge = (cache_height - actual_text_render_height) // 2
y_draw_on_cache = desired_top_edge - bbox[1] y_draw_on_cache = desired_top_edge - bbox[1]
# Draw the text at the beginning of the cache
draw_cache.text((0, y_draw_on_cache), self.text, font=self.font, fill=self.text_color) draw_cache.text((0, y_draw_on_cache), self.text, font=self.font, fill=self.text_color)
logger.info(f"TextDisplay: Created text cache for '{self.text[:30]}...' (TTF). Size: {cache_width}x{cache_height}") # The rest of the image (the gap) is already bg_color
logger.info(f"TextDisplay: Created text cache for '{self.text[:30]}...' (TTF). Text width: {self.text_content_width}, Gap: {self.scroll_gap_width}, Total cache width: {self.cached_total_scroll_width}x{cache_height}")
except Exception as e: except Exception as e:
logger.error(f"TextDisplay: Failed to create text image cache: {e}", exc_info=True) logger.error(f"TextDisplay: Failed to create text image cache: {e}", exc_info=True)
self.text_image_cache = None self.text_image_cache = None
self.cached_total_scroll_width = 0
def _load_font(self): def _load_font(self):
"""Load the specified font file (TTF or BDF).""" """Load the specified font file (TTF or BDF)."""
font_path = self.font_path font_path = self.font_path
# Try to resolve relative path from project root
if not os.path.isabs(font_path) and not font_path.startswith('assets/'): if not os.path.isabs(font_path) and not font_path.startswith('assets/'):
# Assuming relative paths are relative to the project root
# Adjust this logic if paths are relative to src or config
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
font_path = os.path.join(base_path, font_path) font_path = os.path.join(base_path, font_path)
elif not os.path.isabs(font_path) and font_path.startswith('assets/'): elif not os.path.isabs(font_path) and font_path.startswith('assets/'):
# Assuming 'assets/' path is relative to project root
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
font_path = os.path.join(base_path, font_path) font_path = os.path.join(base_path, font_path)
logger.info(f"Attempting to load font: {font_path} at size {self.font_size}") logger.info(f"Attempting to load font: {font_path} at size {self.font_size}")
if not os.path.exists(font_path): if not os.path.exists(font_path):
logger.error(f"Font file not found: {font_path}. Falling back to default.") logger.error(f"Font file not found: {font_path}. Falling back to default.")
return self.display_manager.regular_font # Use default from DisplayManager return self.display_manager.regular_font
try: try:
if font_path.lower().endswith('.ttf'): if font_path.lower().endswith('.ttf'):
@@ -117,10 +115,7 @@ class TextDisplay:
logger.info(f"Loaded TTF font: {self.font_path}") logger.info(f"Loaded TTF font: {self.font_path}")
return font return font
elif font_path.lower().endswith('.bdf'): elif font_path.lower().endswith('.bdf'):
# Use freetype for BDF fonts
face = freetype.Face(font_path) face = freetype.Face(font_path)
# BDF fonts often have fixed sizes, freetype handles this
# We might need to adjust how size is used or interpreted for BDF
face.set_pixel_sizes(0, self.font_size) face.set_pixel_sizes(0, self.font_size)
logger.info(f"Loaded BDF font: {self.font_path} with freetype") logger.info(f"Loaded BDF font: {self.font_path} with freetype")
return face return face
@@ -131,20 +126,23 @@ class TextDisplay:
logger.error(f"Failed to load font {font_path}: {e}", exc_info=True) logger.error(f"Failed to load font {font_path}: {e}", exc_info=True)
return self.display_manager.regular_font return self.display_manager.regular_font
def _calculate_text_width(self): # _calculate_text_width is effectively replaced by logic in _regenerate_renderings
"""Calculate the pixel width of the text with the loaded font.""" # but kept for direct calls if ever needed, or as a reference to DisplayManager's method
def _calculate_text_width(self):
"""DEPRECATED somewhat: Get text width. Relies on self.text_content_width set by _regenerate_renderings."""
try: try:
# This method is now largely superseded by _regenerate_renderings setting self.text_pixel_width
# Kept for potential direct calls or clarity, but should rely on self.text_pixel_width
return self.display_manager.get_text_width(self.text, self.font) return self.display_manager.get_text_width(self.text, self.font)
except Exception as e: except Exception as e:
logger.error(f"Error calculating text width: {e}") logger.error(f"Error calculating text width: {e}")
return 0 # Default to 0 if calculation fails return 0
def update(self): def update(self):
"""Update scroll position if scrolling is enabled.""" """Update scroll position if scrolling is enabled."""
if not self.scroll_enabled or self.text_pixel_width <= self.display_manager.matrix.width: # Scrolling is only meaningful if the actual text content is wider than the screen,
self.scroll_pos = 0.0 # Reset if not scrolling or text fits # or if a cache is used (which implies scrolling over text + gap).
# The condition self.text_content_width <= self.display_manager.matrix.width handles non-scrolling for static text.
if not self.scroll_enabled or (not self.text_image_cache and self.text_content_width <= self.display_manager.matrix.width):
self.scroll_pos = 0.0
return return
current_time = time.time() current_time = time.time()
@@ -154,14 +152,15 @@ class TextDisplay:
scroll_delta = delta_time * self.scroll_speed scroll_delta = delta_time * self.scroll_speed
self.scroll_pos += scroll_delta self.scroll_pos += scroll_delta
# Reset scroll position if self.text_image_cache:
if self.text_image_cache: # Using cached image, scroll_pos is offset into cache # Using cached image: scroll_pos loops over the total cache width (text + gap)
# Loop smoothly over the cached image width if self.cached_total_scroll_width > 0 and self.scroll_pos >= self.cached_total_scroll_width:
if self.scroll_pos >= self.text_pixel_width: self.scroll_pos %= self.cached_total_scroll_width
self.scroll_pos %= self.text_pixel_width else:
else: # Not using cache (e.g., BDF), original scroll logic for off-screen reset # Not using cache (e.g., BDF direct drawing):
# Reset when text fully scrolled past left edge + matrix width padding (appearance of starting from right) # Reset when text fully scrolled past left edge + matrix width (original behavior creating a conceptual gap)
if self.scroll_pos > self.text_pixel_width + self.display_manager.matrix.width: # self.text_content_width is used here as it refers to the actual text being drawn directly.
if self.text_content_width > 0 and self.scroll_pos > self.text_content_width + self.display_manager.matrix.width:
self.scroll_pos = 0.0 self.scroll_pos = 0.0
def display(self): def display(self):
@@ -170,67 +169,62 @@ class TextDisplay:
matrix_width = dm.matrix.width matrix_width = dm.matrix.width
matrix_height = dm.matrix.height matrix_height = dm.matrix.height
# Create a new image and draw context for the display manager for this frame
# Fill with background color. If cache is used, it also has bg color, so this is fine.
dm.image = Image.new('RGB', (matrix_width, matrix_height), self.bg_color) dm.image = Image.new('RGB', (matrix_width, matrix_height), self.bg_color)
dm.draw = ImageDraw.Draw(dm.image) # dm.draw needed for fallback path dm.draw = ImageDraw.Draw(dm.image)
if not self.text or self.text_pixel_width == 0: if not self.text or self.text_content_width == 0:
dm.update_display() # Display empty background dm.update_display()
return return
# Attempt to use pre-rendered cache for scrolling TTF fonts # Use pre-rendered cache if available and scrolling is active
if self.text_image_cache and self.scroll_enabled and self.text_pixel_width > matrix_width: # Scrolling via cache is only relevant if the actual text content itself is wider than the matrix,
# or if we want to scroll a short text with a large gap.
# The self.cached_total_scroll_width > matrix_width implies the content (text+gap) is scrollable.
if self.text_image_cache and self.scroll_enabled and self.cached_total_scroll_width > matrix_width :
current_scroll_int = int(self.scroll_pos) current_scroll_int = int(self.scroll_pos)
source_x1 = current_scroll_int source_x1 = current_scroll_int
source_x2 = current_scroll_int + matrix_width source_x2 = current_scroll_int + matrix_width
if source_x2 <= self.text_pixel_width: if source_x2 <= self.cached_total_scroll_width:
# Normal case: Paste single crop from cache
segment = self.text_image_cache.crop((source_x1, 0, source_x2, matrix_height)) segment = self.text_image_cache.crop((source_x1, 0, source_x2, matrix_height))
dm.image.paste(segment, (0, 0)) dm.image.paste(segment, (0, 0))
else: else:
# Wrap-around case: Paste two parts from cache for seamless loop # Wrap-around: paste two parts from cache
width1 = self.text_pixel_width - source_x1 width1 = self.cached_total_scroll_width - source_x1
if width1 > 0: # Should always be true if source_x2 > self.text_pixel_width if width1 > 0:
segment1 = self.text_image_cache.crop((source_x1, 0, self.text_pixel_width, matrix_height)) segment1 = self.text_image_cache.crop((source_x1, 0, self.cached_total_scroll_width, matrix_height))
dm.image.paste(segment1, (0, 0)) dm.image.paste(segment1, (0, 0))
remaining_width_for_screen = matrix_width - width1 remaining_width_for_screen = matrix_width - width1
if remaining_width_for_screen > 0: if remaining_width_for_screen > 0:
segment2 = self.text_image_cache.crop((0, 0, remaining_width_for_screen, matrix_height)) segment2 = self.text_image_cache.crop((0, 0, remaining_width_for_screen, matrix_height))
# Paste segment2 at the correct x-offset on the screen
dm.image.paste(segment2, (width1 if width1 > 0 else 0, 0)) dm.image.paste(segment2, (width1 if width1 > 0 else 0, 0))
else: else:
# Fallback to direct drawing (e.g., BDF, static text, or text fits screen) # Fallback: Direct drawing (BDF, static TTF, or TTF text that fits screen and isn't forced to scroll by gap)
# Calculate Y position (center vertically) - original logic
# This part needs to be robust for both BDF and TTF (when not cached)
final_y_for_draw = 0 final_y_for_draw = 0
try: try:
if isinstance(self.font, freetype.Face): if isinstance(self.font, freetype.Face):
text_render_height = self.font.size.height >> 6 text_render_height = self.font.size.height >> 6
final_y_for_draw = (matrix_height - text_render_height) // 2 final_y_for_draw = (matrix_height - text_render_height) // 2
else: # PIL TTF Font else:
# Use dm.draw for live textbbox calculation on the current frame's draw context
pil_bbox = dm.draw.textbbox((0, 0), self.text, font=self.font) pil_bbox = dm.draw.textbbox((0, 0), self.text, font=self.font)
text_render_height = pil_bbox[3] - pil_bbox[1] text_render_height = pil_bbox[3] - pil_bbox[1]
final_y_for_draw = (matrix_height - text_render_height) // 2 - pil_bbox[1] # Adjust for PIL's baseline final_y_for_draw = (matrix_height - text_render_height) // 2 - pil_bbox[1]
except Exception as e: except Exception as e:
logger.warning(f"TextDisplay: Could not calculate text height accurately for direct drawing: {e}. Using y=0.", exc_info=True) logger.warning(f"TextDisplay: Could not calculate text height for direct drawing: {e}. Using y=0.", exc_info=True)
final_y_for_draw = 0 final_y_for_draw = 0
if self.scroll_enabled and self.text_pixel_width > matrix_width: if self.scroll_enabled and self.text_content_width > matrix_width:
# Scrolling text (direct drawing path, e.g., for BDF or if cache failed) # Scrolling text (direct drawing path, e.g., for BDF)
# This x calculation makes text appear from right and scroll left x_draw_pos = matrix_width - int(self.scroll_pos) # scroll_pos for BDF already considers a type of gap for reset
x_draw_pos = matrix_width - int(self.scroll_pos)
dm.draw_text( dm.draw_text(
text=self.text, x=x_draw_pos, y=final_y_for_draw, text=self.text, x=x_draw_pos, y=final_y_for_draw,
color=self.text_color, font=self.font color=self.text_color, font=self.font
) )
else: else:
# Static text (centered horizontally) # Static text (centered horizontally)
x_draw_pos = (matrix_width - self.text_pixel_width) // 2 x_draw_pos = (matrix_width - self.text_content_width) // 2
dm.draw_text( dm.draw_text(
text=self.text, x=x_draw_pos, y=final_y_for_draw, text=self.text, x=x_draw_pos, y=final_y_for_draw,
color=self.text_color, font=self.font color=self.text_color, font=self.font
@@ -238,7 +232,6 @@ class TextDisplay:
dm.update_display() dm.update_display()
# Add setters to regenerate cache if properties change
def set_text(self, new_text: str): def set_text(self, new_text: str):
self.text = new_text self.text = new_text
self._regenerate_renderings() self._regenerate_renderings()
@@ -252,16 +245,17 @@ class TextDisplay:
def set_color(self, text_color: tuple, bg_color: tuple): def set_color(self, text_color: tuple, bg_color: tuple):
self.text_color = text_color self.text_color = text_color
self.bg_color = bg_color self.bg_color = bg_color
# Background color change requires cache regeneration
# Text color is part of cache, so also regenerate.
self._regenerate_renderings() self._regenerate_renderings()
def set_scroll_enabled(self, enabled: bool): def set_scroll_enabled(self, enabled: bool):
self.scroll_enabled = enabled self.scroll_enabled = enabled
self.scroll_pos = 0.0 # Reset scroll when state changes self.scroll_pos = 0.0
# No need to regenerate cache, just affects display logic # Cache regeneration is not strictly needed, display logic handles scroll_enabled.
def set_scroll_speed(self, speed: float): def set_scroll_speed(self, speed: float):
self.scroll_speed = speed self.scroll_speed = speed
# No need to regenerate cache
def set_scroll_gap_width(self, gap_width: int):
self.scroll_gap_width = gap_width
self._regenerate_renderings() # Gap change requires cache rebuild