[lvgl] Additional layout features (#16041)

This commit is contained in:
Clyde Stubbs
2026-04-29 10:35:24 +10:00
committed by GitHub
parent 15df477472
commit 0b5835284a
4 changed files with 419 additions and 22 deletions

View File

@@ -1,3 +1,4 @@
import math
import re
import textwrap
@@ -85,6 +86,22 @@ def grid_free_space(value):
grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space)
def grid_dimension(value):
"""
Validator for a grid `rows` or `columns` value.
Accepts either a positive integer (interpreted as that many cells of equal
`LV_GRID_FR(1)` size) or a non-empty list of grid specs.
"""
if isinstance(value, int):
value = cv.int_range(min=1)(value)
return ["LV_GRID_FR(1)"] * value
result = cv.Schema([grid_spec])(value)
if not result:
raise cv.Invalid("Grid dimension list must contain at least one entry")
return result
GRID_CELL_SCHEMA = {
cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int,
cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int,
@@ -184,7 +201,16 @@ class DirectionalLayout(FlexLayout):
class GridLayout(Layout):
_GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$")
# Match shorthand grid layout strings: "NxM", "Nx" or "xM".
# At least one of the two numbers must be present; this is enforced after matching.
_GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)?\s*x\s*(\d+)?\s*$")
@staticmethod
def _match_shorthand(layout):
match = GridLayout._GRID_LAYOUT_REGEX.match(layout)
if match is None or (match.group(1) is None and match.group(2) is None):
return None
return match
def get_type(self):
return TYPE_GRID
@@ -192,7 +218,7 @@ class GridLayout(Layout):
def get_layout_schemas(self, config: dict) -> tuple:
layout = config.get(CONF_LAYOUT)
if isinstance(layout, str):
if GridLayout._GRID_LAYOUT_REGEX.match(layout):
if GridLayout._match_shorthand(layout):
return (
cv.string,
{
@@ -213,59 +239,107 @@ class GridLayout(Layout):
if not isinstance(layout, dict) or layout.get(CONF_TYPE).lower() != TYPE_GRID:
return None, {}
x_default = (
"center" if isinstance(layout.get(CONF_GRID_ROWS), int) else cv.UNDEFINED
)
y_default = (
"center" if isinstance(layout.get(CONF_GRID_COLUMNS), int) else cv.UNDEFINED
)
x_align = layout.get(CONF_GRID_CELL_X_ALIGN, x_default)
y_align = layout.get(CONF_GRID_CELL_Y_ALIGN, y_default)
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_ROWS): grid_dimension,
cv.Optional(CONF_GRID_COLUMNS): grid_dimension,
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_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean,
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
},
{
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): cv.int_range(min=1),
cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1),
cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments,
cv.Optional(CONF_GRID_CELL_X_ALIGN, default=x_align): grid_alignments,
cv.Optional(CONF_GRID_CELL_Y_ALIGN, default=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".
The `layout:` key may be a dictionary with `rows` and/or `columns` keys, or a
shorthand string in the format "<rows>x<columns>", "<rows>x" or "x<columns>".
Either dimension may be omitted, in which case it will be calculated from the
other dimension and the number of configured widgets.
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)
widgets = config.get(CONF_WIDGETS, [])
num_widgets = len(widgets)
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.
# Shorthand string: "<rows>x<columns>", "<rows>x" or "x<columns>".
# Each dimension defaults to LV_GRID_FR(1). A missing dimension is
# calculated from the other dimension and the number of widgets.
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:
match = GridLayout._match_shorthand(layout)
if not match:
raise cv.Invalid(
f"Invalid grid layout format: {config}, expected 'rows x columns'",
f"Invalid grid layout format: {layout!r}, expected "
"'<rows>x<columns>', '<rows>x' or 'x<columns>'",
[CONF_LAYOUT],
)
rows_int = int(match.group(1)) if match.group(1) is not None else None
cols_int = int(match.group(2)) if match.group(2) is not None else None
for label, val in (("row", rows_int), ("column", cols_int)):
if val is not None and val < 1:
raise cv.Invalid(
f"Invalid grid layout {layout!r}: {label} count must be "
"at least 1",
[CONF_LAYOUT],
)
if rows_int is not None and cols_int is not None:
rows = rows_int
cols = cols_int
elif rows_int is not None:
rows = rows_int
cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1
else:
cols = cols_int
rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1
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
# should be guaranteed to be a dict at this point
assert isinstance(layout, dict)
assert layout.get(CONF_TYPE).lower() == TYPE_GRID
rows_list = layout.get(CONF_GRID_ROWS)
cols_list = layout.get(CONF_GRID_COLUMNS)
if rows_list is None and cols_list is None:
raise cv.Invalid(
"Grid layout requires at least one of 'rows' or 'columns' to be "
"specified",
[CONF_LAYOUT],
)
if rows_list is None:
cols = len(cols_list)
rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1
layout[CONF_GRID_ROWS] = ["LV_GRID_FR(1)"] * rows
elif cols_list is None:
rows = len(rows_list)
cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1
layout[CONF_GRID_COLUMNS] = ["LV_GRID_FR(1)"] * cols
allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False)
rows = len(layout[CONF_GRID_ROWS])
columns = len(layout[CONF_GRID_COLUMNS])
@@ -379,7 +453,8 @@ def append_layout_schema(schema, config: dict):
textwrap.dedent(
"""
Invalid 'layout' value
layout choices are 'horizontal', 'vertical', '<rows>x<cols>',
layout choices are 'horizontal', 'vertical',
'<rows>x<cols>', '<rows>x', 'x<cols>',
or a dictionary with a 'type' key
"""
),

