diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 46026852af..32304276d3 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -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 "x", "x" or "x". + 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: "x", "x" or "x". + # 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 " + "'x', 'x' or 'x'", [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', 'x', + layout choices are 'horizontal', 'vertical', + 'x', 'x', 'x', or a dictionary with a 'type' key """ ), diff --git a/tests/component_tests/lvgl/__init__.py b/tests/component_tests/lvgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/lvgl/test_grid_layout.py b/tests/component_tests/lvgl/test_grid_layout.py new file mode 100644 index 0000000000..dfd4b2460c --- /dev/null +++ b/tests/component_tests/lvgl/test_grid_layout.py @@ -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: + """`x` 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: + """`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` 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: + """`x` and `x` 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] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d6e237199a..9c4ad4bbf8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -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 "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": 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