From d18bb34f87758cff3d1a6d881b1b167d1d6f79a1 Mon Sep 17 00:00:00 2001
From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Date: Mon, 5 Aug 2024 15:07:05 +1000
Subject: [PATCH] [lvgl] Stage 4 (#7166)

---
 esphome/components/lvgl/__init__.py        | 110 +++++--
 esphome/components/lvgl/animimg.py         | 117 +++++++
 esphome/components/lvgl/arc.py             |  78 +++++
 esphome/components/lvgl/automation.py      | 186 ++++++-----
 esphome/components/lvgl/btn.py             |  11 +-
 esphome/components/lvgl/checkbox.py        |  25 ++
 esphome/components/lvgl/defines.py         |  64 ++--
 esphome/components/lvgl/helpers.py         |  20 --
 esphome/components/lvgl/img.py             |  85 +++++
 esphome/components/lvgl/label.py           |   9 +-
 esphome/components/lvgl/led.py             |  29 ++
 esphome/components/lvgl/line.py            |  51 +++
 esphome/components/lvgl/lv_bar.py          |  53 ++++
 esphome/components/lvgl/lv_switch.py       |  20 ++
 esphome/components/lvgl/lv_validation.py   |  60 +++-
 esphome/components/lvgl/lvcode.py          | 236 +++++++++-----
 esphome/components/lvgl/lvgl_esphome.cpp   | 202 ++++++++++++
 esphome/components/lvgl/lvgl_esphome.h     | 164 ++++------
 esphome/components/lvgl/page.py            | 113 +++++++
 esphome/components/lvgl/rotary_encoders.py |   3 +-
 esphome/components/lvgl/schemas.py         | 105 ++++++-
 esphome/components/lvgl/slider.py          |  63 ++++
 esphome/components/lvgl/spinner.py         |  43 +++
 esphome/components/lvgl/styles.py          |  58 ++++
 esphome/components/lvgl/trigger.py         |  22 +-
 esphome/components/lvgl/types.py           |  92 ++++--
 esphome/components/lvgl/widget.py          | 219 +++++++++----
 tests/components/lvgl/lvgl-package.yaml    | 343 +++++++++++++--------
 28 files changed, 2002 insertions(+), 579 deletions(-)
 create mode 100644 esphome/components/lvgl/animimg.py
 create mode 100644 esphome/components/lvgl/arc.py
 create mode 100644 esphome/components/lvgl/checkbox.py
 create mode 100644 esphome/components/lvgl/img.py
 create mode 100644 esphome/components/lvgl/led.py
 create mode 100644 esphome/components/lvgl/line.py
 create mode 100644 esphome/components/lvgl/lv_bar.py
 create mode 100644 esphome/components/lvgl/lv_switch.py
 create mode 100644 esphome/components/lvgl/page.py
 create mode 100644 esphome/components/lvgl/slider.py
 create mode 100644 esphome/components/lvgl/spinner.py
 create mode 100644 esphome/components/lvgl/styles.py

diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py
index 182d04e038..c154689199 100644
--- a/esphome/components/lvgl/__init__.py
+++ b/esphome/components/lvgl/__init__.py
@@ -15,44 +15,91 @@ from esphome.const import (
     CONF_TRIGGER_ID,
     CONF_TYPE,
 )
-from esphome.core import CORE, ID, Lambda
+from esphome.core import CORE, ID
 from esphome.cpp_generator import MockObj
 from esphome.final_validate import full_config
 from esphome.helpers import write_file_if_changed
 
 from . import defines as df, helpers, lv_validation as lvalid
-from .automation import update_to_code
+from .animimg import animimg_spec
+from .arc import arc_spec
+from .automation import disp_update, update_to_code
 from .btn import btn_spec
+from .checkbox import checkbox_spec
+from .defines import CONF_SKIP
+from .img import img_spec
 from .label import label_spec
-from .lv_validation import lv_images_used
-from .lvcode import LvContext
+from .led import led_spec
+from .line import line_spec
+from .lv_bar import bar_spec
+from .lv_switch import switch_spec
+from .lv_validation import lv_bool, lv_images_used
+from .lvcode import LvContext, LvglComponent
 from .obj import obj_spec
+from .page import add_pages, page_spec
 from .rotary_encoders import ROTARY_ENCODER_CONFIG, rotary_encoders_to_code
-from .schemas import any_widget_schema, create_modify_schema, obj_schema
+from .schemas import (
+    DISP_BG_SCHEMA,
+    FLEX_OBJ_SCHEMA,
+    GRID_CELL_SCHEMA,
+    LAYOUT_SCHEMAS,
+    STYLE_SCHEMA,
+    WIDGET_TYPES,
+    any_widget_schema,
+    container_schema,
+    create_modify_schema,
+    grid_alignments,
+    obj_schema,
+)
+from .slider import slider_spec
+from .spinner import spinner_spec
+from .styles import add_top_layer, styles_to_code, theme_to_code
 from .touchscreens import touchscreen_schema, touchscreens_to_code
 from .trigger import generate_triggers
 from .types import (
-    WIDGET_TYPES,
     FontEngine,
     IdleTrigger,
-    LvglComponent,
     ObjUpdateAction,
     lv_font_t,
+    lv_style_t,
     lvgl_ns,
 )
 from .widget import Widget, add_widgets, lv_scr_act, set_obj_properties
 
 DOMAIN = "lvgl"
-DEPENDENCIES = ("display",)
-AUTO_LOAD = ("key_provider",)
-CODEOWNERS = ("@clydebarrow",)
+DEPENDENCIES = ["display"]
+AUTO_LOAD = ["key_provider"]
+CODEOWNERS = ["@clydebarrow"]
 LOGGER = logging.getLogger(__name__)
 
-for w_type in (label_spec, obj_spec, btn_spec):
+for w_type in (
+    label_spec,
+    obj_spec,
+    btn_spec,
+    bar_spec,
+    slider_spec,
+    arc_spec,
+    line_spec,
+    spinner_spec,
+    led_spec,
+    animimg_spec,
+    checkbox_spec,
+    img_spec,
+    switch_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",
@@ -61,14 +108,6 @@ for w_type in WIDGET_TYPES.values():
     )(update_to_code)
 
 
-async def add_init_lambda(lv_component, init):
-    if init:
-        lamb = await cg.process_lambda(
-            Lambda(init), [(LvglComponent.operator("ptr"), "lv_component")]
-        )
-        cg.add(lv_component.add_init_lambda(lamb))
-
-
 lv_defines = {}  # Dict of #defines to provide as build flags
 
 
@@ -100,6 +139,9 @@ def generate_lv_conf_h():
 
 
 def final_validation(config):
+    if pages := config.get(CONF_PAGES):
+        if all(p[CONF_SKIP] for p in pages):
+            raise cv.Invalid("At least one page must not be skipped")
     global_config = full_config.get()
     for display_id in config[df.CONF_DISPLAYS]:
         path = global_config.get_path_for_id(display_id)[:-1]
@@ -193,18 +235,23 @@ async def to_code(config):
     else:
         add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font))
 
-    with LvContext():
+    async with LvContext(lv_component):
         await touchscreens_to_code(lv_component, config)
         await rotary_encoders_to_code(lv_component, config)
+        await theme_to_code(config)
+        await styles_to_code(config)
         await set_obj_properties(lv_scr_act, config)
         await add_widgets(lv_scr_act, config)
+        await add_pages(lv_component, config)
+        await add_top_layer(config)
+        await disp_update(f"{lv_component}->get_disp()", config)
         Widget.set_completed()
         await generate_triggers(lv_component)
         for conf in config.get(CONF_ON_IDLE, ()):
             templ = await cg.templatable(conf[CONF_TIMEOUT], [], cg.uint32)
             idle_trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], lv_component, templ)
             await build_automation(idle_trigger, [], conf)
-    await add_init_lambda(lv_component, LvContext.get_code())
+
     for comp in helpers.lvgl_components_required:
         CORE.add_define(f"USE_LVGL_{comp.upper()}")
     for use in helpers.lv_uses:
@@ -239,6 +286,16 @@ CONFIG_SCHEMA = (
             cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
                 "big_endian", "little_endian"
             ),
+            cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
+                cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)})
+                .extend(STYLE_SCHEMA)
+                .extend(
+                    {
+                        cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments,
+                        cv.Optional(df.CONF_GRID_CELL_Y_ALIGN): grid_alignments,
+                    }
+                )
+            ),
             cv.Optional(CONF_ON_IDLE): validate_automation(
                 {
                     cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
@@ -247,10 +304,19 @@ CONFIG_SCHEMA = (
                     ),
                 }
             ),
-            cv.Optional(df.CONF_WIDGETS): cv.ensure_list(WIDGET_SCHEMA),
+            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_PAGE_WRAP, default=True): lv_bool,
+            cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
             cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
+            cv.Optional(df.CONF_THEME): cv.Schema(
+                {cv.Optional(name): obj_schema(w) for name, w in WIDGET_TYPES.items()}
+            ),
             cv.GenerateID(df.CONF_TOUCHSCREENS): touchscreen_schema,
             cv.GenerateID(df.CONF_ROTARY_ENCODERS): ROTARY_ENCODER_CONFIG,
         }
     )
+    .extend(DISP_BG_SCHEMA)
 ).add_extra(cv.has_at_least_one_key(CONF_PAGES, df.CONF_WIDGETS))
diff --git a/esphome/components/lvgl/animimg.py b/esphome/components/lvgl/animimg.py
new file mode 100644
index 0000000000..20b85b019c
--- /dev/null
+++ b/esphome/components/lvgl/animimg.py
@@ -0,0 +1,117 @@
+from esphome import automation
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome.const import CONF_DURATION, CONF_ID
+
+from ...cpp_generator import MockObj
+from .automation import action_to_code
+from .defines import CONF_AUTO_START, CONF_MAIN, CONF_REPEAT_COUNT, CONF_SRC
+from .helpers import lvgl_components_required
+from .img import CONF_IMAGE
+from .label import CONF_LABEL
+from .lv_validation import lv_image, lv_milliseconds
+from .lvcode import lv, lv_expr
+from .types import LvType, ObjUpdateAction, void_ptr
+from .widget import Widget, WidgetType, get_widgets
+
+CONF_ANIMIMG = "animimg"
+CONF_SRC_LIST_ID = "src_list_id"
+
+
+def lv_repeat_count(value):
+    if isinstance(value, str) and value.lower() in ("forever", "infinite"):
+        value = 0xFFFF
+    return cv.int_range(min=0, max=0xFFFF)(value)
+
+
+ANIMIMG_BASE_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_REPEAT_COUNT, default="forever"): lv_repeat_count,
+        cv.Optional(CONF_AUTO_START, default=True): cv.boolean,
+    }
+)
+ANIMIMG_SCHEMA = ANIMIMG_BASE_SCHEMA.extend(
+    {
+        cv.Required(CONF_DURATION): lv_milliseconds,
+        cv.Required(CONF_SRC): cv.ensure_list(lv_image),
+        cv.GenerateID(CONF_SRC_LIST_ID): cv.declare_id(void_ptr),
+    }
+)
+
+ANIMIMG_MODIFY_SCHEMA = ANIMIMG_BASE_SCHEMA.extend(
+    {
+        cv.Optional(CONF_DURATION): lv_milliseconds,
+    }
+)
+
+lv_animimg_t = LvType("lv_animimg_t")
+
+
+class AnimimgType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_ANIMIMG,
+            lv_animimg_t,
+            (CONF_MAIN,),
+            ANIMIMG_SCHEMA,
+            ANIMIMG_MODIFY_SCHEMA,
+        )
+
+    async def to_code(self, w: Widget, config):
+        lvgl_components_required.add(CONF_IMAGE)
+        lvgl_components_required.add(CONF_ANIMIMG)
+        if CONF_SRC in config:
+            for x in config[CONF_SRC]:
+                await cg.get_variable(x)
+            srcs = [lv_expr.img_from(MockObj(x)) for x in config[CONF_SRC]]
+            src_id = cg.static_const_array(config[CONF_SRC_LIST_ID], srcs)
+            count = len(config[CONF_SRC])
+            lv.animimg_set_src(w.obj, src_id, count)
+        lv.animimg_set_repeat_count(w.obj, config[CONF_REPEAT_COUNT])
+        lv.animimg_set_duration(w.obj, config[CONF_DURATION])
+        if config.get(CONF_AUTO_START):
+            lv.animimg_start(w.obj)
+
+    def get_uses(self):
+        return CONF_IMAGE, CONF_LABEL
+
+
+animimg_spec = AnimimgType()
+
+
+@automation.register_action(
+    "lvgl.animimg.start",
+    ObjUpdateAction,
+    cv.maybe_simple_value(
+        {
+            cv.Required(CONF_ID): cv.use_id(lv_animimg_t),
+        },
+        key=CONF_ID,
+    ),
+)
+async def animimg_start(config, action_id, template_arg, args):
+    widget = await get_widgets(config)
+
+    async def do_start(w: Widget):
+        lv.animimg_start(w.obj)
+
+    return await action_to_code(widget, do_start, action_id, template_arg, args)
+
+
+@automation.register_action(
+    "lvgl.animimg.stop",
+    ObjUpdateAction,
+    cv.maybe_simple_value(
+        {
+            cv.Required(CONF_ID): cv.use_id(lv_animimg_t),
+        },
+        key=CONF_ID,
+    ),
+)
+async def animimg_stop(config, action_id, template_arg, args):
+    widget = await get_widgets(config)
+
+    async def do_stop(w: Widget):
+        lv.animimg_stop(w.obj)
+
+    return await action_to_code(widget, do_stop, action_id, template_arg, args)
diff --git a/esphome/components/lvgl/arc.py b/esphome/components/lvgl/arc.py
new file mode 100644
index 0000000000..d036464c7a
--- /dev/null
+++ b/esphome/components/lvgl/arc.py
@@ -0,0 +1,78 @@
+import esphome.config_validation as cv
+from esphome.const import (
+    CONF_MAX_VALUE,
+    CONF_MIN_VALUE,
+    CONF_MODE,
+    CONF_ROTATION,
+    CONF_VALUE,
+)
+from esphome.cpp_types import nullptr
+
+from .defines import (
+    ARC_MODES,
+    CONF_ADJUSTABLE,
+    CONF_CHANGE_RATE,
+    CONF_END_ANGLE,
+    CONF_INDICATOR,
+    CONF_KNOB,
+    CONF_MAIN,
+    CONF_START_ANGLE,
+    literal,
+)
+from .lv_validation import angle, get_start_value, lv_float
+from .lvcode import lv, lv_obj
+from .types import LvNumber, NumberType
+from .widget import Widget
+
+CONF_ARC = "arc"
+ARC_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+        cv.Optional(CONF_MIN_VALUE, default=0): cv.int_,
+        cv.Optional(CONF_MAX_VALUE, default=100): cv.int_,
+        cv.Optional(CONF_START_ANGLE, default=135): angle,
+        cv.Optional(CONF_END_ANGLE, default=45): angle,
+        cv.Optional(CONF_ROTATION, default=0.0): angle,
+        cv.Optional(CONF_ADJUSTABLE, default=False): bool,
+        cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of,
+        cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t,
+    }
+)
+
+ARC_MODIFY_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+    }
+)
+
+
+class ArcType(NumberType):
+    def __init__(self):
+        super().__init__(
+            CONF_ARC,
+            LvNumber("lv_arc_t"),
+            parts=(CONF_MAIN, CONF_INDICATOR, CONF_KNOB),
+            schema=ARC_SCHEMA,
+            modify_schema=ARC_MODIFY_SCHEMA,
+        )
+
+    async def to_code(self, w: Widget, config):
+        if CONF_MIN_VALUE in config:
+            lv.arc_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE])
+            lv.arc_set_bg_angles(
+                w.obj, config[CONF_START_ANGLE] // 10, config[CONF_END_ANGLE] // 10
+            )
+            lv.arc_set_rotation(w.obj, config[CONF_ROTATION] // 10)
+            lv.arc_set_mode(w.obj, literal(config[CONF_MODE]))
+            lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE])
+
+        if config.get(CONF_ADJUSTABLE) is False:
+            lv_obj.remove_style(w.obj, nullptr, literal("LV_PART_KNOB"))
+            w.clear_flag("LV_OBJ_FLAG_CLICKABLE")
+
+        value = await get_start_value(config)
+        if value is not None:
+            lv.arc_set_value(w.obj, value)
+
+
+arc_spec = ArcType()
diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py
index 4fd0be185e..ffa25783ad 100644
--- a/esphome/components/lvgl/automation.py
+++ b/esphome/components/lvgl/automation.py
@@ -1,15 +1,26 @@
+from collections.abc import Awaitable
+from typing import Callable
+
 from esphome import automation
 import esphome.codegen as cg
 import esphome.config_validation as cv
 from esphome.const import CONF_ID, CONF_TIMEOUT
-from esphome.core import Lambda
-from esphome.cpp_generator import RawStatement
 from esphome.cpp_types import nullptr
 
-from .defines import CONF_LVGL_ID, CONF_SHOW_SNOW, literal
-from .lv_validation import lv_bool
+from .defines import (
+    CONF_DISP_BG_COLOR,
+    CONF_DISP_BG_IMAGE,
+    CONF_LVGL_ID,
+    CONF_SHOW_SNOW,
+    literal,
+)
+from .lv_validation import lv_bool, lv_color, lv_image
 from .lvcode import (
+    LVGL_COMP_ARG,
     LambdaContext,
+    LocalVariable,
+    LvConditional,
+    LvglComponent,
     ReturnStatement,
     add_line_marks,
     lv,
@@ -17,46 +28,46 @@ from .lvcode import (
     lv_obj,
     lvgl_comp,
 )
-from .schemas import ACTION_SCHEMA, LVGL_SCHEMA
+from .schemas import DISP_BG_SCHEMA, LIST_ACTION_SCHEMA, LVGL_SCHEMA
 from .types import (
+    LV_EVENT,
+    LV_STATE,
     LvglAction,
-    LvglComponent,
-    LvglComponentPtr,
     LvglCondition,
     ObjUpdateAction,
+    lv_disp_t,
     lv_obj_t,
 )
-from .widget import Widget, get_widget, lv_scr_act, set_obj_properties
+from .widget import Widget, get_widgets, lv_scr_act, set_obj_properties
 
 
-async def action_to_code(action: list, action_id, widget: Widget, template_arg, args):
-    with LambdaContext() as context:
-        lv.cond_if(widget.obj == nullptr)
-        lv_add(RawStatement("  return;"))
-        lv.cond_endif()
-    code = context.get_code()
-    code.extend(action)
-    action = "\n".join(code) + "\n\n"
-    lamb = await cg.process_lambda(Lambda(action), args)
-    var = cg.new_Pvariable(action_id, template_arg, lamb)
+async def action_to_code(
+    widgets: list[Widget],
+    action: Callable[[Widget], Awaitable[None]],
+    action_id,
+    template_arg,
+    args,
+):
+    async with LambdaContext(parameters=args, where=action_id) as context:
+        for widget in widgets:
+            with LvConditional(widget.obj != nullptr):
+                await action(widget)
+    var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
     return var
 
 
 async def update_to_code(config, action_id, template_arg, args):
-    if config is not None:
-        widget = await get_widget(config)
-        with LambdaContext() as context:
-            add_line_marks(action_id)
-            await set_obj_properties(widget, config)
-            await widget.type.to_code(widget, config)
-            if (
-                widget.type.w_type.value_property is not None
-                and widget.type.w_type.value_property in config
-            ):
-                lv.event_send(widget.obj, literal("LV_EVENT_VALUE_CHANGED"), nullptr)
-        return await action_to_code(
-            context.get_code(), action_id, widget, template_arg, args
-        )
+    async def do_update(widget: Widget):
+        await set_obj_properties(widget, config)
+        await widget.type.to_code(widget, config)
+        if (
+            widget.type.w_type.value_property is not None
+            and widget.type.w_type.value_property in config
+        ):
+            lv.event_send(widget.obj, LV_EVENT.VALUE_CHANGED, nullptr)
+
+    widgets = await get_widgets(config[CONF_ID])
+    return await action_to_code(widgets, do_update, action_id, template_arg, args)
 
 
 @automation.register_condition(
@@ -66,9 +77,7 @@ async def update_to_code(config, action_id, template_arg, args):
 )
 async def lvgl_is_paused(config, condition_id, template_arg, args):
     lvgl = config[CONF_LVGL_ID]
-    with LambdaContext(
-        [(LvglComponentPtr, "lvgl_comp")], return_type=cg.bool_
-    ) as context:
+    async with LambdaContext(LVGL_COMP_ARG, return_type=cg.bool_) as context:
         lv_add(ReturnStatement(lvgl_comp.is_paused()))
     var = cg.new_Pvariable(condition_id, template_arg, await context.get_lambda())
     await cg.register_parented(var, lvgl)
@@ -89,15 +98,23 @@ async def lvgl_is_paused(config, condition_id, template_arg, args):
 async def lvgl_is_idle(config, condition_id, template_arg, args):
     lvgl = config[CONF_LVGL_ID]
     timeout = await cg.templatable(config[CONF_TIMEOUT], [], cg.uint32)
-    with LambdaContext(
-        [(LvglComponentPtr, "lvgl_comp")], return_type=cg.bool_
-    ) as context:
+    async with LambdaContext(LVGL_COMP_ARG, return_type=cg.bool_) as context:
         lv_add(ReturnStatement(lvgl_comp.is_idle(timeout)))
     var = cg.new_Pvariable(condition_id, template_arg, await context.get_lambda())
     await cg.register_parented(var, lvgl)
     return var
 
 
+async def disp_update(disp, config: dict):
+    if CONF_DISP_BG_COLOR not in config and CONF_DISP_BG_IMAGE not in config:
+        return
+    with LocalVariable("lv_disp_tmp", lv_disp_t, literal(disp)) as disp_temp:
+        if bg_color := config.get(CONF_DISP_BG_COLOR):
+            lv.disp_set_bg_color(disp_temp, await lv_color.process(bg_color))
+        if bg_image := config.get(CONF_DISP_BG_IMAGE):
+            lv.disp_set_bg_image(disp_temp, await lv_image.process(bg_image))
+
+
 @automation.register_action(
     "lvgl.widget.redraw",
     ObjUpdateAction,
@@ -109,14 +126,32 @@ async def lvgl_is_idle(config, condition_id, template_arg, args):
     ),
 )
 async def obj_invalidate_to_code(config, action_id, template_arg, args):
-    if CONF_ID in config:
-        w = await get_widget(config)
-    else:
-        w = lv_scr_act
-    with LambdaContext() as context:
-        add_line_marks(action_id)
-        lv_obj.invalidate(w.obj)
-    return await action_to_code(context.get_code(), action_id, w, template_arg, args)
+    widgets = await get_widgets(config) or [lv_scr_act]
+
+    async def do_invalidate(widget: Widget):
+        lv_obj.invalidate(widget.obj)
+
+    return await action_to_code(widgets, do_invalidate, action_id, template_arg, args)
+
+
+@automation.register_action(
+    "lvgl.update",
+    LvglAction,
+    DISP_BG_SCHEMA.extend(
+        {
+            cv.GenerateID(): cv.use_id(LvglComponent),
+        }
+    ).add_extra(cv.has_at_least_one_key(CONF_DISP_BG_COLOR, CONF_DISP_BG_IMAGE)),
+)
+async def lvgl_update_to_code(config, action_id, template_arg, args):
+    widgets = await get_widgets(config)
+    w = widgets[0]
+    disp = f"{w.obj}->get_disp()"
+    async with LambdaContext(parameters=args, where=action_id) as context:
+        await disp_update(disp, config)
+    var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+    await cg.register_parented(var, w.var)
+    return var
 
 
 @automation.register_action(
@@ -128,8 +163,8 @@ async def obj_invalidate_to_code(config, action_id, template_arg, args):
     },
 )
 async def pause_action_to_code(config, action_id, template_arg, args):
-    with LambdaContext([(LvglComponentPtr, "lvgl_comp")]) as context:
-        add_line_marks(action_id)
+    async with LambdaContext(LVGL_COMP_ARG) as context:
+        add_line_marks(where=action_id)
         lv_add(lvgl_comp.set_paused(True, config[CONF_SHOW_SNOW]))
     var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
     await cg.register_parented(var, config[CONF_ID])
@@ -144,45 +179,48 @@ async def pause_action_to_code(config, action_id, template_arg, args):
     },
 )
 async def resume_action_to_code(config, action_id, template_arg, args):
-    with LambdaContext([(LvglComponentPtr, "lvgl_comp")]) as context:
-        add_line_marks(action_id)
+    async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
         lv_add(lvgl_comp.set_paused(False, False))
     var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
     await cg.register_parented(var, config[CONF_ID])
     return var
 
 
-@automation.register_action("lvgl.widget.disable", ObjUpdateAction, ACTION_SCHEMA)
+@automation.register_action("lvgl.widget.disable", ObjUpdateAction, LIST_ACTION_SCHEMA)
 async def obj_disable_to_code(config, action_id, template_arg, args):
-    w = await get_widget(config)
-    with LambdaContext() as context:
-        add_line_marks(action_id)
-        w.add_state("LV_STATE_DISABLED")
-    return await action_to_code(context.get_code(), action_id, w, template_arg, args)
+    async def do_disable(widget: Widget):
+        widget.add_state(LV_STATE.DISABLED)
+
+    return await action_to_code(
+        await get_widgets(config), do_disable, action_id, template_arg, args
+    )
 
 
-@automation.register_action("lvgl.widget.enable", ObjUpdateAction, ACTION_SCHEMA)
+@automation.register_action("lvgl.widget.enable", ObjUpdateAction, LIST_ACTION_SCHEMA)
 async def obj_enable_to_code(config, action_id, template_arg, args):
-    w = await get_widget(config)
-    with LambdaContext() as context:
-        add_line_marks(action_id)
-        w.clear_state("LV_STATE_DISABLED")
-    return await action_to_code(context.get_code(), action_id, w, template_arg, args)
+    async def do_enable(widget: Widget):
+        widget.clear_state(LV_STATE.DISABLED)
+
+    return await action_to_code(
+        await get_widgets(config), do_enable, action_id, template_arg, args
+    )
 
 
-@automation.register_action("lvgl.widget.hide", ObjUpdateAction, ACTION_SCHEMA)
+@automation.register_action("lvgl.widget.hide", ObjUpdateAction, LIST_ACTION_SCHEMA)
 async def obj_hide_to_code(config, action_id, template_arg, args):
-    w = await get_widget(config)
-    with LambdaContext() as context:
-        add_line_marks(action_id)
-        w.add_flag("LV_OBJ_FLAG_HIDDEN")
-    return await action_to_code(context.get_code(), action_id, w, template_arg, args)
+    async def do_hide(widget: Widget):
+        widget.add_flag("LV_OBJ_FLAG_HIDDEN")
+
+    return await action_to_code(
+        await get_widgets(config), do_hide, action_id, template_arg, args
+    )
 
 
-@automation.register_action("lvgl.widget.show", ObjUpdateAction, ACTION_SCHEMA)
+@automation.register_action("lvgl.widget.show", ObjUpdateAction, LIST_ACTION_SCHEMA)
 async def obj_show_to_code(config, action_id, template_arg, args):
-    w = await get_widget(config)
-    with LambdaContext() as context:
-        add_line_marks(action_id)
-        w.clear_flag("LV_OBJ_FLAG_HIDDEN")
-    return await action_to_code(context.get_code(), action_id, w, template_arg, args)
+    async def do_show(widget: Widget):
+        widget.clear_flag("LV_OBJ_FLAG_HIDDEN")
+
+    return await action_to_code(
+        await get_widgets(config), do_show, action_id, template_arg, args
+    )
diff --git a/esphome/components/lvgl/btn.py b/esphome/components/lvgl/btn.py
index 064d886d47..2a2a53e1e2 100644
--- a/esphome/components/lvgl/btn.py
+++ b/esphome/components/lvgl/btn.py
@@ -1,19 +1,14 @@
 from esphome.const import CONF_BUTTON
-from esphome.cpp_generator import MockObjClass
 
 from .defines import CONF_MAIN
 from .types import LvBoolean, WidgetType
 
+lv_btn_t = LvBoolean("lv_btn_t")
+
 
 class BtnType(WidgetType):
     def __init__(self):
-        super().__init__(CONF_BUTTON, LvBoolean("lv_btn_t"), (CONF_MAIN,))
-
-    def obj_creator(self, parent: MockObjClass, config: dict):
-        """
-        LVGL 8 calls buttons `btn`
-        """
-        return f"lv_btn_create({parent})"
+        super().__init__(CONF_BUTTON, lv_btn_t, (CONF_MAIN,), lv_name="btn")
 
     def get_uses(self):
         return ("btn",)
diff --git a/esphome/components/lvgl/checkbox.py b/esphome/components/lvgl/checkbox.py
new file mode 100644
index 0000000000..7418d633cf
--- /dev/null
+++ b/esphome/components/lvgl/checkbox.py
@@ -0,0 +1,25 @@
+from .defines import CONF_INDICATOR, CONF_MAIN, CONF_TEXT
+from .lv_validation import lv_text
+from .lvcode import lv
+from .schemas import TEXT_SCHEMA
+from .types import LvBoolean
+from .widget import Widget, WidgetType
+
+CONF_CHECKBOX = "checkbox"
+
+
+class CheckboxType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_CHECKBOX,
+            LvBoolean("lv_checkbox_t"),
+            (CONF_MAIN, CONF_INDICATOR),
+            TEXT_SCHEMA,
+        )
+
+    async def to_code(self, w: Widget, config):
+        if value := config.get(CONF_TEXT):
+            lv.checkbox_set_text(w.obj, await lv_text.process(value))
+
+
+checkbox_spec = CheckboxType()
diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py
index 9f349e3943..16ec45ae8a 100644
--- a/esphome/components/lvgl/defines.py
+++ b/esphome/components/lvgl/defines.py
@@ -4,31 +4,20 @@ Constants already defined in esphome.const are not duplicated here and must be i
 
 """
 
-from typing import Union
-
 from esphome import codegen as cg, config_validation as cv
 from esphome.core import ID, Lambda
-from esphome.cpp_generator import Literal
+from esphome.cpp_generator import MockObj
 from esphome.cpp_types import uint32
 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
 
 from .helpers import requires_component
 
-
-class ConstantLiteral(Literal):
-    __slots__ = ("constant",)
-
-    def __init__(self, constant: str):
-        super().__init__()
-        self.constant = constant
-
-    def __str__(self):
-        return self.constant
+lvgl_ns = cg.esphome_ns.namespace("lvgl")
 
 
-def literal(arg: Union[str, ConstantLiteral]):
+def literal(arg):
     if isinstance(arg, str):
-        return ConstantLiteral(arg)
+        return MockObj(arg)
     return arg
 
 
@@ -93,15 +82,23 @@ class LvConstant(LValidator):
             return self.prefix + cv.one_of(*choices, upper=True)(value)
 
         super().__init__(validator, rtype=uint32)
+        self.retmapper = self.mapper
         self.one_of = LValidator(validator, uint32, retmapper=self.mapper)
         self.several_of = LValidator(
             cv.ensure_list(self.one_of), uint32, retmapper=self.mapper
         )
 
     def mapper(self, value, args=()):
-        if isinstance(value, list):
-            value = "|".join(value)
-        return ConstantLiteral(value)
+        if not isinstance(value, list):
+            value = [value]
+        return literal(
+            "|".join(
+                [
+                    str(v) if str(v).startswith(self.prefix) else self.prefix + str(v)
+                    for v in value
+                ]
+            ).upper()
+        )
 
     def extend(self, *choices):
         """
@@ -112,9 +109,6 @@ class LvConstant(LValidator):
         return LvConstant(self.prefix, *(self.choices + choices))
 
 
-# Widgets
-CONF_LABEL = "label"
-
 # Parts
 CONF_MAIN = "main"
 CONF_SCROLLBAR = "scrollbar"
@@ -123,10 +117,15 @@ CONF_KNOB = "knob"
 CONF_SELECTED = "selected"
 CONF_ITEMS = "items"
 CONF_TICKS = "ticks"
-CONF_TICK_STYLE = "tick_style"
 CONF_CURSOR = "cursor"
 CONF_TEXTAREA_PLACEHOLDER = "textarea_placeholder"
 
+# Layout types
+
+TYPE_FLEX = "flex"
+TYPE_GRID = "grid"
+TYPE_NONE = "none"
+
 LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [
     "dejavu_16_persian_hebrew",
     "simsun_16_cjk",
@@ -134,7 +133,7 @@ LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [
     "unscii_16",
 ]
 
-LV_EVENT = {
+LV_EVENT_MAP = {
     "PRESS": "PRESSED",
     "SHORT_CLICK": "SHORT_CLICKED",
     "LONG_PRESS": "LONG_PRESSED",
@@ -150,7 +149,7 @@ LV_EVENT = {
     "CANCEL": "CANCEL",
 }
 
-LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT)
+LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT_MAP)
 
 
 LV_ANIM = LvConstant(
@@ -305,7 +304,8 @@ OBJ_FLAGS = (
 ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL")
 BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE")
 
-BTNMATRIX_CTRLS = (
+BTNMATRIX_CTRLS = LvConstant(
+    "LV_BTNMATRIX_CTRL_",
     "HIDDEN",
     "NO_REPEAT",
     "DISABLED",
@@ -366,7 +366,6 @@ CONF_ACCEPTED_CHARS = "accepted_chars"
 CONF_ADJUSTABLE = "adjustable"
 CONF_ALIGN = "align"
 CONF_ALIGN_TO = "align_to"
-CONF_ANGLE_RANGE = "angle_range"
 CONF_ANIMATED = "animated"
 CONF_ANIMATION = "animation"
 CONF_ANTIALIAS = "antialias"
@@ -384,8 +383,6 @@ CONF_BYTE_ORDER = "byte_order"
 CONF_CHANGE_RATE = "change_rate"
 CONF_CLOSE_BUTTON = "close_button"
 CONF_COLOR_DEPTH = "color_depth"
-CONF_COLOR_END = "color_end"
-CONF_COLOR_START = "color_start"
 CONF_CONTROL = "control"
 CONF_DEFAULT = "default"
 CONF_DEFAULT_FONT = "default_font"
@@ -414,9 +411,7 @@ CONF_GRID_ROW_ALIGN = "grid_row_align"
 CONF_GRID_ROWS = "grid_rows"
 CONF_HEADER_MODE = "header_mode"
 CONF_HOME = "home"
-CONF_INDICATORS = "indicators"
 CONF_KEY_CODE = "key_code"
-CONF_LABEL_GAP = "label_gap"
 CONF_LAYOUT = "layout"
 CONF_LEFT_BUTTON = "left_button"
 CONF_LINE_WIDTH = "line_width"
@@ -425,7 +420,6 @@ CONF_LONG_PRESS_TIME = "long_press_time"
 CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time"
 CONF_LVGL_ID = "lvgl_id"
 CONF_LONG_MODE = "long_mode"
-CONF_MAJOR = "major"
 CONF_MSGBOXES = "msgboxes"
 CONF_OBJ = "obj"
 CONF_OFFSET_X = "offset_x"
@@ -434,6 +428,7 @@ CONF_ONE_LINE = "one_line"
 CONF_ON_SELECT = "on_select"
 CONF_ONE_CHECKED = "one_checked"
 CONF_NEXT = "next"
+CONF_PAGE = "page"
 CONF_PAGE_WRAP = "page_wrap"
 CONF_PASSWORD_MODE = "password_mode"
 CONF_PIVOT_X = "pivot_x"
@@ -442,14 +437,12 @@ CONF_PLACEHOLDER_TEXT = "placeholder_text"
 CONF_POINTS = "points"
 CONF_PREVIOUS = "previous"
 CONF_REPEAT_COUNT = "repeat_count"
-CONF_R_MOD = "r_mod"
 CONF_RECOLOR = "recolor"
 CONF_RIGHT_BUTTON = "right_button"
 CONF_ROLLOVER = "rollover"
 CONF_ROOT_BACK_BTN = "root_back_btn"
 CONF_ROTARY_ENCODERS = "rotary_encoders"
 CONF_ROWS = "rows"
-CONF_SCALES = "scales"
 CONF_SCALE_LINES = "scale_lines"
 CONF_SCROLLBAR_MODE = "scrollbar_mode"
 CONF_SELECTED_INDEX = "selected_index"
@@ -459,8 +452,9 @@ CONF_SRC = "src"
 CONF_START_ANGLE = "start_angle"
 CONF_START_VALUE = "start_value"
 CONF_STATES = "states"
-CONF_STRIDE = "stride"
 CONF_STYLE = "style"
+CONF_STYLES = "styles"
+CONF_STYLE_DEFINITIONS = "style_definitions"
 CONF_STYLE_ID = "style_id"
 CONF_SKIP = "skip"
 CONF_SYMBOL = "symbol"
@@ -505,4 +499,4 @@ DEFAULT_ESPHOME_FONT = "esphome_lv_default_font"
 
 
 def join_enums(enums, prefix=""):
-    return ConstantLiteral("|".join(f"(int){prefix}{e.upper()}" for e in enums))
+    return literal("|".join(f"(int){prefix}{e.upper()}" for e in enums))
diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py
index d67739155c..e04a0105d5 100644
--- a/esphome/components/lvgl/helpers.py
+++ b/esphome/components/lvgl/helpers.py
@@ -1,10 +1,7 @@
 import re
 
 from esphome import config_validation as cv
-from esphome.config import Config
 from esphome.const import CONF_ARGS, CONF_FORMAT
-from esphome.core import CORE, ID
-from esphome.yaml_util import ESPHomeDataBase
 
 lv_uses = {
     "USER_DATA",
@@ -44,23 +41,6 @@ def validate_printf(value):
     return value
 
 
-def get_line_marks(value) -> list:
-    """
-    If possible, return a preprocessor directive to identify the line number where the given id was defined.
-    :param id: The id in question
-    :return: A list containing zero or more line directives
-    """
-    path = None
-    if isinstance(value, ESPHomeDataBase):
-        path = value.esp_range
-    elif isinstance(value, ID) and isinstance(CORE.config, Config):
-        path = CORE.config.get_path_for_id(value)[:-1]
-        path = CORE.config.get_deepest_document_range_for_path(path)
-    if path is None:
-        return []
-    return [path.start_mark.as_line_directive]
-
-
 def requires_component(comp):
     def validator(value):
         lvgl_components_required.add(comp)
diff --git a/esphome/components/lvgl/img.py b/esphome/components/lvgl/img.py
new file mode 100644
index 0000000000..e9682def8c
--- /dev/null
+++ b/esphome/components/lvgl/img.py
@@ -0,0 +1,85 @@
+import esphome.config_validation as cv
+from esphome.const import CONF_ANGLE, CONF_MODE
+
+from .defines import (
+    CONF_ANTIALIAS,
+    CONF_MAIN,
+    CONF_OFFSET_X,
+    CONF_OFFSET_Y,
+    CONF_PIVOT_X,
+    CONF_PIVOT_Y,
+    CONF_SRC,
+    CONF_ZOOM,
+    LvConstant,
+)
+from .label import CONF_LABEL
+from .lv_validation import angle, lv_bool, lv_image, size, zoom
+from .lvcode import lv
+from .types import lv_img_t
+from .widget import Widget, WidgetType
+
+CONF_IMAGE = "image"
+
+BASE_IMG_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_PIVOT_X, default="50%"): size,
+        cv.Optional(CONF_PIVOT_Y, default="50%"): size,
+        cv.Optional(CONF_ANGLE): angle,
+        cv.Optional(CONF_ZOOM): zoom,
+        cv.Optional(CONF_OFFSET_X): size,
+        cv.Optional(CONF_OFFSET_Y): size,
+        cv.Optional(CONF_ANTIALIAS): lv_bool,
+        cv.Optional(CONF_MODE): LvConstant(
+            "LV_IMG_SIZE_MODE_", "VIRTUAL", "REAL"
+        ).one_of,
+    }
+)
+
+IMG_SCHEMA = BASE_IMG_SCHEMA.extend(
+    {
+        cv.Required(CONF_SRC): lv_image,
+    }
+)
+
+IMG_MODIFY_SCHEMA = BASE_IMG_SCHEMA.extend(
+    {
+        cv.Optional(CONF_SRC): lv_image,
+    }
+)
+
+
+class ImgType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_IMAGE,
+            lv_img_t,
+            (CONF_MAIN,),
+            IMG_SCHEMA,
+            IMG_MODIFY_SCHEMA,
+            lv_name="img",
+        )
+
+    def get_uses(self):
+        return "img", CONF_LABEL
+
+    async def to_code(self, w: Widget, config):
+        if src := config.get(CONF_SRC):
+            lv.img_set_src(w.obj, await lv_image.process(src))
+        if cf_angle := config.get(CONF_ANGLE):
+            pivot_x = config[CONF_PIVOT_X]
+            pivot_y = config[CONF_PIVOT_Y]
+            lv.img_set_pivot(w.obj, pivot_x, pivot_y)
+            lv.img_set_angle(w.obj, cf_angle)
+        if img_zoom := config.get(CONF_ZOOM):
+            lv.img_set_zoom(w.obj, img_zoom)
+        if offset := config.get(CONF_OFFSET_X):
+            lv.img_set_offset_x(w.obj, offset)
+        if offset := config.get(CONF_OFFSET_Y):
+            lv.img_set_offset_y(w.obj, offset)
+        if CONF_ANTIALIAS in config:
+            lv.img_set_antialias(w.obj, config[CONF_ANTIALIAS])
+        if mode := config.get(CONF_MODE):
+            lv.img_set_mode(w.obj, mode)
+
+
+img_spec = ImgType()
diff --git a/esphome/components/lvgl/label.py b/esphome/components/lvgl/label.py
index 0498f39474..6c3e1f4a00 100644
--- a/esphome/components/lvgl/label.py
+++ b/esphome/components/lvgl/label.py
@@ -1,7 +1,6 @@
 import esphome.config_validation as cv
 
 from .defines import (
-    CONF_LABEL,
     CONF_LONG_MODE,
     CONF_MAIN,
     CONF_RECOLOR,
@@ -15,6 +14,8 @@ from .schemas import TEXT_SCHEMA
 from .types import LvText, WidgetType
 from .widget import Widget
 
+CONF_LABEL = "label"
+
 
 class LabelType(WidgetType):
     def __init__(self):
@@ -33,9 +34,9 @@ class LabelType(WidgetType):
     async def to_code(self, w: Widget, config):
         """For a text object, create and set text"""
         if value := config.get(CONF_TEXT):
-            w.set_property(CONF_TEXT, await lv_text.process(value))
-        w.set_property(CONF_LONG_MODE, config)
-        w.set_property(CONF_RECOLOR, config)
+            await w.set_property(CONF_TEXT, await lv_text.process(value))
+        await w.set_property(CONF_LONG_MODE, config)
+        await w.set_property(CONF_RECOLOR, config)
 
 
 label_spec = LabelType()
diff --git a/esphome/components/lvgl/led.py b/esphome/components/lvgl/led.py
new file mode 100644
index 0000000000..f920758efb
--- /dev/null
+++ b/esphome/components/lvgl/led.py
@@ -0,0 +1,29 @@
+import esphome.config_validation as cv
+from esphome.const import CONF_BRIGHTNESS, CONF_COLOR, CONF_LED
+
+from .defines import CONF_MAIN
+from .lv_validation import lv_brightness, lv_color
+from .lvcode import lv
+from .types import LvType
+from .widget import Widget, WidgetType
+
+LED_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_COLOR): lv_color,
+        cv.Optional(CONF_BRIGHTNESS): lv_brightness,
+    }
+)
+
+
+class LedType(WidgetType):
+    def __init__(self):
+        super().__init__(CONF_LED, LvType("lv_led_t"), (CONF_MAIN,), LED_SCHEMA)
+
+    async def to_code(self, w: Widget, config):
+        if color := config.get(CONF_COLOR):
+            lv.led_set_color(w.obj, await lv_color.process(color))
+        if brightness := config.get(CONF_BRIGHTNESS):
+            lv.led_set_brightness(w.obj, await lv_brightness.process(brightness))
+
+
+led_spec = LedType()
diff --git a/esphome/components/lvgl/line.py b/esphome/components/lvgl/line.py
new file mode 100644
index 0000000000..ab50832bbf
--- /dev/null
+++ b/esphome/components/lvgl/line.py
@@ -0,0 +1,51 @@
+import functools
+
+import esphome.codegen as cg
+import esphome.config_validation as cv
+
+from . import defines as df
+from .defines import CONF_MAIN, literal
+from .lvcode import lv
+from .types import LvType
+from .widget import Widget, WidgetType
+
+CONF_LINE = "line"
+CONF_POINTS = "points"
+CONF_POINT_LIST_ID = "point_list_id"
+
+lv_point_t = cg.global_ns.struct("lv_point_t")
+
+
+def point_list(il):
+    il = cv.string(il)
+    nl = il.replace(" ", "").split(",")
+    return [int(n) for n in nl]
+
+
+def cv_point_list(value):
+    if not isinstance(value, list):
+        raise cv.Invalid("List of points required")
+    values = [point_list(v) for v in value]
+    if not functools.reduce(lambda f, v: f and len(v) == 2, values, True):
+        raise cv.Invalid("Points must be a list of x,y integer pairs")
+    return values
+
+
+LINE_SCHEMA = {
+    cv.Required(df.CONF_POINTS): cv_point_list,
+    cv.GenerateID(CONF_POINT_LIST_ID): cv.declare_id(lv_point_t),
+}
+
+
+class LineType(WidgetType):
+    def __init__(self):
+        super().__init__(CONF_LINE, LvType("lv_line_t"), (CONF_MAIN,), LINE_SCHEMA)
+
+    async def to_code(self, w: Widget, config):
+        """For a line object, create and add the points"""
+        data = literal(config[CONF_POINTS])
+        points = cg.static_const_array(config[CONF_POINT_LIST_ID], data)
+        lv.line_set_points(w.obj, points, len(data))
+
+
+line_spec = LineType()
diff --git a/esphome/components/lvgl/lv_bar.py b/esphome/components/lvgl/lv_bar.py
new file mode 100644
index 0000000000..d5dcff0bf0
--- /dev/null
+++ b/esphome/components/lvgl/lv_bar.py
@@ -0,0 +1,53 @@
+import esphome.config_validation as cv
+from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_MODE, CONF_VALUE
+
+from .defines import BAR_MODES, CONF_ANIMATED, CONF_INDICATOR, CONF_MAIN, literal
+from .lv_validation import animated, get_start_value, lv_float
+from .lvcode import lv
+from .types import LvNumber, NumberType
+from .widget import Widget
+
+CONF_BAR = "bar"
+BAR_MODIFY_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+        cv.Optional(CONF_ANIMATED, default=True): animated,
+    }
+)
+
+BAR_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+        cv.Optional(CONF_MIN_VALUE, default=0): cv.int_,
+        cv.Optional(CONF_MAX_VALUE, default=100): cv.int_,
+        cv.Optional(CONF_MODE, default="NORMAL"): BAR_MODES.one_of,
+        cv.Optional(CONF_ANIMATED, default=True): animated,
+    }
+)
+
+
+class BarType(NumberType):
+    def __init__(self):
+        super().__init__(
+            CONF_BAR,
+            LvNumber("lv_bar_t"),
+            parts=(CONF_MAIN, CONF_INDICATOR),
+            schema=BAR_SCHEMA,
+            modify_schema=BAR_MODIFY_SCHEMA,
+        )
+
+    async def to_code(self, w: Widget, config):
+        var = w.obj
+        if CONF_MIN_VALUE in config:
+            lv.bar_set_range(var, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE])
+            lv.bar_set_mode(var, literal(config[CONF_MODE]))
+        value = await get_start_value(config)
+        if value is not None:
+            lv.bar_set_value(var, value, literal(config[CONF_ANIMATED]))
+
+    @property
+    def animated(self):
+        return True
+
+
+bar_spec = BarType()
diff --git a/esphome/components/lvgl/lv_switch.py b/esphome/components/lvgl/lv_switch.py
new file mode 100644
index 0000000000..5db2c2ce38
--- /dev/null
+++ b/esphome/components/lvgl/lv_switch.py
@@ -0,0 +1,20 @@
+from .defines import CONF_INDICATOR, CONF_KNOB, CONF_MAIN
+from .types import LvBoolean
+from .widget import WidgetType
+
+CONF_SWITCH = "switch"
+
+
+class SwitchType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_SWITCH,
+            LvBoolean("lv_switch_t"),
+            (CONF_MAIN, CONF_INDICATOR, CONF_KNOB),
+        )
+
+    async def to_code(self, w, config):
+        return []
+
+
+switch_spec = SwitchType()
diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py
index 818bde6aed..b351b84af6 100644
--- a/esphome/components/lvgl/lv_validation.py
+++ b/esphome/components/lvgl/lv_validation.py
@@ -1,3 +1,5 @@
+from typing import Union
+
 import esphome.codegen as cg
 from esphome.components.binary_sensor import BinarySensor
 from esphome.components.color import ColorStruct
@@ -6,7 +8,7 @@ from esphome.components.image import Image_
 from esphome.components.sensor import Sensor
 from esphome.components.text_sensor import TextSensor
 import esphome.config_validation as cv