View File

View File

@@ -0,0 +1,239 @@
"""Unit tests for the LVGL grid layout shorthand and rows/columns auto-sizing."""
from __future__ import annotations
import pytest
from voluptuous import Invalid
from esphome.components.lvgl.defines import (
CONF_GRID_COLUMNS,
CONF_GRID_ROWS,
CONF_LAYOUT,
CONF_WIDGETS,
TYPE_GRID,
)
from esphome.components.lvgl.layout import GridLayout, grid_dimension
from esphome.const import CONF_TYPE
FR1 = "LV_GRID_FR(1)"
def _widgets(n: int) -> list[dict]:
"""Build a list of `n` placeholder widgets for the validate() input."""
return [{"label": {}} for _ in range(n)]
# ---------------------------------------------------------------------------
# grid_dimension validator
# ---------------------------------------------------------------------------
def test_grid_dimension_int_expands_to_fr1_list() -> None:
"""A positive integer should expand to a list of LV_GRID_FR(1) entries."""
assert grid_dimension(1) == [FR1]
assert grid_dimension(3) == [FR1, FR1, FR1]
def test_grid_dimension_zero_or_negative_rejected() -> None:
"""Non-positive integers must be rejected."""
with pytest.raises(Invalid):
grid_dimension(0)
with pytest.raises(Invalid):
grid_dimension(-2)
def test_grid_dimension_list_passes_through() -> None:
"""A list should be validated through the existing grid_spec list schema."""
result = grid_dimension(["100px", "content", "fr(2)"])
# `grid_spec` normalises each entry: pixel sizes become ints, the
# CONTENT keyword is uppercased and prefixed, and FR(n) is normalised.
assert result == [100, "LV_GRID_CONTENT", "LV_GRID_FR(2)"]
def test_grid_dimension_invalid_string_rejected() -> None:
"""A string is not a valid grid dimension and should be rejected."""
with pytest.raises(Invalid):
grid_dimension("not a list")
def test_grid_dimension_empty_list_rejected() -> None:
"""An empty list of grid specs must be rejected."""
with pytest.raises(Invalid, match="at least one entry"):
grid_dimension([])
# ---------------------------------------------------------------------------
# Shorthand string layouts
# ---------------------------------------------------------------------------
def test_shorthand_full_form_unchanged() -> None:
"""`<rows>x<cols>` continues to work and yields the exact dimensions."""
config = {CONF_LAYOUT: "2x3", CONF_WIDGETS: _widgets(0)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert layout[CONF_TYPE] == TYPE_GRID
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
def test_shorthand_rows_only_calculates_columns_from_widgets() -> None:
"""`<rows>x` derives the column count from the number of widgets."""
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: _widgets(7)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 7 widgets / 3 rows -> ceil = 3 columns.
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 3
def test_shorthand_columns_only_calculates_rows_from_widgets() -> None:
"""`x<cols>` derives the row count from the number of widgets."""
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: _widgets(5)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 5 widgets / 4 cols -> ceil = 2 rows.
assert len(layout[CONF_GRID_ROWS]) == 2
assert len(layout[CONF_GRID_COLUMNS]) == 4
def test_shorthand_rows_only_no_widgets_defaults_columns_to_one() -> None:
"""With no widgets and only rows specified, the column count defaults to 1."""
config = {CONF_LAYOUT: "3x", CONF_WIDGETS: []}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 1
def test_shorthand_columns_only_no_widgets_defaults_rows_to_one() -> None:
"""With no widgets and only columns specified, the row count defaults to 1."""
config = {CONF_LAYOUT: "x4", CONF_WIDGETS: []}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 1
assert len(layout[CONF_GRID_COLUMNS]) == 4
def test_shorthand_with_whitespace_accepted() -> None:
"""The shorthand parser should tolerate whitespace around the components."""
config = {CONF_LAYOUT: " 3 x ", CONF_WIDGETS: _widgets(6)}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 6 widgets / 3 rows -> 2 columns.
assert len(layout[CONF_GRID_ROWS]) == 3
assert len(layout[CONF_GRID_COLUMNS]) == 2
def test_shorthand_bare_x_rejected() -> None:
"""Pure `x` (no digits at all) is not a valid shorthand."""
config = {CONF_LAYOUT: "x", CONF_WIDGETS: _widgets(2)}
with pytest.raises(Invalid):
GridLayout().validate(config)
@pytest.mark.parametrize(
"layout,bad_label",
[
("0x3", "row"),
("3x0", "column"),
("0x", "row"),
("x0", "column"),
("0x0", "row"),
],
)
def test_shorthand_zero_dimension_rejected(layout: str, bad_label: str) -> None:
"""Shorthand row/column counts must be >= 1."""
config = {CONF_LAYOUT: layout, CONF_WIDGETS: _widgets(2)}
with pytest.raises(Invalid, match=f"{bad_label} count must be at least 1"):
GridLayout().validate(config)
def test_shorthand_get_layout_schemas_recognizes_partial_forms() -> None:
"""`<rows>x` and `x<cols>` should be picked up by GridLayout.get_layout_schemas."""
grid = GridLayout()
for layout in ("3x", "x4", "2x3"):
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
assert layout_schema is not None, f"{layout!r} should be recognised"
# Pure `x` and unrelated strings should not be picked up as a grid layout.
for layout in ("x", "horizontal"):
layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout})
assert layout_schema is None, f"{layout!r} should not be recognised"
# ---------------------------------------------------------------------------
# Dict-form layouts with rows/columns auto-sizing
# ---------------------------------------------------------------------------
def test_dict_rows_only_calculates_columns_from_widgets() -> None:
"""A dict layout with only rows fills in the column count from widget count."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1],
},
CONF_WIDGETS: _widgets(5),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 5 widgets / 2 rows -> ceil = 3 columns.
assert len(layout[CONF_GRID_ROWS]) == 2
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]
def test_dict_columns_only_calculates_rows_from_widgets() -> None:
"""A dict layout with only columns fills in the row count from widget count."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
},
CONF_WIDGETS: _widgets(7),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
# 7 widgets / 3 cols -> ceil = 3 rows.
assert layout[CONF_GRID_ROWS] == [FR1, FR1, FR1]
assert len(layout[CONF_GRID_COLUMNS]) == 3
def test_dict_rows_only_no_widgets_defaults_columns_to_one() -> None:
"""A dict layout with rows but no widgets defaults columns to 1."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1, FR1],
},
CONF_WIDGETS: [],
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert len(layout[CONF_GRID_ROWS]) == 3
assert layout[CONF_GRID_COLUMNS] == [FR1]
def test_dict_neither_rows_nor_columns_rejected() -> None:
"""A grid layout dict without rows AND without columns must be rejected."""
config = {
CONF_LAYOUT: {CONF_TYPE: TYPE_GRID},
CONF_WIDGETS: _widgets(3),
}
with pytest.raises(Invalid):
GridLayout().validate(config)
def test_dict_both_rows_and_columns_unchanged() -> None:
"""When both dimensions are present they are preserved as-is."""
config = {
CONF_LAYOUT: {
CONF_TYPE: TYPE_GRID,
CONF_GRID_ROWS: [FR1, FR1],
CONF_GRID_COLUMNS: [FR1, FR1, FR1],
},
CONF_WIDGETS: _widgets(0),
}
result = GridLayout().validate(config)
layout = result[CONF_LAYOUT]
assert layout[CONF_GRID_ROWS] == [FR1, FR1]
assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1]

View File

@@ -1113,6 +1113,8 @@ lvgl:
pad_row: 6px
pad_column: 0
multiple_widgets_per_cell: true
grid_cell_x_align: center
grid_cell_y_align: center
widgets:
- image:
grid_cell_row_pos: 0
@@ -1305,6 +1307,87 @@ lvgl:
hidden: true
mode: text_lower
# Grid shorthand "<rows>x": 3 rows specified, columns derived
# from widget count (4 widgets / 3 rows -> 2 columns)
- obj:
id: grid_rows_only_shorthand
layout: 3x
widgets:
- label:
text: "r1"
- label:
text: "r2"
- label:
text: "r3"
- label:
text: "r4"
# Grid shorthand "x<cols>": 4 columns specified, rows derived
# from widget count (5 widgets / 4 cols -> 2 rows)
- obj:
id: grid_cols_only_shorthand
layout: x4
widgets:
- label:
text: "a"
- label:
text: "b"
- label:
text: "c"
- label:
text: "d"
- label:
text: "e"
# Grid dict form with grid_rows as a plain integer; columns derived
- obj:
id: grid_rows_int
layout:
type: grid
grid_rows: 2
widgets:
- label:
text: "1"
- label:
text: "2"
- label:
text: "3"
# Grid dict form with grid_columns as a plain integer; rows derived
- obj:
id: grid_cols_int
layout:
type: grid
grid_columns: 3
widgets:
- label:
text: "x"
- label:
text: "y"
- label:
text: "z"
- label:
text: "w"
- label:
text: "v"
# Grid dict form with both grid_rows and grid_columns as plain integers
- obj:
id: grid_both_int
layout:
type: grid
grid_rows: 2
grid_columns: 2
widgets:
- label:
text: "1,1"
- label:
text: "1,2"
- label:
text: "2,1"
- label:
text: "2,2"
font:
- file: "gfonts://Roboto"
id: space16