1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-18 15:55:46 +00:00

[lvgl] Layout improvements (#10149)

Co-authored-by: clydeps <U5yx99dok9>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Clyde Stubbs
2025-11-04 13:39:27 +10:00
committed by GitHub
parent 758ac58343
commit 0b04361fc0
15 changed files with 572 additions and 289 deletions

View File

@@ -41,10 +41,7 @@ from .lv_validation import lv_bool, lv_images_used
from .lvcode import LvContext, LvglComponent, lvgl_static
from .schemas import (
DISP_BG_SCHEMA,
FLEX_OBJ_SCHEMA,
FULL_STYLE_SCHEMA,
GRID_CELL_SCHEMA,
LAYOUT_SCHEMAS,
WIDGET_TYPES,
any_widget_schema,
container_schema,
@@ -78,6 +75,7 @@ from .widgets.button import button_spec
from .widgets.buttonmatrix import buttonmatrix_spec
from .widgets.canvas import canvas_spec
from .widgets.checkbox import checkbox_spec
from .widgets.container import container_spec
from .widgets.dropdown import dropdown_spec
from .widgets.img import img_spec
from .widgets.keyboard import keyboard_spec
@@ -130,20 +128,10 @@ for w_type in (
tileview_spec,
qr_code_spec,
canvas_spec,
container_spec,
):
WIDGET_TYPES[w_type.name] = w_type
WIDGET_SCHEMA = any_widget_schema()
LAYOUT_SCHEMAS[df.TYPE_GRID] = {
cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema(GRID_CELL_SCHEMA))
}
LAYOUT_SCHEMAS[df.TYPE_FLEX] = {
cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema(FLEX_OBJ_SCHEMA))
}
LAYOUT_SCHEMAS[df.TYPE_NONE] = {
cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema())
}
for w_type in WIDGET_TYPES.values():
register_action(
f"lvgl.{w_type.name}.update",
@@ -410,7 +398,7 @@ def display_schema(config):
def add_hello_world(config):
if df.CONF_WIDGETS not in config and CONF_PAGES not in config:
LOGGER.info("No pages or widgets configured, creating default hello_world page")
config[df.CONF_WIDGETS] = cv.ensure_list(WIDGET_SCHEMA)(get_hello_world())
config[df.CONF_WIDGETS] = any_widget_schema()(get_hello_world())
return config
@@ -450,6 +438,7 @@ LVGL_SCHEMA = cv.All(
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
@@ -459,12 +448,6 @@ LVGL_SCHEMA = cv.All(
)
for x in SIMPLE_TRIGGERS
},
cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list(
WIDGET_SCHEMA
),
cv.Exclusive(CONF_PAGES, CONF_PAGES): cv.ensure_list(
container_schema(page_spec)
),
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),

View File