-from esphome.const import CONF_ARGS, CONF_COLOR, CONF_FORMAT
+from esphome.const import CONF_ARGS, CONF_COLOR, CONF_FORMAT, CONF_VALUE
 from esphome.core import HexInt
 from esphome.cpp_generator import MockObj
 from esphome.cpp_types import uint32
@@ -14,7 +16,14 @@ from esphome.helpers import cpp_string_escape
 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
 
 from . import types as ty
-from .defines import LV_FONTS, ConstantLiteral, LValidator, LvConstant, literal
+from .defines import (
+    CONF_END_VALUE,
+    CONF_START_VALUE,
+    LV_FONTS,
+    LValidator,
+    LvConstant,
+    literal,
+)
 from .helpers import (
     esphome_fonts_used,
     lv_fonts_used,
@@ -60,6 +69,13 @@ def color_retmapper(value):
     return lv_expr.color_from(MockObj(value))
 
 
+def option_string(value):
+    value = cv.string(value).strip()
+    if value.find("\n") != -1:
+        raise cv.Invalid("Options strings must not contain newlines")
+    return value
+
+
 lv_color = LValidator(color, ty.lv_color_t, retmapper=color_retmapper)
 
 
@@ -156,6 +172,12 @@ lv_bool = LValidator(
 )
 
 
+def lv_pct(value: Union[int, float]):
+    if isinstance(value, float):
+        value = int(value * 100)
+    return literal(f"lv_pct({value})")
+
+
 def lvms_validator_(value):
     if value == "never":
         value = "2147483647ms"
@@ -189,13 +211,16 @@ class TextValidator(LValidator):
             args = [str(x) for x in value[CONF_ARGS]]
             arg_expr = cg.RawExpression(",".join(args))
             format_str = cpp_string_escape(value[CONF_FORMAT])
-            return f"str_sprintf({format_str}, {arg_expr}).c_str()"
+            return literal(f"str_sprintf({format_str}, {arg_expr}).c_str()")
         return await super().process(value, args)
 
 
 lv_text = TextValidator()
 lv_float = LValidator(cv.float_, cg.float_, Sensor, "get_state()")
 lv_int = LValidator(cv.int_, cg.int_, Sensor, "get_state()")
+lv_brightness = LValidator(
+    cv.percentage, cg.float_, Sensor, "get_state()", retmapper=lambda x: int(x * 255)
+)
 
 
 def is_lv_font(font):
@@ -222,8 +247,33 @@ class LvFont(LValidator):
 
     async def process(self, value, args=()):
         if is_lv_font(value):
-            return ConstantLiteral(f"&lv_font_{value}")
-        return ConstantLiteral(f"{value}_engine->get_lv_font()")
+            return literal(f"&lv_font_{value}")
+        return literal(f"{value}_engine->get_lv_font()")
 
 
 lv_font = LvFont()
+
+
+def animated(value):
+    if isinstance(value, bool):
+        value = "ON" if value else "OFF"
+    return LvConstant("LV_ANIM_", "OFF", "ON").one_of(value)
+
+
+def key_code(value):
+    value = cv.Any(cv.All(cv.string_strict, cv.Length(min=1, max=1)), cv.uint8_t)(value)
+    if isinstance(value, str):
+        return ord(value[0])
+    return value
+
+
+async def get_end_value(config):
+    return await lv_int.process(config.get(CONF_END_VALUE))
+
+
+async def get_start_value(config):
+    if CONF_START_VALUE in config:
+        value = config[CONF_START_VALUE]
+    else:
+        value = config.get(CONF_VALUE)
+    return await lv_int.process(value)
diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py
index 3a8a958f2e..f54a032de2 100644
--- a/esphome/components/lvgl/lvcode.py
+++ b/esphome/components/lvgl/lvcode.py
@@ -1,9 +1,9 @@
 import abc
-import logging
 from typing import Union
 
 from esphome import codegen as cg
-from esphome.core import ID, Lambda
+from esphome.config import Config
+from esphome.core import CORE, ID, Lambda
 from esphome.cpp_generator import (
     AssignmentExpression,
     CallExpression,
@@ -18,12 +18,47 @@ from esphome.cpp_generator import (
     VariableDeclarationExpression,
     statement,
 )
+from esphome.yaml_util import ESPHomeDataBase
 
-from .defines import ConstantLiteral
-from .helpers import get_line_marks
-from .types import lv_group_t
+from .defines import literal, lvgl_ns
 
-_LOGGER = logging.getLogger(__name__)
+LVGL_COMP = "lv_component"  # used as a lambda argument in lvgl_comp()
+
+# Argument tuple for use in lambdas
+LvglComponent = lvgl_ns.class_("LvglComponent", cg.PollingComponent)
+LVGL_COMP_ARG = [(LvglComponent.operator("ptr"), LVGL_COMP)]
+lv_event_t_ptr = cg.global_ns.namespace("lv_event_t").operator("ptr")
+EVENT_ARG = [(lv_event_t_ptr, "ev")]
+CUSTOM_EVENT = literal("lvgl::lv_custom_event")
+
+
+def get_line_marks(value) -> list:
+    """
+    If possible, return a preprocessor directive to identify the line number where the given id was defined.
+    :param value: The id or other token to get the line number for
+    :return: A list containing zero or more line directives
+    """
+    path = None
+    if isinstance(value, ESPHomeDataBase):
+        path = value.esp_range
+    elif isinstance(value, ID) and isinstance(CORE.config, Config):
+        path = CORE.config.get_path_for_id(value)[:-1]
+        path = CORE.config.get_deepest_document_range_for_path(path)
+    if path is None:
+        return []
+    return [path.start_mark.as_line_directive]
+
+
+class IndentedStatement(Statement):
+    def __init__(self, stmt: Statement, indent: int):
+        self.statement = stmt
+        self.indent = indent
+
+    def __str__(self):
+        result = " " * self.indent * 4 + str(self.statement).strip()
+        if not isinstance(self.statement, RawStatement):
+            result += ";"
+        return result
 
 
 class CodeContext(abc.ABC):
@@ -39,6 +74,16 @@ class CodeContext(abc.ABC):
     def add(self, expression: Union[Expression, Statement]):
         pass
 
+    @staticmethod
+    def start_block():
+        CodeContext.append(RawStatement("{"))
+        CodeContext.code_context.indent()
+
+    @staticmethod
+    def end_block():
+        CodeContext.code_context.detent()
+        CodeContext.append(RawStatement("}"))
+
     @staticmethod
     def append(expression: Union[Expression, Statement]):
         if CodeContext.code_context is not None:
@@ -47,14 +92,25 @@ class CodeContext(abc.ABC):
 
     def __init__(self):
         self.previous: Union[CodeContext | None] = None
+        self.indent_level = 0
 
-    def __enter__(self):
+    async def __aenter__(self):
         self.previous = CodeContext.code_context
         CodeContext.code_context = self
+        return self
 
-    def __exit__(self, *args):
+    async def __aexit__(self, *args):
         CodeContext.code_context = self.previous
 
+    def indent(self):
+        self.indent_level += 1
+
+    def detent(self):
+        self.indent_level -= 1
+
+    def indented_statement(self, stmt):
+        return IndentedStatement(stmt, self.indent_level)
+
 
 class MainContext(CodeContext):
     """
@@ -62,42 +118,7 @@ class MainContext(CodeContext):
     """
 
     def add(self, expression: Union[Expression, Statement]):
-        return cg.add(expression)
-
-
-class LvContext(CodeContext):
-    """
-    Code generation into the LVGL initialisation code (called in `setup()`)
-    """
-
-    lv_init_code: list["Statement"] = []
-
-    @staticmethod
-    def lv_add(expression: Union[Expression, Statement]):
-        if isinstance(expression, Expression):
-            expression = statement(expression)
-        if not isinstance(expression, Statement):
-            raise ValueError(
-                f"Add '{expression}' must be expression or statement, not {type(expression)}"
-            )
-        LvContext.lv_init_code.append(expression)
-        _LOGGER.debug("LV Adding: %s", expression)
-        return expression
-
-    @staticmethod
-    def get_code():
-        code = []
-        for exp in LvContext.lv_init_code:
-            text = str(statement(exp))
-            text = text.rstrip()
-            code.append(text)
-        return "\n".join(code) + "\n\n"
-
-    def add(self, expression: Union[Expression, Statement]):
-        return LvContext.lv_add(expression)
-
-    def set_style(self, prop):
-        return MockObj("lv_set_style_{prop}", "")
+        return cg.add(self.indented_statement(expression))
 
 
 class LambdaContext(CodeContext):
@@ -110,21 +131,23 @@ class LambdaContext(CodeContext):
         parameters: list[tuple[SafeExpType, str]] = None,
         return_type: SafeExpType = cg.void,
         capture: str = "",
+        where=None,
     ):
         super().__init__()
         self.code_list: list[Statement] = []
-        self.parameters = parameters
+        self.parameters = parameters or []
         self.return_type = return_type
         self.capture = capture
+        self.where = where
 
     def add(self, expression: Union[Expression, Statement]):
-        self.code_list.append(expression)
+        self.code_list.append(self.indented_statement(expression))
         return expression
 
     async def get_lambda(self) -> LambdaExpression:
         code_text = self.get_code()
         return await cg.process_lambda(
-            Lambda("\n".join(code_text) + "\n\n"),
+            Lambda("\n".join(code_text) + "\n"),
             self.parameters,
             capture=self.capture,
             return_type=self.return_type,
@@ -138,33 +161,59 @@ class LambdaContext(CodeContext):
             code_text.append(text)
         return code_text
 
-    def __enter__(self):
-        super().__enter__()
+    async def __aenter__(self):
+        await super().__aenter__()
+        add_line_marks(self.where)
         return self
 
 
+class LvContext(LambdaContext):
+    """
+    Code generation into the LVGL initialisation code (called in `setup()`)
+    """
+
+    def __init__(self, lv_component, args=None):
+        self.args = args or LVGL_COMP_ARG
+        super().__init__(parameters=self.args)
+        self.lv_component = lv_component
+
+    async def add_init_lambda(self):
+        cg.add(self.lv_component.add_init_lambda(await self.get_lambda()))
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        await super().__aexit__(exc_type, exc_val, exc_tb)
+        await self.add_init_lambda()
+
+    def add(self, expression: Union[Expression, Statement]):
+        self.code_list.append(self.indented_statement(expression))
+        return expression
+
+    def __call__(self, *args):
+        return self.add(*args)
+
+
 class LocalVariable(MockObj):
     """
     Create a local variable and enclose the code using it within a block.
     """
 
-    def __init__(self, name, type, modifier=None, rhs=None):
-        base = ID(name, True, type)
+    def __init__(self, name, type, rhs=None, modifier="*"):
+        base = ID(name + "_VAR_", True, type)
         super().__init__(base, "")
         self.modifier = modifier
         self.rhs = rhs
 
     def __enter__(self):
-        CodeContext.append(RawStatement("{"))
+        CodeContext.start_block()
         CodeContext.append(
             VariableDeclarationExpression(self.base.type, self.modifier, self.base.id)
         )
         if self.rhs is not None:
             CodeContext.append(AssignmentExpression(None, "", self.base, self.rhs))
-        return self.base
+        return MockObj(self.base)
 
     def __exit__(self, *args):
-        CodeContext.append(RawStatement("}"))
+        CodeContext.end_block()
 
 
 class MockLv:
@@ -199,14 +248,27 @@ class MockLv:
         self.append(result)
         return result
 
-    def cond_if(self, expression: Expression):
-        CodeContext.append(RawStatement(f"if {expression} {{"))
 
-    def cond_else(self):
+class LvConditional:
+    def __init__(self, condition):
+        self.condition = condition
+
+    def __enter__(self):
+        if self.condition is not None:
+            CodeContext.append(RawStatement(f"if ({self.condition}) {{"))
+            CodeContext.code_context.indent()
+        return self
+
+    def __exit__(self, *args):
+        if self.condition is not None:
+            CodeContext.code_context.detent()
+            CodeContext.append(RawStatement("}"))
+
+    def else_(self):
+        assert self.condition is not None
+        CodeContext.code_context.detent()
         CodeContext.append(RawStatement("} else {"))
-
-    def cond_endif(self):
-        CodeContext.append(RawStatement("}"))
+        CodeContext.code_context.indent()
 
 
 class ReturnStatement(ExpressionStatement):
@@ -228,36 +290,56 @@ lv = MockLv("lv_")
 lv_expr = LvExpr("lv_")
 # Mock for lv_obj_ calls
 lv_obj = MockLv("lv_obj_")
-lvgl_comp = MockObj("lvgl_comp", "->")
+# Operations on the LVGL component
+lvgl_comp = MockObj(LVGL_COMP, "->")
 
 
-# equivalent to cg.add() for the lvgl init context
+# equivalent to cg.add() for the current code context
 def lv_add(expression: Union[Expression, Statement]):
     return CodeContext.append(expression)
 
 
 def add_line_marks(where):
+    """
+    Add line marks for the current code context
+    :param where: An object to identify the source of the line marks
+    :return:
+    """
     for mark in get_line_marks(where):
         lv_add(cg.RawStatement(mark))
 
 
 def lv_assign(target, expression):
-    lv_add(RawExpression(f"{target} = {expression}"))
+    lv_add(AssignmentExpression("", "", target, expression))
 
 
-lv_groups = {}  # Widget group names
+def lv_Pvariable(type, name):
+    """
+    Create but do not initialise a pointer variable
+    :param type: Type of the variable target
+    :param name: name of the variable, or an ID
+    :return:  A MockObj of the variable
+    """
+    if isinstance(name, str):
+        name = ID(name, True, type)
+    decl = VariableDeclarationExpression(type, "*", name)
+    CORE.add_global(decl)
+    var = MockObj(name, "->")
+    CORE.register_variable(name, var)
+    return var
 
 
-def add_group(name):
-    if name is None:
-        return None
-    fullname = f"lv_esp_group_{name}"
-    if name not in lv_groups:
-        gid = ID(fullname, True, type=lv_group_t.operator("ptr"))
-        lv_add(
-            AssignmentExpression(
-                type_=gid.type, modifier="", name=fullname, rhs=lv_expr.group_create()
-            )
-        )
-        lv_groups[name] = ConstantLiteral(fullname)
-    return lv_groups[name]
+def lv_variable(type, name):
+    """
+    Create but do not initialise a variable
+    :param type: Type of the variable target
+    :param name: name of the variable, or an ID
+    :return:  A MockObj of the variable
+    """
+    if isinstance(name, str):
+        name = ID(name, True, type)
+    decl = VariableDeclarationExpression(type, "", name)
+    CORE.add_global(decl)
+    var = MockObj(name, ".")
+    CORE.register_variable(name, var)
+    return var
diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp
index 34f8eaf21f..1221682d28 100644
--- a/esphome/components/lvgl/lvgl_esphome.cpp
+++ b/esphome/components/lvgl/lvgl_esphome.cpp
@@ -9,8 +9,72 @@ namespace esphome {
 namespace lvgl {
 static const char *const TAG = "lvgl";
 
+#if LV_USE_LOG
+static void log_cb(const char *buf) {
+  esp_log_printf_(ESPHOME_LOG_LEVEL_INFO, TAG, 0, "%.*s", (int) strlen(buf) - 1, buf);
+}
+#endif  // LV_USE_LOG
+
+static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) {
+  // make sure all coordinates are even
+  if (area->x1 & 1)
+    area->x1--;
+  if (!(area->x2 & 1))
+    area->x2++;
+  if (area->y1 & 1)
+    area->y1--;
+  if (!(area->y2 & 1))
+    area->y2++;
+}
+
 lv_event_code_t lv_custom_event;  // NOLINT
 void LvglComponent::dump_config() { ESP_LOGCONFIG(TAG, "LVGL:"); }
+void LvglComponent::set_paused(bool paused, bool show_snow) {
+  this->paused_ = paused;
+  this->show_snow_ = show_snow;
+  this->snow_line_ = 0;
+  if (!paused && lv_scr_act() != nullptr) {
+    lv_disp_trig_activity(this->disp_);  // resets the inactivity time
+    lv_obj_invalidate(lv_scr_act());
+  }
+}
+void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) {
+  lv_obj_add_event_cb(obj, callback, event, this);
+  if (event == LV_EVENT_VALUE_CHANGED) {
+    lv_obj_add_event_cb(obj, callback, lv_custom_event, this);
+  }
+}
+void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
+                                 lv_event_code_t event2) {
+  this->add_event_cb(obj, callback, event1);
+  this->add_event_cb(obj, callback, event2);
+}
+void LvglComponent::add_page(LvPageType *page) {
+  this->pages_.push_back(page);
+  page->setup(this->pages_.size() - 1);
+}
+void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time) {
+  if (index >= this->pages_.size())
+    return;
+  this->current_page_ = index;
+  lv_scr_load_anim(this->pages_[this->current_page_]->obj, anim, time, 0, false);
+}
+void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) {
+  if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_))
+    return;
+  do {
+    this->current_page_ = (this->current_page_ + 1) % this->pages_.size();
+  } while (this->pages_[this->current_page_]->skip);  // skip empty pages()
+  this->show_page(this->current_page_, anim, time);
+}
+void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) {
+  if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_))
+    return;
+  do {
+    this->current_page_ = (this->current_page_ + this->pages_.size() - 1) % this->pages_.size();
+  } while (this->pages_[this->current_page_]->skip);  // skip empty pages()
+  this->show_page(this->current_page_, anim, time);
+}
 void LvglComponent::draw_buffer_(const lv_area_t *area, const uint8_t *ptr) {
   for (auto *display : this->displays_) {
     display->draw_pixels_at(area->x1, area->y1, lv_area_get_width(area), lv_area_get_height(area), ptr,
@@ -27,6 +91,116 @@ void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv
   }
   lv_disp_flush_ready(disp_drv);
 }
+IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue<uint32_t> timeout) : timeout_(std::move(timeout)) {
+  parent->add_on_idle_callback([this](uint32_t idle_time) {
+    if (!this->is_idle_ && idle_time > this->timeout_.value()) {
+      this->is_idle_ = true;
+      this->trigger();
+    } else if (this->is_idle_ && idle_time < this->timeout_.value()) {
+      this->is_idle_ = false;
+    }
+  });
+}
+
+#ifdef USE_LVGL_TOUCHSCREEN
+LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time) {
+  lv_indev_drv_init(&this->drv_);
+  this->drv_.long_press_repeat_time = long_press_repeat_time;
+  this->drv_.long_press_time = long_press_time;
+  this->drv_.type = LV_INDEV_TYPE_POINTER;
+  this->drv_.user_data = this;
+  this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) {
+    auto *l = static_cast<LVTouchListener *>(d->user_data);
+    if (l->touch_pressed_) {
+      data->point.x = l->touch_point_.x;
+      data->point.y = l->touch_point_.y;
+      data->state = LV_INDEV_STATE_PRESSED;
+    } else {
+      data->state = LV_INDEV_STATE_RELEASED;
+    }
+  };
+}
+void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) {
+  this->touch_pressed_ = !this->parent_->is_paused() && !tpoints.empty();
+  if (this->touch_pressed_)
+    this->touch_point_ = tpoints[0];
+}
+#endif  // USE_LVGL_TOUCHSCREEN
+
+#ifdef USE_LVGL_ROTARY_ENCODER
+LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t lpt, uint16_t lprt) {
+  lv_indev_drv_init(&this->drv_);
+  this->drv_.type = type;
+  this->drv_.user_data = this;
+  this->drv_.long_press_time = lpt;
+  this->drv_.long_press_repeat_time = lprt;
+  this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) {
+    auto *l = static_cast<LVEncoderListener *>(d->user_data);
+    data->state = l->pressed_ ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
+    data->key = l->key_;
+    data->enc_diff = (int16_t) (l->count_ - l->last_count_);
+    l->last_count_ = l->count_;
+    data->continue_reading = false;
+  };
+}
+#endif  // USE_LVGL_ROTARY_ENCODER
+
+#ifdef USE_LVGL_BUTTONMATRIX
+void LvBtnmatrixType::set_obj(lv_obj_t *lv_obj) {
+  LvCompound::set_obj(lv_obj);
+  lv_obj_add_event_cb(
+      lv_obj,
+      [](lv_event_t *event) {
+        auto *self = static_cast<LvBtnmatrixType *>(event->user_data);
+        if (self->key_callback_.size() == 0)
+          return;
+        auto key_idx = lv_btnmatrix_get_selected_btn(self->obj);
+        if (key_idx == LV_BTNMATRIX_BTN_NONE)
+          return;
+        if (self->key_map_.count(key_idx) != 0) {
+          self->send_key_(self->key_map_[key_idx]);
+          return;
+        }
+        const auto *str = lv_btnmatrix_get_btn_text(self->obj, key_idx);
+        auto len = strlen(str);
+        while (len--)
+          self->send_key_(*str++);
+      },
+      LV_EVENT_PRESSED, this);
+}
+#endif  // USE_LVGL_BUTTONMATRIX
+
+#ifdef USE_LVGL_KEYBOARD
+static const char *const KB_SPECIAL_KEYS[] = {
+    "abc", "ABC", "1#",
+    // maybe add other special keys here
+};
+
+void LvKeyboardType::set_obj(lv_obj_t *lv_obj) {
+  LvCompound::set_obj(lv_obj);
+  lv_obj_add_event_cb(
+      lv_obj,
+      [](lv_event_t *event) {
+        auto *self = static_cast<LvKeyboardType *>(event->user_data);
+        if (self->key_callback_.size() == 0)
+          return;
+
+        auto key_idx = lv_btnmatrix_get_selected_btn(self->obj);
+        if (key_idx == LV_BTNMATRIX_BTN_NONE)
+          return;
+        const char *txt = lv_btnmatrix_get_btn_text(self->obj, key_idx);
+        if (txt == nullptr)
+          return;
+        for (const auto *kb_special_key : KB_SPECIAL_KEYS) {
+          if (strcmp(txt, kb_special_key) == 0)
+            return;
+        }
+        while (*txt != 0)
+          self->send_key_(*txt++);
+      },
+      LV_EVENT_PRESSED, this);
+}
+#endif  // USE_LVGL_KEYBOARD
 
 void LvglComponent::write_random_() {
   // length of 2 lines in 32 bit units
@@ -97,9 +271,24 @@ void LvglComponent::setup() {
   this->disp_ = lv_disp_drv_register(&this->disp_drv_);
   for (const auto &v : this->init_lambdas_)
     v(this);
+  this->show_page(0, LV_SCR_LOAD_ANIM_NONE, 0);
   lv_disp_trig_activity(this->disp_);
   ESP_LOGCONFIG(TAG, "LVGL Setup complete");
 }
+void LvglComponent::update() {
+  // update indicators
+  if (this->paused_) {
+    return;
+  }
+  this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_));
+}
+void LvglComponent::loop() {
+  if (this->paused_) {
+    if (this->show_snow_)
+      this->write_random_();
+  }
+  lv_timer_handler_run_in_period(5);
+}
 
 #ifdef USE_LVGL_IMAGE
 lv_img_dsc_t *lv_img_from(image::Image *src, lv_img_dsc_t *img_dsc) {
@@ -142,7 +331,20 @@ lv_img_dsc_t *lv_img_from(image::Image *src, lv_img_dsc_t *img_dsc) {
   }
   return img_dsc;
 }
