From f57f29a44af9f88236e3221b5892e088a35c4180 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:43:29 +0000 Subject: [PATCH 1/2] of-the-day: font-aware word wrapping with auto-fit for long text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long definitions and subtitles previously wrapped against hardcoded line counts (10 body / 3 subtitle) and any lines past the panel bottom were silently dropped, so long content was cut off mid-sentence — and picking a larger font via the customization UI made it worse. Wrapping is now fully font-aware: the number of lines is computed from the real font height and the space below the underline, and line breaks are measured with the actual font. When the wrapped text still needs more lines than fit, scalable fonts are retried at progressively smaller sizes (down to 5px) and the largest size that fits the whole text wins. When no size fits everything — or the font is a non-scalable bitmap font — the configured font is kept and the text is cut with a visible '...' instead of being clipped silently. Also clamps the dynamic spacing spread so rounding can no longer push the last wrapped line one row past the panel and drop it. New advanced config key auto_fit_text (default true) disables the auto-shrink; wrapping stays font-aware regardless. Consolidates the repeated text-width/font-height fallback code into helpers, adds test_text_fitting.py regression tests, and regenerates the 64x32 golden (the subtitle there was previously clipped mid-sentence; it now shows an ellipsis). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRMQBB431aTK2mq6GGmEgk --- plugins.json | 6 +- plugins/of-the-day/README.md | 1 + plugins/of-the-day/config_schema.json | 7 + plugins/of-the-day/manager.py | 273 ++++++++++-------- plugins/of-the-day/manifest.json | 10 +- .../test/golden/64x32/of_the_day.png | Bin 364 -> 367 bytes plugins/of-the-day/test_text_fitting.py | 133 +++++++++ 7 files changed, 312 insertions(+), 118 deletions(-) create mode 100644 plugins/of-the-day/test_text_fitting.py diff --git a/plugins.json b/plugins.json index 92caae7e..698c9a19 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-05", + "last_updated": "2026-08-06", "plugins": [ { "id": "cricket-scoreboard", @@ -653,10 +653,10 @@ "plugin_path": "plugins/of-the-day", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-06", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.4.0" }, { "id": "olympics", diff --git a/plugins/of-the-day/README.md b/plugins/of-the-day/README.md index e9f2f0d3..9300bf12 100644 --- a/plugins/of-the-day/README.md +++ b/plugins/of-the-day/README.md @@ -60,6 +60,7 @@ category configurations with collapsible sections. - `update_interval`: Seconds between checking for new day (default: 3600) - `display_rotate_interval`: Seconds between category rotations (default: 20) - `subtitle_rotate_interval`: Seconds between title/content rotation (default: 10) +- `auto_fit_text`: Shrink the body font automatically when a long definition or subtitle can't fit the panel at the configured size (default: true). Wrapping is font-aware either way — line breaks and line count follow the actual font metrics, so custom fonts/sizes wrap correctly. Text that can't fit even at the smallest size is shortened with `...` - `category_order`: Order to display categories - `categories`: Dictionary of category configurations - `display_duration`: Total display duration in seconds diff --git a/plugins/of-the-day/config_schema.json b/plugins/of-the-day/config_schema.json index 5230f500..a02da30a 100644 --- a/plugins/of-the-day/config_schema.json +++ b/plugins/of-the-day/config_schema.json @@ -6,6 +6,7 @@ "update_interval", "display_rotate_interval", "subtitle_rotate_interval", + "auto_fit_text", "category_order", "file_manager", "categories", @@ -39,6 +40,12 @@ "x-advanced": true, "description": "Seconds between rotating subtitle information" }, + "auto_fit_text": { + "type": "boolean", + "default": true, + "x-advanced": true, + "description": "Automatically shrink the body text font when a long definition or subtitle can't fit on the panel at the configured size (scalable fonts only; text that still can't fit is shortened with '...')" + }, "category_order": { "type": "array", "items": { diff --git a/plugins/of-the-day/manager.py b/plugins/of-the-day/manager.py index 2d8f2221..0ac9f481 100644 --- a/plugins/of-the-day/manager.py +++ b/plugins/of-the-day/manager.py @@ -50,8 +50,12 @@ class OfTheDayPlugin(BasePlugin): display_rotate_interval (float): Seconds between display rotations subtitle_rotate_interval (float): Seconds between subtitle rotations update_interval (float): Seconds between checking for new day + auto_fit_text (bool): Shrink text to fit long content on the panel """ - + + # Smallest pixel size auto-fitting will shrink a scalable font to. + MIN_AUTO_FONT_SIZE = 5 + def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cache_manager, plugin_manager): """Initialize the of-the-day plugin.""" @@ -61,7 +65,8 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.update_interval = config.get('update_interval', 3600) self.display_rotate_interval = config.get('display_rotate_interval', 20) self.subtitle_rotate_interval = config.get('subtitle_rotate_interval', 10) - + self.auto_fit_text = config.get('auto_fit_text', True) + # Categories self.categories = config.get('categories', {}) self.category_order = config.get('category_order', []) @@ -345,8 +350,27 @@ def display(self, force_clear: bool = False) -> None: self.last_displayed_category = "ERROR" self._display_error() + def _text_width(self, text: str, font) -> int: + """Pixel width of `text` in `font` (display_manager first, PIL fallback).""" + try: + return self.display_manager.get_text_width(text, font) + except Exception: + try: + bbox = font.getbbox(text) + return bbox[2] - bbox[0] + except Exception: + return len(text) * 6 + + def _get_font_height(self, font, default: int = 8) -> int: + """Pixel height of `font`, falling back to `default` on error.""" + try: + return self.display_manager.get_font_height(font) + except Exception as e: + self.logger.warning(f"Error getting font height: {e}, using default {default}") + return default + def _wrap_text(self, text: str, max_width: int, font, max_lines: int = 10) -> List[str]: - """Wrap text to fit within max_width, similar to old manager.""" + """Wrap text to fit within max_width, measuring the actual font.""" if not text: return [""] lines = [] @@ -354,16 +378,7 @@ def _wrap_text(self, text: str, max_width: int, font, max_lines: int = 10) -> Li words = text.split() for word in words: test_line = ' '.join(current_line + [word]) if current_line else word - try: - text_width = self.display_manager.get_text_width(test_line, font) - except Exception: - # Fallback calculation - if isinstance(font, ImageFont.ImageFont): - bbox = font.getbbox(test_line) - text_width = bbox[2] - bbox[0] - else: - text_width = len(test_line) * 6 - if text_width <= max_width: + if self._text_width(test_line, font) <= max_width: current_line.append(word) else: if current_line: @@ -373,15 +388,7 @@ def _wrap_text(self, text: str, max_width: int, font, max_lines: int = 10) -> Li # Word is too long - truncate it truncated = word while len(truncated) > 0: - try: - test_width = self.display_manager.get_text_width(truncated + "...", font) - except Exception: - if isinstance(font, ImageFont.ImageFont): - bbox = font.getbbox(truncated + "...") - test_width = bbox[2] - bbox[0] - else: - test_width = len(truncated + "...") * 6 - if test_width <= max_width: + if self._text_width(truncated + "...", font) <= max_width: lines.append(truncated + "...") break truncated = truncated[:-1] @@ -392,7 +399,19 @@ def _wrap_text(self, text: str, max_width: int, font, max_lines: int = 10) -> Li if current_line and len(lines) < max_lines: lines.append(' '.join(current_line)) return lines[:max_lines] - + + def _ellipsize(self, text: str, font, max_width: int) -> str: + """Trim `text` with '...' so it fits max_width; unchanged if it fits.""" + if self._text_width(text, font) <= max_width: + return text + ellipsis = "..." + if self._text_width(ellipsis, font) > max_width: + return "" + truncated = text + while truncated and self._text_width(truncated + ellipsis, font) > max_width: + truncated = truncated[:-1] + return truncated + ellipsis + def _fit_title(self, title: str, font) -> str: """Ellipsize the title to the panel width. @@ -400,26 +419,73 @@ def _fit_title(self, title: str, font) -> str: render exactly as before; on a narrow panel (64px) a long word is truncated with '...' instead of being drawn past the panel edge. """ - def _w(text: str) -> int: - try: - return self.display_manager.get_text_width(text, font) - except Exception: - try: - bbox = font.getbbox(text) - return bbox[2] - bbox[0] - except Exception: - return len(text) * 6 + return self._ellipsize(title, font, self.display_manager.width) - max_width = self.display_manager.width - if _w(title) <= max_width: - return title - ellipsis = "..." - if _w(ellipsis) > max_width: - return "" - truncated = title - while truncated and _w(truncated + ellipsis) > max_width: - truncated = truncated[:-1] - return truncated + ellipsis + def _resized_font(self, font, size: int): + """The same typeface as `font` at a different pixel size, or None. + + Only scalable fonts (TTF/OTF, which carry a `.path`) can be resized; + bitmap fonts (BDF freetype.Face, PIL's built-in default) return None. + """ + path = getattr(font, 'path', None) + if not path or size < 1: + return None + cache = getattr(self, '_resized_font_cache', None) + if cache is None: + cache = self._resized_font_cache = {} + key = (path, size) + if key not in cache: + try: + cache[key] = ImageFont.truetype(path, size) + except Exception as e: + self.logger.warning(f"Could not load font {path} at {size}px: {e}") + cache[key] = None + return cache[key] + + def _fit_wrapped_text(self, text: str, font, max_width: int, max_height: int, + line_spacing: int = 1): + """Wrap `text` to the panel, shrinking the font when it can't fit. + + Wrapping always measures the actual font, so wider fonts and larger + user-configured sizes wrap into fewer characters per line, and the + number of lines comes from the real font height and the available + vertical space — not a fixed count. When the wrapped text needs more + lines than fit and auto_fit_text is enabled, scalable fonts are + retried at progressively smaller sizes (down to MIN_AUTO_FONT_SIZE) + until the whole text fits (the largest size that fits wins). When no + size fits everything — or the font is a bitmap font that can't be + resized — the configured font is kept (crisper than a shrunken one + that still overflows), the text is cut to the lines that fit, and + the last line is ellipsized. + + Returns (font, lines, line_height). + """ + candidates = [font] + if self.auto_fit_text: + base_size = getattr(font, 'size', None) + if isinstance(base_size, (int, float)): + for size in range(int(base_size) - 1, self.MIN_AUTO_FONT_SIZE - 1, -1): + smaller = self._resized_font(font, size) + if smaller is not None: + candidates.append(smaller) + + first = None + for candidate in candidates: + line_height = self._get_font_height(candidate) + max_lines = max(1, (max_height + line_spacing) // (line_height + line_spacing)) + # Wrap with one spare line so overflow is detectable. + lines = self._wrap_text(text, max_width, candidate, max_lines=max_lines + 1) + if first is None: + first = (candidate, lines, line_height, max_lines) + if len(lines) <= max_lines: + return candidate, lines, line_height + # No size holds everything: keep the configured font, keep the lines + # that fit, and mark the cut with an ellipsis. + candidate, lines, line_height, max_lines = first + lines = lines[:max_lines] + if lines: + lines[-1] = self._ellipsize(lines[-1] + "...", candidate, max_width) + return candidate, lines, line_height def _draw_bdf_text(self, draw, font, text: str, x: int, y: int, color: tuple = (255, 255, 255)): """Draw text supporting both BDF (FreeType Face) and PIL TTF fonts, similar to old manager.""" @@ -497,39 +563,23 @@ def _display_title(self, category_config: Dict, item_data: Dict): body_font, body_color, (body_dx, body_dy)) = self._element_styles() # Get font heights - try: - title_height = self.display_manager.get_font_height(title_font) - except Exception as e: - self.logger.warning(f"Error getting title font height: {e}, using default 8") - title_height = 8 - try: - body_height = self.display_manager.get_font_height(body_font) - except Exception as e: - self.logger.warning(f"Error getting body font height: {e}, using default 8") - body_height = 8 - + title_height = self._get_font_height(title_font) + body_height = self._get_font_height(body_font) + # Layout matching old manager: margin_top = 8 margin_top = 8 margin_bottom = 1 underline_space = 1 - + # Get title/word (JSON uses "title" not "word") title = self._fit_title(item_data.get('title', item_data.get('word', 'N/A')), title_font) # Get subtitle (JSON uses "subtitle") subtitle = item_data.get('subtitle', item_data.get('pronunciation', item_data.get('type', ''))) - + # Calculate title width for centering - try: - title_width = self.display_manager.get_text_width(title, title_font) - except Exception as e: - self.logger.warning(f"Error calculating title width using display_manager: {e}, trying fallback") - if isinstance(title_font, ImageFont.ImageFont): - bbox = title_font.getbbox(title) - title_width = bbox[2] - bbox[0] - else: - title_width = len(title) * 6 - + title_width = self._text_width(title, title_font) + # Center the title horizontally (+ user layout offset) title_x = (self.display_manager.width - title_width) // 2 + title_dx # A user layout offset (title_dx) must not push the title off-panel. @@ -560,20 +610,29 @@ def _display_title(self, category_config: Dict, item_data: Dict): # Draw subtitle below underline (centered, like old manager) if subtitle: - # Wrap subtitle text if needed + # Wrap the subtitle to the panel; when the configured font can't + # fit every line below the underline, shrink it until it does. available_width = self.display_manager.width - 4 - wrapped_subtitle_lines = self._wrap_text(subtitle, available_width, body_font, max_lines=3) + max_subtitle_height = (self.display_manager.height - underline_y + - underline_space - 2 - margin_bottom) + body_font, wrapped_subtitle_lines, body_height = self._fit_wrapped_text( + subtitle, body_font, available_width, max_subtitle_height) actual_subtitle_lines = [line for line in wrapped_subtitle_lines if line.strip()] - + if actual_subtitle_lines: # Calculate spacing - similar to old manager's dynamic spacing total_subtitle_height = len(actual_subtitle_lines) * body_height available_space = self.display_manager.height - underline_y - margin_bottom space_after_underline = max(2, (available_space - total_subtitle_height) // 2) - + # Centering must not push the last line past the panel bottom. + lines_span = total_subtitle_height + (len(actual_subtitle_lines) - 1) + max_space_after = (self.display_manager.height - underline_y + - underline_space - lines_span) + space_after_underline = max(2, min(space_after_underline, max_space_after)) + subtitle_start_y = underline_y + space_after_underline + underline_space current_y = subtitle_start_y - + for line in actual_subtitle_lines: if line.strip(): # Stop before drawing a line that would run past the @@ -581,14 +640,7 @@ def _display_title(self, category_config: Dict, item_data: Dict): if current_y + body_dy + body_height > self.display_manager.height: break # Center each line of subtitle - try: - line_width = self.display_manager.get_text_width(line, body_font) - except Exception: - if isinstance(body_font, ImageFont.ImageFont): - bbox = body_font.getbbox(line) - line_width = bbox[2] - bbox[0] - else: - line_width = len(line) * 6 + line_width = self._text_width(line, body_font) line_x = (self.display_manager.width - line_width) // 2 + body_dx # Use display_manager.draw_text for subtitle @@ -617,37 +669,23 @@ def _display_content(self, category_config: Dict, item_data: Dict): body_font, body_color, (body_dx, body_dy)) = self._element_styles() # Get font heights - try: - title_height = self.display_manager.get_font_height(title_font) - except Exception: - title_height = 8 - try: - body_height = self.display_manager.get_font_height(body_font) - except Exception: - body_height = 8 - + title_height = self._get_font_height(title_font) + # Layout matching old manager: margin_top = 8 margin_top = 8 margin_bottom = 1 underline_space = 1 - + # Get title/word (JSON uses "title") title = self._fit_title(item_data.get('title', item_data.get('word', 'N/A')), title_font) self.logger.debug(f"Displaying content for title: {title}") - + # Get description (JSON uses "description") description = item_data.get('description', item_data.get('definition', item_data.get('content', item_data.get('text', 'No content')))) - + # Calculate title width for centering (for underline placement) - try: - title_width = self.display_manager.get_text_width(title, title_font) - except Exception: - if isinstance(title_font, ImageFont.ImageFont): - bbox = title_font.getbbox(title) - title_width = bbox[2] - bbox[0] - else: - title_width = len(title) * 6 - + title_width = self._text_width(title, title_font) + # Center the title horizontally (same position as in _display_title) title_x = (self.display_manager.width - title_width) // 2 + title_dx # A user layout offset (title_dx) must not push the title off-panel. @@ -671,18 +709,22 @@ def _display_content(self, category_config: Dict, item_data: Dict): draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], fill=title_color, width=1) - # Wrap description text + # Wrap the description to the panel: line width and line count follow + # the actual font metrics, and the font shrinks when the configured + # size can't fit the whole text below the underline. available_width = self.display_manager.width - 4 - max_lines = 10 - wrapped_lines = self._wrap_text(description, available_width, body_font, max_lines=max_lines) + max_body_height = (self.display_manager.height - underline_y + - underline_space - 3) + body_font, wrapped_lines, body_height = self._fit_wrapped_text( + description, body_font, available_width, max_body_height) actual_body_lines = [line for line in wrapped_lines if line.strip()] - + if actual_body_lines: # Calculate dynamic spacing - similar to old manager num_body_lines = len(actual_body_lines) body_content_height = num_body_lines * body_height available_space = self.display_manager.height - underline_y - margin_bottom - + if body_content_height < available_space: # Distribute extra space: some after underline, rest between lines extra_space = available_space - body_content_height @@ -692,7 +734,18 @@ def _display_content(self, category_config: Dict, item_data: Dict): # Tight spacing space_after_underline = 4 space_between_lines = 1 - + + # Rounding in the spread can land the last line past the panel + # bottom; tighten the spacing back in rather than dropping it. + if num_body_lines > 1: + slack = (self.display_manager.height - underline_y - underline_space + - 1 - space_after_underline - body_content_height) + if space_between_lines * (num_body_lines - 1) > slack: + space_between_lines = max(1, slack // (num_body_lines - 1)) + overshoot = space_between_lines * (num_body_lines - 1) - slack + if overshoot > 0: + space_after_underline = max(2, space_after_underline - overshoot) + # Draw body text with dynamic spacing body_start_y = underline_y + space_after_underline + underline_space + 1 # +1 to match old manager's shift current_y = body_start_y @@ -704,14 +757,7 @@ def _display_content(self, category_config: Dict, item_data: Dict): if current_y + body_dy + body_height > self.display_manager.height: break # Center each line of body text (like old manager) - try: - line_width = self.display_manager.get_text_width(line, body_font) - except Exception: - if isinstance(body_font, ImageFont.ImageFont): - bbox = body_font.getbbox(line) - line_width = bbox[2] - bbox[0] - else: - line_width = len(line) * 6 + line_width = self._text_width(line, body_font) line_x = (self.display_manager.width - line_width) // 2 + body_dx # Use display_manager.draw_text for description @@ -787,6 +833,7 @@ def on_config_change(self, config: Dict[str, Any]) -> None: self.update_interval = config.get('update_interval', 3600) self.display_rotate_interval = config.get('display_rotate_interval', 20) self.subtitle_rotate_interval = config.get('subtitle_rotate_interval', 10) + self.auto_fit_text = config.get('auto_fit_text', True) self.categories = config.get('categories', {}) self.category_order = config.get('category_order', []) diff --git a/plugins/of-the-day/manifest.json b/plugins/of-the-day/manifest.json index 0b2ce44b..8df92871 100644 --- a/plugins/of-the-day/manifest.json +++ b/plugins/of-the-day/manifest.json @@ -1,7 +1,7 @@ { "id": "of-the-day", "name": "Of The Day Display", - "version": "1.3.2", + "version": "1.4.0", "author": "ChuckBuilds", "description": "Display daily featured content like Word of the Day, Bible verses, or custom daily items. Supports multiple categories with rotating display and configurable data sources.", "category": "information", @@ -21,6 +21,12 @@ "of_the_day" ], "versions": [ + { + "version": "1.4.0", + "released": "2026-08-06", + "ledmatrix_min": "2.0.0", + "notes": "Font-aware word wrapping: long definitions and subtitles wrap into as many lines as actually fit the panel (measured from the real font metrics, including user font/size overrides), auto-shrinking scalable fonts to the largest size that fits the whole text. Text that still can't fit is ellipsized instead of silently clipped. New advanced setting auto_fit_text (default on) disables the auto-shrink." + }, { "version": "1.3.2", "released": "2026-07-31", @@ -53,7 +59,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-06", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/of-the-day/test/golden/64x32/of_the_day.png b/plugins/of-the-day/test/golden/64x32/of_the_day.png index 26be5abbd0d7f330eda88110f4c97215a6bb6aac..c37ec128d02874b44c462f2344e60d98ead78001 100644 GIT binary patch delta 340 zcmV-a0jvJ(0`CHlB!4zZL_t(|ob8o6j)X7_h8-y-5*&vcke-4wrr-d+fv>?0DCo0A zB+F|c!wkbbcKsCyJF&5UXEFeWVHn1L#sNS|Nqe1pM=|F}DXlyCeEi-*VJvDX`?FK+ zkxT15Pex5(&n#@)+*3|_p4|zQ*(}02$MwDJSM*>5oAq(&xqpo#J?wRR-0$JHoHzB8 zGRtOUpzL99zwuXa-+1>M#8%{yE<>u zR7Z#dIkOB@QB81cCZ!21%aZjdCu-Xkgl5vbnfVe}8>Ixx)-(+WrInV_RC?#Y%_#XH mnOx5B>;>?m3&b#tyZ8ZfOIi+8u3!290000?07}(Px zlI0o_XrXwAYxwDCQg?gmowXADddpj72R)e{`xn za%r9CV$=juW@g*wo_yN#Bqxw(vj}4h*Y~!!=*b2)>*La68-GW7NOgPMZ{c3fUHzoY zvKbjDdr0jsd<*6izx{=efQX35dw)LFIF5Xko|kh;^~}Kr9)0Nkj|>s(x&{E}+&s_9 zB%*1WhG75zA_9Q5X|3)1zN)I|an5ma8O|Yy*md1{^$p^vx j7%$^ZACN+!@GIN^!v<6N_Tsry00000NkvXXu0mjfGg+Wq diff --git a/plugins/of-the-day/test_text_fitting.py b/plugins/of-the-day/test_text_fitting.py new file mode 100644 index 00000000..07e38d4e --- /dev/null +++ b/plugins/of-the-day/test_text_fitting.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Regression tests for of-the-day's font-aware text fitting. + +Long definitions/subtitles must wrap into multiple lines sized to the actual +font metrics (not a fixed line count), and when the configured font can't +fit the whole text on the panel the plugin shrinks scalable fonts to the +largest size that fits everything. When nothing fits, the configured font is +kept and the text is cut with an ellipsis instead of silently dropping lines. + +Run from the core LEDMatrix tree (needs src.* and assets/fonts): + cd /path/to/LEDMatrix + python -m pytest /path/to/of-the-day/test_text_fitting.py -q +""" + +import os +import sys + +import pytest +from PIL import ImageChops + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +from manager import OfTheDayPlugin # noqa: E402 + +SHORT_ITEM = {"title": "Serendipity", "subtitle": "noun", + "description": "Finding something good without looking for it."} + +LONG_ITEM = { + "title": "Nepotism", + "subtitle": ("The practice among those with power or influence of " + "favoring relatives or friends, especially by giving " + "them jobs"), + "description": ("Accusations of nepotism plagued the new administration, " + "as several family members were given high-ranking " + "positions."), +} + + +def _plugin(w, h, config=None): + from src.plugin_system.testing import ( + MockCacheManager, MockPluginManager, VisualTestDisplayManager) + cfg = {"enabled": True, "categories": {}, "category_order": []} + cfg.update(config or {}) + return OfTheDayPlugin("of-the-day", cfg, VisualTestDisplayManager(w, h), + MockCacheManager(), MockPluginManager()) + + +def _body_font(p): + return p._element_styles()[3] + + +class TestFitWrappedText: + def test_short_text_keeps_configured_font(self): + p = _plugin(128, 64) + font = _body_font(p) + fitted, lines, _ = p._fit_wrapped_text("hello world", font, 124, 40) + assert fitted is font + assert lines == ["hello world"] + + def test_long_text_wraps_to_multiple_full_lines(self): + p = _plugin(128, 64) + font = _body_font(p) + fitted, lines, _ = p._fit_wrapped_text( + LONG_ITEM["description"], font, 124, 40) + assert len(lines) > 1 + # Nothing dropped: rejoining the lines restores every word. + assert " ".join(lines).split() == LONG_ITEM["description"].split() + # Every line respects the wrap width for the font actually used. + assert all(p._text_width(line, fitted) <= 124 for line in lines) + + def test_oversized_font_shrinks_until_text_fits(self): + p = _plugin(128, 64, {"customization": { + "body_text": {"font": "4x6-font.ttf", "font_size": 10}}}) + font = _body_font(p) + fitted, lines, _ = p._fit_wrapped_text( + LONG_ITEM["description"], font, 124, 40) + assert fitted.size < font.size + assert fitted.size >= OfTheDayPlugin.MIN_AUTO_FONT_SIZE + assert " ".join(lines).split() == LONG_ITEM["description"].split() + + def test_unfittable_text_keeps_font_and_ellipsizes(self): + p = _plugin(64, 32) + font = _body_font(p) + fitted, lines, height = p._fit_wrapped_text( + LONG_ITEM["description"], font, 60, 11) + # No smaller size holds everything on this panel: the configured + # (crisper) font is kept and the cut is marked. + assert fitted is font + assert lines[-1].endswith("...") + # The lines that remain still fit the given box. + spans = len(lines) * height + (len(lines) - 1) + assert spans <= 11 + height # at most max_lines = (11+1)//(h+1) lines + assert all(p._text_width(line, fitted) <= 60 for line in lines) + + def test_auto_fit_disabled_never_shrinks(self): + p = _plugin(128, 64, {"auto_fit_text": False, "customization": { + "body_text": {"font": "4x6-font.ttf", "font_size": 10}}}) + font = _body_font(p) + fitted, lines, _ = p._fit_wrapped_text( + LONG_ITEM["description"], font, 124, 40) + assert fitted is font + assert lines[-1].endswith("...") + + +class TestRendering: + @pytest.mark.parametrize("w,h", [(64, 32), (128, 32), (128, 64), (256, 32)]) + def test_no_blank_screen_and_no_crash(self, w, h): + p = _plugin(w, h) + p._display_content({}, LONG_ITEM) + assert p.display_manager.image.getbbox() is not None + p._display_title({}, LONG_ITEM) + assert p.display_manager.image.getbbox() is not None + + def test_short_item_render_unaffected_by_auto_fit_flag(self): + """Text that fits renders byte-identically with auto-fit on or off.""" + for render in ("_display_title", "_display_content"): + imgs = [] + for flag in (True, False): + p = _plugin(128, 32, {"auto_fit_text": flag}) + getattr(p, render)({}, SHORT_ITEM) + imgs.append(p.display_manager.image.copy()) + assert ImageChops.difference(imgs[0], imgs[1]).getbbox() is None + + def test_long_item_uses_more_lines_than_before(self): + """A long definition fills the 128x64 panel with wrapped lines whose + content reaches further down than a single clipped line would.""" + p = _plugin(128, 64) + p._display_content({}, LONG_ITEM) + bbox = p.display_manager.image.getbbox() + assert bbox[3] > 40 # text extends well into the lower half From 9c2d5866ec1cece59ceea82a899a4a096211fdd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 15:48:48 +0000 Subject: [PATCH 2/2] of-the-day: address CodeRabbit review on PR #260 - Use ledmatrix_min_version (not the deprecated ledmatrix_min) in the new 1.4.0 manifest release record; the core's store manifest validation flags the old spelling. - Fix the 1.4.0 release note, which described auto_fit_text backwards. - Don't accept a font size as fitting when _wrap_text had to truncate a word wider than the panel: keep trying smaller sizes that can hold the word whole, with a regression test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BRMQBB431aTK2mq6GGmEgk --- plugins/of-the-day/manager.py | 5 ++++- plugins/of-the-day/manifest.json | 4 ++-- plugins/of-the-day/test_text_fitting.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/plugins/of-the-day/manager.py b/plugins/of-the-day/manager.py index 0ac9f481..41d816df 100644 --- a/plugins/of-the-day/manager.py +++ b/plugins/of-the-day/manager.py @@ -477,7 +477,10 @@ def _fit_wrapped_text(self, text: str, font, max_width: int, max_height: int, lines = self._wrap_text(text, max_width, candidate, max_lines=max_lines + 1) if first is None: first = (candidate, lines, line_height, max_lines) - if len(lines) <= max_lines: + # A candidate only wins when every word survived intact: a word + # wider than max_width gets truncated by _wrap_text, and a + # smaller size may be able to hold it whole. + if len(lines) <= max_lines and " ".join(lines).split() == text.split(): return candidate, lines, line_height # No size holds everything: keep the configured font, keep the lines # that fit, and mark the cut with an ellipsis. diff --git a/plugins/of-the-day/manifest.json b/plugins/of-the-day/manifest.json index 8df92871..04b6a937 100644 --- a/plugins/of-the-day/manifest.json +++ b/plugins/of-the-day/manifest.json @@ -24,8 +24,8 @@ { "version": "1.4.0", "released": "2026-08-06", - "ledmatrix_min": "2.0.0", - "notes": "Font-aware word wrapping: long definitions and subtitles wrap into as many lines as actually fit the panel (measured from the real font metrics, including user font/size overrides), auto-shrinking scalable fonts to the largest size that fits the whole text. Text that still can't fit is ellipsized instead of silently clipped. New advanced setting auto_fit_text (default on) disables the auto-shrink." + "ledmatrix_min_version": "2.0.0", + "notes": "Font-aware word wrapping: long definitions and subtitles wrap into as many lines as actually fit the panel (measured from the real font metrics, including user font/size overrides), auto-shrinking scalable fonts to the largest size that fits the whole text. Text that still can't fit is ellipsized instead of silently clipped. The auto-shrink is controlled by the new advanced setting auto_fit_text (default on; turn it off to keep the configured size and ellipsize instead)." }, { "version": "1.3.2", diff --git a/plugins/of-the-day/test_text_fitting.py b/plugins/of-the-day/test_text_fitting.py index 07e38d4e..d3028d48 100644 --- a/plugins/of-the-day/test_text_fitting.py +++ b/plugins/of-the-day/test_text_fitting.py @@ -95,6 +95,21 @@ def test_unfittable_text_keeps_font_and_ellipsizes(self): assert spans <= 11 + height # at most max_lines = (11+1)//(h+1) lines assert all(p._text_width(line, fitted) <= 60 for line in lines) + def test_oversized_word_shrinks_instead_of_truncating(self): + """A single word wider than the panel at the configured size must + shrink to a size that holds it whole, not be ellipsized.""" + p = _plugin(128, 64, {"customization": { + "body_text": {"font": "4x6-font.ttf", "font_size": 12}}}) + font = _body_font(p) + word = "extraordinarily" + small = p._resized_font(font, 6) + max_width = p._text_width(word, small) + 4 + # Precondition: at the configured 12px the word overflows max_width. + assert p._text_width(word, font) > max_width + fitted, lines, _ = p._fit_wrapped_text(word, font, max_width, 40) + assert fitted.size < font.size + assert lines == [word] + def test_auto_fit_disabled_never_shrinks(self): p = _plugin(128, 64, {"auto_fit_text": False, "customization": { "body_text": {"font": "4x6-font.ttf", "font_size": 10}}})