mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[lvgl] Add lvgl.theme.update action (#17678)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
Claude Sonnet 5
parent
ad3e2f83b8
commit
98ee7e0f82
@@ -1,4 +1,3 @@
|
||||
import functools
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
@@ -86,7 +85,7 @@ from .schemas import (
|
||||
any_widget_schema,
|
||||
container_schema,
|
||||
container_schema_value,
|
||||
obj_dict,
|
||||
theme_schema,
|
||||
)
|
||||
from .styles import styles_to_code, theme_to_code
|
||||
from .touchscreens import touchscreen_schema, touchscreens_to_code
|
||||
@@ -215,6 +214,18 @@ def multi_conf_validate(configs: list[dict]):
|
||||
raise cv.Invalid(
|
||||
f"'{item}' must have an explicit group set when using multiple LVGL instances"
|
||||
)
|
||||
# The hidden styles a `theme:` block creates are tracked in a single map shared
|
||||
# by all LVGL instances (keyed only by widget type, not by instance), so a
|
||||
# second instance's `theme:` would silently lose to whichever instance is
|
||||
# processed first instead of doing what its config implies.
|
||||
themed_configs = sum(
|
||||
1 for config in configs if config.get(df.CONF_THEME) is not None
|
||||
)
|
||||
if themed_configs > 1:
|
||||
raise cv.Invalid(
|
||||
"'theme' may only be set on one LVGL instance when using multiple LVGL "
|
||||
"instances -- combine both themes into a single instance's 'theme:' block"
|
||||
)
|
||||
base_config = configs[0]
|
||||
for config in configs[1:]:
|
||||
for item in (
|
||||
@@ -552,34 +563,6 @@ def add_hello_world(config):
|
||||
return config
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _build_theme_schema(
|
||||
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
|
||||
) -> cv.Schema:
|
||||
# The theme schema is value-independent: it depends only on the set of
|
||||
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
|
||||
# that an external component registering a new widget after the first
|
||||
# validation (legal per any_widget_schema's lazy-evaluation contract)
|
||||
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
|
||||
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
|
||||
# schemas.py for why chained .extend() is avoided here.
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(name): cv.Schema(
|
||||
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
|
||||
)
|
||||
for name, w in widget_types
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _theme_schema(value: dict) -> dict:
|
||||
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
# The options accepted at the top level of an `lvgl:` block, on top of the base
|
||||
@@ -647,7 +630,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
|
||||
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
|
||||
cv.Optional(df.CONF_THEME): _theme_schema,
|
||||
cv.Optional(df.CONF_THEME): theme_schema,
|
||||
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
|
||||
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
|
||||
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
|
||||
|
||||
@@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles"
|
||||
KEY_REFRESHED_WIDGETS = "refreshed_widgets"
|
||||
KEY_REMAPPED_USES = "remapped_uses"
|
||||
KEY_STYLES_USED = "styles_used"
|
||||
KEY_THEME_UPDATE_REQUESTS = "theme_update_requests"
|
||||
KEY_THEME_WIDGET_MAP = "theme_widget_map"
|
||||
KEY_UPDATED_WIDGETS = "updated_widgets"
|
||||
KEY_WIDGET_MAP = "widget_map"
|
||||
@@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]:
|
||||
return _get_data(KEY_THEME_WIDGET_MAP, {})
|
||||
|
||||
|
||||
def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]:
|
||||
# Values are dicts used as ordered sets (insertion order is deterministic,
|
||||
# unlike a plain `set` of strings/tuples, whose iteration order depends on
|
||||
# per-process string hash randomization) so codegen output doesn't churn
|
||||
# between builds of the same config.
|
||||
return _get_data(KEY_THEME_UPDATE_REQUESTS, {})
|
||||
|
||||
|
||||
def get_styles_used() -> set[str]:
|
||||
return _get_data(KEY_STYLES_USED, set())
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
@@ -8,6 +9,7 @@ from esphome.components.time import RealTimeClock
|
||||
from esphome.config_validation import prepend_path
|
||||
from esphome.const import (
|
||||
CONF_ARGS,
|
||||
CONF_DEFAULT,
|
||||
CONF_FORMAT,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
@@ -69,7 +71,7 @@ from .types import (
|
||||
lv_pseudo_button_t,
|
||||
lv_style_t,
|
||||
)
|
||||
from .widgets import WidgetType
|
||||
from .widgets import WidgetType, collect_parts
|
||||
|
||||
# this will be populated later, in __init__.py to avoid circular imports.
|
||||
WIDGET_TYPES: dict = {}
|
||||
@@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.Schema:
|
||||
return schema
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _build_theme_schema(
|
||||
widget_types: tuple[tuple[str, WidgetType], ...],
|
||||
include_dark_mode: bool = True,
|
||||
) -> cv.Schema:
|
||||
# The theme schema is value-independent: it depends only on the set of
|
||||
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
|
||||
# that an external component registering a new widget after the first
|
||||
# validation (legal per any_widget_schema's lazy-evaluation contract)
|
||||
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
|
||||
# self-heals instead of stale-rejecting valid themes. See obj_dict() above
|
||||
# for why chained .extend() is avoided here.
|
||||
return cv.Schema(
|
||||
{
|
||||
**(
|
||||
{cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean}
|
||||
if include_dark_mode
|
||||
else {}
|
||||
),
|
||||
**{
|
||||
cv.Optional(name): cv.Schema(
|
||||
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
|
||||
)
|
||||
for name, w in widget_types
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _reject_theme_styles_key(validated: dict) -> dict:
|
||||
"""
|
||||
`styles:` (a list of already-declared named styles) is not allowed inside
|
||||
`theme:` -- the hidden style objects theme: creates only carry direct
|
||||
style properties, so a `styles:` reference there would be silently
|
||||
dropped by `style_set`, which only walks `ALL_STYLES`.
|
||||
"""
|
||||
for w_name, style in validated.items():
|
||||
if w_name not in WIDGET_TYPES:
|
||||
continue
|
||||
for part, states in collect_parts(style).items():
|
||||
for state, props in states.items():
|
||||
if df.CONF_STYLES not in props:
|
||||
continue
|
||||
path = [w_name]
|
||||
if part != df.CONF_MAIN:
|
||||
path.append(part)
|
||||
if state != CONF_DEFAULT:
|
||||
path.append(state)
|
||||
path.append(df.CONF_STYLES)
|
||||
raise cv.Invalid(
|
||||
"'styles:' is not allowed in LVGL theme styles. "
|
||||
"Set style properties directly instead.",
|
||||
path,
|
||||
)
|
||||
return validated
|
||||
|
||||
|
||||
def theme_schema(value: dict) -> dict:
|
||||
return _reject_theme_styles_key(
|
||||
_build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
|
||||
)
|
||||
|
||||
|
||||
def theme_update_schema(value: dict) -> dict:
|
||||
"""
|
||||
Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`.
|
||||
As a validation side effect, records which (widget type, part, state)
|
||||
combos are targeted so `theme_to_code` can make sure a hidden style
|
||||
exists for each -- even ones never mentioned under `theme:` -- and gets
|
||||
it attached to widgets at the same point real theme styles are.
|
||||
"""
|
||||
validated = _reject_theme_styles_key(
|
||||
_build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value)
|
||||
)
|
||||
for w_name, style in validated.items():
|
||||
for part, states in collect_parts(style).items():
|
||||
for state, props in states.items():
|
||||
# collect_parts() unconditionally seeds a main/default entry
|
||||
# even when nothing was set for it (e.g. `{pressed: {...}}`
|
||||
# alone) -- skip combos with no properties so a request for
|
||||
# one state doesn't also create an unused, empty main/default
|
||||
# style that gets attached to every widget of this type.
|
||||
if not props:
|
||||
continue
|
||||
df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = (
|
||||
None
|
||||
)
|
||||
return validated
|
||||
|
||||
|
||||
ALIGN_TO_SCHEMA = {
|
||||
cv.Optional(df.CONF_ALIGN_TO): cv.Schema(
|
||||
{
|
||||
|
||||
@@ -10,11 +10,18 @@ from .defines import (
|
||||
LValidator,
|
||||
add_lv_use,
|
||||
get_styles_used,
|
||||
get_theme_update_requests,
|
||||
get_theme_widget_map,
|
||||
literal,
|
||||
)
|
||||
from .lvcode import LambdaContext, lv
|
||||
from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property
|
||||
from .schemas import (
|
||||
ALL_STYLES,
|
||||
FULL_STYLE_SCHEMA,
|
||||
WIDGET_TYPES,
|
||||
remap_property,
|
||||
theme_update_schema,
|
||||
)
|
||||
from .types import ObjUpdateAction, lv_style_t
|
||||
from .widgets import collect_parts, wait_for_widgets
|
||||
|
||||
@@ -86,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args):
|
||||
style = await cg.get_variable(config[CONF_ID])
|
||||
async with LambdaContext(parameters=args, where=action_id) as context:
|
||||
await style_set(style, config)
|
||||
# Refresh and redraw every widget using this style -- otherwise the
|
||||
# updated properties would sit unused until something else happens to
|
||||
# invalidate the affected widgets.
|
||||
lv.obj_report_style_change(style)
|
||||
|
||||
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
|
||||
|
||||
|
||||
async def theme_to_code(config):
|
||||
if theme := config.get(CONF_THEME):
|
||||
add_lv_use(CONF_THEME)
|
||||
for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES):
|
||||
# Work around Python 3.10 bug with nested async comprehensions
|
||||
# With Python 3.11 this could be simplified
|
||||
# TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions
|
||||
styles = {}
|
||||
for part, states in collect_parts(style).items():
|
||||
styles[part] = {
|
||||
state: await create_style(
|
||||
theme = config.get(CONF_THEME) or {}
|
||||
requests = get_theme_update_requests()
|
||||
# Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather
|
||||
# than a set -- a set of strings/tuples iterates in an order that depends on
|
||||
# per-process hash randomization, which would otherwise churn the order hidden
|
||||
# style variables are declared in main.cpp between builds of the same config.
|
||||
widget_names = [
|
||||
w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests
|
||||
]
|
||||
if not widget_names:
|
||||
return
|
||||
add_lv_use(CONF_THEME)
|
||||
theme_map = get_theme_widget_map()
|
||||
for w_name in widget_names:
|
||||
declared_parts = collect_parts(theme[w_name]) if w_name in theme else {}
|
||||
parts = {part: dict(states) for part, states in declared_parts.items()}
|
||||
for part, state in requests.get(w_name, {}):
|
||||
parts.setdefault(part, {}).setdefault(state, {})
|
||||
widget_styles = theme_map.setdefault(w_name, {})
|
||||
for part, states in parts.items():
|
||||
part_styles = widget_styles.setdefault(part, {})
|
||||
declared_states = declared_parts.get(part, {})
|
||||
for state, props in states.items():
|
||||
if state not in part_styles:
|
||||
part_styles[state] = await create_style(
|
||||
"_lv_theme_style_" + w_name + "_" + part + "_" + state, props
|
||||
)
|
||||
for state, props in states.items()
|
||||
}
|
||||
get_theme_widget_map()[w_name] = styles
|
||||
elif state in declared_states:
|
||||
# A `theme.update` request for this combo (possibly from
|
||||
# another LVGL instance) already created the style as an
|
||||
# empty placeholder before this instance's real `theme:`
|
||||
# declaration was reached -- apply the real values now
|
||||
# instead of silently leaving it empty.
|
||||
await style_set(part_styles[state], props)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.theme.update",
|
||||
ObjUpdateAction,
|
||||
theme_update_schema,
|
||||
synchronous=True,
|
||||
)
|
||||
async def theme_update_to_code(config, action_id, template_arg, args):
|
||||
await wait_for_widgets()
|
||||
theme_map = get_theme_widget_map()
|
||||
# Invariant this relies on: theme_update_schema() records every (widget
|
||||
# type, part, state) combo this action targets as a request during config
|
||||
# validation (which completes for the whole config tree before any
|
||||
# to_code runs), and theme_to_code() -- which runs for every LVGL
|
||||
# instance before any action's own to_code -- materialises a style for
|
||||
# each recorded request. If that handshake is ever broken by a future
|
||||
# change, fail with a diagnosable message rather than a bare KeyError.
|
||||
to_update = []
|
||||
for w_name, style in config.items():
|
||||
for part, states in collect_parts(style).items():
|
||||
for state, props in states.items():
|
||||
# collect_parts() unconditionally seeds an (empty) main/default
|
||||
# entry even when this action didn't target it -- skip it, both
|
||||
# because there's nothing to update and because
|
||||
# theme_update_schema no longer pre-creates a placeholder style
|
||||
# for combos with no properties.
|
||||
if not props:
|
||||
continue
|
||||
style_var = theme_map.get(w_name, {}).get(part, {}).get(state)
|
||||
if style_var is None:
|
||||
raise cv.Invalid(
|
||||
f"No theme style exists for '{w_name}' {part}/{state}. "
|
||||
"This is an internal error -- please report it."
|
||||
)
|
||||
to_update.append((style_var, props))
|
||||
async with LambdaContext(parameters=args, where=action_id) as context:
|
||||
for style_var, props in to_update:
|
||||
await style_set(style_var, props)
|
||||
# Refresh and redraw every widget using this style -- otherwise the
|
||||
# updated properties would sit unused until something else happens
|
||||
# to invalidate the affected widgets.
|
||||
lv.obj_report_style_change(style_var)
|
||||
|
||||
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for LVGL's multi-instance config cross-checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl import defines as df, multi_conf_validate
|
||||
from esphome.components.lvgl.schemas import theme_schema
|
||||
from esphome.config_validation import Invalid
|
||||
|
||||
|
||||
def _config(displays: list[str], theme: dict | None = None) -> dict:
|
||||
config = {
|
||||
df.CONF_DISPLAYS: displays,
|
||||
"log_level": "WARN",
|
||||
"color_depth": 16,
|
||||
"byte_order": "big_endian",
|
||||
df.CONF_TRANSPARENCY_KEY: 0x000400,
|
||||
}
|
||||
if theme is not None:
|
||||
config[df.CONF_THEME] = theme
|
||||
return config
|
||||
|
||||
|
||||
class TestThemeOnMultipleInstances:
|
||||
def test_raises_when_two_instances_have_theme(self) -> None:
|
||||
configs = [
|
||||
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
|
||||
_config(["disp_b"], theme={df.CONF_DARK_MODE: False}),
|
||||
]
|
||||
with pytest.raises(Invalid, match="'theme' may only be set on one"):
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_raises_even_with_an_empty_theme_block(self) -> None:
|
||||
# `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted
|
||||
# by the schema), so it should be treated the same as a populated one.
|
||||
# Run it through the real schema rather than hand-building the dict,
|
||||
# so this actually pins that defaulting behaviour.
|
||||
configs = [
|
||||
_config(["disp_a"], theme=theme_schema({})),
|
||||
_config(["disp_b"], theme=theme_schema({})),
|
||||
]
|
||||
with pytest.raises(Invalid, match="'theme' may only be set on one"):
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_passes_when_only_one_instance_has_theme(self) -> None:
|
||||
configs = [
|
||||
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
|
||||
_config(["disp_b"]),
|
||||
]
|
||||
multi_conf_validate(configs)
|
||||
|
||||
def test_passes_when_no_instance_has_theme(self) -> None:
|
||||
configs = [_config(["disp_a"]), _config(["disp_b"])]
|
||||
multi_conf_validate(configs)
|
||||
@@ -13,12 +13,7 @@ import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import config_validation as cv
|
||||
import esphome.components.lvgl
|
||||
from esphome.components.lvgl import (
|
||||
_theme_schema,
|
||||
defines as df,
|
||||
schemas as lvgl_schemas,
|
||||
)
|
||||
from esphome.components.lvgl import defines as df, schemas as lvgl_schemas
|
||||
from esphome.components.lvgl.schemas import (
|
||||
ALIGN_TO_SCHEMA,
|
||||
FLAG_SCHEMA,
|
||||
@@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import (
|
||||
obj_schema,
|
||||
part_dict,
|
||||
part_schema,
|
||||
theme_schema,
|
||||
theme_update_schema,
|
||||
)
|
||||
from esphome.components.lvgl.types import LvType
|
||||
from esphome.components.lvgl.widgets import WidgetType
|
||||
@@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]:
|
||||
cache.clear()
|
||||
# The lazily-built theme schema is cached on _build_theme_schema; clear it
|
||||
# too so each test starts from a clean slate.
|
||||
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
|
||||
build_theme = getattr(lvgl_schemas, "_build_theme_schema", None)
|
||||
if build_theme is not None and hasattr(build_theme, "cache_clear"):
|
||||
build_theme.cache_clear()
|
||||
yield
|
||||
@@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
|
||||
|
||||
|
||||
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
|
||||
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
|
||||
# theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
|
||||
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
|
||||
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
|
||||
# and a FULL_STYLE-only property) to lock the behaviour against future
|
||||
# regressions in either source.
|
||||
out = _theme_schema(
|
||||
out = theme_schema(
|
||||
{
|
||||
df.CONF_DARK_MODE: True,
|
||||
"obj": {
|
||||
@@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
|
||||
# any_widget_schema explicitly supports external components registering
|
||||
# widgets lazily, and the device builder revalidates in-process, so a
|
||||
# widget registered after first use must invalidate the cached snapshot.
|
||||
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
|
||||
theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
|
||||
|
||||
name = "test_self_heal_widget"
|
||||
assert name not in WIDGET_TYPES
|
||||
@@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
|
||||
# manually so the next theme call sees the new entry.
|
||||
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
|
||||
try:
|
||||
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
|
||||
out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
|
||||
assert out[name]["bg_color"] == 0x010203
|
||||
finally:
|
||||
WIDGET_TYPES.pop(name, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_path"),
|
||||
[
|
||||
({"button": {"styles": ["foo"]}}, ["button", "styles"]),
|
||||
(
|
||||
{"button": {"pressed": {"styles": ["foo"]}}},
|
||||
["button", "pressed", "styles"],
|
||||
),
|
||||
(
|
||||
{"arc": {"indicator": {"styles": ["foo"]}}},
|
||||
["arc", "indicator", "styles"],
|
||||
),
|
||||
(
|
||||
{"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}},
|
||||
["arc", "indicator", "pressed", "styles"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_theme_schema_rejects_styles_key(
|
||||
config: dict, expected_path: list[str]
|
||||
) -> None:
|
||||
# `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA
|
||||
# but silently dropped by style_set when building a theme's hidden style
|
||||
# -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing,
|
||||
# at the top level and when nested under a part and/or state.
|
||||
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
|
||||
theme_schema(config)
|
||||
assert exc_info.value.path == expected_path
|
||||
|
||||
|
||||
def test_theme_update_schema_rejects_styles_key() -> None:
|
||||
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
|
||||
theme_update_schema({"label": {"styles": ["foo"]}})
|
||||
assert exc_info.value.path == ["label", "styles"]
|
||||
|
||||
|
||||
def test_theme_update_schema_does_not_request_untargeted_main_default() -> None:
|
||||
# collect_parts() unconditionally seeds a main/default entry even when
|
||||
# only a specific state (here "pressed") was targeted -- registering a
|
||||
# request for that spurious entry would make theme_to_code create an
|
||||
# unused, empty style and attach it to every widget of this type.
|
||||
theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}})
|
||||
assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None}
|
||||
|
||||
|
||||
def test_theme_update_schema_requests_explicit_main_default() -> None:
|
||||
theme_update_schema({"label": {"text_color": 0x010203}})
|
||||
assert df.get_theme_update_requests()["label"] == {("main", "default"): None}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
|
||||
)
|
||||
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
|
||||
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
|
||||
# theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
|
||||
# collision, dict-spread keeps the first source's marker (and its default)
|
||||
# but the last source's value, whereas .extend() would take both from the
|
||||
# later source. The two are equivalent today because the overlapping
|
||||
|
||||
@@ -295,6 +295,22 @@ lvgl:
|
||||
id: style_test
|
||||
bg_color: blue
|
||||
bg_opa: !lambda return 0.5;
|
||||
# `obj` is already themed above -- exercises updating an existing hidden style.
|
||||
- lvgl.theme.update:
|
||||
obj:
|
||||
border_width: 2
|
||||
# `label` is never mentioned under `theme:` -- exercises lazily creating the
|
||||
# hidden style and getting it attached to already-built label widgets.
|
||||
- lvgl.theme.update:
|
||||
label:
|
||||
text_color: red
|
||||
# `button` is never mentioned under `theme:`, and only a non-default state is
|
||||
# targeted here -- exercises that no spurious, empty main/default style is
|
||||
# created (and attached to every button) alongside the requested one.
|
||||
- lvgl.theme.update:
|
||||
button:
|
||||
pressed:
|
||||
bg_color: red
|
||||
- lvgl.image.update:
|
||||
id: lv_image
|
||||
src:
|
||||
|
||||
Reference in New Issue
Block a user