+#endif  // USE_LVGL_IMAGE
+
+#ifdef USE_LVGL_ANIMIMG
+void lv_animimg_stop(lv_obj_t *obj) {
+  auto *animg = (lv_animimg_t *) obj;
+  int32_t duration = animg->anim.time;
+  lv_animimg_set_duration(obj, 0);
+  lv_animimg_start(obj);
+  lv_animimg_set_duration(obj, duration);
+}
 #endif
+void LvglComponent::static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
+  reinterpret_cast<LvglComponent *>(disp_drv->user_data)->flush_cb_(disp_drv, area, color_p);
+}
 }  // namespace lvgl
 }  // namespace esphome
 
diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h
index a0d3d226ce..b92799addd 100644
--- a/esphome/components/lvgl/lvgl_esphome.h
+++ b/esphome/components/lvgl/lvgl_esphome.h
@@ -18,7 +18,6 @@
 #include "esphome/core/component.h"
 #include "esphome/core/log.h"
 #include <lvgl.h>
-#include <utility>
 #include <vector>
 #ifdef USE_LVGL_IMAGE
 #include "esphome/components/image/image.h"
@@ -31,6 +30,10 @@
 #include "esphome/components/touchscreen/touchscreen.h"
 #endif  // USE_LVGL_TOUCHSCREEN
 
+#if defined(USE_LVGL_BUTTONMATRIX) || defined(USE_LVGL_KEYBOARD)
+#include "esphome/components/key_provider/key_provider.h"
+#endif  // USE_LVGL_BUTTONMATRIX
+
 namespace esphome {
 namespace lvgl {
 
@@ -47,12 +50,25 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT
 #endif  // LV_COLOR_DEPTH
 
 // Parent class for things that wrap an LVGL object
-class LvCompound final {
+class LvCompound {
  public:
-  void set_obj(lv_obj_t *lv_obj) { this->obj = lv_obj; }
+  virtual void set_obj(lv_obj_t *lv_obj) { this->obj = lv_obj; }
   lv_obj_t *obj{};
 };
 
+class LvPageType {
+ public:
+  LvPageType(bool skip) : skip(skip) {}
+
+  void setup(size_t index) {
+    this->index = index;
+    this->obj = lv_obj_create(nullptr);
+  }
+  lv_obj_t *obj{};
+  size_t index{};
+  bool skip;
+};
+
 using LvLambdaType = std::function<void(lv_obj_t *)>;
 using set_value_lambda_t = std::function<void(float)>;
 using event_callback_t = void(_lv_event_t *);
@@ -89,48 +105,20 @@ class FontEngine {
 lv_img_dsc_t *lv_img_from(image::Image *src, lv_img_dsc_t *img_dsc = nullptr);
 #endif  // USE_LVGL_IMAGE
 
+#ifdef USE_LVGL_ANIMIMG
+void lv_animimg_stop(lv_obj_t *obj);
+#endif  // USE_LVGL_ANIMIMG
+
 class LvglComponent : public PollingComponent {
   constexpr static const char *const TAG = "lvgl";
 
  public:
-  static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
-    reinterpret_cast<LvglComponent *>(disp_drv->user_data)->flush_cb_(disp_drv, area, color_p);
-  }
+  static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p);
 
   float get_setup_priority() const override { return setup_priority::PROCESSOR; }
-  static void log_cb(const char *buf) {
-    esp_log_printf_(ESPHOME_LOG_LEVEL_INFO, TAG, 0, "%.*s", (int) strlen(buf) - 1, buf);
-  }
-  static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) {
-    // make sure all coordinates are even
-    if (area->x1 & 1)
-      area->x1--;
-    if (!(area->x2 & 1))
-      area->x2++;
-    if (area->y1 & 1)
-      area->y1--;
-    if (!(area->y2 & 1))
-      area->y2++;
-  }
-
   void setup() override;
-
-  void update() override {
-    // update indicators
-    if (this->paused_) {
-      return;
-    }
-    this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_));
-  }
-
-  void loop() override {
-    if (this->paused_) {
-      if (this->show_snow_)
-        this->write_random_();
-    }
-    lv_timer_handler_run_in_period(5);
-  }
-
+  void update() override;
+  void loop() override;
   void add_on_idle_callback(std::function<void(uint32_t)> &&callback) {
     this->idle_callbacks_.add(std::move(callback));
   }
@@ -141,23 +129,15 @@ class LvglComponent : public PollingComponent {
   bool is_idle(uint32_t idle_ms) { return lv_disp_get_inactive_time(this->disp_) > idle_ms; }
   void set_buffer_frac(size_t frac) { this->buffer_frac_ = frac; }
   lv_disp_t *get_disp() { return this->disp_; }
-  void set_paused(bool paused, bool show_snow) {
-    this->paused_ = paused;
-    this->show_snow_ = show_snow;
-    this->snow_line_ = 0;
-    if (!paused && lv_scr_act() != nullptr) {
-      lv_disp_trig_activity(this->disp_);  // resets the inactivity time
-      lv_obj_invalidate(lv_scr_act());
-    }
-  }
-
-  void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) {
-    lv_obj_add_event_cb(obj, callback, event, this);
-    if (event == LV_EVENT_VALUE_CHANGED) {
-      lv_obj_add_event_cb(obj, callback, lv_custom_event, this);
-    }
-  }
+  void set_paused(bool paused, bool show_snow);
+  void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event);
+  void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2);
   bool is_paused() const { return this->paused_; }
+  void add_page(LvPageType *page);
+  void show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time);
+  void show_next_page(lv_scr_load_anim_t anim, uint32_t time);
+  void show_prev_page(lv_scr_load_anim_t anim, uint32_t time);
+  void set_page_wrap(bool wrap) { this->page_wrap_ = wrap; }
 
  protected:
   void write_random_();
@@ -168,8 +148,11 @@ class LvglComponent : public PollingComponent {
   lv_disp_drv_t disp_drv_{};
   lv_disp_t *disp_{};
   bool paused_{};
+  std::vector<LvPageType *> pages_{};
+  size_t current_page_{0};
   bool show_snow_{};
   lv_coord_t snow_line_{};
+  bool page_wrap_{true};
 
   std::vector<std::function<void(LvglComponent *lv_component)>> init_lambdas_;
   CallbackManager<void(uint32_t)> idle_callbacks_{};
@@ -179,16 +162,7 @@ class LvglComponent : public PollingComponent {
 
 class IdleTrigger : public Trigger<> {
  public:
-  explicit IdleTrigger(LvglComponent *parent, TemplatableValue<uint32_t> timeout) : timeout_(std::move(timeout)) {
-    parent->add_on_idle_callback([this](uint32_t idle_time) {
-      if (!this->is_idle_ && idle_time > this->timeout_.value()) {
-        this->is_idle_ = true;
-        this->trigger();
-      } else if (this->is_idle_ && idle_time < this->timeout_.value()) {
-        this->is_idle_ = false;
-      }
-    });
-  }
+  explicit IdleTrigger(LvglComponent *parent, TemplatableValue<uint32_t> timeout);
 
  protected:
   TemplatableValue<uint32_t> timeout_;
@@ -217,28 +191,8 @@ template<typename... Ts> class LvglCondition : public Condition<Ts...>, public P
 #ifdef USE_LVGL_TOUCHSCREEN
 class LVTouchListener : public touchscreen::TouchListener, public Parented<LvglComponent> {
  public:
-  LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time) {
-    lv_indev_drv_init(&this->drv_);
-    this->drv_.long_press_repeat_time = long_press_repeat_time;
-    this->drv_.long_press_time = long_press_time;
-    this->drv_.type = LV_INDEV_TYPE_POINTER;
-    this->drv_.user_data = this;
-    this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) {
-      auto *l = static_cast<LVTouchListener *>(d->user_data);
-      if (l->touch_pressed_) {
-        data->point.x = l->touch_point_.x;
-        data->point.y = l->touch_point_.y;
-        data->state = LV_INDEV_STATE_PRESSED;
-      } else {
-        data->state = LV_INDEV_STATE_RELEASED;
-      }
-    };
-  }
-  void update(const touchscreen::TouchPoints_t &tpoints) override {
-    this->touch_pressed_ = !this->parent_->is_paused() && !tpoints.empty();
-    if (this->touch_pressed_)
-      this->touch_point_ = tpoints[0];
-  }
+  LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time);
+  void update(const touchscreen::TouchPoints_t &tpoints) override;
   void release() override { touch_pressed_ = false; }
   lv_indev_drv_t *get_drv() { return &this->drv_; }
 
@@ -249,24 +203,10 @@ class LVTouchListener : public touchscreen::TouchListener, public Parented<LvglC
 };
 #endif  // USE_LVGL_TOUCHSCREEN
 
-#ifdef USE_LVGL_KEY_LISTENER
+#ifdef USE_LVGL_ROTARY_ENCODER
 class LVEncoderListener : public Parented<LvglComponent> {
  public:
-  LVEncoderListener(lv_indev_type_t type, uint16_t lpt, uint16_t lprt) {
-    lv_indev_drv_init(&this->drv_);
-    this->drv_.type = type;
-    this->drv_.user_data = this;
-    this->drv_.long_press_time = lpt;
-    this->drv_.long_press_repeat_time = lprt;
-    this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) {
-      auto *l = static_cast<LVEncoderListener *>(d->user_data);
-      data->state = l->pressed_ ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
-      data->key = l->key_;
-      data->enc_diff = (int16_t) (l->count_ - l->last_count_);
-      l->last_count_ = l->count_;
-      data->continue_reading = false;
-    };
-  }
+  LVEncoderListener(lv_indev_type_t type, uint16_t lpt, uint16_t lprt);
 
   void set_left_button(binary_sensor::BinarySensor *left_button) {
     left_button->add_on_state_callback([this](bool state) { this->event(LV_KEY_LEFT, state); });
@@ -304,6 +244,24 @@ class LVEncoderListener : public Parented<LvglComponent> {
   int32_t last_count_{};
   int key_{};
 };
-#endif  // USE_LVGL_KEY_LISTENER
+#endif  // USE_LVGL_ROTARY_ENCODER
+#ifdef USE_LVGL_BUTTONMATRIX
+class LvBtnmatrixType : public key_provider::KeyProvider, public LvCompound {
+ public:
+  void set_obj(lv_obj_t *lv_obj) override;
+  uint16_t get_selected() { return lv_btnmatrix_get_selected_btn(this->obj); }
+  void set_key(size_t idx, uint8_t key) { this->key_map_[idx] = key; }
+
+ protected:
+  std::map<size_t, uint8_t> key_map_{};
+};
+#endif  // USE_LVGL_BUTTONMATRIX
+
+#ifdef USE_LVGL_KEYBOARD
+class LvKeyboardType : public key_provider::KeyProvider, public LvCompound {
+ public:
+  void set_obj(lv_obj_t *lv_obj) override;
+};
+#endif  // USE_LVGL_KEYBOARD
 }  // namespace lvgl
 }  // namespace esphome
diff --git a/esphome/components/lvgl/page.py b/esphome/components/lvgl/page.py
new file mode 100644
index 0000000000..4566b7eea4
--- /dev/null
+++ b/esphome/components/lvgl/page.py
@@ -0,0 +1,113 @@
+from esphome import automation, codegen as cg
+import esphome.config_validation as cv
+from esphome.const import CONF_ID, CONF_PAGES, CONF_TIME
+
+from .defines import (
+    CONF_ANIMATION,
+    CONF_LVGL_ID,
+    CONF_PAGE,
+    CONF_PAGE_WRAP,
+    CONF_SKIP,
+    LV_ANIM,
+)
+from .lv_validation import lv_bool, lv_milliseconds
+from .lvcode import LVGL_COMP_ARG, LambdaContext, add_line_marks, lv_add, lvgl_comp
+from .schemas import LVGL_SCHEMA
+from .types import LvglAction, lv_page_t
+from .widget import Widget, WidgetType, add_widgets, set_obj_properties
+
+
+class PageType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_PAGE,
+            lv_page_t,
+            (),
+            {
+                cv.Optional(CONF_SKIP, default=False): lv_bool,
+            },
+        )
+
+    async def to_code(self, w: Widget, config: dict):
+        return []
+
+
+SHOW_SCHEMA = LVGL_SCHEMA.extend(
+    {
+        cv.Optional(CONF_ANIMATION, default="NONE"): LV_ANIM.one_of,
+        cv.Optional(CONF_TIME, default="50ms"): lv_milliseconds,
+    }
+)
+
+
+page_spec = PageType()
+
+
+@automation.register_action(
+    "lvgl.page.next",
+    LvglAction,
+    SHOW_SCHEMA,
+)
+async def page_next_to_code(config, action_id, template_arg, args):
+    animation = await LV_ANIM.process(config[CONF_ANIMATION])
+    time = await lv_milliseconds.process(config[CONF_TIME])
+    async with LambdaContext(LVGL_COMP_ARG) as context:
+        add_line_marks(action_id)
+        lv_add(lvgl_comp.show_next_page(animation, time))
+    var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+    await cg.register_parented(var, config[CONF_LVGL_ID])
+    return var
+
+
+@automation.register_action(
+    "lvgl.page.previous",
+    LvglAction,
+    SHOW_SCHEMA,
+)
+async def page_previous_to_code(config, action_id, template_arg, args):
+    animation = await LV_ANIM.process(config[CONF_ANIMATION])
+    time = await lv_milliseconds.process(config[CONF_TIME])
+    async with LambdaContext(LVGL_COMP_ARG) as context:
+        add_line_marks(action_id)
+        lv_add(lvgl_comp.show_prev_page(animation, time))
+    var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+    await cg.register_parented(var, config[CONF_LVGL_ID])
+    return var
+
+
+@automation.register_action(
+    "lvgl.page.show",
+    LvglAction,
+    cv.maybe_simple_value(
+        SHOW_SCHEMA.extend(
+            {
+                cv.Required(CONF_ID): cv.use_id(lv_page_t),
+            }
+        ),
+        key=CONF_ID,
+    ),
+)
+async def page_show_to_code(config, action_id, template_arg, args):
+    widget = await cg.get_variable(config[CONF_ID])
+    animation = await LV_ANIM.process(config[CONF_ANIMATION])
+    time = await lv_milliseconds.process(config[CONF_TIME])
+    async with LambdaContext(LVGL_COMP_ARG) as context:
+        add_line_marks(action_id)
+        lv_add(lvgl_comp.show_page(widget.index, animation, time))
+    var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
+    await cg.register_parented(var, config[CONF_LVGL_ID])
+    return var
+
+
+async def add_pages(lv_component, config):
+    lv_add(lv_component.set_page_wrap(config[CONF_PAGE_WRAP]))
+    for pconf in config.get(CONF_PAGES, ()):
+        id = pconf[CONF_ID]
+        skip = pconf[CONF_SKIP]
+        var = cg.new_Pvariable(id, skip)
+        page = Widget.create(id, var, page_spec, pconf)
+        lv_add(lv_component.add_page(var))
+        # Set outer config first
+        await set_obj_properties(page, config)
+        await set_obj_properties(page, pconf)
+        await add_widgets(page, pconf)
diff --git a/esphome/components/lvgl/rotary_encoders.py b/esphome/components/lvgl/rotary_encoders.py
index 77dc397c3e..ede6905a67 100644
--- a/esphome/components/lvgl/rotary_encoders.py
+++ b/esphome/components/lvgl/rotary_encoders.py
@@ -13,9 +13,10 @@ from .defines import (
     CONF_ROTARY_ENCODERS,
 )
 from .helpers import lvgl_components_required
-from .lvcode import add_group, lv, lv_add, lv_expr
+from .lvcode import lv, lv_add, lv_expr
 from .schemas import ENCODER_SCHEMA
 from .types import lv_indev_type_t
+from .widget import add_group
 
 ROTARY_ENCODER_CONFIG = cv.ensure_list(
     ENCODER_SCHEMA.extend(
diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py
index ebef56a882..796783890d 100644
--- a/esphome/components/lvgl/schemas.py
+++ b/esphome/components/lvgl/schemas.py
@@ -15,8 +15,12 @@ from esphome.schema_extractors import SCHEMA_EXTRACT
 
 from . import defines as df, lv_validation as lvalid, types as ty
 from .helpers import add_lv_use, requires_component, validate_printf
-from .lv_validation import id_name, lv_font
-from .types import WIDGET_TYPES, WidgetType
+from .lv_validation import id_name, lv_color, lv_font, lv_image
+from .lvcode import LvglComponent
+from .types import WidgetType
+
+# this will be populated later, in __init__.py to avoid circular imports.
+WIDGET_TYPES: dict = {}
 
 # A schema for text properties
 TEXT_SCHEMA = cv.Schema(
@@ -38,11 +42,13 @@ TEXT_SCHEMA = cv.Schema(
     }
 )
 
-ACTION_SCHEMA = cv.maybe_simple_value(
-    {
-        cv.Required(CONF_ID): cv.use_id(ty.lv_pseudo_button_t),
-    },
-    key=CONF_ID,
+LIST_ACTION_SCHEMA = cv.ensure_list(
+    cv.maybe_simple_value(
+        {
+            cv.Required(CONF_ID): cv.use_id(ty.lv_pseudo_button_t),
+        },
+        key=CONF_ID,
+    )
 )
 
 PRESS_TIME = cv.All(
@@ -154,6 +160,7 @@ STYLE_REMAP = {
 # Complete object style schema
 STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend(
     {
+        cv.Optional(df.CONF_STYLES): cv.ensure_list(cv.use_id(ty.lv_style_t)),
         cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant(
             "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO"
         ).one_of,
@@ -209,7 +216,14 @@ def create_modify_schema(widget_type):
         part_schema(widget_type)
         .extend(
             {
-                cv.Required(CONF_ID): cv.use_id(widget_type),
+                cv.Required(CONF_ID): cv.ensure_list(
+                    cv.maybe_simple_value(
+                        {
+                            cv.Required(CONF_ID): cv.use_id(widget_type),
+                        },
+                        key=CONF_ID,
+                    )
+                ),
                 cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
             }
         )
@@ -227,6 +241,7 @@ def obj_schema(widget_type: WidgetType):
     return (
         part_schema(widget_type)
         .extend(FLAG_SCHEMA)
+        .extend(LAYOUT_SCHEMA)
         .extend(ALIGN_TO_SCHEMA)
         .extend(automation_schema(widget_type.w_type))
         .extend(
@@ -240,6 +255,8 @@ def obj_schema(widget_type: WidgetType):
     )
 
 
+LAYOUT_SCHEMAS = {}
+
 ALIGN_TO_SCHEMA = {
     cv.Optional(df.CONF_ALIGN_TO): cv.Schema(
         {
@@ -252,6 +269,65 @@ 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
+)
+
+cell_alignments = df.LV_CELL_ALIGNMENTS.one_of
+grid_alignments = df.LV_GRID_ALIGNMENTS.one_of
+flex_alignments = df.LV_FLEX_ALIGNMENTS.one_of
+
+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,
+            },
+            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,
+            },
+        },
+        lower=True,
+    )
+}
+
+GRID_CELL_SCHEMA = {
+    cv.Required(df.CONF_GRID_CELL_ROW_POS): cv.positive_int,
+    cv.Required(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): lv_image,
+        cv.Optional(df.CONF_DISP_BG_COLOR): lv_color,
+    }
+)
+
+
 # A style schema that can include text
 STYLED_TEXT_SCHEMA = cv.maybe_simple_value(
     STYLE_SCHEMA.extend(TEXT_SCHEMA), key=df.CONF_TEXT
@@ -260,13 +336,11 @@ STYLED_TEXT_SCHEMA = cv.maybe_simple_value(
 # For use by platform components
 LVGL_SCHEMA = cv.Schema(
     {
-        cv.GenerateID(df.CONF_LVGL_ID): cv.use_id(ty.LvglComponent),
+        cv.GenerateID(df.CONF_LVGL_ID): cv.use_id(LvglComponent),
     }
 )
 
-ALL_STYLES = {
-    **STYLE_PROPS,
-}
+ALL_STYLES = {**STYLE_PROPS, **GRID_CELL_SCHEMA, **FLEX_OBJ_SCHEMA}
 
 
 def container_validator(schema, widget_type: WidgetType):
@@ -281,16 +355,17 @@ def container_validator(schema, widget_type: WidgetType):
         result = schema
         if w_sch := widget_type.schema:
             result = result.extend(w_sch)
+        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)
-        result = result.extend(
-            {cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema())}
-        )
         if value == SCHEMA_EXTRACT:
             return result
+        result = result.extend(LAYOUT_SCHEMAS[ltype.lower()])
         return result(value)
 
     return validator
diff --git a/esphome/components/lvgl/slider.py b/esphome/components/lvgl/slider.py
new file mode 100644
index 0000000000..1886f79b44
--- /dev/null
+++ b/esphome/components/lvgl/slider.py
@@ -0,0 +1,63 @@
+import esphome.config_validation as cv
+from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_MODE, CONF_VALUE
+
+from .defines import (
+    BAR_MODES,
+    CONF_ANIMATED,
+    CONF_INDICATOR,
+    CONF_KNOB,
+    CONF_MAIN,
+    literal,
+)
+from .helpers import add_lv_use
+from .lv_bar import CONF_BAR
+from .lv_validation import animated, get_start_value, lv_float
+from .lvcode import lv
+from .types import LvNumber, NumberType
+from .widget import Widget
+
+CONF_SLIDER = "slider"
+SLIDER_MODIFY_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+        cv.Optional(CONF_ANIMATED, default=True): animated,
+    }
+)
+
+SLIDER_SCHEMA = cv.Schema(
+    {
+        cv.Optional(CONF_VALUE): lv_float,
+        cv.Optional(CONF_MIN_VALUE, default=0): cv.int_,
+        cv.Optional(CONF_MAX_VALUE, default=100): cv.int_,
+        cv.Optional(CONF_MODE, default="NORMAL"): BAR_MODES.one_of,
+        cv.Optional(CONF_ANIMATED, default=True): animated,
+    }
+)
+
+
+class SliderType(NumberType):
+    def __init__(self):
+        super().__init__(
+            CONF_SLIDER,
+            LvNumber("lv_slider_t"),
+            parts=(CONF_MAIN, CONF_INDICATOR, CONF_KNOB),
+            schema=SLIDER_SCHEMA,
+            modify_schema=SLIDER_MODIFY_SCHEMA,
+        )
+
+    @property
+    def animated(self):
+        return True
+
+    async def to_code(self, w: Widget, config):
+        add_lv_use(CONF_BAR)
+        if CONF_MIN_VALUE in config:
+            # not modify case
+            lv.slider_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE])
+            lv.slider_set_mode(w.obj, literal(config[CONF_MODE]))
+        value = await get_start_value(config)
+        if value is not None:
+            lv.slider_set_value(w.obj, value, literal(config[CONF_ANIMATED]))
+
+
+slider_spec = SliderType()
diff --git a/esphome/components/lvgl/spinner.py b/esphome/components/lvgl/spinner.py
new file mode 100644
index 0000000000..2f798d0fbf
--- /dev/null
+++ b/esphome/components/lvgl/spinner.py
@@ -0,0 +1,43 @@
+import esphome.config_validation as cv
+from esphome.cpp_generator import MockObjClass
+
+from .arc import CONF_ARC
+from .defines import CONF_ARC_LENGTH, CONF_INDICATOR, CONF_MAIN, CONF_SPIN_TIME
+from .lv_validation import angle
+from .lvcode import lv_expr
+from .types import LvType
+from .widget import Widget, WidgetType
+
+CONF_SPINNER = "spinner"
+
+SPINNER_SCHEMA = cv.Schema(
+    {
+        cv.Required(CONF_ARC_LENGTH): angle,
+        cv.Required(CONF_SPIN_TIME): cv.positive_time_period_milliseconds,
+    }
+)
+
+
+class SpinnerType(WidgetType):
+    def __init__(self):
+        super().__init__(
+            CONF_SPINNER,
+            LvType("lv_spinner_t"),
+            (CONF_MAIN, CONF_INDICATOR),
+            SPINNER_SCHEMA,
+            {},
+        )
+
+    async def to_code(self, w: Widget, config):
+        return []
+
+    def get_uses(self):
+        return (CONF_ARC,)
+
+    def obj_creator(self, parent: MockObjClass, config: dict):
+        spin_time = config[CONF_SPIN_TIME].total_milliseconds
+        arc_length = config[CONF_ARC_LENGTH] // 10
+        return lv_expr.call("spinner_create", parent, spin_time, arc_length)
+
+
+spinner_spec = SpinnerType()
diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py
new file mode 100644
index 0000000000..7a795bc99d
--- /dev/null
+++ b/esphome/components/lvgl/styles.py
@@ -0,0 +1,58 @@
+import esphome.codegen as cg
+from esphome.const import CONF_ID
+from esphome.core import ID
+from esphome.cpp_generator import MockObj
+
+from .defines import (
+    CONF_STYLE_DEFINITIONS,
+    CONF_THEME,
+    CONF_TOP_LAYER,
+    LValidator,
+    literal,
+)
+from .helpers import add_lv_use
+from .lvcode import LambdaContext, LocalVariable, lv, lv_assign, lv_variable
+from .obj import obj_spec
+from .schemas import ALL_STYLES
+from .types import lv_lambda_t, lv_obj_t, lv_obj_t_ptr
+from .widget import Widget, add_widgets, set_obj_properties, theme_widget_map
+
+TOP_LAYER = literal("lv_disp_get_layer_top(lv_component->get_disp())")
+
+
+async def styles_to_code(config):
+    """Convert styles to C__ code."""
+    for style in config.get(CONF_STYLE_DEFINITIONS, ()):
+        svar = cg.new_Pvariable(style[CONF_ID])
+        lv.style_init(svar)
+        for prop, validator in ALL_STYLES.items():
+            if value := style.get(prop):
+                if isinstance(validator, LValidator):
+                    value = await validator.process(value)
+                if isinstance(value, list):
+                    value = "|".join(value)
+                lv.call(f"style_set_{prop}", svar, literal(value))
+
+
+async def theme_to_code(config):
+    if theme := config.get(CONF_THEME):
+        add_lv_use(CONF_THEME)
+        for w_name, style in theme.items():
+            if not isinstance(style, dict):
+                continue
+
+            lname = "lv_theme_apply_" + w_name
+            apply = lv_variable(lv_lambda_t, lname)
+            theme_widget_map[w_name] = apply
+            ow = Widget.create("obj", MockObj(ID("obj")), obj_spec)
+            async with LambdaContext([(lv_obj_t_ptr, "obj")], where=w_name) as context:
+                await set_obj_properties(ow, style)
+            lv_assign(apply, await context.get_lambda())
+
+
+async def add_top_layer(config):
+    if top_conf := config.get(CONF_TOP_LAYER):
+        with LocalVariable("top_layer", lv_obj_t, TOP_LAYER) as top_layer_obj:
+            top_w = Widget(top_layer_obj, obj_spec, top_conf)
+            await set_obj_properties(top_w, top_conf)
+            await add_widgets(top_w, top_conf)
diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py
index bf92bda5b0..c640c8abd9 100644
--- a/esphome/components/lvgl/trigger.py
+++ b/esphome/components/lvgl/trigger.py
@@ -7,15 +7,14 @@ from .defines import (
     CONF_ALIGN_TO,
     CONF_X,
     CONF_Y,
-    LV_EVENT,
+    LV_EVENT_MAP,
     LV_EVENT_TRIGGERS,
     literal,
 )
-from .lvcode import LambdaContext, add_line_marks, lv, lv_add
+from .lvcode import EVENT_ARG, LambdaContext, LvConditional, lv, lv_add
+from .types import LV_EVENT
 from .widget import widget_map
 
-lv_event_t_ptr = cg.global_ns.namespace("lv_event_t").operator("ptr")
-
 
 async def generate_triggers(lv_component):
     """
@@ -34,15 +33,15 @@ async def generate_triggers(lv_component):
             }.items():
                 conf = conf[0]
                 w.add_flag("LV_OBJ_FLAG_CLICKABLE")
-                event = "LV_EVENT_" + LV_EVENT[event[3:].upper()]
+                event = literal("LV_EVENT_" + LV_EVENT_MAP[event[3:].upper()])
                 await add_trigger(conf, event, lv_component, w)
             for conf in w.config.get(CONF_ON_VALUE, ()):
-                await add_trigger(conf, "LV_EVENT_VALUE_CHANGED", lv_component, w)
+                await add_trigger(conf, LV_EVENT.VALUE_CHANGED, lv_component, w)
 
             # Generate align to directives while we're here
             if align_to := w.config.get(CONF_ALIGN_TO):
                 target = widget_map[align_to[CONF_ID]].obj
-                align = align_to[CONF_ALIGN]
+                align = literal(align_to[CONF_ALIGN])
                 x = align_to[CONF_X]
                 y = align_to[CONF_Y]
                 lv.obj_align_to(w.obj, target, align, x, y)
@@ -50,12 +49,11 @@ async def generate_triggers(lv_component):
 
 async def add_trigger(conf, event, lv_component, w):
     tid = conf[CONF_TRIGGER_ID]
-    add_line_marks(tid)
     trigger = cg.new_Pvariable(tid)
     args = w.get_args()
     value = w.get_value()
     await automation.build_automation(trigger, args, conf)
-    with LambdaContext([(lv_event_t_ptr, "event_data")]) as context:
-        add_line_marks(tid)
-        lv_add(trigger.trigger(value))
-    lv_add(lv_component.add_event_cb(w.obj, await context.get_lambda(), literal(event)))
+    async with LambdaContext(EVENT_ARG, where=tid) as context:
+        with LvConditional(w.is_selected()):
+            lv_add(trigger.trigger(value))
+    lv_add(lv_component.add_event_cb(w.obj, await context.get_lambda(), event))
diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py
index 6997207dac..b6f65c8c1b 100644
--- a/esphome/components/lvgl/types.py
+++ b/esphome/components/lvgl/types.py
@@ -1,8 +1,11 @@
-from esphome import automation, codegen as cg
-from esphome.core import ID
-from esphome.cpp_generator import MockObjClass
+import sys
 
-from .defines import CONF_TEXT
+from esphome import automation, codegen as cg
+from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_VALUE
+from esphome.cpp_generator import MockObj, MockObjClass
+
+from .defines import CONF_TEXT, lvgl_ns
+from .lvcode import lv_expr
 
 
 class LvType(cg.MockObjClass):
@@ -18,36 +21,48 @@ class LvType(cg.MockObjClass):
         return self.args[0][0] if len(self.args) else None
 
 
+class LvNumber(LvType):
+    def __init__(self, *args):
+        super().__init__(
+            *args,
+            largs=[(cg.float_, "x")],
+            lvalue=lambda w: w.get_number_value(),
+            has_on_value=True,
+        )
+        self.value_property = CONF_VALUE
+
+
 uint16_t_ptr = cg.uint16.operator("ptr")
-lvgl_ns = cg.esphome_ns.namespace("lvgl")
 char_ptr = cg.global_ns.namespace("char").operator("ptr")
 void_ptr = cg.void.operator("ptr")
-LvglComponent = lvgl_ns.class_("LvglComponent", cg.PollingComponent)
-LvglComponentPtr = LvglComponent.operator("ptr")
-lv_event_code_t = cg.global_ns.namespace("lv_event_code_t")
+lv_coord_t = cg.global_ns.namespace("lv_coord_t")
+lv_event_code_t = cg.global_ns.enum("lv_event_code_t")
 lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t")
 FontEngine = lvgl_ns.class_("FontEngine")
 IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template())
 ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action)
 LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition)
 LvglAction = lvgl_ns.class_("LvglAction", automation.Action)
+lv_lambda_t = lvgl_ns.class_("LvLambdaType")
 LvCompound = lvgl_ns.class_("LvCompound")
 lv_font_t = cg.global_ns.class_("lv_font_t")
 lv_style_t = cg.global_ns.struct("lv_style_t")
+# fake parent class for first class widgets and matrix buttons
 lv_pseudo_button_t = lvgl_ns.class_("LvPseudoButton")
 lv_obj_base_t = cg.global_ns.class_("lv_obj_t", lv_pseudo_button_t)
 lv_obj_t_ptr = lv_obj_base_t.operator("ptr")
-lv_disp_t_ptr = cg.global_ns.struct("lv_disp_t").operator("ptr")
+lv_disp_t = cg.global_ns.struct("lv_disp_t")
 lv_color_t = cg.global_ns.struct("lv_color_t")
 lv_group_t = cg.global_ns.struct("lv_group_t")
 LVTouchListener = lvgl_ns.class_("LVTouchListener")
 LVEncoderListener = lvgl_ns.class_("LVEncoderListener")
 lv_obj_t = LvType("lv_obj_t")
+lv_page_t = cg.global_ns.class_("LvPageType", LvCompound)
 lv_img_t = LvType("lv_img_t")
 
-
-# this will be populated later, in __init__.py to avoid circular imports.
-WIDGET_TYPES: dict = {}
+LV_EVENT = MockObj(base="LV_EVENT_", op="")
+LV_STATE = MockObj(base="LV_STATE_", op="")
+LV_BTNMATRIX_CTRL = MockObj(base="LV_BTNMATRIX_CTRL_", op="")
 
 
 class LvText(LvType):
@@ -55,7 +70,8 @@ class LvText(LvType):
         super().__init__(
             *args,
             largs=[(cg.std_string, "text")],
-            lvalue=lambda w: w.get_property("text")[0],
+            lvalue=lambda w: w.get_property("text"),
+            has_on_value=True,
             **kwargs,
         )
         self.value_property = CONF_TEXT
@@ -66,13 +82,21 @@ class LvBoolean(LvType):
         super().__init__(
             *args,
             largs=[(cg.bool_, "x")],
-            lvalue=lambda w: w.has_state("LV_STATE_CHECKED"),
+            lvalue=lambda w: w.is_checked(),
             has_on_value=True,
             **kwargs,
         )
 
 
-CUSTOM_EVENT = ID("lv_custom_event", False, type=lv_event_code_t)
+class LvSelect(LvType):
+    def __init__(self, *args, **kwargs):
+        super().__init__(
+            *args,
+            largs=[(cg.int_, "x")],
+            lvalue=lambda w: w.get_property("selected"),
+            has_on_value=True,
+            **kwargs,
+        )
 
 
 class WidgetType:
@@ -80,7 +104,15 @@ class WidgetType:
     Describes a type of Widget, e.g. "bar" or "line"
     """
 
-    def __init__(self, name, w_type, parts, schema=None, modify_schema=None):
+    def __init__(
+        self,
+        name: str,
+        w_type: LvType,
+        parts: tuple,
+        schema=None,
+        modify_schema=None,
+        lv_name=None,
+    ):
         """
         :param name: The widget name, e.g. "bar"
         :param w_type: The C type of the widget
@@ -89,6 +121,7 @@ class WidgetType:
         :param modify_schema: A schema to update the widget
         """
         self.name = name
+        self.lv_name = lv_name or name
         self.w_type = w_type
         self.parts = parts
         if schema is None:
@@ -98,7 +131,8 @@ class WidgetType:
         if modify_schema is None:
             self.modify_schema = self.schema
         else:
-            self.modify_schema = self.schema
+            self.modify_schema = modify_schema
+        self.mock_obj = MockObj(f"lv_{self.lv_name}", "_")
 
     @property
     def animated(self):
@@ -118,7 +152,7 @@ class WidgetType:
         :param config: Its configuration
         :return: Generated code as a list of text lines
         """
-        raise NotImplementedError(f"No to_code defined for {self.name}")
+        return []
 
     def obj_creator(self, parent: MockObjClass, config: dict):
         """
@@ -127,7 +161,7 @@ class WidgetType:
         :param config:  Its configuration
         :return: Generated code as a single text line
         """
-        return f"lv_{self.name}_create({parent})"
+        return lv_expr.call(f"{self.lv_name}_create", parent)
 
     def get_uses(self):
         """
@@ -135,3 +169,23 @@ class WidgetType:
         :return:
         """
         return ()
+
+    def get_max(self, config: dict):
+        return sys.maxsize
+
+    def get_min(self, config: dict):
+        return -sys.maxsize
+
+    def get_step(self, config: dict):
+        return 1
+
+    def get_scale(self, config: dict):
+        return 1.0
+
+
+class NumberType(WidgetType):
+    def get_max(self, config: dict):
+        return int(config[CONF_MAX_VALUE] or 100)
+
+    def get_min(self, config: dict):
+        return int(config[CONF_MIN_VALUE] or 0)
diff --git a/esphome/components/lvgl/widget.py b/esphome/components/lvgl/widget.py
index 83aed341e7..5734aec7dc 100644
--- a/esphome/components/lvgl/widget.py
+++ b/esphome/components/lvgl/widget.py
@@ -1,33 +1,63 @@
 import sys
-from typing import Any
+from typing import Any, Union
 
 from esphome import codegen as cg, config_validation as cv
 from esphome.config_validation import Invalid
-from esphome.const import CONF_GROUP, CONF_ID, CONF_STATE
-from esphome.core import CORE, TimePeriod
+from esphome.const import CONF_GROUP, CONF_ID, CONF_STATE, CONF_TYPE
+from esphome.core import ID, TimePeriod
 from esphome.coroutine import FakeAwaitable
-from esphome.cpp_generator import MockObj, MockObjClass, VariableDeclarationExpression
+from esphome.cpp_generator import AssignmentExpression, CallExpression, MockObj
 
 from .defines import (
     CONF_DEFAULT,
+    CONF_FLEX_ALIGN_CROSS,
+    CONF_FLEX_ALIGN_MAIN,
+    CONF_FLEX_ALIGN_TRACK,
+    CONF_FLEX_FLOW,
+    CONF_GRID_COLUMN_ALIGN,
+    CONF_GRID_COLUMNS,
+    CONF_GRID_ROW_ALIGN,
+    CONF_GRID_ROWS,
+    CONF_LAYOUT,
     CONF_MAIN,
     CONF_SCROLLBAR_MODE,
+    CONF_STYLES,
     CONF_WIDGETS,
     OBJ_FLAGS,
     PARTS,
     STATES,
-    ConstantLiteral,
+    TYPE_FLEX,
+    TYPE_GRID,
     LValidator,
     join_enums,
     literal,
 )
 from .helpers import add_lv_use
-from .lvcode import add_group, add_line_marks, lv, lv_add, lv_assign, lv_expr, lv_obj
-from .schemas import ALL_STYLES, STYLE_REMAP
-from .types import WIDGET_TYPES, LvType, WidgetType, lv_obj_t, lv_obj_t_ptr
+from .lvcode import (
+    LvConditional,
+    add_line_marks,
+    lv,
+    lv_add,
+    lv_assign,
+    lv_expr,
+    lv_obj,
+    lv_Pvariable,
+)
+from .schemas import ALL_STYLES, STYLE_REMAP, WIDGET_TYPES
+from .types import (
+    LV_STATE,
+    LvType,
+    WidgetType,
+    lv_coord_t,
+    lv_group_t,
+    lv_obj_t,
+    lv_obj_t_ptr,
+)
 
 EVENT_LAMB = "event_lamb__"
 
+theme_widget_map = {}
+
 
 class LvScrActType(WidgetType):
     """
@@ -37,9 +67,6 @@ class LvScrActType(WidgetType):
     def __init__(self):
         super().__init__("lv_scr_act()", lv_obj_t, ())
 
-    def obj_creator(self, parent: MockObjClass, config: dict):
-        return []
-
     async def to_code(self, w, config: dict):
         return []
 
@@ -55,7 +82,7 @@ class Widget:
     def set_completed():
         Widget.widgets_completed = True
 
-    def __init__(self, var, wtype: WidgetType, config: dict = None, parent=None):
+    def __init__(self, var, wtype: WidgetType, config: dict = None):
         self.var = var
         self.type = wtype
         self.config = config
@@ -63,21 +90,18 @@ class Widget:
         self.step = 1.0
         self.range_from = -sys.maxsize
         self.range_to = sys.maxsize
-        self.parent = parent
+        if wtype.is_compound():
+            self.obj = MockObj(f"{self.var}->obj")
+        else:
+            self.obj = var
 
     @staticmethod
-    def create(name, var, wtype: WidgetType, config: dict = None, parent=None):
-        w = Widget(var, wtype, config, parent)
+    def create(name, var, wtype: WidgetType, config: dict = None):
+        w = Widget(var, wtype, config)
         if name is not None:
             widget_map[name] = w
         return w
 
-    @property
-    def obj(self):
-        if self.type.is_compound():
-            return f"{self.var}->obj"
-        return self.var
-
     def add_state(self, state):
         return lv_obj.add_state(self.obj, literal(state))
 
@@ -85,7 +109,13 @@ class Widget:
         return lv_obj.clear_state(self.obj, literal(state))
 
     def has_state(self, state):
-        return lv_expr.obj_get_state(self.obj) & literal(state) != 0
+        return (lv_expr.obj_get_state(self.obj) & literal(state)) != 0
+
+    def is_pressed(self):
+        return self.has_state(LV_STATE.PRESSED)
+
+    def is_checked(self):
+        return self.has_state(LV_STATE.CHECKED)
 
     def add_flag(self, flag):
         return lv_obj.add_flag(self.obj, literal(flag))
@@ -93,32 +123,37 @@ class Widget:
     def clear_flag(self, flag):
         return lv_obj.clear_flag(self.obj, literal(flag))
 
-    def set_property(self, prop, value, animated: bool = None, ltype=None):
+    async def set_property(self, prop, value, animated: bool = None):
         if isinstance(value, dict):
             value = value.get(prop)
+            if isinstance(ALL_STYLES.get(prop), LValidator):
+                value = await ALL_STYLES[prop].process(value)
+            else:
+                value = literal(value)
         if value is None:
             return
         if isinstance(value, TimePeriod):
             value = value.total_milliseconds
-        ltype = ltype or self.__type_base()
+        if isinstance(value, str):
+            value = literal(value)
         if animated is None or self.type.animated is not True:
-            lv.call(f"{ltype}_set_{prop}", self.obj, value)
+            lv.call(f"{self.type.lv_name}_set_{prop}", self.obj, value)
         else:
             lv.call(
-                f"{ltype}_set_{prop}",
+                f"{self.type.lv_name}_set_{prop}",
                 self.obj,
                 value,
-                "LV_ANIM_ON" if animated else "LV_ANIM_OFF",
+                literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"),
             )
 
     def get_property(self, prop, ltype=None):
         ltype = ltype or self.__type_base()
-        return f"lv_{ltype}_get_{prop}({self.obj})"
+        return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})")
 
     def set_style(self, prop, value, state):
         if value is None:
-            return []
-        return lv.call(f"obj_set_style_{prop}", self.obj, value, state)
+            return
+        lv.call(f"obj_set_style_{prop}", self.obj, value, state)
 
     def __type_base(self):
         wtype = self.type.w_type
@@ -140,6 +175,32 @@ class Widget:
             return self.type.w_type.value(self)
         return self.obj
 
+    def get_number_value(self):
+        value = self.type.mock_obj.get_value(self.obj)
+        if self.scale == 1.0:
+            return value
+        return value / float(self.scale)
+
+    def is_selected(self):
+        """
+        Overridable property to determine if the widget is selected. Will be None except
+        for matrix buttons
+        :return:
+        """
+        return None
+
+    def get_max(self):
+        return self.type.get_max(self.config)
+
+    def get_min(self):
+        return self.type.get_min(self.config)
+
+    def get_step(self):
+        return self.type.get_step(self.config)
+
+    def get_scale(self):
+        return self.type.get_scale(self.config)
+
 
 # Map of widgets to their config, used for trigger generation
 widget_map: dict[Any, Widget] = {}
@@ -161,13 +222,20 @@ def get_widget_generator(wid):
         yield
 
 
-async def get_widget(config: dict, id: str = CONF_ID) -> Widget:
-    wid = config[id]
+async def get_widget_(wid: Widget):
     if obj := widget_map.get(wid):
         return obj
     return await FakeAwaitable(get_widget_generator(wid))
 
 
+async def get_widgets(config: Union[dict, list], id: str = CONF_ID) -> list[Widget]:
+    if not config:
+        return []
+    if not isinstance(config, list):
+        config = [config]
+    return [await get_widget_(c[id]) for c in config if id in c]
+
+
 def collect_props(config):
     """
     Collect all properties from a configuration
@@ -175,7 +243,7 @@ def collect_props(config):
     :return:
     """
     props = {}
-    for prop in [*ALL_STYLES, *OBJ_FLAGS, CONF_GROUP]:
+    for prop in [*ALL_STYLES, *OBJ_FLAGS, CONF_STYLES, CONF_GROUP]:
         if prop in config:
             props[prop] = config[prop]
     return props
@@ -209,12 +277,39 @@ def collect_parts(config):
 
 async def set_obj_properties(w: Widget, config):
     """Generate a list of C++ statements to apply properties to an lv_obj_t"""
+    if layout := config.get(CONF_LAYOUT):
+        layout_type: str = layout[CONF_TYPE]
+        lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
+        if layout_type == TYPE_GRID:
+            wid = config[CONF_ID]
+            rows = "{" + ",".join(layout[CONF_GRID_ROWS]) + ", LV_GRID_TEMPLATE_LAST}"
+            row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t)
+            row_array = cg.static_const_array(row_id, cg.RawExpression(rows))
+            w.set_style("grid_row_dsc_array", row_array, 0)
+            columns = (
+                "{" + ",".join(layout[CONF_GRID_COLUMNS]) + ", LV_GRID_TEMPLATE_LAST}"
+            )
+            column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t)
+            column_array = cg.static_const_array(column_id, cg.RawExpression(columns))
+            w.set_style("grid_column_dsc_array", column_array, 0)
+            w.set_style(
+                CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)), 0
+            )
+            w.set_style(
+                CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN)), 0
+            )
+        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])
+            track = literal(layout[CONF_FLEX_ALIGN_TRACK])
+            lv_obj.set_flex_align(w.obj, main, cross, track)
     parts = collect_parts(config)
     for part, states in parts.items():
         for state, props in states.items():
-            lv_state = ConstantLiteral(
-                f"(int)LV_STATE_{state.upper()}|(int)LV_PART_{part.upper()}"
-            )
+            lv_state = join_enums((f"LV_STATE_{state}", f"LV_PART_{part}"))
+            for style_id in props.get(CONF_STYLES, ()):
+                lv_obj.add_style(w.obj, MockObj(style_id), lv_state)
             for prop, value in {
                 k: v for k, v in props.items() if k in ALL_STYLES
             }.items():
@@ -258,14 +353,12 @@ async def set_obj_properties(w: Widget, config):
             w.clear_state(clears)
         for key, value in lambs.items():
             lamb = await cg.process_lambda(value, [], return_type=cg.bool_)
-            state = f"LV_STATE_{key.upper}"
-            lv.cond_if(lamb)
-            w.add_state(state)
-            lv.cond_else()
-            w.clear_state(state)
-            lv.cond_endif()
-    if scrollbar_mode := config.get(CONF_SCROLLBAR_MODE):
-        lv_obj.set_scrollbar_mode(w.obj, scrollbar_mode)
+            state = f"LV_STATE_{key.upper()}"
+            with LvConditional(f"{lamb}()") as cond:
+                w.add_state(state)
+                cond.else_()
+                w.clear_state(state)
+    await w.set_property(CONF_SCROLLBAR_MODE, config)
 
 
 async def add_widgets(parent: Widget, config: dict):
@@ -280,7 +373,7 @@ async def add_widgets(parent: Widget, config: dict):
         await widget_to_code(w_cnfig, w_type, parent.obj)
 
 
-async def widget_to_code(w_cnfig, w_type, parent):
+async def widget_to_code(w_cnfig, w_type: WidgetType, parent):
     """
     Converts a Widget definition to C code.
     :param w_cnfig: The widget configuration
@@ -298,19 +391,33 @@ async def widget_to_code(w_cnfig, w_type, parent):
         var = cg.new_Pvariable(wid)
         lv_add(var.set_obj(creator))
     else:
-        var = MockObj(wid, "->")
-        decl = VariableDeclarationExpression(lv_obj_t, "*", wid)
-        CORE.add_global(decl)
-        CORE.register_variable(wid, var)
+        var = lv_Pvariable(lv_obj_t, wid)
         lv_assign(var, creator)
 
-    widget = Widget.create(wid, var, spec, w_cnfig, parent)
-    await set_obj_properties(widget, w_cnfig)
-    await add_widgets(widget, w_cnfig)
-    await spec.to_code(widget, w_cnfig)
+    w = Widget.create(wid, var, spec, w_cnfig)
+    if theme := theme_widget_map.get(w_type):
+        lv_add(CallExpression(theme, w.obj))
+    await set_obj_properties(w, w_cnfig)
+    await add_widgets(w, w_cnfig)
+    await spec.to_code(w, w_cnfig)
 
 
 lv_scr_act_spec = LvScrActType()
-lv_scr_act = Widget.create(
-    None, ConstantLiteral("lv_scr_act()"), lv_scr_act_spec, {}, parent=None
-)
+lv_scr_act = Widget.create(None, literal("lv_scr_act()"), lv_scr_act_spec, {})
+
+lv_groups = {}  # Widget group names
+
+
+def add_group(name):
+    if name is None:
+        return None
+    fullname = f"lv_esp_group_{name}"
+    if name not in lv_groups:
+        gid = ID(fullname, True, type=lv_group_t.operator("ptr"))
+        lv_add(
+            AssignmentExpression(
+                type_=gid.type, modifier="", name=fullname, rhs=lv_expr.group_create()
+            )
+        )
+        lv_groups[name] = literal(fullname)
+    return lv_groups[name]
diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml
index fde700e0bd..0cca45d376 100644
--- a/tests/components/lvgl/lvgl-package.yaml
+++ b/tests/components/lvgl/lvgl-package.yaml
@@ -5,142 +5,229 @@ lvgl:
     - touchscreen_id: tft_touch
       long_press_repeat_time: 200ms
       long_press_time: 500ms
-  widgets:
-    - label:
-        id: hello_label
-        text: Hello world
-        text_color: 0xFF8000
-        align: center
-        text_font: montserrat_40
-        border_post: true
-
-    - label:
-        text: "Hello shiny day"
-        text_color: 0xFFFFFF
-        align: bottom_mid
-        text_font: space16
-    - obj:
-        align: center
-        arc_opa: COVER
-        arc_color: 0xFF0000
-        arc_rounded: false
-        arc_width: 3
-        anim_time: 1s
-        bg_color: light_blue
-        bg_grad_color: light_blue
-        bg_dither_mode: ordered
-        bg_grad_dir: hor
-        bg_grad_stop: 128
-        bg_image_opa: transp
-        bg_image_recolor: light_blue
-        bg_image_recolor_opa: 50%
-        bg_main_stop: 0
-        bg_opa: 20%
-        border_color: 0x00FF00
-        border_opa: cover
-        border_post: true
-        border_side: [bottom, left]
-        border_width: 4
-        clip_corner: false
-        height: 50%
-        image_recolor: light_blue
-        image_recolor_opa: cover
-        line_width: 10
-        line_dash_width: 10
-        line_dash_gap: 10
-        line_rounded: false
-        line_color: light_blue
-        opa: cover
-        opa_layered: cover
-        outline_color: light_blue
-        outline_opa: cover
-        outline_pad: 10px
-        outline_width: 10px
-        pad_all: 10px
-        pad_bottom: 10px
-        pad_column: 10px
-        pad_left: 10px
-        pad_right: 10px
-        pad_row: 10px
-        pad_top: 10px
-        shadow_color: light_blue
-        shadow_ofs_x: 5
-        shadow_ofs_y: 5
-        shadow_opa: cover
-        shadow_spread: 5
-        shadow_width: 10
-        text_align: auto
-        text_color: light_blue
-        text_decor: [underline, strikethrough]
-        text_font: montserrat_18
-        text_letter_space: 4
-        text_line_space: 4
-        text_opa: cover
-        transform_angle: 180
-        transform_height: 100
-        transform_pivot_x: 50%
-        transform_pivot_y: 50%
-        transform_zoom: 0.5
-        translate_x: 10
-        translate_y: 10
-        max_height: 100
-        max_width: 200
-        min_height: 20%
-        min_width: 20%
-        radius: circle
-        width: 10px
-        x: 100
-        y: 120
-    - button:
-        width: 20%
-        height: 10%
-        pressed:
-          bg_color: light_blue
-        checkable: true
-        checked:
-          bg_color: 0x000000
-        widgets:
-          - label:
-              text: Button
-        on_click:
-          lvgl.label.update:
+  pages:
+    - id: page1
+      skip: true
+      widgets:
+        - label:
             id: hello_label
-            bg_color: 0x123456
-            text: clicked
-        on_value:
-          logger.log:
-            format: "state now %d"
-            args: [x]
-        on_short_click:
-          lvgl.widget.hide: hello_label
-        on_long_press:
-          lvgl.widget.show: hello_label
-        on_cancel:
-          lvgl.widget.enable: hello_label
-        on_ready:
-          lvgl.widget.disable: hello_label
-        on_defocus:
-          lvgl.widget.hide: hello_label
-        on_focus:
-          logger.log: Button clicked
-        on_scroll:
-          logger.log: Button clicked
-        on_scroll_end:
-          logger.log: Button clicked
-        on_scroll_begin:
-          logger.log: Button clicked
-        on_release:
-          logger.log: Button clicked
-        on_long_press_repeat:
-          logger.log: Button clicked
+            text: Hello world
+            text_color: 0xFF8000
+            align: center
+            text_font: montserrat_40
+            border_post: true
 
+        - label:
+            text: "Hello shiny day"
+            text_color: 0xFFFFFF
+            align: bottom_mid
+            text_font: space16
+        - obj:
+            align: center
+            arc_opa: COVER
+            arc_color: 0xFF0000
+            arc_rounded: false
+            arc_width: 3
+            anim_time: 1s
+            bg_color: light_blue
+            bg_grad_color: light_blue
+            bg_dither_mode: ordered
+            bg_grad_dir: hor
+            bg_grad_stop: 128
+            bg_image_opa: transp
+            bg_image_recolor: light_blue
+            bg_image_recolor_opa: 50%
+            bg_main_stop: 0
+            bg_opa: 20%
+            border_color: 0x00FF00
+            border_opa: cover
+            border_post: true
+            border_side: [bottom, left]
+            border_width: 4
+            clip_corner: false
+            height: 50%
+            image_recolor: light_blue
+            image_recolor_opa: cover
+            line_width: 10
+            line_dash_width: 10
+            line_dash_gap: 10
+            line_rounded: false
+            line_color: light_blue
+            opa: cover
+            opa_layered: cover
+            outline_color: light_blue
+            outline_opa: cover
+            outline_pad: 10px
+            outline_width: 10px
+            pad_all: 10px
+            pad_bottom: 10px
+            pad_column: 10px
+            pad_left: 10px
+            pad_right: 10px
+            pad_row: 10px
+            pad_top: 10px
+            shadow_color: light_blue
+            shadow_ofs_x: 5
+            shadow_ofs_y: 5
+            shadow_opa: cover
+            shadow_spread: 5
+            shadow_width: 10
+            text_align: auto
+            text_color: light_blue
+            text_decor: [underline, strikethrough]
+            text_font: montserrat_18
+            text_letter_space: 4
+            text_line_space: 4
+            text_opa: cover
+            transform_angle: 180
+            transform_height: 100
+            transform_pivot_x: 50%
+            transform_pivot_y: 50%
+            transform_zoom: 0.5
+            translate_x: 10
+            translate_y: 10
+            max_height: 100
+            max_width: 200
+            min_height: 20%
+            min_width: 20%
+            radius: circle
+            width: 10px
+            x: 100
+            y: 120
+        - button:
+            width: 20%
+            height: 10%
+            pressed:
+              bg_color: light_blue
+            checkable: true
+            checked:
+              bg_color: 0x000000
+            widgets:
+              - label:
+                  text: Button
+            on_click:
+              lvgl.label.update:
+                id: hello_label
+                bg_color: 0x123456
+                text: clicked
+            on_value:
+              logger.log:
+                format: "state now %d"
+                args: [x]
+            on_short_click:
+              lvgl.widget.hide: hello_label
+            on_long_press:
+              lvgl.widget.show: hello_label
+            on_cancel:
+              lvgl.widget.enable: hello_label
+            on_ready:
+              lvgl.widget.disable: hello_label
+            on_defocus:
+              lvgl.widget.hide: hello_label
+            on_focus:
+              logger.log: Button clicked
+            on_scroll:
+              logger.log: Button clicked
+            on_scroll_end:
+              logger.log: Button clicked
+            on_scroll_begin:
+              logger.log: Button clicked
+            on_release:
+              logger.log: Button clicked
+            on_long_press_repeat:
+              logger.log: Button clicked
+        - led:
+            color: 0x00FF00
+            brightness: 50%
+            align: right_mid
+        - spinner:
+            arc_length: 120
+            spin_time: 2s
+            align: left_mid
+        - image:
+            src: cat_image
+            align: top_left
+            y: 50
+
+    - id: page2
+      widgets:
+        - arc:
+            align: left_mid
+            id: lv_arc
+            adjustable: true
+            on_value:
+              then:
+                - logger.log:
+                    format: "Arc value is %f"
+                    args: [x]
+            group: general
+            scroll_on_focus: true
+            value: 75
+            min_value: 1
+            max_value: 100
+            arc_color: 0xFF0000
+            indicator:
+              arc_color: 0xF000FF
+              pressed:
+                arc_color: 0xFFFF00
+              focused:
+                arc_color: 0x808080
+        - bar:
+            id: bar_id
+            align: top_mid
+            y: 20
+            value: 30
+            max_value: 100
+            min_value: 10
+            mode: range
+            on_click:
+              then:
+                - lvgl.bar.update:
+                    id: bar_id
+                    value: !lambda return (int)((float)rand() / RAND_MAX * 100);
+                - logger.log:
+                    format: "bar value %f"
+                    args: [x]
+        - line:
+            align: center
+            points:
+              - 5, 5
+              - 70, 70
+              - 120, 10
+              - 180, 60
+              - 240, 10
+            on_click:
+              lvgl.page.next:
+        - switch:
+            align: right_mid
+        - checkbox:
+            text: Checkbox
+            align: bottom_right
+        - slider:
+            id: slider_id
+            align: top_mid
+            y: 40
+            value: 30
+            max_value: 100
+            min_value: 10
+            mode: normal
+            on_value:
+              then:
+                - logger.log:
+                    format: "slider value %f"
+                    args: [x]
+            on_click:
+              then:
+                - lvgl.slider.update:
+                    id: slider_id
+                    value: !lambda return (int)((float)rand() / RAND_MAX * 100);
 font:
   - file: "gfonts://Roboto"
     id: space16
     bpp: 4
 
 image:
-  - id: cat_img
+  - id: cat_image
     resize: 256x48
     file: $component_dir/logo-text.svg
   - id: dog_img