@@ -394,6 +394,8 @@ LV_FLEX_ALIGNMENTS = LvConstant(
"SPACE_BETWEEN",
)
LV_FLEX_CROSS_ALIGNMENTS = LV_FLEX_ALIGNMENTS.extend("STRETCH")
LV_MENU_MODES = LvConstant(
"LV_MENU_HEADER_",
"TOP_FIXED",
@@ -436,6 +438,7 @@ CONF_BUTTONS = "buttons"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANGE_RATE = "change_rate"
CONF_CLOSE_BUTTON = "close_button"
CONF_CONTAINER = "container"
CONF_CONTROL = "control"
CONF_DEFAULT_FONT = "default_font"
CONF_DEFAULT_GROUP = "default_group"

View File

@@ -0,0 +1,357 @@
import re
import esphome.config_validation as cv
from esphome.const import CONF_HEIGHT, CONF_TYPE, CONF_WIDTH
from .defines import (
CONF_FLEX_ALIGN_CROSS,
CONF_FLEX_ALIGN_MAIN,
CONF_FLEX_ALIGN_TRACK,
CONF_FLEX_FLOW,
CONF_FLEX_GROW,
CONF_GRID_CELL_COLUMN_POS,
CONF_GRID_CELL_COLUMN_SPAN,
CONF_GRID_CELL_ROW_POS,
CONF_GRID_CELL_ROW_SPAN,
CONF_GRID_CELL_X_ALIGN,
CONF_GRID_CELL_Y_ALIGN,
CONF_GRID_COLUMN_ALIGN,
CONF_GRID_COLUMNS,
CONF_GRID_ROW_ALIGN,
CONF_GRID_ROWS,
CONF_LAYOUT,
CONF_PAD_COLUMN,
CONF_PAD_ROW,
CONF_WIDGETS,
FLEX_FLOWS,
LV_CELL_ALIGNMENTS,
LV_FLEX_ALIGNMENTS,
LV_FLEX_CROSS_ALIGNMENTS,
LV_GRID_ALIGNMENTS,
TYPE_FLEX,
TYPE_GRID,
TYPE_NONE,
LvConstant,
)
from .lv_validation import padding, size
cell_alignments = LV_CELL_ALIGNMENTS.one_of
grid_alignments = LV_GRID_ALIGNMENTS.one_of
flex_alignments = LV_FLEX_ALIGNMENTS.one_of
FLEX_LAYOUT_SCHEMA = {
cv.Required(CONF_TYPE): cv.one_of(TYPE_FLEX, lower=True),
cv.Optional(CONF_FLEX_FLOW, default="row_wrap"): FLEX_FLOWS.one_of,
cv.Optional(CONF_FLEX_ALIGN_MAIN, default="start"): flex_alignments,
cv.Optional(
CONF_FLEX_ALIGN_CROSS, default="start"
): LV_FLEX_CROSS_ALIGNMENTS.one_of,
cv.Optional(CONF_FLEX_ALIGN_TRACK, default="start"): flex_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
cv.Optional(CONF_FLEX_GROW): cv.int_,
}
FLEX_HV_STYLE = {
CONF_FLEX_ALIGN_MAIN: "LV_FLEX_ALIGN_SPACE_EVENLY",
CONF_FLEX_ALIGN_TRACK: "LV_FLEX_ALIGN_CENTER",
CONF_FLEX_ALIGN_CROSS: "LV_FLEX_ALIGN_CENTER",
CONF_TYPE: TYPE_FLEX,
}
FLEX_OBJ_SCHEMA = {
cv.Optional(CONF_FLEX_GROW): cv.int_,
}
def flex_hv_schema(dir):
dir = CONF_HEIGHT if dir == "horizontal" else CONF_WIDTH
return {
cv.Optional(CONF_FLEX_GROW, default=1): cv.int_,
cv.Optional(dir, default="100%"): size,
}
def grid_free_space(value):
value = cv.Upper(value)
if value.startswith("FR(") and value.endswith(")"):
value = value.removesuffix(")").removeprefix("FR(")
return f"LV_GRID_FR({cv.positive_int(value)})"
raise cv.Invalid("must be a size in pixels, CONTENT or FR(nn)")
grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space)
GRID_CELL_SCHEMA = {
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int,
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
}
class Layout:
"""
Define properties for a layout
The base class is layout "none"
"""
def get_type(self):
return TYPE_NONE
def get_layout_schemas(self, config: dict) -> tuple:
"""
Get the layout and child schema for a given widget based on its layout type.
"""
return None, {}
def validate(self, config):
"""
Validate the layout configuration. This is called late in the schema validation
:param config: The input configuration
:return: The validated configuration
"""
return config
class FlexLayout(Layout):
def get_type(self):
return TYPE_FLEX
def get_layout_schemas(self, config: dict) -> tuple:
layout = config.get(CONF_LAYOUT)
if not isinstance(layout, dict) or layout.get(CONF_TYPE) != TYPE_FLEX:
return None, {}
child_schema = FLEX_OBJ_SCHEMA
if grow := layout.get(CONF_FLEX_GROW):
child_schema = {cv.Optional(CONF_FLEX_GROW, default=grow): cv.int_}
# Polyfill to implement stretch alignment for flex containers
# LVGL does not support this natively, so we add a 100% size property to the children in the cross-axis
if layout.get(CONF_FLEX_ALIGN_CROSS) == "LV_FLEX_ALIGN_STRETCH":
dimension = (
CONF_WIDTH
if "COLUMN" in layout[CONF_FLEX_FLOW].upper()
else CONF_HEIGHT
)
child_schema[cv.Optional(dimension, default="100%")] = size
return FLEX_LAYOUT_SCHEMA, child_schema
def validate(self, config):
"""
Perform validation on the container and its children for this layout
:param config:
:return:
"""
return config
class DirectionalLayout(FlexLayout):
def __init__(self, direction: str, flow):
"""
:param direction: "horizontal" or "vertical"
:param flow: "row" or "column"
"""
super().__init__()
self.direction = direction
self.flow = flow
def get_type(self):
return self.direction
def get_layout_schemas(self, config: dict) -> tuple:
if config.get(CONF_LAYOUT, "").lower() != self.direction:
return None, {}
return cv.one_of(self.direction, lower=True), flex_hv_schema(self.direction)
def validate(self, config):
assert config[CONF_LAYOUT].lower() == self.direction
config[CONF_LAYOUT] = {
**FLEX_HV_STYLE,
CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(),
}
return config
class GridLayout(Layout):
_GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$")
def get_type(self):
return TYPE_GRID
def get_layout_schemas(self, config: dict) -> tuple:
layout = config.get(CONF_LAYOUT)
if isinstance(layout, str):
if GridLayout._GRID_LAYOUT_REGEX.match(layout):
return (
cv.string,
{
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
cv.Optional(
CONF_GRID_CELL_ROW_SPAN, default=1
): cv.positive_int,
cv.Optional(
CONF_GRID_CELL_COLUMN_SPAN, default=1
): cv.positive_int,
cv.Optional(
CONF_GRID_CELL_X_ALIGN, default="center"
): grid_alignments,
cv.Optional(
CONF_GRID_CELL_Y_ALIGN, default="center"
): grid_alignments,
},
)
# Not a valid grid layout string
return None, {}
if not isinstance(layout, dict) or layout.get(CONF_TYPE) != TYPE_GRID:
return None, {}
return (
{
cv.Required(CONF_TYPE): cv.one_of(TYPE_GRID, lower=True),
cv.Required(CONF_GRID_ROWS): [grid_spec],
cv.Required(CONF_GRID_COLUMNS): [grid_spec],
cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(CONF_PAD_ROW): padding,
cv.Optional(CONF_PAD_COLUMN): padding,
},
{
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int,
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
},
)
def validate(self, config: dict):
"""
Validate the grid layout.
The `layout:` key may be a dictionary with `rows` and `columns` keys, or a string in the format "rows x columns".
Either all cells must have a row and column,
or none, in which case the grid layout is auto-generated.
:param config:
:return: The config updated with auto-generated values
"""
layout = config.get(CONF_LAYOUT)
if isinstance(layout, str):
# If the layout is a string, assume it is in the format "rows x columns", implying
# a grid layout with the specified number of rows and columns each with CONTENT sizing.
layout = layout.strip()
match = GridLayout._GRID_LAYOUT_REGEX.match(layout)
if match:
rows = int(match.group(1))
cols = int(match.group(2))
layout = {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows,
CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols,
}
config[CONF_LAYOUT] = layout
else:
raise cv.Invalid(
f"Invalid grid layout format: {config}, expected 'rows x columns'",
[CONF_LAYOUT],
)
# should be guaranteed to be a dict at this point
assert isinstance(layout, dict)
assert layout.get(CONF_TYPE) == TYPE_GRID
rows = len(layout[CONF_GRID_ROWS])
columns = len(layout[CONF_GRID_COLUMNS])
used_cells = [[None] * columns for _ in range(rows)]
for index, widget in enumerate(config.get(CONF_WIDGETS, [])):
_, w = next(iter(widget.items()))
if (CONF_GRID_CELL_COLUMN_POS in w) != (CONF_GRID_CELL_ROW_POS in w):
raise cv.Invalid(
"Both row and column positions must be specified, or both omitted",
[CONF_WIDGETS, index],
)
if CONF_GRID_CELL_ROW_POS in w:
row = w[CONF_GRID_CELL_ROW_POS]
column = w[CONF_GRID_CELL_COLUMN_POS]
else:
try:
row, column = next(
(r_idx, c_idx)
for r_idx, row in enumerate(used_cells)
for c_idx, value in enumerate(row)
if value is None
)
except StopIteration:
raise cv.Invalid(
"No free cells available in grid layout", [CONF_WIDGETS, index]
) from None
w[CONF_GRID_CELL_ROW_POS] = row
w[CONF_GRID_CELL_COLUMN_POS] = column
for i in range(w[CONF_GRID_CELL_ROW_SPAN]):
for j in range(w[CONF_GRID_CELL_COLUMN_SPAN]):
if row + i >= rows or column + j >= columns:
raise cv.Invalid(
f"Cell at {row}/{column} span {w[CONF_GRID_CELL_ROW_SPAN]}x{w[CONF_GRID_CELL_COLUMN_SPAN]} "
f"exceeds grid size {rows}x{columns}",
[CONF_WIDGETS, index],
)
if used_cells[row + i][column + j] is not None:
raise cv.Invalid(
f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}",
[CONF_WIDGETS, index],
)
used_cells[row + i][column + j] = index
return config
LAYOUT_CLASSES = (
FlexLayout(),
GridLayout(),
DirectionalLayout("horizontal", "row"),
DirectionalLayout("vertical", "column"),
)
LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES]
def append_layout_schema(schema, config: dict):
"""
Get the child layout schema for a given widget based on its layout type.
:param config: The config to check
:return: A schema for the layout including a widgets key
"""
# Local import to avoid circular dependencies
if CONF_WIDGETS not in config:
if CONF_LAYOUT in config:
raise cv.Invalid(
f"Layout {config[CONF_LAYOUT]} requires a {CONF_WIDGETS} key",
[CONF_LAYOUT],
)
return schema
from .schemas import any_widget_schema
if CONF_LAYOUT not in config:
# If no layout is specified, return the schema as is
return schema.extend({cv.Optional(CONF_WIDGETS): any_widget_schema()})
for layout_class in LAYOUT_CLASSES:
layout_schema, child_schema = layout_class.get_layout_schemas(config)
if layout_schema:
layout_schema = cv.Schema(
{
cv.Required(CONF_LAYOUT): layout_schema,
cv.Required(CONF_WIDGETS): any_widget_schema(child_schema),
}
)
layout_schema.add_extra(layout_class.validate)
return layout_schema.extend(schema)
# If no layout class matched, return a default schema
return cv.Schema(
{
cv.Optional(CONF_LAYOUT): cv.one_of(*LAYOUT_CHOICES, lower=True),
cv.Optional(CONF_WIDGETS): any_widget_schema(),
}
)

