[lvgl] Animation updates and debug borders option (#19420)

This commit is contained in:
Clyde Stubbs
2026-09-30 07:20:13 +10:00
committed by GitHub
parent 662c7db222
commit 35d41adf86
15 changed files with 271 additions and 3 deletions
+3
View File
@@ -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)
+25
View File
@@ -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<size_t DATA_SIZE, bool AUTO_START = false> class LvAnimation : public Component {
public:
LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector<TemplatableValue<lv_coord_t>> from,
+8
View File
@@ -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",
{
+28
View File
@@ -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"
+4 -2
View File
@@ -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):
+1
View File
@@ -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);
}
@@ -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.
@@ -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, ()):
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -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)
@@ -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
+4
View File
@@ -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: