[lvgl] Dynamic rotation features (#16773)

This commit is contained in:
Clyde Stubbs
2026-07-09 05:54:28 +10:00
committed by GitHub
parent 9088875491
commit 640e0973ac
12 changed files with 538 additions and 41 deletions
+2
View File
@@ -148,6 +148,8 @@ SIMPLE_TRIGGERS = (
df.CONF_ON_RESUME,
df.CONF_ON_DRAW_START,
df.CONF_ON_DRAW_END,
df.CONF_ON_LANDSCAPE,
df.CONF_ON_PORTRAIT,
)
+21 -6
View File
@@ -4,7 +4,6 @@ from typing import Any
from esphome import automation
from esphome.automation import StatelessLambdaAction
import esphome.codegen as cg
from esphome.components.display import validate_rotation
import esphome.config_validation as cv
from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT
from esphome.core import Lambda
@@ -16,6 +15,7 @@ from .defines import (
CONF_BOTTOM_LAYER,
CONF_EDITING,
CONF_FREEZE,
CONF_LAYOUT,
CONF_LVGL_ID,
CONF_MAIN,
CONF_OBJ,
@@ -29,7 +29,8 @@ from .defines import (
get_options,
get_refreshed_widgets,
)
from .lv_validation import lv_bool, lv_milliseconds
from .layout import layout_validator
from .lv_validation import lv_bool, lv_milliseconds, lv_rotation
from .lvcode import (
LVGL_COMP_ARG,
UPDATE_EVENT,
@@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args):
def _validate_rotation(value):
# Note that we need rotation
get_options()[CONF_ROTATION] = True
return validate_rotation(value)
return lv_rotation(value)
@automation.register_action(
@@ -218,7 +219,8 @@ def _validate_rotation(value):
async def lvgl_set_rotation(config, action_id, template_arg, args):
lv_comp = await cg.get_variable(config[CONF_LVGL_ID])
async with LambdaContext(args, where=action_id) as context:
lv_add(lv_comp.set_rotation(config[CONF_ROTATION]))
rotation = await lv_rotation.process(config[CONF_ROTATION])
lv_add(lv_comp.set_rotation(rotation))
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
@@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock
DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
def _layer_update_schema() -> cv.Schema:
"""Schema for updating a display layer's styling and layout options."""
return part_schema(layer_spec.parts).extend(
{cv.Optional(CONF_LAYOUT): layout_validator}
)
@automation.register_action(
"lvgl.update",
LvglAction,
@@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
.extend(DISP_BG_SCHEMA)
.extend(
{
cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts),
cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts),
cv.Optional(CONF_LAYOUT): layout_validator,
cv.Optional(CONF_TOP_LAYER): _layer_update_schema(),
cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(),
}
),
synchronous=True,
@@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config, CONF_LVGL_ID)
w = widgets[0]
async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
# Apply the top-level properties (styles and layout) to the active screen...
await set_obj_properties(get_screen_active(w.var), config)
# ...the deprecated flat `disp_*` background properties...
await lvgl_update(w.var, config)
# ...and the `top_layer`/`bottom_layer` keys (styling and layout updates).
await layers_to_code(w.var, config)
var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
await cg.register_parented(var, w.var)
return var
+2
View File
@@ -760,7 +760,9 @@ CONF_ONE_CHECKED = "one_checked"
CONF_ONE_LINE = "one_line"
CONF_ON_DRAW_START = "on_draw_start"
CONF_ON_DRAW_END = "on_draw_end"
CONF_ON_LANDSCAPE = "on_landscape"
CONF_ON_PAUSE = "on_pause"
CONF_ON_PORTRAIT = "on_portrait"
CONF_ON_RESUME = "on_resume"
CONF_ON_SELECT = "on_select"
CONF_ON_STOP = "on_stop"
+56
View File
@@ -34,6 +34,7 @@ from .defines import (
TYPE_GRID,
TYPE_NONE,
LvConstant,
add_lv_use,
)
from .lv_validation import padding, size
@@ -401,6 +402,61 @@ LAYOUT_CLASSES = (
LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES]
# Layout properties that may be changed at runtime via an update action. These
# are limited to simple style properties (set via ``lv_obj_set_style_...``).
# Structural properties are deliberately excluded:
# - the layout ``type``, which determines which options are available to child
# widgets, and
# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells
# that child widgets are placed into.
# Both are fixed at widget creation.
_GRID_LAYOUT_KEYS = (
CONF_GRID_COLUMN_ALIGN,
CONF_GRID_ROW_ALIGN,
)
_FLEX_LAYOUT_KEYS = (
CONF_FLEX_FLOW,
CONF_FLEX_ALIGN_MAIN,
CONF_FLEX_ALIGN_CROSS,
CONF_FLEX_ALIGN_TRACK,
)
LAYOUT_UPDATE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of,
cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments,
cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of,
cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments,
cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
}
)
def layout_validator(value):
"""
Validate a ``layout:`` value for an update action. Only the layout options
may be changed (not the layout ``type``, which is fixed at widget creation).
:param value: The value of the ``layout:`` key
:return: The validated layout options dict
"""
result = LAYOUT_UPDATE_SCHEMA(value)
if not result:
raise cv.Invalid(
"A layout update must specify at least one layout option", [CONF_LAYOUT]
)
# Register the relevant layout feature so its LV_USE_* define is emitted even
# when the option is set solely via an update action (whose code generation
# may run after LVGL has finished collecting its used features).
if any(key in result for key in _GRID_LAYOUT_KEYS):
add_lv_use(TYPE_GRID)
if any(key in result for key in _FLEX_LAYOUT_KEYS):
add_lv_use(TYPE_FLEX)
return result
def append_layout_schema(schema, config: dict):
"""
Get the child layout schema for a given widget based on its layout type.
+13
View File
@@ -331,6 +331,19 @@ lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable
lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True)
def rotation_degrees(value):
"""Validate a display rotation, returning the angle in whole degrees.
Accepts the four supported rotations, optionally suffixed with "°".
"""
value = cv.string(value).removesuffix("°")
return cv.one_of(0, 90, 180, 270, int=True)(value)
# Validator for a display rotation expressed in whole degrees (templatable)
lv_rotation = LValidator(rotation_degrees, cg.int_)
@schema_extractor("one_of")
def size_validator(value):
"""A size in one axis - one of "size_content", a number (pixels) or a percentage"""
+27 -1
View File
@@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) {
this->rotation_ = rotation;
if (this->is_ready()) {
this->set_resolution_();
this->update_orientation_();
lv_obj_update_layout(this->get_screen_active());
lv_obj_invalidate(this->get_screen_active());
}
}
void LvglComponent::set_rotation(int angle) {
// Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees.
angle %= 360;
if (angle < 0)
angle += 360;
if (angle % 90 != 0) {
ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle);
return;
}
this->set_rotation(static_cast<display::DisplayRotation>(angle));
}
void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const {
switch (this->rotation_) {
default:
@@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const {
}
lv_display_set_resolution(this->disp_, width, height);
}
void LvglComponent::update_orientation_() {
// A square display is treated as landscape.
auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT;
if (orientation == this->orientation_)
return;
this->orientation_ = orientation;
auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_;
if (trigger != nullptr)
trigger->trigger();
}
void LvglComponent::setup() {
auto *display = this->displays_[0];
auto rounding = this->draw_rounding;
@@ -757,7 +782,7 @@ void LvglComponent::setup() {
lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this);
lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes,
this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL);
if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) {
if (this->rotation_type_ == ROTATION_SOFTWARE) {
this->rotate_buf_ = static_cast<lv_color_t *>(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT
if (this->rotate_buf_ == nullptr) {
this->status_set_error(LOG_STR("Memory allocation failure"));
@@ -796,6 +821,7 @@ void LvglComponent::setup() {
#endif
this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0);
lv_display_trigger_activity(this->disp_);
this->update_orientation_();
}
void LvglComponent::update() {
+16
View File
@@ -185,6 +185,12 @@ enum RotationType : uint8_t {
ROTATION_HARDWARE,
};
enum class Orientation : uint8_t {
UNKNOWN,
LANDSCAPE,
PORTRAIT,
};
class LvglComponent final : public PollingComponent {
constexpr static const char *const TAG = "lvgl";
@@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent {
void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; }
void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; }
void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; }
void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; }
void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; }
void set_rotation(display::DisplayRotation rotation);
/// Set the rotation from an angle in degrees. Must be a multiple of 90.
void set_rotation(int angle);
display::DisplayRotation get_rotation() const { return this->rotation_; }
void rotate_coordinates(int32_t &x, int32_t &y) const;
@@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent {
protected:
void set_resolution_() const;
// Determine the current orientation from the effective resolution and fire the
// landscape/portrait trigger if it has changed since the last check.
void update_orientation_();
void draw_end_();
// Not checking for non-null callback since the
// LVGL callback that calls it is not set in that case
@@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent {
Trigger<> *resume_callback_{};
Trigger<> *draw_start_callback_{};
Trigger<> *draw_end_callback_{};
Trigger<> *landscape_callback_{};
Trigger<> *portrait_callback_{};
Orientation orientation_{Orientation::UNKNOWN};
void *rotate_buf_{};
display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES};
RotationType rotation_type_;
+2
View File
@@ -55,6 +55,7 @@ from .layout import (
GRID_CELL_SCHEMA,
append_layout_schema,
grid_alignments,
layout_validator,
)
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
@@ -523,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
)
),
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
cv.Optional(df.CONF_LAYOUT): layout_validator,
}
)
+65 -34
View File
@@ -36,11 +36,10 @@ from ..defines import (
CONF_SCALE,
CONF_STYLES,
CONF_WIDGETS,
LOGGER,
OBJ_FLAGS,
PARTS,
STATES,
TYPE_FLEX,
TYPE_GRID,
LValidator,
add_lv_use,
call_lambda,
@@ -541,44 +540,76 @@ def _size_to_str(value):
return str(value)
def _grid_descriptor_array(name: str, specs) -> MockObj:
"""Generate a file-scope ``static const`` grid row/column descriptor array
and return a reference to it."""
values = ",".join(_size_to_str(x) for x in specs)
initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}"
arr_id = ID(name, is_declaration=True, type=lv_coord_t)
return cg.static_const_array(arr_id, cg.RawExpression(initializer))
def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None:
"""Apply the layout options present in ``layout`` to ``w``.
Only options actually present are applied, so this works both for widget
creation (where every option is supplied) and for update actions (where the
layout ``type`` and grid structure are fixed and only the style options are
changed). ``base_name`` names the generated grid descriptor arrays and is
only required at creation, when ``grid_rows``/``grid_columns`` are present.
"""
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
w.set_style(CONF_PAD_ROW, pad_row)
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
w.set_style(CONF_PAD_COLUMN, pad_column)
if (rows := layout.get(CONF_GRID_ROWS)) is not None:
w.set_style(
"grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows)
)
if (columns := layout.get(CONF_GRID_COLUMNS)) is not None:
w.set_style(
"grid_column_dsc_array",
_grid_descriptor_array(f"{base_name}_column_dsc", columns),
)
if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None:
w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align))
if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None:
w.set_style(CONF_GRID_ROW_ALIGN, literal(align))
if (flow := layout.get(CONF_FLEX_FLOW)) is not None:
lv_obj.set_flex_flow(w.obj, literal(flow))
if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None:
w.set_style("flex_main_place", literal(main))
if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None:
# Stretch is implemented at creation time by sizing the children; at
# runtime we can only fall back to centering.
if cross == "LV_FLEX_ALIGN_STRETCH":
LOGGER.warning(
"Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead"
)
cross = "LV_FLEX_ALIGN_CENTER"
w.set_style("flex_cross_place", literal(cross))
if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None:
w.set_style("flex_track_place", literal(track))
async def set_obj_properties(w: Widget, config):
"""Generate a list of C++ statements to apply properties to an lv_obj_t"""
from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property
if layout := config.get(CONF_LAYOUT):
layout_type: str = layout[CONF_TYPE]
add_lv_use(layout_type)
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
w.set_style(CONF_PAD_ROW, pad_row)
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
w.set_style(CONF_PAD_COLUMN, pad_column)
if layout_type == TYPE_GRID:
wid = config[CONF_ID]
rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]]
rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}"
row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t)
row_array = cg.static_const_array(row_id, cg.RawExpression(rows))
w.set_style("grid_row_dsc_array", row_array)
columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]]
columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}"
column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t)
column_array = cg.static_const_array(column_id, cg.RawExpression(columns))
w.set_style("grid_column_dsc_array", column_array)
w.set_style(
CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN))
)
w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN)))
if layout_type == TYPE_FLEX:
lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW]))
main = literal(layout[CONF_FLEX_ALIGN_MAIN])
cross = layout[CONF_FLEX_ALIGN_CROSS]
if cross == "LV_FLEX_ALIGN_STRETCH":
cross = "LV_FLEX_ALIGN_CENTER"
cross = literal(cross)
track = literal(layout[CONF_FLEX_ALIGN_TRACK])
lv_obj.set_flex_align(w.obj, main, cross, track)
# The layout `type` (and the grid row/column structure) is only present
# when a widget is created; update actions only change the layout style
# options, leaving the type and grid structure unchanged.
layout_type = layout.get(CONF_TYPE)
if layout_type is not None:
add_lv_use(layout_type)
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
# The widget's own id gives the grid descriptor arrays stable names.
base_name = str(config[CONF_ID])
else:
base_name = None
_set_layout_options(w, layout, base_name)
parts = collect_parts(config)
for part, states in parts.items():
part = "LV_PART_" + part.upper()
@@ -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
)
+34
View File
@@ -46,6 +46,40 @@ lvgl:
- lvgl.display.set_rotation:
rotation: 0
lvgl_id: lvgl_id
- lvgl.display.set_rotation:
rotation: !lambda "return 180;"
lvgl_id: lvgl_id
on_landscape:
- logger.log: LVGL display is now landscape
# Re-layout a container in response to orientation changes. The layout type
# and grid structure are fixed at creation; only the style options change.
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: center
grid_row_align: space_between
pad_row: 4px
- lvgl.update:
top_layer:
layout:
flex_flow: row
on_portrait:
- logger.log: LVGL display is now portrait
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: start
pad_row: 2px
# Top-level layout applies to the active screen
- lvgl.update:
layout:
flex_flow: column
pad_row: 8px
- lvgl.update:
top_layer:
layout:
flex_flow: column
flex_align_main: center
on_boot:
- logger.log: LVGL has started