[lvgl] Add a color picker widget (#17883)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Clyde Stubbs
2026-10-12 15:12:22 +11:00
committed by GitHub
co-authored by Claude Opus 5 J. Nick Koston
parent 5c1c946e88
commit 3f21ddf6ff
16 changed files with 2171 additions and 25 deletions
@@ -0,0 +1,133 @@
esphome:
name: test-color-picker
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
color:
- id: picker_colour
hex: "3366CC"
lvgl:
style_definitions:
- id: picker_style
bg_opa: cover
widgets:
# Sized from the text font
- color_picker:
id: picker_content
width: SIZE_CONTENT
# Fixed width, colour given as a hex literal
- color_picker:
id: picker_fixed
width: 120
color: 0xFF8800
# Styling the sliders the widget is built from
- color_picker:
id: picker_styled
width: 100
items:
bg_opa: 80%
pressed:
border_width: 2
knob:
bg_color: 0x112233
radius: 4
styles: [picker_style]
# One value for six sliders: it must not be generated once per slider. The body
# is more than an expression so that it stays a lambda rather than being folded
# into a constant.
border_width: !lambda |-
int width = 3;
return width;
pressed:
styles: [picker_style]
# Belongs to the widget as a whole, so it is reported and ignored here
ext_click_area: 8
# Only the red, green and blue sliders, with a margin making them easier to press.
# The scrolling options belong to the widget's own object, not to the sliders.
- color_picker:
id: picker_rgb
width: 100
sliders: rgb
ext_click_area: 12
scrollbar_mode: "OFF"
# Black must not be mistaken for no colour at all
- color_picker:
id: picker_black
width: 100
color: 0x000000
# The knob background arrives through a named style rather than directly
- color_picker:
id: picker_knob_style
width: 100
knob:
styles: [picker_style]
# Only the hue, saturation and brightness sliders, styled
- color_picker:
id: picker_hsv
width: 100
sliders: [hsv]
knob:
radius: 2
# A single slider, for picking a hue with the brightness controlled elsewhere
- color_picker:
id: picker_hue
width: 100
sliders: hue
# A colour wheel in place of the hue and saturation sliders
- color_picker:
id: picker_wheel
width: 100
wheel: true
# Colour given as the id of a colour defined above
- color_picker:
id: picker_colour_id
width: 50%
color: picker_colour
on_value:
- logger.log:
format: "picked %d,%d,%d"
args: [x.r, x.g, x.b]
on_release:
- logger.log: released
on_update:
- logger.log: updated
script:
- id: update_picker
then:
- lvgl.color_picker.update:
id: picker_fixed
color: 0x00FF00
- lvgl.color_picker.update:
id: picker_hue
color: !lambda return lv_color_hex(0x123456);
@@ -0,0 +1,48 @@
esphome:
name: test-color-picker
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
style_definitions:
# A widget property in a style is dropped, so it is reported
- id: clickable_style
ext_click_area: 10
theme:
# A themed knob colour must not be covered by the knob tinting
color_picker:
knob:
bg_color: 0x112233
# Widget properties in a theme are dropped too, at any level
button:
scrollbar_mode: "OFF"
pressed:
ext_click_area: 4
widgets:
- color_picker:
id: themed_picker
width: 100
- button:
id: themed_button
styles: clickable_style
@@ -0,0 +1,47 @@
esphome:
name: test-named-styles
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
style_definitions:
- id: chunky
bg_opa: cover
border_width: 2
widgets:
# A plain widget, so the selector each named style is added with is the one LVGL is
# given directly, with no compound widget in the way.
- slider:
id: plain_slider
width: 100
# No part and no state: the selector is a bare zero.
styles: [chunky]
knob:
# A part on its own.
styles: [chunky]
pressed:
# A part combined with a state, which has to survive the cast.
styles: [chunky]
pressed:
# A state on its own.
styles: [chunky]
@@ -0,0 +1,628 @@
"""Tests for the LVGL color picker widget schema and code generation."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from voluptuous import Schema
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.widgets import _build_update_schema
from esphome.components.lvgl.widgets.color_picker import color_picker_spec
from esphome.config_validation import Invalid
from esphome.const import CONF_WIDTH
@pytest.fixture()
def update_schema() -> Schema:
"""The schema behind `lvgl.color_picker.update`."""
return _build_update_schema(color_picker_spec)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestColorPickerValidation:
"""Test the widget's own schema options."""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
return container_schema(color_picker_spec)
@pytest.mark.parametrize("width", [100, "50%", "SIZE_CONTENT"])
def test_width_accepted(
self, schema: Callable[[dict], dict], width: int | str
) -> None:
assert schema({"width": width}) is not None
def test_width_defaults_to_size_content(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({})[CONF_WIDTH] == "LV_SIZE_CONTENT"
def test_height_is_rejected(self, schema: Callable[[dict], dict]) -> None:
"""The widget is square, so a height of its own makes no sense."""
with pytest.raises(Invalid, match="Height will be set to the same as width"):
schema({"width": 100, "height": 100})
def test_color_is_optional(self, schema: Callable[[dict], dict]) -> None:
assert "color" not in schema({"width": 100})
def test_on_value_is_accepted(self, schema: Callable[[dict], dict]) -> None:
"""The trigger is only offered if the widget declares it has a value."""
result = schema({"width": 100, "on_value": [{"logger.log": "picked"}]})
assert "on_value" in result
def test_value_argument_is_a_color(self) -> None:
"""`x` must be an esphome Color, so that lambdas can use x.r/x.g/x.b.
esphome::Color converts to lv_color_t implicitly, so declaring the wrong
one of the two still compiles but gives lambdas the LVGL struct, whose
members are named differently.
"""
(arg_type,) = color_picker_spec.w_type.get_arg_type()
assert str(arg_type) == "Color"
def test_update_action_sets_the_colour(self, update_schema: Schema) -> None:
"""The colour is the one thing an update action is for."""
assert update_schema({"id": "picker", "color": 0xFF8800}) is not None
def test_update_action_does_not_require_width(self, update_schema: Schema) -> None:
"""Width is only needed when the widget is created, unlike in the widget schema."""
assert CONF_WIDTH not in update_schema({"id": "picker", "color": 0xFF8800})
def test_slider_parts_are_declared(self) -> None:
"""`items` and `knob` stand for the sliders the widget is built from."""
assert color_picker_spec.parts == ("main", "items", "knob")
@pytest.mark.parametrize("part", ["items", "knob"])
def test_slider_parts_are_accepted(
self, schema: Callable[[dict], dict], part: str
) -> None:
assert schema({"width": 100, part: {"radius": 4}}) is not None
class TestColorPickerSliderChoice:
"""Test choosing which of the six sliders the widget is built from."""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
return container_schema(color_picker_spec)
def test_all_sliders_by_default(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100})["sliders"] == [
"hue",
"saturation",
"brightness",
"red",
"green",
"blue",
]
@pytest.mark.parametrize(
("configured", "expected"),
[
("rgb", ["red", "green", "blue"]),
("hsv", ["hue", "saturation", "brightness"]),
# Both spellings of the third HSV component are accepted.
("hsb", ["hue", "saturation", "brightness"]),
(["hue", "saturation"], ["hue", "saturation"]),
(["hue"], ["hue"]),
# A group can be mixed with single sliders.
(["rgb", "hue"], ["hue", "red", "green", "blue"]),
],
)
def test_groups_are_expanded(
self,
schema: Callable[[dict], dict],
configured: str | list[str],
expected: list[str],
) -> None:
assert schema({"width": 100, "sliders": configured})["sliders"] == expected
def test_order_follows_the_widget_not_the_config(
self, schema: Callable[[dict], dict]
) -> None:
"""The layout is fixed, so the order they are listed in makes no difference."""
assert schema({"width": 100, "sliders": ["blue", "red"]})["sliders"] == [
"red",
"blue",
]
def test_repeats_are_dropped(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100, "sliders": ["red", "rgb"]})["sliders"] == [
"red",
"green",
"blue",
]
def test_empty_list_is_rejected(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="At least one slider is required"):
schema({"width": 100, "sliders": []})
def test_unknown_slider_is_rejected(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="Unknown value 'cyan'"):
schema({"width": 100, "sliders": ["cyan"]})
def test_sliders_cannot_be_updated(self, update_schema: Schema) -> None:
"""The layout is built from them, so they cannot change after creation."""
with pytest.raises(Invalid, match="sliders"):
update_schema({"id": "picker", "sliders": "rgb"})
class TestColorPickerWheel:
"""Test the `wheel` option, which replaces the hue and saturation sliders, and the RGB
sliders besides, with a circular hue/saturation picker.
The exclusion of these sliders happens in `ColorPickerType.validate()`, which runs
before the schema itself (see `WIDGET_TYPES` validation in schemas.py), so these tests
reproduce that order rather than using the `container_schema` fixture alone.
"""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
container = container_schema(color_picker_spec)
def _validate(value: dict) -> dict:
return container(color_picker_spec.validate(value))
return _validate
def test_wheel_defaults_to_false(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100})["wheel"] is False
def test_wheel_must_be_a_boolean(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="boolean"):
schema({"width": 100, "wheel": "round"})
def test_wheel_leaves_only_brightness_by_default(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({"width": 100, "wheel": True})["sliders"] == ["brightness"]
def test_wheel_is_compatible_with_an_explicit_slider_list(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({"width": 100, "wheel": True, "sliders": "brightness"})[
"sliders"
] == ["brightness"]
@pytest.mark.parametrize(
"sliders",
["hue", "saturation", "red", "green", "blue", "rgb", ["hue", "red"], "hsv"],
)
def test_wheel_conflicts_with_sliders_it_replaces(
self, schema: Callable[[dict], dict], sliders: str | list[str]
) -> None:
with pytest.raises(Invalid, match="color wheel"):
schema({"width": 100, "wheel": True, "sliders": sliders})
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
_SET_STYLE_RE = re.compile(r"lv_obj_set_style_(\w+)\((\w+)->obj, (.+?), \w+\);")
def _styles_for(main_cpp: str, var: str) -> dict[str, str]:
"""Return {property: value} for every style set directly on a widget."""
return {
m.group(1): m.group(3)
for m in _SET_STYLE_RE.finditer(main_cpp)
if m.group(2) == var
}
class TestColorPickerCodeGeneration:
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
def test_height_follows_width(self, main_cpp: str) -> None:
"""The widget is square, so height is generated to match the given width."""
for var, expected in (
("picker_content", "LV_SIZE_CONTENT"),
("picker_fixed", "120"),
("picker_colour_id", "lv_pct(50)"),
):
styles = _styles_for(main_cpp, var)
assert styles["width"] == expected
assert styles["height"] == expected
def test_color_is_applied(self, main_cpp: str) -> None:
assert "picker_fixed->set_color(" in main_cpp
assert "picker_colour_id->set_color(" in main_cpp
@pytest.mark.parametrize("var", ["picker_content", "picker_rgb", "picker_wheel"])
def test_color_is_omitted_when_not_configured(
self, main_cpp: str, var: str
) -> None:
"""Each of these can change saturation, so the grey it starts with is no trap."""
assert f"{var}->set_color(" not in main_cpp
def test_wheel_code_is_built_for_a_wheel(self, main_cpp: str) -> None:
from esphome.core import CORE
assert "USE_LVGL_COLOR_PICKER_WHEEL" in {d.name for d in CORE.defines}
def test_wheel_code_is_left_out_without_a_wheel(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Its gradients would otherwise take RAM in every build with a picker."""
from esphome.core import CORE
generate_main(component_config_path("color_picker_theme.yaml"))
assert "USE_LVGL_COLOR_PICKER_WHEEL" not in {d.name for d in CORE.defines}
def test_hue_only_picker_starts_saturated(self, main_cpp: str) -> None:
"""With no way to change saturation, a grey start would leave it stuck at grey."""
assert "picker_hue->set_color(lv_color_make(255, 0, 0));" in main_cpp
def test_black_is_applied(self, main_cpp: str) -> None:
"""A hex colour validates to a plain int, and black must not read as absent."""
assert "picker_black->set_color(lv_color_make(0, 0, 0))" in main_cpp
def test_trigger_uses_color_type(self, main_cpp: str) -> None:
"""The on_value trigger is declared with the Color the widget reports."""
assert "Trigger<Color, lv_event_t *>" in main_cpp
def test_trigger_reads_the_current_color(self, main_cpp: str) -> None:
assert "picker_colour_id->get_color()" in main_cpp
def test_update_action_generates_set_color(self, main_cpp: str) -> None:
"""`lvgl.color_picker.update` used to fail outright on the missing width."""
assert main_cpp.count("picker_fixed->set_color(") == 2
def test_update_action_accepts_a_lambda(self, main_cpp: str) -> None:
# A lambda that only returns an expression is generated as that expression.
assert "picker_hue->set_color(lv_color_hex(0x123456));" in main_cpp
@pytest.mark.parametrize("message", ["released", "updated"])
def test_other_triggers_are_generated(self, main_cpp: str, message: str) -> None:
assert f'"{message}"' in main_cpp
def test_gradient_stops_are_raised_for_the_hue_bar(self, main_cpp: str) -> None:
"""The hue bar fills seven stops of a fixed-size array.
`gradient.py` raises the limit when a colour picker is in use, and the C++
`static_assert` relies on that having happened. It works because the widget
records the use during validation, which runs first, but nothing in either
file says so, hence this.
"""
from esphome.components.lvgl.defines import get_defines
assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "7"
def test_gradient_stops_are_left_alone_without_a_picker(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The other half of the same invariant: only a picker pays for the extra stops."""
from esphome.components.lvgl.defines import get_defines
# Any configuration without a colour picker in it will do.
generate_main(component_config_path("named_styles.yaml"))
assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "2"
def test_complex_gradients_are_enabled_for_a_wheel(self, main_cpp: str) -> None:
"""The wheel is a conical and a radial gradient, which need this to render at all."""
from esphome.components.lvgl.defines import get_defines
assert get_defines()["LV_USE_DRAW_SW_COMPLEX_GRADIENTS"] == "1"
def test_complex_gradients_are_left_alone_without_a_wheel(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
from esphome.components.lvgl.defines import get_defines
generate_main(component_config_path("named_styles.yaml"))
assert "LV_USE_DRAW_SW_COMPLEX_GRADIENTS" not in get_defines()
# The widget's own object is a plain container, so styles for the parts a slider has are
# applied to each of its sliders instead.
_SLIDER_STYLE_RE = re.compile(
r"lv_obj_set_style_(\w+)\((\w+)->get_slider\(LvColorPickerType::SLIDER_(\w+)\), "
r"(.+?), (\w+)\);"
)
ALL_SLIDERS = {"HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"}
def _slider_style_values(main_cpp: str, var: str, prop: str) -> set[str]:
"""Return every value one style property was set to across a widget's sliders."""
return {
m.group(4)
for m in _SLIDER_STYLE_RE.finditer(main_cpp)
if m.group(2) == var and m.group(1) == prop and m.group(5) == "LV_PART_KNOB"
}
def _slider_styles(main_cpp: str, var: str) -> dict[tuple[str, str], set[str]]:
"""Return {(property, selector): sliders it was applied to} for one widget."""
found: dict[tuple[str, str], set[str]] = {}
for m in _SLIDER_STYLE_RE.finditer(main_cpp):
if m.group(2) == var:
found.setdefault((m.group(1), m.group(5)), set()).add(m.group(3))
return found
class TestColorPickerSliderStyles:
@pytest.fixture()
def styles(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> dict[tuple[str, str], set[str]]:
main_cpp = generate_main(component_config_path("color_picker.yaml"))
return _slider_styles(main_cpp, "picker_styled")
@pytest.mark.parametrize(
("prop", "selector"),
[
# `knob` keeps its name on the slider...
("bg_color", "LV_PART_KNOB"),
("radius", "LV_PART_KNOB"),
# ...while `items` becomes the slider's own body.
("bg_opa", "LV_PART_MAIN"),
# A state without a part names only the state, as both are zero.
("border_width", "LV_STATE_PRESSED"),
],
)
def test_style_reaches_every_slider(
self, styles: dict[tuple[str, str], set[str]], prop: str, selector: str
) -> None:
assert styles[prop, selector] == ALL_SLIDERS
def test_style_skips_sliders_the_widget_lacks(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A style must not be applied to a slider that was never created."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
styles = _slider_styles(main_cpp, "picker_hsv")
assert styles["radius", "LV_PART_KNOB"] == {"HUE", "SATURATION", "BRIGHTNESS"}
@pytest.mark.parametrize(
"selector",
[
# A part on its own needs no cast...
"LV_PART_KNOB",
# ...while a part combined with a state is cast to the type LVGL takes, which is
# wide enough for both. A state alone would not be.
(
"(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
),
],
)
def test_named_style_reaches_every_slider(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
selector: str,
) -> None:
"""A style listed under `styles:` is added to each slider, not to the container."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
added = re.findall(
r"lv_obj_add_style\(picker_styled->get_slider\("
r"LvColorPickerType::SLIDER_(\w+)\), picker_style, (.+?)\);",
main_cpp,
)
assert {name for name, sel in added if sel == selector} == ALL_SLIDERS
def test_a_lambda_is_generated_once_for_all_the_sliders(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""One YAML value must not become one C++ function per slider."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
# The source also appears once in the comment codegen writes above the lambda.
assert main_cpp.count("int border_width_VAR_ = []() -> int {") == 1
def test_the_same_lambda_reaches_every_slider(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Generating it once must not mean applying it once."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
styles = _slider_styles(main_cpp, "picker_styled")
assert styles["border_width", "LV_PART_KNOB"] == ALL_SLIDERS
assert _slider_style_values(main_cpp, "picker_styled", "border_width") == {
"border_width_VAR_"
}
def test_no_styles_are_left_on_the_widget_itself(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Setting these on the container would have no visible effect."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
own = _styles_for(main_cpp, "picker_styled")
assert set(own) == {"width", "height"}
@pytest.mark.parametrize(
"var",
[
# The background is set on the knob directly...
"picker_styled",
# ...or through a named style, which the tint would still outrank.
"picker_knob_style",
],
)
def test_knob_tinting_is_disabled_by_a_knob_background(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
var: str,
) -> None:
"""The tint is a local style, so it would overwrite the configured colour."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
assert f"{var}->set_tint_knobs(false)" in main_cpp
@pytest.mark.parametrize(
"var", ["picker_content", "picker_fixed", "picker_colour_id"]
)
def test_knob_tinting_is_left_on_otherwise(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
var: str,
) -> None:
main_cpp = generate_main(component_config_path("color_picker.yaml"))
assert f"{var}->set_tint_knobs(" not in main_cpp
class TestColorPickerObjectProperties:
"""The widget's own object is never pressed, so touch settings go to the sliders."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
def test_ext_click_area_reaches_the_sliders(self, main_cpp: str) -> None:
"""This is what makes the knobs easier to grab: it widens each slider's touch area."""
found = re.findall(
r"lv_obj_set_ext_click_area\(picker_rgb->get_slider\("
r"LvColorPickerType::SLIDER_(\w+)\), 12\);",
main_cpp,
)
assert set(found) == {"RED", "GREEN", "BLUE"}
def test_ext_click_area_is_not_left_on_the_widget_itself(
self, main_cpp: str
) -> None:
"""The container has nothing clickable in it, so setting it there does nothing."""
assert "lv_obj_set_ext_click_area(picker_rgb->obj" not in main_cpp
def test_ext_click_area_under_a_part_is_reported(self, main_cpp: str) -> None:
"""It takes no part, so LVGL cannot apply it to one; that must not pass silently."""
from esphome.components.lvgl.defines import get_warnings
assert any(
"'ext_click_area'" in w and "'knob'" in w and "'picker_styled'" in w
for w in get_warnings()
), get_warnings()
def test_scrollbar_mode_stays_on_the_widget_itself(self, main_cpp: str) -> None:
"""Only the touch margin moves: a slider never scrolls, but the container can."""
assert "lv_obj_set_scrollbar_mode(picker_rgb->obj, LV_SCROLLBAR_MODE_OFF)" in (
main_cpp
)
def test_scrollbar_mode_does_not_reach_the_sliders(self, main_cpp: str) -> None:
assert "lv_obj_set_scrollbar_mode(picker_rgb->get_slider(" not in main_cpp
class TestColorPickerSliderConstruction:
"""The chosen sliders are passed to the constructor, before the layout is built."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
@pytest.mark.parametrize(
("var", "expected"),
[
("picker_rgb", ["RED", "GREEN", "BLUE"]),
("picker_hsv", ["HUE", "SATURATION", "BRIGHTNESS"]),
("picker_hue", ["HUE"]),
(
"picker_fixed",
["HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"],
),
],
)
def test_constructor_names_the_chosen_sliders(
self, main_cpp: str, var: str, expected: list[str]
) -> None:
flags = " | ".join(
f"LvColorPickerType::SLIDER_FLAG_{name}" for name in expected
)
assert f"new({var}) LvColorPickerType({flags}, false);" in main_cpp
def test_constructor_passes_wheel_true(self, main_cpp: str) -> None:
assert (
"new(picker_wheel) LvColorPickerType("
"LvColorPickerType::SLIDER_FLAG_BRIGHTNESS, true);"
) in main_cpp
def test_constructor_passes_wheel_false_by_default(self, main_cpp: str) -> None:
assert "new(picker_hue) LvColorPickerType(" in main_cpp
assert "LvColorPickerType::SLIDER_FLAG_HUE, false);" in main_cpp
class TestColorPickerTheme:
"""Settings that come from the theme or a style definition rather than the widget."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker_theme.yaml"))
def test_theme_knob_color_turns_tinting_off(self, main_cpp: str) -> None:
"""The tint is a local style, so it would overwrite the themed colour."""
assert "themed_picker->set_tint_knobs(false)" in main_cpp
@pytest.mark.parametrize(
"expected",
[
("'ext_click_area'", "style 'clickable_style'"),
("'scrollbar_mode'", "the theme for 'button'"),
("'ext_click_area'", "the theme for 'button' under 'pressed'"),
],
)
def test_widget_properties_in_styles_are_reported(
self, main_cpp: str, expected: tuple[str, str]
) -> None:
"""A style only holds style properties, so these would be dropped silently."""
from esphome.components.lvgl.defines import get_warnings
prop, place = expected
assert any(prop in w and f"in {place} " in w for w in get_warnings()), (
get_warnings()
)
@@ -0,0 +1,57 @@
"""Tests for the selector a named style is added to a widget with.
A part and a state are combined into one value, but they live in different halves of it:
a state fits in 16 bits, while a part starts at bit 16. Casting the pair to the state type
would throw the part away and quietly move the style to the main part, so these pin the
type each selector is generated with.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
def _selectors(main_cpp: str, var: str, style: str) -> set[str]:
"""Return every selector a named style was added to a widget with."""
return set(
re.findall(
rf"lv_obj_add_style\({var}, {style}, (.+?)\);",
main_cpp,
)
)
class TestNamedStyleSelectors:
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("named_styles.yaml"))
def test_every_selector_is_generated(self, main_cpp: str) -> None:
assert _selectors(main_cpp, "plain_slider", "chunky") == {
# Neither a part nor a state, which names the main part.
"LV_PART_MAIN",
# A state on its own, which needs no cast as it is the narrower half.
"LV_STATE_PRESSED",
# A part on its own.
"LV_PART_KNOB",
# A part with a state, cast to the type LVGL takes, which is wide enough for
# both. The state type alone would drop the part.
(
"(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
),
}
def test_a_combined_selector_is_not_narrowed_to_a_state(
self, main_cpp: str
) -> None:
"""`lv_state_t` is 16 bits, so it cannot hold a part."""
assert "(lv_state_t)((int)LV_PART_KNOB" not in main_cpp
+52
View File
@@ -1777,6 +1777,58 @@ lvgl:
- label:
text: "2,2"
# Sized from its text font, with a colour given as an id
- color_picker:
id: lv_color_picker_1
width: SIZE_CONTENT
text_font: space16
color: light_blue
on_value:
- logger.log:
format: "color picker is now %d,%d,%d"
args: [x.r, x.g, x.b]
on_release:
- lvgl.color_picker.update:
id: lv_color_picker_2
color: !lambda return x;
# Fixed size, with a colour given as a hex literal
- color_picker:
id: lv_color_picker_2
width: 200
color: 0x3366CC
# Styles for the sliders the widget is built from
items:
border_color: 0x808080
pressed:
border_width: 2
knob:
radius: 4
# A named style, added for both a part on its own and a part with a state
styles: [style_test]
pressed:
styles: [style_test]
on_update:
- logger.log: color picker updated
# Only the red, green and blue sliders
- color_picker:
id: lv_color_picker_3
width: 150
sliders: rgb
# A hue and saturation pair, as used where brightness is set elsewhere
- color_picker:
id: lv_color_picker_4
width: 150
sliders: [hue, saturation]
# Widens the area each slider can be pressed on, so the knobs are easier to move
ext_click_area: 10
# Applies to the widget as a whole, unlike the setting above
scrollbar_mode: "OFF"
knob:
bg_color: 0xFFFFFF
font:
- file: "gfonts://Roboto"
id: space16
+5
View File
@@ -79,6 +79,11 @@ lvgl:
- lvgl.animation.start:
id: anim_slide
lvgl_id: lvgl_0
# Sliders only and no complex gradients, so the wheel's gradient set-up must still build
- color_picker:
id: host_hue_picker
width: 200
sliders: hue
font:
- file: "gfonts://Roboto"
@@ -0,0 +1,82 @@
esphome:
name: lvgl-color-picker-render
host:
api:
actions:
# A snapshot never writes over an existing file, so the test supplies a new name each time.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: picker_display
filename: !lambda return name;
- action: set_orange
then:
- lvgl.color_picker.update:
id: picker_sliders
color: 0xFF8800
- lvgl.color_picker.update:
id: picker_wheel
color: 0xFF8800
# White has no saturation, which a hue-only picker could never change again
- lvgl.color_picker.update:
id: picker_hue
color: 0xFFFFFF
logger:
level: DEBUG
display:
- platform: snapshot
id: picker_display
auto_clear_enabled: false
dimensions:
width: 680
height: 480
lvgl:
displays: picker_display
default_font: montserrat_14
widgets:
- obj:
width: 100%
height: 100%
bg_color: 0x404040
bg_opa: cover
border_width: 0
radius: 0
pad_all: 4
layout:
type: flex
flex_flow: row_wrap
pad_row: 4
pad_column: 4
widgets:
- color_picker:
id: picker_sliders
width: 196
color: 0x3366CC
- color_picker:
id: picker_wheel
width: 196
wheel: true
color: 0x3366CC
# Sized from the text font
- color_picker:
id: picker_content
color: 0x3366CC
# No red, green and blue bars beside the swatch
- color_picker:
id: picker_hsv
width: 196
sliders: hsv
color: 0x3366CC
# Only the hue bar
- color_picker:
id: picker_hue
width: 196
sliders: hue
color: 0x3366CC
@@ -0,0 +1,105 @@
"""Golden-image test for the LVGL colour picker, drawn on a display with no screen.
Pickers are drawn with every slider, with a colour wheel, at the size taken from the font,
and with only some of the sliders. The first two are then given a new colour by
`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in
test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it
saw and prints its hash; update the hash once the image has been looked at and found to be
correct.
"""
from __future__ import annotations
import asyncio
import hashlib
from pathlib import Path
import pytest
from .artifact_utils import keep_artifact
from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 680
HEIGHT = 480
# Every picker showing 0x3366CC.
EXPECTED_INITIAL_SHA256 = (
"5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47"
)
# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white,
# after the update action.
EXPECTED_UPDATED_SHA256 = (
"8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3"
)
# The LVGL version the hashes were generated against; a version change can move them.
EXPECTED_LVGL_VERSION = "9.6.0"
REDRAW_TIMEOUT = 5.0
def _check(image: Bmp, capture: Path, expected: str, name: str) -> None:
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
digest = hashlib.sha256(image.pixels).hexdigest()
if digest == expected:
return
kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes())
from esphome.components.lvgl import LVGL_VERSION
version_hint = ""
if LVGL_VERSION != EXPECTED_LVGL_VERSION:
version_hint = (
f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> "
f"{LVGL_VERSION}), which is the likely cause\n"
)
pytest.fail(
f"{name} screen does not match the expected hash\n"
f"{version_hint}"
f" expected: {expected}\n"
f" actual: {digest}\n"
f"the image that was rendered has been kept at {kept}"
)
@pytest.mark.asyncio
async def test_lvgl_color_picker_render(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The colour picker draws as expected, before and after its colour is changed."""
snapshot_dir = tmp_path / "snapshots"
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
take_screenshot = next(s for s in services if s.name == "take_screenshot")
set_orange = next(s for s in services if s.name == "set_orange")
async def take(name: str) -> None:
await client.execute_service(take_screenshot, {"name": name})
image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial")
_check(image, capture, EXPECTED_INITIAL_SHA256, "initial")
await client.execute_service(set_orange, {})
# The redraw follows the update on a later LVGL tick, so capture until the picture changes.
loop = asyncio.get_running_loop()
deadline = loop.time() + REDRAW_TIMEOUT
attempt = 0
while True:
attempt += 1
capture = snapshot_dir / f"updated-{attempt}.bmp"
await take(capture.name)
updated = await wait_for_bmp(capture)
if updated.pixels != image.pixels:
break
if loop.time() >= deadline:
pytest.fail(
f"the screen did not change within {REDRAW_TIMEOUT}s of the update"
)
await asyncio.sleep(0.2)
_check(updated, capture, EXPECTED_UPDATED_SHA256, "updated")