diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index b758390f0d..256bf4bb3a 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -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, ) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index bf9a3d74ad..b7c90a5c51 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -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 diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 480ba515d1..4f734fe20c 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -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" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 32304276d3..fd1f242d86 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -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. diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index b588e865d2..42352b9602 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -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""" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 1db5992389..b66a904437 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -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(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_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() { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index dcbf490bce..9221ab9542 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -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_; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 13214d459d..dd4f71a346 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -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, } ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 4d62c3de05..968db46adc 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -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() diff --git a/tests/component_tests/lvgl/config/layout_update_test.yaml b/tests/component_tests/lvgl/config/layout_update_test.yaml new file mode 100644 index 0000000000..84765a60cf --- /dev/null +++ b/tests/component_tests/lvgl/config/layout_update_test.yaml @@ -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 diff --git a/tests/component_tests/lvgl/test_layout_update.py b/tests/component_tests/lvgl/test_layout_update.py new file mode 100644 index 0000000000..b9730df379 --- /dev/null +++ b/tests/component_tests/lvgl/test_layout_update.py @@ -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..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 + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6cd3821f9..f085b62cb6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -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