mirror of
https://github.com/esphome/esphome.git
synced 2026-10-17 11:42:51 +00:00
[lvgl] Implement menu widget (#18022)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user