From f7b6f5183924d6bbbc3a6cafd80dc941aee5c6a2 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Jul 2026 16:52:01 -0400 Subject: [PATCH] feat(of-the-day): user-customizable fonts/colors/offsets via x-style-elements (v1.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First adopter of the core's x-style-elements system: the schema declares two styleable elements (title_text, body_text) in ~20 lines and the core expands them into full font/size/color/x-y-offset config blocks — no hand-copied property blocks, and the plugin code resolves them through src.element_style in one helper. Purely additive: with an untouched config (bare, or carrying the schema defaults the web UI bakes in on every save) rendering is byte-identical to v1.2.0 (verified across 5 sizes x both screens x both config shapes). On older cores the declaration is simply not expanded (no customization UI appears) and a guarded import keeps the classic styling path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --- plugins.json | 4 +- plugins/of-the-day/config_schema.json | 86 ++++++++++--- plugins/of-the-day/manager.py | 147 ++++++++++++++-------- plugins/of-the-day/manifest.json | 2 +- plugins/of-the-day/test_element_styles.py | 146 +++++++++++++++++++++ 5 files changed, 313 insertions(+), 72 deletions(-) create mode 100644 plugins/of-the-day/test_element_styles.py diff --git a/plugins.json b/plugins.json index 1b9aca0d..389f2798 100644 --- a/plugins.json +++ b/plugins.json @@ -601,10 +601,10 @@ "plugin_path": "plugins/of-the-day", "stars": 0, "downloads": 0, - "last_updated": "2026-05-26", + "last_updated": "2026-05-30", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.3.0" }, { "id": "olympics", diff --git a/plugins/of-the-day/config_schema.json b/plugins/of-the-day/config_schema.json index 312cef49..83362771 100644 --- a/plugins/of-the-day/config_schema.json +++ b/plugins/of-the-day/config_schema.json @@ -9,7 +9,8 @@ "category_order", "file_manager", "categories", - "display_duration" + "display_duration", + "customization" ], "properties": { "enabled": { @@ -40,7 +41,10 @@ "items": { "type": "string" }, - "default": ["word_of_the_day", "slovenian_word_of_the_day"], + "default": [ + "word_of_the_day", + "slovenian_word_of_the_day" + ], "description": "Order to display categories" }, "categories": { @@ -64,7 +68,11 @@ "description": "Display name shown in configuration" } }, - "required": ["enabled", "data_file", "display_name"] + "required": [ + "enabled", + "data_file", + "display_name" + ] } }, "display_duration": { @@ -80,35 +88,81 @@ "x-widget": "plugin-file-manager", "x-widget-config": { "actions": { - "list": "list-files", - "get": "get-file", - "save": "save-file", + "list": "list-files", + "get": "get-file", + "save": "save-file", "upload": "upload-file", "delete": "delete-file", "create": "create-file", "toggle": "toggle-category" }, - "upload_hint": "JSON files with day numbers 1–365 as keys", + "upload_hint": "JSON files with day numbers 1\u2013365 as keys", "directory_label": "of_the_day/", "create_fields": [ { - "key": "category_name", - "label": "Category Name", + "key": "category_name", + "label": "Category Name", "placeholder": "e.g., my_words", - "pattern": "^[a-z0-9_]+$", - "hint": "Lowercase letters, numbers, underscores — used as filename" + "pattern": "^[a-z0-9_]+$", + "hint": "Lowercase letters, numbers, underscores \u2014 used as filename" }, { - "key": "display_name", - "label": "Display Name", + "key": "display_name", + "label": "Display Name", "placeholder": "e.g., My Words", - "hint": "Shown in plugin configuration (leave blank to auto-generate)" + "hint": "Shown in plugin configuration (leave blank to auto-generate)" } ], "toggle_key": "category_name" } + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts, sizes, colors and positioning for the display text. Requires a LEDMatrix core with the element-style system; on older cores this section is not shown and the classic styling is used.", + "x-style-elements": { + "title_text": { + "title": "Title", + "font": { + "default": "PressStart2P-Regular.ttf" + }, + "size": { + "default": 8, + "min": 4, + "max": 16 + }, + "color": { + "default": [ + 255, + 255, + 255 + ] + }, + "offsets": true + }, + "body_text": { + "title": "Body Text", + "font": { + "default": "4x6-font.ttf" + }, + "size": { + "default": 6, + "min": 4, + "max": 12 + }, + "color": { + "default": [ + 200, + 200, + 200 + ] + }, + "offsets": true + } + } } }, - "required": ["enabled"] + "required": [ + "enabled" + ] } - diff --git a/plugins/of-the-day/manager.py b/plugins/of-the-day/manager.py index 940bdf34..edcb935d 100644 --- a/plugins/of-the-day/manager.py +++ b/plugins/of-the-day/manager.py @@ -24,6 +24,17 @@ from src.plugin_system.base_plugin import BasePlugin +# Shared element-style resolver (newer cores): user-customizable per-element +# fonts/sizes/colors/offsets, declared once in config_schema.json via +# x-style-elements. Older cores don't expand the declaration (no +# customization UI is shown) and this import guard keeps the classic +# styling path untouched there. +try: + from src.element_style import ElementStyleResolver, defaults_from_schema_file + STYLE_AVAILABLE = True +except ImportError: + STYLE_AVAILABLE = False + logger = logging.getLogger(__name__) @@ -116,6 +127,50 @@ def _register_fonts(self): self.logger.info("Of The Day fonts registered") except Exception as e: self.logger.warning(f"Error registering fonts: {e}") + + def _element_styles(self): + """Resolved (title, body) styles from customization config. + + Returns (title_font, title_color, title_offset, + body_font, body_color, body_offset). + + With an untouched config this resolves to exactly the classic fonts + and colors (PressStart2P@8 white / 4x6@6 gray), so rendering is + unchanged; a genuine user override in customization.title_text / + body_text wins. On older cores (no src.element_style) the classic + values are returned directly. + """ + if STYLE_AVAILABLE: + resolver = getattr(self, '_element_style_resolver', None) + if resolver is None or resolver._config is not self.config: + schema_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config_schema.json') + resolver = ElementStyleResolver( + self.config, defaults_from_schema_file(schema_path)) + self._element_style_resolver = resolver + title = resolver.style('title_text', + classic_font='PressStart2P-Regular.ttf', + classic_size=8, + classic_color=self.title_color) + body = resolver.style('body_text', + classic_font='4x6-font.ttf', + classic_size=6, + classic_color=self.subtitle_color) + return (title.font, title.color, title.offset, + body.font, body.color, body.offset) + + try: + title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) + except Exception as e: + self.logger.warning(f"Failed to load PressStart2P font: {e}, using fallback") + title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() + try: + body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) + except Exception as e: + self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") + body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() + return (title_font, self.title_color, (0, 0), + body_font, self.subtitle_color, (0, 0)) def _load_data_files(self): """Load all data files for enabled categories.""" @@ -400,23 +455,15 @@ def _display_title(self, category_config: Dict, item_data: Dict): """Display the title/word with subtitle, matching old manager layout.""" # Clear display first self.display_manager.clear() - + # Use display_manager's image and draw directly draw = self.display_manager.draw - - # Load fonts - match old manager font usage - try: - title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) - except Exception as e: - self.logger.warning(f"Failed to load PressStart2P font: {e}, using fallback") - title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() - - try: - body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except Exception as e: - self.logger.warning(f"Failed to load 4x6 font: {e}, using fallback") - body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() - + + # Fonts/colors/offsets honor customization. (classic + # PressStart2P@8 / 4x6@6 when untouched) + (title_font, title_color, (title_dx, title_dy), + 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) @@ -451,10 +498,10 @@ def _display_title(self, category_config: Dict, item_data: Dict): else: title_width = len(title) * 6 - # Center the title horizontally - title_x = (self.display_manager.width - title_width) // 2 - title_y = margin_top - + # Center the title horizontally (+ user layout offset) + title_x = (self.display_manager.width - title_width) // 2 + title_dx + title_y = margin_top + title_dy + # Draw title using display_manager.draw_text (proper method) self.logger.info(f"Drawing title '{title}' at ({title_x}, {title_y}) with font type {type(title_font).__name__}") try: @@ -462,19 +509,19 @@ def _display_title(self, category_config: Dict, item_data: Dict): title, x=title_x, y=title_y, - color=self.title_color, + color=title_color, font=title_font ) self.logger.debug(f"Title '{title}' drawn using display_manager.draw_text") except Exception as e: self.logger.error(f"Error drawing title '{title}': {e}", exc_info=True) - + # Draw underline below title (like old manager) underline_y = title_y + title_height + 1 underline_x_start = title_x underline_x_end = title_x + title_width - draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], - fill=self.title_color, width=1) + draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], + fill=title_color, width=1) # Draw subtitle below underline (centered, like old manager) if subtitle: @@ -503,39 +550,33 @@ def _display_title(self, category_config: Dict, item_data: Dict): line_width = bbox[2] - bbox[0] else: line_width = len(line) * 6 - line_x = (self.display_manager.width - line_width) // 2 - + line_x = (self.display_manager.width - line_width) // 2 + body_dx + # Use display_manager.draw_text for subtitle self.display_manager.draw_text( line, x=line_x, - y=current_y, - color=self.subtitle_color, + y=current_y + body_dy, + color=body_color, font=body_font ) current_y += body_height + 1 - + self.display_manager.update_display() - + def _display_content(self, category_config: Dict, item_data: Dict): """Display the definition/content, matching old manager layout.""" # Clear display first self.display_manager.clear() - + # Use display_manager's image and draw directly draw = self.display_manager.draw - - # Load fonts - match old manager - try: - title_font = ImageFont.truetype('assets/fonts/PressStart2P-Regular.ttf', 8) - except Exception: - title_font = self.display_manager.small_font if hasattr(self.display_manager, 'small_font') else ImageFont.load_default() - - try: - body_font = ImageFont.truetype('assets/fonts/4x6-font.ttf', 6) - except Exception: - body_font = self.display_manager.extra_small_font if hasattr(self.display_manager, 'extra_small_font') else ImageFont.load_default() - + + # Fonts/colors/offsets honor customization. (classic + # PressStart2P@8 / 4x6@6 when untouched) + (title_font, title_color, (title_dx, title_dy), + 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) @@ -569,24 +610,24 @@ def _display_content(self, category_config: Dict, item_data: Dict): title_width = len(title) * 6 # Center the title horizontally (same position as in _display_title) - title_x = (self.display_manager.width - title_width) // 2 - title_y = margin_top - + title_x = (self.display_manager.width - title_width) // 2 + title_dx + title_y = margin_top + title_dy + # Draw title using display_manager.draw_text (same as title screen) self.display_manager.draw_text( title, x=title_x, y=title_y, - color=self.title_color, + color=title_color, font=title_font ) - + # Draw underline below title (same as title screen) underline_y = title_y + title_height + 1 underline_x_start = title_x underline_x_end = title_x + title_width - draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], - fill=self.title_color, width=1) + draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], + fill=title_color, width=1) # Wrap description text available_width = self.display_manager.width - 4 @@ -625,14 +666,14 @@ def _display_content(self, category_config: Dict, item_data: Dict): line_width = bbox[2] - bbox[0] else: line_width = len(line) * 6 - line_x = (self.display_manager.width - line_width) // 2 - + line_x = (self.display_manager.width - line_width) // 2 + body_dx + # Use display_manager.draw_text for description self.display_manager.draw_text( line, x=line_x, - y=current_y, - color=self.subtitle_color, + y=current_y + body_dy, + color=body_color, font=body_font ) diff --git a/plugins/of-the-day/manifest.json b/plugins/of-the-day/manifest.json index 61259417..a5489d92 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.2.0", + "version": "1.3.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", diff --git a/plugins/of-the-day/test_element_styles.py b/plugins/of-the-day/test_element_styles.py new file mode 100644 index 00000000..d16ae3cb --- /dev/null +++ b/plugins/of-the-day/test_element_styles.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Regression tests for of-the-day's element-style customization. + +This plugin is the exemplar for the x-style-elements system: it had NO +customization before — the schema declares two elements (title_text, +body_text) compactly and the core expands them into full font/size/color/ +offset config blocks. The promises tested here: + +1. UNTOUCHED IS UNCHANGED — with no customization config (or one carrying + only the schema defaults the web UI bakes in on save), rendering is + byte-identical to the pre-customization code. +2. OVERRIDES ENGAGE — a genuinely changed font size / color / offset + visibly changes the render. +3. The schema declaration expands (core side) and its defaults agree with + what the plugin's resolver reads from the raw file. + +Run from the core LEDMatrix tree (needs src.* and assets/fonts): + cd /path/to/LEDMatrix + python -m pytest /path/to/of-the-day/test_element_styles.py -q +""" + +import os +import sys +from datetime import date + +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, STYLE_AVAILABLE # noqa: E402 + +pytestmark = pytest.mark.skipif( + not STYLE_AVAILABLE, + reason="core without src.element_style — plugin uses classic styling", +) + +ITEM = {"title": "Serendipity", "subtitle": "noun | ser-en-DIP-i-tee", + "description": "Finding something good without looking for it."} + + +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 {}) + p = OfTheDayPlugin("of-the-day", cfg, VisualTestDisplayManager(w, h), + MockCacheManager(), MockPluginManager()) + return p + + +def _render_title(p): + p._display_title({}, ITEM) + return p.display_manager.image.copy() + + +def _render_content(p): + p._display_content({}, ITEM) + return p.display_manager.image.copy() + + +# What the web UI writes into config.json on save: the full schema defaults, +# whether or not the user touched anything. +SCHEMA_POPULATED = {"customization": { + "title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8, + "text_color": [255, 255, 255]}, + "body_text": {"font": "4x6-font.ttf", "font_size": 6, + "text_color": [200, 200, 200]}, + "layout": {"title_text": {"x_offset": 0, "y_offset": 0}, + "body_text": {"x_offset": 0, "y_offset": 0}}, +}} + + +class TestUntouchedIsUnchanged: + @pytest.mark.parametrize("w,h", [(64, 32), (128, 32), (128, 64)]) + def test_bare_config_matches_schema_populated(self, w, h): + """A never-saved config and a once-saved config (defaults baked in) + must render identically — schema defaults are not overrides.""" + for render in (_render_title, _render_content): + bare = render(_plugin(w, h, {})) + populated = render(_plugin(w, h, SCHEMA_POPULATED)) + assert ImageChops.difference(bare, populated).getbbox() is None + + def test_classic_colors_survive(self): + """The schema-default white/gray text_color must not clobber the + plugin's classic colors (they're identical here, but the render + must go through the classic path, not a forced override).""" + p = _plugin(128, 32, SCHEMA_POPULATED) + style = p._element_style_resolver if hasattr(p, '_element_style_resolver') else None + p._display_title({}, ITEM) # builds the resolver + title = p._element_style_resolver.style( + 'title_text', classic_font='PressStart2P-Regular.ttf', + classic_size=8, classic_color=(255, 255, 255)) + assert not title.user_forced + assert not title.user_forced_color + + +class TestOverridesEngage: + def test_font_size_override_changes_render(self): + base = _render_title(_plugin(128, 32, {})) + big = _render_title(_plugin(128, 32, {"customization": { + "title_text": {"font": "PressStart2P-Regular.ttf", + "font_size": 16}}})) + assert ImageChops.difference(base, big).getbbox() is not None + + def test_color_override_changes_render(self): + base = _render_title(_plugin(128, 32, {})) + red = _render_title(_plugin(128, 32, {"customization": { + "title_text": {"text_color": [255, 0, 0]}}})) + assert ImageChops.difference(base, red).getbbox() is not None + + def test_offset_shifts_render(self): + base = _render_title(_plugin(128, 32, {})) + shifted = _render_title(_plugin(128, 32, {"customization": { + "layout": {"title_text": {"x_offset": 4}}}})) + diff = ImageChops.difference(base, shifted).getbbox() + assert diff is not None + + +class TestSchemaDeclaration: + def test_expansion_generates_blocks(self): + from src.element_style import expand_style_elements + import json + schema = json.load(open(os.path.join(PLUGIN_DIR, "config_schema.json"))) + expanded = expand_style_elements(schema) + cust = expanded["properties"]["customization"]["properties"] + assert cust["title_text"]["x-style-managed"] is True + assert cust["title_text"]["properties"]["font"]["default"] == \ + "PressStart2P-Regular.ttf" + assert cust["body_text"]["properties"]["text_color"]["default"] == \ + [200, 200, 200] + assert "title_text" in cust["layout"]["properties"] + + def test_raw_file_defaults_match_declaration(self): + from src.element_style import defaults_from_schema_file + defaults = defaults_from_schema_file( + os.path.join(PLUGIN_DIR, "config_schema.json")) + assert defaults["customization"]["title_text"]["font_size"] == 8 + assert defaults["customization"]["body_text"]["text_color"] == [200, 200, 200] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))