From 69d408b3215e1f9e26ba5c6fbd98960599d16496 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:53:50 -0400 Subject: [PATCH] feat(core): one per-element display-customization framework, wired into the web UI (#566) * fix(sports): rebuild un-shared faces through the pinned layout engine unshare_element_fonts re-instantiates a duplicate font face so two elements can be told apart by id(). It did so through bare ImageFont.truetype, which takes PIL's default layout engine rather than the one src/common/font_layout.py pins. Raqm and Basic disagree on fractional advances -- that disagreement is the reason the pin exists, having broken golden images across machines -- so a rebuilt face could measure differently from the shared face it replaced, on any host where Raqm is installed. These were the only two call sites in src/ bypassing the pin. The guard asserts that the rebuild goes through the pinned loader rather than comparing engine values: where Raqm is absent, bare truetype returns BASIC anyway, so an engine comparison passes whether or not the pin is honoured. The first draft of this test did exactly that and passed with the bug reintroduced. Co-Authored-By: Claude Opus 5 * refactor(web): drop the two dead client-side config-form renderers generateConfigForm and generateSimpleConfigForm (580 lines) were defined on the Alpine component and never called: server-side Jinja replaced them, as pages_v3.py:641 records. Nothing in any template invokes them -- there is no x-html in the templates and no bracket access on the component. They carried their own x-widget dispatch, which made them an active trap: the next person adding a widget would reasonably think both renderers needed updating. plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the same reason -- loaded on every page from base.html, referenced only by itself and by an archived doc. Kept, having checked them: widgets/example-color-picker.js is the worked example docs/widget-guide.md points plugin authors at, and widgets/plugin-loader.js is the client half of a documented feature (manifest-declared plugin widgets) whose server route is missing -- soccer-scoreboard already ships a widgets/custom-leagues.js that this loader is meant to fetch. That is an unfinished feature to complete, not dead code to delete. Co-Authored-By: Claude Opus 5 * feat(web): serve plugin-declared widgets, and actually ask for them LEDMatrixWidgets.loadPluginWidget has always fetched /static/plugin-widgets//.js, and docs/widget-guide.md has always documented that path, but nothing served it. soccer-scoreboard has shipped a 17KB widgets/custom-leagues.js since August that could never load. Both halves were missing, not just the route: - serve_plugin_widget serves the script from the plugin's widgets/ directory as text/javascript. The manifest is the allowlist -- only a widget the plugin declares is reachable -- so installing a plugin does not publish everything it ships. Path handling mirrors the sibling serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() + relative_to() containment, and the ledmatrix- prefix fallback. The declared script name is guarded too, since it comes from the plugin rather than the request. - The config form never requested one. Its x-widget dispatch is a hardcoded list of core widget names, so a plugin's own widget fell through to a plain text input. An unrecognised x-widget on a string field now asks ensureWidget() for it. The text input stays as the fallback and is removed only once the widget has actually rendered, so a missing or broken widget costs the user an editor rather than their configured value on the next save. - manifest_schema.json gains "widgets", so the declaration is validated rather than merely tolerated by additionalProperties. Verified in a browser against the real partial: a declared widget loads, registers and renders, and its field posts exactly one value; a field whose widget 404s keeps its text input and still posts its value. Not addressed: loadPluginWidgetsFromManifest still has no caller. The per-field ensureWidget path is lazier and is what the form now uses, so that bulk helper is dead weight -- worth removing, but left alone here rather than inventing a call site for it. Known limitation, documented: only string-typed fields take this path. object/array/boolean/number fields and enums are dispatched by the template's own branches, which still only know core widgets. Co-Authored-By: Claude Opus 5 * fix(element-style): a wrong-size BDF now keeps its font, not its size BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel size baked into the file and raises for anything else. 32 of the 35 shipped fonts are BDF, so a size picked in the web UI usually is not a valid strike -- and load_font caught that failure with its generic "unloadable font" handler, which substitutes PressStart2P. Asking for 5x7.bdf at size 10 therefore rendered a completely different typeface, silently. It now falls back to the file's own native size instead, which is what SportsCore._load_custom_font_from_element_config has always done. The native size is read via FontManager._read_bdf_native_size rather than a fourth copy of that parser, matching how core.py already delegates. Also here, because they are the same code path: - native_bdf_size() is exposed for the web UI, which needs to know when a size field can take effect at all. None means "free choice". - ElementStyle.font_size now reports the size actually realised rather than the one requested. Callers lay out from it, and reserving space for a size nothing was drawn at is how this surfaces. - The module font cache is a bounded LRU (256) instead of an unbounded dict. The display process runs for weeks and every config save can add a (font, size) pair; every other hot cache in the codebase is bounded this way. Untouched configs are unaffected: the shipped classic fonts are the three TTFs, so nothing was hitting the substitution path by default. Co-Authored-By: Claude Opus 5 * feat(element-style): per-mode style and offset overrides Lets one element be styled differently per situation -- a scoreboard's live / upcoming / recent cards, weather's current / hourly / daily screens -- under customization.modes.. The mode is bound at construction rather than passed per call. That is what makes this cheap to adopt: SportsUpcoming and SportsRecent are already separate instances with distinct SKIN_MODE values, so binding once makes every existing style()/offset_value() call site mode-aware without editing any of them. A per-call mode argument exists for the rare host that renders more than one mode. The two layers answer different questions, deliberately: - The base layer keeps the existing "differs from the schema default" rule, because the save flow writes the full default object into config.json whether or not the user touched it. - A mode layer is pure override -- its fields default to None, so presence is intent. Nothing writes into it unasked, so there is nothing for the stricter rule to protect against. None therefore means inherit, and has to stay distinct from 0: a mode y_offset of 0 means "sit at the base position", not "no preference". This is the same distinction scroll_card.switch_* draws with "inherit". A malformed mode value falls back to the resolved base value rather than to the caller's default -- caught by the degradation tests, which is what they are for: resolving the mode first let one bad string in a mode block silently discard a good base offset. With no modes block, and for every existing caller, resolution is unchanged. Co-Authored-By: Claude Opus 5 * feat(element-style): declare per-mode overrides in config_schema.json A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside its x-style-elements declaration and gets a customization.modes. group per mode, with every field of every declared element repeated as an override. Those override fields are typed nullable and default to null, which is the whole trick. The save flow writes schema defaults into config.json wholesale, so giving a mode field the base element's default would make every mode a frozen copy of the base the first time a user pressed Save, and the base would stop reaching them. Null means inherit. The mutation test for this is explicit: with concrete defaults, a base font_size of 14 resolves as 10 with user_forced set. min/max from the declaration carry into the mode blocks, so an out-of-range override is rejected by validation rather than clamped silently at render time. Also: the emitted font field now carries "x-widget": "font-selector". The widget already shipped and the config form already allowlisted it -- the hint was simply never emitted, so the field rendered as a bare text box that the user had to type a font filename into. Verified through the real SchemaManager path -- load_schema, defaults extraction, merge_with_defaults, validation, then resolution -- rather than against a hand-built dict, since the thing at risk is what that pipeline does to a null. Co-Authored-By: Claude Opus 5 * fix(web): render the config form from the schema the save route validates The form read config_schema.json with a raw json.load while api_v3.save_plugin_config went through SchemaManager. Those are not the same schema: SchemaManager applies expand_style_elements, which turns a compact customization.x-style-elements declaration into the per-element blocks the form knows how to render. Without it, that customization object has an x-style-elements key and no "properties", so the template's object branch matched nothing and the section rendered as empty space -- while saving still validated against the expanded shape. of-the-day ships the compact form, so its customization section has been invisible in the web UI. pages_v3 gains a schema_manager the way it already has config_manager and plugin_manager. use_cache=False matches the save route, so an edited schema is not served stale during plugin development. The raw read stays as a fallback for callers that register this blueprint without one. Checked before making the change: load_schema does nothing here except read, validate and expand -- inject_skin_selector is a separate method it does not call -- so this is not a behaviour change for schemas without the declaration. The test pair renders the same compact schema with and without a SchemaManager, so it documents exactly what was broken as well as what is fixed. Co-Authored-By: Claude Opus 5 * feat(web): style-editor widget -- a row per element instead of 65 accordions Rendered element by element, a realistic scoreboard's customization block is 65 nested sections, and reaching one per-mode font size takes five levels of expanding. The widget collapses that to one compact row per element -- font, size, colour, X, Y -- with a tab per declared mode. It emits ordinary inputs under the same dotted names the generic renderer would produce, so the save/validate/merge pipeline is untouched: no hidden JSON blob and no new server-side parsing. It is driven entirely by the schema block it is handed, so fields added to the schema later appear without editing the widget. If it fails to load or throws, the generic nested rendering it replaces is left in place. Fixing two things the save path got wrong for nullable fields, found by posting what the widget actually emits: - The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared the declared type to the string 'array', so a per-mode colour, typed ["array", "null"], was never reassembled and failed validation on save. _parse_form_value_with_schema had the same comparison. - A blank nullable field became [] rather than None, which then failed the minItems the colour array declares. Null is the inherit sentinel, so it has to survive. And two things the widget itself got wrong, found by looking at it: - An unset base control fell back to the select's first option, so an untouched scoreboard claimed every element used 10x20.bdf -- and the size box then locked itself to that bitmap font's fixed size. Base controls now show the schema default; mode controls stay blank, because blank there means inherit. - Elements arrived alphabetised (Detail and Odds above Score). Flask's JSON provider sorts keys, so declaration order has to be stated explicitly; expand_style_elements now emits x-propertyOrder, which the generic renderer already honoured too. Size is disabled and shown as fixed for a bitmap font, using the scalable/native_size the font catalog now reports. Co-Authored-By: Claude Opus 5 * feat(element-style): visibility, alignment and scale per element Completes the customization vocabulary: hide an element, align it, and resize a logo, alongside the font/size/colour/offset that already existed. All three per mode. They resolve to "change nothing" until the user asks for something -- True, None and 1.0 -- rather than to whatever the schema declares. That is the same invariant the font fields keep: a caller that honours them still renders an untouched config exactly as it did before they existed. A schema default therefore does not count as a choice, which matters because the save flow writes that default into config either way. scale sits in the layout block with the offsets rather than in the element block, because it is geometry: a logo has a scale and no font. The widget's columns come from the schema, so a logo row shows visibility, offsets and scale and no empty font cell. Two bugs found by the tests rather than by reading: - A nullable enum needs null in its enum list, not just in its type. The mode copy of `align` defaulted to null and then failed its own schema, so a plugin declaring any enum field with modes could not save at all. Six tests failed on this before any of them reached what they were testing. - defaults_from_schema only ever extracted font/font_size/text_color, so the schema defaults for the new fields were invisible to the resolver and a declared default read as a user choice. Widget: the table scrolls horizontally and pins the element-name column. Nine columns do not fit the config panel, and clipping them hid the offsets entirely while scrolling them made every row anonymous. Co-Authored-By: Claude Opus 5 * feat(element-style): resolve elements under the names plugins actually use Two naming conventions collided as the scoreboards grew. Counted across the published schemas: the style block names elements with a _text suffix (score_text, status_text, detail_text), while the layout block mostly uses the bare noun (score, date, time, odds) -- except status_text, which kept the suffix in seven plugins and lost it in two. records vs record splits seven to two the same way. A lookup now tries the exact name first and then the spellings that mean the same thing. Exact-first is what makes this inert for any config that already matches; the aliases only decide cases that resolved to nothing before. This is also what makes migrating to the compact declaration form safe. That form uses one key for both blocks, so a scoreboard adopting it asks for layout.score_text while its users have layout.score saved -- without the aliases, every offset they had dialled in would silently become 0. Applies to the style block, the layout block, the schema defaults and the per-mode overrides, since the drift shows up in all four. Not attempting to canonicalise on write: renaming keys in config.json would break the plugins still reading the old spelling from their own bundled code, and the drift costs a dict miss rather than correctness. Co-Authored-By: Claude Opus 5 * feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits Adopting the element-style system meant repeating three things in every plugin: a guarded import, finding its own config_schema.json, and rebuilding the resolver when on_config_change swapped the config dict. This is those three things once, on the class all 45 plugins already inherit from. title = self.styles.style('title_text', classic_font='PressStart2P-Regular.ttf', classic_size=8, classic_color=(255, 255, 255)) The classic_* arguments are the adoption contract: with nothing configured they come back verbatim, so a plugin that switches to this renders exactly as before until a user changes something. A plugin with one instance per display mode sets STYLE_MODE on the class and every existing lookup becomes mode-aware without a call site changing -- which is the point of binding the mode to the resolver rather than passing it per call. styles_for() covers a plugin that renders several modes from one instance. Schema discovery reads the concrete class's own module rather than this file, because this file lives in src/plugin_system where no plugin schema exists -- the same trap SportsCore._config_schema_path documents. The first mutation test for that passed anyway: an installed plugin's module directory and its entry under plugins_dir are the same path, so the test could not tell the two apart. The case where they diverge is a plugin symlinked in for development, and the test now forces that shape. Getting discovery wrong is silent rather than loud: with no schema the resolver has no defaults to compare against, so every configured value reads as a deliberate override and the plugin quietly stops honouring its own shipped styling. Co-Authored-By: Claude Opus 5 * feat(element-style): adopt hand-written customization blocks, and widen the font list Nineteen plugins spell their style elements out longhand instead of declaring them -- football's block is 701 lines for seven elements -- and predate this system entirely. Core now recognises that shape, so they pick up the row-per-element editor and the real font picker on a core update rather than on a plugin release. Checked against every published schema: 21 plugins adopt, and the defaults of each still validate against the schema generated for it. Detection requires *every* field in a block to be one this system understands. A looser "has at least one style field" rule sweeps in baseball's `count`, which carries a text_color beside geometry that means nothing here. That distinction took three attempts to test: the first two assertions passed under both rules, because an over-eager rule leaves a fontless block looking untouched and only surfaces as an extra row in the editor. The hardcoded font enum is replaced rather than extended. Football lists five of the thirty-five installed fonts, which is why a font a user uploads can never appear in one. It is not a curated safe set -- it omits some twenty other faces that fit the declared size cap just as well -- it is the fonts that happened to exist when it was written. Widening it does need a guard, though, and not the one the schema already has: a bitmap font ignores font_size and renders at its size baked into the file, so `maximum: 16` cannot stop a 27px face. The picker now filters out fixed-size fonts taller than the element's own declared ceiling, which drops exactly the four that would overflow a 32px panel and keeps the other thirty. Per-mode overrides stay opt-in: core cannot invent a plugin's display modes, so `x-style-modes` remains the one line that unlocks them. Their layout half covers every positionable element rather than only those with a style block -- the two namespaces do not line up in a hand-written schema, and football positions six things (logos, timeouts, possession) that have no style block at all. Co-Authored-By: Claude Opus 5 * refactor(web): remove the two Fonts-tab panels that reported invented data "Element Font Overrides" let a user configure an override, showed a success toast, and changed nothing. All three endpoints behind it were stubs -- GET returned a hardcoded {}, POST and DELETE returned success without calling anything -- each marked "This would integrate with the actual font system". Wiring them to FontManager would not have fixed it. The machinery there is real (_load_overrides/_save_overrides persist config/font_overrides.json, resolve_font applies them, and the countdown plugin genuinely consumes it), but the panel's element dropdown offered eleven invented keys -- nfl.live.score, clock.time, weather.current -- that no plugin has ever read. An override saved against one of those would have persisted correctly and still done nothing. "Detected Manager Fonts" goes for the same reason. It claimed to show "fonts currently in use by managers (auto-detected)"; its own comment said "we'll simulate this", and it listed every font in the catalog with a hardcoded usage_count of 1 -- the panel beside it, with fabricated numbers attached. Per-element font choice now lives in each plugin's own config editor, against the elements that plugin actually has, and covers size, colour, offsets, visibility, alignment and scale rather than family and size. Kept: the font library (upload, preview, delete), which works, and /fonts/tokens, which is a stub but genuinely feeds the preview's size dropdown. FontManager's override methods are untouched -- countdown uses them. Verified in a browser with the tab's JS running: no console errors, 35 fonts listed, upload and preview intact. Removing the panel meant unwiring it from populateFontSelects too, which would otherwise have bailed out early on the missing select and left the preview dropdown empty. Co-Authored-By: Claude Opus 5 * refactor(sports): one reader for element colours and layout offsets There were two copies of the per-element colour read and three of the layout-offset read. They had already drifted -- the scroll-card renderer carries a comment about having ignored offsets its own schema advertised -- and each new capability had to be added to all of them or silently work in some places and not others. All of them now go through src.element_style, which is what carries the alias handling and the per-mode lookup. That lands immediately for the nine plugins importing these modules: a scoreboard asking for `score_text` offsets finds the `layout.score` its users configured, and a Live instance resolves its own colours through SKIN_MODE without any call site passing a mode. _normalize_color learned "#RRGGBB" in the process. The scoreboards' own readers have always accepted it, so the shared one had to, or consolidating would have quietly dropped a form users' configs may hold. _coerce_offset picked up the non-finite guard the scroll-card reader had and the other two did not. _get_layout_offset is promoted onto SportsCoreSharedMixin. Each plugin still carries its own copy in its bundled sports.py, which wins by MRO -- so adopting this is a deletion in the plugin, and until that deletion nothing changes for it. Note for whoever runs the suite next: test_display_dirty_tracking.py is order-dependent. Fifteen of its tests failed in one full run and passed in the next with no change in between, and pass in isolation. Pre-existing, unrelated to this, but it makes a full-run diff untrustworthy until it is fixed. Co-Authored-By: Claude Opus 5 * docs(changelog): record the element-style work under Unreleased This file's own preamble asks for it: a plugin may delete its bundled fallback copy of a core module only when its manifest floors on the first release that shipped that module, which requires the additions to be recorded here against a version. Names a plugin can now import and floor on -- the stateless layout_offset and element_color readers, alias_keys, native_bdf_size, the resolver's mode binding, BasePlugin.styles, and the promoted SportsCoreSharedMixin._get_layout_offset -- plus the schema and web-UI changes, the four fixes and the three removals. Co-Authored-By: Claude Opus 5 * fix(fonts): log the BDF native-size read failure instead of swallowing it The bdf-native-size lookup in get_fonts_catalog() caught any exception and silently discarded it. Every other guarded read added in this PR (the manifest parse in _declared_widget_script, the SchemaManager fallback in _load_plugin_config_partial) logs before falling through to the same degraded behavior. This one didn't, which is the shape a silent-exception-swallow lint rule flags. Behavior is unchanged -- native_size still comes back None -- but a corrupt or unreadable BDF file now leaves a trace. Verified: font-related tests (140) and the full suite still pass, with only the 2 pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias failures already present on origin/main. Co-Authored-By: Claude Opus 5 * fix: address CodeRabbit findings on the style-editor/font-selector PR - Fix _load_font_sized double-wrapping the (font, size) tuple on the missing-font path, which handed callers a tuple instead of a font. - Fix _set_nested_value skipping an explicit None when the key already existed, which silently kept stale overrides when a user cleared a nullable per-mode field or blanked all channels of an indexed color. - Preserve BDF scalable/native_size metadata through fetchFontCatalog's catalog-format mapping so maxFixedSize filtering actually applies. - Stop caching an empty array on a failed font-catalog fetch so a later call can retry instead of being stuck with the failed result. - Keep a saved font selected in the style editor even when it no longer fits a newly declared maxFixedSize, instead of silently deselecting it. - Don't drop in-progress user edits to fallback fields when a plugin widget finishes loading asynchronously and takes over the form. - Tighten the removed font-override endpoint test to assert 405, not just != 200. Co-Authored-By: Claude Opus 5 * fix(web): a partial save no longer switches off checkboxes it never showed An HTML checkbox posts nothing when unchecked, so the save route walked the schema and forced every boolean missing from the form to False. That is right for the rendered form and wrong for every other caller: a script, the MQTT bridge or a curl against the documented endpoint never rendered a checkbox, and reading its silence as "all off" turns a one-field save into a mass disable. Found on hardware. Posting four customization.* keys to a live device switched off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request. The form now reports the top-level sections it drew (__rendered_section), and inside those an absent checkbox still means unchecked -- including a section whose only fields are checkboxes that are all off, which no heuristic could recover. A post with no marker only touches objects it actually posted a field from. Meta fields are dropped before form keys are treated as config paths, because unknown keys are otherwise written straight into config.json. Co-Authored-By: Claude Opus 5 * feat(sports): resolve element colour by name, and honour visible/align/scale Two of the three gaps this framework shipped with. Colour by name. A draw resolved its colour by comparing the *identity* of the font object it was handed, which cannot tell two elements apart when they share a face -- so those draws went out white. Every bitmap font is in that case, because a freetype.Face cannot be re-instantiated to un-share it, which is how an element rendered in any of the 32 shipped BDF fonts silently lost a colour its picker had offered all along. _draw_text_with_outline now takes element="score_text" and reads the colour by name; the identity path remains for un-annotated callers, but narrows before giving up -- one configured colour among the sharers is the only thing the user can have meant. Visible, align and scale. The resolver has understood these since the framework landed and nothing consumed them: an element could be marked hidden in the web UI and still render. Adds the stateless readers, the mixin accessors, and a scale parameter on the one shared logo-sizing seam (keyed into the cache, so two elements scaled differently cannot be served each other's image). Naming an element in a draw also honours its visibility. Untouched configs are unaffected: every new parameter defaults to today's behaviour, and all ten affected plugins render pixel-identically to main across every harness size. Co-Authored-By: Claude Opus 5 * docs(plugins): how to declare styleable elements; harden the widget's lookups The plugin-author guide for the compact x-style-elements declaration -- what each key does, how to read values back without breaking the "user-forced only when it differs from the default" rule, and why a hand-written block needs no changes to be adopted. Also clears the static-analysis findings on style-editor.js. Every lookup in that file is keyed by something out of a schema or a saved config, so a key of __proto__ or constructor would walk the prototype chain and hand back a function instead of a schema; reads now go through an own-property helper. The panel registry became a list, and the flagged vars moved to their function roots. Co-Authored-By: Claude Opus 5 * fix(web): clear the remaining static-analysis findings Five, all on lines this branch touched. The Python one is not a new defect: _set_missing_booleans_to_false's first parameter was always named `config`, which shadows the `config` submodule imported for its side effects at the bottom of this module. Editing the signature simply put the existing warning on a changed line. The parameter is the plugin's config dict, so `plugin_config` is what it should have been called anyway; callers pass it positionally and are unaffected. The JavaScript ones are the object-injection rule firing on reads keyed by data. own() now goes through a property descriptor, so the one unavoidable data-keyed read is no longer a computed member access; at() consumes its path instead of indexing it; and the column set is a Map, which has no prototype to pollute and needs no guarded reads at all. Verified the widget still renders identically against football's real schema: 29 element rows, all four mode tabs, values populated, no console errors. Co-Authored-By: Claude Opus 5 * fix(web): drop the hasOwnProperty alias the descriptor read made redundant own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 90 + docs/PLUGIN_ELEMENT_STYLING.md | 135 ++ docs/README.md | 2 + docs/widget-guide.md | 62 +- schema/manifest_schema.json | 24 + src/common/logo_helper.py | 83 +- src/common/sports_card.py | 89 +- src/common/sports_game_renderer.py | 16 +- src/common/sports_shared.py | 131 +- src/element_style.py | 1043 ++++++++- src/plugin_system/base_plugin.py | 164 ++ test/fixtures/api_v3_url_map.json | 1967 ++++++++--------- test/test_base_plugin_styles.py | 224 ++ test/test_element_style.py | 864 +++++++- test/test_element_visibility_align_scale.py | 187 ++ test/test_sports_card.py | 151 +- test/test_sports_game_renderer.py | 45 + test/test_sports_shared.py | 88 + test/test_web_api.py | 37 +- test/web_interface/test_api_v3_helpers.py | 14 +- .../test_partial_save_booleans.py | 249 +++ .../test_plugin_config_schema_expansion.py | 182 ++ .../web_interface/test_plugin_widget_route.py | 261 +++ .../test_style_editor_extra_fields.py | 212 ++ .../test_style_editor_save_roundtrip.py | 222 ++ web_interface/app.py | 1 + web_interface/blueprints/api_v3/__init__.py | 142 +- web_interface/blueprints/api_v3/fonts.py | 49 +- web_interface/blueprints/api_v3/plugins.py | 31 +- web_interface/blueprints/pages_v3.py | 155 +- web_interface/static/v3/app.css | 82 + web_interface/static/v3/js/app-shell.js | 580 ----- .../static/v3/js/plugins/config_manager.js | 133 -- web_interface/static/v3/js/widgets/README.md | 34 +- .../static/v3/js/widgets/font-selector.js | 20 +- .../static/v3/js/widgets/style-editor.js | 636 ++++++ web_interface/templates/v3/base.html | 2 +- .../templates/v3/partials/fonts.html | 362 +-- .../templates/v3/partials/plugin_config.html | 140 ++ 39 files changed, 6572 insertions(+), 2337 deletions(-) create mode 100644 docs/PLUGIN_ELEMENT_STYLING.md create mode 100644 test/test_base_plugin_styles.py create mode 100644 test/test_element_visibility_align_scale.py create mode 100644 test/web_interface/test_partial_save_booleans.py create mode 100644 test/web_interface/test_plugin_config_schema_expansion.py create mode 100644 test/web_interface/test_plugin_widget_route.py create mode 100644 test/web_interface/test_style_editor_extra_fields.py create mode 100644 test/web_interface/test_style_editor_save_roundtrip.py delete mode 100644 web_interface/static/v3/js/plugins/config_manager.js create mode 100644 web_interface/static/v3/js/widgets/style-editor.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ab90b8e6..4d7ffd30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,96 @@ release that ships it. accepts both, but the store flags the old spelling as deprecated (`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. +## Unreleased + +**Per-element display customization, and the last mile of it into the web UI.** +A user can set the font, size, colour, position, visibility and alignment of +individual display elements per plugin -- and, where a plugin has display +modes, separately per mode. + +New public API a plugin may import via `src.*` (floor on the release that +ships this): + +- `src.element_style.layout_offset(config, element, axis, default, mode)` and + `element_color(config, element, default, mode)` — the stateless reads the + scoreboard helpers share. There were three copies of the offset read and two + of the colour read; these are the one implementation, and they carry the + element-name aliasing and the per-mode lookup. +- `src.element_style.alias_keys(element)` — the names one element may be stored + under. The style block names elements `score_text` while the layout block + says `score`, and `records`/`record` and `status_text`/`status` split seven + to two across the published schemas. A lookup tries the exact name first, so + this is inert for a config that already matches. +- `src.element_style.element_visible(config, element, default, mode)`, + `element_align(...)` and `element_scale(...)` — the stateless reads for the + three knobs the resolver already understood but no draw path consumed, so an + element could be marked hidden in the web UI and still render. +- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` — + naming the element resolves its colour by name and honours its visibility + toggle. Without a name the colour is inferred from font-object identity, + which cannot separate two elements sharing a face; that is the case every + bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and + it is how a BDF-rendered element silently lost a configured colour. Shared + faces now resolve when exactly one sharer has a colour set. +- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys + the cache on the scaled box so two elements scaled differently cannot be + served each other's image. +- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font + can render at, or None for a scalable one. The web UI needs this to know + whether a size control can take effect at all. +- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align` + and `scale` on `ElementStyle`. The mode binds to the resolver rather than + being passed per call, so a plugin with one instance per mode makes every + existing lookup mode-aware by setting one class attribute. +- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every + plugin inherits, so adopting this is no longer a guarded import plus schema + discovery plus resolver invalidation in each plugin. +- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins' + bundled copies. Each still carries its own, which wins by MRO, so adopting + it is a deletion. + +Schema and web UI: + +- A `customization` block is now rendered by a composite style editor: one row + per element rather than nested accordions, with a tab per declared mode. + Plugins that hand-wrote their style blocks get it without a plugin release; + `x-style-elements` and `x-style-modes` declare it compactly. +- Font fields become a real picker rather than a hardcoded `enum`, so a font + the user uploads is selectable. Bitmap fonts taller than the element's + declared size ceiling are filtered out, because a bitmap font ignores + `font_size` and renders at its own size. +- `/static/plugin-widgets//.js` serves a plugin's own web-UI + widgets. The client half and the docs already existed; nothing served them. + +Fixed: + +- A bitmap font asked for a size it has no strike for fell back to + *PressStart2P* — a different typeface — rather than to its own native size. + 32 of the 35 shipped fonts are bitmap, so this was reachable for most font + choices. +- The plugin config form read `config_schema.json` directly while the save + route read it through `SchemaManager`. Only the latter expands a compact + `x-style-elements` declaration, so a plugin using that form had a + customization section that rendered as empty space. +- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`, + bypassing the layout engine `src/common/font_layout.py` pins. These were the + only two call sites in `src/` doing so. +- The form parser compared a schema type to a bare string, so a nullable field + (`["array", "null"]`) never had its indexed colour inputs recombined, and a + blank one became `[]` rather than null. + +Removed: + +- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They + reported success and saved nothing, and the element keys the panel offered + (`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the + real `FontManager` methods would still have changed nothing on the panel. + Per-element font choice now lives in each plugin's own config editor. +- "Detected Manager Fonts", which listed every installed font with a hardcoded + usage count. +- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and + the legacy `plugins/config_manager.js`, superseded by server-side rendering. + ## 3.3.0 **The release the sports scoreboards floor on to delete their bundled copies.** diff --git a/docs/PLUGIN_ELEMENT_STYLING.md b/docs/PLUGIN_ELEMENT_STYLING.md new file mode 100644 index 00000000..d6cc993a --- /dev/null +++ b/docs/PLUGIN_ELEMENT_STYLING.md @@ -0,0 +1,135 @@ +# Per-element styling for plugin authors + +Users want to change the font, size and colour of individual things on screen, +nudge them a few pixels, hide the ones they do not care about, and scale a logo +down. This is the one system that does that, and a plugin joins it by +**declaring elements in its `config_schema.json`** — not by writing a style +resolver, a font cache or a web form. + +The short version: + +```jsonc +"customization": { + "type": "object", + "title": "Display Customization", + "x-style-elements": { + "score_text": { + "title": "Score", + "font": { "default": "PressStart2P-Regular.ttf" }, + "size": { "default": 10, "min": 4, "max": 16 }, + "color": { "default": [255, 255, 255] }, + "offsets": true, + "visible": true, + "align": true + }, + "home_logo": { "title": "Home logo", "offsets": true, "scale": true } + }, + "x-style-modes": ["live", "upcoming", "recent"] +} +``` + +That is the whole declaration. The core expands it into a full JSON Schema, the +web UI renders a compact style editor with a row per element, and the values +land in `config.json` under the keys you named. + +## What each key does + +| Key | Effect | +|---|---| +| `font` | Font picker listing every shipped **and user-uploaded** font. | +| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. | +| `color` | Colour swatch; stored as `[r, g, b]`. | +| `offsets` | X/Y nudge, stored under `customization.layout.`. | +| `visible` | Show/hide toggle. | +| `align` | `left` / `center` / `right`. | +| `scale` | Size multiplier, for logos and images. Also under `layout`. | + +`x-style-modes` is optional. Declare it and every element gains a per-mode +override tab — a scoreboard can then style its live, upcoming and recent cards +separately. **A mode field left blank means "inherit", not zero.** + +## Reading the values + +Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json` +on its own: + +```python +style = self.styles.style( + "score_text", + classic_font="PressStart2P-Regular.ttf", # what you shipped + classic_size=10, + classic_color=(255, 255, 255), +) +if style.visible: + draw.text((x + style.offset[0], y + style.offset[1]), + text, font=style.font, fill=style.color) +``` + +For a specific mode, use `self.styles_for("recent")`, or set +`STYLE_MODE = "recent"` on the class and keep calling `self.styles`. + +### The one rule that matters + +**Pass your shipped values as the `classic_*` arguments.** The resolver returns +them verbatim unless the user actually changed something, which is what keeps an +untouched install rendering byte-identically. It can tell the difference because +a value only counts as user-forced when it *differs from the schema default* — +the save path writes the full default object into `config.json` on every save, +so "present in config" proves nothing. + +Never compare against the default yourself; that rule lives in exactly one place. + +### Stateless readers + +For helpers handed a config dict rather than a plugin instance: + +```python +from src.element_style import (element_color, element_visible, + element_align, element_scale, layout_offset) + +colour = element_color(config, "score_text", (255, 255, 255), mode) +shown = element_visible(config, "records", True, mode) +dy = layout_offset(config, "score", "y_offset", 0, mode) +``` + +## Sports scoreboards + +`SportsCoreSharedMixin` wires most of this up already. Two things to know: + +* **Name your draws.** `_draw_text_with_outline(..., element="score_text")` + resolves the colour by name *and* honours the visibility toggle. Without it + the colour has to be guessed from the identity of the font object, which + cannot tell two elements apart when they share a face — the case every + bitmap font is in. +* **Modes are free.** Live/upcoming/recent are separate instances, so setting + `SKIN_MODE` on each is enough; no call site passes a mode. + +## Adopting an existing hand-written block + +If your schema already spells out `font` / `font_size` / `text_color` per +element longhand, **you do not need to change anything**. The core recognises +that shape and upgrades it in place: the style editor, the real font picker +(including uploaded fonts) and per-mode overrides all appear on a core update. +Add `x-style-modes` if you want the mode tabs. + +## Fonts, and why size is sometimes locked + +32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly +one pixel size and ignore `font_size`. The picker knows which, and the editor +locks the size field to the native size and labels it `fixed`. A font too tall +for the `max` you declared is not offered at all. + +Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker +automatically. + +## Checklist + +1. Declare `x-style-elements` (and `x-style-modes` if you have modes). +2. Read through `self.styles`, passing your shipped values as `classic_*`. +3. Honour `style.visible`, `style.offset` and `style.scale` where they apply. +4. Confirm an untouched config renders identically: + `python scripts/check_plugin.py --plugin `. +5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`. + +See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md), +[docs/FONT_MANAGER.md](FONT_MANAGER.md). diff --git a/docs/README.md b/docs/README.md index 63128da6..c1b66aeb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ Going deeper: - [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need - [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design +- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle, + move, hide and scale individual elements (per display mode, if you have them) - [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs - [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works - [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors diff --git a/docs/widget-guide.md b/docs/widget-guide.md index 9bc271eb..2ac2a99a 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -285,7 +285,9 @@ Guidelines: ### Step 1: Create Widget File -Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`: +Create a JavaScript file in your plugin's `widgets/` directory, named +`widgets/[widget-name].js`. The directory is not optional: it is the only +place the core will serve a widget from. ```javascript // Ensure LEDMatrixWidgets registry is available @@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', { }); ``` -### Step 2: Reference Widget in Schema +### Step 2: Declare the Widget in `manifest.json` + +The manifest is the allowlist. A widget is served only if the plugin declares +it, so shipping a file under `widgets/` does not by itself publish it: + +```json +{ + "widgets": [ + { + "name": "my-custom-widget", + "script": "my-custom-widget.js", + "description": "What this widget is for" + } + ] +} +``` + +`name` is what you use in `x-widget` and in the URL. `script` is optional and +defaults to `[name].js`; it must be a plain filename directly inside +`widgets/` (no paths). Both are validated against +`schema/manifest_schema.json`. + +### Step 3: Reference Widget in Schema In your plugin's `config_schema.json`: @@ -383,15 +407,30 @@ In your plugin's `config_schema.json`: } ``` -### Step 3: Widget Loading +### Step 4: Widget Loading -The widget will be automatically loaded when the plugin configuration form is rendered. The system will: +The widget is loaded on demand when the plugin's configuration form renders a +field that references it. The system will: -1. Check if widget is registered in the core registry -2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js` -3. Render the widget using the registered `render` function +1. Check whether the widget is already registered in the core registry. +2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`. + That route serves the declared `script` from your plugin's `widgets/` + directory, as `text/javascript`. +3. Render it by calling the `render` function your script registered. -**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement. +The fetch uses a dynamic `import()`, so the file must parse as an ES module. +A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs. + +**If the widget fails to load** (not declared, file missing, script throws, or +it never calls `register`), the field falls back to a plain text input holding +the current value. This is deliberate: a broken widget costs the user an +editor, not their configured value. + +**Limitation:** the on-demand path applies to `string`-typed fields (the +default branch of the config-form renderer). Fields typed `object`, `array`, +`boolean`, `integer` or `number`, and fields whose `enum` is set, are +dispatched by the server-side template to its own built-in renderers, so a +plugin-supplied `x-widget` on one of those is ignored today. ## Widget API Reference @@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf - ✅ Plugin widget loading system implemented **Current Behavior:** -- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved) +- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved) - Widget handlers are registered and available globally -- Custom widgets can be created and registered -- Full client-side rendering is a future enhancement +- Custom widgets can be created, declared in `manifest.json`, and are served + and rendered on demand for `string`-typed fields +- Plugin widgets on non-string fields are not dispatched yet (see Step 4) **Backwards Compatibility:** - All existing plugins using widgets continue to work without changes diff --git a/schema/manifest_schema.json b/schema/manifest_schema.json index dce337c5..5dd360d8 100644 --- a/schema/manifest_schema.json +++ b/schema/manifest_schema.json @@ -257,6 +257,30 @@ }, "description": "Web UI action definitions" }, + "widgets": { + "type": "array", + "description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets//.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"\".", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}$", + "description": "Widget name, as used in x-widget and in the URL." + }, + "script": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$", + "description": "Filename inside widgets/. Defaults to .js." + }, + "description": { + "type": "string", + "description": "Human-readable summary shown to plugin authors." + } + } + } + }, "ledmatrix_version": { "type": "string", "description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets" diff --git a/src/common/logo_helper.py b/src/common/logo_helper.py index 0361a08b..c8a5c221 100644 --- a/src/common/logo_helper.py +++ b/src/common/logo_helper.py @@ -35,6 +35,29 @@ from src.common.permission_utils import ( # trade for not re-warning about a file nobody is going to add. MISSING_LOGO_RECHECK_SECONDS = 3600.0 +#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed +#: enough that a typo cannot ask for a 4000px image on a 64px panel. +MIN_LOGO_SCALE = 0.05 +MAX_LOGO_SCALE = 8.0 + + +def _usable_scale(scale) -> float: + """A scale that can be applied, or 1.0. + + Anything unusable -- None, a string, zero, a negative, NaN, infinity -- + means "as shipped", because the alternative is a blank panel from a + mistyped number. + """ + try: + value = float(scale) + except (TypeError, ValueError): + return 1.0 + if value != value or value in (float('inf'), float('-inf')): + return 1.0 + if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE: + return 1.0 + return value + # Well above any real team logo; bounds what a remote URL can write to disk. @@ -88,18 +111,23 @@ class LogoHelper: 'Accept': 'image/*', }) - def load_logo(self, team_abbr: str, logo_path: Union[str, Path], - max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Optional[Image.Image]: + def load_logo(self, team_abbr: str, logo_path: Union[str, Path], + max_width: Optional[int] = None, + max_height: Optional[int] = None, + scale: float = 1.0) -> Optional[Image.Image]: """ Load and resize a team logo. - + Args: team_abbr: Team abbreviation for caching logo_path: Path to the logo file max_width: Maximum width (defaults to display_width * 1.5) max_height: Maximum height (defaults to display_height * 1.5) - + scale: User's size multiplier for this image, from + ``customization.layout..scale``. 1.0 is untouched and + takes exactly the path it always did. Callers hold the config, + so they resolve the element name; this only applies the number. + Returns: PIL Image object or None if loading fails @@ -115,6 +143,12 @@ class LogoHelper: max_width = int(self.display_width * 1.5) if max_height is None: max_height = int(self.display_height * 1.5) + scale = _usable_scale(scale) + if scale != 1.0: + max_width = max(1, int(round(max_width * scale))) + max_height = max(1, int(round(max_height * scale))) + # The key carries the scaled box, so two elements scaled differently + # cannot be served each other's image. cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}" if cache_key in self._logo_cache: self.logger.debug(f"Using cached logo for {team_abbr}") @@ -146,7 +180,8 @@ class LogoHelper: logo = logo.convert('RGBA') # Resize if needed - logo = self._resize_logo(logo, max_width, max_height) + logo = self._resize_logo(logo, max_width, max_height, + allow_upscale=scale > 1.0) # Cache the logo self._cache_logo(cache_key, logo) @@ -158,10 +193,11 @@ class LogoHelper: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None - def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path], + def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path], logo_url: Optional[str] = None, max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Optional[Image.Image]: + max_height: Optional[int] = None, + scale: float = 1.0) -> Optional[Image.Image]: """ Load logo with automatic download if missing. @@ -181,7 +217,8 @@ class LogoHelper: # failed download does not count: it wears the real logo's filename, so # trusting the file's existence is what left teams as grey boxes. if logo_path.exists() and not self._is_stale_placeholder(logo_path): - return self.load_logo(team_abbr, logo_path, max_width, max_height) + return self.load_logo(team_abbr, logo_path, max_width, max_height, + scale) # Download if URL provided and file doesn't exist if logo_url: @@ -193,7 +230,8 @@ class LogoHelper: # from the cache before touching the disk -- so without this the # real logo would not appear until the process restarted. self._invalidate_cached_logo(team_abbr, logo_path) - return self.load_logo(team_abbr, logo_path, max_width, max_height) + return self.load_logo(team_abbr, logo_path, max_width, max_height, + scale) except Exception as e: self.logger.error(f"Failed to download logo for {team_abbr}: {e}") # The retry failed, so restart the back-off. The stale @@ -328,18 +366,31 @@ class LogoHelper: ), } - def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Image.Image: - """Resize logo to fit display dimensions.""" + def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None, + max_height: Optional[int] = None, + allow_upscale: bool = False) -> Image.Image: + """Resize logo to fit display dimensions. + + ``allow_upscale`` is only set when the user asked for a scale above 1: + the fit rule is "never larger than the box", and growing an image + nobody asked to grow would change every existing render. + """ if max_width is None: max_width = int(self.display_width * 1.5) if max_height is None: max_height = int(self.display_height * 1.5) - + # Only resize if necessary if logo.width <= max_width and logo.height <= max_height: - return logo - + if not allow_upscale or not logo.width or not logo.height: + return logo + ratio = min(max_width / logo.width, max_height / logo.height) + if ratio <= 1: + return logo + return logo.resize((max(1, int(logo.width * ratio)), + max(1, int(logo.height * ratio))), + Image.Resampling.LANCZOS) + # Maintain aspect ratio logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) return logo diff --git a/src/common/sports_card.py b/src/common/sports_card.py index 453eafdc..b2b7d563 100644 --- a/src/common/sports_card.py +++ b/src/common/sports_card.py @@ -97,38 +97,71 @@ def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str: # --------------------------------------------------------------------------- def element_color(config: Optional[Dict[str, Any]], element: str, - default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" + default: Tuple[int, int, int] = (255, 255, 255), + mode: Optional[str] = None): + """Per-element text colour from customization..text_color. + + Delegated rather than reimplemented: there were two copies of this + read and three of the offset read, and the shared one also resolves + the element under the names plugins actually use (the layout block + says `score` where the style block says `score_text`) and honours a + per-mode override. Hex strings are still accepted. + """ + from src.element_style import element_color as _shared + return _shared(config, element, default, mode) + + +def resolve_font_color(config: Optional[Dict[str, Any]], + fonts: Optional[Dict[str, Any]], font, + default: Tuple[int, int, int], + element_for_font: Dict[str, str], + mode: Optional[str] = None): + """Colour for whichever element owns this face. + + Identity matching is a stand-in for the element name, used where the draw + site only ever received a font. Prefer ``element=`` on the draw call; this + is the fallback for the sites that have not been annotated yet. + + One object can legitimately belong to several elements -- a size resolver + can land two of them on the same face, and a BDF face cannot be un-shared + at all because ``freetype.Face`` objects cannot be rebuilt from a path. + Those draws used to go out white, which is how an element rendered in any + of the 32 shipped bitmap fonts could silently lose a colour the user had + set. So ambiguity is now narrowed before it is given up on: among the + elements sharing a face, a single configured colour is the only thing the + user can have meant, and several that agree mean the same thing. Only a + genuine disagreement falls back to *default*. + + The element vocabulary is a parameter because the two callers disagree + about it -- the mixin's map says ``team_text`` where this module's says + ``team_name`` -- and quietly re-pointing either at the other's names would + change which colour setting a live install honours. + """ try: - cfg = (config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): + fonts = fonts or {} + matches = [element for key, element in element_for_font.items() + if fonts.get(key) is font] + if len(matches) == 1: + return element_color(config, matches[0], default, mode) + if len(matches) > 1: + configured = [] + for element in matches: + colour = element_color(config, element, None, mode) + if colour is not None and colour not in configured: + configured.append(colour) + if len(configured) == 1: + return configured[0] + except (AttributeError, TypeError): pass return default def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], - font, default: Tuple[int, int, int] = (255, 255, 255)): - """Colour for whichever element owns this face. - - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. - """ - try: - fonts = fonts or {} - matches = [element for key, element in ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return element_color(config, matches[0], default) - except (AttributeError, TypeError): - pass - return default + font, default: Tuple[int, int, int] = (255, 255, 255), + mode: Optional[str] = None): + """Colour for whichever element owns this face, by this module's map.""" + return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT, + mode) def coerce_rgb(value, fallback): @@ -428,7 +461,7 @@ def unshare_element_fonts(logger, fonts): path) are left shared, and their draws stay white as before. """ try: - from PIL import ImageFont as _IF + from src.common.font_layout import load_truetype as _load except ImportError: # pragma: no cover return fonts seen = {} @@ -443,7 +476,7 @@ def unshare_element_fonts(logger, fonts): if not path or not size: continue try: - fonts[key] = _IF.truetype(path, size) + fonts[key] = _load(path, size) except (OSError, ValueError, TypeError): logger.debug( "Could not un-share the %s face; it keeps the default colour", key) diff --git a/src/common/sports_game_renderer.py b/src/common/sports_game_renderer.py index d42faf3f..c890e45c 100644 --- a/src/common/sports_game_renderer.py +++ b/src/common/sports_game_renderer.py @@ -134,19 +134,9 @@ class SportsGameRendererMixin: the element on the scroll/Vegas card too -- previously the schema advertised these offsets but this renderer ignored them. """ - try: - layout = (self.config or {}).get("customization", {}).get("layout", {}) - value = (layout.get(element) or {}).get(axis, default) - if isinstance(value, bool): - return default - if isinstance(value, (int, float)): - return int(value) if math.isfinite(value) else default - if isinstance(value, str): - parsed = float(value) - return int(parsed) if math.isfinite(parsed) else default - except (TypeError, ValueError, OverflowError): - pass - return default + from src.element_style import layout_offset + return layout_offset(self.config, element, axis, default, + getattr(self, "SKIN_MODE", None)) # ---- upcoming cards ------------------------------------------------ diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index 7d41d006..054378ba 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -191,7 +191,8 @@ class SportsCoreSharedMixin: img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) draw = ImageDraw.Draw(img) status = game.get("status_text", "N/A") - self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"], + element="status_text") self.display_manager.image.paste(img, (0, 0)) # Don't call update_display here, let subclasses handle it after drawing except Exception as e: @@ -507,8 +508,10 @@ class SportsCoreSharedMixin: + self._get_layout_offset('score', 'x_offset')) vs_y = (center_y - 3 + self._get_layout_offset('score', 'y_offset')) + vs_x = self._aligned_x('score_text', vs_width, width, vs_x) self._draw_text_with_outline( - draw, vs_text, (vs_x, vs_y), self.fonts["score"] + draw, vs_text, (vs_x, vs_y), self.fonts["score"], + element="score_text" ) # "vs" and "none" both push the date and time out to the edges, time @@ -757,18 +760,71 @@ class SportsCoreSharedMixin: self.logger.debug("Headline font scaling skipped", exc_info=True) return fonts + def _get_layout_offset(self, element: str, axis: str, + default: int = 0) -> int: + """X/Y nudge for one element, from ``customization.layout``. + + Promoted here so every scoreboard reads offsets the same way the + scroll card does. Each plugin still carries its own copy in its + bundled sports.py, which wins by MRO until that copy is deleted -- + deleting it is what buys the alias handling (a plugin asking for + ``score_text`` finds the ``score`` its users configured) and the + per-mode overrides, since this resolves through SKIN_MODE. + """ + from src.element_style import layout_offset + return layout_offset(self.config, element, axis, default, + getattr(self, "SKIN_MODE", None)) + def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)): - """Per-element text colour from customization..text_color.""" - try: - cfg = (self.config or {}).get("customization", {}).get(element, {}) - value = cfg.get("text_color") - if isinstance(value, (list, tuple)) and len(value) == 3: - return tuple(max(0, min(255, int(c))) for c in value) - if isinstance(value, str) and value.startswith("#") and len(value) == 7: - return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5)) - except (TypeError, ValueError): - pass - return default + """Per-element text colour from customization..text_color. + + Mode-aware through SKIN_MODE, so Live and Recent instances of the + same scoreboard resolve their own colours without any call site + passing a mode. + """ + from src.element_style import element_color as _shared + return _shared(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _element_visible(self, element: str, default: bool = True) -> bool: + """Whether ``customization..visible`` allows this draw. + + Mode-aware like the colour read, so a user can hide the records on the + recent card and keep them on the upcoming one. + """ + from src.element_style import element_visible + return element_visible(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _element_align(self, element: str, default: Optional[str] = None): + """``customization..align``: 'left', 'center' or 'right'.""" + from src.element_style import element_align + return element_align(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _element_scale(self, element: str, default: float = 1.0) -> float: + """``customization.layout..scale`` -- logos, mostly.""" + from src.element_style import element_scale + return element_scale(self.config, element, default, + getattr(self, "SKIN_MODE", None)) + + def _aligned_x(self, element: str, text_width: float, container_width: int, + centered_x: float) -> float: + """Where a run of text starts, honouring ``align``. + + Unset means "leave it exactly where it was", so this returns the + caller's own x rather than re-deriving a centre: these draws have + accumulated per-sport nudges and a centre computed here would not be + the same pixel. + """ + align = self._element_align(element) + if not align: + return centered_x + if align == 'left': + return 0 + if align == 'right': + return max(0, container_width - text_width) + return centered_x def _unshare_element_fonts(self, fonts): """Give each colourable element its own face object. @@ -787,7 +843,7 @@ class SportsCoreSharedMixin: path) are left shared, and their draws stay white as before. """ try: - from PIL import ImageFont as _IF + from src.common.font_layout import load_truetype as _load except ImportError: # pragma: no cover return fonts seen = {} @@ -802,7 +858,7 @@ class SportsCoreSharedMixin: if not path or not size: continue try: - fonts[key] = _IF.truetype(path, size) + fonts[key] = _load(path, size) except (OSError, ValueError, TypeError): self.logger.debug( "Could not un-share the %s face; it keeps the default colour", key) @@ -811,25 +867,31 @@ class SportsCoreSharedMixin: def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): """Colour for whichever element owns this face. - Matched on identity, and deliberately gives up when one object is - shared: the last-resort font path can hand the same face to several - keys, and there is no right answer for which element's colour that is. - White is what those draws used before, so ambiguity costs nothing. + The fallback for draw sites that were only ever handed a font. Prefer + ``element=`` on :meth:`_draw_text_with_outline`, which needs none of + this. Shared with the scroll card's copy so the narrowing rule that + rescues bitmap-font colours lives in one place; the element vocabulary + stays this class's own, because its map says ``team_text`` where + sports_card's says ``team_name``. """ - try: - fonts = getattr(self, "fonts", None) or {} - matches = [element for key, element in self._ELEMENT_FOR_FONT.items() - if fonts.get(key) is font] - if len(matches) == 1: - return self._element_color(matches[0], default) - except (AttributeError, TypeError): - pass - return default + from src.common.sports_card import resolve_font_color + return resolve_font_color( + getattr(self, "config", None), getattr(self, "fonts", None), font, + default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None)) def _draw_text_with_outline( - self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) + self, draw, text, position, font, fill=None, outline_color=(0, 0, 0), + element=None ): - """Draw text with a black outline for better readability.""" + """Draw text with a black outline for better readability. + + Pass ``element`` (``"score_text"``, ``"status_text"``, ...) wherever the + caller knows what it is drawing: the colour is then read by name, which + is exact. Without it the colour has to be inferred from the identity of + the font object, which cannot tell two elements apart when they share a + face -- the case every bitmap font is in, because a ``freetype.Face`` + cannot be re-instantiated. + """ # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying # glyphs. 1-bit mode keeps strokes crisp. @@ -839,7 +901,14 @@ class SportsCoreSharedMixin: # and they only ever changed the font. An explicit fill still wins: # the odds colours and the favourite-result score tint mean something # the palette does not. - if fill is None: + if element is not None: + # Named, so both questions can be answered exactly: whether this + # element is meant to be on screen at all, and what colour it is. + if not self._element_visible(element): + return + if fill is None: + fill = self._element_color(element) + elif fill is None: fill = self._font_color(font) draw.fontmode = "1" x, y = position diff --git a/src/element_style.py b/src/element_style.py index 12b1abb2..5319ee07 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -46,7 +46,9 @@ root derived from this module's own location). import copy import json import logging +import math import os +from collections import OrderedDict from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, Union @@ -69,12 +71,28 @@ _FONTS_SUBDIR = os.path.join('assets', 'fonts') # Last-resort font when a requested file can't be found or loaded. _FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf' -# (resolved absolute path, size) -> loaded font face. BDF faces are stateful -# in principle, but the core's own FontManager shares faces the same way. -_font_cache: Dict[Tuple[str, int], Any] = {} +# (resolved absolute path, requested size) -> (font face, realised size). +# BDF faces are stateful in principle, but the core's own FontManager shares +# faces the same way. +# +# Bounded LRU rather than the unbounded dict this started as: the display +# process runs for weeks, and every config save can introduce a new +# (font, size) pair. 256 is far above the working set -- a panel draws from a +# handful of faces -- while still having a ceiling. Matches the house style of +# every other hot cache (display_manager, font_manager, adaptive_layout). +_FONT_CACHE_MAX = 256 +_font_cache: 'OrderedDict[Tuple[str, int], Tuple[Any, int]]' = OrderedDict() + + +def _cache_put(key: Tuple[str, int], value: Tuple[Any, int]) -> None: + """Insert, evicting the least recently used entry past the bound.""" + _font_cache[key] = value + _font_cache.move_to_end(key) + while len(_font_cache) > _FONT_CACHE_MAX: + _font_cache.popitem(last=False) # Config keys a style element block carries, in schema/UI order. -_STYLE_KEYS = ('font', 'font_size', 'text_color') +_STYLE_KEYS = ('font', 'font_size', 'text_color', 'visible', 'align') @dataclass(frozen=True) @@ -89,6 +107,14 @@ class ElementStyle: user_forced: bool # font or size genuinely overridden user_forced_color: bool # color genuinely overridden + # The three below default to "change nothing", so a caller that ignores + # them renders exactly as it did before they existed, and a caller that + # honours them sees a neutral value until the user actually asks for + # something. That is what keeps an untouched config byte-identical. + visible: bool = True # False hides the element entirely + align: Optional[str] = None # 'left'|'center'|'right'; None = caller's own + scale: float = 1.0 # size multiplier for images/logos + # --------------------------------------------------------------------------- # Font loading (cwd-independent, cached) @@ -124,13 +150,91 @@ def resolve_font_path(font_name: str) -> Optional[str]: return None +def native_bdf_size(font_name: str) -> Optional[int]: + """The one pixel size a BDF font can render at, or None. + + None means "not a BDF, not found, or unreadable" — i.e. the size is a + free choice. The web UI uses this to lock the size field for a bitmap + font instead of offering a number that cannot take effect. + """ + path = resolve_font_path(font_name) + if path is None or not path.lower().endswith('.bdf'): + return None + return _read_bdf_native_size(path) + + +def _read_bdf_native_size(path: str) -> Optional[int]: + """A BDF file's own pixel size, delegated to FontManager. + + Deliberately not reimplemented: FontManager's reader prefers PIXEL_SIZE + over the SIZE line's point-size (they differ on the several bundled + fonts defined at 75dpi) and stops at the first STARTCHAR. Core always + ships it; the guard is for the plugin test harnesses that stub the + module out. + """ + try: + from src.font_manager import FontManager + return FontManager._read_bdf_native_size(path) + except Exception: # pragma: no cover - defensive + return None + + +def _load_bdf(path: str, size: int) -> Tuple[Any, int]: + """A ``freetype.Face`` for a BDF file at the closest size it can do. + + BDF fonts are fixed-size bitmap strikes, not scalable outlines: + FreeType accepts only the exact pixel size baked into the file and + raises for anything else. 32 of the 35 shipped fonts are BDF, so a + size the user picked in the web UI usually is not a valid strike. + + Retrying at the file's native size is the behaviour SportsCore already + has (``_load_custom_font_from_element_config``). Without it this + function fell through to the generic except below and returned + *PressStart2P* — so choosing 5x7.bdf at size 10 silently rendered a + completely different typeface rather than 5x7 at 7px. + """ + if freetype is None: + raise RuntimeError("freetype not available for BDF fonts") + + def _face_at(px: int) -> Any: + face = freetype.Face(path) + # Character size in 1/64th points at 72dpi == pixel size. + face.set_char_size(px * 64, px * 64, 72, 72) + return face + + try: + return _face_at(size), size + except Exception: + native = _read_bdf_native_size(path) + if not native or native == size: + raise + # A fresh Face: the first one already took a failed set_char_size. + face = _face_at(native) + logger.debug("BDF font %s loaded at its native size %s " + "(requested %s is not a strike in this file)", + path, native, size) + return face, native + + def load_font(font_name: str, size: int) -> Any: """Load a font by filename at a pixel size, with caching and fallback. ``.bdf`` files load as ``freetype.Face`` (matching FontManager), other - files through ``PIL.ImageFont.truetype``. A missing or unloadable font - degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to - PIL's built-in default — this function never raises. + files through the pinned ``load_truetype``. A BDF asked for a size it + has no strike for falls back to its own native size (see + :func:`_load_bdf`), not to a different font. A missing or unloadable + font degrades to ``PressStart2P-Regular.ttf`` at the requested size, + then to PIL's built-in default — this function never raises. + """ + return _load_font_sized(font_name, size)[0] + + +def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]: + """``load_font`` plus the pixel size actually realised. + + The two differ only for a BDF snapped to its native strike. Callers + that lay out by size (line heights, ladders) need the realised value, + or they reserve space for a size nothing was drawn at. """ try: size = max(1, int(size)) @@ -145,42 +249,39 @@ def load_font(font_name: str, size: int) -> Any: cache_key = (path, size) cached = _font_cache.get(cache_key) if cached is not None: + _font_cache.move_to_end(cache_key) return cached try: if path.lower().endswith('.bdf'): - if freetype is None: - raise RuntimeError("freetype not available for BDF fonts") - face = freetype.Face(path) - # Character size in 1/64th points at 72dpi == pixel size. - face.set_char_size(size * 64, size * 64, 72, 72) - font: Any = face + font, effective = _load_bdf(path, size) else: - font = load_truetype(path, size) + font, effective = load_truetype(path, size), size except Exception as e: logger.warning("Error loading font %s at %spx: %s, using fallback", path, size, e) return _load_fallback_font(size) - _font_cache[cache_key] = font - return font + _cache_put(cache_key, (font, effective)) + return font, effective -def _load_fallback_font(size: int) -> Any: +def _load_fallback_font(size: int) -> Tuple[Any, int]: """PressStart2P at the requested size, else PIL's built-in default.""" path = resolve_font_path(_FALLBACK_FONT_NAME) if path is not None: cache_key = (path, size) cached = _font_cache.get(cache_key) if cached is not None: + _font_cache.move_to_end(cache_key) return cached try: - font = load_truetype(path, size) - _font_cache[cache_key] = font - return font + entry = (load_truetype(path, size), size) + _cache_put(cache_key, entry) + return entry except Exception as e: logger.error("Error loading fallback font: %s", e) - return ImageFont.load_default() + return ImageFont.load_default(), size # --------------------------------------------------------------------------- @@ -207,11 +308,19 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: return schema declaration = customization.get('x-style-elements') if not isinstance(declaration, dict) or not declaration: - return schema + # No compact declaration: the plugin may still have hand-written + # its style blocks longhand, which nineteen of them do. + return _adopt_handwritten_block(schema, customization) expanded = copy.deepcopy(schema) customization = expanded['properties']['customization'] customization.setdefault('type', 'object') + # One composite editor for the whole block. Rendered element by + # element, a realistic scoreboard is 65 nested accordions and five + # levels of clicking to reach one per-mode font size; the widget + # collapses that to a row per element. setdefault, so a plugin that + # names its own widget keeps it. + customization.setdefault('x-widget', 'style-editor') props = customization.setdefault('properties', {}) layout_props: Dict[str, Any] = {} @@ -238,6 +347,20 @@ def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]: for element_key, block in layout_props.items(): layout['properties'].setdefault(element_key, block) + modes = customization.get('x-style-modes') + if isinstance(modes, list) and modes: + props.setdefault('modes', + _modes_block(declaration, modes)) + + # Declaration order, stated explicitly. Python preserves it in the + # dict, but the config form serialises the schema to JSON with + # Flask's provider, which sorts keys -- so without this the elements + # reach the browser alphabetised, and a scoreboard lists Detail and + # Odds above Score. + order = [k for k in declaration if isinstance(declaration.get(k), dict)] + order += [k for k in ('layout', 'modes') if k in props] + customization.setdefault('x-propertyOrder', order) + return expanded except Exception as e: logger.warning("Error expanding x-style-elements: %s", e) @@ -250,13 +373,25 @@ def _element_block_from_spec(element_key: str, properties: Dict[str, Any] = {} order = [] + size_spec = spec.get('size') if isinstance(spec.get('size'), dict) else None font_spec = spec.get('font') if isinstance(font_spec, dict): font_prop: Dict[str, Any] = { 'type': 'string', 'title': 'Font Family', 'x-advanced': True, + # The core already ships this widget and the config form already + # allowlists it; without the hint the field rendered as a bare + # text box the user had to type a filename into. + 'x-widget': 'font-selector', } + # A bitmap font ignores font_size and renders at its own baked-in + # size, so the size ceiling has to be enforced when picking the + # font, not when setting the size. + max_size = (size_spec or {}).get('max') if isinstance( + spec.get('size'), dict) else None + if isinstance(max_size, (int, float)): + font_prop['x-options'] = {'maxFixedSize': max_size} if 'default' in font_spec: font_prop['default'] = font_spec['default'] if isinstance(font_spec.get('enum'), list): @@ -264,7 +399,6 @@ def _element_block_from_spec(element_key: str, properties['font'] = font_prop order.append('font') - size_spec = spec.get('size') if isinstance(size_spec, dict): size_prop: Dict[str, Any] = { 'type': 'integer', @@ -296,6 +430,35 @@ def _element_block_from_spec(element_key: str, properties['text_color'] = color_prop order.append('text_color') + # ``"visible": true`` is accepted as shorthand for + # ``{"default": true}`` -- the common case is a plugin saying only that + # the element can be hidden. + visible_spec = spec.get('visible') + if visible_spec is True or isinstance(visible_spec, dict): + default = True + if isinstance(visible_spec, dict): + default = bool(visible_spec.get('default', True)) + properties['visible'] = { + 'type': 'boolean', + 'title': 'Show', + 'default': default, + 'x-widget': 'toggle-switch', + } + order.append('visible') + + align_spec = spec.get('align') + if align_spec is True or isinstance(align_spec, dict): + align_prop: Dict[str, Any] = { + 'type': 'string', + 'title': 'Align', + 'enum': list(_ALIGNMENTS), + 'x-advanced': True, + } + if isinstance(align_spec, dict) and 'default' in align_spec: + align_prop['default'] = align_spec['default'] + properties['align'] = align_prop + order.append('align') + return { 'type': 'object', 'title': spec.get('title', element_key), @@ -308,24 +471,314 @@ def _element_block_from_spec(element_key: str, def _offset_block_from_spec(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]: - """Build one layout. offset block (x/y, default 0).""" + """Build one layout. block: x/y offsets, and scale if declared.""" axis = { 'type': 'integer', 'default': 0, 'x-advanced': True, } + properties: Dict[str, Any] = { + 'x_offset': dict(axis, title='X Offset'), + 'y_offset': dict(axis, title='Y Offset'), + } + + # scale sits here rather than in the element block because it is + # geometry, like the offsets: a logo has a scale and no font, and the + # renderer applies both when it places the thing. + scale_spec = spec.get('scale') + if scale_spec is True or isinstance(scale_spec, dict): + scale_prop: Dict[str, Any] = { + 'type': 'number', + 'title': 'Scale', + 'description': 'Size multiplier; 1 is the shipped size.', + 'default': 1.0, + 'minimum': 0.1, + 'maximum': 10.0, + 'x-advanced': True, + } + if isinstance(scale_spec, dict): + for key, prop_key in (('default', 'default'), + ('min', 'minimum'), ('max', 'maximum')): + if key in scale_spec: + scale_prop[prop_key] = scale_spec[key] + properties['scale'] = scale_prop + return { 'type': 'object', 'title': spec.get('title', element_key), 'x-style-managed': True, 'additionalProperties': False, - 'properties': { - 'x_offset': dict(axis, title='X Offset'), - 'y_offset': dict(axis, title='Y Offset'), - }, + 'properties': properties, } +def _nullable(prop: Dict[str, Any]) -> Dict[str, Any]: + """The same property, retyped as "this or unset". + + A mode field defaults to null, meaning inherit the base element. The + default has to be null rather than the base value: the save flow writes + schema defaults into config.json wholesale, so a concrete default here + would turn every mode into a copy of the base the moment a user pressed + Save, and the base would stop reaching them. + """ + out = dict(prop) + declared = out.get('type', 'string') + types = declared if isinstance(declared, list) else [declared] + if 'null' not in types: + types = list(types) + ['null'] + out['type'] = types + out['default'] = None + # An enum constrains the value independently of the type, so widening + # the type is not enough: null has to be an allowed choice too, or the + # default this function just set fails its own schema. That is not a + # corner case -- the save flow writes the default into config, so a + # plugin declaring an enum field with modes could not save at all. + if isinstance(out.get('enum'), list) and None not in out['enum']: + out['enum'] = list(out['enum']) + [None] + return out + + +def _mode_element_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]: + """One element's override block for one mode: every field nullable.""" + base = _element_block_from_spec(element_key, spec) + base['properties'] = {k: _nullable(v) + for k, v in base.get('properties', {}).items()} + base['description'] = ('Leave blank to use the settings above for this ' + 'mode.') + return base + + +def _mode_offset_block(element_key: str, spec: Dict[str, Any]) -> Dict[str, Any]: + """One element's offset overrides for one mode: both axes nullable.""" + base = _offset_block_from_spec(element_key, spec) + base['properties'] = {k: _nullable(v) + for k, v in base.get('properties', {}).items()} + return base + + +def _modes_block(declaration: Dict[str, Any], + modes: Any) -> Dict[str, Any]: + """``customization.modes`` — one override group per declared mode.""" + mode_props: Dict[str, Any] = {} + for mode in modes: + if not isinstance(mode, str) or not mode: + continue + element_props: Dict[str, Any] = {} + layout_props: Dict[str, Any] = {} + for element_key, spec in declaration.items(): + if not isinstance(spec, dict): + continue + element_props[element_key] = _mode_element_block(element_key, spec) + if spec.get('offsets'): + layout_props[element_key] = _mode_offset_block(element_key, spec) + if layout_props: + element_props['layout'] = { + 'type': 'object', + 'title': 'Layout Offsets', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': layout_props, + } + mode_props[mode] = { + 'type': 'object', + 'title': mode.replace('_', ' ').title(), + 'x-style-managed': True, + 'additionalProperties': False, + 'properties': element_props, + } + return { + 'type': 'object', + 'title': 'Per-Mode Overrides', + 'description': 'Override the settings above for one display mode. ' + 'Anything left blank follows the settings above.', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': mode_props, + } + + +#: The sub-fields that make a customization sub-object a style element. +#: Checked against every published schema: 68 blocks across 19 plugins match +#: exactly, and nothing else does -- favorite_result_colors, baseball's +#: bases/outs/player_card, jellyfin's progress_bar and the stocks blocks all +#: carry other fields and are correctly left alone. +_STYLE_BLOCK_FIELDS = frozenset(_STYLE_KEYS) + + +def _looks_like_style_block(block: Any) -> bool: + """Whether a hand-written customization sub-object is a style element. + + Deliberately strict: every field must be one this system understands. + A looser rule ("has at least one style field") would sweep in blocks + like baseball's ``count``, which happens to carry a text_color next to + geometry that means nothing here. + """ + if not isinstance(block, dict): + return False + props = block.get('properties') + if not isinstance(props, dict) or not props: + return False + return set(props) <= _STYLE_BLOCK_FIELDS + + +def _detect_style_blocks(customization: Dict[str, Any]) -> list: + """Element keys in a hand-written customization block, in declared order.""" + props = customization.get('properties') + if not isinstance(props, dict): + return [] + return [key for key, block in props.items() + if key not in ('layout', 'modes') and _looks_like_style_block(block)] + + +def _upgrade_font_property(block: Dict[str, Any]) -> None: + """Point a hand-written font field at the font picker, in place. + + These fields ship a hardcoded ``enum`` -- football lists five of the + thirty-five installed fonts -- which is why a font a user uploads can + never appear in one. The enum is replaced rather than extended: it is + not a curated safe set (it omits some twenty other faces that fit + just as well), it is the fonts that happened to exist when it was + written. + + The size ceiling the block already declares is carried across as + ``maxFixedSize``, because a bitmap font ignores font_size and renders + at its own baked-in size -- so widening the list without that would + offer faces that overflow the panel no matter what size is set. + """ + props = block.get('properties') + if not isinstance(props, dict): + return + font_prop = props.get('font') + if not isinstance(font_prop, dict): + return + + font_prop.pop('enum', None) + font_prop['x-widget'] = 'font-selector' + + size_prop = props.get('font_size') + maximum = size_prop.get('maximum') if isinstance(size_prop, dict) else None + if isinstance(maximum, (int, float)): + options = font_prop.setdefault('x-options', {}) + if isinstance(options, dict): + options.setdefault('maxFixedSize', maximum) + + +def _nullable_block(block: Dict[str, Any], title: Optional[str] = None, + description: Optional[str] = None) -> Dict[str, Any]: + """A copy of an element block with every field optional. + + The per-mode counterpart of a hand-written block: same fields, all + nullable and defaulting to null, which is this system's "inherit". + """ + out = copy.deepcopy(block) + out['properties'] = {k: _nullable(v) + for k, v in (out.get('properties') or {}).items()} + out['x-style-managed'] = True + if title: + out['title'] = title + if description is not None: + out['description'] = description + return out + + +def _modes_block_from_properties(props: Dict[str, Any], element_keys: list, + modes: Any) -> Dict[str, Any]: + """``customization.modes`` built from already-expanded element blocks. + + The compact declaration has ``_modes_block``; this is the same thing for + a plugin that hand-wrote its blocks, so both forms get per-mode overrides + from one declaration line. + """ + layout_source = (props.get('layout') or {}).get('properties') or {} + mode_props: Dict[str, Any] = {} + for mode in modes: + if not isinstance(mode, str) or not mode: + continue + element_props: Dict[str, Any] = {} + layout_props: Dict[str, Any] = {} + for key in element_keys: + block = props.get(key) + if isinstance(block, dict): + element_props[key] = _nullable_block( + block, + description='Leave blank to use the settings above for ' + 'this mode.') + # Every layout element, not just those with a style block. The two + # namespaces do not line up in a hand-written schema -- football + # styles 'score_text' but positions 'score', and positions logos, + # timeouts and possession that have no style block at all. Keying + # this off the style elements would have given six of its eleven + # positionable things no per-mode offset. + for key, layout_block in layout_source.items(): + if isinstance(layout_block, dict): + layout_props[key] = _nullable_block(layout_block) + if layout_props: + element_props['layout'] = { + 'type': 'object', + 'title': 'Layout Offsets', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': layout_props, + } + mode_props[mode] = { + 'type': 'object', + 'title': mode.replace('_', ' ').title(), + 'x-style-managed': True, + 'additionalProperties': False, + 'properties': element_props, + } + return { + 'type': 'object', + 'title': 'Per-Mode Overrides', + 'description': 'Override the settings above for one display mode. ' + 'Anything left blank follows the settings above.', + 'x-advanced': True, + 'additionalProperties': False, + 'properties': mode_props, + } + + +def _adopt_handwritten_block(schema: Dict[str, Any], + customization: Dict[str, Any]) -> Dict[str, Any]: + """Give a hand-written customization block the same treatment as a + declared one, without the plugin rewriting its schema. + + Nineteen plugins spell their style elements out longhand -- football's + block is 701 lines for seven elements -- and predate every part of this + system. Recognising that shape lets them pick up the row-per-element + editor and the real font picker on a core update, with no plugin + release. What they do not get for free is per-mode overrides and the + visible/align/scale fields, because core cannot invent a plugin's list + of display modes: adding ``x-style-modes`` is the one line that unlocks + the rest. + """ + element_keys = _detect_style_blocks(customization) + if not element_keys: + return schema + + expanded = copy.deepcopy(schema) + customization = expanded['properties']['customization'] + customization.setdefault('x-widget', 'style-editor') + props = customization['properties'] + + for key in element_keys: + _upgrade_font_property(props[key]) + + modes = customization.get('x-style-modes') + if isinstance(modes, list) and modes: + props.setdefault('modes', + _modes_block_from_properties(props, element_keys, + modes)) + + # Stated explicitly because the config form serialises the schema with + # Flask's JSON provider, which sorts keys -- without this the elements + # reach the browser alphabetised. + order = list(element_keys) + order += [k for k in props if k not in order] + customization.setdefault('x-propertyOrder', order) + return expanded + + def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: """Extract per-element style defaults from a config schema dict. @@ -340,6 +793,9 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: Elements with no declared defaults are omitted. Never raises. """ elements: Dict[str, Dict[str, Any]] = {} + # Layout defaults live alongside the elements under the reserved + # 'layout' key, mirroring the config shape, so one dict carries both. + layout: Dict[str, Dict[str, Any]] = {} try: customization = schema.get('properties', {}).get('customization') if not isinstance(customization, dict): @@ -360,13 +816,35 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: color_spec = spec.get('color') if isinstance(color_spec, dict) and 'default' in color_spec: defaults['text_color'] = list(color_spec['default']) + visible_spec = spec.get('visible') + if visible_spec is True: + defaults['visible'] = True + elif isinstance(visible_spec, dict) and 'default' in visible_spec: + defaults['visible'] = bool(visible_spec['default']) + align_spec = spec.get('align') + if isinstance(align_spec, dict) and 'default' in align_spec: + defaults['align'] = align_spec['default'] + scale_spec = spec.get('scale') + if isinstance(scale_spec, dict) and 'default' in scale_spec: + layout.setdefault(element_key, {})['scale'] = scale_spec['default'] + elif scale_spec is True: + layout.setdefault(element_key, {})['scale'] = 1.0 if defaults: elements[element_key] = defaults properties = customization.get('properties') if isinstance(properties, dict): + layout_block = properties.get('layout') + if isinstance(layout_block, dict): + for element_key, block in ( + layout_block.get('properties') or {}).items(): + if not isinstance(block, dict): + continue + scale_prop = (block.get('properties') or {}).get('scale') + if isinstance(scale_prop, dict) and 'default' in scale_prop: + layout.setdefault(element_key, {})['scale'] = scale_prop['default'] for element_key, block in properties.items(): - if element_key == 'layout' or element_key in elements: + if element_key in ('layout', 'modes') or element_key in elements: continue if not isinstance(block, dict): continue @@ -382,6 +860,8 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]: elements[element_key] = defaults except Exception as e: logger.warning("Error extracting style defaults from schema: %s", e) + if layout: + elements['layout'] = layout return {'customization': elements} @@ -407,7 +887,21 @@ def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, # --------------------------------------------------------------------------- def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]: - """An (r, g, b) tuple of ints in 0..255, or None for anything else.""" + """An (r, g, b) tuple of ints in 0..255, or None for anything else. + + ``"#RRGGBB"`` is accepted as well as ``[r, g, b]``: the scoreboards' + own colour readers have always taken both, and this is the function + they now share. + """ + if isinstance(value, str): + text = value.strip() + if len(text) == 7 and text.startswith('#'): + try: + return (int(text[1:3], 16), int(text[3:5], 16), + int(text[5:7], 16)) + except ValueError: + return None + return None if isinstance(value, (list, tuple)) and len(value) == 3: try: rgb = tuple(int(c) for c in value) @@ -418,6 +912,286 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]: return None +#: Element names that drifted between plugins, beyond what the ``_text`` +#: suffix rule below covers. Counted across the published schemas: the +#: layout block spells it ``records`` in seven plugins and ``record`` in +#: two, ``status_text`` in seven and ``status`` in two. +_ELEMENT_ALIASES: Dict[str, Tuple[str, ...]] = { + 'records': ('record',), + 'record': ('records',), + 'rank_text': ('ranking', 'rank'), + 'ranking': ('rank_text', 'rank'), + 'team_name': ('team',), + 'team': ('team_name',), +} + + +def alias_keys(element_key: str) -> Tuple[str, ...]: + """The names one element may be stored under, exact match first. + + Two conventions collided as the scoreboards grew. The style block names + elements with a ``_text`` suffix (``score_text``, ``status_text``) while + the layout block mostly uses the bare noun (``score``, ``date``, + ``odds``) -- except ``status_text``, which kept the suffix in seven + plugins and lost it in two. Plugins also disagree on ``records`` vs + ``record``. + + Rather than make every plugin rename its config keys -- which would + orphan whatever offsets its users had already dialled in -- a lookup + tries the exact name first and then the spellings that mean the same + thing. Exact-first is what keeps this from changing any behaviour for a + config that already matches. + + This also covers the compact declaration form, which uses one key for + both blocks: a plugin moving to it can still find offsets its users + saved under the old bare-noun layout key. + """ + if not isinstance(element_key, str) or not element_key: + return () + explicit = _ELEMENT_ALIASES.get(element_key) + if explicit: + # An explicit entry replaces the suffix rule rather than adding to + # it, so 'records' does not also generate 'records_text'. + return (element_key,) + tuple(a for a in explicit if a != element_key) + + if element_key.endswith('_text'): + stem = element_key[:-len('_text')] + return (element_key, stem) if stem else (element_key,) + return (element_key, element_key + '_text') + + +def _lookup_element(block: Any, element_key: str) -> Dict[str, Any]: + """``block[element]`` under any of its names, or {}.""" + if not isinstance(block, dict): + return {} + for key in alias_keys(element_key): + value = block.get(key) + if isinstance(value, dict): + return value + return {} + + +def _coerce_bool(value: Any, default: bool) -> bool: + """A real bool, or ``default``. Accepts the strings a form may post.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ('true', 'yes', 'on', '1'): + return True + if lowered in ('false', 'no', 'off', '0'): + return False + return default + + +_ALIGNMENTS = ('left', 'center', 'right') + + +def _coerce_align(value: Any) -> Optional[str]: + """One of left/center/right, or None for anything else. + + None means "no preference", which is what an unset value resolves to -- + the caller keeps whatever alignment it already did. + """ + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in _ALIGNMENTS: + return lowered + if lowered in ('centre', 'middle'): # the spelling users try + return 'center' + return None + + +def _coerce_scale(value: Any, default: float) -> float: + """A positive size multiplier, or ``default``. + + Clamped rather than merely validated: a scale of 0 or a negative one is + a zero-or-inverted image, and the panel is 32 pixels tall -- a typo + should cost a wrong size, not a crash inside PIL. + """ + if isinstance(value, bool) or value is None: + return default + try: + scale = float(value) + except (TypeError, ValueError): + return default + if scale <= 0: + return default + return min(scale, 10.0) + + +def _coerce_offset(value: Any, default: int, element_key: str, + axis: str) -> int: + """A pixel offset as an int; anything nonsensical is ``default``. + + A bool degrades rather than counting as 1/0 -- the more correct reading + of a pixel offset, and what the shared resolver has always done + relative to the classic inline read. Non-finite floats degrade too: + the scroll-card reader guarded against those explicitly and this is now + the one implementation. + """ + if isinstance(value, bool): + return int(default) + if isinstance(value, (int, float)): + if isinstance(value, float) and not math.isfinite(value): + return int(default) + return int(value) + if isinstance(value, str): + try: + parsed = float(value) + except (TypeError, ValueError): + logger.warning("Invalid layout offset for %s.%s: %r, using %s", + element_key, axis, value, default) + return int(default) + if not math.isfinite(parsed): + return int(default) + return int(parsed) + return int(default) + + +def layout_offset(config: Any, element_key: str, axis: str, + default: int = 0, mode: Optional[str] = None) -> int: + """One ``customization.layout..`` value, as an int. + + The stateless form of :meth:`ElementStyleResolver.offset_value`, for the + scoreboard helpers that are handed a config rather than holding one. + Both go through here, so the alias handling and the per-mode lookup + cannot drift between them -- there were three separate readers of this + block before, and the scroll-card one had already been found ignoring + offsets the schema advertised. + """ + try: + block = config.get('customization') if isinstance(config, dict) else None + block = block if isinstance(block, dict) else {} + base = _lookup_element(block.get('layout'), element_key).get(axis) + base_value = (int(default) if base is None + else _coerce_offset(base, default, element_key, axis)) + + if mode: + modes = block.get('modes') + mode_block = modes.get(mode) if isinstance(modes, dict) else None + if isinstance(mode_block, dict): + override = _lookup_element(mode_block.get('layout'), + element_key).get(axis) + if override is not None: + return _coerce_offset(override, base_value, + element_key, axis) + return base_value + except Exception as e: + logger.warning("Error reading layout offset %s.%s: %s", + element_key, axis, e) + try: + return int(default) + except (TypeError, ValueError): + return 0 + + +def element_color(config: Any, element_key: str, + default: Tuple[int, int, int] = (255, 255, 255), + mode: Optional[str] = None) -> Tuple[int, int, int]: + """``customization..text_color``, or ``default``. + + The stateless colour lookup the scoreboards share. Unlike + :meth:`ElementStyleResolver.style` this does not compare against a + schema default -- the callers have no schema to hand -- so any + configured colour counts, which is what their own readers did. + """ + try: + block = config.get('customization') if isinstance(config, dict) else None + block = block if isinstance(block, dict) else {} + if mode: + modes = block.get('modes') + mode_block = modes.get(mode) if isinstance(modes, dict) else None + if isinstance(mode_block, dict): + override = _normalize_color( + _lookup_element(mode_block, element_key).get('text_color')) + if override is not None: + return override + value = _normalize_color( + _lookup_element(block, element_key).get('text_color')) + return value if value is not None else default + except Exception as e: + logger.warning("Error reading colour for %s: %s", element_key, e) + return default + + +def _element_field(config: Any, element_key: str, field: str, + mode: Optional[str] = None, in_layout: bool = False): + """Raw ``customization[.layout]..``, mode first. + + The shared body behind the stateless visible/align/scale readers. Returns + None for "not configured", which every caller turns into its own default -- + None must not collapse into a value here, because for a mode it is the + inherit sentinel. + """ + block = config.get('customization') if isinstance(config, dict) else None + block = block if isinstance(block, dict) else {} + + def _read(source): + if not isinstance(source, dict): + return None + holder = source.get('layout') if in_layout else source + return _lookup_element(holder, element_key).get(field) + + if mode: + modes = block.get('modes') + mode_block = modes.get(mode) if isinstance(modes, dict) else None + override = _read(mode_block) + if override is not None: + return override + return _read(block) + + +def element_visible(config: Any, element_key: str, default: bool = True, + mode: Optional[str] = None) -> bool: + """``customization..visible``, or *default*. + + The stateless form of the flag :meth:`ElementStyleResolver.style` already + resolves, for the scoreboard draw paths that hold a config rather than a + resolver. + """ + try: + value = _element_field(config, element_key, 'visible', mode) + return default if value is None else _coerce_bool(value, default) + except Exception as e: + logger.warning("Error reading visibility for %s: %s", element_key, e) + return default + + +def element_align(config: Any, element_key: str, + default: Optional[str] = None, + mode: Optional[str] = None) -> Optional[str]: + """``customization..align`` ('left'/'center'/'right'), or *default*.""" + try: + value = _element_field(config, element_key, 'align', mode) + if value is None: + return default + # _coerce_align answers None for anything that is not an alignment; + # that is "no preference", which means the caller's default. + coerced = _coerce_align(value) + return default if coerced is None else coerced + except Exception as e: + logger.warning("Error reading alignment for %s: %s", element_key, e) + return default + + +def element_scale(config: Any, element_key: str, default: float = 1.0, + mode: Optional[str] = None) -> float: + """``customization.layout..scale``, or *default*. + + Scale sits in the layout block beside the offsets, because it positions and + sizes rather than styles -- a logo has no font or colour but is very much + something users want smaller. + """ + try: + value = _element_field(config, element_key, 'scale', mode, + in_layout=True) + return default if value is None else _coerce_scale(value, default) + except Exception as e: + logger.warning("Error reading scale for %s: %s", element_key, e) + return default + + class ElementStyleResolver: """Resolves per-element user styling against schema defaults. @@ -431,10 +1205,27 @@ class ElementStyleResolver: from the schema default (see module docstring); otherwise ``style()`` returns the caller's classic values verbatim, keeping untouched configs byte-identical to pre-customization rendering. + + **Modes.** A plugin that displays the same element in more than one + situation — a scoreboard's live / upcoming / recent cards, weather's + current / hourly / daily screens — can let the user style each one + separately under ``customization.modes.``. The mode is normally + bound once at construction rather than passed per call, because the + natural owner already knows it: SportsUpcoming and SportsRecent are + distinct instances with distinct ``SKIN_MODE`` values, so binding here + makes every existing call site mode-aware without touching one of them. + + A mode layer is pure override. Its fields default to ``None``, which + means *inherit*, and any non-None value wins over the base element. + That is why ``None`` and a real value must stay distinguishable: a mode + offset of ``0`` means "sit at the base position", not "no preference" + — the same distinction ``scroll_card.switch_*`` draws with its + ``"inherit"`` sentinel. """ def __init__(self, config: Optional[Dict[str, Any]], - defaults: Optional[Dict[str, Any]] = None): + defaults: Optional[Dict[str, Any]] = None, + mode: Optional[str] = None): # Keep the exact object for identity-based invalidation, even if the # caller hands us something odd; reads are guarded. self._config = config @@ -444,8 +1235,14 @@ class ElementStyleResolver: element_defaults = {} self._defaults: Dict[str, Any] = ( element_defaults if isinstance(element_defaults, dict) else {}) + self._mode = mode if isinstance(mode, str) and mode else None self._memo: Dict[Any, ElementStyle] = {} + @property + def mode(self) -> Optional[str]: + """The mode bound at construction, if any.""" + return self._mode + # -- internal accessors ------------------------------------------------- def _customization(self) -> Dict[str, Any]: @@ -454,19 +1251,38 @@ class ElementStyleResolver: return customization if isinstance(customization, dict) else {} def _element_config(self, element_key: str) -> Dict[str, Any]: - element = self._customization().get(element_key, {}) - return element if isinstance(element, dict) else {} + return _lookup_element(self._customization(), element_key) def _element_defaults(self, element_key: str) -> Dict[str, Any]: - defaults = self._defaults.get(element_key, {}) - return defaults if isinstance(defaults, dict) else {} + return _lookup_element(self._defaults, element_key) + + def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]: + """``customization.modes.``, or {} when there is no such block.""" + if not mode: + return {} + modes = self._customization().get('modes', {}) + if not isinstance(modes, dict): + return {} + block = modes.get(mode, {}) + return block if isinstance(block, dict) else {} + + def _mode_element_config(self, element_key: str, + mode: Optional[str]) -> Dict[str, Any]: + return _lookup_element(self._mode_block(mode), element_key) + + def _effective_mode(self, mode: Any) -> Optional[str]: + """A per-call mode overrides the bound one; anything else uses it.""" + if isinstance(mode, str) and mode: + return mode + return self._mode # -- public API --------------------------------------------------------- def style(self, element_key: str, classic_font: str = _FALLBACK_FONT_NAME, classic_size: int = 8, - classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle: + classic_color: Optional[Tuple[int, int, int]] = None, + mode: Optional[str] = None) -> ElementStyle: """Resolve one element's style. Never raises. Args: @@ -478,14 +1294,19 @@ class ElementStyleResolver: classic_color: Classic RGB color, or None when the caller only cares about the font (``.color`` then falls back to the schema default color, else white). + mode: Overrides the mode bound at construction for this call. + Rarely needed — a host that renders one mode should bind it + once instead. Returns: ElementStyle with the loaded font face, RGB color, (x, y) offset, and the ``user_forced`` / ``user_forced_color`` flags. """ + effective_mode = self._effective_mode(mode) try: memo_key = (element_key, classic_font, classic_size, - _normalize_color(classic_color) or classic_color) + _normalize_color(classic_color) or classic_color, + effective_mode) memoized = self._memo.get(memo_key) if memoized is not None: return memoized @@ -494,7 +1315,8 @@ class ElementStyleResolver: try: resolved = self._resolve(element_key, classic_font, - classic_size, classic_color) + classic_size, classic_color, + effective_mode) except Exception as e: logger.warning("Error resolving style for element '%s': %s — " "using classic style", element_key, e) @@ -504,56 +1326,89 @@ class ElementStyleResolver: self._memo[memo_key] = resolved return resolved - def offset(self, element_key: str) -> Tuple[int, int]: + def offset(self, element_key: str, + mode: Optional[str] = None) -> Tuple[int, int]: """The user's ``customization.layout.`` (x, y) pixel offset, defaulting to (0, 0). Never raises.""" - return (self.offset_value(element_key, 'x_offset', 0), - self.offset_value(element_key, 'y_offset', 0)) + return (self.offset_value(element_key, 'x_offset', 0, mode), + self.offset_value(element_key, 'y_offset', 0, mode)) - def offset_value(self, element_key: str, axis: str, default: int = 0) -> int: + def offset_value(self, element_key: str, axis: str, default: int = 0, + mode: Optional[str] = None) -> int: """One ``customization.layout..`` value as an int. ``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric strings are coerced; anything else degrades to ``default``. Never raises. + + When a mode is in play, ``customization.modes..layout`` is + consulted first and wins if it carries a non-None value for this + axis — ``None`` there means inherit the base offset, which is what + lets a mode nudge one element without restating the rest. """ - try: - layout = self._customization().get('layout', {}) - if not isinstance(layout, dict): - return int(default) - element = layout.get(element_key, {}) - if not isinstance(element, dict): - return int(default) - value = element.get(axis, default) - if isinstance(value, bool): - return int(default) - if isinstance(value, (int, float)): - return int(value) - if isinstance(value, str): - try: - return int(float(value)) - except (TypeError, ValueError): - logger.warning( - "Invalid layout offset for %s.%s: %r, using %s", - element_key, axis, value, default) - return int(default) - return int(default) - except Exception as e: - logger.warning("Error reading layout offset %s.%s: %s", - element_key, axis, e) - try: - return int(default) - except (TypeError, ValueError): - return 0 + return layout_offset(self._config, element_key, axis, default, + self._effective_mode(mode)) + + @staticmethod + def _layout_element(block: Dict[str, Any], + element_key: str) -> Dict[str, Any]: + """``block['layout'][element]``, or {} if absent anywhere. + + The layout block is where the naming drift lives, so the lookup + goes through the aliases: a plugin asking for ``score_text`` + offsets still finds the ``score`` its users configured. + """ + if not isinstance(block, dict): + return {} + return _lookup_element(block.get('layout'), element_key) + + @classmethod + def _layout_axis(cls, block: Dict[str, Any], element_key: str, + axis: str) -> Any: + """``block['layout'][element][axis]``, or None if absent anywhere.""" + return cls._layout_element(block, element_key).get(axis) + # -- resolution internals ----------------------------------------------- + def _forced(self, element_config: Dict[str, Any], + element_defaults: Dict[str, Any], + mode_config: Dict[str, Any], key: str) -> Any: + """A field's value if the user genuinely chose one, else None. + + Same two-layer rule the font/size/colour resolution uses: a mode + value counts whenever it is set, a base value only when it differs + from the schema default (the save flow writes that default in + whether or not the user touched it). Returning None for "not + chosen" lets the caller substitute a neutral value, which is how + an untouched config keeps rendering exactly as before. + """ + mode_value = mode_config.get(key) + if mode_value is not None: + return mode_value + configured = element_config.get(key) + if configured is None: + return None + if key in element_defaults and configured == element_defaults[key]: + return None + return configured + + def _resolve(self, element_key: str, classic_font: str, classic_size: int, - classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle: + classic_color: Optional[Tuple[int, int, int]], + mode: Optional[str] = None) -> ElementStyle: element_config = self._element_config(element_key) element_defaults = self._element_defaults(element_key) + mode_config = self._mode_element_config(element_key, mode) + + # The two layers answer different questions. The base layer asks + # "does this differ from the schema default?", because the save flow + # writes the full default object into config.json whether or not the + # user touched it. The mode layer asks only "is it set?", because its + # schema default is None -- there is nothing for a stray write to + # make look deliberate. # Font family: forced only when it differs from the schema default # (falling back to the classic font as the reference when the @@ -576,6 +1431,15 @@ class ElementStyleResolver: font_name = configured_font if font_forced else classic_font font_size = configured_size if size_forced else self._coerce_size( classic_size, 8) + + # Mode overrides sit on top of whatever the base layer settled on. + mode_font = mode_config.get('font') + if isinstance(mode_font, str) and mode_font: + font_name, font_forced = mode_font, True + mode_size = self._coerce_size(mode_config.get('font_size'), None) + if mode_size is not None: + font_size, size_forced = mode_size, True + user_forced = bool(font_forced or size_forced) # Color: forced only when it differs from the schema default (or, @@ -592,14 +1456,40 @@ class ElementStyleResolver: color = (_normalize_color(classic_color) or default_color or (255, 255, 255)) + mode_color = _normalize_color(mode_config.get('text_color')) + if mode_color is not None: + color, color_forced = mode_color, True + + visible = self._forced(element_config, element_defaults, + mode_config, 'visible') + align = self._forced(element_config, element_defaults, + mode_config, 'align') + # scale is geometry, so it lives with the offsets rather than in the + # element block -- a logo has a scale and no font. + layout_defaults = self._defaults.get('layout', {}) + scale = self._forced( + self._layout_element(self._customization(), element_key), + layout_defaults.get(element_key, {}) + if isinstance(layout_defaults, dict) else {}, + self._layout_element(self._mode_block(mode), element_key), + 'scale') + + # font_size reports what was actually realised, which differs from + # the request only when a BDF snapped to its native strike. Callers + # lay out from this value; reporting the request would reserve space + # for a size nothing was drawn at. + font, realised_size = _load_font_sized(font_name, font_size) return ElementStyle( - font=load_font(font_name, font_size), + font=font, color=color, - offset=self.offset(element_key), + offset=self.offset(element_key, mode), font_name=font_name, - font_size=font_size, + font_size=realised_size, user_forced=user_forced, user_forced_color=bool(color_forced), + visible=_coerce_bool(visible, True), + align=_coerce_align(align), + scale=_coerce_scale(scale, 1.0), ) def _classic_style(self, classic_font: str, classic_size: int, @@ -607,12 +1497,13 @@ class ElementStyleResolver: """The untouched fallback style — used when resolution itself fails, so ``style()`` can keep its never-raises promise.""" size = self._coerce_size(classic_size, 8) + font, realised_size = _load_font_sized(classic_font, size) return ElementStyle( - font=load_font(classic_font, size), + font=font, color=_normalize_color(classic_color) or (255, 255, 255), offset=(0, 0), font_name=classic_font, - font_size=size, + font_size=realised_size, user_forced=False, user_forced_color=False, ) diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 54aa7426..8bd62635 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -12,11 +12,47 @@ from abc import ABC, abstractmethod from enum import Enum from typing import Dict, Any, Optional, List import logging +import os +import sys from src.logging_config import get_logger _shared_fallback_font_manager: Optional[Any] = None +#: Distinguishes "not looked up yet" from "looked up and not found", so a +#: plugin with no schema does not re-scan the disk on every frame. +_UNSET_SCHEMA_PATH = object() + + +class _NullStyleResolver: + """Stand-in for ElementStyleResolver when the module is unavailable. + + Only reachable on a core that predates src.element_style, which + ``styles`` degrades to rather than raising: every lookup returns the + caller's classic values, which is what the plugin drew before styling + existed. + """ + + def __init__(self, config: Any) -> None: + self._config = config + + def style(self, element_key: str, classic_font: str = None, + classic_size: int = 8, classic_color: Any = None, + mode: Optional[str] = None) -> Any: + from types import SimpleNamespace + return SimpleNamespace( + font=None, color=classic_color or (255, 255, 255), offset=(0, 0), + font_name=classic_font, font_size=classic_size, + user_forced=False, user_forced_color=False, + visible=True, align=None, scale=1.0) + + def offset(self, element_key: str, mode: Optional[str] = None) -> tuple: + return (0, 0) + + def offset_value(self, element_key: str, axis: str, default: int = 0, + mode: Optional[str] = None) -> int: + return default + def _fallback_font_manager() -> Any: """Shared FontManager for environments (unit tests, mocks) where the @@ -63,6 +99,12 @@ class BasePlugin(ABC): API_VERSION = "1.0.0" + #: Which ``customization.modes.`` overrides :attr:`styles` applies. + #: A plugin with one instance per display mode (the scoreboards' live / + #: upcoming / recent classes) sets this and every existing style lookup + #: becomes mode-aware without changing a call site. + STYLE_MODE: Optional[str] = None + def __init__( self, plugin_id: str, @@ -260,6 +302,128 @@ class BasePlugin(ABC): self._layout_font_generation = generation return context + @property + def styles(self) -> Any: + """ + The user's per-element styling: fonts, sizes, colours, offsets, + visibility, alignment and scale, resolved against this plugin's own + config_schema.json. + + Every consumer of src.element_style used to repeat the same three + things -- a guarded import, finding its own schema file, and + rebuilding the resolver when on_config_change swapped the config + dict. This is those three things, once. + + Ask for a style by element name, passing what the plugin drew before + the user could customise anything:: + + title = self.styles.style('title_text', + classic_font='PressStart2P-Regular.ttf', + classic_size=8, + classic_color=(255, 255, 255)) + x, y = title.offset + self.display_manager.draw_text(text, x=x, y=y, + font=title.font, color=title.color) + + The classic_* arguments matter: when the user has chosen nothing, + they come back verbatim, so a plugin that adopts this renders + identically until someone actually changes a setting. + + A plugin whose display has modes (a scoreboard's live/upcoming/ + recent, weather's current/hourly/daily) sets ``STYLE_MODE`` on the + class, and every lookup here honours the matching + ``customization.modes.`` overrides without any call site + passing a mode. Use :meth:`styles_for` for a one-off mode. + + Never raises: with no schema on disk, or with the element-style + module unavailable, lookups fall back to the classic values. + """ + resolver = getattr(self, "_style_resolver", None) + # The config dict is swapped wholesale by on_config_change, so + # identity is the invalidation signal -- the same check the sports + # base classes use. + if resolver is not None and resolver._config is self.config: + return resolver + resolver = self._build_style_resolver(getattr(self, "STYLE_MODE", None)) + self._style_resolver = resolver + return resolver + + def styles_for(self, mode: Optional[str]) -> Any: + """:attr:`styles`, bound to ``mode`` instead of ``STYLE_MODE``. + + For a plugin that renders more than one mode from one instance. A + plugin with an instance per mode should set ``STYLE_MODE`` instead + and leave its call sites alone. + """ + cache = getattr(self, "_style_resolvers_by_mode", None) + if cache is None or getattr(self, "_style_resolver_config", None) is not self.config: + cache = {} + self._style_resolvers_by_mode = cache + self._style_resolver_config = self.config + if mode not in cache: + cache[mode] = self._build_style_resolver(mode) + return cache[mode] + + def _build_style_resolver(self, mode: Optional[str]) -> Any: + """Construct a resolver for this plugin's config and schema.""" + try: + from src.element_style import (ElementStyleResolver, + defaults_from_schema_file) + except ImportError: # pragma: no cover - core always ships it + return _NullStyleResolver(self.config) + + schema_path = self._config_schema_path() + defaults = (defaults_from_schema_file(schema_path) if schema_path + else {}) + return ElementStyleResolver(self.config, defaults, mode=mode) + + def _config_schema_path(self) -> Optional[str]: + """This plugin's config_schema.json, or None. + + Looked up from the concrete class's own module rather than from this + file: a plugin's subclass lives in its plugin directory, while this + module lives in src/plugin_system, where no plugin schema exists. + Falls back to the configured plugins directory, including the + ledmatrix- prefix form the loader accepts. + + Returning None is safe, not fatal -- the resolver then has no + defaults to compare against, so every configured value counts as a + deliberate override, which is the conservative reading. + """ + cached = getattr(self, "_config_schema_path_cache", _UNSET_SCHEMA_PATH) + if cached is not _UNSET_SCHEMA_PATH: + return cached + + path = None + try: + for candidate in self._schema_path_candidates(): + if candidate and os.path.isfile(candidate): + path = candidate + break + except Exception as exc: # pragma: no cover - defensive + self.logger.debug("Could not locate config_schema.json: %s", exc) + self._config_schema_path_cache = path + return path + + def _schema_path_candidates(self) -> list: + """Where a plugin's schema might be, best guess first.""" + candidates = [] + + module = sys.modules.get(type(self).__module__) + module_file = getattr(module, "__file__", None) + if module_file: + candidates.append(os.path.join( + os.path.dirname(os.path.abspath(module_file)), + "config_schema.json")) + + plugins_dir = getattr(self.plugin_manager, "plugins_dir", None) + if plugins_dir: + for plugin_id in (self.plugin_id, f"ledmatrix-{self.plugin_id}"): + candidates.append(os.path.join( + str(plugins_dir), os.path.basename(plugin_id), + "config_schema.json")) + return candidates + def draw_fit(self, text: str, box: Any, color: tuple = (255, 255, 255), ladder: Optional[Any] = None, diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index f66784e7..22291e85 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -1,998 +1,973 @@ [ - [ - "/api/v3/backup/", - "api_v3.backup_delete", [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/backup/download/", - "api_v3.backup_download", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/backup/export", - "api_v3.backup_export", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/backup/list", - "api_v3.backup_list", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/backup/preview", - "api_v3.backup_preview", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/backup/restore", - "api_v3.backup_restore", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/backup/validate", - "api_v3.backup_validate", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/cache/delete", - "api_v3.delete_cache_file", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/cache/list", - "api_v3.list_cache_files", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/config/dim-schedule", - "api_v3.get_dim_schedule_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/config/dim-schedule", - "api_v3.save_dim_schedule_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/config/main", - "api_v3.get_main_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/config/main", - "api_v3.save_main_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/config/raw/main", - "api_v3.save_raw_main_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/config/raw/secrets", - "api_v3.save_raw_secrets_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/config/schedule", - "api_v3.get_schedule_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/config/schedule", - "api_v3.save_schedule_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/config/secrets", - "api_v3.get_secrets_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/display/current", - "api_v3.get_display_current", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/display/current-status", - "api_v3.get_current_display_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/display/modes", - "api_v3.get_display_modes", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/display/on-demand/start", - "api_v3.start_on_demand_display", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/display/on-demand/status", - "api_v3.get_on_demand_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/display/on-demand/stop", - "api_v3.stop_on_demand_display", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/errors/clear", - "api_v3.clear_old_errors", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/errors/plugin/", - "api_v3.get_plugin_errors", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/errors/summary", - "api_v3.get_error_summary", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/", - "api_v3.delete_font", - [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/catalog", - "api_v3.get_fonts_catalog", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/overrides", - "api_v3.get_fonts_overrides", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/overrides", - "api_v3.save_fonts_overrides", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/fonts/overrides/", - "api_v3.delete_font_override", - [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/preview", - "api_v3.get_font_preview", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/tokens", - "api_v3.get_font_tokens", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/fonts/upload", - "api_v3.upload_font", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/hardware/status", - "api_v3.get_hardware_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/health", - "api_v3.get_health", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/integrations/mqtt-bridge", - "api_v3.get_mqtt_bridge", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/integrations/mqtt-bridge/config", - "api_v3.update_mqtt_bridge_config", - [ - "OPTIONS", - "PUT" - ] - ], - [ - "/api/v3/logs", - "api_v3.get_logs", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins//static/", - "api_v3.serve_plugin_static", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/action", - "api_v3.execute_plugin_action", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/assets/delete", - "api_v3.delete_plugin_asset", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/assets/list", - "api_v3.list_plugin_assets", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/assets/upload", - "api_v3.upload_plugin_asset", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/authenticate/spotify", - "api_v3.authenticate_spotify", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/authenticate/ytm", - "api_v3.authenticate_ytm", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/calendar/authenticate", - "api_v3.authenticate_calendar", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/calendar/list-calendars", - "api_v3.list_calendar_calendars", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/calendar/upload-credentials", - "api_v3.upload_calendar_credentials", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/config", - "api_v3.get_plugin_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/config", - "api_v3.save_plugin_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/config/reset", - "api_v3.reset_plugin_config", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/health", - "api_v3.get_plugin_health", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/health/", - "api_v3.get_plugin_health_single", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/health//reset", - "api_v3.reset_plugin_health", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/install", - "api_v3.install_plugin", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/install-from-url", - "api_v3.install_plugin_from_url", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/installed", - "api_v3.get_installed_plugins", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/limits/", - "api_v3.manage_plugin_limits", - [ - "GET", - "HEAD", - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/metrics", - "api_v3.get_plugin_metrics", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/metrics/", - "api_v3.get_plugin_metrics_single", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/metrics//reset", - "api_v3.reset_plugin_metrics", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/of-the-day/json/delete", - "api_v3.delete_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/of-the-day/json/upload", - "api_v3.upload_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/operation/", - "api_v3.get_operation_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/operation/history", - "api_v3.clear_operation_history", - [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/operation/history", - "api_v3.get_operation_history", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/reconciliation-status", - "api_v3.get_reconciliation_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/registry-from-url", - "api_v3.get_registry_from_url", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/saved-repositories", - "api_v3.add_saved_repository", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/saved-repositories", - "api_v3.get_saved_repositories", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/saved-repositories", - "api_v3.remove_saved_repository", - [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/schema", - "api_v3.get_plugin_schema", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/state", - "api_v3.get_plugin_state", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/state/reconcile", - "api_v3.reconcile_plugin_state", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/store/github-status", - "api_v3.get_github_auth_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/store/list", - "api_v3.list_plugin_store", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/plugins/store/refresh", - "api_v3.refresh_plugin_store", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/toggle", - "api_v3.toggle_plugin", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/uninstall", - "api_v3.uninstall_plugin", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/update", - "api_v3.update_plugin", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/skins", - "api_v3.list_skins", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/apps", - "api_v3.get_starlark_apps", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/apps/", - "api_v3.get_starlark_app", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/apps/", - "api_v3.uninstall_starlark_app", - [ - "DELETE", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/apps//config", - "api_v3.get_starlark_app_config", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/apps//config", - "api_v3.update_starlark_app_config", - [ - "OPTIONS", - "PUT" - ] - ], - [ - "/api/v3/starlark/apps//render", - "api_v3.render_starlark_app", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/apps//toggle", - "api_v3.toggle_starlark_app", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/editor/apps", - "api_v3.list_pixlet_editor_apps", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/editor/start", - "api_v3.start_pixlet_editor", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/editor/status", - "api_v3.get_pixlet_editor_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/editor/stop", - "api_v3.stop_pixlet_editor", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/install-pixlet", - "api_v3.install_pixlet", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/repository/browse", - "api_v3.browse_tronbyte_repository", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/repository/categories", - "api_v3.get_tronbyte_categories", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/repository/install", - "api_v3.install_from_tronbyte_repository", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/starlark/status", - "api_v3.get_starlark_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/starlark/upload", - "api_v3.upload_starlark_app", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/sync/status", - "api_v3.get_sync_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/system/action", - "api_v3.execute_system_action", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/system/check-update", - "api_v3.check_for_update", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/system/git-branches", - "api_v3.get_git_branches", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/system/git-info", - "api_v3.get_git_info", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/system/status", - "api_v3.get_system_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/system/version", - "api_v3.get_system_version", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/wifi/ap/auto-enable", - "api_v3.get_auto_enable_ap_mode", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/wifi/ap/auto-enable", - "api_v3.set_auto_enable_ap_mode", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/ap/disable", - "api_v3.disable_ap_mode", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/ap/enable", - "api_v3.enable_ap_mode", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/connect", - "api_v3.connect_wifi", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/disconnect", - "api_v3.disconnect_wifi", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/radio", - "api_v3.get_wifi_radio", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/wifi/radio", - "api_v3.set_wifi_radio", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/wifi/scan", - "api_v3.scan_wifi_networks", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ], - [ - "/api/v3/wifi/status", - "api_v3.get_wifi_status", - [ - "GET", - "HEAD", - "OPTIONS" - ] - ] -] \ No newline at end of file + "/api/v3/backup/", + "api_v3.backup_delete", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/download/", + "api_v3.backup_download", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/export", + "api_v3.backup_export", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/backup/list", + "api_v3.backup_list", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/preview", + "api_v3.backup_preview", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/backup/restore", + "api_v3.backup_restore", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/backup/validate", + "api_v3.backup_validate", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/cache/delete", + "api_v3.delete_cache_file", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/cache/list", + "api_v3.list_cache_files", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/dim-schedule", + "api_v3.get_dim_schedule_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/dim-schedule", + "api_v3.save_dim_schedule_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/main", + "api_v3.get_main_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/main", + "api_v3.save_main_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/raw/main", + "api_v3.save_raw_main_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/raw/secrets", + "api_v3.save_raw_secrets_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/schedule", + "api_v3.get_schedule_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/config/schedule", + "api_v3.save_schedule_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/config/secrets", + "api_v3.get_secrets_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/current", + "api_v3.get_display_current", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/current-status", + "api_v3.get_current_display_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/modes", + "api_v3.get_display_modes", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/on-demand/start", + "api_v3.start_on_demand_display", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/display/on-demand/status", + "api_v3.get_on_demand_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/display/on-demand/stop", + "api_v3.stop_on_demand_display", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/errors/clear", + "api_v3.clear_old_errors", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/errors/plugin/", + "api_v3.get_plugin_errors", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/errors/summary", + "api_v3.get_error_summary", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/", + "api_v3.delete_font", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/catalog", + "api_v3.get_fonts_catalog", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/preview", + "api_v3.get_font_preview", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/tokens", + "api_v3.get_font_tokens", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/fonts/upload", + "api_v3.upload_font", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/hardware/status", + "api_v3.get_hardware_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/health", + "api_v3.get_health", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/integrations/mqtt-bridge", + "api_v3.get_mqtt_bridge", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/integrations/mqtt-bridge/config", + "api_v3.update_mqtt_bridge_config", + [ + "OPTIONS", + "PUT" + ] + ], + [ + "/api/v3/logs", + "api_v3.get_logs", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins//static/", + "api_v3.serve_plugin_static", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/action", + "api_v3.execute_plugin_action", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/assets/delete", + "api_v3.delete_plugin_asset", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/assets/list", + "api_v3.list_plugin_assets", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/assets/upload", + "api_v3.upload_plugin_asset", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/authenticate/spotify", + "api_v3.authenticate_spotify", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/authenticate/ytm", + "api_v3.authenticate_ytm", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/calendar/authenticate", + "api_v3.authenticate_calendar", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/calendar/list-calendars", + "api_v3.list_calendar_calendars", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/calendar/upload-credentials", + "api_v3.upload_calendar_credentials", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/config", + "api_v3.get_plugin_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/config", + "api_v3.save_plugin_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/config/reset", + "api_v3.reset_plugin_config", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/health", + "api_v3.get_plugin_health", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/health/", + "api_v3.get_plugin_health_single", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/health//reset", + "api_v3.reset_plugin_health", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/install", + "api_v3.install_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/install-from-url", + "api_v3.install_plugin_from_url", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/installed", + "api_v3.get_installed_plugins", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/limits/", + "api_v3.manage_plugin_limits", + [ + "GET", + "HEAD", + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/metrics", + "api_v3.get_plugin_metrics", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/metrics/", + "api_v3.get_plugin_metrics_single", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/metrics//reset", + "api_v3.reset_plugin_metrics", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/of-the-day/json/delete", + "api_v3.delete_of_the_day_json", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/of-the-day/json/upload", + "api_v3.upload_of_the_day_json", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/operation/", + "api_v3.get_operation_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/operation/history", + "api_v3.clear_operation_history", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/operation/history", + "api_v3.get_operation_history", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/reconciliation-status", + "api_v3.get_reconciliation_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/registry-from-url", + "api_v3.get_registry_from_url", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.add_saved_repository", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.get_saved_repositories", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/saved-repositories", + "api_v3.remove_saved_repository", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/schema", + "api_v3.get_plugin_schema", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/state", + "api_v3.get_plugin_state", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/state/reconcile", + "api_v3.reconcile_plugin_state", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/store/github-status", + "api_v3.get_github_auth_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/store/list", + "api_v3.list_plugin_store", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/plugins/store/refresh", + "api_v3.refresh_plugin_store", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/toggle", + "api_v3.toggle_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/uninstall", + "api_v3.uninstall_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/plugins/update", + "api_v3.update_plugin", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/skins", + "api_v3.list_skins", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps", + "api_v3.get_starlark_apps", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps/", + "api_v3.get_starlark_app", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps/", + "api_v3.uninstall_starlark_app", + [ + "DELETE", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps//config", + "api_v3.get_starlark_app_config", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/apps//config", + "api_v3.update_starlark_app_config", + [ + "OPTIONS", + "PUT" + ] + ], + [ + "/api/v3/starlark/apps//render", + "api_v3.render_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/apps//toggle", + "api_v3.toggle_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/editor/apps", + "api_v3.list_pixlet_editor_apps", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/editor/start", + "api_v3.start_pixlet_editor", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/editor/status", + "api_v3.get_pixlet_editor_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/editor/stop", + "api_v3.stop_pixlet_editor", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/install-pixlet", + "api_v3.install_pixlet", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/repository/browse", + "api_v3.browse_tronbyte_repository", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/repository/categories", + "api_v3.get_tronbyte_categories", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/repository/install", + "api_v3.install_from_tronbyte_repository", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/starlark/status", + "api_v3.get_starlark_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/starlark/upload", + "api_v3.upload_starlark_app", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/sync/status", + "api_v3.get_sync_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/action", + "api_v3.execute_system_action", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/system/check-update", + "api_v3.check_for_update", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/git-branches", + "api_v3.get_git_branches", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/git-info", + "api_v3.get_git_info", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/status", + "api_v3.get_system_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/system/version", + "api_v3.get_system_version", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/ap/auto-enable", + "api_v3.get_auto_enable_ap_mode", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/ap/auto-enable", + "api_v3.set_auto_enable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/ap/disable", + "api_v3.disable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/ap/enable", + "api_v3.enable_ap_mode", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/connect", + "api_v3.connect_wifi", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/disconnect", + "api_v3.disconnect_wifi", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/radio", + "api_v3.get_wifi_radio", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/radio", + "api_v3.set_wifi_radio", + [ + "OPTIONS", + "POST" + ] + ], + [ + "/api/v3/wifi/scan", + "api_v3.scan_wifi_networks", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], + [ + "/api/v3/wifi/status", + "api_v3.get_wifi_status", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ] +] diff --git a/test/test_base_plugin_styles.py b/test/test_base_plugin_styles.py new file mode 100644 index 00000000..3bc77ef1 --- /dev/null +++ b/test/test_base_plugin_styles.py @@ -0,0 +1,224 @@ +"""BasePlugin.styles -- the per-element styling seam every plugin inherits. + +Before this, each consumer of src.element_style repeated the same three +things: a guarded import, finding its own config_schema.json, and rebuilding +the resolver when on_config_change swapped the config dict. Getting the +second one wrong is silent -- the resolver simply has no defaults to compare +against, so every configured value reads as a deliberate override and the +plugin quietly stops honouring its own shipped styling. +""" + +import json +import sys +import types + +import pytest +from unittest.mock import MagicMock + +from src.plugin_system.base_plugin import BasePlugin + +SCHEMA = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "x-style-modes": ["live", "recent"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "offsets": True, + }, + }, + }, + }, +} + + +def _plugin_class(module_name, module_file, **attrs): + """A plugin class whose module sits in a plugin directory, as a real + one does -- that is what schema discovery keys on.""" + module = types.ModuleType(module_name) + module.__file__ = str(module_file) + sys.modules[module_name] = module + + namespace = dict(attrs) + namespace["update"] = lambda self: None + namespace["display"] = lambda self, force_clear=False: None + cls = type("DemoPlugin", (BasePlugin,), namespace) + cls.__module__ = module_name + return cls + + +@pytest.fixture +def plugin_dir(tmp_path): + d = tmp_path / "demo" + d.mkdir() + (d / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + return d + + +@pytest.fixture +def make(tmp_path, plugin_dir, request): + created = [] + + def _make(config, plugin_id="demo", schema_in_module_dir=True, **attrs): + name = f"demo_module_{len(created)}_{id(request)}" + module_file = (plugin_dir if schema_in_module_dir + else tmp_path / "elsewhere") / "manager.py" + module_file.parent.mkdir(parents=True, exist_ok=True) + cls = _plugin_class(name, module_file, **attrs) + created.append(name) + pm = MagicMock() + pm.plugins_dir = str(tmp_path) + return cls(plugin_id, config, MagicMock(), MagicMock(), pm) + + try: + yield _make + finally: + for name in created: + sys.modules.pop(name, None) + + +CONFIG = {"customization": { + "score_text": {"font_size": 12}, + "layout": {"score_text": {"y_offset": -3}}, + "modes": {"live": {"score_text": {"font_size": 16}}}, +}} + + +class TestSchemaDiscovery: + def test_it_finds_the_schema_beside_the_plugin_module(self, make, plugin_dir): + """Not beside base_plugin.py, which lives in src/plugin_system where + no plugin schema exists.""" + p = make({}) + assert p._config_schema_path() == str(plugin_dir / "config_schema.json") + + def test_it_falls_back_to_the_plugins_directory(self, make): + p = make({}, schema_in_module_dir=False) + assert p._config_schema_path() is not None + + def test_it_accepts_the_ledmatrix_prefix_form(self, tmp_path, make): + (tmp_path / "ledmatrix-music").mkdir() + (tmp_path / "ledmatrix-music" / "config_schema.json").write_text( + json.dumps(SCHEMA), encoding="utf-8") + p = make({}, plugin_id="music", schema_in_module_dir=False) + assert "ledmatrix-music" in p._config_schema_path() + + def test_a_missing_schema_is_not_fatal(self, tmp_path, make): + p = make({}, plugin_id="ghost", schema_in_module_dir=False) + # 'elsewhere' has no schema and neither does tmp_path/ghost + assert p._config_schema_path() is None + assert p.styles.style("score_text", classic_size=8).font_size == 8 + + def test_the_module_directory_wins_over_the_plugins_directory( + self, tmp_path, plugin_dir): + """These two are the same path for an installed plugin, so the test + has to force them apart -- and they genuinely diverge for a plugin + symlinked in for development, where the module lives outside the + configured plugins directory. Sourcing the path from this module's + own __file__ instead would find neither, then quietly fall through + to whatever the plugins directory happened to hold. + """ + dev_dir = tmp_path / "dev-checkout" + dev_dir.mkdir() + dev_schema = json.loads(json.dumps(SCHEMA)) + (dev_schema["properties"]["customization"]["x-style-elements"] + ["score_text"]["size"]["default"]) = 99 + (dev_dir / "config_schema.json").write_text(json.dumps(dev_schema), + encoding="utf-8") + + cls = _plugin_class("demo_dev_checkout", dev_dir / "manager.py") + try: + pm = MagicMock() + pm.plugins_dir = str(tmp_path) # holds demo/config_schema.json + p = cls("demo", {}, MagicMock(), MagicMock(), pm) + assert p._config_schema_path() == str(dev_dir / "config_schema.json") + # and the difference is observable: 99 is this schema's default, + # so configuring 99 must read as "not a choice". + p.on_config_change({"customization": {"score_text": {"font_size": 99}}}) + assert p.styles.style("score_text", classic_size=10).font_size == 10 + finally: + sys.modules.pop("demo_dev_checkout", None) + + def test_the_lookup_is_cached(self, make): + """A miss must not re-scan the disk on every frame.""" + p = make({}) + first = p._config_schema_path() + assert p._config_schema_path() is first + + +class TestResolution: + def test_an_untouched_config_gets_the_classic_values(self, make): + """The invariant that makes adopting this safe.""" + style = make({}).styles.style( + "score_text", classic_font="4x6-font.ttf", classic_size=6, + classic_color=(1, 2, 3)) + assert (style.font_name, style.font_size, style.color) == ( + "4x6-font.ttf", 6, (1, 2, 3)) + assert style.user_forced is False + + def test_a_users_choice_comes_through(self, make): + style = make(CONFIG).styles.style("score_text", classic_size=10) + assert style.font_size == 12 + assert style.offset == (0, -3) + + def test_style_mode_binds_without_touching_call_sites(self, make): + """The whole point: a per-mode subclass sets one attribute and its + existing lookups become mode-aware.""" + assert make(CONFIG).styles.style( + "score_text", classic_size=10).font_size == 12 + assert make(CONFIG, STYLE_MODE="live").styles.style( + "score_text", classic_size=10).font_size == 16 + + def test_styles_for_handles_a_one_off_mode(self, make): + p = make(CONFIG) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16 + assert p.styles_for("recent").style("score_text", classic_size=10).font_size == 12 + assert p.styles_for(None).style("score_text", classic_size=10).font_size == 12 + + +class TestInvalidation: + def test_the_resolver_is_reused(self, make): + p = make(CONFIG) + assert p.styles is p.styles + + def test_a_config_swap_rebuilds_it(self, make): + """on_config_change replaces the dict rather than mutating it, so + identity is the signal.""" + p = make(CONFIG) + assert p.styles.style("score_text", classic_size=10).font_size == 12 + p.on_config_change({"customization": {"score_text": {"font_size": 6}}}) + assert p.styles.style("score_text", classic_size=10).font_size == 6 + + def test_a_config_swap_rebuilds_the_per_mode_cache_too(self, make): + p = make(CONFIG) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16 + p.on_config_change({"customization": { + "modes": {"live": {"score_text": {"font_size": 5}}}}}) + assert p.styles_for("live").style("score_text", classic_size=10).font_size == 5 + + +class TestDegradation: + # config=None is not in this list on purpose: BasePlugin.__init__ reads + # config.get("enabled") before styles exists, so a None config is a + # constructor precondition rather than something styles should absorb. + @pytest.mark.parametrize("config", [ + {}, {"customization": None}, {"customization": "nonsense"}, + {"customization": {"score_text": "nonsense"}}, + {"customization": {"layout": "nonsense"}}, + {"customization": {"modes": "nonsense"}}, + ]) + def test_a_hostile_config_still_resolves(self, config, make): + style = make(config).styles.style("score_text", classic_size=7) + assert style.font_size == 7 + + def test_it_works_without_a_plugin_manager(self, plugin_dir): + cls = _plugin_class("demo_no_pm", plugin_dir / "manager.py") + try: + p = cls("demo", {}, MagicMock(), MagicMock(), None) + assert p.styles.style("score_text", classic_size=8).font_size == 8 + finally: + sys.modules.pop("demo_no_pm", None) diff --git a/test/test_element_style.py b/test/test_element_style.py index 8d0535ab..3352b47a 100644 --- a/test/test_element_style.py +++ b/test/test_element_style.py @@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard): - style() never raises; malformed input degrades to the classic style. """ +import copy import json import os @@ -23,7 +24,10 @@ import pytest from PIL import ImageFont from src.element_style import ( + alias_keys, ElementStyleResolver, + _load_font_sized, + native_bdf_size, defaults_from_schema, defaults_from_schema_file, expand_style_elements, @@ -173,10 +177,25 @@ class TestExpandStyleElements: expand_style_elements(STYLE_ELEMENTS_SCHEMA) assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before - def test_no_declaration_returns_same_object(self): - assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA + def test_a_schema_with_nothing_to_expand_is_returned_as_is(self): + """No needless deep copy when there is nothing to do.""" empty = {"properties": {}} assert expand_style_elements(empty) is empty + no_style = {"properties": {"customization": {"type": "object", + "properties": {"favorite_result_colors": {"type": "object", + "properties": {"win_color": {"type": "array"}}}}}}} + assert expand_style_elements(no_style) is no_style + + def test_a_hand_written_block_is_adopted(self): + """Nineteen plugins spell their style elements out longhand rather + than declaring them, and predate this system entirely. They pick up + the editor and the font picker on a core update rather than on a + plugin release.""" + expanded = expand_style_elements(MANUAL_SCHEMA) + assert expanded is not MANUAL_SCHEMA + customization = expanded["properties"]["customization"] + assert customization["x-widget"] == "style-editor" + assert "x-style-elements" not in MANUAL_SCHEMA["properties"]["customization"] def test_garbage_input_never_raises(self): bad = {"properties": {"customization": {"x-style-elements": "nope"}}} @@ -340,6 +359,19 @@ class TestDegradation: # The override IS honored as forced, but the face degrades safely. assert style.user_forced assert style.font is not None + assert not isinstance(style.font, tuple) + + def test_missing_font_load_returns_a_font_not_a_nested_tuple(self): + # Regression: the missing-font path in _load_font_sized used to wrap + # _load_fallback_font's own (font, size) tuple a second time, so + # load_font() returned a (font, size) tuple where callers (draw.text, + # layout math) expect a font object. + font, size = _load_font_sized("definitely-not-a-real-font.ttf", 8) + assert not isinstance(font, tuple) + assert size == 8 + + single = load_font("definitely-not-a-real-font.ttf", 8) + assert not isinstance(single, tuple) def test_empty_defaults_treats_config_as_reference_to_classic(self): # No schema defaults at all: a config value equal to the classic @@ -410,3 +442,831 @@ class TestResolverPlumbing: cust = schema["properties"]["customization"]["properties"] assert cust["title_text"]["x-style-managed"] is True assert "title_text" in cust["layout"]["properties"] +class TestBdfSizing: + """A BDF is a fixed-size bitmap strike, not a scalable outline. + + FreeType accepts only the exact pixel size baked into the file and raises + for anything else, and 32 of the 35 shipped fonts are BDF -- so a size + picked in the web UI usually is not a valid strike. This used to fall + through to the generic font-load except and return *PressStart2P*, so + asking for 5x7.bdf at size 10 silently rendered a different typeface. + It now falls back to the file's own size instead, matching what + SportsCore._load_custom_font_from_element_config already did. + """ + + def test_a_valid_strike_loads_at_that_size(self): + import freetype + font, realised = _load_font_sized("5x7.bdf", 7) + assert isinstance(font, freetype.Face) + assert realised == 7 + + @pytest.mark.parametrize("requested", [4, 10, 16, 32]) + def test_a_wrong_size_keeps_the_font_and_snaps_the_size(self, requested): + """The regression: the face must still be 5x7, not a substitute.""" + import freetype + font, realised = _load_font_sized("5x7.bdf", requested) + assert isinstance(font, freetype.Face), ( + "a BDF asked for a bad size used to come back as a PIL " + "PressStart2P face -- a different font entirely") + assert realised == 7 + + def test_the_reported_size_is_what_was_realised(self, style_schema_path): + """ElementStyle.font_size drives caller layout, so it must not report + a size nothing was drawn at.""" + config = {"customization": {"title_text": {"font": "5x7.bdf", + "font_size": 20}}} + style = ElementStyleResolver( + config, defaults_from_schema_file(style_schema_path) + ).style("title_text", classic_font="PressStart2P-Regular.ttf", + classic_size=8) + assert style.font_name == "5x7.bdf" + assert style.font_size == 7 + + def test_native_bdf_size_reads_the_file(self): + assert native_bdf_size("5x7.bdf") == 7 + + @pytest.mark.parametrize("name", ["PressStart2P-Regular.ttf", + "no-such-font.bdf", "", None]) + def test_native_bdf_size_is_none_when_size_is_a_free_choice(self, name): + """None is the web UI's signal that the size field stays editable.""" + assert native_bdf_size(name) is None + + def test_a_scalable_font_realises_the_requested_size(self): + for size in (6, 8, 13): + font, realised = _load_font_sized("PressStart2P-Regular.ttf", size) + assert realised == size + assert not isinstance(font, type(None)) + + +class TestFontCacheIsBounded: + """The display process runs for weeks and every config save can add a new + (font, size) pair; this cache was unbounded, against the house style of + every other hot cache in the codebase.""" + + def test_the_cache_evicts_past_its_bound(self): + import src.element_style as es + es._font_cache.clear() + try: + for size in range(1, es._FONT_CACHE_MAX + 40): + load_font("PressStart2P-Regular.ttf", size) + assert len(es._font_cache) <= es._FONT_CACHE_MAX + finally: + es._font_cache.clear() + + def test_a_repeated_load_is_the_same_object(self): + import src.element_style as es + es._font_cache.clear() + try: + first = load_font("PressStart2P-Regular.ttf", 8) + assert load_font("PressStart2P-Regular.ttf", 8) is first + finally: + es._font_cache.clear() + + def test_eviction_is_least_recently_used(self): + import src.element_style as es + es._font_cache.clear() + try: + oldest = load_font("PressStart2P-Regular.ttf", 1) + for size in range(2, es._FONT_CACHE_MAX + 1): + load_font("PressStart2P-Regular.ttf", size) + # Touch the oldest so it is no longer the eviction candidate, + # then overflow by one. + assert load_font("PressStart2P-Regular.ttf", 1) is oldest + load_font("PressStart2P-Regular.ttf", es._FONT_CACHE_MAX + 1) + assert load_font("PressStart2P-Regular.ttf", 1) is oldest + finally: + es._font_cache.clear() + +class TestModes: + """Per-mode overrides: one element styled differently per situation. + + The motivating case is a scoreboard, where the score wants a bigger font + on a live card than on an upcoming one. Live/Upcoming/Recent are already + separate instances with distinct SKIN_MODE values, so the mode is bound + to the resolver rather than threaded through every call site. + + A mode layer is pure override: its fields default to None, meaning + inherit. That is why None and 0 must stay distinct -- a mode y_offset of + 0 means "sit at the base position", not "no preference". + """ + + def _resolver(self, config, mode=None, schema=None): + defaults = defaults_from_schema_file(schema) if schema else {} + return ElementStyleResolver(config, defaults, mode=mode) + + def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path): + config = {"customization": {"title_text": {"font_size": 12}}} + plain = self._resolver(config, schema=style_schema_path) + moded = self._resolver(config, mode="live", schema=style_schema_path) + a = plain.style("title_text", classic_size=8) + b = moded.style("title_text", classic_size=8) + assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size, + b.color) + + def test_a_mode_overrides_the_base_size(self, style_schema_path): + config = {"customization": { + "title_text": {"font_size": 12}, + "modes": {"live": {"title_text": {"font_size": 16}}}}} + r = self._resolver(config, mode="live", schema=style_schema_path) + assert r.style("title_text", classic_size=8).font_size == 16 + assert r.style("title_text", classic_size=8).user_forced is True + + def test_a_different_mode_is_unaffected(self, style_schema_path): + config = {"customization": { + "title_text": {"font_size": 12}, + "modes": {"live": {"title_text": {"font_size": 16}}}}} + r = self._resolver(config, mode="upcoming", schema=style_schema_path) + assert r.style("title_text", classic_size=8).font_size == 12 + + def test_two_resolvers_share_a_config_and_differ_by_mode( + self, style_schema_path): + """The SportsUpcoming / SportsRecent case: same config dict, two + instances, two answers.""" + config = {"customization": { + "title_text": {"font_size": 12}, + "modes": {"live": {"title_text": {"font_size": 16}}, + "recent": {"title_text": {"font_size": 6}}}}} + live = self._resolver(config, mode="live", schema=style_schema_path) + recent = self._resolver(config, mode="recent", schema=style_schema_path) + assert live.style("title_text", classic_size=8).font_size == 16 + assert recent.style("title_text", classic_size=8).font_size == 6 + + def test_a_mode_overrides_font_and_colour(self, style_schema_path): + config = {"customization": { + "modes": {"live": {"title_text": {"font": "4x6-font.ttf", + "text_color": [1, 2, 3]}}}}} + st = self._resolver(config, mode="live", + schema=style_schema_path).style( + "title_text", classic_font="PressStart2P-Regular.ttf", + classic_color=(255, 255, 255)) + assert st.font_name == "4x6-font.ttf" + assert st.color == (1, 2, 3) + assert st.user_forced and st.user_forced_color + + def test_an_unset_mode_field_inherits_rather_than_resetting( + self, style_schema_path): + """Only font_size is overridden; the colour must survive.""" + config = {"customization": { + "title_text": {"text_color": [9, 9, 9]}, + "modes": {"live": {"title_text": {"font_size": 16}}}}} + st = self._resolver(config, mode="live", + schema=style_schema_path).style( + "title_text", classic_color=(255, 255, 255)) + assert st.font_size == 16 + assert st.color == (9, 9, 9) + + def test_a_null_mode_field_means_inherit(self, style_schema_path): + config = {"customization": { + "title_text": {"font_size": 12}, + "modes": {"live": {"title_text": {"font_size": None}}}}} + st = self._resolver(config, mode="live", + schema=style_schema_path).style("title_text") + assert st.font_size == 12 + + def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path): + config = {"customization": {"modes": { + "live": {"title_text": {"font_size": 16}}, + "recent": {"title_text": {"font_size": 6}}}}} + r = self._resolver(config, mode="live", schema=style_schema_path) + assert r.style("title_text").font_size == 16 + assert r.style("title_text", mode="recent").font_size == 6 + + def test_the_memo_does_not_leak_between_modes(self, style_schema_path): + config = {"customization": {"modes": { + "live": {"title_text": {"font_size": 16}}, + "recent": {"title_text": {"font_size": 6}}}}} + r = self._resolver(config, mode="live", schema=style_schema_path) + assert r.style("title_text").font_size == 16 + assert r.style("title_text", mode="recent").font_size == 6 + assert r.style("title_text").font_size == 16 + + def test_mode_is_exposed(self): + assert ElementStyleResolver({}, {}, mode="live").mode == "live" + assert ElementStyleResolver({}, {}).mode is None + + +class TestModeOffsets: + def _r(self, config, mode=None): + return ElementStyleResolver(config, {}, mode=mode) + + def test_a_mode_offset_wins(self): + config = {"customization": { + "layout": {"score": {"y_offset": -2}}, + "modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}} + assert self._r(config, "live").offset_value("score", "y_offset") == 5 + + def test_an_absent_mode_offset_inherits_the_base(self): + config = {"customization": { + "layout": {"score": {"y_offset": -2}}, + "modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}} + r = self._r(config, "live") + assert r.offset_value("score", "y_offset") == -2 + assert r.offset_value("score", "x_offset") == 1 + + def test_an_explicit_zero_is_an_override_not_an_absence(self): + """The reason None is the inherit sentinel: 0 has to mean something.""" + config = {"customization": { + "layout": {"score": {"y_offset": -2}}, + "modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}} + assert self._r(config, "live").offset_value("score", "y_offset") == 0 + + def test_a_null_mode_offset_inherits(self): + config = {"customization": { + "layout": {"score": {"y_offset": -2}}, + "modes": {"live": {"layout": {"score": {"y_offset": None}}}}}} + assert self._r(config, "live").offset_value("score", "y_offset") == -2 + + def test_offset_pair_is_mode_aware(self): + config = {"customization": { + "layout": {"score": {"x_offset": 1, "y_offset": 2}}, + "modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}} + assert self._r(config, "live").offset("score") == (1, 9) + + def test_an_arbitrary_axis_is_mode_aware(self): + """The scoreboards use away_x_offset / home_x_offset.""" + config = {"customization": { + "layout": {"records": {"away_x_offset": 3}}, + "modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}} + assert self._r(config, "recent").offset_value( + "records", "away_x_offset") == 7 + + @pytest.mark.parametrize("modes", [ + None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}}, + {"live": {"layout": {"score": {"y_offset": "bad"}}}}, + ]) + def test_garbage_modes_degrade_to_the_base(self, modes): + config = {"customization": {"layout": {"score": {"y_offset": -2}}, + "modes": modes}} + assert self._r(config, "live").offset_value("score", "y_offset") == -2 + + @pytest.mark.parametrize("modes", [ + None, "nonsense", {"live": "nonsense"}, + {"live": {"title_text": "nonsense"}}, + {"live": {"title_text": {"font_size": "huge", "text_color": "red"}}}, + ]) + def test_garbage_mode_styles_degrade(self, modes, style_schema_path): + config = {"customization": {"title_text": {"font_size": 12}, + "modes": modes}} + st = ElementStyleResolver( + config, defaults_from_schema_file(style_schema_path), + mode="live").style("title_text", classic_size=8) + assert st.font_size == 12 + +MODE_SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "enabled": {"type": "boolean", "default": False}, + "customization": { + "type": "object", + "x-style-modes": ["live", "recent"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "offsets": True, + }, + }, + }, + }, +} + + +class TestModeSchemaEmission: + def _expanded(self): + return expand_style_elements(MODE_SCHEMA)["properties"]["customization"] + + def test_a_modes_block_is_emitted_per_declared_mode(self): + assert sorted(self._expanded()["properties"]["modes"]["properties"]) == [ + "live", "recent"] + + def test_mode_fields_are_nullable_and_default_to_null(self): + """Null is the inherit sentinel. A concrete default here would turn + every mode into a copy of the base the moment the user pressed Save, + because the save flow writes schema defaults into config wholesale.""" + live = self._expanded()["properties"]["modes"]["properties"]["live"] + block = live["properties"]["score_text"]["properties"] + for name, prop in block.items(): + assert prop["default"] is None, name + assert "null" in prop["type"], name + + def test_mode_offsets_are_nullable_too(self): + live = self._expanded()["properties"]["modes"]["properties"]["live"] + axes = live["properties"]["layout"]["properties"]["score_text"]["properties"] + assert set(axes) == {"x_offset", "y_offset"} + for prop in axes.values(): + assert prop["default"] is None + assert "null" in prop["type"] + + def test_the_base_block_keeps_its_real_defaults(self): + block = self._expanded()["properties"]["score_text"]["properties"] + assert block["font_size"]["default"] == 10 + assert block["font"]["default"] == "PressStart2P-Regular.ttf" + + def test_the_font_field_asks_for_the_font_selector_widget(self): + """The widget already existed and was already allowlisted by the + config form; without the hint the field was a bare text box the user + had to type a filename into.""" + block = self._expanded()["properties"]["score_text"]["properties"] + assert block["font"]["x-widget"] == "font-selector" + + def test_no_declared_modes_emits_no_modes_block(self): + schema = copy.deepcopy(MODE_SCHEMA) + del schema["properties"]["customization"]["x-style-modes"] + expanded = expand_style_elements(schema)["properties"]["customization"] + assert "modes" not in expanded["properties"] + + @pytest.mark.parametrize("modes", ["nonsense", [], [None], [""], 5, {}]) + def test_garbage_mode_declarations_do_not_break_expansion(self, modes): + schema = copy.deepcopy(MODE_SCHEMA) + schema["properties"]["customization"]["x-style-modes"] = modes + expanded = expand_style_elements(schema)["properties"]["customization"] + assert "score_text" in expanded["properties"] + + def test_the_input_schema_is_not_mutated(self): + expand_style_elements(MODE_SCHEMA) + assert "properties" not in MODE_SCHEMA["properties"]["customization"] + + +class TestModeSaveRoundTrip: + """The path a real save takes: schema -> defaults -> merge -> validate -> + resolve. The risk being covered is that the save flow writes schema + defaults into config.json wholesale, so a mode block has to survive that + still meaning "inherit".""" + + @pytest.fixture + def plugin(self, tmp_path): + from src.plugin_system.schema_manager import SchemaManager + pdir = tmp_path / "plugins" / "demo" + pdir.mkdir(parents=True) + (pdir / "config_schema.json").write_text(json.dumps(MODE_SCHEMA), + encoding="utf-8") + sm = SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + schema = sm.load_schema("demo", use_cache=False) + return sm, schema, sm.extract_defaults_from_schema(schema), pdir + + def test_a_save_leaves_mode_blocks_meaning_inherit(self, plugin): + sm, schema, defaults, pdir = plugin + merged = sm.merge_with_defaults( + {"enabled": True, "customization": {"score_text": {"font_size": 14}}}, + defaults) + live = merged["customization"]["modes"]["live"]["score_text"] + assert live == {"font": None, "font_size": None, "text_color": None} + + ok, errors = sm.validate_config_against_schema(merged, schema) + assert ok, errors + + style = ElementStyleResolver( + merged, defaults_from_schema_file(str(pdir / "config_schema.json")), + mode="live").style("score_text", + classic_font="PressStart2P-Regular.ttf", + classic_size=10) + assert style.font_size == 14, "the base must still reach the mode" + + def test_a_real_mode_override_validates_and_wins(self, plugin): + sm, schema, defaults, pdir = plugin + merged = sm.merge_with_defaults({"customization": { + "score_text": {"font_size": 14}, + "modes": {"live": {"score_text": {"font_size": 16}, + "layout": {"score_text": {"y_offset": -3}}}}, + }}, defaults) + ok, errors = sm.validate_config_against_schema(merged, schema) + assert ok, errors + + schema_defaults = defaults_from_schema_file( + str(pdir / "config_schema.json")) + live = ElementStyleResolver(merged, schema_defaults, mode="live") + recent = ElementStyleResolver(merged, schema_defaults, mode="recent") + assert live.style("score_text", classic_size=10).font_size == 16 + assert live.offset("score_text") == (0, -3) + assert recent.style("score_text", classic_size=10).font_size == 14 + assert recent.offset("score_text") == (0, 0) + + def test_a_mode_size_outside_the_declared_range_is_rejected(self, plugin): + """min/max from the declaration must carry into the mode blocks.""" + sm, schema, defaults, _ = plugin + merged = sm.merge_with_defaults( + {"customization": {"modes": {"live": {"score_text": { + "font_size": 99}}}}}, defaults) + ok, _errors = sm.validate_config_against_schema(merged, schema) + assert not ok + +EXTRA_SCHEMA = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "x-style-modes": ["live"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10}, + "color": {"default": [255, 255, 255]}, + "visible": True, + "align": {"default": "center"}, + "offsets": True, + }, + "home_logo": { + "title": "Home Logo", + "offsets": True, + "visible": True, + "scale": {"default": 1.0, "min": 0.25, "max": 4}, + }, + }, + }, + }, +} + + +@pytest.fixture +def extra_schema_path(tmp_path): + path = tmp_path / "extra_schema.json" + path.write_text(json.dumps(EXTRA_SCHEMA), encoding="utf-8") + return str(path) + + +class TestVisibleAlignScale: + """visible / align / scale all resolve to "change nothing" until asked. + + That is the same invariant the font fields keep: a caller that honours + these must still render an untouched config exactly as it did before + they existed, so the neutral values are True / None / 1.0 rather than + whatever the schema happens to declare. + """ + + def _style(self, config, mode=None, schema=None): + defaults = defaults_from_schema_file(schema) if schema else {} + return ElementStyleResolver(config, defaults, mode=mode).style( + "score_text", classic_font="PressStart2P-Regular.ttf", + classic_size=10, classic_color=(255, 255, 255)) + + def test_an_untouched_config_is_neutral(self, extra_schema_path): + st = self._style({}, schema=extra_schema_path) + assert (st.visible, st.align, st.scale) == (True, None, 1.0) + + def test_a_schema_default_is_not_a_choice(self, extra_schema_path): + """align defaults to 'center' in the schema, and the save flow writes + that into config -- which must not read as the user asking for it.""" + config = {"customization": {"score_text": {"align": "center", + "visible": True}}} + st = self._style(config, schema=extra_schema_path) + assert st.align is None, "the plugin keeps its own alignment" + assert st.visible is True + + def test_hiding_an_element(self, extra_schema_path): + config = {"customization": {"score_text": {"visible": False}}} + assert self._style(config, schema=extra_schema_path).visible is False + + def test_a_real_alignment_choice_comes_through(self, extra_schema_path): + config = {"customization": {"score_text": {"align": "right"}}} + assert self._style(config, schema=extra_schema_path).align == "right" + + @pytest.mark.parametrize("written,expected", [ + ("left", "left"), ("RIGHT", "right"), (" center ", "center"), + ("centre", "center"), ("middle", "center"), + ("sideways", None), ("", None), (5, None), (None, None), + ]) + def test_alignment_coercion(self, written, expected, extra_schema_path): + config = {"customization": {"score_text": {"align": written}}} + assert self._style(config, schema=extra_schema_path).align == expected + + @pytest.mark.parametrize("written,expected", [ + (2, 2.0), (0.5, 0.5), ("1.5", 1.5), + (0, 1.0), (-3, 1.0), # nonsense degrades, never inverts + (999, 10.0), # clamped: the panel is 32px tall + ("huge", 1.0), (None, 1.0), (True, 1.0), + ]) + def test_scale_coercion(self, written, expected, extra_schema_path): + config = {"customization": {"layout": {"score_text": {"scale": written}}}} + assert self._style(config, schema=extra_schema_path).scale == expected + + def test_scale_reads_from_the_layout_block(self, extra_schema_path): + """scale is geometry, so it sits with the offsets -- a logo has a + scale and no font.""" + config = {"customization": {"layout": {"score_text": {"scale": 2}}}} + assert self._style(config, schema=extra_schema_path).scale == 2.0 + + @pytest.mark.parametrize("field,value,attr,expected", [ + ("visible", False, "visible", False), + ("align", "right", "align", "right"), + ]) + def test_a_mode_overrides_them(self, field, value, attr, expected, + extra_schema_path): + config = {"customization": {"modes": { + "live": {"score_text": {field: value}}}}} + st = self._style(config, mode="live", schema=extra_schema_path) + assert getattr(st, attr) == expected + + def test_a_mode_overrides_scale(self, extra_schema_path): + config = {"customization": { + "layout": {"score_text": {"scale": 2}}, + "modes": {"live": {"layout": {"score_text": {"scale": 3}}}}}} + assert self._style(config, mode="live", + schema=extra_schema_path).scale == 3.0 + + def test_an_unset_mode_field_inherits_the_base(self, extra_schema_path): + config = {"customization": { + "score_text": {"visible": False}, + "modes": {"live": {"score_text": {"align": "right"}}}}} + st = self._style(config, mode="live", schema=extra_schema_path) + assert st.visible is False and st.align == "right" + + +class TestExtraFieldSchema: + def _props(self): + return expand_style_elements( + EXTRA_SCHEMA)["properties"]["customization"]["properties"] + + def test_only_declared_subfields_are_emitted(self): + """home_logo declares no font, so it gets no font control.""" + assert list(self._props()["home_logo"]["properties"]) == ["visible"] + + def test_a_text_element_gets_the_text_fields(self): + assert list(self._props()["score_text"]["properties"]) == [ + "font", "font_size", "text_color", "visible", "align"] + + def test_scale_is_emitted_with_the_offsets(self): + layout = self._props()["layout"]["properties"] + assert list(layout["home_logo"]["properties"]) == [ + "x_offset", "y_offset", "scale"] + assert list(layout["score_text"]["properties"]) == [ + "x_offset", "y_offset"], "scale is opt-in" + + def test_declared_scale_bounds_are_kept(self): + scale = self._props()["layout"]["properties"]["home_logo"]["properties"]["scale"] + assert (scale["minimum"], scale["maximum"]) == (0.25, 4) + + def test_align_offers_only_valid_choices(self): + align = self._props()["score_text"]["properties"]["align"] + assert align["enum"] == ["left", "center", "right"] + + def test_the_mode_copies_are_nullable(self): + live = self._props()["modes"]["properties"]["live"]["properties"] + assert live["score_text"]["properties"]["visible"]["default"] is None + assert "null" in live["score_text"]["properties"]["visible"]["type"] + scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"] + assert scale["default"] is None and "null" in scale["type"] + +class TestAliasKeys: + """Two naming conventions collided as the scoreboards grew. + + Counted across the published schemas: the style block names elements + with a _text suffix (score_text, status_text), while the layout block + mostly uses the bare noun (score, date, odds) -- except status_text, + which kept the suffix in seven plugins and lost it in two. records vs + record splits seven to two the same way. + + Renaming config keys to fix that would orphan whatever offsets users had + already dialled in, so lookups try the alternatives instead. + """ + + @pytest.mark.parametrize("key,expected", [ + ("score_text", ("score_text", "score")), + ("status", ("status", "status_text")), + ("status_text", ("status_text", "status")), + ("records", ("records", "record")), + ("record", ("record", "records")), + ("rank_text", ("rank_text", "ranking", "rank")), + ("team_name", ("team_name", "team")), + ]) + def test_the_spellings_tried(self, key, expected): + assert alias_keys(key) == expected + + def test_the_exact_name_is_always_first(self): + for key in ("score_text", "status", "records", "home_logo"): + assert alias_keys(key)[0] == key + + def test_an_explicit_entry_replaces_the_suffix_rule(self): + """'records' must not also generate the meaningless 'records_text'.""" + assert "records_text" not in alias_keys("records") + + @pytest.mark.parametrize("key", ["", None, 5, [], {}]) + def test_nonsense_keys_yield_nothing(self, key): + assert alias_keys(key) == () + + +class TestAliasedLookup: + def _r(self, config, mode=None): + return ElementStyleResolver(config, {}, mode=mode) + + def test_a_compact_plugin_finds_offsets_saved_under_the_bare_noun(self): + """The migration case: a scoreboard moving to the compact form asks + for score_text offsets, and its users wrote layout.score.""" + config = {"customization": {"layout": {"score": {"y_offset": -3}}}} + assert self._r(config).offset("score_text") == (0, -3) + + def test_status_and_status_text_find_each_other(self): + written_long = {"customization": {"layout": {"status_text": {"x_offset": 2}}}} + written_short = {"customization": {"layout": {"status": {"x_offset": 4}}}} + assert self._r(written_long).offset("status") == (2, 0) + assert self._r(written_short).offset("status_text") == (4, 0) + + def test_record_and_records_find_each_other(self): + config = {"customization": {"layout": {"records": {"away_x_offset": 5}}}} + assert self._r(config).offset_value("record", "away_x_offset") == 5 + + def test_an_exact_match_beats_an_alias(self): + """Nothing changes for a config that already uses the right name.""" + config = {"customization": {"layout": { + "score": {"y_offset": 1}, "score_text": {"y_offset": 9}}}} + assert self._r(config).offset("score_text") == (0, 9) + assert self._r(config).offset("score") == (0, 1) + + def test_style_blocks_alias_too(self, style_schema_path): + config = {"customization": {"title": {"font_size": 13}}} + st = ElementStyleResolver( + config, defaults_from_schema_file(style_schema_path) + ).style("title_text", classic_size=8) + assert st.font_size == 13 + + def test_a_mode_block_aliases_too(self): + config = {"customization": { + "layout": {"score": {"y_offset": -3}}, + "modes": {"live": {"layout": {"score": {"y_offset": 7}}}}}} + assert self._r(config, "live").offset("score_text") == (0, 7) + + def test_an_unrelated_element_is_unaffected(self): + config = {"customization": {"layout": {"home_logo": {"x_offset": 3}}}} + r = self._r(config) + assert r.offset("home_logo") == (3, 0) + assert r.offset("away_logo") == (0, 0) + + def test_scale_is_found_through_an_alias(self, extra_schema_path): + config = {"customization": {"layout": {"score": {"scale": 2}}}} + st = ElementStyleResolver( + config, defaults_from_schema_file(extra_schema_path) + ).style("score_text") + assert st.scale == 2.0 + +HANDWRITTEN = { + "type": "object", + "properties": { + "customization": { + "type": "object", + "title": "Display Customization", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "properties": { + "font": { + "type": "string", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", + "5by7.regular.ttf", "5x7.bdf", "4x6.bdf"], + "default": "PressStart2P-Regular.ttf", + }, + "font_size": {"type": "integer", "minimum": 4, + "maximum": 16, "default": 10}, + "text_color": {"type": "array", "minItems": 3, + "maxItems": 3, "default": [255, 255, 255]}, + }, + }, + "favorite_result_colors": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "win_color": {"type": "array", "default": [0, 255, 0]}, + }, + }, + # baseball's 'count' shape: one field this system knows, + # next to geometry that means nothing to it. + "count": { + "type": "object", + "properties": { + "text_color": {"type": "array", "default": [0, 255, 0]}, + "y_offset": {"type": "integer", "default": 2}, + }, + }, + "layout": { + "type": "object", + "properties": { + "score": {"type": "object", "properties": { + "x_offset": {"type": "integer", "default": 0}, + "y_offset": {"type": "integer", "default": 0}}}, + "home_logo": {"type": "object", "properties": { + "x_offset": {"type": "integer", "default": 0}}}, + }, + }, + }, + }, + }, +} + + +class TestHandWrittenAdoption: + """Nineteen plugins spell their style elements out longhand -- football's + block is 701 lines for seven elements -- and predate this system. Core + recognises that shape so they pick up the editor and the real font picker + on a core update rather than on a plugin release. + """ + + def _customization(self, schema=None): + return expand_style_elements( + schema or HANDWRITTEN)["properties"]["customization"] + + def test_the_block_gets_the_composite_editor(self): + assert self._customization()["x-widget"] == "style-editor" + + def test_the_declared_order_is_stated_explicitly(self): + """Flask's JSON provider sorts keys, so without this the elements + reach the browser alphabetised.""" + order = self._customization()["x-propertyOrder"] + assert order[0] == "score_text" + + def test_a_block_that_is_not_styling_is_left_alone(self): + """favorite_result_colors, baseball's bases/outs/player_card and the + stocks blocks all carry fields this system knows nothing about.""" + block = self._customization()["properties"]["favorite_result_colors"] + assert "x-style-managed" not in block + assert block["properties"]["win_color"]["default"] == [0, 255, 0] + + def test_a_block_that_merely_shares_a_field_is_left_alone(self): + """The reason detection requires *every* field to be one this system + understands. baseball's 'count' carries a text_color beside a + y_offset that means nothing here. + + Asserted on the element list rather than on the block itself: an + over-eager rule leaves a fontless block looking untouched, and only + shows up as an extra row in the editor and an extra per-mode + override group. + """ + schema = copy.deepcopy(HANDWRITTEN) + schema["properties"]["customization"]["x-style-modes"] = ["live"] + live = expand_style_elements(schema)["properties"]["customization"][ + "properties"]["modes"]["properties"]["live"]["properties"] + assert "score_text" in live + assert "count" not in live, ( + "'count' is not a style element -- it shares one field and " + "carries geometry this system does not understand") + assert "favorite_result_colors" not in live + + def test_the_hardcoded_font_list_is_replaced_by_the_picker(self): + """The reason an uploaded font could never appear in one of these.""" + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert "enum" not in font + assert font["x-widget"] == "font-selector" + + def test_the_picker_inherits_the_declared_size_ceiling(self): + """A bitmap font ignores font_size and renders at its own baked-in + size, so a field capped at 16 must not offer a 27px face.""" + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert font["x-options"]["maxFixedSize"] == 16 + + def test_the_users_existing_font_choice_stays_valid(self): + font = self._customization()["properties"]["score_text"]["properties"]["font"] + assert font["default"] == "PressStart2P-Regular.ttf" + assert font["type"] == "string" + + def test_the_input_schema_is_not_mutated(self): + expand_style_elements(HANDWRITTEN) + original = HANDWRITTEN["properties"]["customization"] + assert "x-widget" not in original + assert "enum" in original["properties"]["score_text"]["properties"]["font"] + + def test_no_style_blocks_means_no_expansion(self): + schema = {"properties": {"customization": {"type": "object", + "properties": {"favorite_result_colors": {"type": "object", + "properties": {"win_color": {"type": "array"}}}}}}} + assert expand_style_elements(schema) is schema + + +class TestAdoptedModes: + """Per-mode overrides stay opt-in: core cannot invent a plugin's list of + display modes. Declaring x-style-modes is the one line that unlocks them + for a hand-written block.""" + + def _live(self): + schema = copy.deepcopy(HANDWRITTEN) + schema["properties"]["customization"]["x-style-modes"] = ["live", "recent"] + return expand_style_elements(schema)["properties"]["customization"][ + "properties"]["modes"]["properties"]["live"]["properties"] + + def test_modes_are_not_invented(self): + assert "modes" not in self._customization_props() + + def _customization_props(self): + return expand_style_elements(HANDWRITTEN)["properties"]["customization"]["properties"] + + def test_declaring_modes_generates_them(self): + assert "score_text" in self._live() + + def test_mode_fields_are_nullable(self): + size = self._live()["score_text"]["properties"]["font_size"] + assert size["default"] is None and "null" in size["type"] + + def test_every_positionable_element_gets_per_mode_offsets(self): + """The two namespaces do not line up in a hand-written schema: this + one styles 'score_text' but positions 'score', and positions a logo + that has no style block at all. Keying the layout off the style + elements would have left both without a per-mode offset.""" + layout = self._live()["layout"]["properties"] + assert set(layout) == {"score", "home_logo"} + + def test_the_mode_font_picker_keeps_the_ceiling(self): + font = self._live()["score_text"]["properties"]["font"] + assert font["x-options"]["maxFixedSize"] == 16 + assert "null" in font["type"] diff --git a/test/test_element_visibility_align_scale.py b/test/test_element_visibility_align_scale.py new file mode 100644 index 00000000..171f9f4e --- /dev/null +++ b/test/test_element_visibility_align_scale.py @@ -0,0 +1,187 @@ +"""Visibility, alignment and scale reach the draw. + +The resolver has understood these three since the framework landed, but nothing +on the scoreboard path consumed them: an element could be marked hidden in the +web UI and still render. This covers the readers, the mixin accessors the draw +paths use, and the one shared logo-sizing seam. + +The rule throughout is that an untouched config takes exactly the path it took +before -- so each test that asserts an effect has a sibling asserting the +default is inert. +""" + +import logging +import sys +from pathlib import Path + +import pytest +from PIL import Image + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.common.logo_helper import LogoHelper # noqa: E402 +from src.common.sports_shared import SportsCoreSharedMixin # noqa: E402 +from src.element_style import ( # noqa: E402 + element_align, element_scale, element_visible) + +HIDDEN = {"customization": {"records": {"visible": False}}} +ALIGNED = {"customization": {"score_text": {"align": "right"}}} +SCALED = {"customization": {"layout": {"home_logo": {"scale": 0.5}}}} + + +class TestTheReaders: + def test_an_element_is_visible_unless_it_says_otherwise(self): + assert element_visible({}, "records") is True + assert element_visible(HIDDEN, "records") is False + + def test_a_mode_can_hide_what_the_base_shows(self): + cfg = {"customization": {"records": {"visible": True}, + "modes": {"recent": {"records": {"visible": False}}}}} + assert element_visible(cfg, "records") is True + assert element_visible(cfg, "records", mode="recent") is False + + def test_a_mode_that_says_nothing_inherits(self): + """None is the inherit sentinel and must not read as False.""" + cfg = {"customization": {"records": {"visible": False}, + "modes": {"live": {"records": {"visible": None}}}}} + assert element_visible(cfg, "records", mode="live") is False + + def test_alignment_is_unset_by_default(self): + assert element_align({}, "score_text") is None + assert element_align(ALIGNED, "score_text") == "right" + + def test_scale_reads_the_layout_block(self): + assert element_scale({}, "home_logo") == 1.0 + assert element_scale(SCALED, "home_logo") == 0.5 + + def test_scale_takes_a_per_mode_override(self): + cfg = {"customization": {"layout": {"home_logo": {"scale": 0.5}}, + "modes": {"live": {"layout": {"home_logo": {"scale": 2.0}}}}}} + assert element_scale(cfg, "home_logo", mode="live") == 2.0 + assert element_scale(cfg, "home_logo") == 0.5 + + @pytest.mark.parametrize("cfg", [ + None, {}, "nonsense", {"customization": "nonsense"}, + {"customization": {"records": "nonsense"}}, + {"customization": {"records": {"visible": "maybe"}}}, + ]) + def test_a_hostile_config_never_raises(self, cfg): + assert element_visible(cfg, "records") is True + assert element_align(cfg, "records") is None + assert element_scale(cfg, "records") == 1.0 + + +class _Draw: + """Records what would have been drawn.""" + + def __init__(self): + self.calls = [] + self.fontmode = "" + + def text(self, position, text, font=None, fill=None): + self.calls.append({"position": position, "text": text, "fill": fill}) + + +class _Host(SportsCoreSharedMixin): + def __init__(self, config, mode=None): + self.config = config + self.fonts = {} + self.logger = logging.getLogger("test") + self.SKIN_MODE = mode + + +class TestTheDrawPath: + def test_a_hidden_element_is_not_drawn_at_all(self): + draw = _Draw() + _Host(HIDDEN)._draw_text_with_outline( + draw, "9-1", (0, 0), None, element="records") + assert draw.calls == [], ( + "a hidden element must produce no draw, outline included") + + def test_a_visible_element_still_draws(self): + draw = _Draw() + _Host({})._draw_text_with_outline( + draw, "9-1", (0, 0), None, element="records") + assert draw.calls, "the default must be inert" + + def test_naming_the_element_resolves_its_colour(self): + draw = _Draw() + cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}} + _Host(cfg)._draw_text_with_outline( + draw, "21", (0, 0), None, element="score_text") + # Last call is the text itself; the earlier eight are the outline. + assert draw.calls[-1]["fill"] == (1, 2, 3) + + def test_an_explicit_fill_still_wins(self): + """Odds colours and the favourite-result tint mean something the + palette does not.""" + draw = _Draw() + cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}} + _Host(cfg)._draw_text_with_outline( + draw, "21", (0, 0), None, fill=(9, 9, 9), element="score_text") + assert draw.calls[-1]["fill"] == (9, 9, 9) + + def test_a_mode_hides_only_that_mode(self): + cfg = {"customization": {"modes": {"recent": {"records": {"visible": False}}}}} + recent, live = _Draw(), _Draw() + _Host(cfg, "recent")._draw_text_with_outline( + recent, "9-1", (0, 0), None, element="records") + _Host(cfg, "live")._draw_text_with_outline( + live, "9-1", (0, 0), None, element="records") + assert recent.calls == [] + assert live.calls + + +class TestAlignment: + def test_unset_leaves_the_caller_where_it_was(self): + """These draws carry per-sport nudges; a centre recomputed here would + not be the same pixel.""" + assert _Host({})._aligned_x("score_text", 20, 64, 22) == 22 + + def test_right_pushes_to_the_edge(self): + assert _Host(ALIGNED)._aligned_x("score_text", 20, 64, 22) == 44 + + def test_left_goes_to_zero(self): + cfg = {"customization": {"score_text": {"align": "left"}}} + assert _Host(cfg)._aligned_x("score_text", 20, 64, 22) == 0 + + def test_text_wider_than_the_panel_is_not_pushed_off(self): + assert _Host(ALIGNED)._aligned_x("score_text", 100, 64, 0) == 0 + + +class TestLogoScale: + @pytest.fixture + def logo(self, tmp_path): + path = tmp_path / "team.png" + Image.new("RGBA", (40, 40), (255, 0, 0, 255)).save(path) + return path + + def test_an_unscaled_load_is_unchanged(self, logo): + helper = LogoHelper(display_width=64, display_height=32) + plain = helper.load_logo("AAA", logo, 32, 32) + assert plain.size == (32, 32) + + def test_a_half_scale_logo_is_half_the_box(self, logo): + helper = LogoHelper(display_width=64, display_height=32) + small = helper.load_logo("AAA", logo, 32, 32, scale=0.5) + assert small.size == (16, 16) + + def test_scaling_up_grows_the_logo(self, logo): + """The fit rule never grows an image, so this only happens when the + user asks for it.""" + helper = LogoHelper(display_width=64, display_height=32) + big = helper.load_logo("AAA", logo, 64, 64, scale=2.0) + assert big.size[0] > 40 + + def test_two_scales_do_not_share_a_cache_entry(self, logo): + helper = LogoHelper(display_width=64, display_height=32) + full = helper.load_logo("AAA", logo, 32, 32) + half = helper.load_logo("AAA", logo, 32, 32, scale=0.5) + assert full.size == (32, 32) and half.size == (16, 16) + + @pytest.mark.parametrize("bad", [0, -1, None, "big", float("inf"), + float("nan")]) + def test_an_unusable_scale_is_ignored(self, logo, bad): + helper = LogoHelper(display_width=64, display_height=32) + assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32) diff --git a/test/test_sports_card.py b/test/test_sports_card.py index 8de04e47..0bf88f37 100644 --- a/test/test_sports_card.py +++ b/test/test_sports_card.py @@ -65,11 +65,37 @@ class TestColour: cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6) - def test_a_shared_face_gives_up_rather_than_guessing(self): - """One object used for two elements has no single right colour.""" + def test_a_shared_face_takes_the_one_colour_that_was_configured(self): + """A face shared by two elements used to go out white even when only + one of them had a colour set -- and a bitmap face is *always* shared, + because a freetype.Face cannot be re-instantiated to un-share it. That + is how an element in any of the 32 shipped BDF fonts silently lost the + colour its picker had offered all along. One configured colour among + the sharers is the only thing the user can have meant.""" shared = object() cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} - assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255) + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (4, 5, 6) + + def test_a_shared_face_still_gives_up_when_the_colours_disagree(self): + """Two different answers is the case with no right answer.""" + shared = object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}, + "team_name": {"text_color": [7, 8, 9]}}} + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (255, 255, 255) + + def test_sharers_that_agree_resolve_to_that_colour(self): + shared = object() + cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}, + "team_name": {"text_color": [4, 5, 6]}}} + assert C.font_color(cfg, {"score": shared, "team": shared}, + shared) == (4, 5, 6) + + def test_an_unconfigured_shared_face_keeps_the_default(self): + shared = object() + assert C.font_color({}, {"score": shared, "team": shared}, shared, + (9, 9, 9)) == (9, 9, 9) class TestFavourites: @@ -193,3 +219,122 @@ class TestTables: def test_month_and_weekday_tables_are_complete(self): assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7 + + +class TestUnshareElementFonts: + """Re-instantiated faces must match the ones they replace. + + ``unshare_element_fonts`` rebuilds a duplicate face purely so two elements + can be told apart by ``id()``. That is only safe while the rebuilt face + lays text out identically -- and PIL's two layout engines disagree on + fractional advances, which is why ``src.common.font_layout`` exists and + pins one. Rebuilding through bare ``ImageFont.truetype`` took the default + engine instead, so a re-instantiated face could measure differently from + the shared face it replaced. + """ + + def _face(self, size=10): + from src.common.font_layout import load_truetype + path = os.path.join("assets", "fonts", "PressStart2P-Regular.ttf") + return load_truetype(path, size) + + def test_rebuilt_face_goes_through_the_pinned_loader(self, log, monkeypatch): + """Asserted on the loader, not on the resulting engine value. + + PIL only selects Raqm when it is installed; where it is not, a bare + ``ImageFont.truetype`` returns BASIC too, so comparing engine values + passes on those machines whether or not the pin is honoured -- this + test did exactly that before it was rewritten. Spying on the pinned + loader fails on every machine when the pin is bypassed, which is the + point: the cross-machine mismatch font_layout exists to prevent + cannot be reproduced on a Raqm-less runner. + """ + import src.common.font_layout as fl + + # Build the face BEFORE patching: _face() loads through the same + # pinned loader, so patching first would let the fixture's own call + # satisfy the assertion and the test would pass either way. + shared = self._face() + + calls = [] + real = fl.load_truetype + + def spy(font, size, **kwargs): + calls.append((font, size)) + return real(font, size, **kwargs) + + monkeypatch.setattr(fl, "load_truetype", spy) + fonts = {"score": shared, "time": shared} + C.unshare_element_fonts(log, fonts) + assert fonts["time"] is not shared, "the duplicate should have been rebuilt" + assert calls, "the rebuild must go through the pinned loader" + + def test_rebuilt_face_keeps_the_shared_faces_layout_engine(self, log): + shared = self._face() + fonts = {"score": shared, "time": shared} + C.unshare_element_fonts(log, fonts) + assert fonts["time"].layout_engine == shared.layout_engine + + def test_rebuilt_face_measures_identically(self, log): + shared = self._face() + fonts = {"score": shared, "time": shared} + C.unshare_element_fonts(log, fonts) + text = "88-88" + assert (fonts["time"].getlength(text) + == shared.getlength(text)), "metrics must not shift" + + def test_distinct_faces_are_left_alone(self, log): + a, b = self._face(10), self._face(8) + fonts = {"score": a, "time": b} + C.unshare_element_fonts(log, fonts) + assert fonts["score"] is a and fonts["time"] is b + +class TestSharedElementReaders: + """The scoreboards' colour and offset reads now go through one + implementation. + + There were two copies of the colour read and three of the offset read. + They had already drifted: the scroll-card renderer carries a comment + noting it used to ignore offsets its own schema advertised. Sharing one + reader is what lets a fix (or an alias, or a per-mode override) reach + the full-screen scorebug and the scroll card together. + """ + + CONFIG = { + "customization": { + "score_text": {"text_color": [255, 200, 0]}, + "status": {"text_color": "#00ff00"}, + "layout": {"score": {"y_offset": -3}}, + "modes": { + "live": {"score_text": {"text_color": [255, 0, 0]}, + "layout": {"score": {"y_offset": 7}}}, + }, + }, + } + + def test_colour_reads_the_configured_value(self, log): + assert C.element_color(self.CONFIG, "score_text") == (255, 200, 0) + + def test_hex_colours_are_still_accepted(self, log): + """These readers have always taken '#RRGGBB' as well as [r, g, b], + so the shared one had to learn it rather than the callers losing it.""" + assert C.element_color(self.CONFIG, "status") == (0, 255, 0) + + def test_colour_resolves_through_an_alias(self, log): + """The style block says status_text where this config says status.""" + assert C.element_color(self.CONFIG, "status_text") == (0, 255, 0) + + def test_a_mode_overrides_the_colour(self, log): + assert C.element_color(self.CONFIG, "score_text", + mode="live") == (255, 0, 0) + + def test_an_unset_element_gets_the_default(self, log): + assert C.element_color(self.CONFIG, "nothing", (1, 2, 3)) == (1, 2, 3) + + @pytest.mark.parametrize("config", [ + None, {}, "nonsense", {"customization": "nonsense"}, + {"customization": {"score_text": "nonsense"}}, + {"customization": {"score_text": {"text_color": "not a colour"}}}, + ]) + def test_a_hostile_config_gives_the_default(self, config, log): + assert C.element_color(config, "score_text", (9, 9, 9)) == (9, 9, 9) diff --git a/test/test_sports_game_renderer.py b/test/test_sports_game_renderer.py index 50590005..14046d2c 100644 --- a/test/test_sports_game_renderer.py +++ b/test/test_sports_game_renderer.py @@ -256,3 +256,48 @@ class TestContract: h._draw_upcoming_center(_draw(), {}) h._draw_upcoming_game_status(_draw(), {}) h.set_rankings_cache({}) + + +class TestLayoutOffsetsAreShared: + """The scroll/Vegas card reads offsets through the same function the + full-screen scorebug does. + + This renderer already carries a comment about having ignored offsets its + own schema advertised -- it was the third copy of this read. Sharing one + means the alias handling and per-mode overrides land here too, without + this file knowing about either. + """ + + CONFIG = {"customization": { + "layout": {"score": {"y_offset": -3}}, + "modes": {"live": {"layout": {"score": {"y_offset": 7}}}}, + }} + + def _host(self, config, mode=None): + attrs = {"config": config} + if mode: + attrs["SKIN_MODE"] = mode + return type("Card", (SportsGameRendererMixin,), attrs)() + + def test_it_reads_the_configured_offset(self): + assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3 + + def test_it_resolves_through_an_alias(self): + """A caller asking for score_text finds the layout.score the user + configured -- the two namespaces disagree by convention.""" + assert self._host(self.CONFIG)._layout_offset( + "score_text", "y_offset") == -3 + + def test_skin_mode_selects_the_per_mode_offset(self): + assert self._host(self.CONFIG, "live")._layout_offset( + "score", "y_offset") == 7 + + def test_a_host_without_a_skin_mode_gets_the_base(self): + assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3 + + @pytest.mark.parametrize("written", [ + float("inf"), float("nan"), True, "bad", None, [], {}, + ]) + def test_nonsense_degrades_to_the_default(self, written): + config = {"customization": {"layout": {"score": {"y_offset": written}}}} + assert self._host(config)._layout_offset("score", "y_offset", 4) == 4 diff --git a/test/test_sports_shared.py b/test/test_sports_shared.py index bd402ae3..5ef97d40 100644 --- a/test/test_sports_shared.py +++ b/test/test_sports_shared.py @@ -374,3 +374,91 @@ class TestPluginDirIsToldNotDeduced: assert host._schema_font_size("score") == 16, ( "without the schema this is None, which is what made a configured " "size look user-chosen and skipped the grid snap") +class TestUnshareKeepsTheLayoutEngine: + """``_unshare_element_fonts`` rebuilds a face; the rebuild must be pinned. + + This body did move verbatim from the plugins, but it is the one that + rebuilds a font, and it rebuilt through bare ``ImageFont.truetype`` -- + taking PIL's default layout engine rather than the one + ``src.common.font_layout`` pins. Raqm and Basic disagree on fractional + advances, so a re-instantiated face could measure differently from the + shared face it replaced, on any machine where Raqm is installed. That is + invisible on a Raqm-less runner, which is why this asserts on the loader + rather than on the resulting engine value. + """ + + def _host(self): + import logging + return type("H", (SportsCoreSharedMixin,), { + "logger": logging.getLogger("test_sports_shared")})() + + def _face(self, size=10): + from src.common.font_layout import load_truetype + return load_truetype( + os.path.join("assets", "fonts", "PressStart2P-Regular.ttf"), size) + + def test_rebuild_goes_through_the_pinned_loader(self, monkeypatch): + import src.common.font_layout as fl + + # Built before patching: _face() uses the same loader, so patching + # first would let the fixture's own call satisfy the assertion. + shared = self._face() + + calls = [] + real = fl.load_truetype + + def spy(font, size, **kwargs): + calls.append((font, size)) + return real(font, size, **kwargs) + + monkeypatch.setattr(fl, "load_truetype", spy) + fonts = {"score": shared, "time": shared} + self._host()._unshare_element_fonts(fonts) + assert fonts["time"] is not shared, "the duplicate should have been rebuilt" + assert calls, "the rebuild must go through the pinned loader" + + def test_rebuilt_face_measures_like_the_one_it_replaced(self): + shared = self._face() + fonts = {"score": shared, "time": shared} + self._host()._unshare_element_fonts(fonts) + assert fonts["time"].getlength("88-88") == shared.getlength("88-88") + + +class TestPromotedLayoutOffset: + """_get_layout_offset moved here so every scoreboard reads offsets the + way the scroll card does. + + Each plugin still carries its own copy in its bundled sports.py, which + wins by MRO. That is the migration property: adopting this is a + deletion in the plugin, and until that deletion nothing changes. + """ + + CONFIG = {"customization": { + "layout": {"score": {"y_offset": -3}}, + "modes": {"recent": {"layout": {"score": {"y_offset": 9}}}}, + }} + + def _host(self, **attrs): + return type("H", (SportsCoreSharedMixin,), + dict({"config": self.CONFIG}, **attrs))() + + def test_it_reads_the_configured_offset(self): + assert self._host()._get_layout_offset("score", "y_offset") == -3 + + def test_it_resolves_through_an_alias(self): + """What a plugin gains by deleting its own copy: the style block + says score_text where the layout block says score.""" + assert self._host()._get_layout_offset("score_text", "y_offset") == -3 + + def test_skin_mode_selects_the_per_mode_offset(self): + assert self._host(SKIN_MODE="recent")._get_layout_offset( + "score", "y_offset") == 9 + + def test_a_plugins_own_copy_still_wins(self): + """Until a plugin deletes its copy, this changes nothing for it.""" + host = self._host( + _get_layout_offset=lambda self, element, axis, default=0: 99) + assert host._get_layout_offset("score", "y_offset") == 99 + + def test_an_unset_offset_is_the_default(self): + assert self._host()._get_layout_offset("nothing", "y_offset", 5) == 5 diff --git a/test/test_web_api.py b/test/test_web_api.py index 3ed16caf..9a1bd1f7 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -814,28 +814,21 @@ class TestFontsAPI: data = json.loads(response.data) assert 'tokens' in data.get('data', {}) or 'data' in data - def test_get_fonts_overrides(self, client): - """Test getting font overrides.""" - response = client.get('/api/v3/fonts/overrides') - - assert response.status_code == 200 - data = json.loads(response.data) - assert 'overrides' in data.get('data', {}) or 'data' in data - - def test_save_fonts_overrides(self, client): - """Test saving font overrides.""" - request_data = { - 'weather': 'small', - 'clock': 'regular' - } - - response = client.post( - '/api/v3/fonts/overrides', - data=json.dumps(request_data), - content_type='application/json' - ) - - assert response.status_code == 200 + def test_the_font_override_endpoints_are_gone(self, client): + """They reported success without doing anything: GET returned a + hardcoded {}, POST and DELETE saved and deleted nothing. The panel + they served offered eleven element keys -- nfl.live.score, + clock.time -- that no plugin has ever read, so wiring them to the + real FontManager methods would still have changed nothing on the + panel. Per-element font choice lives in each plugin's own config + editor now, against the elements that plugin actually has.""" + # 405, not 404: the path still matches DELETE /fonts/, + # which now reads "overrides" as a font name. Nothing is routed to a + # handler for GET or POST, which is what matters here. + assert client.get('/api/v3/fonts/overrides').status_code == 405 + assert client.post('/api/v3/fonts/overrides', + data=json.dumps({}), + content_type='application/json').status_code == 405 class TestAPIErrorHandling: diff --git a/test/web_interface/test_api_v3_helpers.py b/test/web_interface/test_api_v3_helpers.py index dc21864b..fbd142bf 100644 --- a/test/web_interface/test_api_v3_helpers.py +++ b/test/web_interface/test_api_v3_helpers.py @@ -25,6 +25,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402 _parse_form_value, _get_schema_property, _set_nested_value, + _SKIP_FIELD, ) @@ -221,12 +222,21 @@ class TestSetNestedValue: _set_nested_value(config, "fifa.world.enabled", True) assert config == {"fifa.world": {"enabled": True}} - def test_none_does_not_overwrite_existing(self): + def test_none_overwrites_existing(self): + # Regression: None is a real value here (the per-mode "inherit the + # base" override for a nullable field, or a blank indexed color + # channel) and must replace whatever was already stored. Only the + # _SKIP_FIELD sentinel means "leave it alone" -- see the next test. config = {"a": 1} _set_nested_value(config, "a", None) - assert config == {"a": 1} + assert config == {"a": None} def test_none_sets_missing_key(self): config = {} _set_nested_value(config, "a", None) assert config == {"a": None} + + def test_skip_field_does_not_overwrite_existing(self): + config = {"a": 1} + _set_nested_value(config, "a", _SKIP_FIELD) + assert config == {"a": 1} diff --git a/test/web_interface/test_partial_save_booleans.py b/test/web_interface/test_partial_save_booleans.py new file mode 100644 index 00000000..4a4ff984 --- /dev/null +++ b/test/web_interface/test_partial_save_booleans.py @@ -0,0 +1,249 @@ +"""A save must only switch off checkboxes the caller actually saw. + +An HTML checkbox posts nothing when unchecked, so the save route walks the +schema and forces every boolean missing from the form to ``False``. That is +right for the rendered form and wrong for everything else: a caller that posts +four fields -- a script, the MQTT bridge, a curl against the documented +endpoint -- never rendered a checkbox, and reading its silence as "all off" +turns a one-field save into a mass disable. + +That is not hypothetical. Posting four ``customization.*`` keys to a live +device switched off ``nfl.enabled``, ``ncaa_fb.enabled`` and every display-mode +toggle in one request. + +The fix keeps both halves working, so both are pinned here: + +* the rendered form reports the sections it drew (``__rendered_section``), and + inside those, an absent checkbox still means unchecked -- including a section + whose only fields are checkboxes that are all off, which no heuristic could + recover; +* a post with no such marker only touches objects it actually posted a field + from. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask +from werkzeug.datastructures import MultiDict + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "nfl": { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "live_priority": {"type": "boolean", "default": True}, + "update_interval": {"type": "integer", "default": 3600}, + # A section with nothing but checkboxes: when they are all + # unchecked it posts no keys at all. + "display_modes": { + "type": "object", + "properties": { + "show_live": {"type": "boolean", "default": True}, + "show_recent": {"type": "boolean", "default": True}, + }, + }, + }, + }, + "customization": { + "type": "object", + "properties": { + "score_text": { + "type": "object", + "properties": { + "visible": {"type": "boolean", "default": True}, + "text_color": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 3, "maxItems": 3, + "default": [255, 255, 255], + }, + }, + }, + }, + }, + }, +} + +STORED = { + "enabled": True, + "nfl": { + "enabled": True, + "live_priority": True, + "update_interval": 3600, + "display_modes": {"show_live": True, "show_recent": True}, + }, + "customization": {"score_text": {"visible": True, + "text_color": [255, 255, 255]}}, +} + + +class _SaveOk: + status = type("S", (), {"value": "success"})() + message = None + + +@pytest.fixture +def post(tmp_path): + """POST form data to the real save route; yields the stored config.""" + from src.plugin_system.schema_manager import SchemaManager + from web_interface.blueprints import api_v3 as a + + originals = {k: getattr(a.api_v3, k, None) + for k in ("config_manager", "schema_manager", "plugin_manager")} + + pdir = tmp_path / "plugins" / "demo" + pdir.mkdir(parents=True) + (pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + + store = {"demo": json.loads(json.dumps(STORED))} + + cm = MagicMock() + cm.load_config.side_effect = lambda: json.loads(json.dumps(store)) + cm.get_config_path.return_value = str(tmp_path / "config.json") + + def _save(cfg, **_kw): + store.clear() + store.update(cfg) + return _SaveOk() + + cm.save_config_atomic.side_effect = _save + + a.api_v3.config_manager = cm + a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + pm = MagicMock() + pm.plugins = {} + pm.get_plugin.return_value = None + a.api_v3.plugin_manager = pm + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(a.api_v3, url_prefix="/api/v3") + client = app.test_client() + + def _post(form): + # A list of pairs, not a dict: __rendered_section repeats, and that + # repetition is the whole point of the marker. + data = MultiDict(form) if isinstance(form, list) else form + resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=data) + return resp, store.get("demo", {}) + + try: + yield _post + finally: + for k, v in originals.items(): + setattr(a.api_v3, k, v) + + +# What the style editor posts when someone changes one colour and nothing else. +PARTIAL = [ + ("customization.score_text.text_color.0", "255"), + ("customization.score_text.text_color.1", "0"), + ("customization.score_text.text_color.2", "255"), +] + + +class TestAPartialPost: + def test_it_does_not_disable_another_section(self, post): + """The live-device regression, in one assertion.""" + _post = post + resp, cfg = _post(list(PARTIAL)) + assert resp.status_code == 200, resp.get_json() + assert cfg["nfl"]["enabled"] is True + assert cfg["nfl"]["live_priority"] is True + assert cfg["nfl"]["display_modes"] == {"show_live": True, + "show_recent": True} + + def test_it_still_applies_what_was_posted(self, post): + _post = post + _resp, cfg = _post(list(PARTIAL)) + assert cfg["customization"]["score_text"]["text_color"] == [255, 0, 255] + + def test_it_leaves_a_sibling_checkbox_it_never_mentioned(self, post): + """`visible` lives beside the colour, but the post carried no field + from that object -- only from the colour array inside it.""" + _post = post + _resp, cfg = _post(list(PARTIAL)) + assert cfg["customization"]["score_text"]["visible"] is True + + def test_it_does_clear_a_box_in_an_object_it_posted_to(self, post): + """Evidence, not guesswork: a field from `display_modes` was posted, + so the checkbox missing from that same object really is unchecked.""" + _post = post + _resp, cfg = _post([("nfl.display_modes.show_live", "true")]) + assert cfg["nfl"]["display_modes"] == {"show_live": True, + "show_recent": False} + assert cfg["nfl"]["enabled"] is True + + +class TestTheRenderedForm: + """With section markers the old behaviour must be exactly preserved.""" + + def test_an_unchecked_box_is_still_cleared(self, post): + _post = post + resp, cfg = _post([("__rendered_section", "nfl"), + ("__rendered_section", "customization"), + ("enabled", "true"), + ("nfl.update_interval", "3600"), + ("nfl.display_modes.show_live", "true")]) + assert resp.status_code == 200, resp.get_json() + assert cfg["nfl"]["enabled"] is False + assert cfg["nfl"]["live_priority"] is False + assert cfg["nfl"]["display_modes"]["show_recent"] is False + + def test_a_section_of_only_checkboxes_all_unchecked_is_cleared(self, post): + """It posts no keys of its own, so only the marker can vouch for it.""" + _post = post + _resp, cfg = _post([("__rendered_section", "nfl"), + ("nfl.update_interval", "3600")]) + assert cfg["nfl"]["display_modes"] == {"show_live": False, + "show_recent": False} + + def test_a_section_the_form_did_not_draw_is_untouched(self, post): + _post = post + _resp, cfg = _post([("__rendered_section", "nfl"), + ("nfl.update_interval", "3600")]) + assert cfg["customization"]["score_text"]["visible"] is True + + def test_the_marker_is_not_written_into_the_config(self, post): + """It describes the submission; it is not a config path. Unknown form + keys are otherwise stored verbatim.""" + _post = post + _resp, cfg = _post([("__rendered_section", "nfl"), + ("nfl.update_interval", "3600")]) + assert "__rendered_section" not in cfg + assert not [k for k in cfg if k.startswith("__")] + + +class TestTheFormEmitsTheMarker: + """The fix only works if the rendered form actually reports its sections.""" + + def test_every_top_level_section_is_reported(self, tmp_path): + import re + + from jinja2 import Environment, FileSystemLoader + + templates = PROJECT_ROOT / "web_interface" / "templates" + env = Environment(loader=FileSystemLoader(str(templates))) + source = (templates / "v3" / "partials" / "plugin_config.html").read_text( + encoding="utf-8") + assert '__rendered_section' in source, ( + "the form must tell the save route which sections it drew") + # It has to cover the advanced tier too, or collapsing a section into + # Advanced Settings would quietly stop its checkboxes clearing. + marker = re.search(r"\{% for key in ([^%]+) %\}\s*" + r" is + # created client-side; what the server emits is the container and + # the key handed to the widget. + assert "name: 'customization.title_text.font'" in body + + def test_the_colour_field_renders_as_rgb_inputs(self, render): + body = render(COMPACT_SCHEMA) + for channel in range(3): + assert f'name="customization.title_text.text_color.{channel}"' in body + + def test_the_font_field_uses_the_font_selector_widget(self, render): + body = render(COMPACT_SCHEMA) + assert "LEDMatrixWidgets.get('font-selector')" in body + + def test_layout_offsets_appear(self, render): + body = render(COMPACT_SCHEMA) + assert 'name="customization.layout.title_text.x_offset"' in body + assert 'name="customization.layout.title_text.y_offset"' in body + + def test_declared_modes_appear(self, render): + body = render(COMPACT_SCHEMA) + assert 'name="customization.modes.live.title_text.font_size"' in body + assert 'name="customization.modes.recent.title_text.font_size"' in body + + def test_mode_layout_offsets_appear(self, render): + body = render(COMPACT_SCHEMA) + assert ('name="customization.modes.live.layout.title_text.y_offset"' + in body) + + +class TestWithoutASchemaManager: + """Several callers register this blueprint without a schema_manager; the + raw read stays as their fallback.""" + + def test_a_manual_block_still_renders(self, render): + body = render(MANUAL_SCHEMA, with_schema_manager=False) + assert 'name="customization.title_text.font_size"' in body + + def test_a_compact_declaration_renders_nothing_without_expansion(self, render): + """Documents precisely what the fix buys: the fallback path cannot + expand, so this is what every plugin using the compact form saw.""" + body = render(COMPACT_SCHEMA, with_schema_manager=False) + assert 'name="customization.title_text.font"' not in body + + +class TestRenderAndSaveAgree: + def test_the_form_posts_names_the_save_path_recognises(self, render): + """The rendered field names must resolve against the same expanded + schema the save route looks them up in.""" + from web_interface.blueprints.api_v3 import _get_schema_property + from src.element_style import expand_style_elements + + render(COMPACT_SCHEMA) # ensure it renders at all + schema = expand_style_elements(COMPACT_SCHEMA) + for path in ("customization.title_text.font", + "customization.title_text.font_size", + "customization.layout.title_text.x_offset", + "customization.modes.live.title_text.font_size"): + assert _get_schema_property(schema, path) is not None, path diff --git a/test/web_interface/test_plugin_widget_route.py b/test/web_interface/test_plugin_widget_route.py new file mode 100644 index 00000000..330fb1cf --- /dev/null +++ b/test/web_interface/test_plugin_widget_route.py @@ -0,0 +1,261 @@ +"""The server half of the plugin-supplied widget feature. + +``LEDMatrixWidgets.loadPluginWidget`` (static/v3/js/widgets/plugin-loader.js) +has always fetched ``/static/plugin-widgets//.js``, and +docs/widget-guide.md has always documented that path, but nothing served it -- +so a plugin could declare a widget, ship the file, and still never load it. +soccer-scoreboard has shipped exactly that since August. + +The load-bearing property here is that the manifest is the allowlist. A plugin +directory is attacker-influenced in the sense that matters -- plugins are +user-installed, and the store installs them -- so "serve files from the plugin +directory" would publish everything a plugin ships. Only a widget the manifest +declares is reachable, and only from that plugin's widgets/ directory. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +WIDGET_BODY = "(function(){ window.LEDMatrixWidgets.register('custom-leagues', {}); })();\n" + +_UNSET = object() + + +def _make_plugin(plugins_dir, plugin_id="soccer-scoreboard", widgets=None, + files=None): + """Write a plugin directory with a manifest and a widgets/ folder.""" + d = plugins_dir / plugin_id + (d / "widgets").mkdir(parents=True) + manifest = {"id": plugin_id, "name": plugin_id, "version": "1.0.0"} + if widgets is not None: + manifest["widgets"] = widgets + (d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + for name, body in (files or {}).items(): + (d / "widgets" / name).write_text(body, encoding="utf-8") + return d + + +@pytest.fixture +def make_client(tmp_path): + """Build a test client whose plugin manager points at a temp plugins dir. + + pages_v3 is a module-level Blueprint singleton shared across the test + process, so the original plugin_manager is restored on teardown. + """ + from web_interface.blueprints import pages_v3 as pv + + original_pm = getattr(pv.pages_v3, "plugin_manager", None) + + def _build(plugins_dir=None, plugin_manager=_UNSET): + base = PROJECT_ROOT / "web_interface" + app = Flask(__name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static")) + app.config["TESTING"] = True + + if plugin_manager is _UNSET: + plugin_manager = MagicMock() + plugin_manager.plugins_dir = str(plugins_dir or tmp_path) + pv.pages_v3.plugin_manager = plugin_manager + + app.register_blueprint(pv.pages_v3, url_prefix="") + return app.test_client() + + try: + yield _build + finally: + pv.pages_v3.plugin_manager = original_pm + + +URL = "/static/plugin-widgets/{}/{}.js" + + +class TestDeclaredWidgetIsServed: + def test_a_declared_widget_is_served(self, tmp_path, make_client): + _make_plugin(tmp_path, + widgets=[{"name": "custom-leagues", + "script": "custom-leagues.js"}], + files={"custom-leagues.js": WIDGET_BODY}) + r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues")) + assert r.status_code == 200 + assert r.get_data(as_text=True) == WIDGET_BODY + + def test_it_is_served_as_javascript(self, tmp_path, make_client): + """The loader uses dynamic import(); a wrong MIME type is refused.""" + _make_plugin(tmp_path, + widgets=[{"name": "custom-leagues", + "script": "custom-leagues.js"}], + files={"custom-leagues.js": WIDGET_BODY}) + r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues")) + assert "javascript" in r.headers["Content-Type"] + + def test_script_defaults_to_the_widget_name(self, tmp_path, make_client): + _make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}) + r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues")) + assert r.status_code == 200 + + def test_the_ledmatrix_prefix_fallback_resolves(self, tmp_path, make_client): + """PluginManager resolves 'music' to 'ledmatrix-music'; so must this.""" + _make_plugin(tmp_path, plugin_id="ledmatrix-music", + widgets=[{"name": "deck", "script": "deck.js"}], + files={"deck.js": WIDGET_BODY}) + r = make_client().get(URL.format("music", "deck")) + assert r.status_code == 200 + + +class TestTheManifestIsTheAllowlist: + def test_an_undeclared_file_in_widgets_is_not_served(self, tmp_path, make_client): + """The whole point: shipping a file does not publish it.""" + _make_plugin(tmp_path, widgets=[], + files={"secrets.js": "const KEY='hunter2';"}) + r = make_client().get(URL.format("soccer-scoreboard", "secrets")) + assert r.status_code == 404 + assert "hunter2" not in r.get_data(as_text=True) + + def test_a_manifest_with_no_widgets_key_serves_nothing(self, tmp_path, make_client): + _make_plugin(tmp_path, files={"anything.js": WIDGET_BODY}) + r = make_client().get(URL.format("soccer-scoreboard", "anything")) + assert r.status_code == 404 + + def test_a_declared_widget_whose_file_is_missing_is_404(self, tmp_path, make_client): + _make_plugin(tmp_path, widgets=[{"name": "ghost", "script": "ghost.js"}]) + r = make_client().get(URL.format("soccer-scoreboard", "ghost")) + assert r.status_code == 404 + + def test_a_malformed_manifest_serves_nothing(self, tmp_path, make_client): + d = tmp_path / "broken" + (d / "widgets").mkdir(parents=True) + (d / "manifest.json").write_text("{not json", encoding="utf-8") + (d / "widgets" / "w.js").write_text(WIDGET_BODY, encoding="utf-8") + assert make_client().get(URL.format("broken", "w")).status_code == 404 + + +class TestPathTraversal: + @pytest.mark.parametrize("plugin_id", ["../etc", "..%2f..", "a/b", "a\\b", ""]) + def test_a_hostile_plugin_id_never_reaches_the_filesystem( + self, plugin_id, tmp_path, make_client): + r = make_client().get(URL.format(plugin_id, "custom-leagues")) + assert r.status_code in (400, 404), r.status_code + + @pytest.mark.parametrize("widget", ["../manifest", "..%2fsecret", "a/b"]) + def test_a_hostile_widget_name_never_reaches_the_filesystem( + self, widget, tmp_path, make_client): + _make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}) + r = make_client().get(URL.format("soccer-scoreboard", widget)) + assert r.status_code in (400, 404), r.status_code + + def test_a_manifest_cannot_escape_the_widgets_directory(self, tmp_path, make_client): + """A hostile manifest is the traversal vector the URL allowlist can't + cover: the script name comes from the plugin, not the request.""" + (tmp_path / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8") + _make_plugin(tmp_path, + widgets=[{"name": "evil", "script": "../../loot.js"}]) + r = make_client().get(URL.format("soccer-scoreboard", "evil")) + assert r.status_code == 404 + assert "hunter2" not in r.get_data(as_text=True) + + +class TestDegradation: + def test_an_unknown_plugin_is_404(self, tmp_path, make_client): + assert make_client().get(URL.format("nope", "w")).status_code == 404 + + def test_no_plugin_manager_is_503(self, make_client): + r = make_client(plugin_manager=None).get(URL.format("any", "w")) + assert r.status_code == 503 + + +def _schema_with_widget(widget_name): + return { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": False}, + "leagues": {"type": "string", "default": "eng.1", + "x-widget": widget_name}, + }, + } + + +@pytest.fixture +def config_form(tmp_path): + """Render a plugin's config partial with a temp plugin on disk.""" + from web_interface.blueprints import pages_v3 as pv + + orig_pm = getattr(pv.pages_v3, "plugin_manager", None) + orig_cm = getattr(pv.pages_v3, "config_manager", None) + + def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None, + files=None): + d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files) + (d / "config_schema.json").write_text( + json.dumps(schema or {"type": "object", "properties": {}}), + encoding="utf-8") + + pm = MagicMock() + pm.plugins_dir = str(tmp_path) + pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id} + pm.get_plugin.return_value = None + pv.pages_v3.plugin_manager = pm + + cm = MagicMock() + cm.load_config.return_value = {plugin_id: {"enabled": True}} + pv.pages_v3.config_manager = cm + + base = PROJECT_ROOT / "web_interface" + app = Flask(__name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static")) + app.config["TESTING"] = True + app.register_blueprint(pv.pages_v3, url_prefix="") + return app.test_client().get(f"/partials/plugin-config/{plugin_id}") + + try: + yield _render + finally: + pv.pages_v3.plugin_manager = orig_pm + pv.pages_v3.config_manager = orig_cm + + +class TestTheFormRequestsPluginWidgets: + """Without this the feature is still dead: the route can serve a widget, + but nothing ever asks for one. The server-side form only knows a hardcoded + list of core widget names, so a plugin's own x-widget fell through to a + plain text input and was never fetched.""" + + def test_an_unknown_widget_name_triggers_a_plugin_load(self, config_form): + r = config_form(schema=_schema_with_widget("custom-leagues"), + widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}) + assert r.status_code == 200 + body = r.get_data(as_text=True) + assert "ensureWidget" in body + assert '"custom-leagues"' in body + + def test_the_text_input_remains_as_the_fallback(self, config_form): + """A widget that fails to load must not cost the user their value.""" + r = config_form(schema=_schema_with_widget("custom-leagues"), + widgets=[{"name": "custom-leagues"}], + files={"custom-leagues.js": WIDGET_BODY}) + body = r.get_data(as_text=True) + assert 'name="leagues"' in body + assert 'value="eng.1"' in body + + def test_a_plain_string_field_asks_for_no_widget(self, config_form): + r = config_form(schema={"type": "object", "properties": { + "leagues": {"type": "string", "default": "eng.1"}}}) + assert "ensureWidget" not in r.get_data(as_text=True) + + def test_a_core_widget_does_not_take_the_plugin_path(self, config_form): + r = config_form(schema=_schema_with_widget("font-selector")) + body = r.get_data(as_text=True) + assert "ensureWidget" not in body + assert "LEDMatrixWidgets.get('font-selector')" in body diff --git a/test/web_interface/test_style_editor_extra_fields.py b/test/web_interface/test_style_editor_extra_fields.py new file mode 100644 index 00000000..ad823f4c --- /dev/null +++ b/test/web_interface/test_style_editor_extra_fields.py @@ -0,0 +1,212 @@ +"""visible / align / scale, from the form the widget renders to the style the +display resolves. + +These three are the difference between "restyle the text" and "lay the card +out", and each has a way of going quietly wrong in a form post: + +- ``visible`` is a boolean, and an unchecked checkbox posts nothing at all, + so the widget carries the value in a hidden field. The save path has to + turn those strings into real booleans, or config.json grows "false" + strings that are truthy everywhere they are read. +- ``align`` is an enum, so an invalid value must not reach the renderer. +- ``scale`` is a float living in the layout block rather than the element + block, because a logo has a scale and no font. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "customization": { + "type": "object", + "x-style-modes": ["live"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "visible": True, + "align": {"default": "center"}, + "offsets": True, + }, + "home_logo": { + "title": "Home Logo", + "offsets": True, + "visible": True, + "scale": {"default": 1.0, "min": 0.25, "max": 4}, + }, + }, + }, + }, +} + + +class _SaveOk: + status = type("S", (), {"value": "success"})() + message = None + + +@pytest.fixture +def post(tmp_path): + from src.plugin_system.schema_manager import SchemaManager + from web_interface.blueprints import api_v3 as a + + originals = {k: getattr(a.api_v3, k, None) + for k in ("config_manager", "schema_manager", "plugin_manager")} + + pdir = tmp_path / "plugins" / "demo" + pdir.mkdir(parents=True) + (pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + + store = {"demo": {"enabled": True}} + cm = MagicMock() + cm.load_config.side_effect = lambda: json.loads(json.dumps(store)) + cm.get_config_path.return_value = str(tmp_path / "config.json") + + def _save(cfg, **_kw): + store.clear() + store.update(cfg) + return _SaveOk() + + cm.save_config_atomic.side_effect = _save + + a.api_v3.config_manager = cm + a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + pm = MagicMock() + pm.plugins = {} + pm.get_plugin.return_value = None + a.api_v3.plugin_manager = pm + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(a.api_v3, url_prefix="/api/v3") + client = app.test_client() + + def _post(extra): + form = { + "enabled": "true", + "customization.score_text.font": "PressStart2P-Regular.ttf", + "customization.score_text.font_size": "10", + "customization.score_text.text_color.0": "255", + "customization.score_text.text_color.1": "255", + "customization.score_text.text_color.2": "255", + "customization.score_text.visible": "true", + "customization.score_text.align": "center", + "customization.layout.score_text.x_offset": "0", + "customization.layout.score_text.y_offset": "0", + "customization.home_logo.visible": "true", + "customization.layout.home_logo.x_offset": "0", + "customization.layout.home_logo.y_offset": "0", + "customization.layout.home_logo.scale": "1", + } + form.update(extra) + resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form) + return resp, store.get("demo", {}) + + def _style(cfg, element="score_text", mode=None): + from src.element_style import (ElementStyleResolver, + defaults_from_schema_file) + defaults = defaults_from_schema_file(str(pdir / "config_schema.json")) + return ElementStyleResolver(cfg, defaults, mode=mode).style( + element, classic_font="PressStart2P-Regular.ttf", classic_size=10, + classic_color=(255, 255, 255)) + + try: + yield _post, _style + finally: + for k, v in originals.items(): + setattr(a.api_v3, k, v) + + +class TestVisible: + def test_a_checked_box_stores_a_real_boolean(self, post): + _post, _ = post + resp, cfg = _post({}) + assert resp.status_code == 200, resp.get_json() + stored = cfg["customization"]["score_text"]["visible"] + assert stored is True, f"stored {stored!r}, not a bool" + + def test_hiding_an_element_survives_the_round_trip(self, post): + _post, _style = post + resp, cfg = _post({"customization.score_text.visible": "false"}) + assert resp.status_code == 200, resp.get_json() + assert cfg["customization"]["score_text"]["visible"] is False + assert _style(cfg).visible is False + + def test_the_default_is_not_read_as_a_choice(self, post): + """The save flow writes the schema default in either way.""" + _post, _style = post + _resp, cfg = _post({}) + assert _style(cfg).visible is True + + +class TestAlign: + def test_a_choice_reaches_the_display(self, post): + _post, _style = post + resp, cfg = _post({"customization.score_text.align": "right"}) + assert resp.status_code == 200, resp.get_json() + assert _style(cfg).align == "right" + + def test_the_schema_default_resolves_to_no_preference(self, post): + """'center' is what the schema declares, so the plugin keeps doing + whatever it already did rather than being told to centre.""" + _post, _style = post + _resp, cfg = _post({}) + assert _style(cfg).align is None + + def test_an_invalid_alignment_is_rejected_on_save(self, post): + _post, _ = post + resp, _cfg = _post({"customization.score_text.align": "sideways"}) + assert resp.status_code == 400 + + +class TestScale: + def test_a_logo_scale_saves_into_the_layout_block(self, post): + _post, _style = post + resp, cfg = _post({"customization.layout.home_logo.scale": "2.5"}) + assert resp.status_code == 200, resp.get_json() + assert cfg["customization"]["layout"]["home_logo"]["scale"] == 2.5 + assert _style(cfg, "home_logo").scale == 2.5 + + def test_a_scale_outside_the_declared_range_is_rejected(self, post): + _post, _ = post + resp, _cfg = _post({"customization.layout.home_logo.scale": "99"}) + assert resp.status_code == 400 + + def test_an_element_without_a_declared_scale_stays_neutral(self, post): + _post, _style = post + _resp, cfg = _post({}) + assert _style(cfg, "score_text").scale == 1.0 + + +class TestPerMode: + def test_a_mode_can_hide_what_the_base_shows(self, post): + _post, _style = post + resp, cfg = _post({"customization.modes.live.score_text.visible": "false", + "customization.modes.live.score_text.align": "", + "customization.modes.live.score_text.font": "", + "customization.modes.live.score_text.font_size": ""}) + assert resp.status_code == 200, resp.get_json() + assert _style(cfg).visible is True + assert _style(cfg, mode="live").visible is False + + def test_a_blank_mode_boolean_means_inherit(self, post): + _post, _style = post + resp, cfg = _post({"customization.score_text.visible": "false", + "customization.modes.live.score_text.visible": ""}) + assert resp.status_code == 200, resp.get_json() + assert cfg["customization"]["modes"]["live"]["score_text"]["visible"] is None + assert _style(cfg, mode="live").visible is False, "inherits the base" diff --git a/test/web_interface/test_style_editor_save_roundtrip.py b/test/web_interface/test_style_editor_save_roundtrip.py new file mode 100644 index 00000000..50f91a6f --- /dev/null +++ b/test/web_interface/test_style_editor_save_roundtrip.py @@ -0,0 +1,222 @@ +"""The style editor's form data must survive the real save route. + +The widget posts ordinary dotted field names so the existing pipeline needs no +new parsing -- but per-mode override fields are typed ``["integer", "null"]`` +and ``["array", "null"]``, and two places in that pipeline compared the +declared type to a bare string: + +- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped + anything whose type was not exactly ``'array'``, so a per-mode colour was + never reassembled; +- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable + field into ``[]`` rather than ``None`` -- which then failed the ``minItems`` + the colour array declares. + +Both would have surfaced as "saving a scoreboard's per-mode colour fails +validation", with nothing in the widget to suggest why. This posts what the +widget really emits and asserts on what lands in config, then on what the +resolver makes of it. +""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +SCHEMA = { + "type": "object", + "properties": { + "enabled": {"type": "boolean", "default": True}, + "customization": { + "type": "object", + "x-style-modes": ["live", "recent"], + "x-style-elements": { + "score_text": { + "title": "Score", + "font": {"default": "PressStart2P-Regular.ttf"}, + "size": {"default": 10, "min": 4, "max": 16}, + "color": {"default": [255, 255, 255]}, + "offsets": True, + }, + }, + }, + }, +} + + +class _SaveOk: + status = type("S", (), {"value": "success"})() + message = None + + +@pytest.fixture +def post(tmp_path): + """POST form data to the real save route; yields the stored config.""" + from src.plugin_system.schema_manager import SchemaManager + from web_interface.blueprints import api_v3 as a + + originals = {k: getattr(a.api_v3, k, None) + for k in ("config_manager", "schema_manager", "plugin_manager")} + + pdir = tmp_path / "plugins" / "demo" + pdir.mkdir(parents=True) + (pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + + store = {"demo": {"enabled": True}} + + cm = MagicMock() + cm.load_config.side_effect = lambda: json.loads(json.dumps(store)) + cm.get_config_path.return_value = str(tmp_path / "config.json") + + def _save(cfg, **_kw): + store.clear() + store.update(cfg) + return _SaveOk() + + cm.save_config_atomic.side_effect = _save + + a.api_v3.config_manager = cm + a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins", + project_root=tmp_path) + pm = MagicMock() + pm.plugins = {} + pm.get_plugin.return_value = None + a.api_v3.plugin_manager = pm + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(a.api_v3, url_prefix="/api/v3") + client = app.test_client() + + def _post(form): + resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form) + return resp, store.get("demo", {}) + + try: + yield _post, pdir + finally: + for k, v in originals.items(): + setattr(a.api_v3, k, v) + + +BASE_FORM = { + "enabled": "true", + "customization.score_text.font": "PressStart2P-Regular.ttf", + "customization.score_text.font_size": "12", + "customization.score_text.text_color.0": "255", + "customization.score_text.text_color.1": "200", + "customization.score_text.text_color.2": "0", + "customization.layout.score_text.x_offset": "0", + "customization.layout.score_text.y_offset": "0", +} + +# A mode the user left alone: every field blank. Blank colours are simply +# absent, because the widget disables those inputs rather than posting "". +BLANK_MODE = { + "customization.modes.recent.score_text.font": "", + "customization.modes.recent.score_text.font_size": "", + "customization.modes.recent.layout.score_text.x_offset": "", + "customization.modes.recent.layout.score_text.y_offset": "", +} + + +class TestSaveRoundTrip: + def test_the_base_element_saves(self, post): + _post, _ = post + resp, cfg = _post(dict(BASE_FORM)) + assert resp.status_code == 200, resp.get_json() + assert cfg["customization"]["score_text"] == { + "font": "PressStart2P-Regular.ttf", + "font_size": 12, + "text_color": [255, 200, 0], + } + + def test_a_blank_mode_saves_as_null_not_zero(self, post): + """Null is the inherit sentinel; 0 or [] would pin the mode.""" + _post, _ = post + resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE)) + assert resp.status_code == 200, resp.get_json() + recent = cfg["customization"]["modes"]["recent"] + assert recent["score_text"] == {"font": None, "font_size": None, + "text_color": None} + assert recent["layout"]["score_text"] == {"x_offset": None, + "y_offset": None} + + def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post): + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.font": "", + "customization.modes.live.score_text.font_size": "16", + "customization.modes.live.layout.score_text.x_offset": "", + "customization.modes.live.layout.score_text.y_offset": "-3"})) + assert resp.status_code == 200, resp.get_json() + live = cfg["customization"]["modes"]["live"] + assert live["score_text"]["font_size"] == 16 + assert live["score_text"]["font"] is None + assert live["score_text"]["text_color"] is None + assert live["layout"]["score_text"] == {"x_offset": None, + "y_offset": -3} + + def test_a_per_mode_colour_is_reassembled_into_a_list(self, post): + """The recombiner used to skip this: its type is ["array", "null"], + not "array", so the three indexed inputs were never joined.""" + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.text_color.0": "0", + "customization.modes.live.score_text.text_color.1": "255", + "customization.modes.live.score_text.text_color.2": "0"})) + assert resp.status_code == 200, resp.get_json() + assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"] + == [0, 255, 0]) + + def test_all_blank_colour_channels_mean_inherit(self, post): + """A hand-written form (or an older widget) can still post three + empty strings; they must not become [] and fail minItems.""" + _post, _ = post + resp, cfg = _post(dict( + BASE_FORM, + **{"customization.modes.live.score_text.text_color.0": "", + "customization.modes.live.score_text.text_color.1": "", + "customization.modes.live.score_text.text_color.2": ""})) + assert resp.status_code == 200, resp.get_json() + assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"] + is None) + + +class TestWhatTheDisplayThenRenders: + """The point of the round trip: what the resolver makes of what was saved.""" + + def _styles(self, cfg, pdir): + from src.element_style import (ElementStyleResolver, + defaults_from_schema_file) + defaults = defaults_from_schema_file(str(pdir / "config_schema.json")) + out = {} + for mode in (None, "live", "recent"): + r = ElementStyleResolver(cfg, defaults, mode=mode) + style = r.style("score_text", + classic_font="PressStart2P-Regular.ttf", + classic_size=10, classic_color=(255, 255, 255)) + out[mode] = (style.font_size, style.color, r.offset("score_text")) + return out + + def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post): + _post, pdir = post + _resp, cfg = _post(dict( + BASE_FORM, **BLANK_MODE, + **{"customization.modes.live.score_text.font": "", + "customization.modes.live.score_text.font_size": "16", + "customization.modes.live.layout.score_text.x_offset": "", + "customization.modes.live.layout.score_text.y_offset": "-3"})) + styles = self._styles(cfg, pdir) + assert styles[None] == (12, (255, 200, 0), (0, 0)) + # live overrides size and y, and inherits the colour it never set + assert styles["live"] == (16, (255, 200, 0), (0, -3)) + assert styles["recent"] == (12, (255, 200, 0), (0, 0)) diff --git a/web_interface/app.py b/web_interface/app.py index e2f3c71a..3836ba09 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -168,6 +168,7 @@ pages_v3.config_manager = config_manager pages_v3.plugin_manager = plugin_manager pages_v3.plugin_store_manager = plugin_store_manager pages_v3.saved_repositories_manager = saved_repositories_manager +pages_v3.schema_manager = schema_manager api_v3.config_manager = config_manager api_v3.plugin_manager = plugin_manager diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 80f8d5a1..d07f4db7 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -808,6 +808,31 @@ def _is_field_required(key_path, schema): return field_name in required # Sentinel object to indicate a field should be skipped (not set in config) _SKIP_FIELD = object() + + +def _schema_type_is(prop, wanted): + """Whether a schema property is of ``wanted`` type. + + JSON Schema allows a union (``["array", "null"]``), which the per-element + style system uses for its per-mode override fields: null there means + "inherit the base", so the type genuinely is "an array or nothing". A + bare ``prop.get('type') == 'array'`` reads False for those, which meant + the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were + never recombined into a list. + """ + if not isinstance(prop, dict): + return False + declared = prop.get('type') + if isinstance(declared, list): + return wanted in declared + return declared == wanted + + +def _schema_allows_null(prop): + """Whether a schema property's declared type includes null.""" + return _schema_type_is(prop, 'null') + + def _parse_form_value_with_schema(value, key_path, schema): """ Parse a form value using schema information to determine correct type. @@ -828,11 +853,17 @@ def _parse_form_value_with_schema(value, key_path, schema): # Handle None/empty values if value is None or (isinstance(value, str) and value.strip() == ''): + # A nullable field left blank means null, not an empty container. + # This is the inherit sentinel for per-mode style overrides: an + # empty list there would read as "the user chose no colour" rather + # than "follow the base element". + if _schema_allows_null(prop): + return None # If schema says it's an array, return empty array instead of None - if prop and prop.get('type') == 'array': + if prop and _schema_type_is(prop, 'array'): return [] # If schema says it's an object, return empty dict instead of None - if prop and prop.get('type') == 'object': + if prop and _schema_type_is(prop, 'object'): return {} # If it's an optional string field, preserve empty string instead of None if prop and prop.get('type') == 'string': @@ -869,7 +900,7 @@ def _parse_form_value_with_schema(value, key_path, schema): return False # Handle arrays based on schema - if prop and prop.get('type') == 'array': + if prop and _schema_type_is(prop, 'array'): # Try parsing as JSON first (handles "[1,2,3]" format) if stripped.startswith('['): try: @@ -892,7 +923,7 @@ def _parse_form_value_with_schema(value, key_path, schema): return [] # Handle objects based on schema - if prop and prop.get('type') == 'object': + if prop and _schema_type_is(prop, 'object'): # Try parsing as JSON if stripped.startswith('{'): try: @@ -995,17 +1026,64 @@ def _set_nested_value(config, key_path, value): current[seg] = {} current = current[seg] - # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) - if value is not None or segments[-1] not in current: - current[segments[-1]] = value -def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): + # Set the final value. _SKIP_FIELD (checked above) is the only sentinel + # for "leave the existing value alone" -- an explicit None here is a real + # value (e.g. the per-mode "inherit the base" override) and must overwrite + # whatever was already stored. + current[segments[-1]] = value + + +#: Hidden field the rendered plugin form repeats once per top-level section it +#: drew. Named with a leading underscore pair so the save path can drop it (and +#: anything else meta) before treating form keys as config paths. +_RENDERED_SECTION_FIELD = '__rendered_section' + + +def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents): + """Whether a missing checkbox at ``full_path`` may be forced to False. + + "Missing" only means "unchecked" for a form that actually rendered the + control. A caller that posts a handful of fields -- a script, the MQTT + bridge, a curl against the documented endpoint -- never rendered anything, + and reading its silence as "every other checkbox is off" turns a one-field + save into a mass disable. That is not hypothetical: a partial post of four + ``customization.*`` keys switched off ``nfl.enabled``, ``ncaa_fb.enabled`` + and every display-mode toggle on a live device. + + Two ways to be in scope: + + * ``sections`` -- the rendered form lists the top-level sections it drew + (``__rendered_section``). Anything it drew is fair game, including a + section whose only fields are checkboxes that are all unchecked, which + is the case no heuristic can recover. + * ``submitted_parents`` -- no marker, so fall back to evidence: the + containing object must have had at least one field posted. + """ + if sections is not None: + return full_path.split('.', 1)[0] in sections + if submitted_parents is None: + return True + return prefix in submitted_parents + + +def _submitted_parents(form_keys): + """The object paths a form actually posted a field from ('' = top level).""" + parents = set() + for key in form_keys: + parents.add(key.rsplit('.', 1)[0] if '.' in key else '') + return parents + + +def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefix='', config_node=None, + sections=None, submitted_parents=None): """Walk schema and set missing boolean form fields to False. - HTML checkboxes don't submit values when unchecked. When saving plugin config, - the backend starts from existing config (to support partial form updates), which + HTML checkboxes don't submit values when unchecked. When saving plugin plugin_config, + the backend starts from existing plugin_config (to support partial form updates), which means an unchecked checkbox's old ``True`` value persists. This function detects boolean schema properties not present in the form submission and explicitly sets - them to ``False``. + them to ``False`` -- but only where that silence is evidence, see + :func:`_boolean_is_in_scope`. The top-level ``enabled`` field is excluded because it has its own preservation logic in the save endpoint. @@ -1014,15 +1092,21 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c (e.g. ``feeds.custom_feeds.0.enabled``). Args: - config: The root plugin config dict (used for pure-dict paths) + plugin_config: The root plugin plugin_config dict (used for pure-dict paths) schema_props: Schema ``properties`` dict at the current nesting level form_keys: Set of form field names that were submitted prefix: Dot-notation prefix for the current nesting level - config_node: The current config subtree when inside an array item (avoids + config_node: The current plugin_config subtree when inside an array item (avoids using _set_nested_value which corrupts lists) + sections: Top-level sections the form reported rendering, or None when it + reported none (then submitted_parents decides) + submitted_parents: Object paths with at least one posted field; computed + on the first call when there are no section markers """ - # Determine which config node to operate on - node = config_node if config_node is not None else config + if sections is None and submitted_parents is None: + submitted_parents = _submitted_parents(form_keys) + # Determine which plugin_config node to operate on + node = config_node if config_node is not None else plugin_config for prop_name, prop_schema in schema_props.items(): if not isinstance(prop_schema, dict): @@ -1032,14 +1116,17 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c prop_type = prop_schema.get('type') if prop_type == 'boolean' and full_path != 'enabled': - # If this boolean wasn't submitted in the form, it's an unchecked checkbox - if full_path not in form_keys: + # If this boolean wasn't submitted in the form, it's an unchecked + # checkbox -- provided the form drew it at all. + if (full_path not in form_keys + and _boolean_is_in_scope(full_path, prefix, sections, + submitted_parents)): if config_node is not None: # Inside an array item — set directly on the item dict node[prop_name] = False else: # Pure dict path — use helper - _set_nested_value(config, full_path, False) + _set_nested_value(plugin_config, full_path, False) elif prop_type == 'object' and 'properties' in prop_schema: # Recurse into nested objects @@ -1048,12 +1135,14 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c if prop_name not in node or not isinstance(node[prop_name], dict): node[prop_name] = {} _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path, - config_node=node[prop_name] + plugin_config, prop_schema['properties'], form_keys, full_path, + config_node=node[prop_name], + sections=sections, submitted_parents=submitted_parents ) else: _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path + plugin_config, prop_schema['properties'], form_keys, full_path, + sections=sections, submitted_parents=submitted_parents ) elif prop_type == 'array': @@ -1075,15 +1164,15 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c if not indices: continue - # Navigate to the array in the config (create if missing) + # Navigate to the array in the plugin_config (create if missing) if config_node is not None: if prop_name not in node or not isinstance(node[prop_name], list): node[prop_name] = [] array_list = node[prop_name] else: - # Navigate from root config through dict keys to get the list + # Navigate from root plugin_config through dict keys to get the list parts = full_path.split('.') - current = config + current = plugin_config for part in parts[:-1]: if part not in current or not isinstance(current[part], dict): current[part] = {} @@ -1102,8 +1191,9 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c array_list[idx] = {} item_prefix = f"{full_path}.{idx}" _set_missing_booleans_to_false( - config, items_schema['properties'], form_keys, item_prefix, - config_node=array_list[idx] + plugin_config, items_schema['properties'], form_keys, item_prefix, + config_node=array_list[idx], + sections=sections, submitted_parents=submitted_parents ) def _enhance_schema_with_core_properties(schema): """ diff --git a/web_interface/blueprints/api_v3/fonts.py b/web_interface/blueprints/api_v3/fonts.py index ad966ae6..db0d7d5a 100644 --- a/web_interface/blueprints/api_v3/fonts.py +++ b/web_interface/blueprints/api_v3/fonts.py @@ -83,6 +83,20 @@ def get_fonts_catalog(): # Check if this is a system font (cannot be deleted) is_system = catalog_key.lower() in SYSTEM_FONTS + # BDF files are fixed-size bitmap strikes: FreeType + # accepts only the pixel size baked into the file. The + # UI needs to know that before offering a size control, + # or it offers a number that cannot take effect. + native_size = None + if font_type == 'bdf': + try: + from src.element_style import _read_bdf_native_size + native_size = _read_bdf_native_size(str(filepath)) + except Exception as e: + logger.debug("Could not read native size for BDF font %s: %s", + filepath, e) + native_size = None + catalog[catalog_key] = { 'filename': filename, 'family_name': family_name, @@ -90,6 +104,8 @@ def get_fonts_catalog(): 'path': relative_path, 'type': font_type, 'is_system': is_system, + 'scalable': font_type != 'bdf', + 'native_size': native_size, 'metadata': metadata if metadata else None } @@ -124,39 +140,6 @@ def get_font_tokens(): except Exception as e: logger.error('Unhandled exception', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/fonts/overrides', methods=['GET']) -def get_fonts_overrides(): - """Get font overrides""" - try: - # This would integrate with the actual font system - # For now, return empty overrides - overrides = {} - return jsonify({'status': 'success', 'data': {'overrides': overrides}}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/fonts/overrides', methods=['POST']) -def save_fonts_overrides(): - """Save font overrides""" - try: - data = request.get_json(silent=True) - if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 - - # This would integrate with the actual font system - return jsonify({'status': 'success', 'message': 'Font overrides saved'}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/fonts/overrides/', methods=['DELETE']) -def delete_font_override(element_key): - """Delete font override""" - try: - # This would integrate with the actual font system - return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'}) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 @api_v3.route('/fonts/upload', methods=['POST']) def upload_font(): """Upload font file""" diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 2e61ef41..27d8a901 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -5,12 +5,13 @@ endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( ErrorCode, OperationType, PROJECT_ROOT, Path, Response, - _CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD, _coerce_to_bool, + _CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool, _do_transactional_uninstall, _enhance_schema_with_core_properties, _filter_config_by_schema, _get_plugin_version, _get_schema_property, _installed_plugin_ids, _is_plugin_update_available, _parse_form_value_with_schema, _prune_credential_backups, - _run_calendar_registration, _set_missing_booleans_to_false, + _run_calendar_registration, _schema_allows_null, _schema_type_is, + _set_missing_booleans_to_false, _set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app, api_v3, datetime, deep_merge, describe_exception, error_response, find_secret_fields, hashlib, json, jsonify, logger, logging, @@ -1729,6 +1730,11 @@ def save_plugin_config(): # Convert form data to config dict # Form fields can use dot notation for nested values (e.g., "transition.type") form_data = request.form.to_dict() + # Meta fields describe the submission, they are not config paths. + # Unknown keys are otherwise written straight into config.json by + # the non-indexed pass below. + form_data = {k: v for k, v in form_data.items() + if not k.startswith('__')} # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) # These fields use getlist() to preserve all values, then replace in form_data @@ -1781,7 +1787,7 @@ def save_plugin_config(): if last_part.isdigit(): # Get schema property for the base path to verify it's an array base_prop = _get_schema_property(schema, base_path) - if base_prop and base_prop.get('type') == 'array': + if base_prop and _schema_type_is(base_prop, 'array'): # This is an array index field index = int(last_part) if base_path not in array_fields: @@ -1796,6 +1802,16 @@ def save_plugin_config(): # Sort by index and extract values index_values.sort(key=lambda x: x[0]) values = [v for _, v in index_values] + # Every channel blank on a nullable field means "unset", not + # an empty array: joining them would produce ", , ", which + # parses to [] and then fails the minItems the array + # declares. This is how a per-mode colour override says + # "inherit the base colour". + base_prop_for_null = _get_schema_property(schema, base_path) + if (_schema_allows_null(base_prop_for_null) + and all(str(v).strip() == '' for v in values)): + _set_nested_value(plugin_config, base_path, None) + continue # Combine values into comma-separated string for parsing combined_value = ', '.join(str(v) for v in values) # Parse as array using schema @@ -2078,7 +2094,14 @@ def save_plugin_config(): # Walk the schema and set any boolean fields missing from form data to False. if schema and 'properties' in schema: form_keys = set(request.form.keys()) - _set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) + # The rendered form reports which top-level sections it drew, so + # an unchecked box can be told apart from a field the caller + # never had in front of it. A caller that sends none gets the + # evidence-based fallback in _boolean_is_in_scope. + rendered_sections = set(request.form.getlist(_RENDERED_SECTION_FIELD)) + _set_missing_booleans_to_false( + plugin_config, schema['properties'], form_keys, + sections=rendered_sections or None) # Get schema manager instance (for JSON requests) schema_mgr = api_v3.schema_manager diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index a6e6cf4c..b7467f8e 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -10,6 +10,8 @@ from pathlib import Path # Strict allowlists for URL-derived values used in path and script operations. _SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') _SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$') +_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') +_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$') from src.web_interface.secret_helpers import mask_secret_fields from src.common.path_safety import resolve_under, safe_path_component @@ -19,6 +21,7 @@ logger = logging.getLogger(__name__) config_manager = None plugin_manager = None plugin_store_manager = None +schema_manager = None pages_v3 = Blueprint('pages_v3', __name__) @@ -363,6 +366,119 @@ def serve_plugin_web_ui(plugin_id, filename): logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True) return 'Error serving file', 500, {'Content-Type': 'text/plain'} + +def _plugin_dir_for(safe_id): + """Resolve a sanitised plugin id to its directory, or None. + + Mirrors serve_plugin_web_ui: containment-guarded against the configured + plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback. + """ + plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() + plugin_dir = resolve_under(plugins_base, safe_id) + if plugin_dir is None: + raise ValueError('plugin id escapes the plugins directory') + + if not plugin_dir.exists(): + alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}') + if alt is not None: + plugin_dir = alt + return plugin_dir + + +def _declared_widget_script(plugin_dir, widget_name): + """The script filename a plugin's manifest declares for ``widget_name``. + + The manifest is the allowlist: only a widget the plugin actually declares + can be served, so this route never exposes arbitrary files under the + plugin directory even though the directory itself is attacker-influenced + (plugins are user-installed). Returns None when the widget is not + declared, the manifest is unreadable, or the declared script name is not + a plain ``.js`` basename. + """ + manifest_path = plugin_dir / 'manifest.json' + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + manifest = json.load(f) + except (OSError, ValueError): + return None + if not isinstance(manifest, dict): + return None + + for entry in manifest.get('widgets') or (): + if not isinstance(entry, dict): + continue + if entry.get('name') != widget_name: + continue + script = entry.get('script') or f'{widget_name}.js' + if not isinstance(script, str) or not _SAFE_WIDGET_SCRIPT_RE.match(script): + return None + return script + return None + + +@pages_v3.route('/static/plugin-widgets//.js') +def serve_plugin_widget(plugin_id, widget_name): + """Serve a plugin-declared widget script from its ``widgets/`` directory. + + This is the server half of ``LEDMatrixWidgets.loadPluginWidget`` (see + static/v3/js/widgets/plugin-loader.js), which fetches exactly this path. + The loader uses a dynamic ``import()``, so the response must carry a + JavaScript MIME type or the browser refuses the module. + + The route is deliberately narrower than the plugin directory: a script is + served only when the plugin's own manifest declares a widget by that name, + so installing a plugin does not publish everything it ships. + """ + if not _SAFE_PLUGIN_ID_RE.match(plugin_id): + return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'} + if not _SAFE_WIDGET_NAME_RE.match(widget_name): + return 'Invalid widget name', 400, {'Content-Type': 'text/plain'} + + # safe_path_component is this codebase's sanitiser (src/common/ + # path_safety.py): it rejects rather than mangles, so a name that is not + # a plain path component never reaches the filesystem. + safe_id = safe_path_component(plugin_id) + safe_widget = safe_path_component(widget_name) + if not safe_id or not safe_widget: + return 'Invalid path component', 400, {'Content-Type': 'text/plain'} + + if not pages_v3.plugin_manager: + return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'} + + try: + plugin_dir = _plugin_dir_for(safe_id) + if not plugin_dir.exists(): + return 'Not found', 404, {'Content-Type': 'text/plain'} + + script = _declared_widget_script(plugin_dir, safe_widget) + if script is None: + # Undeclared is a 404 rather than a 403: whether a plugin happens + # to ship an undeclared file is not something to confirm. + return 'Not found', 404, {'Content-Type': 'text/plain'} + + widgets_dir = (plugin_dir / 'widgets').resolve() + # The script name comes from the plugin's manifest, not the request, + # so it gets the same containment treatment the URL parts got. + script_path = resolve_under(widgets_dir, script) + if script_path is None or not script_path.is_file(): + return 'Not found', 404, {'Content-Type': 'text/plain'} + + body = script_path.read_text(encoding='utf-8') + return body, 200, { + 'Content-Type': 'text/javascript; charset=utf-8', + # Plugin updates replace this file in place; revalidate so a + # stale widget cannot outlive the plugin version that shipped it. + 'Cache-Control': 'no-cache', + } + + except ValueError: + return 'Forbidden', 403, {'Content-Type': 'text/plain'} + except Exception: + logger.error('Error serving plugin widget %s/%s', plugin_id, widget_name, + exc_info=True) + return 'Error serving file', 500, {'Content-Type': 'text/plain'} + + def _load_overview_partial(): """Load overview partial with system stats""" try: @@ -715,15 +831,42 @@ def _load_plugin_config_partial(plugin_id): except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e) - # Get plugin schema + # Get plugin schema. + # + # Through SchemaManager, not a raw json.load, because that is what + # the save route uses (api_v3.save_plugin_config) -- and the two + # disagreeing is not academic. SchemaManager applies + # expand_style_elements, which turns a compact + # customization.x-style-elements declaration into the per-element + # blocks this form renders. Reading the file directly meant a plugin + # using that form (of-the-day ships one) had a customization section + # that rendered nothing at all, while saving still validated against + # the expanded shape. + # + # use_cache=False matches the save route: a plugin's schema changes + # on disk during development, and a cached copy would keep serving + # the old form. + # + # The raw read stays as a fallback for callers that never set a + # schema_manager (several tests, and any embedder of this blueprint). schema = {} - schema_path = resolve_under(_plugin_dir, "config_schema.json") - if schema_path is not None and schema_path.exists(): + schema_mgr = getattr(pages_v3, 'schema_manager', None) + if schema_mgr is not None: try: - with open(schema_path, 'r', encoding='utf-8') as f: - schema = json.load(f) + schema = schema_mgr.load_schema(plugin_id, use_cache=False) or {} except Exception as e: - logger.warning("Could not load schema for plugin: %s", e) + logger.warning("SchemaManager could not load schema for %s: %s", + plugin_id, e) + if not schema: + # resolve_under keeps the containment guard main added here; the + # SchemaManager path above does its own. + schema_path = resolve_under(_plugin_dir, "config_schema.json") + if schema_path is not None and schema_path.exists(): + try: + with open(schema_path, 'r', encoding='utf-8') as f: + schema = json.load(f) + except Exception as e: + logger.warning("Could not load schema for plugin: %s", e) # Get web UI actions from plugin manifest web_ui_actions = [] diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 993902dd..2f35fc41 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1412,3 +1412,85 @@ button.bg-white { max-width: calc(100vw - 2rem); } } + +/* ---- Style editor widget -------------------------------------------- */ +/* One row per display element: label, font, size, colour, X, Y. The grid + is declared once here so the header and the rows cannot drift apart. */ +/* The plugin config panel is narrower than nine columns of controls, and a + clipped table hides the offsets entirely -- scroll the table rather than + crushing the controls or dropping columns. */ +.style-editor { + overflow-x: auto; +} +.style-editor-table { + min-width: max-content; +} +/* Scrolled right, the element names would otherwise scroll away and the + row you are editing becomes anonymous. */ +.style-editor-label, +.style-editor-head > :first-child { + position: sticky; + left: 0; + z-index: 1; + background-color: #ffffff; +} +.style-editor-head > :first-child { + background-color: #f9fafb; +} +.style-editor-row { + /* Set by the widget: the column count depends on what the plugin + declares, so the track list cannot be fixed here. */ + grid-template-columns: var(--style-editor-columns, + minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5rem 4.5rem 5rem 5rem); +} +.style-editor-check { + width: 1rem; + height: 1rem; +} +.style-editor-row .form-control { + padding: 0.2rem 0.4rem; + height: auto; +} +.style-editor-row input[type="number"] { + width: 100%; +} +.style-editor-row input[type="number"]:disabled { + background-color: #f3f4f6; + color: #9ca3af; + cursor: not-allowed; +} +.style-editor-colour { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 0.25rem; + background: none; + cursor: pointer; +} +.style-editor-tab { + border: 1px solid #d1d5db; + background-color: #f9fafb; + color: #374151; +} +.style-editor-tab.is-active { + background-color: #2563eb; + border-color: #2563eb; + color: #ffffff; +} +.style-editor-panel[hidden] { + display: none; +} +@media (max-width: 768px) { + /* Stack rather than squeeze: six columns on a phone is unreadable. */ + .style-editor-row { + grid-template-columns: 1fr 1fr; + } + .style-editor-head { + display: none; + } + .style-editor-label { + grid-column: 1 / -1; + font-weight: 600; + } +} diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 0ee00fd5..5f9ceb43 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -752,586 +752,6 @@ } }, - generateConfigForm(pluginId, config, schema, webUiActions = []) { - // Safety check - if schema/config not ready, return empty - if (!pluginId || !config) { - return '
Loading configuration...
'; - } - - // Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering) - if (!this._configFormLogged || this._configFormLogged !== pluginId) { - debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions'); - // Debug: Check if image_config.images has x-widget in schema - if (schema && schema.properties && schema.properties.image_config) { - const imgConfig = schema.properties.image_config; - if (imgConfig.properties && imgConfig.properties.images) { - const imagesProp = imgConfig.properties.images; - debugLog('[DEBUG] Schema check - image_config.images:', { - type: imagesProp.type, - 'x-widget': imagesProp['x-widget'], - 'has x-widget': 'x-widget' in imagesProp, - keys: Object.keys(imagesProp) - }); - } - } - this._configFormLogged = pluginId; - } - if (!schema || !schema.properties) { - return this.generateSimpleConfigForm(config, webUiActions, pluginId); - } - - // Helper function to get schema property by full key path - const getSchemaProperty = (schemaObj, keyPath) => { - if (!schemaObj || !schemaObj.properties) return null; - const keys = keyPath.split('.'); - let current = schemaObj.properties; - for (let i = 0; i < keys.length; i++) { - const k = keys[i]; - if (!current || !current[k]) { - return null; - } - - const prop = current[k]; - // If this is the last key, return the property - if (i === keys.length - 1) { - return prop; - } - - // If this property has nested properties, navigate deeper - if (prop && typeof prop === 'object' && prop.properties) { - current = prop.properties; - } else { - // Can't navigate deeper - return null; - } - } - return null; - }; - - const generateFieldHtml = (key, prop, value, prefix = '') => { - const fullKey = prefix ? `${prefix}.${key}` : key; - const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const description = prop.description || ''; - let html = ''; - - // Debug: Log property structure for arrays to help diagnose file-upload widget issues - if (prop.type === 'array') { - // Also check schema directly as fallback - const schemaProp = getSchemaProperty(schema, fullKey); - const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; - - debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, { - 'prop.x-widget': prop['x-widget'], - 'prop.x_widget': prop['x_widget'], - 'schema.x-widget': xWidgetFromSchema, - 'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'), - 'x-widget in prop': 'x-widget' in prop, - 'all prop keys': Object.keys(prop), - 'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null' - }); - } - - // Handle nested objects - if (prop.type === 'object' && prop.properties) { - const sectionId = `section-${fullKey.replace(/\./g, '-')}`; - const nestedConfig = value || {}; - const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - // Calculate nesting depth for better spacing - const nestingDepth = (fullKey.match(/\./g) || []).length; - const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4'; - - html += ` -
- - -
- `; - - // Add extra spacing after nested sections to prevent overlap with next section - if (nestingDepth > 0) { - html += `
`; - } - - return html; - } - - // Regular (non-nested) field - html += `
`; - html += ``; - - if (description) { - html += `

${description}

`; - } - - // Generate appropriate input based on type - if (prop.type === 'boolean') { - html += ``; - } else if (prop.type === 'number' || prop.type === 'integer' || - (Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) { - // Handle union types like ["integer", "null"] - const isUnionType = Array.isArray(prop.type); - const allowsNull = isUnionType && prop.type.includes('null'); - const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer')); - const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number')); - const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : ''; - const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : ''; - const step = isInteger ? 'step="1"' : 'step="any"'; - - // For union types with null, don't show default if value is null (leave empty) - // This allows users to explicitly set null by leaving it empty - let fieldValue = ''; - if (value !== undefined && value !== null) { - fieldValue = value; - } else if (!allowsNull && prop.default !== undefined) { - // Only use default if null is not allowed - fieldValue = prop.default; - } - - // Ensure value respects min/max constraints - if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) { - const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue; - if (!isNaN(numValue)) { - // Clamp value to min/max if constraints exist - if (prop.minimum !== undefined && numValue < prop.minimum) { - fieldValue = prop.minimum; - } else if (prop.maximum !== undefined && numValue > prop.maximum) { - fieldValue = prop.maximum; - } else { - fieldValue = numValue; - } - } - } - - // Add placeholder/help text for null-able fields - const placeholder = allowsNull ? 'Leave empty to use current time (random)' : ''; - const helpText = allowsNull && description && description.includes('null') ? - `

${description}

` : ''; - - html += ``; - if (helpText) { - html += helpText; - } - } else if (prop.type === 'array') { - // AGGRESSIVE file upload widget detection - // For 'images' field in static-image plugin, always check schema directly - let isFileUpload = false; - let uploadConfig = {}; - - // Direct check: if this is the 'images' field and schema has it with x-widget - if (fullKey === 'images' && schema && schema.properties && schema.properties.images) { - const imagesSchema = schema.properties.images; - if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') { - isFileUpload = true; - uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig); - } - } - - // Fallback: check prop object (should have x-widget if schema loaded correctly) - if (!isFileUpload) { - const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget; - if (xWidgetFromProp === 'file-upload') { - isFileUpload = true; - uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Detection via prop object'); - } - } - - // Fallback: schema property lookup - if (!isFileUpload) { - let schemaProp = getSchemaProperty(schema, fullKey); - if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) { - schemaProp = schema.properties.images; - } - const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null; - if (xWidgetFromSchema === 'file-upload') { - isFileUpload = true; - uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {}; - debugLog('[DEBUG] ✅ Detection via schema lookup'); - } - } - - // Debug logging for ALL array fields to diagnose - debugLog('[DEBUG] Array field check:', fullKey, { - 'isFileUpload': isFileUpload, - 'prop keys': Object.keys(prop), - 'prop.x-widget': prop['x-widget'], - 'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images), - 'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null, - 'uploadConfig': uploadConfig - }); - - if (isFileUpload) { - debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig); - // Use the file upload widget from plugins.html - // We'll need to call a function that exists in the global scope - const maxFiles = uploadConfig.max_files || 10; - const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; - const maxSizeMB = uploadConfig.max_size_mb || 5; - - const currentImages = Array.isArray(value) ? value : []; - const fieldId = fullKey.replace(/\./g, '_'); - const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_'); - - html += ` -
- -
- - -