View File

@@ -1,3 +1,4 @@
import re
from typing import TYPE_CHECKING, Any
import esphome.codegen as cg
@@ -246,6 +247,8 @@ def pixels_or_percent_validator(value):
return ["pixels", "..%"]
if isinstance(value, str) and value.lower().endswith("px"):
value = cv.int_(value[:-2])
if isinstance(value, str) and re.match(r"^lv_pct\((\d+)\)$", value):
return value
value = cv.Any(cv.int_, cv.percentage)(value)
if isinstance(value, int):
return value

View File

@@ -299,6 +299,7 @@ class LvExpr(MockLv):
# Top level mock for generic lv_ calls to be recorded
lv = MockLv("lv_")
LV = MockLv("LV_")
# Just generate an expression
lv_expr = LvExpr("lv_")
# Mock for lv_obj_ calls
@@ -327,7 +328,7 @@ def lv_assign(target, expression):
lv_add(AssignmentExpression("", "", target, expression))
def lv_Pvariable(type, name):
def lv_Pvariable(type, name) -> MockObj:
"""
Create but do not initialise a pointer variable
:param type: Type of the variable target
@@ -343,7 +344,7 @@ def lv_Pvariable(type, name):
return var
def lv_variable(type, name):
def lv_variable(type, name) -> MockObj:
"""
Create but do not initialise a variable
:param type: Type of the variable target

View File

@@ -12,17 +12,21 @@ from esphome.const import (
CONF_TEXT,
CONF_TIME,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_X,
CONF_Y,
)
from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger
from esphome.schema_extractors import SCHEMA_EXTRACT
from . import defines as df, lv_validation as lvalid
from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR, TYPE_GRID
from .helpers import add_lv_use, requires_component, validate_printf
from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR
from .helpers import requires_component, validate_printf
from .layout import (
FLEX_OBJ_SCHEMA,
GRID_CELL_SCHEMA,
append_layout_schema,
grid_alignments,
)
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
from .lvcode import LvglComponent, lv_event_t_ptr
from .types import (
@@ -72,11 +76,9 @@ def _validate_text(value):
# A schema for text properties
TEXT_SCHEMA = cv.Schema(
{
TEXT_SCHEMA = {
cv.Optional(CONF_TEXT): _validate_text,
}
)
LIST_ACTION_SCHEMA = cv.ensure_list(
cv.maybe_simple_value(
@@ -136,7 +138,7 @@ STYLE_PROPS = {
"arc_opa": lvalid.opacity,
"arc_color": lvalid.lv_color,
"arc_rounded": lvalid.lv_bool,
"arc_width": lvalid.lv_positive_int,
"arc_width": lvalid.pixels,
"anim_time": lvalid.lv_milliseconds,
"bg_color": lvalid.lv_color,
"bg_grad": lv_gradient,
@@ -223,10 +225,6 @@ STYLE_REMAP = {
"image_recolor_opa": "img_recolor_opa",
}
cell_alignments = df.LV_CELL_ALIGNMENTS.one_of
grid_alignments = df.LV_GRID_ALIGNMENTS.one_of
flex_alignments = df.LV_FLEX_ALIGNMENTS.one_of
# Complete object style schema
STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend(
{
@@ -266,10 +264,8 @@ def part_schema(parts):
:param parts: The parts to include
:return: The schema
"""
return (
cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts})
.extend(STATE_SCHEMA)
.extend(FLAG_SCHEMA)
return STATE_SCHEMA.extend(FLAG_SCHEMA).extend(
{cv.Optional(part): STATE_SCHEMA for part in parts}
)
@@ -277,10 +273,10 @@ def automation_schema(typ: LvType):
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
if typ.has_on_value:
events = events + (CONF_ON_VALUE,)
args = typ.get_arg_type() if isinstance(typ, LvType) else []
args = typ.get_arg_type()
args.append(lv_event_t_ptr)
return cv.Schema(
{
return {
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
@@ -289,14 +285,11 @@ def automation_schema(typ: LvType):
}
)
for event in events
}
).extend(
{
},
cv.Optional(CONF_ON_BOOT): validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)}
)
),
}
)
def base_update_schema(widget_type, parts):
@@ -335,75 +328,17 @@ def obj_schema(widget_type: WidgetType):
"""
return (
part_schema(widget_type.parts)
.extend(LAYOUT_SCHEMA)
.extend(ALIGN_TO_SCHEMA)
.extend(automation_schema(widget_type.w_type))
.extend(
cv.Schema(
{
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
}
)
)
)
def _validate_grid_layout(config):
layout = config[df.CONF_LAYOUT]
rows = len(layout[df.CONF_GRID_ROWS])
columns = len(layout[df.CONF_GRID_COLUMNS])
used_cells = [[None] * columns for _ in range(rows)]
for index, widget in enumerate(config[df.CONF_WIDGETS]):
_, w = next(iter(widget.items()))
if (df.CONF_GRID_CELL_COLUMN_POS in w) != (df.CONF_GRID_CELL_ROW_POS in w):
# pylint: disable=raise-missing-from
raise cv.Invalid(
"Both row and column positions must be specified, or both omitted",
[df.CONF_WIDGETS, index],
)
if df.CONF_GRID_CELL_ROW_POS in w:
row = w[df.CONF_GRID_CELL_ROW_POS]
column = w[df.CONF_GRID_CELL_COLUMN_POS]
else:
try:
row, column = next(
(r_idx, c_idx)
for r_idx, row in enumerate(used_cells)
for c_idx, value in enumerate(row)
if value is None
)
except StopIteration:
# pylint: disable=raise-missing-from
raise cv.Invalid(
"No free cells available in grid layout", [df.CONF_WIDGETS, index]
)
w[df.CONF_GRID_CELL_ROW_POS] = row
w[df.CONF_GRID_CELL_COLUMN_POS] = column
for i in range(w[df.CONF_GRID_CELL_ROW_SPAN]):
for j in range(w[df.CONF_GRID_CELL_COLUMN_SPAN]):
if row + i >= rows or column + j >= columns:
# pylint: disable=raise-missing-from
raise cv.Invalid(
f"Cell at {row}/{column} span {w[df.CONF_GRID_CELL_ROW_SPAN]}x{w[df.CONF_GRID_CELL_COLUMN_SPAN]} "
f"exceeds grid size {rows}x{columns}",
[df.CONF_WIDGETS, index],
)
if used_cells[row + i][column + j] is not None:
# pylint: disable=raise-missing-from
raise cv.Invalid(
f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}",
[df.CONF_WIDGETS, index],
)
used_cells[row + i][column + j] = index
return config
LAYOUT_SCHEMAS = {}
LAYOUT_VALIDATORS = {TYPE_GRID: _validate_grid_layout}
ALIGN_TO_SCHEMA = {
cv.Optional(df.CONF_ALIGN_TO): cv.Schema(
{
@@ -416,57 +351,6 @@ ALIGN_TO_SCHEMA = {
}
def grid_free_space(value):
value = cv.Upper(value)
if value.startswith("FR(") and value.endswith(")"):
value = value.removesuffix(")").removeprefix("FR(")
return f"LV_GRID_FR({cv.positive_int(value)})"
raise cv.Invalid("must be a size in pixels, CONTENT or FR(nn)")
grid_spec = cv.Any(
lvalid.size, df.LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space
)
LAYOUT_SCHEMA = {
cv.Optional(df.CONF_LAYOUT): cv.typed_schema(
{
df.TYPE_GRID: {
cv.Required(df.CONF_GRID_ROWS): [grid_spec],
cv.Required(df.CONF_GRID_COLUMNS): [grid_spec],
cv.Optional(df.CONF_GRID_COLUMN_ALIGN): grid_alignments,
cv.Optional(df.CONF_GRID_ROW_ALIGN): grid_alignments,
cv.Optional(df.CONF_PAD_ROW): lvalid.padding,
cv.Optional(df.CONF_PAD_COLUMN): lvalid.padding,
},
df.TYPE_FLEX: {
cv.Optional(
df.CONF_FLEX_FLOW, default="row_wrap"
): df.FLEX_FLOWS.one_of,
cv.Optional(df.CONF_FLEX_ALIGN_MAIN, default="start"): flex_alignments,
cv.Optional(df.CONF_FLEX_ALIGN_CROSS, default="start"): flex_alignments,
cv.Optional(df.CONF_FLEX_ALIGN_TRACK, default="start"): flex_alignments,
cv.Optional(df.CONF_PAD_ROW): lvalid.padding,
cv.Optional(df.CONF_PAD_COLUMN): lvalid.padding,
},
},
lower=True,
)
}
GRID_CELL_SCHEMA = {
cv.Optional(df.CONF_GRID_CELL_ROW_POS): cv.positive_int,
cv.Optional(df.CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
cv.Optional(df.CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int,
cv.Optional(df.CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int,
cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments,
cv.Optional(df.CONF_GRID_CELL_Y_ALIGN): grid_alignments,
}
FLEX_OBJ_SCHEMA = {
cv.Optional(df.CONF_FLEX_GROW): cv.int_,
}
DISP_BG_SCHEMA = cv.Schema(
{
cv.Optional(df.CONF_DISP_BG_IMAGE): cv.Any(
@@ -498,48 +382,11 @@ ALL_STYLES = {
}
def container_validator(schema, widget_type: WidgetType):
"""
Create a validator for a container given the widget type
:param schema: Base schema to extend
:param widget_type:
:return:
"""
def validator(value):
if w_sch := widget_type.schema:
if isinstance(w_sch, dict):
w_sch = cv.Schema(w_sch)
# order is important here to preserve extras
result = w_sch.extend(schema)
else:
result = schema
ltype = df.TYPE_NONE
if value and (layout := value.get(df.CONF_LAYOUT)):
if not isinstance(layout, dict):
raise cv.Invalid("Layout value must be a dict")
ltype = layout.get(CONF_TYPE)
if not ltype:
raise (cv.Invalid("Layout schema requires type:"))
add_lv_use(ltype)
if value == SCHEMA_EXTRACT:
return result
result = result.extend(
LAYOUT_SCHEMAS.get(ltype.lower(), LAYOUT_SCHEMAS[df.TYPE_NONE])
)
value = result(value)
if layout_validator := LAYOUT_VALIDATORS.get(ltype):
value = layout_validator(value)
return value
return validator
def container_schema(widget_type: WidgetType, extras=None):
"""
Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified.
:param widget_type: The widget type, e.g. "img"
:param widget_type: The widget type, e.g. "image"
:param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget.
"""
@@ -549,31 +396,49 @@ def container_schema(widget_type: WidgetType, extras=None):
if extras:
schema = schema.extend(extras)
# Delayed evaluation for recursion
return container_validator(schema, widget_type)
schema = schema.extend(widget_type.schema)
def widget_schema(widget_type: WidgetType, extras=None):
"""
Create a schema for a given widget type
:param widget_type: The name of the widget
:param extras:
:return:
"""
validator = container_schema(widget_type, extras=extras)
if required := widget_type.required_component:
validator = cv.All(validator, requires_component(required))
return cv.Exclusive(widget_type.name, df.CONF_WIDGETS), validator
def validator(value):
return append_layout_schema(schema, value)(value)
# All widget schemas must be defined before this is called.
return validator
def any_widget_schema(extras=None):
"""
Generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of
Dynamically generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of
widget under the widgets: key.
:param extras: Additional schema to be applied to each generated one
:return:
:return: A validator for the Widgets key
"""
return cv.Any(dict(widget_schema(wt, extras) for wt in WIDGET_TYPES.values()))
def validator(value):
if isinstance(value, dict):
# Convert to list
value = [{k: v} for k, v in value.items()]
if not isinstance(value, list):
raise cv.Invalid("Expected a list of widgets")
result = []
for index, entry in enumerate(value):
if not isinstance(entry, dict) or len(entry) != 1:
raise cv.Invalid(
"Each widget must be a dictionary with a single key", path=[index]
)
[(key, value)] = entry.items()
# Validate the widget against its schema
widget_type = WIDGET_TYPES.get(key)
if not widget_type:
raise cv.Invalid(f"Unknown widget type: {key}", path=[index])
container_validator = container_schema(widget_type, extras=extras)
if required := widget_type.required_component:
container_validator = cv.All(
container_validator, requires_component(required)
)
# Apply custom validation
value = widget_type.validate(value or {})
result.append({key: container_validator(value)})
return result
return validator

View File

@@ -1,6 +1,7 @@
import sys
from esphome import automation, codegen as cg
from esphome.config_validation import Schema
from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.cpp_types import esphome_ns
@@ -135,13 +136,13 @@ class WidgetType:
self.lv_name = lv_name or name
self.w_type = w_type
self.parts = parts
if schema is None:
self.schema = {}
else:
if not isinstance(schema, Schema):
schema = Schema(schema or {})
self.schema = schema
if modify_schema is None:
self.modify_schema = self.schema
else:
modify_schema = schema
if not isinstance(modify_schema, Schema):
modify_schema = Schema(modify_schema)
self.modify_schema = modify_schema
self.mock_obj = MockObj(f"lv_{self.lv_name}", "_")
@@ -163,7 +164,6 @@ class WidgetType:
:param config: Its configuration
:return: Generated code as a list of text lines
"""
return []
async def obj_creator(self, parent: MockObjClass, config: dict):
"""
@@ -174,6 +174,13 @@ class WidgetType:
"""
return lv_expr.call(f"{self.lv_name}_create", parent)
def on_create(self, var: MockObj, config: dict):
"""
Called from to_code when the widget is created, to set up any initial properties
:param var: The variable representing the widget
:param config: Its configuration
"""
def get_uses(self):
"""
Get a list of other widgets used by this one
@@ -193,6 +200,14 @@ class WidgetType:
def get_scale(self, config: dict):
return 1.0
def validate(self, value):
"""
Provides an opportunity for custom validation for a given widget type
:param value:
:return:
"""
return value
class NumberType(WidgetType):
def get_max(self, config: dict):

View File

@@ -339,7 +339,10 @@ async def set_obj_properties(w: Widget, config):
if layout_type == TYPE_FLEX:
lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW]))
main = literal(layout[CONF_FLEX_ALIGN_MAIN])
cross = literal(layout[CONF_FLEX_ALIGN_CROSS])
cross = layout[CONF_FLEX_ALIGN_CROSS]
if cross == "LV_FLEX_ALIGN_STRETCH":
cross = "LV_FLEX_ALIGN_CENTER"
cross = literal(cross)
track = literal(layout[CONF_FLEX_ALIGN_TRACK])
lv_obj.set_flex_align(w.obj, main, cross, track)
parts = collect_parts(config)
@@ -446,9 +449,11 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent):
if spec.is_compound():
var = cg.new_Pvariable(wid)
lv_add(var.set_obj(creator))
spec.on_create(var.obj, w_cnfig)
else:
var = lv_Pvariable(lv_obj_t, wid)
lv_assign(var, creator)
spec.on_create(var, w_cnfig)
w = Widget.create(wid, var, spec, w_cnfig)
if theme := theme_widget_map.get(w_type):

View File

@@ -159,18 +159,15 @@ async def canvas_set_pixel(config, action_id, template_arg, args):
)
DRAW_SCHEMA = cv.Schema(
{
DRAW_SCHEMA = {
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Required(CONF_X): pixels,
cv.Required(CONF_Y): pixels,
}
)
DRAW_OPA_SCHEMA = DRAW_SCHEMA.extend(
{
DRAW_OPA_SCHEMA = {
**DRAW_SCHEMA,
cv.Optional(CONF_OPA): opacity,
}
)
async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg, args):
@@ -224,12 +221,14 @@ RECT_PROPS = {
@automation.register_action(
"lvgl.canvas.draw_rectangle",
ObjUpdateAction,
DRAW_SCHEMA.extend(
cv.Schema(
{
**DRAW_OPA_SCHEMA,
cv.Required(CONF_WIDTH): cv.templatable(cv.int_),
cv.Required(CONF_HEIGHT): cv.templatable(cv.int_),
},
).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
}
),
)
async def canvas_draw_rect(config, action_id, template_arg, args):
width = await pixels.process(config[CONF_WIDTH])
@@ -261,13 +260,14 @@ TEXT_PROPS = {
@automation.register_action(
"lvgl.canvas.draw_text",
ObjUpdateAction,
TEXT_SCHEMA.extend(DRAW_OPA_SCHEMA)
.extend(
cv.Schema(
{
**TEXT_SCHEMA,
**DRAW_OPA_SCHEMA,
cv.Required(CONF_MAX_WIDTH): cv.templatable(cv.int_),
**{cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS},
},
)
.extend({cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}),
),
)
async def canvas_draw_text(config, action_id, template_arg, args):
text = await lv_text.process(config[CONF_TEXT])
@@ -293,13 +293,15 @@ IMG_PROPS = {
@automation.register_action(
"lvgl.canvas.draw_image",
ObjUpdateAction,
DRAW_OPA_SCHEMA.extend(
cv.Schema(
{
**DRAW_OPA_SCHEMA,
cv.Required(CONF_SRC): lv_image,
cv.Optional(CONF_PIVOT_X, default=0): pixels,
cv.Optional(CONF_PIVOT_Y, default=0): pixels,
},
).extend({cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}),
**{cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()},
}
),
)
async def canvas_draw_image(config, action_id, template_arg, args):
src = await lv_image.process(config[CONF_SRC])
@@ -336,8 +338,9 @@ LINE_PROPS = {
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Optional(CONF_OPA): opacity,
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
},
).extend({cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}),
**{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()},
}
),
)
async def canvas_draw_line(config, action_id, template_arg, args):
points = [
@@ -363,8 +366,9 @@ async def canvas_draw_line(config, action_id, template_arg, args):
{
cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t),
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
},
).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}),
),
)
async def canvas_draw_polygon(config, action_id, template_arg, args):
points = [
@@ -395,13 +399,15 @@ ARC_PROPS = {
@automation.register_action(
"lvgl.canvas.draw_arc",
ObjUpdateAction,
DRAW_OPA_SCHEMA.extend(
cv.Schema(
{
**DRAW_OPA_SCHEMA,
cv.Required(CONF_RADIUS): pixels,
cv.Required(CONF_START_ANGLE): lv_angle_degrees,
cv.Required(CONF_END_ANGLE): lv_angle_degrees,
**{cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()},
}
).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}),
),
)
async def canvas_draw_arc(config, action_id, template_arg, args):
radius = await size.process(config[CONF_RADIUS])

View File

@@ -17,11 +17,10 @@ class CheckboxType(WidgetType):
CONF_CHECKBOX,
LvBoolean("lv_checkbox_t"),
(CONF_MAIN, CONF_INDICATOR),
TEXT_SCHEMA.extend(
{
**TEXT_SCHEMA,
Optional(CONF_PAD_COLUMN): padding,
}
),
},
)
async def to_code(self, w: Widget, config):

View File

@@ -0,0 +1,39 @@
import esphome.config_validation as cv
from esphome.const import CONF_HEIGHT, CONF_WIDTH
from esphome.cpp_generator import MockObj
from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_OBJ, CONF_SCROLLBAR
from ..lv_validation import size
from ..lvcode import lv
from ..types import WidgetType, lv_obj_t
CONTAINER_SCHEMA = cv.Schema(
{
cv.Optional(CONF_HEIGHT, default="100%"): size,
cv.Optional(CONF_WIDTH, default="100%"): size,
}
)
class ContainerType(WidgetType):
"""
A simple container widget that can hold other widgets and which defaults to a 100% size.
Made from an obj with all styles removed
"""
def __init__(self):
super().__init__(
CONF_CONTAINER,
lv_obj_t,
(CONF_MAIN, CONF_SCROLLBAR),
schema=CONTAINER_SCHEMA,
modify_schema={},
lv_name=CONF_OBJ,
)
self.styles = {}
def on_create(self, var: MockObj, config: dict):
lv.obj_remove_style_all(var)
container_spec = ContainerType()

View File

@@ -23,12 +23,11 @@ class LabelType(WidgetType):
CONF_LABEL,
LvText("lv_label_t"),
(CONF_MAIN, CONF_SCROLLBAR, CONF_SELECTED),
TEXT_SCHEMA.extend(
{
**TEXT_SCHEMA,
cv.Optional(CONF_RECOLOR): lv_bool,
cv.Optional(CONF_LONG_MODE): LV_LONG_MODES.one_of,
}
),
},
)
async def to_code(self, w: Widget, config):

View File

@@ -14,13 +14,12 @@ CONF_QRCODE = "qrcode"
CONF_DARK_COLOR = "dark_color"
CONF_LIGHT_COLOR = "light_color"
QRCODE_SCHEMA = TEXT_SCHEMA.extend(
{
QRCODE_SCHEMA = {
**TEXT_SCHEMA,
cv.Optional(CONF_DARK_COLOR, default="black"): lv_color,
cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color,
cv.Required(CONF_SIZE): cv.int_,
}
)
class QrCodeType(WidgetType):

View File

@@ -21,15 +21,14 @@ CONF_TEXTAREA = "textarea"
lv_textarea_t = LvText("lv_textarea_t")
TEXTAREA_SCHEMA = TEXT_SCHEMA.extend(
{
TEXTAREA_SCHEMA = {
**TEXT_SCHEMA,
cv.Optional(CONF_PLACEHOLDER_TEXT): lv_text,
cv.Optional(CONF_ACCEPTED_CHARS): lv_text,
cv.Optional(CONF_ONE_LINE): lv_bool,
cv.Optional(CONF_PASSWORD_MODE): lv_bool,
cv.Optional(CONF_MAX_LENGTH): lv_int,
}
)
class TextareaType(WidgetType):

View File

@@ -113,7 +113,8 @@ lvgl:
title: Messagebox
bg_color: 0xffff
widgets:
- label:
# Test single widget without list
label:
text: Hello Msgbox
id: msgbox_label
body:
@@ -281,7 +282,7 @@ lvgl:
#endif
return std::string(buf);
align: top_left
- obj:
- container:
align: center
arc_opa: COVER
arc_color: 0xFF0000
@@ -414,6 +415,7 @@ lvgl:
- buttons:
- id: button_e
- button:
layout: 2x1
id: button_button
width: 20%
height: 10%
@@ -430,8 +432,13 @@ lvgl:
checked:
bg_color: 0x000000
widgets:
- label:
# Test parse a dict instead of list
label:
text: Button
align: bottom_right
image:
src: cat_image
align: top_left
on_click:
- lvgl.widget.focus: spin_up
- lvgl.widget.focus: next
@@ -539,6 +546,7 @@ lvgl:
- logger.log: "tile 1 is now showing"
tiles:
- id: tile_1
layout: vertical
row: 0
column: 0
dir: ALL
@@ -554,6 +562,7 @@ lvgl:
bg_color: 0x000000
- id: page2
layout: vertical
widgets:
- canvas:
id: canvas_id
@@ -1005,6 +1014,7 @@ lvgl:
r_mod: -20
opa: 0%
- id: page3
layout: horizontal
widgets:
- keyboard:
id: lv_keyboard