From 35d41adf8606883080d368633f53b5a56ba102f9 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:20:13 +1000 Subject: [PATCH] [lvgl] Animation updates and debug borders option (#19420) --- esphome/components/lvgl/__init__.py | 3 ++ esphome/components/lvgl/animation.h | 25 +++++++++++++ esphome/components/lvgl/animation.py | 8 +++++ esphome/components/lvgl/defines.py | 28 +++++++++++++++ esphome/components/lvgl/lv_validation.py | 6 ++-- esphome/components/lvgl/lvgl_esphome.cpp | 1 + esphome/components/lvgl/widgets/__init__.py | 19 ++++++++++ esphome/components/lvgl/widgets/lv_list.py | 2 ++ .../lvgl/config/debug_outline.yaml | 36 +++++++++++++++++++ .../lvgl/config/no_debug_outline.yaml | 35 ++++++++++++++++++ tests/component_tests/lvgl/test_animation.py | 18 ++++++++++ .../lvgl/test_debug_outline.py | 33 +++++++++++++++++ .../lvgl/test_multi_conf_validate.py | 22 +++++++++++- .../lvgl/test-debug-outline.esp32-idf.yaml | 34 ++++++++++++++++++ tests/components/lvgl/test.host.yaml | 4 +++ 15 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/config/debug_outline.yaml create mode 100644 tests/component_tests/lvgl/config/no_debug_outline.yaml create mode 100644 tests/component_tests/lvgl/test_debug_outline.py create mode 100644 tests/components/lvgl/test-debug-outline.esp32-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 382e483d0c..3822664c94 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -238,6 +238,7 @@ def multi_conf_validate(configs: list[dict]): CONF_COLOR_DEPTH, CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, + df.CONF_DEBUG_OUTLINE, ): if base_config[item] != config[item]: raise cv.Invalid( @@ -393,6 +394,7 @@ async def to_code(configs): df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) cg.add(lvgl_static.esphome_lvgl_init()) default_group = get_default_group(config_0) + df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE] for config in configs: frac = config[CONF_BUFFER_SIZE] @@ -634,6 +636,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, + cv.Optional(df.CONF_DEBUG_OUTLINE, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 05b84926eb..1ef22f9b91 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -101,6 +101,31 @@ class LvAnimationTimingEaseInOut : public LvAnimationTiming { float slope_; }; +class LvAnimationTimingEaseIn : public LvAnimationTiming { + public: + LvAnimationTimingEaseIn(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float sqr = value * value; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + +class LvAnimationTimingEaseOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + const float inv = 1.0f - value; + const float sqr = 1.0f - inv * inv; + return this->slope_ * sqr + (1.0f - this->slope_) * value; + } + + protected: + float slope_; +}; + template class LvAnimation : public Component { public: LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 95d45de5ea..bab413f04d 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -76,6 +76,14 @@ TIMING_SCHEMA = cv.maybe_simple_value( "ease_in_out", {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), + timing_class( + "ease_in", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), + timing_class( + "ease_out", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, + ), timing_class( "gravity", { diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f601b4b57a..fbb621800b 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -35,6 +35,24 @@ KEY_WIDGET_MAP = "widget_map" KEY_WIDGETS_COMPLETED = "widgets_completed" KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" + +# Colours for the debug outline, in (red, green, blue) order. They are picked to stay +# distinct from each other and to show up on both light and dark backgrounds. +DEBUG_OUTLINE_COLORS = ( + (255, 0, 0), + (0, 160, 0), + (0, 0, 255), + (255, 140, 0), + (200, 0, 200), + (0, 190, 190), + (160, 100, 0), + (255, 0, 120), + (110, 110, 110), + (140, 200, 0), + (0, 110, 255), + (130, 0, 255), +) # Initial set of LVGL features that are always enabled. _INITIAL_LV_USES = frozenset( @@ -102,6 +120,15 @@ def get_options() -> dict[str, Any]: return _get_data(KEY_OPTIONS, {}) +def next_debug_outline_color() -> tuple[int, int, int]: + """Return the next debug outline colour, cycling through the palette.""" + # A one-element list so that the count can be updated in place. + count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0]) + color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)] + count[0] += 1 + return color + + def get_defines() -> dict[str, str]: return _get_data(KEY_LV_DEFINES, {}) @@ -684,6 +711,7 @@ CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" +CONF_DEBUG_OUTLINE = "debug_outline" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" CONF_DIR = "dir" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b3285afa1a..961fa4079d 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -157,10 +157,12 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def scale_validator(value): - return cv.float_range(0.1, 10.0)(value) + return cv.float_range(0.0, 10.0)(value) -scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256)) +scale = LValidator( + scale_validator, uint32, retmapper=lambda x: int(x * 256), animatable=True +) def angle(value): diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 2c988473a9..90a770ac3b 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -994,6 +994,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a524fe761f..da513e05a8 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -21,6 +21,7 @@ from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( + CONF_DEBUG_OUTLINE, CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, @@ -42,12 +43,14 @@ from ..defines import ( STATES, LValidator, add_lv_use, + get_options, get_styles_used, get_theme_widget_map, get_widget_map, get_widgets_completed, join_enums, literal, + next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( @@ -191,6 +194,7 @@ class WidgetType: w = Widget.create(wid, var, self, config) apply_theme_styles(w) await set_obj_properties(w, config) + apply_debug_outline(w) await add_widgets(w, config) await self.to_code(w, config) return w @@ -270,6 +274,21 @@ def apply_theme_styles(w: "Widget") -> None: w.add_style(style, lv_state) +def apply_debug_outline(w: "Widget") -> None: + """ + When `debug_outline` is set, outline this widget in the next palette colour. + An outline is drawn outside the widget's own box, so it doesn't take up layout + space and doesn't touch the widget's own `border_*` style, unlike a border. + """ + if not get_options().get(CONF_DEBUG_OUTLINE): + return + r, g, b = next_debug_outline_color() + w.set_style("outline_width", 1) + w.set_style("outline_pad", 0) + w.set_style("outline_color", f"lv_color_make({r}, {g}, {b})") + w.set_style("outline_opa", "LV_OPA_COVER") + + class Widget: """ Represents a Widget. diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index 7711e8bfe4..5d94c2565a 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -57,6 +57,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t from . import ( Widget, WidgetType, + apply_debug_outline, apply_theme_styles, collect_parts, get_widgets, @@ -445,6 +446,7 @@ async def _finish_dynamic_widget( await w.type.on_create(w.obj, w_conf) apply_theme_styles(w) await set_obj_properties(w, w_conf) + apply_debug_outline(w) await w.type.to_code(w, w_conf) await _wire_dynamic_triggers(w, w_conf) for child in w_conf.get(CONF_WIDGETS, ()): diff --git a/tests/component_tests/lvgl/config/debug_outline.yaml b/tests/component_tests/lvgl/config/debug_outline.yaml new file mode 100644 index 0000000000..eb8a488c13 --- /dev/null +++ b/tests/component_tests/lvgl/config/debug_outline.yaml @@ -0,0 +1,36 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +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: + debug_outline: true + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/config/no_debug_outline.yaml b/tests/component_tests/lvgl/config/no_debug_outline.yaml new file mode 100644 index 0000000000..2ea646bcb2 --- /dev/null +++ b/tests/component_tests/lvgl/config/no_debug_outline.yaml @@ -0,0 +1,35 @@ +esphome: + name: test-debug-outline + +esp32: + board: lolin_c3_mini + +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: + widgets: + - obj: + id: root_obj + widgets: + - label: + id: title_label + text: Title + - button: + id: ok_button + widgets: + - label: + text: OK diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py index ce9a162d99..a150f66a40 100644 --- a/tests/component_tests/lvgl/test_animation.py +++ b/tests/component_tests/lvgl/test_animation.py @@ -191,6 +191,24 @@ class TestTimingSchema: result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5}) assert result["weight"] == pytest.approx(0.5) + def test_ease_in_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in") + assert result["type"] == "ease_in" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_in_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + + def test_ease_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_out") + assert result["type"] == "ease_out" + assert result["weight"] == pytest.approx(1.0) + + def test_ease_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_out", "weight": 0.5}) + assert result["weight"] == pytest.approx(0.5) + def test_gravity_defaults(self) -> None: result = TIMING_SCHEMA("gravity") assert result["type"] == "gravity" diff --git a/tests/component_tests/lvgl/test_debug_outline.py b/tests/component_tests/lvgl/test_debug_outline.py new file mode 100644 index 0000000000..78eb3c077f --- /dev/null +++ b/tests/component_tests/lvgl/test_debug_outline.py @@ -0,0 +1,33 @@ +"""Tests for the LVGL ``debug_outline`` option code generation.""" + +from __future__ import annotations + +import re + +_OUTLINE_COLOR_RE = re.compile( + r"lv_obj_set_style_outline_color\(.+?, (lv_color_make\(.+?\))," +) +_OUTLINE_WIDTH_RE = re.compile(r"lv_obj_set_style_outline_width\(") +_OUTLINE_PAD_RE = re.compile(r"lv_obj_set_style_outline_pad\(.+?, 0,") + + +class TestDebugOutlineCodeGeneration: + """Verify that ``debug_outline`` outlines every widget in a distinct colour.""" + + def test_debug_outline_styles_every_widget( + self, generate_main, component_config_path + ): + """Four widgets are declared, so four outlines with different colours appear.""" + main_cpp = generate_main(component_config_path("debug_outline.yaml")) + assert len(_OUTLINE_WIDTH_RE.findall(main_cpp)) == 4 + assert len(_OUTLINE_PAD_RE.findall(main_cpp)) == 4 + colors = _OUTLINE_COLOR_RE.findall(main_cpp) + assert len(colors) == 4 + assert len(set(colors)) == 4 + + def test_debug_outline_default_emits_nothing( + self, generate_main, component_config_path + ): + """Without ``debug_outline`` no outline styles are generated.""" + main_cpp = generate_main(component_config_path("no_debug_outline.yaml")) + assert "lv_obj_set_style_outline_" not in main_cpp diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index b63b7618e7..c44b40317e 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -9,13 +9,16 @@ from esphome.components.lvgl.schemas import theme_schema from esphome.config_validation import Invalid -def _config(displays: list[str], theme: dict | None = None) -> dict: +def _config( + displays: list[str], theme: dict | None = None, debug_outline: bool = False +) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, + df.CONF_DEBUG_OUTLINE: debug_outline, } if theme is not None: config[df.CONF_THEME] = theme @@ -53,3 +56,20 @@ class TestThemeOnMultipleInstances: def test_passes_when_no_instance_has_theme(self) -> None: configs = [_config(["disp_a"]), _config(["disp_b"])] multi_conf_validate(configs) + + +class TestDebugOutlineOnMultipleInstances: + def test_raises_when_instances_differ(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=False), + ] + with pytest.raises(Invalid, match="'debug_outline' must be the same"): + multi_conf_validate(configs) + + def test_passes_when_instances_match(self) -> None: + configs = [ + _config(["disp_a"], debug_outline=True), + _config(["disp_b"], debug_outline=True), + ] + multi_conf_validate(configs) diff --git a/tests/components/lvgl/test-debug-outline.esp32-idf.yaml b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml new file mode 100644 index 0000000000..73418962ff --- /dev/null +++ b/tests/components/lvgl/test-debug-outline.esp32-idf.yaml @@ -0,0 +1,34 @@ +# Kept separate from the main lvgl-package.yaml so that enabling debug_outline +# here doesn't add its extra style calls to the memory-impact baseline that +# every other lvgl test change is measured against. +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: debug_outline_display + dimensions: + width: 240 + height: 320 + data_rate: 80MHz + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + update_interval: never + +lvgl: + debug_outline: true + displays: + - debug_outline_display + widgets: + - obj: + id: debug_outline_root + widgets: + - label: + text: Title + - button: + widgets: + - label: + text: OK diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 3fa54fa3d6..22d051da8e 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -40,6 +40,10 @@ lvgl: - round_trip - type: ease_in_out weight: 0.5 + - type: ease_in + weight: 0.3 + - type: ease_out + weight: 0.7 on_start: - logger.log: anim started on_stop: