From bb0837d814a04769b7b7ff7c9634c970a4d3e299 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:44:23 +1100 Subject: [PATCH] [lvgl] Implement menu widget (#18022) --- esphome/components/lvgl/__init__.py | 3 + esphome/components/lvgl/defines.py | 18 + esphome/components/lvgl/lvgl_esphome.cpp | 7 + esphome/components/lvgl/lvgl_esphome.h | 6 + esphome/components/lvgl/widgets/menu.py | 326 ++++++++++++++ .../lvgl/config/menu_test.yaml | 81 ++++ tests/component_tests/lvgl/test_menu.py | 407 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 50 +++ tests/components/lvgl/validate.host.yaml | 42 +- 9 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 esphome/components/lvgl/widgets/menu.py create mode 100644 tests/component_tests/lvgl/config/menu_test.yaml create mode 100644 tests/component_tests/lvgl/test_menu.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6806eab0cf3..810d15f7005 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -112,6 +112,7 @@ from .widgets import ( set_obj_properties, ) from .widgets.keyboard import attach_textareas +from .widgets.menu import add_root_back_button_triggers, validate_menu_pages # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -333,6 +334,7 @@ def final_validation(config_list): path = global_config.get_path_for_id(name) widget_conf = global_config.get_config_for_path(path[:-1]) widget_type.final_validate(name, conf, widget_conf, path[1:]) + validate_menu_pages(global_config) def lv_conf_path_flag(lv_conf_h_file: Path) -> str: @@ -513,6 +515,7 @@ async def to_code(configs): await finish_list_triggers() await generate_triggers() + await add_root_back_button_triggers() await generate_align_tos(configs[0]) await attach_textareas() for config in configs: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 69e15bdeca6..203a75b0c8e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -25,6 +25,7 @@ KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" +KEY_MENU_SET_PAGE_ACTIONS = "menu_set_page_actions" KEY_NAMED_STYLES = "named_styles" KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" @@ -172,6 +173,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_menu_set_page_actions() -> list: + return _get_data(KEY_MENU_SET_PAGE_ACTIONS, []) + + def get_list_triggers() -> dict: return _get_data(KEY_LIST_TRIGGERS, {}) @@ -682,6 +687,12 @@ LV_MENU_MODES = LvConstant( "BOTTOM_FIXED", ) +LV_MENU_ROOT_BACK_BUTTON_MODES = LvConstant( + "LV_MENU_ROOT_BACK_BUTTON_", + "DISABLED", + "ENABLED", +) + LV_CHART_TYPES = ( "NONE", "LINE", @@ -762,6 +773,7 @@ CONF_GRID_ROW_ALIGN = "grid_row_align" CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" +CONF_HEADER_STYLE = "header_style" CONF_HOME = "home" CONF_IMAGE = "image" CONF_INDICATORS = "indicators" @@ -778,6 +790,7 @@ CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" CONF_MAPPING = "mapping" +CONF_MENU = "menu" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" @@ -788,6 +801,7 @@ CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" +CONF_ON_ROOT_BACK_BUTTON_CLICK = "on_root_back_button_click" CONF_ON_SELECT = "on_select" CONF_OPA = "opa" CONF_NEXT = "next" @@ -810,6 +824,7 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROOT_PAGE = "root_page" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -817,9 +832,12 @@ CONF_SCROLL_DIR = "scroll_dir" CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" +CONF_SECTIONS = "sections" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" +CONF_SIDEBAR_PAGE = "sidebar_page" +CONF_SIDEBAR_HEADER_STYLE = "sidebar_header_style" CONF_SPIN_TIME = "spin_time" CONF_SRC = "src" CONF_START_ANGLE = "start_angle" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0b375404d74..9bc83800051 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1009,6 +1009,13 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { } #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e) { + return lv_event_get_code(e) == LV_EVENT_CLICKED && + lv_menu_back_button_is_root(menu, static_cast(lv_event_get_target(e))); +} +#endif // USE_LVGL_MENU + lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { auto *indev = lv_indev_get_act(); if (indev == nullptr) { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 75f926d865e..35e5335c10e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -143,6 +143,12 @@ lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index); lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +// True if `e` is a click on `menu`'s back button while already at the root page -- the one +// case LVGL's own docs say the app must handle itself (there is no default behavior). +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e); +#endif // USE_LVGL_MENU + // Parent class for things that wrap an LVGL object class LvCompound { public: diff --git a/esphome/components/lvgl/widgets/menu.py b/esphome/components/lvgl/widgets/menu.py new file mode 100644 index 00000000000..e32a7e9ba35 --- /dev/null +++ b/esphome/components/lvgl/widgets/menu.py @@ -0,0 +1,326 @@ +from esphome import automation +from esphome.automation import Trigger +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_BUTTON, CONF_ID, CONF_PAGES, CONF_TRIGGER_ID +from esphome.final_validate import FinalValidateConfig +from esphome.types import ConfigType + +from ..automation import action_to_code +from ..defines import ( + CONF_HEADER_MODE, + CONF_HEADER_STYLE, + CONF_IMAGE, + CONF_MAIN, + CONF_MENU, + CONF_ON_ROOT_BACK_BUTTON_CLICK, + CONF_PAGE, + CONF_ROOT_BACK_BTN, + CONF_ROOT_PAGE, + CONF_SECTIONS, + CONF_SIDEBAR_HEADER_STYLE, + CONF_SIDEBAR_PAGE, + CONF_TITLE, + LV_MENU_MODES, + LV_MENU_ROOT_BACK_BUTTON_MODES, + TYPE_FLEX, + get_menu_set_page_actions, + get_widget_map, + literal, +) +from ..lv_validation import lv_text +from ..lvcode import ( + EVENT_ARG, + LambdaContext, + LocalVariable, + LvConditional, + lv, + lv_add, + lv_expr, + lv_obj, +) +from ..schemas import container_schema, part_schema +from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr, lvgl_ns +from . import ( + Widget, + WidgetType, + get_widget_, + get_widgets, + set_obj_properties, + widget_to_code, +) +from .obj import obj_spec + +# Local to the "menu" widget only -- not a shared config concept. +CONF_SIDEBAR = "sidebar" + +# Tag types, only used to distinguish declare_id()/use_id() checks for pages and +# sections -- like tileview's lv_tileview_tile_t they carry no extra behaviour, but +# they must have their own names: id type checks compare the type name, so reusing +# `lv_obj_t` here would let any object be passed where a page is required. The +# generated code always declares the variable as `lv_obj_t *`. +lv_menu_page_t = LvType("lv_menu_page_t") +lv_menu_section_t = LvType("lv_menu_section_t") + +lv_menu_t = LvType( + "lv_menu_t", + largs=[(lv_obj_t_ptr, "page")], + lvalue=lambda w: lv_expr.menu_get_cur_main_page(w.obj), + has_on_value=True, +) + + +class MenuSectionType(WidgetType): + """ + A grouped, bordered set of rows inside a menu page (lv_menu_section_create). + Only valid as a direct child of a menu page, so it's not exposed in the + generic `widgets:` menu -- reached only via a page's `sections:` key. + """ + + def __init__(self): + super().__init__( + "menu_section", lv_menu_section_t, (CONF_MAIN,), {}, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + return lv_expr.menu_section_create(parent) + + +menu_section_spec = MenuSectionType() + +MENU_PAGE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TITLE): lv_text, + cv.Optional(CONF_SECTIONS): cv.ensure_list( + container_schema( + menu_section_spec, + {cv.GenerateID(): cv.declare_id(lv_menu_section_t)}, + ) + ), + } +) + + +class MenuPageType(WidgetType): + """ + A page of the menu (lv_menu_page_create), shown one at a time via + `lvgl.menu.set_page`. Like tabview's tabs/tileview's tiles, pages are only + ever declared via the menu's own `pages:` key, not the generic `widgets:` + menu -- reachable content, not a freestanding widget type. + """ + + def __init__(self): + super().__init__( + "menu_page", lv_menu_page_t, (CONF_MAIN,), MENU_PAGE_SCHEMA, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + title = config.get(CONF_TITLE) + return lv_expr.menu_page_create( + parent, await lv_text.process(title) if title is not None else cg.nullptr + ) + + async def on_create(self, var: cg.MockObj, config: dict): + # Runs before the page's ungrouped `widgets:` are added, so sections come first. + for section_conf in config.get(CONF_SECTIONS, ()): + await widget_to_code(section_conf, menu_section_spec, var) + return var + + +menu_page_spec = MenuPageType() + +MENU_SCHEMA = cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list( + container_schema( + menu_page_spec, {cv.GenerateID(): cv.declare_id(lv_menu_page_t)} + ) + ), + cv.Length(min=1), + ), + cv.Required(CONF_ROOT_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_HEADER_MODE, default="top_fixed"): LV_MENU_MODES.one_of, + cv.Optional( + CONF_ROOT_BACK_BTN, default="disabled" + ): LV_MENU_ROOT_BACK_BUTTON_MODES.one_of, + cv.Optional(CONF_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_SIDEBAR_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_ON_ROOT_BACK_BUTTON_CLICK): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template())} + ), + } +) + + +class MenuType(WidgetType): + def __init__(self): + super().__init__( + CONF_MENU, lv_menu_t, (CONF_MAIN,), schema=MENU_SCHEMA, modify_schema={} + ) + + def get_uses(self): + return CONF_BUTTON, CONF_IMAGE, TYPE_FLEX + + def validate(self, value: ConfigType) -> ConfigType: + # The sidebar header only exists once a sidebar page has been set. + if CONF_SIDEBAR_HEADER_STYLE in value and CONF_SIDEBAR_PAGE not in value: + raise cv.Invalid( + f"'{CONF_SIDEBAR_HEADER_STYLE}' requires '{CONF_SIDEBAR_PAGE}'", + path=[CONF_SIDEBAR_HEADER_STYLE], + ) + # Runs before the schema, so the default has not been applied yet and an + # invalid value is left for the schema to report. + back_btn = ( + str(value.get(CONF_ROOT_BACK_BTN, "disabled")) + .upper() + .removeprefix(LV_MENU_ROOT_BACK_BUTTON_MODES.prefix) + ) + if CONF_ON_ROOT_BACK_BUTTON_CLICK in value and back_btn != "ENABLED": + raise cv.Invalid( + f"'{CONF_ON_ROOT_BACK_BUTTON_CLICK}' requires " + f"'{CONF_ROOT_BACK_BTN}: enabled'", + path=[CONF_ON_ROOT_BACK_BUTTON_CLICK], + ) + return value + + async def to_code(self, w: Widget, config: dict): + await w.set_property( + "mode_header", await LV_MENU_MODES.process(config[CONF_HEADER_MODE]) + ) + await w.set_property( + "mode_root_back_button", + await LV_MENU_ROOT_BACK_BUTTON_MODES.process(config[CONF_ROOT_BACK_BTN]), + ) + for page_conf in config[CONF_PAGES]: + await widget_to_code(page_conf, menu_page_spec, w.obj) + + root_page = await get_widget_(config[CONF_ROOT_PAGE]) + lv.menu_set_page(w.obj, root_page.obj) + if sidebar_id := config.get(CONF_SIDEBAR_PAGE): + sidebar_page = await get_widget_(sidebar_id) + lv.menu_set_sidebar_page(w.obj, sidebar_page.obj) + + if header_style := config.get(CONF_HEADER_STYLE): + with LocalVariable( + "menu_header", lv_obj_t, rhs=lv_expr.menu_get_main_header(w.obj) + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), header_style) + if sidebar_header_style := config.get(CONF_SIDEBAR_HEADER_STYLE): + with LocalVariable( + "menu_sidebar_header", + lv_obj_t, + rhs=lv_expr.menu_get_sidebar_header(w.obj), + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), sidebar_header_style) + + +menu_spec = MenuType() + + +def _menu_page_owners(global_config: FinalValidateConfig) -> dict[str, tuple]: + """ + Map every declared menu page id to the config path of the menu that owns it. + + A page id is always declared inside its menu's `pages:` list, so the owning menu + is three steps up the path (`id` -> list index -> `pages`). + """ + return { + str(declared_id): tuple(path[:-3]) + for declared_id, path in global_config.declare_ids + if declared_id.type is lv_menu_page_t + } + + +def validate_menu_pages(global_config: FinalValidateConfig) -> None: + """ + Check that a menu only ever refers to pages of its own. + + `lv_menu_page_create()` makes a page a child of one specific menu, so handing a + page of another menu to `root_page:`, `sidebar_page:` or `lvgl.menu.set_page` + can only misbehave at run time. It also hangs code generation when the other + menu is defined later, since the waiting menu's page never appears. + """ + owners = _menu_page_owners(global_config) + for menu_path in set(owners.values()): + menu_config = global_config.get_config_for_path(list(menu_path)) + for key in (CONF_ROOT_PAGE, CONF_SIDEBAR_PAGE): + page_id = menu_config.get(key) + if page_id is not None and owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'{page_id}' is not a page of this menu; " + f"'{key}' must name one of the menu's own 'pages:'", + # Paths reported from final validation are relative to the + # component config, so drop the leading domain name. + path=[*menu_path[1:], key], + ) + for config in get_menu_set_page_actions(): + menu_id = config[CONF_ID] + page_id = config[CONF_PAGE] + menu_path = tuple(global_config.get_path_for_id(menu_id)[:-1]) + if owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'lvgl.menu.set_page' page '{page_id}' is not a page of menu '{menu_id}'" + ) + + +async def add_root_back_button_triggers() -> None: + """ + Wire up on_root_back_button_click for every menu, once all widgets exist. + Actions like `lvgl.widget.hide` on the menu itself need the widget map fully + populated, so this must run as a deferred pass rather than inline in + MenuType.to_code -- doing it inline deadlocks the code-generation coroutine + scheduler. + """ + # Snapshot: build_automation() can create widgets (e.g. `lvgl.list.add`). + for w in list(get_widget_map().values()): + if not isinstance(w.type, MenuType): + continue + for conf in w.config.get(CONF_ON_ROOT_BACK_BUTTON_CLICK, ()): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [], conf) + async with LambdaContext(EVENT_ARG, where=conf[CONF_TRIGGER_ID]) as context: + with LvConditional( + lvgl_ns.lv_menu_back_button_is_root_click(w.obj, literal("event")) + ): + lv_add(trigger.trigger()) + lv_obj.add_event_cb( + w.obj, await context.get_lambda(), LV_EVENT.CLICKED, cg.nullptr + ) + + +def _record_set_page_action(config: ConfigType) -> ConfigType: + """Remember the action so validate_menu_pages() can check the page's owner once + the whole config is known.""" + get_menu_set_page_actions().append(config) + return config + + +@automation.register_action( + "lvgl.menu.set_page", + ObjUpdateAction, + cv.All( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_menu_t), + cv.Required(CONF_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR, default=False): cv.boolean, + } + ), + _record_set_page_action, + ), + synchronous=True, +) +async def menu_set_page(config, action_id, template_arg, args): + widget = await get_widgets(config) + page = await get_widget_(config[CONF_PAGE]) + sidebar = config[CONF_SIDEBAR] + + async def do_set_page(w: Widget): + if sidebar: + lv.menu_set_sidebar_page(w.obj, page.obj) + else: + lv.menu_set_page(w.obj, page.obj) + + return await action_to_code(widget, do_set_page, action_id, template_arg, args) diff --git a/tests/component_tests/lvgl/config/menu_test.yaml b/tests/component_tests/lvgl/config/menu_test.yaml new file mode 100644 index 00000000000..df0faeb1bc3 --- /dev/null +++ b/tests/component_tests/lvgl/config/menu_test.yaml @@ -0,0 +1,81 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + +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 + widgets: + - menu: + id: test_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: main_page + sidebar_page: nav_page + header_style: + bg_color: 0xFF0000 + sidebar_header_style: + bg_color: 0x00FF00 + on_root_back_button_click: + - logger.log: "root back clicked" + on_value: + - logger.log: "page changed" + pages: + - id: main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: advanced_page + - id: advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug" + - id: nav_page + title: "Navigate" + widgets: + - button: + id: nav_button + text: "Go" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: main_page + sidebar: true diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py new file mode 100644 index 00000000000..287dd927cea --- /dev/null +++ b/tests/component_tests/lvgl/test_menu.py @@ -0,0 +1,407 @@ +"""Tests for the LVGL ``menu`` widget: schema validation for the ``menu``/ +``menu_page``/``menu_section`` widget types and the ``lvgl.menu.set_page`` +action, and the code they generate. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.menu import MENU_PAGE_SCHEMA, MENU_SCHEMA +from esphome.config import load_config, read_config +import esphome.config_validation as cv +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# The menu widget's own schema +# --------------------------------------------------------------------------- + + +class TestMenuSchema: + def test_pages_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['pages'\]" + ): + MENU_SCHEMA({"root_page": "main"}) + + def test_root_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['root_page'\]" + ): + MENU_SCHEMA({"pages": [{"id": "main"}]}) + + def test_sidebar_page_optional(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert "sidebar_page" not in config + + def test_header_mode_and_root_back_btn_defaults(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert config["header_mode"] == "LV_MENU_HEADER_TOP_FIXED" + assert config["root_back_btn"] == "LV_MENU_ROOT_BACK_BUTTON_DISABLED" + + def test_header_mode_accepts_bare_choice(self) -> None: + config = MENU_SCHEMA( + { + "pages": [{"id": "main"}], + "root_page": "main", + "header_mode": "bottom_fixed", + } + ) + assert config["header_mode"] == "LV_MENU_HEADER_BOTTOM_FIXED" + + +# --------------------------------------------------------------------------- +# A menu page's own schema (title optional, sections optional) +# --------------------------------------------------------------------------- + + +class TestMenuPageSchema: + def test_empty_page_is_valid(self) -> None: + config = MENU_PAGE_SCHEMA({}) + assert "title" not in config + assert "sections" not in config + + def test_title_optional(self) -> None: + config = MENU_PAGE_SCHEMA({"title": "Settings"}) + assert config["title"] == "Settings" + + def test_section_id_is_optional(self) -> None: + """cv.GenerateID() means a section's id: is optional, unlike a page's.""" + config = MENU_PAGE_SCHEMA({"sections": [{}]}) + assert config["sections"][0]["id"].is_declaration + + +# --------------------------------------------------------------------------- +# lvgl.menu.set_page action schema +# --------------------------------------------------------------------------- + + +class TestMenuSetPageActionSchema: + @property + def schema(self): + return ACTION_REGISTRY["lvgl.menu.set_page"].raw_schema + + def test_id_and_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['page'\]" + ): + self.schema({"id": "my_menu"}) + + def test_sidebar_defaults_false(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page"}) + assert config["sidebar"] is False + + def test_sidebar_can_be_set_true(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page", "sidebar": True}) + assert config["sidebar"] is True + + +# --------------------------------------------------------------------------- +# Pages belong to exactly one menu +# --------------------------------------------------------------------------- + +# Boilerplate around the widgets under test: an LVGL instance needs a display. +MENU_CONFIG_TEMPLATE = """ +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: + displays: tft_display + widgets: +{widgets} +""" + + +def _config_errors(tmp_path: Path, widgets: str) -> list[str]: + """Validate a config built from the given `widgets:` block and return the + validation errors as text.""" + config_path = tmp_path / "menu.yaml" + config_path.write_text(MENU_CONFIG_TEMPLATE.format(widgets=widgets)) + original_path = CORE.config_path + try: + CORE.config_path = config_path + return [str(err) for err in load_config({}).errors] + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_root_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_empty_pages_list_is_rejected(tmp_path: Path) -> None: + """Without this, a menu with no pages of its own escapes the page-owner check.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: [] + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("length of value must be at least 1" in err for err in errors) + + +def test_sidebar_header_style_without_sidebar_page_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_header_style: + bg_color: 0x101010 + pages: + - id: menu_a_page +""", + ) + assert any( + "'sidebar_header_style' requires 'sidebar_page'" in err for err in errors + ) + + +def test_root_back_button_click_needs_root_back_button_enabled(tmp_path: Path) -> None: + """With the default `root_back_btn: disabled` the button never appears, so the + trigger could never fire.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + on_root_back_button_click: + - logger.log: "never" + pages: + - id: menu_a_page +""", + ) + assert any( + "'on_root_back_button_click' requires 'root_back_btn: enabled'" in err + for err in errors + ) + + +def test_sidebar_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_set_page_action_with_a_page_of_another_menu_is_rejected( + tmp_path: Path, +) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + pages: + - id: menu_a_page + widgets: + - button: + text: "Go" + on_click: + - lvgl.menu.set_page: + id: menu_a + page: menu_b_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any( + "page 'menu_b_page' is not a page of menu 'menu_a'" in err for err in errors + ) + + +def test_section_id_is_not_accepted_where_a_page_is_required(tmp_path: Path) -> None: + """The page and section tag types are distinct, so a section id can't be + passed off as a page.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_section + pages: + - id: menu_a_page + sections: + - id: menu_a_section +""", + ) + assert any( + "ID 'menu_a_section' of type lv_menu_section_t doesn't inherit from " + "lv_menu_page_t" in err + for err in errors + ) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared menu-widget YAML config once per + module -- see test_widget_state.py for why this is module-scoped and + inlines the generate_main fixture logic rather than depending on it. + """ + config_path = Path(request.fspath).parent / "config" / "menu_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_menu_created_with_header_and_back_button_modes(main_cpp: str) -> None: + assert "test_menu = lv_menu_create(lvgl_id->get_screen_active());" in main_cpp + assert "lv_menu_set_mode_header(test_menu, LV_MENU_HEADER_TOP_FIXED);" in main_cpp + assert ( + "lv_menu_set_mode_root_back_button(test_menu, LV_MENU_ROOT_BACK_BUTTON_ENABLED);" + in main_cpp + ) + + +def test_page_created_with_title(main_cpp: str) -> None: + assert 'main_page = lv_menu_page_create(test_menu, "Settings");' in main_cpp + assert 'advanced_page = lv_menu_page_create(test_menu, "Advanced");' in main_cpp + + +def test_section_created_under_its_page_not_the_menu(main_cpp: str) -> None: + assert "wifi_section = lv_menu_section_create(main_page);" in main_cpp + assert "wifi_switch = lv_switch_create(wifi_section);" in main_cpp + + +def test_ungrouped_widget_created_directly_under_page(main_cpp: str) -> None: + assert "advanced_button = lv_button_create(main_page);" in main_cpp + + +def test_sections_created_before_ungrouped_widgets(main_cpp: str) -> None: + assert main_cpp.index("wifi_section = lv_menu_section_create(main_page);") < ( + main_cpp.index("advanced_button = lv_button_create(main_page);") + ) + + +def test_root_page_and_sidebar_page_set_at_boot(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, main_page);" in main_cpp + assert "lv_menu_set_sidebar_page(test_menu, nav_page);" in main_cpp + + +def test_header_style_applied_via_main_header_accessor(main_cpp: str) -> None: + assert ( + "lv_obj_t *menu_header_VAR_ = lv_menu_get_main_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_header_VAR_, lv_color_make(255, 0, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_sidebar_header_style_applied_via_sidebar_header_accessor( + main_cpp: str, +) -> None: + assert ( + "lv_obj_t *menu_sidebar_header_VAR_ = lv_menu_get_sidebar_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_sidebar_header_VAR_, lv_color_make(0, 255, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_root_back_button_click_guarded_by_helper(main_cpp: str) -> None: + """The on_root_back_button_click trigger must only fire when the guard + helper (which wraps lv_menu_back_button_is_root) returns true. + """ + assert ( + "lv_obj_add_event_cb(test_menu, [](lv_event_t * event) -> void {" + ) in main_cpp + assert ( + "if (lvgl::lv_menu_back_button_is_root_click(test_menu, event)) {\n" + " trigger_id->trigger();\n" + " }" + ) in main_cpp + assert "}, LV_EVENT_CLICKED, nullptr);" in main_cpp + + +def test_set_page_action_targets_main_by_default(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, advanced_page);" in main_cpp + + +def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: + assert "lv_menu_set_sidebar_page(test_menu, main_page);" in main_cpp + + +def test_on_value_passes_current_main_page(main_cpp: str) -> None: + assert ( + "->trigger(lv_menu_get_cur_main_page(test_menu), event);\n" + " \n" + "}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr);" + ) in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 952dd5f50f1..3ac6e6a81d6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,56 @@ lvgl: - label: text: "2,2" + - menu: + id: settings_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: settings_main_page + sidebar_page: settings_nav_page + header_style: + bg_color: 0x202020 + sidebar_header_style: + bg_color: 0x101010 + on_root_back_button_click: + - logger.log: "settings menu closed at root" + on_value: + - lambda: |- + ESP_LOGD("lvgl", "Menu page %p", page); + pages: + - id: settings_main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: open_advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_advanced_page + - id: settings_advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug options" + - id: settings_nav_page + title: "Navigate" + widgets: + - button: + id: nav_to_main_button + text: "Go to Settings" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_main_page + sidebar: true + # Sized from its text font, with a colour given as an id - color_picker: id: lv_color_picker_1 diff --git a/tests/components/lvgl/validate.host.yaml b/tests/components/lvgl/validate.host.yaml index 77db404a6ec..afdb89b9499 100644 --- a/tests/components/lvgl/validate.host.yaml +++ b/tests/components/lvgl/validate.host.yaml @@ -1,5 +1,5 @@ esphome: - name: lvgl-list-validate + name: lvgl-validate host: @@ -15,6 +15,46 @@ display: lvgl: displays: sdl0 widgets: + # A menu with no sidebar_page (optional), no page titles (optional) and a + # single empty page (no sections, no widgets) as its root. + - menu: + id: menu_a + root_page: menu_a_empty_page + pages: + - id: menu_a_empty_page + # A second, independent menu -- exercises sidebar_page, multiple + # sections, and a page with both sections and ungrouped widgets. + - menu: + id: menu_b + header_mode: bottom_fixed + root_back_btn: enabled + root_page: menu_b_page + sidebar_page: menu_b_nav_page + pages: + - id: menu_b_page + title: "Settings" + sections: + - id: menu_b_section_1 + widgets: + - label: {text: "Wifi"} + - id: menu_b_section_2 + widgets: + - label: {text: "Bluetooth"} + - switch: {} + widgets: + - button: + text: "Advanced" + on_click: + - lvgl.menu.set_page: {id: menu_b, page: menu_b_page} + - id: menu_b_nav_page + widgets: + - button: + text: "Nav" + on_click: + - lvgl.menu.set_page: + id: menu_b + page: menu_b_page + sidebar: true # Two independent lists, each with their own on_add/on_remove and, for list_a, # more than one automation under the same trigger key -- checks that the # per-list trigger bookkeeping is keyed correctly and doesn't require exactly