Drag and drop images here or click to browse

-

Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)

-
- - -
- ${currentImages.map((img, idx) => { - const imgSchedule = img.schedule || {}; - const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; - let scheduleSummary = 'Always shown'; - if (hasSchedule && window.getScheduleSummary) { - try { - scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled'; - } catch (e) { - scheduleSummary = 'Scheduled'; - } - } else if (hasSchedule) { - scheduleSummary = 'Scheduled'; - } - // Escape the summary for HTML - scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - - return ` -
-
-
- ${(img.filename || '').replace(/ - -
-

${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(//g, '>')}

-

${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}

-

- ${scheduleSummary} -

-
-
-
- - -
-
- - -
- `; - }).join('')} -
- - - -
- `; - } else { - // Regular array input - const arrayValue = Array.isArray(value) ? value.join(', ') : ''; - html += ``; - html += `

Enter values separated by commas

`; - } - } else if (prop.enum) { - html += ``; - } else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') { - // File upload widget for string fields (e.g., credentials.json) - const uploadConfig = prop['x-upload-config'] || {}; - const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload'; - const maxSizeMB = uploadConfig.max_size_mb || 1; - const allowedExtensions = uploadConfig.allowed_extensions || ['.json']; - const targetFilename = uploadConfig.target_filename || 'file.json'; - const fieldId = fullKey.replace(/\./g, '_'); - const hasFile = value && value !== ''; - - html += ` -
-
- - -

- ${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename} -

-

Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})

-
- -
- `; - } else { - // Default to text input - const maxLength = prop.maxLength || ''; - const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; - html += ``; - } - - html += `
`; - return html; - }; - - let formHtml = ''; - // Get ordered properties if x-propertyOrder is defined - let propertyEntries = Object.entries(schema.properties); - if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) { - const order = schema['x-propertyOrder']; - const orderedEntries = []; - const unorderedEntries = []; - - // Separate ordered and unordered properties - propertyEntries.forEach(([key, prop]) => { - const index = order.indexOf(key); - if (index !== -1) { - orderedEntries[index] = [key, prop]; - } else { - unorderedEntries.push([key, prop]); - } - }); - - // Combine ordered entries (filter out undefined from sparse array) with unordered entries - propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries); - } - - propertyEntries.forEach(([key, prop]) => { - // Skip the 'enabled' property - it's managed separately via the header toggle - if (key === 'enabled') return; - // Use config value if key exists and is not null/undefined, otherwise use schema default - // Check if key exists in config and value is not null/undefined - const hasValue = key in config && config[key] !== null && config[key] !== undefined; - // For nested objects, if the value is an empty object, still use it (don't fall back to default) - const isNestedObject = prop.type === 'object' && prop.properties; - const value = hasValue ? config[key] : - (prop.default !== undefined ? prop.default : - (isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : '')))); - formHtml += generateFieldHtml(key, prop, value); - }); - - // Add web UI actions section if plugin defines any - if (webUiActions && webUiActions.length > 0) { - debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form'); - - // Map color names to explicit Tailwind classes - const colorMap = { - 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, - 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, - 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, - 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, - 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } - }; - - formHtml += ` -
-

Actions

-

${webUiActions[0].section_description || 'Perform actions for this plugin'}

- -
- `; - - webUiActions.forEach((action, index) => { - const actionId = `action-${action.id}-${index}`; - const statusId = `action-status-${action.id}-${index}`; - const bgColor = action.color || 'blue'; - const colors = colorMap[bgColor] || colorMap['blue']; - // Ensure pluginId is valid for template interpolation - const safePluginId = pluginId || ''; - - formHtml += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - - formHtml += ` -
-
- `; - } - - return formHtml; - }, - - generateSimpleConfigForm(config, webUiActions = [], pluginId = '') { - let actionsHtml = ''; - if (webUiActions && webUiActions.length > 0) { - const colorMap = { - 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, - 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, - 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, - 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, - 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } - }; - - actionsHtml = ` -
-

Actions

-
- `; - webUiActions.forEach((action, index) => { - const actionId = `action-${action.id}-${index}`; - const statusId = `action-status-${action.id}-${index}`; - const bgColor = action.color || 'blue'; - const colors = colorMap[bgColor] || colorMap['blue']; - // Ensure pluginId is valid for template interpolation - const safePluginId = pluginId || ''; - actionsHtml += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - actionsHtml += ` -
-
- `; - } - - return ` -
- - -

How long to show this plugin's content

-
- ${actionsHtml} - `; - }, - // Helper function to get schema property type for a field path getSchemaPropertyType(schema, path) { if (!schema || !schema.properties) return null; diff --git a/web_interface/static/v3/js/plugins/config_manager.js b/web_interface/static/v3/js/plugins/config_manager.js deleted file mode 100644 index 0182bd16..00000000 --- a/web_interface/static/v3/js/plugins/config_manager.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Plugin configuration form management. - * - * Handles configuration form generation, validation, and submission. - */ - -const PluginConfigManager = { - /** - * Current plugin configuration state. - */ - currentState: { - pluginId: null, - config: null, - schema: null, - jsonEditor: null - }, - - /** - * Initialize configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Configuration and schema - */ - async initialize(pluginId) { - try { - const [config, schema] = await Promise.all([ - window.PluginAPI.getPluginConfig(pluginId), - window.PluginAPI.getPluginSchema(pluginId) - ]); - - this.currentState = { - pluginId, - config, - schema, - jsonEditor: null - }; - - return { config, schema }; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to initialize config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Reset configuration to defaults. - * - * @param {string} pluginId - Plugin identifier - * @returns {Promise} Default configuration - */ - async resetToDefaults(pluginId) { - try { - const result = await window.PluginAPI.resetPluginConfig(pluginId); - - // Reload configuration - if (this.currentState.pluginId === pluginId) { - await this.initialize(pluginId); - } - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to reset config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Save configuration. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} config - Configuration data - * @returns {Promise} Save result - */ - async save(pluginId, config) { - try { - const result = await window.PluginAPI.savePluginConfig(pluginId, config); - - // Update local state - if (this.currentState.pluginId === pluginId) { - this.currentState.config = config; - } - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`); - } - throw error; - } - }, - - /** - * Validate configuration against schema. - * - * @param {Object} config - Configuration data - * @param {Object} schema - JSON schema - * @returns {Object} Validation result with errors - */ - validate(config, schema) { - // Basic validation - full validation happens on server - const errors = []; - - if (!schema || !schema.properties) { - return { valid: true, errors: [] }; - } - - // Check required fields - if (schema.required) { - for (const field of schema.required) { - if (!(field in config)) { - errors.push(`Required field '${field}' is missing`); - } - } - } - - return { - valid: errors.length === 0, - errors: errors - }; - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = PluginConfigManager; -} else { - window.PluginConfigManager = PluginConfigManager; -} - diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md index c2ecbcd1..be1f2189 100644 --- a/web_interface/static/v3/js/widgets/README.md +++ b/web_interface/static/v3/js/widgets/README.md @@ -280,14 +280,40 @@ In your plugin's `config_schema.json`: } ``` -### Step 3: Widget Loading +### Step 3: Declare the Widget in `manifest.json` -The widget will be automatically loaded when the plugin configuration form is rendered. The system will: +The manifest is the allowlist -- a widget is served only if the plugin declares +it, so shipping a file under `widgets/` does not by itself publish it: -1. Check if widget is registered in the core registry -2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js` +```json +{ + "widgets": [ + { "name": "my-custom-widget", "script": "my-custom-widget.js" } + ] +} +``` + +`script` is optional and defaults to `[name].js`. It must be a plain filename +directly inside the plugin's `widgets/` directory. + +### Step 4: Widget Loading + +The widget is loaded on demand when the config form renders a field that +references it. The system will: + +1. Check if the widget is registered in the core registry +2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which + serves the declared script from the plugin's `widgets/` directory 3. Render the widget using the registered `render` function +The fetch is a dynamic `import()`, so the file must parse as an ES module (a +plain IIFE does). If anything fails, the field falls back to a plain text input +holding the current value, so a broken widget never costs the user their +configured value. + +Only `string`-typed fields take this path today; see `docs/widget-guide.md` +for the full details and limitations. + ## Widget API Reference ### Widget Definition Object diff --git a/web_interface/static/v3/js/widgets/font-selector.js b/web_interface/static/v3/js/widgets/font-selector.js index bf1f887c..c8c84d1d 100644 --- a/web_interface/static/v3/js/widgets/font-selector.js +++ b/web_interface/static/v3/js/widgets/font-selector.js @@ -121,7 +121,9 @@ family: family, display_name: info.display_name || generateDisplayName(info.filename || family), path: info.path, - type: info.type || 'unknown' + type: info.type || 'unknown', + scalable: info.scalable, + native_size: info.native_size })); } else if (Array.isArray(data)) { // Direct array format @@ -174,6 +176,12 @@ const xOptions = config['x-options'] || config['x_options'] || {}; const placeholder = xOptions.placeholder || 'Select a font...'; const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf'] + // Bitmap (BDF) fonts render at the one size baked into the file + // and ignore the size setting entirely, so a field that caps size + // at 16 can still be handed a 27px face. maxFixedSize hides the + // ones that cannot honour the cap, rather than offering a choice + // that silently overflows the panel. + const maxFixedSize = Number(xOptions.maxFixedSize) || null; const showPreview = xOptions.showPreview === true; const disabled = xOptions.disabled === true; const required = xOptions.required === true; @@ -204,6 +212,16 @@ return filterTypes.some(t => t.toLowerCase() === fontType); }); } + if (maxFixedSize) { + filteredFonts = filteredFonts.filter(font => { + // A scalable face can always meet the cap. + if (font.scalable !== false) { return true; } + // An unknown native size is not evidence it is too + // big; keep it rather than hiding a usable font. + if (!font.native_size) { return true; } + return font.native_size <= maxFixedSize; + }); + } // Build select HTML let html = `
`; diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js new file mode 100644 index 00000000..1017ae39 --- /dev/null +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -0,0 +1,636 @@ +/** + * Style Editor Widget + * + * One compact row per display element -- font, size, colour, alignment, + * visibility, X/Y nudge, scale -- instead of the nested accordions the + * generic object renderer produces. A realistic scoreboard declares seven + * elements with layout offsets and three modes, which comes to 65 nested + * sections and five levels of clicking to reach one per-mode font size. + * + * Three things about this widget are load-bearing: + * + * 1. It emits ordinary inputs with the same dotted names the generic + * renderer would produce (`customization.score_text.font`, + * `customization.score_text.text_color.0`, `customization.layout. + * score_text.x_offset`, `customization.modes.live....`). The whole + * save/validate/merge pipeline is therefore untouched: no hidden JSON + * blob, no new server-side parsing. + * + * 2. Its columns come from the schema, not from a list in here. A column + * appears when any element declares that field, so a plugin adding a + * field to its schema gets a control without this file changing, and a + * logo that declares only offsets and a scale gets no empty font cell. + * + * 3. Mode tabs edit `customization.modes.`, whose fields mean + * "inherit" when blank. Blank must post as empty (-> null), never as 0, + * or every mode would pin itself to the base the first time it was saved. + * + * @module StyleEditorWidget + */ + +(function () { + 'use strict'; + + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + var FONT_CACHE = null; + var FONT_INFLIGHT = null; + + /** + * Read one own property, by a key that came from data. + * + * Every lookup in here is keyed by something out of a schema or a saved + * config -- an element name, a mode name, a field name. A key of + * `__proto__` or `constructor` would otherwise walk up the prototype + * chain and hand back a function instead of a schema, so reads go + * through here and misses come back undefined. + */ + function own(obj, key) { + if (!obj || typeof obj !== 'object') { return undefined; } + // Via the descriptor rather than obj[key]: this is the one read that + // cannot avoid a data-supplied key, and going through the descriptor + // means there is no computed member access here at all. + var descriptor = Object.getOwnPropertyDescriptor(obj, key); + return descriptor ? descriptor.value : undefined; + } + + /** `own`, but always an object -- for `(x || {}).properties` chains. */ + function ownObj(obj, key) { + var found = own(obj, key); + return (found && typeof found === 'object') ? found : {}; + } + + /** The font catalog, fetched once per page. */ + function loadFonts() { + if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); } + if (FONT_INFLIGHT) { return FONT_INFLIGHT; } + FONT_INFLIGHT = fetch('/api/v3/fonts/catalog') + .then(function (r) { + if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); } + return r.json(); + }) + .then(function (payload) { + var catalog = (payload && payload.data && payload.data.catalog) || {}; + FONT_CACHE = Object.keys(catalog).map(function (key) { + var entry = ownObj(catalog, key); + return { + filename: entry.filename, + label: entry.display_name || entry.filename, + scalable: entry.scalable !== false, + nativeSize: entry.native_size || null + }; + }).sort(function (a, b) { return a.label.localeCompare(b.label); }); + return FONT_CACHE; + }) + .catch(function (e) { + console.warn('[StyleEditor] could not load the font catalog', e); + // Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next + // call retries instead of being stuck on an empty result. + FONT_INFLIGHT = null; + return []; + }); + return FONT_INFLIGHT; + } + + function el(tag, attrs, children) { + var node = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + var v = own(attrs, k); + if (k === 'class') { node.className = v; } + else if (k === 'text') { node.textContent = v; } + else if (v !== null && v !== undefined) { + node.setAttribute(k, v); + } + }); + (children || []).forEach(function (c) { node.appendChild(c); }); + return node; + } + + /** Walk a nested value object by path segments. */ + function at(value, path) { + var cur = value; + // Consumed rather than indexed, so no step reads path[i]. + var remaining = (path || []).slice(); + while (remaining.length) { + if (cur === null || typeof cur !== 'object') { return undefined; } + cur = own(cur, remaining.shift()); + } + return cur; + } + + /** + * What a control should show: the configured value if there is one, + * else the schema default. + * + * The base tab must fall back to the default rather than leaving the + * control empty. An empty has no empty + // state of its own. + clearBtn = el('button', { + type: 'button', + class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear', + title: 'Inherit the colour above', + text: '×' + }); + if (!has) { clearBtn.classList.add('hidden'); } + clearBtn.addEventListener('click', function () { + channels.forEach(function (c) { c.value = ''; }); + setSubmitted(false); + swatch.value = '#ffffff'; + clearBtn.classList.add('hidden'); + }); + wrap.appendChild(clearBtn); + } + return wrap; + } + + // ---- columns --------------------------------------------------------- + + var COLUMN_ORDER = ['font', 'font_size', 'text_color', 'align', 'visible', + 'x_offset', 'y_offset', 'scale']; + var COLUMN_LABELS = { + font: 'Font', font_size: 'Size', text_color: 'Colour', + align: 'Align', visible: 'Show', + x_offset: 'X', y_offset: 'Y', scale: 'Scale' + }; + var COLUMN_WIDTHS = { + font: 'minmax(8rem, 2fr)', text_color: '4.5rem', align: '6rem', + visible: '3.5rem' + }; + + /** + * The columns a table needs, derived from the schema rather than fixed. + * + * Two sources: the sub-fields elements declare (font, font_size, + * text_color, visible, align) and the sub-fields their layout blocks + * declare (x_offset, y_offset, scale). + */ + function columnsFor(schema) { + var props = schema.properties || {}; + var layoutProps = ownObj(props, 'layout').properties || {}; + // A Map, not an object: the keys are field names out of a schema, so + // a field literally named "constructor" is a column like any other + // and never touches a prototype. + var seen = new Map(); + elementKeys(schema).forEach(function (key) { + Object.keys(ownObj(props, key).properties || {}).forEach( + function (f) { seen.set(f, 'element'); }); + Object.keys(ownObj(layoutProps, key).properties || {}).forEach( + function (f) { seen.set(f, 'layout'); }); + }); + var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); }); + // Anything the schema declares that this file has never heard of + // still gets a column, rather than silently vanishing. + var extra = Array.from(seen.keys()).filter(function (f) { + return COLUMN_ORDER.indexOf(f) === -1; + }).sort(); + return known.concat(extra).map(function (f) { + return { + key: f, + where: seen.get(f), + label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ') + }; + }); + } + + /** Build the control for one cell from its schema property. */ + function control(opts) { + var prop = opts.prop; + var declared = prop.type; + var types = Array.isArray(declared) ? declared : [declared]; + var xOptions = prop['x-options'] || prop['x_options'] || {}; + var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null; + + if (opts.key === 'font' || prop['x-widget'] === 'font-selector') { + return fontControl(opts.name, opts.current, + usableFonts(opts.fonts, cap, opts.current), + opts.optional, opts.onFontChange); + } + if (types.indexOf('boolean') !== -1) { + return booleanControl(opts.name, opts.current, opts.optional); + } + if (types.indexOf('array') !== -1) { + return colourControl(opts.name, opts.current, opts.optional); + } + if (Array.isArray(prop.enum)) { + return enumControl(opts.name, opts.current, prop.enum, + opts.optional); + } + return numberControl(opts.name, opts.current, prop, + opts.optional ? 'inherit' : ''); + } + + function elementRow(opts) { + var schema = opts.schema; + var key = opts.key; + var value = opts.value; + var optional = opts.optional; + + var props = ownObj(schema.properties || {}, key).properties || {}; + var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {}; + var axes = ownObj(layoutProps, key).properties || {}; + + var row = el('div', { + class: 'style-editor-row grid items-center gap-2 py-1', + 'data-element': key + }); + row.appendChild(el('div', { + class: 'text-sm text-gray-700 style-editor-label', + text: titleOf(schema, key) + })); + + var sizeInput = null; + var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' }); + var fontSelect = null; + + function syncSize(select) { + // A BDF font has exactly one usable pixel size; offering a free + // number there offers something that cannot take effect. + if (!sizeInput || !select) { return; } + var opt = select.options[select.selectedIndex]; + var scalable = !opt || opt.dataset.scalable !== '0'; + if (scalable) { + sizeInput.disabled = false; + sizeInput.title = ''; + sizeNote.textContent = ''; + } else { + sizeInput.disabled = true; + sizeInput.value = opt.dataset.nativeSize || ''; + sizeInput.title = 'This is a bitmap font; it renders at one fixed size.'; + sizeNote.textContent = 'fixed'; + } + } + + opts.columns.forEach(function (col) { + var inLayout = col.where === 'layout'; + var prop = inLayout ? own(axes, col.key) : own(props, col.key); + var cell; + if (!prop) { + // This element does not declare that field; keep the grid + // aligned with an empty cell. + row.appendChild(el('span')); + return; + } + var path = inLayout ? ['layout', key, col.key] : [key, col.key]; + var base = inLayout ? opts.layoutPrefix + '.' + key + : opts.prefix + '.' + key; + var node = control({ + key: col.key, + prop: prop, + name: base + '.' + col.key, + current: effective(value, path, prop, optional), + optional: optional, + fonts: opts.fonts, + // The element's declared size ceiling is what a fixed-size + // font has to fit under. + maxFixedSize: (props.font_size || {}).maximum || null, + onFontChange: function () { syncSize(fontSelect); } + }); + if (col.key === 'font') { fontSelect = node; } + if (col.key === 'font_size') { + sizeInput = node; + cell = el('div', { class: 'flex items-center' }); + cell.appendChild(node); + cell.appendChild(sizeNote); + node = cell; + } + row.appendChild(node); + }); + + if (fontSelect) { syncSize(fontSelect); } + return row; + } + + function header(columns) { + var row = el('div', { + class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300' + }); + ['Element'].concat(columns.map(function (c) { return c.label; })) + .forEach(function (label) { + row.appendChild(el('div', { + class: 'text-xs font-semibold text-gray-500 uppercase', + text: label + })); + }); + return row; + } + + function table(opts) { + var wrap = el('div', { class: 'style-editor-table' }); + var columns = columnsFor(opts.schema); + // Sized here rather than in CSS: the column count depends on what + // the plugin declared. + wrap.style.gridTemplateColumns = ''; + wrap.style.setProperty('--style-editor-columns', + 'minmax(7rem, 1.4fr) ' + columns.map(function (c) { + return COLUMN_WIDTHS[c.key] || '5rem'; + }).join(' ')); + wrap.appendChild(header(columns)); + elementKeys(opts.schema).forEach(function (key) { + wrap.appendChild(elementRow({ + schema: opts.schema, + key: key, + columns: columns, + prefix: opts.prefix, + layoutPrefix: opts.prefix + '.layout', + value: opts.value, + fonts: opts.fonts, + optional: opts.optional + })); + }); + return wrap; + } + + // ---- widget ---------------------------------------------------------- + + window.LEDMatrixWidgets.register('style-editor', { + name: 'Style Editor', + version: '1.1.0', + + render: function (container, config, value, options) { + var schema = (config && config.schema) || {}; + var base = (options && options.name) || 'customization'; + var current = value || {}; + + container.innerHTML = ''; + var root = el('div', { class: 'style-editor' }); + container.appendChild(root); + + loadFonts().then(function (fonts) { + var modeProps = ownObj(schema.properties || {}, 'modes').properties || {}; + var modes = Object.keys(modeProps); + + // A list, not a keyed object: the ids are mode names out of + // the schema, and nothing here needs a lookup by key. + var panels = []; + var tabs = null; + if (modes.length) { + tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' }); + root.appendChild(tabs); + } + + function panel(id, node) { + panels.push({ id: id, node: node }); + node.classList.add('style-editor-panel'); + root.appendChild(node); + } + + function show(id) { + panels.forEach(function (p) { + p.node.hidden = (p.id !== id); + }); + if (!tabs) { return; } + Array.prototype.forEach.call(tabs.children, function (b) { + b.classList.toggle('is-active', b.dataset.panel === id); + }); + } + + function tab(id, label) { + if (!tabs) { return; } + var b = el('button', { + type: 'button', + class: 'style-editor-tab text-sm px-3 py-1 rounded', + text: label + }); + b.dataset.panel = id; + b.addEventListener('click', function () { show(id); }); + tabs.appendChild(b); + } + + panel('__base__', table({ + schema: schema, prefix: base, value: current, + fonts: fonts, optional: false + })); + tab('__base__', 'All modes'); + + modes.forEach(function (mode) { + var modeSchema = ownObj(modeProps, mode); + var modeValue = at(current, ['modes', mode]) || {}; + var node = el('div'); + node.appendChild(el('p', { + class: 'text-xs text-gray-500 mb-2', + text: 'Anything left blank follows the "All modes" tab.' + })); + node.appendChild(table({ + schema: modeSchema, + prefix: base + '.modes.' + mode, + value: modeValue, + fonts: fonts, + optional: true + })); + panel(mode, node); + tab(mode, modeSchema.title || mode); + }); + + show('__base__'); + }); + }, + + getValue: function () { + // The inputs are ordinary named form fields; the form itself is + // the source of truth, so there is no separate value to hand back. + return null; + } + }); + + console.log('[StyleEditor] widget registered'); +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 87cd0a34..eb23905b 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -972,7 +972,6 @@ - @@ -1003,6 +1002,7 @@ + diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index fec1d16a..29038eba 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -1,20 +1,11 @@

Font Management

-

Manage custom fonts, overrides, and system font configuration for your LED matrix display.

+

Upload, preview and manage the fonts available to every plugin.

-
- -
-

Detected Manager Fonts

-
-
Loading...
-
-

Fonts currently in use by managers (auto-detected)

-
- +

Available Font Families

@@ -78,73 +69,6 @@
- -
-

Element Font Overrides

-

Override fonts for specific display elements. Changes take effect immediately.

- - -
-
- - -
- -
- - -
- -
- - -
- -
- -
-
- - -
-

Current Overrides

-
- -
No font overrides configured
-
-
-
-

Font Preview

@@ -206,13 +130,11 @@ // Initialize global variables on window object window.fontCatalog = window.fontCatalog || {}; window.fontTokens = window.fontTokens || {}; - window.fontOverrides = window.fontOverrides || {}; window.selectedFontFiles = window.selectedFontFiles || []; // Create references that can be reassigned var fontCatalog = window.fontCatalog; var fontTokens = window.fontTokens; - var fontOverrides = window.fontOverrides; var selectedFontFiles = window.selectedFontFiles; // Retry counter for initialization @@ -222,17 +144,15 @@ function initializeFontsTab() { // Allow re-initialization on each HTMX content swap // The window._fontsScriptLoaded guard prevents function redeclaration - const detectedEl = document.getElementById('detected-fonts'); const availableEl = document.getElementById('available-fonts'); - if (!detectedEl || !availableEl) { + if (!availableEl) { initRetryCount++; if (initRetryCount >= MAX_INIT_RETRIES) { console.error('Fonts tab elements not found after max retries, giving up'); return; } console.log('Fonts tab elements not found, retrying...', { - detectedFonts: !!detectedEl, availableFonts: !!availableEl, attempt: initRetryCount }); @@ -259,7 +179,6 @@ function initializeFontsTab() { const fontFileInput = document.getElementById('font-file-input'); const uploadFontsBtn = document.getElementById('upload-fonts-btn'); const cancelUploadBtn = document.getElementById('cancel-upload-btn'); - const addOverrideBtn = document.getElementById('add-override-btn'); const updatePreviewBtn = document.getElementById('update-preview-btn'); if (uploadDropzone && fontFileInput) { @@ -280,9 +199,6 @@ function initializeFontsTab() { cancelUploadBtn.addEventListener('click', cancelFontUpload); } - if (addOverrideBtn) { - addOverrideBtn.addEventListener('click', addFontOverride); - } if (updatePreviewBtn) { updatePreviewBtn.addEventListener('click', updateFontPreview); @@ -319,9 +235,8 @@ window.initializeFontsTab = initializeFontsTab; // Function to initialize when fonts content is loaded function tryInitializeFontsTab() { const fontsContent = document.getElementById('fonts-content'); - const detectedFonts = document.getElementById('detected-fonts'); - if (fontsContent && detectedFonts) { + if (fontsContent) { console.log('Fonts content detected, initializing...'); setTimeout(() => { initializeFontsTab(); @@ -355,7 +270,6 @@ async function initializeFontManagement() { try { await loadFontData(); populateFontSelects(); - displayCurrentOverrides(); updateFontPreview(); initializeFontUpload(); } catch (error) { @@ -365,44 +279,39 @@ async function initializeFontManagement() { } async function loadFontData() { - const detectedContainer = document.getElementById('detected-fonts'); const availableContainer = document.getElementById('available-fonts'); // Ensure containers exist before proceeding - if (!detectedContainer || !availableContainer) { + if (!availableContainer) { console.error('Font containers not found, cannot load font data'); return; } // Show loading states - detectedContainer.innerHTML = '
Loading font data...
'; availableContainer.innerHTML = '
Loading font data...
'; try { // Use absolute URLs to ensure they work when loaded via HTMX - const [catalogRes, tokensRes, overridesRes] = await Promise.all([ + const [catalogRes, tokensRes] = await Promise.all([ fetch(`/api/v3/fonts/catalog`), - fetch(`/api/v3/fonts/tokens`), - fetch(`/api/v3/fonts/overrides`) + fetch(`/api/v3/fonts/tokens`) ]); // Check if all responses are successful - if (!catalogRes.ok || !tokensRes.ok || !overridesRes.ok) { - const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}/${overridesRes.status}`; + if (!catalogRes.ok || !tokensRes.ok) { + const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}`; console.error('Font API error:', statusText); throw new Error(`Failed to load font data: ${statusText}`); } const catalogData = await catalogRes.json(); const tokensData = await tokensRes.json(); - const overridesData = await overridesRes.json(); // Validate response structure - if (!catalogData || !catalogData.data || !tokensData || !tokensData.data || !overridesData || !overridesData.data) { + if (!catalogData || !catalogData.data || !tokensData || !tokensData.data) { console.error('Invalid font API response structure:', { catalog: !!catalogData?.data, - tokens: !!tokensData?.data, - overrides: !!overridesData?.data + tokens: !!tokensData?.data }); throw new Error('Invalid response format from font API'); } @@ -410,27 +319,22 @@ async function loadFontData() { // Update both window properties and local references window.fontCatalog = catalogData.data.catalog || {}; window.fontTokens = tokensData.data.tokens || {}; - window.fontOverrides = overridesData.data.overrides || {}; // Update local variable references fontCatalog = window.fontCatalog; fontTokens = window.fontTokens; - fontOverrides = window.fontOverrides; // Update displays - updateDetectedFontsDisplay(); updateAvailableFontsDisplay(); console.log('Font data loaded successfully', { catalogSize: Object.keys(fontCatalog).length, - tokensSize: Object.keys(fontTokens).length, - overridesSize: Object.keys(fontOverrides).length + tokensSize: Object.keys(fontTokens).length }); } catch (error) { console.error('Error loading font data:', error); // Show error states - detectedContainer.innerHTML = '
Error loading font data. Please refresh the page.
'; availableContainer.innerHTML = '
Error loading font data. Please refresh the page.
'; // Only show notification if showNotification is available @@ -444,52 +348,6 @@ async function loadFontData() { } } -function updateDetectedFontsDisplay() { - const container = document.getElementById('detected-fonts'); - if (!container) return; - - // In a real implementation, this would collect font usage from all active managers - // For now, we'll simulate this by analyzing the font overrides and catalog - const detectedFonts = {}; - - // Check font overrides for active elements - for (const [elementKey, override] of Object.entries(fontOverrides)) { - if (override.family) { - detectedFonts[elementKey] = { - family: override.family, - size_px: override.size_px || 8, - usage_count: 1, // Would be actual usage count in real implementation - source: 'override' - }; - } - } - - // Check font catalog for commonly used fonts - for (const [fontKey, fontPath] of Object.entries(fontCatalog)) { - // Add some commonly used system fonts if not already in overrides - if (!detectedFonts[fontKey]) { - detectedFonts[fontKey] = { - family: fontKey, - size_px: 8, - usage_count: 1, - source: 'system' - }; - } - } - - if (Object.keys(detectedFonts).length === 0) { - container.innerHTML = '
No fonts detected yet (managers will register fonts when they render)
'; - return; - } - - const lines = []; - for (const [elementKey, fontInfo] of Object.entries(detectedFonts)) { - const sourceStr = fontInfo.source === 'override' ? ' [OVERRIDE]' : ' [SYSTEM]'; - lines.push(`${elementKey}: ${fontInfo.family}@${fontInfo.size_px}px (used ${fontInfo.usage_count}x)${sourceStr}`); - } - container.textContent = lines.join('\n'); -} - function updateAvailableFontsDisplay() { const container = document.getElementById('available-fonts'); if (!container) return; @@ -594,10 +452,9 @@ async function deleteFont(fontFamily) { function populateFontSelects() { // Populate font family dropdowns from catalog - const overrideSelect = document.getElementById('override-family'); const previewSelect = document.getElementById('preview-family'); - if (!overrideSelect || !previewSelect) return; + if (!previewSelect) return; // Get font entries sorted by display name const fontEntries = Object.entries(fontCatalog).map(([key, info]) => { @@ -608,13 +465,6 @@ function populateFontSelects() { }).sort((a, b) => a.displayName.localeCompare(b.displayName)); // Build options using DOM APIs to prevent XSS - // Clear and add default option for override select - overrideSelect.innerHTML = ''; - const defaultOption = document.createElement('option'); - defaultOption.value = ''; - defaultOption.textContent = 'Use default'; - overrideSelect.appendChild(defaultOption); - // Clear preview select previewSelect.innerHTML = ''; @@ -622,11 +472,6 @@ function populateFontSelects() { fontEntries.forEach(font => { const typeLabel = font.fontType ? ` (${font.fontType})` : ''; - const overrideOpt = document.createElement('option'); - overrideOpt.value = font.filename; - overrideOpt.textContent = font.displayName + typeLabel; - overrideSelect.appendChild(overrideOpt); - const previewOpt = document.createElement('option'); previewOpt.value = font.filename; previewOpt.textContent = font.displayName + typeLabel; @@ -641,187 +486,6 @@ function populateFontSelects() { console.log(`Populated font selects with ${fontEntries.length} fonts`); } -async function addFontOverride() { - const element = document.getElementById('override-element').value; - const family = document.getElementById('override-family').value; - const sizeToken = document.getElementById('override-size').value; - - if (!element) { - showNotification('Please select an element', 'warning'); - return; - } - - if (!family && !sizeToken) { - showNotification('Please specify at least a font family or size', 'warning'); - return; - } - - try { - const overrideData = {}; - if (family) overrideData.family = family; - if (sizeToken) { - const sizePx = fontTokens[sizeToken]; - if (sizePx) overrideData.size_px = sizePx; - } - - const response = await fetch(`/api/v3/fonts/overrides`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - [element]: overrideData - }) - }); - - if (!response.ok) { - const text = await response.text(); - let message; - try { - const errorData = JSON.parse(text); - message = errorData.message || `Server error: ${response.status}`; - } catch { - message = `Server error: ${response.status}`; - } - showNotification('Error adding font override: ' + message, 'error'); - return; - } - - const data = await response.json(); - if (data.status === 'success') { - showNotification('Font override added successfully', 'success'); - await loadFontData(); - displayCurrentOverrides(); - // Clear form - document.getElementById('override-element').value = ''; - document.getElementById('override-family').value = ''; - document.getElementById('override-size').value = ''; - } else { - showNotification('Error adding font override: ' + data.message, 'error'); - } - } catch (error) { - console.error('Error adding font override:', error); - showNotification('Error adding font override: ' + error, 'error'); - } -} - -async function deleteFontOverride(elementKey) { - if (!confirm(`Are you sure you want to remove the font override for "${elementKey}"?`)) { - return; - } - - try { - const response = await fetch(`/api/v3/fonts/overrides/${elementKey}`, { - method: 'DELETE' - }); - - if (!response.ok) { - const text = await response.text(); - let message; - try { - const errorData = JSON.parse(text); - message = errorData.message || `Server error: ${response.status}`; - } catch { - message = `Server error: ${response.status}`; - } - showNotification('Error removing font override: ' + message, 'error'); - return; - } - - const data = await response.json(); - if (data.status === 'success') { - showNotification('Font override removed successfully', 'success'); - await loadFontData(); - displayCurrentOverrides(); - } else { - showNotification('Error removing font override: ' + data.message, 'error'); - } - } catch (error) { - console.error('Error deleting font override:', error); - showNotification('Error removing font override: ' + error, 'error'); - } -} - -function displayCurrentOverrides() { - const container = document.getElementById('overrides-list'); - if (!container) return; - - if (Object.keys(fontOverrides).length === 0) { - container.innerHTML = '
No font overrides configured
'; - return; - } - - // Build list using DOM APIs to prevent XSS - container.innerHTML = ''; - Object.entries(fontOverrides).forEach(([elementKey, override]) => { - const elementName = getElementDisplayName(elementKey); - const settings = []; - - if (override.family) { - const familyName = getFontDisplayName(override.family); - settings.push(`Family: ${familyName}`); - } - - if (override.size_px) { - settings.push(`Size: ${override.size_px}px`); - } - - const row = document.createElement('div'); - row.className = 'flex items-center justify-between p-3 bg-white rounded border'; - - const infoDiv = document.createElement('div'); - - const nameDiv = document.createElement('div'); - nameDiv.className = 'font-medium text-gray-900'; - nameDiv.textContent = elementName; - - const settingsDiv = document.createElement('div'); - settingsDiv.className = 'text-sm text-gray-600'; - settingsDiv.textContent = settings.join(', '); - - infoDiv.appendChild(nameDiv); - infoDiv.appendChild(settingsDiv); - - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm'; - const trashIcon = document.createElement('i'); - trashIcon.className = 'fas fa-trash mr-1'; - deleteBtn.appendChild(trashIcon); - deleteBtn.appendChild(document.createTextNode('Remove')); - deleteBtn.dataset.elementKey = elementKey; - deleteBtn.addEventListener('click', function() { - deleteFontOverride(this.dataset.elementKey); - }); - - row.appendChild(infoDiv); - row.appendChild(deleteBtn); - container.appendChild(row); - }); -} - -function getElementDisplayName(elementKey) { - const names = { - 'nfl.live.score': 'NFL Live Score', - 'nfl.live.time': 'NFL Live Time', - 'nfl.live.team': 'NFL Live Team', - 'mlb.live.score': 'MLB Live Score', - 'nhl.live.score': 'NHL Live Score', - 'nba.live.score': 'NBA Live Score', - 'clock.time': 'Clock Time', - 'clock.date': 'Clock Date', - 'weather.current': 'Weather Current', - 'weather.forecast': 'Weather Forecast' - }; - return names[elementKey] || elementKey; -} - -function getFontDisplayName(fontKey) { - const names = { - 'press_start': 'Press Start 2P', - 'four_by_six': '4x6 Font', - 'matrix_light_6': 'Matrix Light 6' - }; - return names[fontKey] || fontKey; -} - async function updateFontPreview() { const previewImage = document.getElementById('font-preview-image'); const loadingText = document.getElementById('font-preview-loading'); diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 9e5f3707..07615448 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -71,6 +71,76 @@ } })(); + {% elif obj_widget == 'style-editor' %} + {# Composite per-element style editor. It renders its own inputs + with the same dotted names the generic renderer would produce + (customization.score_text.font, ...text_color.0, ...), so the + save/validate/merge pipeline is untouched -- no hidden JSON + blob, no new server-side parsing. It falls back to the normal + nested rendering if the widget cannot be loaded. #} + {% set obj_value = value if value is not none else {} %} +
+ +
+
+ {{ render_nested_section(key, prop, value, prefix, plugin_id) }} +
+
+ {% elif prop.properties %} {{ render_nested_section(key, prop, value, prefix, plugin_id) }} {% endif %} @@ -872,6 +942,68 @@ name="{{ full_key }}" value="{{ str_value }}" class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500"> + {% if str_widget %} + {# An x-widget the core does not ship may be supplied by the + plugin itself (manifest "widgets", served from its widgets/ + directory). Ask the loader for it; the text input above is + the fallback and stays put unless the widget really renders, + so a missing or broken widget degrades to an editable field + rather than dropping the value on save. #} +
+ + {% endif %} {% endif %} {% endif %}
@@ -1059,6 +1191,14 @@ {% endif %} {% endif %} {% endfor %} + {# Tell the save path which sections this form actually + drew. An unchecked checkbox posts nothing, so without + this the server cannot tell "the user cleared it" + from "the caller never had that field" -- and a + partial post would read as every box being off. #} + {% for key in tiers.basic + tiers.advanced %} + + {% endfor %} {% for key in tiers.basic %} {% set prop = schema.properties[key] %} {% set value = config[key] if key in config else none %}