[lvgl] Implement menu widget (#18022)

This commit is contained in:
Clyde Stubbs
2026-10-15 17:44:23 +11:00
committed by GitHub
parent ecd5980c5d
commit bb0837d814
9 changed files with 939 additions and 1 deletions
+3
View File
@@ -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:
+18
View File
@@ -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"
+7
View File
@@ -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_obj_t *>(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) {
+6
View File
@@ -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:
+326
View File
@@ -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)
@@ -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
+407
View File
@@ -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
+50
View File
@@ -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
+41 -1
View File
@@ -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