diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b7c90a5c51..cad065adee 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -5,7 +5,14 @@ from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT +from esphome.const import ( + CONF_ACTION, + CONF_GROUP, + CONF_ID, + CONF_POSITION, + CONF_ROTATION, + CONF_TIMEOUT, +) from esphome.core import Lambda from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr @@ -28,6 +35,7 @@ from .defines import ( get_focused_widgets, get_options, get_refreshed_widgets, + literal, ) from .layout import layout_validator from .lv_validation import lv_bool, lv_milliseconds, lv_rotation @@ -36,6 +44,7 @@ from .lvcode import ( UPDATE_EVENT, LambdaContext, LocalVariable, + LvConditional, LvglComponent, ReturnStatement, add_line_marks, @@ -376,6 +385,48 @@ async def obj_show_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_show, action_id, template_arg, args) +SET_Z_INDEX_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.ensure_list( + cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(lv_obj_t)}, + key=CONF_ID, + ) + ), + cv.Required(CONF_POSITION): cv.Any( + cv.one_of("TOP", "BOTTOM", "UP", "DOWN", upper=True), cv.int_ + ), + } +) + + +@automation.register_action( + "lvgl.widget.set_z_index", ObjUpdateAction, SET_Z_INDEX_SCHEMA, synchronous=True +) +async def obj_set_z_index_to_code(config, action_id, template_arg, args): + position = config[CONF_POSITION] + + async def do_set_z_index(widget: Widget): + if position == "TOP": + lv_obj.move_foreground(widget.obj) + elif position == "BOTTOM": + lv_obj.move_background(widget.obj) + elif position == "UP": + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1") + ) + elif position == "DOWN": + with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"): + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1") + ) + else: + lv_obj.move_to_index(widget.obj, position) + + widgets = [widget.outer or widget for widget in await get_widgets(config[CONF_ID])] + return await action_to_code(widgets, do_set_z_index, action_id, template_arg, args) + + def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) get_focused_widgets().add(value) diff --git a/tests/component_tests/lvgl/config/set_z_index_test.yaml b/tests/component_tests/lvgl/config/set_z_index_test.yaml new file mode 100644 index 0000000000..61a248ff99 --- /dev/null +++ b/tests/component_tests/lvgl/config/set_z_index_test.yaml @@ -0,0 +1,60 @@ +esphome: + name: test-set-z-index + +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: + displays: tft_display + widgets: + - label: + id: label_a + text: "A" + - label: + id: label_b + text: "B" + - button: + id: trigger_btn + on_click: + - lvgl.widget.set_z_index: + id: label_a + position: top + - lvgl.widget.set_z_index: + id: label_a + position: bottom + - lvgl.widget.set_z_index: + id: label_a + position: up + - lvgl.widget.set_z_index: + id: label_a + position: down + - lvgl.widget.set_z_index: + id: label_a + position: 3 + - lvgl.widget.set_z_index: + id: label_a + position: -2 + - lvgl.widget.set_z_index: + id: [label_a, label_b] + position: up diff --git a/tests/component_tests/lvgl/test_set_z_index.py b/tests/component_tests/lvgl/test_set_z_index.py new file mode 100644 index 0000000000..cf2a072ca0 --- /dev/null +++ b/tests/component_tests/lvgl/test_set_z_index.py @@ -0,0 +1,128 @@ +"""Tests for the ``lvgl.widget.set_z_index`` action: schema validation and +code generation. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.components.lvgl.automation import SET_Z_INDEX_SCHEMA +from esphome.config_validation import Invalid + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestSetZIndexSchemaValidation: + """Test that SET_Z_INDEX_SCHEMA accepts the documented forms and rejects + everything else. + """ + + @pytest.mark.parametrize("position", ["top", "bottom", "up", "down"]) + def test_keyword_position_accepted(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", ["Top", "BOTTOM", "Up", "dOwN"]) + def test_keyword_position_case_insensitive(self, position: str) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position.upper() + + @pytest.mark.parametrize("position", [0, 1, 5, -1, -5]) + def test_integer_position_accepted(self, position: int) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position}) + assert config["position"] == position + + def test_unknown_keyword_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "sideways"}) + + def test_float_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": 1.5}) + + def test_missing_id_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"position": "top"}) + + def test_missing_position_rejected(self) -> None: + with pytest.raises(Invalid): + SET_Z_INDEX_SCHEMA({"id": "my_widget"}) + + def test_single_id_is_wrapped_in_list(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "top"}) + assert len(config["id"]) == 1 + assert config["id"][0]["id"].id == "my_widget" + + def test_list_of_ids_accepted(self) -> None: + config = SET_Z_INDEX_SCHEMA({"id": ["widget_a", "widget_b"], "position": "top"}) + assert [entry["id"].id for entry in config["id"]] == ["widget_a", "widget_b"] + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared set_z_index YAML config once + per module. See ``test_widget_state.py`` for why this is module-scoped + and self-contained rather than using the function-scoped ``generate_main`` + fixture from ``conftest.py``. + """ + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + from esphome.core import CORE + + config_path = Path(request.fspath).parent / "config" / "set_z_index_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_top_emits_move_foreground(main_cpp: str) -> None: + assert "lv_obj_move_foreground(label_a);" in main_cpp + + +def test_bottom_emits_move_background(main_cpp: str) -> None: + assert "lv_obj_move_background(label_a);" in main_cpp + + +def test_up_emits_unguarded_index_increment(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);" in main_cpp + + +def test_down_emits_guarded_index_decrement(main_cpp: str) -> None: + """``down`` must be guarded so that a widget already at index 0 isn't + reinterpreted by LVGL as "move to the top" (LVGL treats a negative + index as "count from the back"). + """ + assert "if (lv_obj_get_index(label_a) > 0) {" in main_cpp + assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) - 1);" in main_cpp + + +def test_positive_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, 3);" in main_cpp + + +def test_negative_integer_emits_direct_index(main_cpp: str) -> None: + assert "lv_obj_move_to_index(label_a, -2);" in main_cpp + + +def test_list_of_ids_applies_to_each_widget(main_cpp: str) -> None: + """``id: [label_a, label_b]`` must emit the move call once per widget.""" + assert ( + main_cpp.count("lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);") + == 2 + ) + assert "lv_obj_move_to_index(label_b, lv_obj_get_index(label_b) + 1);" in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index ef8ba13e42..903480dde1 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -756,7 +756,25 @@ lvgl: on_defocus: lvgl.widget.hide: hello_label on_focus: - logger.log: Button clicked + - logger.log: Button clicked + - lvgl.widget.set_z_index: + id: hello_label + position: top + - lvgl.widget.set_z_index: + id: hello_label + position: bottom + - lvgl.widget.set_z_index: + id: hello_label + position: up + - lvgl.widget.set_z_index: + id: hello_label + position: down + - lvgl.widget.set_z_index: + id: hello_label + position: 1 + - lvgl.widget.set_z_index: + id: hello_label + position: -1 on_scroll: logger.log: Button clicked on_scroll_end: