mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3d68f23f6 | ||
|
|
ece2065e42 | ||
|
|
a016dd9446 | ||
|
|
da3cef0bc0 | ||
|
|
1df51b172a | ||
|
|
f9e9eeba00 | ||
|
|
a5342939af | ||
|
|
59144b0088 | ||
|
|
d28f562eb9 | ||
|
|
b950871ca8 | ||
|
|
a46a908c79 | ||
|
|
e86dc9c27b | ||
|
|
6cd886cd78 | ||
|
|
c5858f1e5e | ||
|
|
521f81272f | ||
|
|
90d6d91c27 | ||
|
|
f91f1b02e3 | ||
|
|
75026115d5 | ||
|
|
9998aaf51c | ||
|
|
2cfbc1e969 | ||
|
|
a694b23cd1 | ||
|
|
7216d254a9 | ||
|
|
0d7e409d77 |
@@ -17,6 +17,96 @@ release that ships it.
|
|||||||
accepts both, but the store flags the old spelling as deprecated
|
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`.
|
(`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/<plugin>/<widget>.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
|
## 3.3.0
|
||||||
|
|
||||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||||
|
|||||||
@@ -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.<element>`. |
|
||||||
|
| `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 <id>`.
|
||||||
|
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).
|
||||||
@@ -45,6 +45,8 @@ Going deeper:
|
|||||||
|
|
||||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
- [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_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_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_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
|
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||||
|
|||||||
+51
-11
@@ -285,7 +285,9 @@ Guidelines:
|
|||||||
|
|
||||||
### Step 1: Create Widget File
|
### 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
|
```javascript
|
||||||
// Ensure LEDMatrixWidgets registry is available
|
// 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`:
|
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
|
1. Check whether the widget is already registered in the core registry.
|
||||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||||
3. Render the widget using the registered `render` function
|
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
|
## 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
|
- ✅ Plugin widget loading system implemented
|
||||||
|
|
||||||
**Current Behavior:**
|
**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
|
- Widget handlers are registered and available globally
|
||||||
- Custom widgets can be created and registered
|
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||||
- Full client-side rendering is a future enhancement
|
and rendered on demand for `string`-typed fields
|
||||||
|
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||||
|
|
||||||
**Backwards Compatibility:**
|
**Backwards Compatibility:**
|
||||||
- All existing plugins using widgets continue to work without changes
|
- All existing plugins using widgets continue to work without changes
|
||||||
|
|||||||
@@ -257,6 +257,30 @@
|
|||||||
},
|
},
|
||||||
"description": "Web UI action definitions"
|
"description": "Web UI action definitions"
|
||||||
},
|
},
|
||||||
|
"widgets": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||||
|
"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 <name>.js."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable summary shown to plugin authors."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledmatrix_version": {
|
"ledmatrix_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||||
|
|||||||
+67
-16
@@ -35,6 +35,29 @@ from src.common.permission_utils import (
|
|||||||
# trade for not re-warning about a file nobody is going to add.
|
# trade for not re-warning about a file nobody is going to add.
|
||||||
MISSING_LOGO_RECHECK_SECONDS = 3600.0
|
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.
|
# Well above any real team logo; bounds what a remote URL can write to disk.
|
||||||
@@ -88,18 +111,23 @@ class LogoHelper:
|
|||||||
'Accept': 'image/*',
|
'Accept': 'image/*',
|
||||||
})
|
})
|
||||||
|
|
||||||
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
|
||||||
max_width: Optional[int] = 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 and resize a team logo.
|
Load and resize a team logo.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
team_abbr: Team abbreviation for caching
|
team_abbr: Team abbreviation for caching
|
||||||
logo_path: Path to the logo file
|
logo_path: Path to the logo file
|
||||||
max_width: Maximum width (defaults to display_width * 1.5)
|
max_width: Maximum width (defaults to display_width * 1.5)
|
||||||
max_height: Maximum height (defaults to display_height * 1.5)
|
max_height: Maximum height (defaults to display_height * 1.5)
|
||||||
|
scale: User's size multiplier for this image, from
|
||||||
|
``customization.layout.<element>.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:
|
Returns:
|
||||||
PIL Image object or None if loading fails
|
PIL Image object or None if loading fails
|
||||||
|
|
||||||
@@ -115,6 +143,12 @@ class LogoHelper:
|
|||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * 1.5)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
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}"
|
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||||
if cache_key in self._logo_cache:
|
if cache_key in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||||
@@ -146,7 +180,8 @@ class LogoHelper:
|
|||||||
logo = logo.convert('RGBA')
|
logo = logo.convert('RGBA')
|
||||||
|
|
||||||
# Resize if needed
|
# 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
|
# Cache the logo
|
||||||
self._cache_logo(cache_key, logo)
|
self._cache_logo(cache_key, logo)
|
||||||
@@ -158,10 +193,11 @@ class LogoHelper:
|
|||||||
self.logger.error(f"Error loading logo for {team_abbr}: {e}")
|
self.logger.error(f"Error loading logo for {team_abbr}: {e}")
|
||||||
return None
|
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,
|
logo_url: Optional[str] = None,
|
||||||
max_width: Optional[int] = 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.
|
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
|
# 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.
|
# trusting the file's existence is what left teams as grey boxes.
|
||||||
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
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
|
# Download if URL provided and file doesn't exist
|
||||||
if logo_url:
|
if logo_url:
|
||||||
@@ -193,7 +230,8 @@ class LogoHelper:
|
|||||||
# from the cache before touching the disk -- so without this the
|
# from the cache before touching the disk -- so without this the
|
||||||
# real logo would not appear until the process restarted.
|
# real logo would not appear until the process restarted.
|
||||||
self._invalidate_cached_logo(team_abbr, logo_path)
|
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:
|
except Exception as e:
|
||||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||||
# The retry failed, so restart the back-off. The stale
|
# 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,
|
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
|
||||||
max_height: Optional[int] = None) -> Image.Image:
|
max_height: Optional[int] = None,
|
||||||
"""Resize logo to fit display dimensions."""
|
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:
|
if max_width is None:
|
||||||
max_width = int(self.display_width * 1.5)
|
max_width = int(self.display_width * 1.5)
|
||||||
if max_height is None:
|
if max_height is None:
|
||||||
max_height = int(self.display_height * 1.5)
|
max_height = int(self.display_height * 1.5)
|
||||||
|
|
||||||
# Only resize if necessary
|
# Only resize if necessary
|
||||||
if logo.width <= max_width and logo.height <= max_height:
|
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
|
# Maintain aspect ratio
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||||
return logo
|
return logo
|
||||||
|
|||||||
+61
-28
@@ -99,38 +99,71 @@ def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def element_color(config: Optional[Dict[str, Any]], element: str,
|
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||||
default: Tuple[int, int, int] = (255, 255, 255)):
|
default: Tuple[int, int, int] = (255, 255, 255),
|
||||||
"""Per-element text colour from customization.<element>.text_color."""
|
mode: Optional[str] = None):
|
||||||
|
"""Per-element text colour from customization.<element>.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:
|
try:
|
||||||
cfg = (config or {}).get("customization", {}).get(element, {})
|
fonts = fonts or {}
|
||||||
value = cfg.get("text_color")
|
matches = [element for key, element in element_for_font.items()
|
||||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
if fonts.get(key) is font]
|
||||||
return tuple(max(0, min(255, int(c))) for c in value)
|
if len(matches) == 1:
|
||||||
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
return element_color(config, matches[0], default, mode)
|
||||||
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
if len(matches) > 1:
|
||||||
except (TypeError, ValueError):
|
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
|
pass
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
font, default: Tuple[int, int, int] = (255, 255, 255),
|
||||||
"""Colour for whichever element owns this face.
|
mode: Optional[str] = None):
|
||||||
|
"""Colour for whichever element owns this face, by this module's map."""
|
||||||
Matched on identity, and deliberately gives up when one object is
|
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
|
||||||
shared: the last-resort font path can hand the same face to several
|
mode)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def coerce_rgb(value, fallback):
|
def coerce_rgb(value, fallback):
|
||||||
@@ -448,7 +481,7 @@ def unshare_element_fonts(logger, fonts):
|
|||||||
path) are left shared, and their draws stay white as before.
|
path) are left shared, and their draws stay white as before.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PIL import ImageFont as _IF
|
from src.common.font_layout import load_truetype as _load
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
return fonts
|
return fonts
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -463,7 +496,7 @@ def unshare_element_fonts(logger, fonts):
|
|||||||
if not path or not size:
|
if not path or not size:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fonts[key] = _IF.truetype(path, size)
|
fonts[key] = _load(path, size)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Could not un-share the %s face; it keeps the default colour", key)
|
"Could not un-share the %s face; it keeps the default colour", key)
|
||||||
|
|||||||
@@ -134,19 +134,9 @@ class SportsGameRendererMixin:
|
|||||||
the element on the scroll/Vegas card too -- previously the schema
|
the element on the scroll/Vegas card too -- previously the schema
|
||||||
advertised these offsets but this renderer ignored them.
|
advertised these offsets but this renderer ignored them.
|
||||||
"""
|
"""
|
||||||
try:
|
from src.element_style import layout_offset
|
||||||
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
return layout_offset(self.config, element, axis, default,
|
||||||
value = (layout.get(element) or {}).get(axis, default)
|
getattr(self, "SKIN_MODE", None))
|
||||||
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
|
|
||||||
|
|
||||||
# ---- upcoming cards ------------------------------------------------
|
# ---- upcoming cards ------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
+100
-31
@@ -191,7 +191,8 @@ class SportsCoreSharedMixin:
|
|||||||
img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
|
img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
status = game.get("status_text", "N/A")
|
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))
|
self.display_manager.image.paste(img, (0, 0))
|
||||||
# Don't call update_display here, let subclasses handle it after drawing
|
# Don't call update_display here, let subclasses handle it after drawing
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -507,8 +508,10 @@ class SportsCoreSharedMixin:
|
|||||||
+ self._get_layout_offset('score', 'x_offset'))
|
+ self._get_layout_offset('score', 'x_offset'))
|
||||||
vs_y = (center_y - 3
|
vs_y = (center_y - 3
|
||||||
+ self._get_layout_offset('score', 'y_offset'))
|
+ self._get_layout_offset('score', 'y_offset'))
|
||||||
|
vs_x = self._aligned_x('score_text', vs_width, width, vs_x)
|
||||||
self._draw_text_with_outline(
|
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
|
# "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)
|
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
||||||
return fonts
|
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)):
|
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
"""Per-element text colour from customization.<element>.text_color."""
|
"""Per-element text colour from customization.<element>.text_color.
|
||||||
try:
|
|
||||||
cfg = (self.config or {}).get("customization", {}).get(element, {})
|
Mode-aware through SKIN_MODE, so Live and Recent instances of the
|
||||||
value = cfg.get("text_color")
|
same scoreboard resolve their own colours without any call site
|
||||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
passing a mode.
|
||||||
return tuple(max(0, min(255, int(c))) for c in value)
|
"""
|
||||||
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
from src.element_style import element_color as _shared
|
||||||
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
return _shared(self.config, element, default,
|
||||||
except (TypeError, ValueError):
|
getattr(self, "SKIN_MODE", None))
|
||||||
pass
|
|
||||||
return default
|
def _element_visible(self, element: str, default: bool = True) -> bool:
|
||||||
|
"""Whether ``customization.<element>.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.<element>.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.<element>.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):
|
def _unshare_element_fonts(self, fonts):
|
||||||
"""Give each colourable element its own face object.
|
"""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.
|
path) are left shared, and their draws stay white as before.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PIL import ImageFont as _IF
|
from src.common.font_layout import load_truetype as _load
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
return fonts
|
return fonts
|
||||||
seen = {}
|
seen = {}
|
||||||
@@ -802,7 +858,7 @@ class SportsCoreSharedMixin:
|
|||||||
if not path or not size:
|
if not path or not size:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fonts[key] = _IF.truetype(path, size)
|
fonts[key] = _load(path, size)
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Could not un-share the %s face; it keeps the default colour", key)
|
"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)):
|
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
"""Colour for whichever element owns this face.
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
Matched on identity, and deliberately gives up when one object is
|
The fallback for draw sites that were only ever handed a font. Prefer
|
||||||
shared: the last-resort font path can hand the same face to several
|
``element=`` on :meth:`_draw_text_with_outline`, which needs none of
|
||||||
keys, and there is no right answer for which element's colour that is.
|
this. Shared with the scroll card's copy so the narrowing rule that
|
||||||
White is what those draws used before, so ambiguity costs nothing.
|
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:
|
from src.common.sports_card import resolve_font_color
|
||||||
fonts = getattr(self, "fonts", None) or {}
|
return resolve_font_color(
|
||||||
matches = [element for key, element in self._ELEMENT_FOR_FONT.items()
|
getattr(self, "config", None), getattr(self, "fonts", None), font,
|
||||||
if fonts.get(key) is font]
|
default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None))
|
||||||
if len(matches) == 1:
|
|
||||||
return self._element_color(matches[0], default)
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
pass
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _draw_text_with_outline(
|
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
|
# Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get
|
||||||
# anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying
|
# anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying
|
||||||
# glyphs. 1-bit mode keeps strokes crisp.
|
# 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:
|
# and they only ever changed the font. An explicit fill still wins:
|
||||||
# the odds colours and the favourite-result score tint mean something
|
# the odds colours and the favourite-result score tint mean something
|
||||||
# the palette does not.
|
# 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)
|
fill = self._font_color(font)
|
||||||
draw.fontmode = "1"
|
draw.fontmode = "1"
|
||||||
x, y = position
|
x, y = position
|
||||||
|
|||||||
+967
-76
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,47 @@ from abc import ABC, abstractmethod
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
|
|
||||||
_shared_fallback_font_manager: Optional[Any] = None
|
_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:
|
def _fallback_font_manager() -> Any:
|
||||||
"""Shared FontManager for environments (unit tests, mocks) where the
|
"""Shared FontManager for environments (unit tests, mocks) where the
|
||||||
@@ -63,6 +99,12 @@ class BasePlugin(ABC):
|
|||||||
|
|
||||||
API_VERSION = "1.0.0"
|
API_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
#: Which ``customization.modes.<mode>`` 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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
@@ -260,6 +302,128 @@ class BasePlugin(ABC):
|
|||||||
self._layout_font_generation = generation
|
self._layout_font_generation = generation
|
||||||
return context
|
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.<mode>`` 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,
|
def draw_fit(self, text: str, box: Any,
|
||||||
color: tuple = (255, 255, 255),
|
color: tuple = (255, 255, 255),
|
||||||
ladder: Optional[Any] = None,
|
ladder: Optional[Any] = None,
|
||||||
|
|||||||
Vendored
+971
-996
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
+862
-2
@@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard):
|
|||||||
- style() never raises; malformed input degrades to the classic style.
|
- style() never raises; malformed input degrades to the classic style.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -23,7 +24,10 @@ import pytest
|
|||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
|
|
||||||
from src.element_style import (
|
from src.element_style import (
|
||||||
|
alias_keys,
|
||||||
ElementStyleResolver,
|
ElementStyleResolver,
|
||||||
|
_load_font_sized,
|
||||||
|
native_bdf_size,
|
||||||
defaults_from_schema,
|
defaults_from_schema,
|
||||||
defaults_from_schema_file,
|
defaults_from_schema_file,
|
||||||
expand_style_elements,
|
expand_style_elements,
|
||||||
@@ -173,10 +177,25 @@ class TestExpandStyleElements:
|
|||||||
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||||
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
||||||
|
|
||||||
def test_no_declaration_returns_same_object(self):
|
def test_a_schema_with_nothing_to_expand_is_returned_as_is(self):
|
||||||
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
|
"""No needless deep copy when there is nothing to do."""
|
||||||
empty = {"properties": {}}
|
empty = {"properties": {}}
|
||||||
assert expand_style_elements(empty) is empty
|
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):
|
def test_garbage_input_never_raises(self):
|
||||||
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
||||||
@@ -340,6 +359,19 @@ class TestDegradation:
|
|||||||
# The override IS honored as forced, but the face degrades safely.
|
# The override IS honored as forced, but the face degrades safely.
|
||||||
assert style.user_forced
|
assert style.user_forced
|
||||||
assert style.font is not None
|
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):
|
def test_empty_defaults_treats_config_as_reference_to_classic(self):
|
||||||
# No schema defaults at all: a config value equal to the classic
|
# No schema defaults at all: a config value equal to the classic
|
||||||
@@ -410,3 +442,831 @@ class TestResolverPlumbing:
|
|||||||
cust = schema["properties"]["customization"]["properties"]
|
cust = schema["properties"]["customization"]["properties"]
|
||||||
assert cust["title_text"]["x-style-managed"] is True
|
assert cust["title_text"]["x-style-managed"] is True
|
||||||
assert "title_text" in cust["layout"]["properties"]
|
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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
+148
-3
@@ -65,11 +65,37 @@ class TestColour:
|
|||||||
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||||
assert C.font_color(cfg, {"score": a, "team": b}, a) == (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):
|
def test_a_shared_face_takes_the_one_colour_that_was_configured(self):
|
||||||
"""One object used for two elements has no single right colour."""
|
"""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()
|
shared = object()
|
||||||
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
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:
|
class TestFavourites:
|
||||||
@@ -193,3 +219,122 @@ class TestTables:
|
|||||||
|
|
||||||
def test_month_and_weekday_tables_are_complete(self):
|
def test_month_and_weekday_tables_are_complete(self):
|
||||||
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
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)
|
||||||
|
|||||||
@@ -256,3 +256,48 @@ class TestContract:
|
|||||||
h._draw_upcoming_center(_draw(), {})
|
h._draw_upcoming_center(_draw(), {})
|
||||||
h._draw_upcoming_game_status(_draw(), {})
|
h._draw_upcoming_game_status(_draw(), {})
|
||||||
h.set_rankings_cache({})
|
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
|
||||||
|
|||||||
@@ -374,3 +374,91 @@ class TestPluginDirIsToldNotDeduced:
|
|||||||
assert host._schema_font_size("score") == 16, (
|
assert host._schema_font_size("score") == 16, (
|
||||||
"without the schema this is None, which is what made a configured "
|
"without the schema this is None, which is what made a configured "
|
||||||
"size look user-chosen and skipped the grid snap")
|
"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
|
||||||
|
|||||||
+15
-22
@@ -814,28 +814,21 @@ class TestFontsAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert 'tokens' in data.get('data', {}) or 'data' in data
|
assert 'tokens' in data.get('data', {}) or 'data' in data
|
||||||
|
|
||||||
def test_get_fonts_overrides(self, client):
|
def test_the_font_override_endpoints_are_gone(self, client):
|
||||||
"""Test getting font overrides."""
|
"""They reported success without doing anything: GET returned a
|
||||||
response = client.get('/api/v3/fonts/overrides')
|
hardcoded {}, POST and DELETE saved and deleted nothing. The panel
|
||||||
|
they served offered eleven element keys -- nfl.live.score,
|
||||||
assert response.status_code == 200
|
clock.time -- that no plugin has ever read, so wiring them to the
|
||||||
data = json.loads(response.data)
|
real FontManager methods would still have changed nothing on the
|
||||||
assert 'overrides' in data.get('data', {}) or 'data' in data
|
panel. Per-element font choice lives in each plugin's own config
|
||||||
|
editor now, against the elements that plugin actually has."""
|
||||||
def test_save_fonts_overrides(self, client):
|
# 405, not 404: the path still matches DELETE /fonts/<font_family>,
|
||||||
"""Test saving font overrides."""
|
# which now reads "overrides" as a font name. Nothing is routed to a
|
||||||
request_data = {
|
# handler for GET or POST, which is what matters here.
|
||||||
'weather': 'small',
|
assert client.get('/api/v3/fonts/overrides').status_code == 405
|
||||||
'clock': 'regular'
|
assert client.post('/api/v3/fonts/overrides',
|
||||||
}
|
data=json.dumps({}),
|
||||||
|
content_type='application/json').status_code == 405
|
||||||
response = client.post(
|
|
||||||
'/api/v3/fonts/overrides',
|
|
||||||
data=json.dumps(request_data),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
class TestAPIErrorHandling:
|
class TestAPIErrorHandling:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402
|
|||||||
_parse_form_value,
|
_parse_form_value,
|
||||||
_get_schema_property,
|
_get_schema_property,
|
||||||
_set_nested_value,
|
_set_nested_value,
|
||||||
|
_SKIP_FIELD,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,12 +222,21 @@ class TestSetNestedValue:
|
|||||||
_set_nested_value(config, "fifa.world.enabled", True)
|
_set_nested_value(config, "fifa.world.enabled", True)
|
||||||
assert 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}
|
config = {"a": 1}
|
||||||
_set_nested_value(config, "a", None)
|
_set_nested_value(config, "a", None)
|
||||||
assert config == {"a": 1}
|
assert config == {"a": None}
|
||||||
|
|
||||||
def test_none_sets_missing_key(self):
|
def test_none_sets_missing_key(self):
|
||||||
config = {}
|
config = {}
|
||||||
_set_nested_value(config, "a", None)
|
_set_nested_value(config, "a", None)
|
||||||
assert 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}
|
||||||
|
|||||||
@@ -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"<input type=\"hidden\" name=\"__rendered_section\"",
|
||||||
|
source)
|
||||||
|
assert marker, "marker loop not found"
|
||||||
|
assert "tiers.basic" in marker.group(1)
|
||||||
|
assert "tiers.advanced" in marker.group(1)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""The config form must render the same schema the save route validates.
|
||||||
|
|
||||||
|
The form used to read config_schema.json with a raw json.load while
|
||||||
|
api_v3.save_plugin_config went through SchemaManager. That is not a stylistic
|
||||||
|
difference: 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 the customization object has an
|
||||||
|
x-style-elements key and no ``properties``, so the template's object branch
|
||||||
|
matched nothing and the whole section rendered as empty space -- while saving
|
||||||
|
still validated against the expanded shape.
|
||||||
|
|
||||||
|
of-the-day ships the compact form, so this was live.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
COMPACT_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": False},
|
||||||
|
"customization": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Customization",
|
||||||
|
"x-style-modes": ["live", "recent"],
|
||||||
|
"x-style-elements": {
|
||||||
|
"title_text": {
|
||||||
|
"title": "Title",
|
||||||
|
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||||
|
"size": {"default": 8, "min": 4, "max": 16},
|
||||||
|
"color": {"default": [255, 255, 255]},
|
||||||
|
"offsets": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
MANUAL_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": False},
|
||||||
|
"customization": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Display Customization",
|
||||||
|
"properties": {
|
||||||
|
"title_text": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Title",
|
||||||
|
"properties": {
|
||||||
|
"font_size": {"type": "integer", "default": 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def render(tmp_path):
|
||||||
|
"""Render a plugin's config partial, with or without a SchemaManager."""
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
from src.plugin_system.schema_manager import SchemaManager
|
||||||
|
|
||||||
|
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||||
|
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||||
|
orig_sm = getattr(pv.pages_v3, "schema_manager", None)
|
||||||
|
|
||||||
|
def _render(schema, with_schema_manager=True, plugin_id="demo"):
|
||||||
|
pdir = tmp_path / "plugins" / plugin_id
|
||||||
|
pdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(pdir / "config_schema.json").write_text(json.dumps(schema),
|
||||||
|
encoding="utf-8")
|
||||||
|
(pdir / "manifest.json").write_text(
|
||||||
|
json.dumps({"id": plugin_id, "name": plugin_id}), encoding="utf-8")
|
||||||
|
|
||||||
|
pm = MagicMock()
|
||||||
|
pm.plugins_dir = str(tmp_path / "plugins")
|
||||||
|
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
|
||||||
|
|
||||||
|
pv.pages_v3.schema_manager = (
|
||||||
|
SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||||
|
project_root=tmp_path)
|
||||||
|
if with_schema_manager else None)
|
||||||
|
|
||||||
|
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="")
|
||||||
|
resp = app.test_client().get(f"/partials/plugin-config/{plugin_id}")
|
||||||
|
assert resp.status_code == 200, resp.status_code
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield _render
|
||||||
|
finally:
|
||||||
|
pv.pages_v3.plugin_manager = orig_pm
|
||||||
|
pv.pages_v3.config_manager = orig_cm
|
||||||
|
pv.pages_v3.schema_manager = orig_sm
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompactDeclarationRenders:
|
||||||
|
def test_the_element_fields_appear(self, render):
|
||||||
|
"""This is the regression: the section used to render empty."""
|
||||||
|
body = render(COMPACT_SCHEMA)
|
||||||
|
assert 'name="customization.title_text.font_size"' in body
|
||||||
|
# The font field is widget-rendered, so its <select name=...> 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
|
||||||
@@ -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/<plugin>/<widget>.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
|
||||||
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -168,6 +168,7 @@ pages_v3.config_manager = config_manager
|
|||||||
pages_v3.plugin_manager = plugin_manager
|
pages_v3.plugin_manager = plugin_manager
|
||||||
pages_v3.plugin_store_manager = plugin_store_manager
|
pages_v3.plugin_store_manager = plugin_store_manager
|
||||||
pages_v3.saved_repositories_manager = saved_repositories_manager
|
pages_v3.saved_repositories_manager = saved_repositories_manager
|
||||||
|
pages_v3.schema_manager = schema_manager
|
||||||
|
|
||||||
api_v3.config_manager = config_manager
|
api_v3.config_manager = config_manager
|
||||||
api_v3.plugin_manager = plugin_manager
|
api_v3.plugin_manager = plugin_manager
|
||||||
|
|||||||
@@ -808,6 +808,31 @@ def _is_field_required(key_path, schema):
|
|||||||
return field_name in required
|
return field_name in required
|
||||||
# Sentinel object to indicate a field should be skipped (not set in config)
|
# Sentinel object to indicate a field should be skipped (not set in config)
|
||||||
_SKIP_FIELD = object()
|
_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):
|
def _parse_form_value_with_schema(value, key_path, schema):
|
||||||
"""
|
"""
|
||||||
Parse a form value using schema information to determine correct type.
|
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
|
# Handle None/empty values
|
||||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
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 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 []
|
return []
|
||||||
# If schema says it's an object, return empty dict instead of None
|
# 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 {}
|
return {}
|
||||||
# If it's an optional string field, preserve empty string instead of None
|
# If it's an optional string field, preserve empty string instead of None
|
||||||
if prop and prop.get('type') == 'string':
|
if prop and prop.get('type') == 'string':
|
||||||
@@ -869,7 +900,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Handle arrays based on schema
|
# 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)
|
# Try parsing as JSON first (handles "[1,2,3]" format)
|
||||||
if stripped.startswith('['):
|
if stripped.startswith('['):
|
||||||
try:
|
try:
|
||||||
@@ -892,7 +923,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Handle objects based on schema
|
# Handle objects based on schema
|
||||||
if prop and prop.get('type') == 'object':
|
if prop and _schema_type_is(prop, 'object'):
|
||||||
# Try parsing as JSON
|
# Try parsing as JSON
|
||||||
if stripped.startswith('{'):
|
if stripped.startswith('{'):
|
||||||
try:
|
try:
|
||||||
@@ -995,17 +1026,64 @@ def _set_nested_value(config, key_path, value):
|
|||||||
current[seg] = {}
|
current[seg] = {}
|
||||||
current = 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)
|
# Set the final value. _SKIP_FIELD (checked above) is the only sentinel
|
||||||
if value is not None or segments[-1] not in current:
|
# for "leave the existing value alone" -- an explicit None here is a real
|
||||||
current[segments[-1]] = value
|
# value (e.g. the per-mode "inherit the base" override) and must overwrite
|
||||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
# 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.
|
"""Walk schema and set missing boolean form fields to False.
|
||||||
|
|
||||||
HTML checkboxes don't submit values when unchecked. When saving plugin config,
|
HTML checkboxes don't submit values when unchecked. When saving plugin plugin_config,
|
||||||
the backend starts from existing config (to support partial form updates), which
|
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
|
means an unchecked checkbox's old ``True`` value persists. This function detects
|
||||||
boolean schema properties not present in the form submission and explicitly sets
|
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
|
The top-level ``enabled`` field is excluded because it has its own preservation
|
||||||
logic in the save endpoint.
|
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``).
|
(e.g. ``feeds.custom_feeds.0.enabled``).
|
||||||
|
|
||||||
Args:
|
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
|
schema_props: Schema ``properties`` dict at the current nesting level
|
||||||
form_keys: Set of form field names that were submitted
|
form_keys: Set of form field names that were submitted
|
||||||
prefix: Dot-notation prefix for the current nesting level
|
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)
|
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
|
if sections is None and submitted_parents is None:
|
||||||
node = config_node if config_node is not None else config
|
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():
|
for prop_name, prop_schema in schema_props.items():
|
||||||
if not isinstance(prop_schema, dict):
|
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')
|
prop_type = prop_schema.get('type')
|
||||||
|
|
||||||
if prop_type == 'boolean' and full_path != 'enabled':
|
if prop_type == 'boolean' and full_path != 'enabled':
|
||||||
# If this boolean wasn't submitted in the form, it's an unchecked checkbox
|
# If this boolean wasn't submitted in the form, it's an unchecked
|
||||||
if full_path not in form_keys:
|
# 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:
|
if config_node is not None:
|
||||||
# Inside an array item — set directly on the item dict
|
# Inside an array item — set directly on the item dict
|
||||||
node[prop_name] = False
|
node[prop_name] = False
|
||||||
else:
|
else:
|
||||||
# Pure dict path — use helper
|
# 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:
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||||
# Recurse into nested objects
|
# 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):
|
if prop_name not in node or not isinstance(node[prop_name], dict):
|
||||||
node[prop_name] = {}
|
node[prop_name] = {}
|
||||||
_set_missing_booleans_to_false(
|
_set_missing_booleans_to_false(
|
||||||
config, prop_schema['properties'], form_keys, full_path,
|
plugin_config, prop_schema['properties'], form_keys, full_path,
|
||||||
config_node=node[prop_name]
|
config_node=node[prop_name],
|
||||||
|
sections=sections, submitted_parents=submitted_parents
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_set_missing_booleans_to_false(
|
_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':
|
elif prop_type == 'array':
|
||||||
@@ -1075,15 +1164,15 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
|
|||||||
if not indices:
|
if not indices:
|
||||||
continue
|
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 config_node is not None:
|
||||||
if prop_name not in node or not isinstance(node[prop_name], list):
|
if prop_name not in node or not isinstance(node[prop_name], list):
|
||||||
node[prop_name] = []
|
node[prop_name] = []
|
||||||
array_list = node[prop_name]
|
array_list = node[prop_name]
|
||||||
else:
|
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('.')
|
parts = full_path.split('.')
|
||||||
current = config
|
current = plugin_config
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
if part not in current or not isinstance(current[part], dict):
|
if part not in current or not isinstance(current[part], dict):
|
||||||
current[part] = {}
|
current[part] = {}
|
||||||
@@ -1102,8 +1191,9 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
|
|||||||
array_list[idx] = {}
|
array_list[idx] = {}
|
||||||
item_prefix = f"{full_path}.{idx}"
|
item_prefix = f"{full_path}.{idx}"
|
||||||
_set_missing_booleans_to_false(
|
_set_missing_booleans_to_false(
|
||||||
config, items_schema['properties'], form_keys, item_prefix,
|
plugin_config, items_schema['properties'], form_keys, item_prefix,
|
||||||
config_node=array_list[idx]
|
config_node=array_list[idx],
|
||||||
|
sections=sections, submitted_parents=submitted_parents
|
||||||
)
|
)
|
||||||
def _enhance_schema_with_core_properties(schema):
|
def _enhance_schema_with_core_properties(schema):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ def get_fonts_catalog():
|
|||||||
# Check if this is a system font (cannot be deleted)
|
# Check if this is a system font (cannot be deleted)
|
||||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
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] = {
|
catalog[catalog_key] = {
|
||||||
'filename': filename,
|
'filename': filename,
|
||||||
'family_name': family_name,
|
'family_name': family_name,
|
||||||
@@ -90,6 +104,8 @@ def get_fonts_catalog():
|
|||||||
'path': relative_path,
|
'path': relative_path,
|
||||||
'type': font_type,
|
'type': font_type,
|
||||||
'is_system': is_system,
|
'is_system': is_system,
|
||||||
|
'scalable': font_type != 'bdf',
|
||||||
|
'native_size': native_size,
|
||||||
'metadata': metadata if metadata else None
|
'metadata': metadata if metadata else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,39 +140,6 @@ def get_font_tokens():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
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/<element_key>', 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'])
|
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||||
def upload_font():
|
def upload_font():
|
||||||
"""Upload font file"""
|
"""Upload font file"""
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ endpoint names are unchanged by living here.
|
|||||||
"""
|
"""
|
||||||
from web_interface.blueprints.api_v3 import (
|
from web_interface.blueprints.api_v3 import (
|
||||||
ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
|
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,
|
_do_transactional_uninstall, _enhance_schema_with_core_properties,
|
||||||
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
|
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
|
||||||
_installed_plugin_ids, _is_plugin_update_available,
|
_installed_plugin_ids, _is_plugin_update_available,
|
||||||
_parse_form_value_with_schema, _prune_credential_backups,
|
_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,
|
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
|
||||||
api_v3, datetime, deep_merge, describe_exception, error_response,
|
api_v3, datetime, deep_merge, describe_exception, error_response,
|
||||||
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
||||||
@@ -1729,6 +1730,11 @@ def save_plugin_config():
|
|||||||
# Convert form data to config dict
|
# Convert form data to config dict
|
||||||
# Form fields can use dot notation for nested values (e.g., "transition.type")
|
# Form fields can use dot notation for nested values (e.g., "transition.type")
|
||||||
form_data = request.form.to_dict()
|
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)
|
# 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
|
# 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():
|
if last_part.isdigit():
|
||||||
# Get schema property for the base path to verify it's an array
|
# Get schema property for the base path to verify it's an array
|
||||||
base_prop = _get_schema_property(schema, base_path)
|
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
|
# This is an array index field
|
||||||
index = int(last_part)
|
index = int(last_part)
|
||||||
if base_path not in array_fields:
|
if base_path not in array_fields:
|
||||||
@@ -1796,6 +1802,16 @@ def save_plugin_config():
|
|||||||
# Sort by index and extract values
|
# Sort by index and extract values
|
||||||
index_values.sort(key=lambda x: x[0])
|
index_values.sort(key=lambda x: x[0])
|
||||||
values = [v for _, v in index_values]
|
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
|
# Combine values into comma-separated string for parsing
|
||||||
combined_value = ', '.join(str(v) for v in values)
|
combined_value = ', '.join(str(v) for v in values)
|
||||||
# Parse as array using schema
|
# 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.
|
# Walk the schema and set any boolean fields missing from form data to False.
|
||||||
if schema and 'properties' in schema:
|
if schema and 'properties' in schema:
|
||||||
form_keys = set(request.form.keys())
|
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)
|
# Get schema manager instance (for JSON requests)
|
||||||
schema_mgr = api_v3.schema_manager
|
schema_mgr = api_v3.schema_manager
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
|||||||
# Strict allowlists for URL-derived values used in path and script operations.
|
# 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_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_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.web_interface.secret_helpers import mask_secret_fields
|
||||||
from src.common.path_safety import resolve_under, safe_path_component
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ logger = logging.getLogger(__name__)
|
|||||||
config_manager = None
|
config_manager = None
|
||||||
plugin_manager = None
|
plugin_manager = None
|
||||||
plugin_store_manager = None
|
plugin_store_manager = None
|
||||||
|
schema_manager = None
|
||||||
|
|
||||||
pages_v3 = Blueprint('pages_v3', __name__)
|
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)
|
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
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 ``<name>.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/<plugin_id>/<widget_name>.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():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
try:
|
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
|
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)
|
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 = {}
|
||||||
schema_path = resolve_under(_plugin_dir, "config_schema.json")
|
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
||||||
if schema_path is not None and schema_path.exists():
|
if schema_mgr is not None:
|
||||||
try:
|
try:
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
schema = schema_mgr.load_schema(plugin_id, use_cache=False) or {}
|
||||||
schema = json.load(f)
|
|
||||||
except Exception as e:
|
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
|
# Get web UI actions from plugin manifest
|
||||||
web_ui_actions = []
|
web_ui_actions = []
|
||||||
|
|||||||
@@ -1412,3 +1412,85 @@ button.bg-white {
|
|||||||
max-width: calc(100vw - 2rem);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -752,586 +752,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
generateConfigForm(pluginId, config, schema, webUiActions = []) {
|
|
||||||
// Safety check - if schema/config not ready, return empty
|
|
||||||
if (!pluginId || !config) {
|
|
||||||
return '<div class="text-gray-500">Loading configuration...</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 += `
|
|
||||||
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
|
||||||
<button type="button"
|
|
||||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
|
||||||
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
|
||||||
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
||||||
</button>
|
|
||||||
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Recursively generate fields for nested properties
|
|
||||||
// Get ordered properties if x-propertyOrder is defined
|
|
||||||
let nestedPropertyEntries = Object.entries(prop.properties);
|
|
||||||
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
|
|
||||||
const order = prop['x-propertyOrder'];
|
|
||||||
const orderedEntries = [];
|
|
||||||
const unorderedEntries = [];
|
|
||||||
|
|
||||||
// Separate ordered and unordered properties
|
|
||||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
||||||
const index = order.indexOf(nestedKey);
|
|
||||||
if (index !== -1) {
|
|
||||||
orderedEntries[index] = [nestedKey, nestedProp];
|
|
||||||
} else {
|
|
||||||
unorderedEntries.push([nestedKey, nestedProp]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
||||||
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
||||||
// Use config value if it exists and is not null (including false), otherwise use schema default
|
|
||||||
// Check if key exists in config and value is not null/undefined
|
|
||||||
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
|
|
||||||
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
||||||
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
|
|
||||||
const nestedValue = hasValue ? nestedConfig[nestedKey] :
|
|
||||||
(nestedProp.default !== undefined ? nestedProp.default :
|
|
||||||
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
|
|
||||||
|
|
||||||
// Debug logging for file-upload widgets
|
|
||||||
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
|
|
||||||
debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
|
||||||
}
|
|
||||||
|
|
||||||
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
html += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add extra spacing after nested sections to prevent overlap with next section
|
|
||||||
if (nestingDepth > 0) {
|
|
||||||
html += `<div class="mb-2"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular (non-nested) field
|
|
||||||
html += `<div class="form-group">`;
|
|
||||||
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
|
||||||
|
|
||||||
if (description) {
|
|
||||||
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate appropriate input based on type
|
|
||||||
if (prop.type === 'boolean') {
|
|
||||||
html += `<label class="flex items-center">`;
|
|
||||||
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
|
||||||
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
|
||||||
html += `</label>`;
|
|
||||||
} 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') ?
|
|
||||||
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
|
||||||
|
|
||||||
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
||||||
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 += `
|
|
||||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
||||||
<!-- File Upload Drop Zone -->
|
|
||||||
<div id="${fieldId}_drop_zone"
|
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
||||||
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
||||||
ondragover="event.preventDefault()"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
|
||||||
<input type="file"
|
|
||||||
id="${fieldId}_file_input"
|
|
||||||
multiple
|
|
||||||
accept="${allowedTypes.join(',')}"
|
|
||||||
style="display: none;"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Uploaded Images List -->
|
|
||||||
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
||||||
${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, '>').replace(/"/g, '"');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div class="flex items-center space-x-3 flex-1">
|
|
||||||
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
|
||||||
alt="${(img.filename || '').replace(/"/g, '"')}"
|
|
||||||
class="w-16 h-16 object-cover rounded"
|
|
||||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
|
||||||
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
|
||||||
<i class="fas fa-image text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
||||||
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
|
||||||
<p class="text-xs text-blue-600 mt-1">
|
|
||||||
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
|
||||||
<button type="button"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
data-image-id="${img.id || ''}"
|
|
||||||
data-image-idx="${idx}"
|
|
||||||
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
|
||||||
class="text-blue-600 hover:text-blue-800 p-2"
|
|
||||||
title="Schedule this image">
|
|
||||||
<i class="fas fa-calendar-alt"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
data-image-id="${img.id || ''}"
|
|
||||||
data-plugin-id="${safePluginId}"
|
|
||||||
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
|
||||||
class="text-red-600 hover:text-red-800 p-2"
|
|
||||||
title="Delete image">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Schedule widget will be inserted here when opened -->
|
|
||||||
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden input to store image data -->
|
|
||||||
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
// Regular array input
|
|
||||||
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
|
||||||
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
||||||
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
|
||||||
}
|
|
||||||
} else if (prop.enum) {
|
|
||||||
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
||||||
prop.enum.forEach(option => {
|
|
||||||
const selected = value === option ? 'selected' : '';
|
|
||||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
||||||
});
|
|
||||||
html += `</select>`;
|
|
||||||
} 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 += `
|
|
||||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
||||||
<div id="${fieldId}_file_upload"
|
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
||||||
<input type="file"
|
|
||||||
id="${fieldId}_file_input"
|
|
||||||
accept="${allowedExtensions.join(',')}"
|
|
||||||
style="display: none;"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
data-upload-endpoint="${uploadEndpoint}"
|
|
||||||
data-target-filename="${targetFilename}"
|
|
||||||
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
|
||||||
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
|
||||||
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
|
||||||
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
// Default to text input
|
|
||||||
const maxLength = prop.maxLength || '';
|
|
||||||
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
||||||
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
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 += `
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 += `
|
|
||||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-medium ${colors.text} mb-1">
|
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button"
|
|
||||||
id="${actionId}"
|
|
||||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
||||||
data-plugin-id="${safePluginId}"
|
|
||||||
data-action-id="${action.id}"
|
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
formHtml += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
`;
|
|
||||||
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 += `
|
|
||||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h4 class="font-medium ${colors.text} mb-1">
|
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
||||||
</div>
|
|
||||||
<button type="button"
|
|
||||||
id="${actionId}"
|
|
||||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
||||||
data-plugin-id="${safePluginId}"
|
|
||||||
data-action-id="${action.id}"
|
|
||||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
|
||||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
actionsHtml += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
|
||||||
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
|
||||||
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
|
||||||
</div>
|
|
||||||
${actionsHtml}
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Helper function to get schema property type for a field path
|
// Helper function to get schema property type for a field path
|
||||||
getSchemaPropertyType(schema, path) {
|
getSchemaPropertyType(schema, path) {
|
||||||
if (!schema || !schema.properties) return null;
|
if (!schema || !schema.properties) return 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<Object>} 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<Object>} 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<Object>} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
```json
|
||||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
{
|
||||||
|
"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
|
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 API Reference
|
||||||
|
|
||||||
### Widget Definition Object
|
### Widget Definition Object
|
||||||
|
|||||||
@@ -121,7 +121,9 @@
|
|||||||
family: family,
|
family: family,
|
||||||
display_name: info.display_name || generateDisplayName(info.filename || family),
|
display_name: info.display_name || generateDisplayName(info.filename || family),
|
||||||
path: info.path,
|
path: info.path,
|
||||||
type: info.type || 'unknown'
|
type: info.type || 'unknown',
|
||||||
|
scalable: info.scalable,
|
||||||
|
native_size: info.native_size
|
||||||
}));
|
}));
|
||||||
} else if (Array.isArray(data)) {
|
} else if (Array.isArray(data)) {
|
||||||
// Direct array format
|
// Direct array format
|
||||||
@@ -174,6 +176,12 @@
|
|||||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||||
const placeholder = xOptions.placeholder || 'Select a font...';
|
const placeholder = xOptions.placeholder || 'Select a font...';
|
||||||
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
|
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 showPreview = xOptions.showPreview === true;
|
||||||
const disabled = xOptions.disabled === true;
|
const disabled = xOptions.disabled === true;
|
||||||
const required = xOptions.required === true;
|
const required = xOptions.required === true;
|
||||||
@@ -204,6 +212,16 @@
|
|||||||
return filterTypes.some(t => t.toLowerCase() === fontType);
|
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
|
// Build select HTML
|
||||||
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
|
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
|
||||||
|
|||||||
@@ -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.<mode>`, 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 <select> shows its first option, which for
|
||||||
|
* fonts is whatever sorts first alphabetically -- so an untouched
|
||||||
|
* scoreboard claimed every element used 10x20.bdf, and the size control
|
||||||
|
* then locked itself to that bitmap font's fixed size. A mode tab is the
|
||||||
|
* opposite: blank there means inherit, so it must stay blank.
|
||||||
|
*/
|
||||||
|
function effective(value, path, prop, optional) {
|
||||||
|
var v = at(value, path);
|
||||||
|
if (v !== undefined && v !== null) { return v; }
|
||||||
|
if (optional) { return undefined; }
|
||||||
|
return prop ? prop.default : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element blocks in a customization schema, in declared order.
|
||||||
|
* `layout` and `modes` are containers, not elements.
|
||||||
|
*/
|
||||||
|
function elementKeys(schema) {
|
||||||
|
var props = (schema && schema.properties) || {};
|
||||||
|
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||||
|
return order.filter(function (k) {
|
||||||
|
return k !== 'layout' && k !== 'modes'
|
||||||
|
&& own(props, k) && ownObj(props, k).properties;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleOf(schema, key) {
|
||||||
|
var prop = ownObj(schema.properties || {}, key);
|
||||||
|
return prop.title || key.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- individual controls -------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonts this field can actually honour.
|
||||||
|
*
|
||||||
|
* A bitmap (BDF) face renders at the one size baked into the file and
|
||||||
|
* ignores the size setting, so an element that caps size at 16 can
|
||||||
|
* still be handed a 27px face and overflow a 32px panel. The cap comes
|
||||||
|
* from the element's own font_size.maximum, so this hides exactly the
|
||||||
|
* faces that cannot meet the limit the plugin already declared --
|
||||||
|
* rather than a list curated by hand, which is what these fields used
|
||||||
|
* to carry and why an uploaded font could never appear in one.
|
||||||
|
*/
|
||||||
|
function usableFonts(fonts, maxFixedSize, current) {
|
||||||
|
if (!maxFixedSize) { return fonts; }
|
||||||
|
return fonts.filter(function (f) {
|
||||||
|
// Keep the font already saved on this element even if it no
|
||||||
|
// longer fits the cap -- dropping it would leave the select
|
||||||
|
// with nothing chosen and silently misrepresent the config.
|
||||||
|
if (current && f.filename === current) { return true; }
|
||||||
|
if (f.scalable !== false) { return true; }
|
||||||
|
// An unknown native size is not evidence it is too big.
|
||||||
|
if (!f.nativeSize) { return true; }
|
||||||
|
return f.nativeSize <= maxFixedSize;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontControl(name, current, fonts, optional, onChange) {
|
||||||
|
var select = el('select', {
|
||||||
|
name: name,
|
||||||
|
class: 'form-control text-sm style-editor-font'
|
||||||
|
});
|
||||||
|
if (optional) {
|
||||||
|
select.appendChild(el('option', { value: '', text: 'Inherit' }));
|
||||||
|
}
|
||||||
|
fonts.forEach(function (f) {
|
||||||
|
var opt = el('option', { value: f.filename, text: f.label });
|
||||||
|
opt.dataset.scalable = f.scalable ? '1' : '0';
|
||||||
|
opt.dataset.nativeSize = f.nativeSize || '';
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (current) { select.value = current; }
|
||||||
|
if (onChange) { select.addEventListener('change', onChange); }
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumControl(name, current, choices, optional) {
|
||||||
|
var select = el('select', {
|
||||||
|
name: name,
|
||||||
|
class: 'form-control text-sm style-editor-enum'
|
||||||
|
});
|
||||||
|
if (optional) {
|
||||||
|
select.appendChild(el('option', { value: '', text: 'Inherit' }));
|
||||||
|
}
|
||||||
|
choices.forEach(function (c) {
|
||||||
|
select.appendChild(el('option', {
|
||||||
|
value: c,
|
||||||
|
text: String(c).replace(/_/g, ' ')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
if (current !== undefined && current !== null) { select.value = current; }
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanControl(name, current, optional) {
|
||||||
|
if (optional) {
|
||||||
|
// Three states, not two: on, off, and "follow the base". A bare
|
||||||
|
// checkbox cannot say the third, and an unchecked box would read
|
||||||
|
// as "hide this in live mode" rather than "no preference".
|
||||||
|
return enumControl(name, boolAsString(current),
|
||||||
|
['true', 'false'], true);
|
||||||
|
}
|
||||||
|
// An unchecked checkbox posts nothing at all, so the hidden field
|
||||||
|
// carries the value and the checkbox drives it. The save path turns
|
||||||
|
// "true"/"false" into a real boolean.
|
||||||
|
var wrap = el('div', { class: 'flex items-center' });
|
||||||
|
var hidden = el('input', { type: 'hidden', name: name });
|
||||||
|
var box = el('input', { type: 'checkbox', class: 'style-editor-check' });
|
||||||
|
box.checked = current !== false;
|
||||||
|
hidden.value = box.checked ? 'true' : 'false';
|
||||||
|
box.addEventListener('change', function () {
|
||||||
|
hidden.value = box.checked ? 'true' : 'false';
|
||||||
|
});
|
||||||
|
wrap.appendChild(box);
|
||||||
|
wrap.appendChild(hidden);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolAsString(value) {
|
||||||
|
if (value === true) { return 'true'; }
|
||||||
|
if (value === false) { return 'false'; }
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberControl(name, current, prop, placeholder) {
|
||||||
|
var input = el('input', {
|
||||||
|
type: 'number',
|
||||||
|
name: name,
|
||||||
|
class: 'form-control text-sm style-editor-size',
|
||||||
|
placeholder: placeholder || ''
|
||||||
|
});
|
||||||
|
if (prop && prop.minimum !== undefined) { input.min = prop.minimum; }
|
||||||
|
if (prop && prop.maximum !== undefined) { input.max = prop.maximum; }
|
||||||
|
if (prop && prop.type
|
||||||
|
&& String(prop.type).indexOf('number') !== -1) {
|
||||||
|
input.step = 'any';
|
||||||
|
}
|
||||||
|
// Blank stays blank. For a mode field that is the inherit sentinel;
|
||||||
|
// writing 0 here would pin the mode to the base on the next save.
|
||||||
|
if (current !== undefined && current !== null) { input.value = current; }
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(rgb) {
|
||||||
|
if (!Array.isArray(rgb) || rgb.length < 3) { return '#ffffff'; }
|
||||||
|
return '#' + rgb.slice(0, 3).map(function (c) {
|
||||||
|
var v = Math.max(0, Math.min(255, parseInt(c, 10) || 0));
|
||||||
|
return ('0' + v.toString(16)).slice(-2);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function colourControl(name, current, optional) {
|
||||||
|
// Three number inputs carry the value (name.0/.1/.2 is what the save
|
||||||
|
// path recombines); the colour swatch is the human control and just
|
||||||
|
// drives them.
|
||||||
|
var wrap = el('div', { class: 'flex items-center gap-1' });
|
||||||
|
var has = Array.isArray(current) && current.length >= 3;
|
||||||
|
var startValues = has ? current.slice(0, 3) : [];
|
||||||
|
var channels = startValues.concat([null, null, null])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(function (channelValue, i) {
|
||||||
|
var input = el('input', {
|
||||||
|
type: 'hidden', name: name + '.' + i
|
||||||
|
});
|
||||||
|
if (has) { input.value = channelValue; }
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A disabled input is not submitted at all, which is how an unset
|
||||||
|
// optional colour says nothing rather than posting three empty
|
||||||
|
// strings. The server copes with those too, but not posting them is
|
||||||
|
// both clearer and one less thing depending on that.
|
||||||
|
function setSubmitted(on) {
|
||||||
|
channels.forEach(function (c) { c.disabled = !on; });
|
||||||
|
}
|
||||||
|
setSubmitted(!optional || has);
|
||||||
|
|
||||||
|
var clearBtn = null;
|
||||||
|
var swatch = el('input', {
|
||||||
|
type: 'color',
|
||||||
|
class: 'style-editor-colour',
|
||||||
|
value: has ? toHex(current) : '#ffffff'
|
||||||
|
});
|
||||||
|
swatch.addEventListener('input', function () {
|
||||||
|
var hex = swatch.value;
|
||||||
|
channels.forEach(function (channel, i) {
|
||||||
|
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
|
||||||
|
});
|
||||||
|
setSubmitted(true);
|
||||||
|
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||||
|
});
|
||||||
|
wrap.appendChild(swatch);
|
||||||
|
channels.forEach(function (c) { wrap.appendChild(c); });
|
||||||
|
|
||||||
|
if (optional) {
|
||||||
|
// A mode colour must be able to go back to "inherit", which means
|
||||||
|
// posting nothing at all -- an <input type=color> 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');
|
||||||
|
})();
|
||||||
@@ -972,7 +972,6 @@
|
|||||||
<!-- Load plugin management modules -->
|
<!-- Load plugin management modules -->
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
||||||
<!-- Shared search/filter/sort controller for the plugin card grids -->
|
<!-- Shared search/filter/sort controller for the plugin card grids -->
|
||||||
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
|
||||||
@@ -1003,6 +1002,7 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='v3/js/widgets/style-editor.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Font Management</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Font Management</h2>
|
||||||
<p class="mt-1 text-sm text-gray-600">Manage custom fonts, overrides, and system font configuration for your LED matrix display.</p>
|
<p class="mt-1 text-sm text-gray-600">Upload, preview and manage the fonts available to every plugin.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Font System Overview -->
|
<!-- Font System Overview -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6 mb-8">
|
<div class="mb-8">
|
||||||
<!-- Detected Fonts from Managers -->
|
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Detected Manager Fonts</h3>
|
|
||||||
<div id="detected-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
|
|
||||||
<div class="text-gray-400">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 mt-2">Fonts currently in use by managers (auto-detected)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Available Fonts -->
|
<!-- Available Fonts -->
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
||||||
@@ -78,73 +69,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Font Overrides -->
|
|
||||||
<div class="bg-gray-50 rounded-lg p-4 mb-8">
|
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-4">Element Font Overrides</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">Override fonts for specific display elements. Changes take effect immediately.</p>
|
|
||||||
|
|
||||||
<!-- Add New Override -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4 p-4 bg-white rounded border">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Element</label>
|
|
||||||
<select id="override-element" class="form-control text-sm">
|
|
||||||
<option value="">Select an element...</option>
|
|
||||||
<optgroup label="Sports">
|
|
||||||
<option value="nfl.live.score">NFL Live Score</option>
|
|
||||||
<option value="nfl.live.time">NFL Live Time</option>
|
|
||||||
<option value="nfl.live.team">NFL Live Team</option>
|
|
||||||
<option value="mlb.live.score">MLB Live Score</option>
|
|
||||||
<option value="nhl.live.score">NHL Live Score</option>
|
|
||||||
<option value="nba.live.score">NBA Live Score</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Clock">
|
|
||||||
<option value="clock.time">Clock Time</option>
|
|
||||||
<option value="clock.date">Clock Date</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Weather">
|
|
||||||
<option value="weather.current">Weather Current</option>
|
|
||||||
<option value="weather.forecast">Weather Forecast</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
|
|
||||||
<select id="override-family" class="form-control text-sm">
|
|
||||||
<option value="">Use default</option>
|
|
||||||
<!-- Dynamically populated from font catalog -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
|
|
||||||
<select id="override-size" class="form-control text-sm">
|
|
||||||
<option value="">Use default</option>
|
|
||||||
<option value="xs">Extra Small (6px)</option>
|
|
||||||
<option value="sm">Small (8px)</option>
|
|
||||||
<option value="md">Medium (10px)</option>
|
|
||||||
<option value="lg">Large (12px)</option>
|
|
||||||
<option value="xl">Extra Large (14px)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button id="add-override-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
|
|
||||||
<i class="fas fa-plus mr-2"></i>Add Override
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Overrides List -->
|
|
||||||
<div id="overrides-container">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Current Overrides</h4>
|
|
||||||
<div id="overrides-list" class="space-y-2">
|
|
||||||
<!-- Overrides will be populated here -->
|
|
||||||
<div class="text-gray-500 text-sm italic">No font overrides configured</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Font Preview -->
|
<!-- Font Preview -->
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
|
||||||
@@ -206,13 +130,11 @@
|
|||||||
// Initialize global variables on window object
|
// Initialize global variables on window object
|
||||||
window.fontCatalog = window.fontCatalog || {};
|
window.fontCatalog = window.fontCatalog || {};
|
||||||
window.fontTokens = window.fontTokens || {};
|
window.fontTokens = window.fontTokens || {};
|
||||||
window.fontOverrides = window.fontOverrides || {};
|
|
||||||
window.selectedFontFiles = window.selectedFontFiles || [];
|
window.selectedFontFiles = window.selectedFontFiles || [];
|
||||||
|
|
||||||
// Create references that can be reassigned
|
// Create references that can be reassigned
|
||||||
var fontCatalog = window.fontCatalog;
|
var fontCatalog = window.fontCatalog;
|
||||||
var fontTokens = window.fontTokens;
|
var fontTokens = window.fontTokens;
|
||||||
var fontOverrides = window.fontOverrides;
|
|
||||||
var selectedFontFiles = window.selectedFontFiles;
|
var selectedFontFiles = window.selectedFontFiles;
|
||||||
|
|
||||||
// Retry counter for initialization
|
// Retry counter for initialization
|
||||||
@@ -222,17 +144,15 @@
|
|||||||
function initializeFontsTab() {
|
function initializeFontsTab() {
|
||||||
// Allow re-initialization on each HTMX content swap
|
// Allow re-initialization on each HTMX content swap
|
||||||
// The window._fontsScriptLoaded guard prevents function redeclaration
|
// The window._fontsScriptLoaded guard prevents function redeclaration
|
||||||
const detectedEl = document.getElementById('detected-fonts');
|
|
||||||
const availableEl = document.getElementById('available-fonts');
|
const availableEl = document.getElementById('available-fonts');
|
||||||
|
|
||||||
if (!detectedEl || !availableEl) {
|
if (!availableEl) {
|
||||||
initRetryCount++;
|
initRetryCount++;
|
||||||
if (initRetryCount >= MAX_INIT_RETRIES) {
|
if (initRetryCount >= MAX_INIT_RETRIES) {
|
||||||
console.error('Fonts tab elements not found after max retries, giving up');
|
console.error('Fonts tab elements not found after max retries, giving up');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('Fonts tab elements not found, retrying...', {
|
console.log('Fonts tab elements not found, retrying...', {
|
||||||
detectedFonts: !!detectedEl,
|
|
||||||
availableFonts: !!availableEl,
|
availableFonts: !!availableEl,
|
||||||
attempt: initRetryCount
|
attempt: initRetryCount
|
||||||
});
|
});
|
||||||
@@ -259,7 +179,6 @@ function initializeFontsTab() {
|
|||||||
const fontFileInput = document.getElementById('font-file-input');
|
const fontFileInput = document.getElementById('font-file-input');
|
||||||
const uploadFontsBtn = document.getElementById('upload-fonts-btn');
|
const uploadFontsBtn = document.getElementById('upload-fonts-btn');
|
||||||
const cancelUploadBtn = document.getElementById('cancel-upload-btn');
|
const cancelUploadBtn = document.getElementById('cancel-upload-btn');
|
||||||
const addOverrideBtn = document.getElementById('add-override-btn');
|
|
||||||
const updatePreviewBtn = document.getElementById('update-preview-btn');
|
const updatePreviewBtn = document.getElementById('update-preview-btn');
|
||||||
|
|
||||||
if (uploadDropzone && fontFileInput) {
|
if (uploadDropzone && fontFileInput) {
|
||||||
@@ -280,9 +199,6 @@ function initializeFontsTab() {
|
|||||||
cancelUploadBtn.addEventListener('click', cancelFontUpload);
|
cancelUploadBtn.addEventListener('click', cancelFontUpload);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addOverrideBtn) {
|
|
||||||
addOverrideBtn.addEventListener('click', addFontOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatePreviewBtn) {
|
if (updatePreviewBtn) {
|
||||||
updatePreviewBtn.addEventListener('click', updateFontPreview);
|
updatePreviewBtn.addEventListener('click', updateFontPreview);
|
||||||
@@ -319,9 +235,8 @@ window.initializeFontsTab = initializeFontsTab;
|
|||||||
// Function to initialize when fonts content is loaded
|
// Function to initialize when fonts content is loaded
|
||||||
function tryInitializeFontsTab() {
|
function tryInitializeFontsTab() {
|
||||||
const fontsContent = document.getElementById('fonts-content');
|
const fontsContent = document.getElementById('fonts-content');
|
||||||
const detectedFonts = document.getElementById('detected-fonts');
|
|
||||||
|
|
||||||
if (fontsContent && detectedFonts) {
|
if (fontsContent) {
|
||||||
console.log('Fonts content detected, initializing...');
|
console.log('Fonts content detected, initializing...');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initializeFontsTab();
|
initializeFontsTab();
|
||||||
@@ -355,7 +270,6 @@ async function initializeFontManagement() {
|
|||||||
try {
|
try {
|
||||||
await loadFontData();
|
await loadFontData();
|
||||||
populateFontSelects();
|
populateFontSelects();
|
||||||
displayCurrentOverrides();
|
|
||||||
updateFontPreview();
|
updateFontPreview();
|
||||||
initializeFontUpload();
|
initializeFontUpload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -365,44 +279,39 @@ async function initializeFontManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFontData() {
|
async function loadFontData() {
|
||||||
const detectedContainer = document.getElementById('detected-fonts');
|
|
||||||
const availableContainer = document.getElementById('available-fonts');
|
const availableContainer = document.getElementById('available-fonts');
|
||||||
|
|
||||||
// Ensure containers exist before proceeding
|
// Ensure containers exist before proceeding
|
||||||
if (!detectedContainer || !availableContainer) {
|
if (!availableContainer) {
|
||||||
console.error('Font containers not found, cannot load font data');
|
console.error('Font containers not found, cannot load font data');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading states
|
// Show loading states
|
||||||
detectedContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
|
||||||
availableContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
availableContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use absolute URLs to ensure they work when loaded via HTMX
|
// 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/catalog`),
|
||||||
fetch(`/api/v3/fonts/tokens`),
|
fetch(`/api/v3/fonts/tokens`)
|
||||||
fetch(`/api/v3/fonts/overrides`)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if all responses are successful
|
// Check if all responses are successful
|
||||||
if (!catalogRes.ok || !tokensRes.ok || !overridesRes.ok) {
|
if (!catalogRes.ok || !tokensRes.ok) {
|
||||||
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}/${overridesRes.status}`;
|
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}`;
|
||||||
console.error('Font API error:', statusText);
|
console.error('Font API error:', statusText);
|
||||||
throw new Error(`Failed to load font data: ${statusText}`);
|
throw new Error(`Failed to load font data: ${statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const catalogData = await catalogRes.json();
|
const catalogData = await catalogRes.json();
|
||||||
const tokensData = await tokensRes.json();
|
const tokensData = await tokensRes.json();
|
||||||
const overridesData = await overridesRes.json();
|
|
||||||
|
|
||||||
// Validate response structure
|
// 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:', {
|
console.error('Invalid font API response structure:', {
|
||||||
catalog: !!catalogData?.data,
|
catalog: !!catalogData?.data,
|
||||||
tokens: !!tokensData?.data,
|
tokens: !!tokensData?.data
|
||||||
overrides: !!overridesData?.data
|
|
||||||
});
|
});
|
||||||
throw new Error('Invalid response format from font API');
|
throw new Error('Invalid response format from font API');
|
||||||
}
|
}
|
||||||
@@ -410,27 +319,22 @@ async function loadFontData() {
|
|||||||
// Update both window properties and local references
|
// Update both window properties and local references
|
||||||
window.fontCatalog = catalogData.data.catalog || {};
|
window.fontCatalog = catalogData.data.catalog || {};
|
||||||
window.fontTokens = tokensData.data.tokens || {};
|
window.fontTokens = tokensData.data.tokens || {};
|
||||||
window.fontOverrides = overridesData.data.overrides || {};
|
|
||||||
|
|
||||||
// Update local variable references
|
// Update local variable references
|
||||||
fontCatalog = window.fontCatalog;
|
fontCatalog = window.fontCatalog;
|
||||||
fontTokens = window.fontTokens;
|
fontTokens = window.fontTokens;
|
||||||
fontOverrides = window.fontOverrides;
|
|
||||||
|
|
||||||
// Update displays
|
// Update displays
|
||||||
updateDetectedFontsDisplay();
|
|
||||||
updateAvailableFontsDisplay();
|
updateAvailableFontsDisplay();
|
||||||
|
|
||||||
console.log('Font data loaded successfully', {
|
console.log('Font data loaded successfully', {
|
||||||
catalogSize: Object.keys(fontCatalog).length,
|
catalogSize: Object.keys(fontCatalog).length,
|
||||||
tokensSize: Object.keys(fontTokens).length,
|
tokensSize: Object.keys(fontTokens).length
|
||||||
overridesSize: Object.keys(fontOverrides).length
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading font data:', error);
|
console.error('Error loading font data:', error);
|
||||||
|
|
||||||
// Show error states
|
// Show error states
|
||||||
detectedContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
|
||||||
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
||||||
|
|
||||||
// Only show notification if showNotification is available
|
// 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 = '<div class="text-gray-400">No fonts detected yet (managers will register fonts when they render)</div>';
|
|
||||||
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() {
|
function updateAvailableFontsDisplay() {
|
||||||
const container = document.getElementById('available-fonts');
|
const container = document.getElementById('available-fonts');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -594,10 +452,9 @@ async function deleteFont(fontFamily) {
|
|||||||
|
|
||||||
function populateFontSelects() {
|
function populateFontSelects() {
|
||||||
// Populate font family dropdowns from catalog
|
// Populate font family dropdowns from catalog
|
||||||
const overrideSelect = document.getElementById('override-family');
|
|
||||||
const previewSelect = document.getElementById('preview-family');
|
const previewSelect = document.getElementById('preview-family');
|
||||||
|
|
||||||
if (!overrideSelect || !previewSelect) return;
|
if (!previewSelect) return;
|
||||||
|
|
||||||
// Get font entries sorted by display name
|
// Get font entries sorted by display name
|
||||||
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
|
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
|
||||||
@@ -608,13 +465,6 @@ function populateFontSelects() {
|
|||||||
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
|
||||||
// Build options using DOM APIs to prevent XSS
|
// 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
|
// Clear preview select
|
||||||
previewSelect.innerHTML = '';
|
previewSelect.innerHTML = '';
|
||||||
|
|
||||||
@@ -622,11 +472,6 @@ function populateFontSelects() {
|
|||||||
fontEntries.forEach(font => {
|
fontEntries.forEach(font => {
|
||||||
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
|
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');
|
const previewOpt = document.createElement('option');
|
||||||
previewOpt.value = font.filename;
|
previewOpt.value = font.filename;
|
||||||
previewOpt.textContent = font.displayName + typeLabel;
|
previewOpt.textContent = font.displayName + typeLabel;
|
||||||
@@ -641,187 +486,6 @@ function populateFontSelects() {
|
|||||||
console.log(`Populated font selects with ${fontEntries.length} fonts`);
|
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 = '<div class="text-gray-500 text-sm italic">No font overrides configured</div>';
|
|
||||||
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() {
|
async function updateFontPreview() {
|
||||||
const previewImage = document.getElementById('font-preview-image');
|
const previewImage = document.getElementById('font-preview-image');
|
||||||
const loadingText = document.getElementById('font-preview-loading');
|
const loadingText = document.getElementById('font-preview-loading');
|
||||||
|
|||||||
@@ -71,6 +71,76 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% 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 {} %}
|
||||||
|
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
|
||||||
|
<div id="{{ field_id }}_container" class="style-editor-container mt-1"></div>
|
||||||
|
<div id="{{ field_id }}_fallback">
|
||||||
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
function fallbackIsDirty(root) {
|
||||||
|
if (!root) { return false; }
|
||||||
|
var controls = root.querySelectorAll('input, select, textarea');
|
||||||
|
for (var i = 0; i < controls.length; i++) {
|
||||||
|
var c = controls[i];
|
||||||
|
if (c.type === 'checkbox' || c.type === 'radio') {
|
||||||
|
if (c.checked !== c.defaultChecked) { return true; }
|
||||||
|
} else if (c.tagName === 'SELECT') {
|
||||||
|
for (var j = 0; j < c.options.length; j++) {
|
||||||
|
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
|
||||||
|
}
|
||||||
|
} else if (c.value !== c.defaultValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function initWidget() {
|
||||||
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
||||||
|
var widget = window.LEDMatrixWidgets.get('style-editor');
|
||||||
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
||||||
|
var container = document.getElementById('{{ field_id }}_container');
|
||||||
|
var fallback = document.getElementById('{{ field_id }}_fallback');
|
||||||
|
if (!container) { return; }
|
||||||
|
// A user may already be editing the plain fallback fields
|
||||||
|
// while this widget's script is still loading; swapping
|
||||||
|
// in the widget now would replace them with the stale
|
||||||
|
// server-rendered value and silently drop that edit.
|
||||||
|
if (fallbackIsDirty(fallback)) { container.remove(); return; }
|
||||||
|
var value = {{ obj_value|tojson|safe }};
|
||||||
|
var config = { schema: {{ prop|tojson|safe }} };
|
||||||
|
try {
|
||||||
|
widget.render(container, config, value, {
|
||||||
|
fieldId: '{{ field_id }}',
|
||||||
|
name: '{{ full_key }}',
|
||||||
|
pluginId: '{{ plugin_id }}'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[style-editor] render failed', e);
|
||||||
|
container.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The widget owns these fields now; drop the generic
|
||||||
|
// rendering so the form does not post both.
|
||||||
|
if (fallback) { fallback.remove(); }
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initWidget);
|
||||||
|
} else {
|
||||||
|
setTimeout(initWidget, 50);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% elif prop.properties %}
|
{% elif prop.properties %}
|
||||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -872,6 +942,68 @@
|
|||||||
name="{{ full_key }}"
|
name="{{ full_key }}"
|
||||||
value="{{ str_value }}"
|
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">
|
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. #}
|
||||||
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var WIDGET = {{ str_widget|tojson|safe }};
|
||||||
|
var PLUGIN = {{ plugin_id|tojson|safe }};
|
||||||
|
function initPluginWidget() {
|
||||||
|
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
|
||||||
|
setTimeout(initPluginWidget, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var container = document.getElementById('{{ field_id }}_container');
|
||||||
|
var fallback = document.getElementById('{{ field_id }}');
|
||||||
|
if (!container) { return; }
|
||||||
|
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
|
||||||
|
if (!available) { container.remove(); return; }
|
||||||
|
var widget = window.LEDMatrixWidgets.get(WIDGET);
|
||||||
|
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
|
||||||
|
// Read the fallback's live value rather than the
|
||||||
|
// server-rendered one, so an edit typed while the
|
||||||
|
// widget script was still loading isn't lost.
|
||||||
|
var value = fallback ? fallback.value : {{ str_value|tojson|safe }};
|
||||||
|
var config = {
|
||||||
|
'type': '{{ field_type }}',
|
||||||
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||||
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
||||||
|
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
widget.render(container, config, value, {
|
||||||
|
fieldId: '{{ field_id }}',
|
||||||
|
name: '{{ full_key }}',
|
||||||
|
pluginId: PLUGIN
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginWidget] render failed for ' + WIDGET, e);
|
||||||
|
container.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only now is the widget authoritative for this
|
||||||
|
// field; drop the fallback so the form posts one
|
||||||
|
// value rather than two under the same name.
|
||||||
|
if (fallback) { fallback.remove(); }
|
||||||
|
}).catch(function(e) {
|
||||||
|
console.warn('[PluginWidget] could not load ' + WIDGET, e);
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initPluginWidget);
|
||||||
|
} else {
|
||||||
|
setTimeout(initPluginWidget, 50);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -1059,6 +1191,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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 %}
|
||||||
|
<input type="hidden" name="__rendered_section" value="{{ key }}">
|
||||||
|
{% endfor %}
|
||||||
{% for key in tiers.basic %}
|
{% for key in tiers.basic %}
|
||||||
{% set prop = schema.properties[key] %}
|
{% set prop = schema.properties[key] %}
|
||||||
{% set value = config[key] if key in config else none %}
|
{% set value = config[key] if key in config else none %}
|
||||||
|
|||||||
Reference in New Issue
Block a user