mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
Compare commits
5
Commits
f9e9eeba00
...
c3d68f23f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3d68f23f6 | ||
|
|
ece2065e42 | ||
|
|
a016dd9446 | ||
|
|
da3cef0bc0 | ||
|
|
1df51b172a |
@@ -37,6 +37,20 @@ ships this):
|
|||||||
says `score`, and `records`/`record` and `status_text`/`status` split seven
|
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
|
to two across the published schemas. A lookup tries the exact name first, so
|
||||||
this is inert for a config that already matches.
|
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
|
- `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
|
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.
|
whether a size control can take effect at all.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -90,7 +113,8 @@ class LogoHelper:
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -99,6 +123,10 @@ class LogoHelper:
|
|||||||
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)
|
||||||
@@ -161,7 +196,8 @@ class LogoHelper:
|
|||||||
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
|
||||||
@@ -329,8 +367,14 @@ 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:
|
||||||
@@ -338,7 +382,14 @@ class LogoHelper:
|
|||||||
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -113,26 +113,59 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
|
|||||||
return _shared(config, element, default, mode)
|
return _shared(config, element, default, mode)
|
||||||
|
|
||||||
|
|
||||||
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
def resolve_font_color(config: Optional[Dict[str, Any]],
|
||||||
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
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.
|
"""Colour for whichever element owns this face.
|
||||||
|
|
||||||
Matched on identity, and deliberately gives up when one object is
|
Identity matching is a stand-in for the element name, used where the draw
|
||||||
shared: the last-resort font path can hand the same face to several
|
site only ever received a font. Prefer ``element=`` on the draw call; this
|
||||||
keys, and there is no right answer for which element's colour that is.
|
is the fallback for the sites that have not been annotated yet.
|
||||||
White is what those draws used before, so ambiguity costs nothing.
|
|
||||||
|
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:
|
||||||
fonts = fonts or {}
|
fonts = fonts or {}
|
||||||
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
matches = [element for key, element in element_for_font.items()
|
||||||
if fonts.get(key) is font]
|
if fonts.get(key) is font]
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
return element_color(config, matches[0], default)
|
return element_color(config, matches[0], default, mode)
|
||||||
|
if len(matches) > 1:
|
||||||
|
configured = []
|
||||||
|
for element in matches:
|
||||||
|
colour = element_color(config, element, None, mode)
|
||||||
|
if colour is not None and colour not in configured:
|
||||||
|
configured.append(colour)
|
||||||
|
if len(configured) == 1:
|
||||||
|
return configured[0]
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||||
|
font, default: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
mode: Optional[str] = None):
|
||||||
|
"""Colour for whichever element owns this face, by this module's map."""
|
||||||
|
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
|
||||||
|
mode)
|
||||||
|
|
||||||
|
|
||||||
def coerce_rgb(value, fallback):
|
def coerce_rgb(value, fallback):
|
||||||
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||||
# Checked before unpacking: a 3-character string ("123") would otherwise
|
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||||
|
|||||||
+74
-18
@@ -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
|
||||||
@@ -783,6 +786,46 @@ class SportsCoreSharedMixin:
|
|||||||
return _shared(self.config, element, default,
|
return _shared(self.config, element, default,
|
||||||
getattr(self, "SKIN_MODE", None))
|
getattr(self, "SKIN_MODE", None))
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -824,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.
|
||||||
@@ -852,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
|
||||||
|
|||||||
@@ -1115,6 +1115,83 @@ def element_color(config: Any, element_key: str,
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _element_field(config: Any, element_key: str, field: str,
|
||||||
|
mode: Optional[str] = None, in_layout: bool = False):
|
||||||
|
"""Raw ``customization[.layout].<element>.<field>``, mode first.
|
||||||
|
|
||||||
|
The shared body behind the stateless visible/align/scale readers. Returns
|
||||||
|
None for "not configured", which every caller turns into its own default --
|
||||||
|
None must not collapse into a value here, because for a mode it is the
|
||||||
|
inherit sentinel.
|
||||||
|
"""
|
||||||
|
block = config.get('customization') if isinstance(config, dict) else None
|
||||||
|
block = block if isinstance(block, dict) else {}
|
||||||
|
|
||||||
|
def _read(source):
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
return None
|
||||||
|
holder = source.get('layout') if in_layout else source
|
||||||
|
return _lookup_element(holder, element_key).get(field)
|
||||||
|
|
||||||
|
if mode:
|
||||||
|
modes = block.get('modes')
|
||||||
|
mode_block = modes.get(mode) if isinstance(modes, dict) else None
|
||||||
|
override = _read(mode_block)
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
|
return _read(block)
|
||||||
|
|
||||||
|
|
||||||
|
def element_visible(config: Any, element_key: str, default: bool = True,
|
||||||
|
mode: Optional[str] = None) -> bool:
|
||||||
|
"""``customization.<element>.visible``, or *default*.
|
||||||
|
|
||||||
|
The stateless form of the flag :meth:`ElementStyleResolver.style` already
|
||||||
|
resolves, for the scoreboard draw paths that hold a config rather than a
|
||||||
|
resolver.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = _element_field(config, element_key, 'visible', mode)
|
||||||
|
return default if value is None else _coerce_bool(value, default)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading visibility for %s: %s", element_key, e)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def element_align(config: Any, element_key: str,
|
||||||
|
default: Optional[str] = None,
|
||||||
|
mode: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""``customization.<element>.align`` ('left'/'center'/'right'), or *default*."""
|
||||||
|
try:
|
||||||
|
value = _element_field(config, element_key, 'align', mode)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
# _coerce_align answers None for anything that is not an alignment;
|
||||||
|
# that is "no preference", which means the caller's default.
|
||||||
|
coerced = _coerce_align(value)
|
||||||
|
return default if coerced is None else coerced
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading alignment for %s: %s", element_key, e)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def element_scale(config: Any, element_key: str, default: float = 1.0,
|
||||||
|
mode: Optional[str] = None) -> float:
|
||||||
|
"""``customization.layout.<element>.scale``, or *default*.
|
||||||
|
|
||||||
|
Scale sits in the layout block beside the offsets, because it positions and
|
||||||
|
sizes rather than styles -- a logo has no font or colour but is very much
|
||||||
|
something users want smaller.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
value = _element_field(config, element_key, 'scale', mode,
|
||||||
|
in_layout=True)
|
||||||
|
return default if value is None else _coerce_scale(value, default)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading scale for %s: %s", element_key, e)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class ElementStyleResolver:
|
class ElementStyleResolver:
|
||||||
"""Resolves per-element user styling against schema defaults.
|
"""Resolves per-element user styling against schema defaults.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1031,14 +1031,59 @@ def _set_nested_value(config, key_path, value):
|
|||||||
# value (e.g. the per-mode "inherit the base" override) and must overwrite
|
# value (e.g. the per-mode "inherit the base" override) and must overwrite
|
||||||
# whatever was already stored.
|
# whatever was already stored.
|
||||||
current[segments[-1]] = value
|
current[segments[-1]] = value
|
||||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
|
||||||
|
|
||||||
|
#: 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.
|
||||||
@@ -1047,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):
|
||||||
@@ -1065,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
|
||||||
@@ -1081,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':
|
||||||
@@ -1108,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] = {}
|
||||||
@@ -1135,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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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,
|
||||||
@@ -1730,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
|
||||||
@@ -2089,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
|
||||||
|
|||||||
@@ -39,6 +39,30 @@
|
|||||||
var FONT_CACHE = null;
|
var FONT_CACHE = null;
|
||||||
var FONT_INFLIGHT = 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. */
|
/** The font catalog, fetched once per page. */
|
||||||
function loadFonts() {
|
function loadFonts() {
|
||||||
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
||||||
@@ -51,7 +75,7 @@
|
|||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
||||||
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
||||||
var entry = catalog[key];
|
var entry = ownObj(catalog, key);
|
||||||
return {
|
return {
|
||||||
filename: entry.filename,
|
filename: entry.filename,
|
||||||
label: entry.display_name || entry.filename,
|
label: entry.display_name || entry.filename,
|
||||||
@@ -74,10 +98,11 @@
|
|||||||
function el(tag, attrs, children) {
|
function el(tag, attrs, children) {
|
||||||
var node = document.createElement(tag);
|
var node = document.createElement(tag);
|
||||||
Object.keys(attrs || {}).forEach(function (k) {
|
Object.keys(attrs || {}).forEach(function (k) {
|
||||||
if (k === 'class') { node.className = attrs[k]; }
|
var v = own(attrs, k);
|
||||||
else if (k === 'text') { node.textContent = attrs[k]; }
|
if (k === 'class') { node.className = v; }
|
||||||
else if (attrs[k] !== null && attrs[k] !== undefined) {
|
else if (k === 'text') { node.textContent = v; }
|
||||||
node.setAttribute(k, attrs[k]);
|
else if (v !== null && v !== undefined) {
|
||||||
|
node.setAttribute(k, v);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(children || []).forEach(function (c) { node.appendChild(c); });
|
(children || []).forEach(function (c) { node.appendChild(c); });
|
||||||
@@ -87,9 +112,11 @@
|
|||||||
/** Walk a nested value object by path segments. */
|
/** Walk a nested value object by path segments. */
|
||||||
function at(value, path) {
|
function at(value, path) {
|
||||||
var cur = value;
|
var cur = value;
|
||||||
for (var i = 0; i < path.length; i++) {
|
// 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; }
|
if (cur === null || typeof cur !== 'object') { return undefined; }
|
||||||
cur = cur[path[i]];
|
cur = own(cur, remaining.shift());
|
||||||
}
|
}
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
@@ -121,12 +148,12 @@
|
|||||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||||
return order.filter(function (k) {
|
return order.filter(function (k) {
|
||||||
return k !== 'layout' && k !== 'modes'
|
return k !== 'layout' && k !== 'modes'
|
||||||
&& props[k] && props[k].properties;
|
&& own(props, k) && ownObj(props, k).properties;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleOf(schema, key) {
|
function titleOf(schema, key) {
|
||||||
var prop = (schema.properties || {})[key] || {};
|
var prop = ownObj(schema.properties || {}, key);
|
||||||
return prop.title || key.replace(/_/g, ' ');
|
return prop.title || key.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,11 +284,16 @@
|
|||||||
// drives them.
|
// drives them.
|
||||||
var wrap = el('div', { class: 'flex items-center gap-1' });
|
var wrap = el('div', { class: 'flex items-center gap-1' });
|
||||||
var has = Array.isArray(current) && current.length >= 3;
|
var has = Array.isArray(current) && current.length >= 3;
|
||||||
var channels = [0, 1, 2].map(function (i) {
|
var startValues = has ? current.slice(0, 3) : [];
|
||||||
var input = el('input', { type: 'hidden', name: name + '.' + i });
|
var channels = startValues.concat([null, null, null])
|
||||||
if (has) { input.value = current[i]; }
|
.slice(0, 3)
|
||||||
return input;
|
.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
|
// A disabled input is not submitted at all, which is how an unset
|
||||||
// optional colour says nothing rather than posting three empty
|
// optional colour says nothing rather than posting three empty
|
||||||
@@ -280,8 +312,8 @@
|
|||||||
});
|
});
|
||||||
swatch.addEventListener('input', function () {
|
swatch.addEventListener('input', function () {
|
||||||
var hex = swatch.value;
|
var hex = swatch.value;
|
||||||
[1, 3, 5].forEach(function (start, i) {
|
channels.forEach(function (channel, i) {
|
||||||
channels[i].value = parseInt(hex.substr(start, 2), 16);
|
channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
|
||||||
});
|
});
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||||
@@ -334,25 +366,28 @@
|
|||||||
*/
|
*/
|
||||||
function columnsFor(schema) {
|
function columnsFor(schema) {
|
||||||
var props = schema.properties || {};
|
var props = schema.properties || {};
|
||||||
var layoutProps = (props.layout || {}).properties || {};
|
var layoutProps = ownObj(props, 'layout').properties || {};
|
||||||
var seen = {};
|
// 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) {
|
elementKeys(schema).forEach(function (key) {
|
||||||
Object.keys((props[key] || {}).properties || {}).forEach(
|
Object.keys(ownObj(props, key).properties || {}).forEach(
|
||||||
function (f) { seen[f] = 'element'; });
|
function (f) { seen.set(f, 'element'); });
|
||||||
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
|
Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
|
||||||
function (f) { seen[f] = 'layout'; });
|
function (f) { seen.set(f, 'layout'); });
|
||||||
});
|
});
|
||||||
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
|
var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); });
|
||||||
// Anything the schema declares that this file has never heard of
|
// Anything the schema declares that this file has never heard of
|
||||||
// still gets a column, rather than silently vanishing.
|
// still gets a column, rather than silently vanishing.
|
||||||
var extra = Object.keys(seen).filter(function (f) {
|
var extra = Array.from(seen.keys()).filter(function (f) {
|
||||||
return COLUMN_ORDER.indexOf(f) === -1;
|
return COLUMN_ORDER.indexOf(f) === -1;
|
||||||
}).sort();
|
}).sort();
|
||||||
return known.concat(extra).map(function (f) {
|
return known.concat(extra).map(function (f) {
|
||||||
return {
|
return {
|
||||||
key: f,
|
key: f,
|
||||||
where: seen[f],
|
where: seen.get(f),
|
||||||
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
|
label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -362,10 +397,10 @@
|
|||||||
var prop = opts.prop;
|
var prop = opts.prop;
|
||||||
var declared = prop.type;
|
var declared = prop.type;
|
||||||
var types = Array.isArray(declared) ? declared : [declared];
|
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') {
|
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
||||||
var xOptions = prop['x-options'] || prop['x_options'] || {};
|
|
||||||
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
|
|
||||||
return fontControl(opts.name, opts.current,
|
return fontControl(opts.name, opts.current,
|
||||||
usableFonts(opts.fonts, cap, opts.current),
|
usableFonts(opts.fonts, cap, opts.current),
|
||||||
opts.optional, opts.onFontChange);
|
opts.optional, opts.onFontChange);
|
||||||
@@ -390,9 +425,9 @@
|
|||||||
var value = opts.value;
|
var value = opts.value;
|
||||||
var optional = opts.optional;
|
var optional = opts.optional;
|
||||||
|
|
||||||
var props = ((schema.properties || {})[key] || {}).properties || {};
|
var props = ownObj(schema.properties || {}, key).properties || {};
|
||||||
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
|
var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
|
||||||
var axes = (layoutProps[key] || {}).properties || {};
|
var axes = ownObj(layoutProps, key).properties || {};
|
||||||
|
|
||||||
var row = el('div', {
|
var row = el('div', {
|
||||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||||
@@ -427,7 +462,8 @@
|
|||||||
|
|
||||||
opts.columns.forEach(function (col) {
|
opts.columns.forEach(function (col) {
|
||||||
var inLayout = col.where === 'layout';
|
var inLayout = col.where === 'layout';
|
||||||
var prop = inLayout ? axes[col.key] : props[col.key];
|
var prop = inLayout ? own(axes, col.key) : own(props, col.key);
|
||||||
|
var cell;
|
||||||
if (!prop) {
|
if (!prop) {
|
||||||
// This element does not declare that field; keep the grid
|
// This element does not declare that field; keep the grid
|
||||||
// aligned with an empty cell.
|
// aligned with an empty cell.
|
||||||
@@ -452,7 +488,7 @@
|
|||||||
if (col.key === 'font') { fontSelect = node; }
|
if (col.key === 'font') { fontSelect = node; }
|
||||||
if (col.key === 'font_size') {
|
if (col.key === 'font_size') {
|
||||||
sizeInput = node;
|
sizeInput = node;
|
||||||
var cell = el('div', { class: 'flex items-center' });
|
cell = el('div', { class: 'flex items-center' });
|
||||||
cell.appendChild(node);
|
cell.appendChild(node);
|
||||||
cell.appendChild(sizeNote);
|
cell.appendChild(sizeNote);
|
||||||
node = cell;
|
node = cell;
|
||||||
@@ -520,10 +556,12 @@
|
|||||||
container.appendChild(root);
|
container.appendChild(root);
|
||||||
|
|
||||||
loadFonts().then(function (fonts) {
|
loadFonts().then(function (fonts) {
|
||||||
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
|
var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
|
||||||
var modes = Object.keys(modeProps);
|
var modes = Object.keys(modeProps);
|
||||||
|
|
||||||
var panels = {};
|
// 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;
|
var tabs = null;
|
||||||
if (modes.length) {
|
if (modes.length) {
|
||||||
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
||||||
@@ -531,14 +569,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function panel(id, node) {
|
function panel(id, node) {
|
||||||
panels[id] = node;
|
panels.push({ id: id, node: node });
|
||||||
node.classList.add('style-editor-panel');
|
node.classList.add('style-editor-panel');
|
||||||
root.appendChild(node);
|
root.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(id) {
|
function show(id) {
|
||||||
Object.keys(panels).forEach(function (k) {
|
panels.forEach(function (p) {
|
||||||
panels[k].hidden = (k !== id);
|
p.node.hidden = (p.id !== id);
|
||||||
});
|
});
|
||||||
if (!tabs) { return; }
|
if (!tabs) { return; }
|
||||||
Array.prototype.forEach.call(tabs.children, function (b) {
|
Array.prototype.forEach.call(tabs.children, function (b) {
|
||||||
@@ -565,7 +603,7 @@
|
|||||||
tab('__base__', 'All modes');
|
tab('__base__', 'All modes');
|
||||||
|
|
||||||
modes.forEach(function (mode) {
|
modes.forEach(function (mode) {
|
||||||
var modeSchema = modeProps[mode] || {};
|
var modeSchema = ownObj(modeProps, mode);
|
||||||
var modeValue = at(current, ['modes', mode]) || {};
|
var modeValue = at(current, ['modes', mode]) || {};
|
||||||
var node = el('div');
|
var node = el('div');
|
||||||
node.appendChild(el('p', {
|
node.appendChild(el('p', {
|
||||||
|
|||||||
@@ -1191,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