[lvgl] Dynamic rotation features (#16773)

This commit is contained in:
Clyde Stubbs
2026-07-08 15:54:28 -04:00
committed by GitHub
parent 9088875491
commit 640e0973ac
12 changed files with 538 additions and 41 deletions
@@ -0,0 +1,92 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
id: lvgl_id
displays: tft_display
pages:
- id: main_page
widgets:
# A flex container whose layout options are changed at runtime.
- obj:
id: flex_box
layout:
type: flex
flex_flow: row
widgets:
- label:
text: a
- label:
text: b
# A grid container whose alignment options are changed at runtime.
# The grid structure (rows/columns) is fixed here at creation.
- obj:
id: grid_box
layout:
type: grid
grid_rows: [content, content]
grid_columns: [fr(1), fr(1)]
widgets:
- label:
text: c
- label:
text: d
# Button hosting all of the update actions under test.
- button:
id: btn_actions
on_click:
# Update flex container options (type unchanged).
- lvgl.widget.update:
id: flex_box
layout:
flex_flow: column
flex_align_main: center
flex_align_cross: end
pad_row: 7px
# Update grid container alignment options (structure unchanged).
- lvgl.widget.update:
id: grid_box
layout:
grid_column_align: space_between
grid_row_align: center
# Top-level layout applies to the active screen.
- lvgl.update:
layout:
flex_flow: column
pad_column: 5px
# Layout applied to the top display layer.
- lvgl.update:
top_layer:
layout:
flex_flow: row
# Styling applied to the bottom display layer (exercises the
# layers code path that previously generated no code).
- lvgl.update:
bottom_layer:
bg_color: 0x123456
@@ -0,0 +1,208 @@
"""Tests for updating LVGL layout options via the update actions.
The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget
``lvgl.<widget>.update``) actions can change a container's layout *options* at
runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns``
structure are fixed at widget creation (they determine the cells/options
available to child widgets), so only the simple style options - those applied
via ``lv_obj_set_style_...`` calls - may be changed.
These tests cover both the ``layout_validator`` (schema/normalisation) and the
generated C++ for each target: a widget, the active screen (top-level
``lvgl.update``) and the display layers.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses
from esphome.components.lvgl.layout import layout_validator
from esphome.config import read_config
from esphome.core import CORE
# ---------------------------------------------------------------------------
# layout_validator - schema and normalisation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}),
({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}),
({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}),
(
{"grid_column_align": "space_between"},
{"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"},
),
({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}),
({"pad_row": "7px"}, {"pad_row": 7}),
({"pad_column": "5px"}, {"pad_column": 5}),
],
)
def test_layout_validator_normalises_options(value: dict, expected: dict) -> None:
"""Each supported option is accepted and normalised to its LVGL form."""
assert layout_validator(value) == expected
def test_layout_validator_accepts_multiple_options() -> None:
"""Several options may be combined in one update."""
result = layout_validator(
{"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"}
)
assert result == {
"flex_flow": "LV_FLEX_FLOW_COLUMN",
"flex_align_main": "LV_FLEX_ALIGN_CENTER",
"pad_row": 4,
}
@pytest.mark.parametrize(
"value",
[
{"type": "flex"},
{"type": "grid", "grid_column_align": "center"},
{"grid_rows": 3},
{"grid_columns": ["fr(1)"]},
{"grid_rows": [1, 2], "flex_flow": "row"},
],
)
def test_layout_validator_rejects_structural_keys(value: dict) -> None:
"""The layout type and grid structure are fixed at creation and must not
be changeable via an update action."""
with pytest.raises(Invalid, match="extra keys not allowed"):
layout_validator(value)
def test_layout_validator_rejects_empty() -> None:
"""An update must specify at least one layout option."""
with pytest.raises(Invalid, match="at least one layout option"):
layout_validator({})
def test_layout_validator_registers_flex_use() -> None:
"""Validating a flex option registers the flex feature so LV_USE_FLEX is
emitted even when the option is set solely via an update action."""
layout_validator({"flex_flow": "row"})
assert TYPE_FLEX in get_lv_uses()
def test_layout_validator_registers_grid_use() -> None:
"""Validating a grid option registers the grid feature."""
layout_validator({"grid_column_align": "center"})
assert TYPE_GRID in get_lv_uses()
def test_pad_only_update_registers_no_layout_use() -> None:
"""Padding options belong to both layout types, so they alone do not force
either feature on."""
layout_validator({"pad_row": "4px"})
uses = get_lv_uses()
assert TYPE_FLEX not in uses
assert TYPE_GRID not in uses
# ---------------------------------------------------------------------------
# Generated C++ for the update actions
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared layout-update YAML config once
per module (codegen is relatively expensive)."""
config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None:
"""``lvgl.widget.update`` changes only the flex options that are specified,
via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow``
calls on the target widget."""
assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp
assert (
"lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)"
in main_cpp
)
assert (
"lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)"
in main_cpp
)
assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp
def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None:
"""The update must not re-establish the layout type: ``lv_obj_set_layout``
is emitted once (at creation) and never from the update action."""
assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1
def test_widget_flex_update_is_partial(main_cpp: str) -> None:
"""An option that was not specified in the update (the track placement) is
only set at creation, not by the partial update."""
assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1
def test_widget_grid_update_applies_alignments(main_cpp: str) -> None:
"""``lvgl.widget.update`` on a grid container changes its alignment
options without touching the grid structure."""
assert (
"lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, "
"LV_STATE_DEFAULT)" in main_cpp
)
assert (
"lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)"
in main_cpp
)
def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None:
"""The grid row/column descriptor arrays are structural and generated once
at creation; an update must not regenerate them."""
assert main_cpp.count("grid_box_row_dsc") != 0
# The descriptor array is declared once and referenced once at creation.
assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc")
assert "lv_obj_set_layout(grid_box," in main_cpp
assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1
def test_top_level_layout_targets_active_screen(main_cpp: str) -> None:
"""A top-level ``lvgl.update: { layout: ... }`` applies to the active
screen, not to the LVGL component object."""
assert (
"lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)"
in main_cpp
)
assert (
"lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)"
in main_cpp
)
def test_top_layer_layout_applied(main_cpp: str) -> None:
"""A layout under ``top_layer`` is applied to the display's top layer."""
assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp
assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp
def test_bottom_layer_styling_applied(main_cpp: str) -> None:
"""A ``bottom_layer`` style update generates code (previously the layer
keys of ``lvgl.update`` were silently ignored)."""
assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp
assert (
"lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), "
"LV_PART_MAIN)" in main_cpp
)