Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-08-05",
"last_updated": "2026-08-06",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions plugins/of-the-day/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions plugins/of-the-day/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"update_interval",
"display_rotate_interval",
"subtitle_rotate_interval",
"auto_fit_text",
"category_order",
"file_manager",
"categories",
Expand Down Expand Up @@ -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": {
Expand Down
276 changes: 163 additions & 113 deletions plugins/of-the-day/manager.py

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions plugins/of-the-day/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -21,6 +21,12 @@
"of_the_day"
],
"versions": [
{
"version": "1.4.0",
"released": "2026-08-06",
"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",
"released": "2026-07-31",
Expand Down Expand Up @@ -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,
Expand Down
Binary file modified plugins/of-the-day/test/golden/64x32/of_the_day.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 148 additions & 0 deletions plugins/of-the-day/test_text_fitting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/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_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}}})
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
Loading