diff --git a/.site/gallery/index.html b/.site/gallery/index.html
index 3de879518..611f6cda1 100644
--- a/.site/gallery/index.html
+++ b/.site/gallery/index.html
@@ -130,7 +130,7 @@
Flagship LVGL automobile instrument cluster.
+ An electric car's instrument cluster, 800x480, in LVGL.
diff --git a/.site/pyscript/thumbnails/car_cluster.png b/.site/pyscript/thumbnails/car_cluster.png
index 3577441f8..4192d7085 100644
Binary files a/.site/pyscript/thumbnails/car_cluster.png and b/.site/pyscript/thumbnails/car_cluster.png differ
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
index 33599c33a..005da30ff 100644
--- a/docs/screenshots/README.md
+++ b/docs/screenshots/README.md
@@ -7,6 +7,7 @@
|  |  |  |
|  |  |  |
|  |  |  |
+|  | | |
On Android (the PyDevices Runner) and as an installed PWA:
diff --git a/docs/screenshots/car_cluster.png b/docs/screenshots/car_cluster.png
new file mode 100644
index 000000000..9f66ebc4a
Binary files /dev/null and b/docs/screenshots/car_cluster.png differ
diff --git a/lib/examples/car_cluster/car_cluster.py b/lib/examples/car_cluster/car_cluster.py
index 576f1af64..2868a828b 100644
--- a/lib/examples/car_cluster/car_cluster.py
+++ b/lib/examples/car_cluster/car_cluster.py
@@ -4,18 +4,23 @@
"""
car_cluster
====================================================
-Flagship LVGL automobile instrument cluster.
+An electric car's instrument cluster, 800x480, in LVGL.
-Landscape 1024×512 cluster with Material accents, nested chrome, four side
-gauges, dual steering-wheel style menu rails, and eight live center screens.
+Two neon dials, speed in mph on the left and motor power in kW on the right,
+with the odometer and the miles per kWh under them; the clock, the date and
+the outside temperature along the top; the turn indicators; a lit lane
+running to the horizon with the car's marker at its foot; and P R N D S
+along the bottom.
-Controls:
- Left/Right — switch focus group (left rail / center / right rail)
- Up/Down — previous/next control within the active group
- Enter — activate focused control
+The car drives itself round a 90 second loop (see ``vehicle.py``). Keys:
-Vehicle speed, gear, RPM, and oil pressure follow an automatic drive cycle
-(speed ≈ 10× gear). Fuel and coolant stay fixed.
+ Left / Right hold that turn indicator on; press again to cancel
+ Enter pause and resume the drive
+
+The dials are art drawn once on a canvas (a colour sweep made of many short
+arc segments, a tick ring, a hairline) with three cheap moving parts over it
+(see ``ring.py``). Everything is sized from the screen, so it also fits a
+smaller landscape display, though it is drawn for 800x480.
"""
import sys
@@ -24,7 +29,7 @@
_file = __file__.replace("\\", "/")
_PKG = _file.rsplit("/", 1)[0]
_parts = _file.split("/")
-# src/ is the parent of examples/ (package lives in examples/car_cluster/).
+# lib/ is the parent of examples/ (the package lives in examples/car_cluster/).
if "examples" in _parts:
_idx = _parts.index("examples")
_src = "/".join(_parts[:_idx]) if _idx else "."
@@ -37,31 +42,25 @@
if _PKG not in sys.path:
sys.path.insert(0, _PKG)
-import input_map # noqa: E402 — capture hook must run before display_driver
-
-input_map.capture_virtual_devices()
+from boarddev import env_bool, env_get, env_set # noqa: E402
-from boarddev import env_bool, env_get, env_set
-
-env_set("PYDEVICES_WIDTH", "1024")
-env_set("PYDEVICES_HEIGHT", "512")
+env_set("PYDEVICES_WIDTH", "800")
+env_set("PYDEVICES_HEIGHT", "480")
if env_get("PYDEVICES_SCALE") is None:
env_set("PYDEVICES_SCALE", "1")
import display_driver # noqa: E402 — wires LVGL into the shared app
import lvgl as lv # noqa: E402
-from board_config import display_drv # noqa: E402
-from display_driver import app # noqa: E402
+from display_driver import app # noqa: E402, F401
-import focus_nav # noqa: E402
-import input_map # noqa: E402
-import layout # noqa: E402
+from cluster import Cluster # noqa: E402
from vehicle import Vehicle # noqa: E402
_ui = None
_vehicle = None
_last_ms = None
_timer = None
+_keys = None
def _now_ms():
@@ -75,50 +74,82 @@ def _dt_s(now):
global _last_ms
if _last_ms is None:
_last_ms = now
- return 0.05
+ return 0.04
try:
dt = time.ticks_diff(now, _last_ms) / 1000.0
except AttributeError:
dt = (now - _last_ms) / 1000.0
_last_ms = now
if dt < 0:
- dt = 0.05
+ dt = 0.04
if dt > 0.25:
dt = 0.25
return dt
-# Design / interactive freeze: set CAR_CLUSTER_FREEZE=1 (or truthy) to hold
-# gauges/screens at static values so layout can be tweaked from the REPL.
+def _clock_text():
+ try:
+ t = time.localtime()
+ hour = t[3] % 12 or 12
+ return "%d:%02d %s" % (hour, t[4], "PM" if t[3] >= 12 else "AM")
+ except Exception:
+ return "9:59 PM"
+
+
+# Set CAR_CLUSTER_FREEZE=1 to hold the dials still for layout work at the REPL.
_FREEZE = env_bool("CAR_CLUSTER_FREEZE", False)
def _on_tick(timer):
- global _ui, _vehicle
- if _ui is None or _vehicle is None:
- return
- if _FREEZE:
+ if _ui is None or _vehicle is None or _FREEZE:
return
- dt = _dt_s(_now_ms())
- _vehicle.tick(dt)
+ _vehicle.tick(_dt_s(_now_ms()))
+ _ui.set_clock(_clock_text())
_ui.update()
def freeze(static=True):
- """Hold or resume vehicle/gauge animation (interactive design helper)."""
+ """Hold or resume the drive (a design helper for the REPL)."""
global _FREEZE
_FREEZE = bool(static)
if _FREEZE and _ui is not None and _vehicle is not None:
- # Snap once to a readable static scene.
- _vehicle.rpm = 3200
- _vehicle.speed_mph = 48
- _vehicle.fuel_frac = 0.62
- _vehicle.coolant_f = 195
- _vehicle.oil_psi = 42
+ _vehicle.speed_mph = 51
+ _vehicle.power_kw = 138
+ _vehicle.gear = "N"
_ui.update()
return _FREEZE
+def _on_key(e):
+ key = e.get_key()
+ if key == lv.KEY.LEFT:
+ _vehicle.toggle_signal("left")
+ elif key == lv.KEY.RIGHT:
+ _vehicle.toggle_signal("right")
+ elif key == lv.KEY.ENTER:
+ _vehicle.running = not _vehicle.running
+
+
+def _listen_for_keys(scr):
+ """A focused, invisible object in the default group receives the keypad."""
+ global _keys
+ group = lv.group_get_default()
+ if group is None:
+ group = lv.group_create()
+ group.set_default()
+ _keys = lv.obj(scr)
+ _keys.set_size(1, 1)
+ _keys.set_pos(0, 0)
+ _keys.set_style_bg_opa(lv.OPA.TRANSP, 0)
+ _keys.set_style_border_width(0, 0)
+ _keys.set_style_outline_width(0, lv.STATE.FOCUSED)
+ _keys.set_style_outline_width(0, lv.STATE.FOCUS_KEY)
+ _keys.remove_flag(lv.obj.FLAG.SCROLLABLE)
+ group.add_obj(_keys)
+ lv.group_focus_obj(_keys)
+ _keys.add_event_cb(_on_key, lv.EVENT.KEY, None)
+
+
def build_ui():
global _ui, _vehicle, _timer, _last_ms
@@ -127,24 +158,12 @@ def build_ui():
inst.disable()
try:
_vehicle = Vehicle()
- nav = focus_nav.FocusNav()
-
- bridge = input_map.InputBridge(app, _vehicle, focus_nav=nav)
- bridge.install()
-
- _ui = layout.ClusterUI(_vehicle, focus_nav=nav)
- # Groups are populated by layout; start focus on the left rail.
- nav.set_active(focus_nav.FocusNav.LEFT)
+ scr = lv.screen_active()
+ _ui = Cluster(_vehicle, scr)
+ _ui.set_clock(_clock_text())
+ _listen_for_keys(scr)
_last_ms = _now_ms()
_timer = lv.timer_create(_on_tick, 40, None)
-
- # Apply deferred rail→tabview changes outside LVGL task_handler.
- def _drain_rails(_t):
- if _ui is not None and _ui.rails is not None:
- _ui.rails.drain_pending()
-
- app.on_tick(_drain_rails, period=30)
- _ui.rails.drain_pending()
if _FREEZE:
freeze(True)
finally:
diff --git a/lib/examples/car_cluster/chrome.py b/lib/examples/car_cluster/chrome.py
deleted file mode 100644
index 3bf7b662c..000000000
--- a/lib/examples/car_cluster/chrome.py
+++ /dev/null
@@ -1,231 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Nested 3D chrome bezels and focus styles for the cluster shell."""
-
-import lvgl as lv
-
-import theme
-
-# Bezel shells registered for shininess / scheme restyle.
-_chrome_shells = []
-
-
-def _circle_radius():
- return getattr(lv, "RADIUS_CIRCLE", 0x7FFF)
-
-
-def _no_scroll(obj):
- if hasattr(obj, "remove_flag"):
- obj.remove_flag(lv.obj.FLAG.SCROLLABLE)
- elif hasattr(obj, "clear_flag"):
- obj.clear_flag(lv.obj.FLAG.SCROLLABLE)
-
-
-def style_root(obj):
- theme.style_bg(obj, theme.bg(), radius=0)
-
-
-def style_panel(obj, radius=8):
- theme.style_bg(obj, theme.panel(), radius=radius, border_w=1, border_color=theme.chrome_mid())
-
-
-def make_focus_style():
- """Accent ring for STATE.FOCUSED (rail / menu controls)."""
- style = lv.style_t()
- style.init()
- style.set_outline_width(2)
- style.set_outline_pad(2)
- style.set_outline_color(theme.accent())
- style.set_outline_opa(lv.OPA.COVER)
- style.set_border_width(2)
- style.set_border_color(theme.accent_lite())
- style.set_border_opa(lv.OPA.COVER)
- return theme.retain_style(style)
-
-
-def _add_specular_strip(parent, radius, pad=2):
- """Thin bright top edge to sell polished metal."""
- strip = lv.obj(parent)
- _no_scroll(strip)
- strip.set_size(lv.pct(100), max(3, pad + 1))
- strip.align(lv.ALIGN.TOP_MID, 0, 0)
- theme.style_chrome(
- strip,
- radius=max(2, radius - 2),
- border_w=0,
- highlight=True,
- vertical=True,
- )
- try:
- strip.set_style_bg_opa(lv.OPA._70, 0)
- except Exception:
- strip.set_style_bg_opa(180, 0)
- return strip
-
-
-def make_bezel(parent, x, y, w, h, depth=3, radius=10, pad=3):
- """Stacked frames for a plastic/chrome edge. Returns (outer, content)."""
- outer = lv.obj(parent)
- outer.set_pos(x, y)
- outer.set_size(w, h)
- _no_scroll(outer)
- # Outer shell: specular→dark vertical metal.
- theme.style_chrome(
- outer,
- radius=radius,
- border_w=2,
- border_color=theme.chrome_hi(),
- highlight=True,
- vertical=True,
- )
- _add_specular_strip(outer, radius, pad=pad)
-
- cur = outer
- inner_r = radius
- metal_layers = []
- for i in range(depth):
- child = lv.obj(cur)
- _no_scroll(child)
- cur.set_style_pad_all(pad if i == 0 else max(1, pad - 1), 0)
- child.set_size(lv.pct(100), lv.pct(100))
- child.align(lv.ALIGN.CENTER, 0, 0)
- inner_r = max(2, inner_r - 2)
- if i == depth - 1:
- theme.style_bg(
- child, theme.face(), radius=inner_r, border_w=1, border_color=theme.chrome_lo()
- )
- elif i % 2 == 0:
- # Bright band
- theme.style_chrome(
- child,
- radius=inner_r,
- border_w=1,
- border_color=theme.chrome_mid(),
- highlight=(i == 0),
- vertical=True,
- )
- metal_layers.append(child)
- else:
- # Dark recess with horizontal sheen for contrast.
- theme.style_chrome(
- child,
- radius=inner_r,
- border_w=1,
- border_color=theme.chrome_specular(),
- highlight=False,
- vertical=False,
- )
- metal_layers.append(child)
- cur = child
-
- content = cur
- # No content padding — callers position with set_pos against the full inner area.
- content.set_style_pad_all(0, 0)
- _chrome_shells.append({"kind": "bezel", "outer": outer, "layers": metal_layers})
- return outer, content
-
-
-def make_center_bezel(parent, x, y, w, h):
- """Thicker, shinier bezel for the center instrument column."""
- return make_bezel(parent, x, y, w, h, depth=4, radius=14, pad=4)
-
-
-def make_gauge_ring(parent, size):
- """Circular chrome ring + dark face for a gauge. Returns (ring, face)."""
- rad = _circle_radius()
- ring = lv.obj(parent)
- ring.set_size(size, size)
- _no_scroll(ring)
- # Outer metal: bright top → dark bottom.
- theme.style_chrome(
- ring,
- radius=rad,
- border_w=2,
- border_color=theme.chrome_specular(),
- highlight=True,
- vertical=True,
- )
-
- # Mid ring with opposite sheen (dark→bright) for a machined edge.
- mid_inset = max(3, size // 36)
- mid = lv.obj(ring)
- mid.set_size(size - 2 * mid_inset, size - 2 * mid_inset)
- mid.center()
- _no_scroll(mid)
- theme.style_chrome(
- mid,
- radius=rad,
- border_w=1,
- border_color=theme.chrome_hi(),
- highlight=False,
- vertical=True,
- )
- # Flip mid gradient visually by swapping ends via local props after style.
- mid.set_style_bg_color(theme.chrome_lo(), 0)
- mid.set_style_bg_grad_color(theme.chrome_specular(), 0)
-
- inset = max(5, size // 22)
- face = lv.obj(ring)
- face.set_size(size - 2 * inset, size - 2 * inset)
- face.center()
- _no_scroll(face)
- theme.style_bg(face, theme.face(), radius=rad, border_w=1, border_color=theme.chrome_lo())
- _chrome_shells.append({"kind": "gauge", "ring": ring, "mid": mid, "face": face})
- return ring, face
-
-
-def apply_theme():
- """Refresh chrome gradients after shininess / brightness / scheme changes."""
- for entry in _chrome_shells:
- kind = entry.get("kind")
- if kind == "bezel":
- outer = entry.get("outer")
- if outer is not None:
- theme.apply_chrome_local(outer, highlight=True, border_color=theme.chrome_hi())
- for i, layer in enumerate(entry.get("layers") or ()):
- theme.apply_chrome_local(
- layer,
- highlight=(i == 0),
- vertical=(i % 2 == 0),
- border_color=theme.chrome_mid() if i % 2 == 0 else theme.chrome_specular(),
- )
- elif kind == "gauge":
- ring = entry.get("ring")
- mid = entry.get("mid")
- face = entry.get("face")
- if ring is not None:
- theme.apply_chrome_local(ring, highlight=True, border_color=theme.chrome_specular())
- if mid is not None:
- mid.set_style_bg_color(theme.chrome_lo(), 0)
- mid.set_style_bg_grad_color(theme.chrome_specular(), 0)
- mid.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
- mid.set_style_border_color(theme.chrome_hi(), 0)
- if face is not None:
- face.set_style_bg_color(theme.face(), 0)
- face.set_style_border_color(theme.chrome_lo(), 0)
-
-
-def style_rail_button(btn, selected=False, *, initial=False):
- """Update rail button colors without stacking new styles.
-
- Calling ``theme.style_bg`` / ``add_style`` on every FOCUSED select accumulates
- style nodes and hard-locks LVGL under arrow-key storms (nesting=1). Mutate
- local style properties instead; use ``initial=True`` once at construction.
- """
- bg = theme.panel_raised() if not selected else theme.accent_dim()
- border = theme.accent() if selected else theme.chrome_mid()
- if initial:
- theme.style_bg(btn, bg, radius=8, border_w=1, border_color=border)
- focus = make_focus_style()
- btn.add_style(focus, lv.STATE.FOCUSED)
- btn.set_style_bg_color(bg, 0)
- btn.set_style_bg_opa(lv.OPA.COVER, 0)
- btn.set_style_border_width(1, 0)
- btn.set_style_border_color(border, 0)
- btn.set_style_radius(8, 0)
- btn.set_style_pad_all(2, 0)
- btn.set_style_pad_row(2, 0)
- btn.set_style_outline_width(2, lv.STATE.FOCUSED)
- btn.set_style_outline_pad(2, lv.STATE.FOCUSED)
- btn.set_style_outline_color(theme.accent(), lv.STATE.FOCUSED)
- btn.set_style_outline_opa(lv.OPA.COVER, lv.STATE.FOCUSED)
- btn.set_style_border_color(theme.accent_lite(), lv.STATE.FOCUSED)
diff --git a/lib/examples/car_cluster/cluster.py b/lib/examples/car_cluster/cluster.py
new file mode 100644
index 000000000..21e600c8f
--- /dev/null
+++ b/lib/examples/car_cluster/cluster.py
@@ -0,0 +1,303 @@
+# SPDX-License-Identifier: MIT
+"""The cluster's screen: status line, indicators, two dials, the road, PRNDS."""
+
+import lvgl as lv
+
+from ring import Ring, _color, plain, scaled_font
+
+_CIRCLE = getattr(lv, "RADIUS_CIRCLE", 0x7FFF)
+
+BG = 0x05080F
+TRACK = 0x0E1E33
+CYAN = 0x3DF0FF
+GREEN = 0x3FE878
+DIM = 0x5E6E8C
+TEXT = 0xE6ECF7
+
+SPEED_STOPS = ((0.0, 0x35F2C8), (0.45, 0x3DE0FF), (1.0, 0x2E7BFF))
+POWER_STOPS = ((0.0, 0x3DE0FF), (0.5, 0x4E8DFF), (1.0, 0xA05CFF))
+
+GEARS = ("P", "R", "N", "D", "S")
+
+
+def _hood(parent, w, y, h, curves):
+ """Faint lines curving like a dash hood, on a strip canvas ``w`` by ``h``.
+
+ Each curve is ``(crest_y, drop, opa, width)``: highest at the centre, and
+ ``drop`` pixels lower at the edges. Drawn as short straight pieces, which
+ is far cheaper than an arc the size of several screens.
+ """
+ strip = lv.canvas(parent)
+ strip.set_size(w, h)
+ strip.set_pos(0, y)
+ plain(strip)
+ buf = lv.draw_buf_create(w, h, lv.COLOR_FORMAT.ARGB8888, 0)
+ strip.set_draw_buf(buf)
+ strip.fill_bg(_color(0x000000), lv.OPA.TRANSP)
+ layer = lv.layer_t()
+ strip.init_layer(layer)
+ line = lv.draw_line_dsc_t()
+ line.init()
+ line.color = _color(CYAN)
+ pieces = 32
+ half = w / 2.0
+ for crest, drop, opa, width in curves:
+ line.opa = opa
+ line.width = width
+ prev = None
+ for i in range(pieces + 1):
+ x = w * i / pieces
+ cy = crest - y + drop * ((x - half) / half) ** 2
+ if prev is not None:
+ line.p1 = lv.point_precise_t({"x": prev[0], "y": prev[1]})
+ line.p2 = lv.point_precise_t({"x": x, "y": cy})
+ lv.draw_line(layer, line)
+ prev = (x, cy)
+ strip.finish_layer(layer)
+ return strip, buf
+
+
+class Road:
+ """The centre: a lit lane that narrows to the horizon, and the car's marker."""
+
+ def __init__(self, parent, x, y, w, h):
+ self.obj = lv.obj(parent)
+ self.obj.set_pos(x, y)
+ self.obj.set_size(w, h)
+ plain(self.obj)
+
+ # Lane edges, converging upward, drawn once on a small canvas.
+ self.canvas = lv.canvas(self.obj)
+ self.canvas.set_size(w, h)
+ self.canvas.center()
+ plain(self.canvas)
+ self._buf = lv.draw_buf_create(w, h, lv.COLOR_FORMAT.ARGB8888, 0)
+ self.canvas.set_draw_buf(self._buf)
+ self.canvas.fill_bg(_color(0x000000), lv.OPA.TRANSP)
+ layer = lv.layer_t()
+ self.canvas.init_layer(layer)
+ line = lv.draw_line_dsc_t()
+ line.init()
+ line.width = 2
+ line.round_start = True
+ line.round_end = True
+ top = h // 6
+ vx = w // 2
+ for sx, ex in ((w * 5 // 100, vx - w // 9), (w * 95 // 100, vx + w // 9)):
+ # Each edge is a few straight pieces that fade toward the horizon.
+ pieces = 5
+ for i in range(pieces):
+ t0 = i / pieces
+ t1 = (i + 1) / pieces
+ line.color = _color(CYAN)
+ line.opa = int(30 + 110 * (1 - t0))
+ line.p1 = lv.point_precise_t({"x": sx + (ex - sx) * t0, "y": h - (h - top) * t0})
+ line.p2 = lv.point_precise_t({"x": sx + (ex - sx) * t1, "y": h - (h - top) * t1})
+ lv.draw_line(layer, line)
+ self.canvas.finish_layer(layer)
+
+ # The beam: a soft column of light from the marker up the lane.
+ beam_h = h * 58 // 100
+ beam_w = max(10, w // 9)
+ self.beam = lv.obj(self.obj)
+ self.beam.set_size(beam_w, beam_h)
+ self.beam.align(lv.ALIGN.TOP_MID, 0, h * 18 // 100)
+ plain(self.beam)
+ self.beam.set_style_radius(beam_w // 2, 0)
+ self.beam.set_style_bg_color(_color(CYAN), 0)
+ self.beam.set_style_bg_grad_color(_color(CYAN), 0)
+ self.beam.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
+ self.beam.set_style_bg_opa(0, 0)
+ self.beam.set_style_bg_grad_opa(200, 0)
+ if hasattr(self.beam, "set_style_blur_radius"):
+ self.beam.set_style_blur_radius(max(3, beam_w // 3), 0)
+
+ core_w = max(3, beam_w // 4)
+ self.core = lv.obj(self.obj)
+ self.core.set_size(core_w, beam_h)
+ self.core.align(lv.ALIGN.TOP_MID, 0, h * 18 // 100)
+ plain(self.core)
+ self.core.set_style_radius(core_w // 2, 0)
+ self.core.set_style_bg_color(_color(0xDDFBFF), 0)
+ self.core.set_style_bg_grad_color(_color(0xDDFBFF), 0)
+ self.core.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
+ self.core.set_style_bg_opa(0, 0)
+ self.core.set_style_bg_grad_opa(255, 0)
+
+ # The marker: a glowing ring with the car's green dot in it.
+ my = h * 18 // 100 + beam_h
+ ring = max(24, w // 3)
+ self.halo = lv.obj(self.obj)
+ self.halo.set_size(min(w - 4, ring * 5 // 2), ring * 2 // 5)
+ self.halo.align(lv.ALIGN.TOP_MID, 0, my + ring * 3 // 5)
+ plain(self.halo)
+ self.halo.set_style_radius(_CIRCLE, 0)
+ self.halo.set_style_bg_color(_color(CYAN), 0)
+ self.halo.set_style_bg_opa(90, 0)
+ if hasattr(self.halo, "set_style_blur_radius"):
+ self.halo.set_style_blur_radius(ring // 3, 0)
+
+ self.ring = lv.obj(self.obj)
+ self.ring.set_size(ring, ring)
+ self.ring.align(lv.ALIGN.TOP_MID, 0, my)
+ plain(self.ring)
+ self.ring.set_style_radius(_CIRCLE, 0)
+ self.ring.set_style_border_width(max(2, ring // 14), 0)
+ self.ring.set_style_border_color(_color(CYAN), 0)
+ self.ring.set_style_border_opa(230, 0)
+ self.ring.set_style_shadow_color(_color(CYAN), 0)
+ self.ring.set_style_shadow_width(ring, 0)
+ self.ring.set_style_shadow_opa(160, 0)
+
+ dot = max(8, ring // 3)
+ self.dot = lv.obj(self.ring)
+ self.dot.set_size(dot, dot)
+ self.dot.center()
+ plain(self.dot)
+ self.dot.set_style_radius(_CIRCLE, 0)
+ self.dot.set_style_bg_color(_color(GREEN), 0)
+ self.dot.set_style_bg_opa(lv.OPA.COVER, 0)
+ self.dot.set_style_shadow_color(_color(GREEN), 0)
+ self.dot.set_style_shadow_width(dot, 0)
+ self.dot.set_style_shadow_opa(200, 0)
+
+
+class Cluster:
+ def __init__(self, vehicle, scr=None):
+ self.vehicle = vehicle
+ scr = scr or lv.screen_active()
+ self.scr = scr
+ W = scr.get_width()
+ H = scr.get_height()
+ self.W, self.H = W, H
+ self._blink = None
+ self._gear = None
+ self._clock = None
+
+ scr.set_style_bg_color(_color(BG), 0)
+ scr.set_style_bg_opa(lv.OPA.COVER, 0)
+ scr.set_style_pad_all(0, 0)
+ scr.remove_flag(lv.obj.FLAG.SCROLLABLE)
+
+ # A lift of blue at the top of the dash, fading to black at the feet.
+ wash = lv.obj(scr)
+ wash.set_size(W, H)
+ wash.set_pos(0, 0)
+ plain(wash)
+ wash.set_style_radius(0, 0)
+ wash.set_style_bg_color(_color(0x0C1736), 0)
+ wash.set_style_bg_grad_color(_color(BG), 0)
+ wash.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
+ wash.set_style_bg_opa(lv.OPA.COVER, 0)
+ wash.set_style_bg_main_stop(0, 0)
+ wash.set_style_bg_grad_stop(170, 0)
+
+ # The dash's own curves above and below the dials.
+ drop = H * 9 // 100
+ self._hoods = (
+ _hood(scr, W, H * 15 // 100, drop + H * 8 // 100, ((H * 17 // 100, drop, 46, 2), (H * 21 // 100, drop, 22, 1))),
+ _hood(scr, W, H * 82 // 100, drop + H * 4 // 100, ((H * 84 // 100, drop, 36, 2),)),
+ )
+
+ # Status line: date, clock, outside temperature.
+ self.date = self._text(scr, "9/30", lv.font_montserrat_16, DIM)
+ self.clock = self._text(scr, "9:59 PM", lv.font_montserrat_24, TEXT)
+ self.temp = self._text(scr, "54~61\u00b0F", lv.font_montserrat_16, DIM)
+ self.clock.align(lv.ALIGN.TOP_MID, 0, H * 5 // 100)
+ self.date.align_to(self.clock, lv.ALIGN.OUT_LEFT_MID, -W * 5 // 100, 0)
+ self.temp.align_to(self.clock, lv.ALIGN.OUT_RIGHT_MID, W * 5 // 100, 0)
+
+ # Turn indicators.
+ self.left = self._text(scr, "<<<", lv.font_montserrat_24, CYAN)
+ self.right = self._text(scr, ">>>", lv.font_montserrat_24, CYAN)
+ self.left.align(lv.ALIGN.TOP_MID, -W * 13 // 100, H * 18 // 100)
+ self.right.align(lv.ALIGN.TOP_MID, W * 13 // 100, H * 18 // 100)
+ for lamp in (self.left, self.right):
+ lamp.set_style_text_letter_space(-3, 0)
+
+ # The dials.
+ size = min(H * 62 // 100, W * 34 // 100)
+ cy = H * 50 // 100
+ self.speed = Ring(
+ scr,
+ size,
+ SPEED_STOPS,
+ 120,
+ track=TRACK,
+ bg=BG,
+ unit="mph",
+ caption="13488 mi",
+ )
+ self.speed.set_pos(W * 27 // 100 - size // 2, cy - size // 2)
+ self.power = Ring(
+ scr,
+ size,
+ POWER_STOPS,
+ 200,
+ track=TRACK,
+ bg=BG,
+ unit="kW",
+ caption="3.7 mi/kWh",
+ )
+ self.power.set_pos(W * 73 // 100 - size // 2, cy - size // 2)
+
+ # The road between them.
+ road_w = W * 18 // 100
+ road_h = size * 92 // 100
+ self.road = Road(scr, W // 2 - road_w // 2, cy - road_h // 2 - H // 50, road_w, road_h)
+
+ # P R N D S along the bottom.
+ self.gears = {}
+ self.gear_box = lv.obj(scr)
+ box = max(22, H // 15)
+ self.gear_box.set_size(box, box)
+ plain(self.gear_box)
+ self.gear_box.set_style_radius(max(3, box // 7), 0)
+ self.gear_box.set_style_border_width(2, 0)
+ self.gear_box.set_style_border_color(_color(GREEN), 0)
+ self.gear_box.set_style_border_opa(200, 0)
+ self.gear_box.set_style_shadow_color(_color(GREEN), 0)
+ self.gear_box.set_style_shadow_width(box // 2, 0)
+ self.gear_box.set_style_shadow_opa(90, 0)
+ pitch = max(30, W * 55 // 1000)
+ for i, g in enumerate(GEARS):
+ lbl = self._text(scr, g, lv.font_montserrat_24, DIM)
+ lbl.set_style_text_letter_space(0, 0)
+ lbl.align(lv.ALIGN.BOTTOM_MID, (i - 2) * pitch, -H * 4 // 100)
+ self.gears[g] = lbl
+
+ self.update()
+
+ def _text(self, parent, text, font, color):
+ lbl = lv.label(parent)
+ lbl.set_text(text)
+ lbl.set_style_text_font(font, 0)
+ lbl.set_style_text_color(_color(color), 0)
+ lbl.set_style_text_letter_space(1, 0)
+ return lbl
+
+ def set_clock(self, text):
+ if text != self._clock:
+ self._clock = text
+ self.clock.set_text(text)
+
+ def update(self):
+ v = self.vehicle
+ self.speed.set_value(v.speed_mph)
+ self.power.set_value(abs(v.power_kw))
+ self.speed.set_caption("%d mi" % int(v.odometer_mi))
+ self.power.set_caption("%.1f mi/kWh" % v.mi_per_kwh)
+ self.power.unit.set_text("kW regen" if v.power_kw < -0.5 else "kW")
+
+ on = v.blink_on()
+ state = (v.left and on, v.right and on)
+ if state != self._blink:
+ self._blink = state
+ self.left.set_style_text_opa(255 if state[0] else 70, 0)
+ self.right.set_style_text_opa(255 if state[1] else 70, 0)
+
+ if v.gear != self._gear:
+ self._gear = v.gear
+ for g, lbl in self.gears.items():
+ lbl.set_style_text_color(_color(GREEN if g == v.gear else DIM), 0)
+ self.gear_box.align_to(self.gears[v.gear], lv.ALIGN.CENTER, 0, 0)
diff --git a/lib/examples/car_cluster/focus_nav.py b/lib/examples/car_cluster/focus_nav.py
deleted file mode 100644
index 8041171bc..000000000
--- a/lib/examples/car_cluster/focus_nav.py
+++ /dev/null
@@ -1,171 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Multi-group focus navigation for left rail / center / right rail."""
-
-import lvgl as lv
-
-
-def _set_wrap(group, en=True):
- try:
- group.set_wrap(en)
- except Exception:
- pass
-
-
-def _focus_first(group):
- if group is None:
- return
- try:
- n = group.get_obj_count()
- except Exception:
- n = 0
- if n <= 0:
- return
- try:
- obj = group.get_obj_by_index(0)
- if obj is not None:
- lv.group_focus_obj(obj)
- return
- except Exception:
- pass
- try:
- group.focus_next()
- except Exception:
- try:
- lv.group_focus_next(group)
- except Exception:
- pass
-
-
-class FocusNav:
- """Three LVGL groups: left rail, center content, right rail.
-
- LEFT/RIGHT — switch active group (and point keypad indev at it)
- UP/DOWN — previous/next within the active group
- ENTER — activate focused control (via KEY_ENTER to the indev path)
- """
-
- LEFT = 0
- CENTER = 1
- RIGHT = 2
-
- def __init__(self):
- self.left = lv.group_create()
- self.center = lv.group_create()
- self.right = lv.group_create()
- for g in (self.left, self.center, self.right):
- _set_wrap(g, True)
- self._groups = (self.left, self.center, self.right)
- self._active = self.CENTER
- self._keypad_indev = None
-
- # Keep default group empty so display_driver doesn't dump everything here.
- default = lv.group_get_default()
- if default is None:
- default = self.center
- default.set_default()
- self._default = default
-
- def bind_keypad_indev(self, indev):
- self._keypad_indev = indev
- self._apply_indev_group()
-
- def active_group(self):
- return self._groups[self._active]
-
- def _apply_indev_group(self):
- g = self.active_group()
- if self._keypad_indev is not None:
- try:
- self._keypad_indev.set_group(g)
- except Exception:
- pass
- try:
- g.set_default()
- except Exception:
- pass
-
- def set_active(self, index):
- index = int(index) % 3
- if index == self._active:
- self._apply_indev_group()
- return
- prev = self.active_group()
- self._defocus_group(prev)
- self._active = index
- self._apply_indev_group()
- g = self.active_group()
- focused = None
- try:
- focused = g.get_focused()
- except Exception:
- try:
- focused = lv.group_get_focused(g)
- except Exception:
- focused = None
- if focused is None:
- _focus_first(g)
- else:
- try:
- lv.group_focus_obj(focused)
- except Exception:
- pass
-
- def focus_next(self):
- g = self.active_group()
- try:
- g.focus_next()
- except Exception:
- try:
- lv.group_focus_next(g)
- except Exception:
- pass
-
- def focus_prev(self):
- g = self.active_group()
- try:
- g.focus_prev()
- except Exception:
- try:
- lv.group_focus_prev(g)
- except Exception:
- pass
-
- def _defocus_group(self, group):
- focused = None
- try:
- focused = group.get_focused()
- except Exception:
- try:
- focused = lv.group_get_focused(group)
- except Exception:
- focused = None
- if focused is None:
- return
- try:
- focused.remove_state(lv.STATE.FOCUSED)
- except Exception:
- pass
- try:
- focused.remove_state(lv.STATE.FOCUS_KEY)
- except Exception:
- pass
-
- def handle_nav_key(self, lv_key):
- """Handle remapped lv.KEY value. Return True if consumed.
-
- UP/DOWN — previous/next member in the active group
- LEFT/RIGHT — switch active group (left / center / right)
- """
- if lv_key == lv.KEY.UP:
- self.focus_prev()
- return True
- if lv_key == lv.KEY.DOWN:
- self.focus_next()
- return True
- if lv_key == lv.KEY.LEFT:
- self.set_active(self._active - 1)
- return True
- if lv_key == lv.KEY.RIGHT:
- self.set_active(self._active + 1)
- return True
- return False
diff --git a/lib/examples/car_cluster/gauges.py b/lib/examples/car_cluster/gauges.py
deleted file mode 100644
index bbfb120fb..000000000
--- a/lib/examples/car_cluster/gauges.py
+++ /dev/null
@@ -1,743 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Coherent analog / digital gauges for the instrument cluster."""
-
-import lvgl as lv
-
-import chrome
-import lv_util
-import theme
-
-_styles = []
-
-
-def _retain(style):
- _styles.append(style)
- theme.retain_style(style)
- return style
-
-
-def _no_scroll(obj):
- if hasattr(obj, "remove_flag"):
- obj.remove_flag(lv.obj.FLAG.SCROLLABLE)
- elif hasattr(obj, "clear_flag"):
- obj.clear_flag(lv.obj.FLAG.SCROLLABLE)
-
-
-def _overflow_visible(obj):
- if hasattr(obj, "add_flag"):
- try:
- obj.add_flag(lv.obj.FLAG.OVERFLOW_VISIBLE)
- except Exception:
- pass
-
-
-def _style_scale(scale_obj, *, tick_w=2, minor_len=10, major_len=16):
- # Background arc under ticks
- st = lv.style_t()
- st.init()
- st.set_arc_color(theme.chrome_lo())
- st.set_arc_width(4)
- st.set_arc_opa(lv.OPA.COVER)
- scale_obj.add_style(st, lv.PART.MAIN)
- _retain(st)
-
- # Major ticks (LV_PART_INDICATOR)
- st_maj = lv.style_t()
- st_maj.init()
- st_maj.set_line_color(theme.tick())
- st_maj.set_line_opa(lv.OPA.COVER)
- st_maj.set_line_width(tick_w + 1)
- st_maj.set_line_rounded(True)
- st_maj.set_length(major_len)
- st_maj.set_text_color(theme.text_dim())
- scale_obj.add_style(st_maj, lv.PART.INDICATOR)
- _retain(st_maj)
-
- # Minor ticks (LV_PART_ITEMS)
- st_min = lv.style_t()
- st_min.init()
- st_min.set_line_color(theme.chrome_mid())
- st_min.set_line_opa(lv.OPA.COVER)
- st_min.set_line_width(tick_w)
- st_min.set_line_rounded(True)
- st_min.set_length(minor_len)
- scale_obj.add_style(st_min, lv.PART.ITEMS)
- _retain(st_min)
-
- # Local styles as a belt-and-suspenders (some ports read these first).
- try:
- scale_obj.set_style_length(major_len, lv.PART.INDICATOR)
- scale_obj.set_style_line_width(tick_w + 1, lv.PART.INDICATOR)
- scale_obj.set_style_line_color(theme.tick(), lv.PART.INDICATOR)
- scale_obj.set_style_line_opa(lv.OPA.COVER, lv.PART.INDICATOR)
- scale_obj.set_style_length(minor_len, lv.PART.ITEMS)
- scale_obj.set_style_line_width(tick_w, lv.PART.ITEMS)
- scale_obj.set_style_line_color(theme.chrome_mid(), lv.PART.ITEMS)
- scale_obj.set_style_line_opa(lv.OPA.COVER, lv.PART.ITEMS)
- except Exception:
- pass
-
-
-def _add_zone_section(scale_obj, vmin, vmax, color_fn):
- sec = scale_obj.add_section()
- scale_obj.set_section_range(sec, int(vmin), int(vmax))
- st = lv.style_t()
- st.init()
- c = color_fn()
- st.set_arc_color(c)
- st.set_arc_width(8)
- st.set_line_color(c)
- for setter in (
- lambda: scale_obj.section_set_style(sec, lv.PART.INDICATOR, st),
- lambda: scale_obj.set_section_style_indicator(sec, st),
- lambda: scale_obj.section_set_style(sec, lv.PART.ITEMS, st),
- lambda: scale_obj.set_section_style_items(sec, st),
- ):
- try:
- setter()
- break
- except Exception:
- continue
- _retain(st)
- return sec
-
-
-def _hub(parent, size):
- rad = getattr(lv, "RADIUS_CIRCLE", 0x7FFF)
- hub = lv.obj(parent)
- hub.set_size(size, size)
- hub.center()
- _no_scroll(hub)
- theme.style_bg(hub, theme.chrome_hi(), radius=rad, border_w=2, border_color=theme.accent_dim())
- inner = lv.obj(hub)
- inner.set_size(max(6, size // 2), max(6, size // 2))
- inner.center()
- _no_scroll(inner)
- theme.style_bg(inner, theme.accent(), radius=rad, border_w=1, border_color=theme.accent_lite())
- dot = lv.obj(hub)
- dot.set_size(max(3, size // 6), max(3, size // 6))
- dot.center()
- _no_scroll(dot)
- theme.style_bg(dot, theme.secondary(), radius=rad)
- return hub
-
-
-def _cap_label(parent, text, align, x_ofs=0, y_ofs=0):
- lbl = lv.label(parent)
- lbl.set_text(text)
- lbl.set_style_text_color(theme.text_dim(), 0)
- theme.apply_font(lbl, theme.pick_font(max(100, parent.get_width() // 4), parent))
- lbl.align(align, x_ofs, y_ofs)
- return lbl
-
-
-def _scale_2x(lbl):
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def _cap_mid(g, *, x_extra=0, y=0, bottom=False):
- """Mid-align min/max caps (design session); optional inset / vertical offset."""
- if g is None:
- return
- x_ofs = max(8, g.size // 10) + x_extra
- align_l = lv.ALIGN.BOTTOM_LEFT if bottom else lv.ALIGN.LEFT_MID
- align_r = lv.ALIGN.BOTTOM_RIGHT if bottom else lv.ALIGN.RIGHT_MID
- y_ofs = -g.size // 6 if bottom else y
- for cap, align, x in (
- (getattr(g, "cap_min_lbl", None), align_l, x_ofs),
- (getattr(g, "cap_max_lbl", None), align_r, -x_ofs),
- ):
- if cap is None:
- continue
- cap.set_style_translate_x(0, 0)
- cap.set_style_translate_y(0, 0)
- try:
- cap.set_style_transform_scale(256, 0)
- except Exception:
- cap.set_style_transform_scale_x(256, 0)
- cap.set_style_transform_scale_y(256, 0)
- cap.align(align, x, y_ofs)
- _scale_2x(cap)
-
-
-class AnalogGauge:
- def __init__(
- self,
- parent,
- size,
- vmin,
- vmax,
- *,
- angle_range=270,
- rotation=135,
- ticks=41,
- major_every=5,
- label="",
- unit="",
- redline=None,
- cap_min="",
- cap_max="",
- needle_len=None,
- arc_mode=True,
- bg_angles=None,
- value_fmt=None,
- show_value=True,
- design_layout=None,
- ):
- self.vmin = vmin
- self.vmax = vmax
- self.value = vmin
- self.unit = unit
- self.label = label
- self.size = size
- self._value_fmt = value_fmt
- self._show_value = show_value
- self._design_layout = design_layout
- self._needle_len = needle_len if needle_len is not None else int(size * 0.42)
-
- # Size from the constructor arg — get_width() is often 0 before layout.
- ring_inset = max(4, size // 28)
- face_sz = size - 2 * ring_inset
-
- self.ring, self.face = chrome.make_gauge_ring(parent, size)
- self.face.set_style_pad_all(0, 0)
- _overflow_visible(self.ring)
- _overflow_visible(self.face)
- for obj in (self.ring, self.face):
- try:
- obj.set_style_clip_corner(False, 0)
- except Exception:
- pass
-
- # Accent ring drawn as a non-clipping child (ticks live on the face).
- inset = max(4, size // 28)
- inner_sz = max(40, face_sz - 2 * inset)
- self.inner_ring = lv.obj(self.face)
- self.inner_ring.set_size(inner_sz, inner_sz)
- self.inner_ring.center()
- self.inner_ring.set_style_pad_all(0, 0)
- _no_scroll(self.inner_ring)
- _overflow_visible(self.inner_ring)
- try:
- self.inner_ring.set_style_clip_corner(False, 0)
- except Exception:
- pass
- theme.style_bg(
- self.inner_ring,
- theme.face(),
- radius=getattr(lv, "RADIUS_CIRCLE", 0x7FFF),
- border_w=1,
- border_color=theme.accent_dim(),
- )
- self.inner_ring.set_style_pad_all(0, 0)
- # Keep accent ring under the scale so it cannot cover ticks.
- try:
- self.inner_ring.move_background()
- except Exception:
- pass
-
- font_lg = theme.pick_font(size, self.face)
- font_sm = theme.pick_font(max(160, size // 2), self.face)
-
- # Sweep arc UNDER the scale — a later sibling paints over ticks/needles (H-J).
- # Named sweep_arc: binding corrupts attribute name ``arc`` on some ports.
- self.sweep_arc = None
- if arc_mode:
- arc_sz = max(40, face_sz - max(22, size // 7))
- self.sweep_arc = lv.arc(self.face)
- self.sweep_arc.set_size(arc_sz, arc_sz)
- self.sweep_arc.center()
- self.sweep_arc.set_style_pad_all(0, 0)
- try:
- self.sweep_arc.set_style_bg_opa(lv.OPA.TRANSP, 0)
- except Exception:
- pass
- self.sweep_arc.set_range(int(vmin), int(vmax))
- if bg_angles is not None:
- self.sweep_arc.set_bg_angles(bg_angles[0], bg_angles[1])
- else:
- self.sweep_arc.set_bg_angles(rotation, (rotation + angle_range) % 360)
- self.sweep_arc.set_mode(lv.arc.MODE.NORMAL)
- st_a = lv.style_t()
- st_a.init()
- st_a.set_arc_color(theme.chrome_lo())
- st_a.set_arc_width(8)
- self.sweep_arc.add_style(st_a, lv.PART.MAIN)
- _retain(st_a)
- st_i = lv.style_t()
- st_i.init()
- st_i.set_arc_color(theme.accent())
- st_i.set_arc_width(8)
- self.sweep_arc.add_style(st_i, lv.PART.INDICATOR)
- _retain(st_i)
- self._arc_ind_style = st_i
- st_k = lv.style_t()
- st_k.init()
- st_k.set_bg_opa(lv.OPA.TRANSP)
- st_k.set_pad_all(0)
- st_k.set_width(0)
- st_k.set_height(0)
- self.sweep_arc.add_style(st_k, lv.PART.KNOB)
- _retain(st_k)
- lv_util.hide_clickable(self.sweep_arc)
- else:
- self._arc_ind_style = None
-
- self.scale = lv.scale(self.face)
- self.scale.set_size(face_sz, face_sz)
- self.scale.center()
- self.scale.set_style_pad_all(0, 0)
- try:
- self.scale.set_style_bg_opa(lv.OPA.TRANSP, 0)
- except Exception:
- pass
- _overflow_visible(self.scale)
- try:
- self.scale.set_style_clip_corner(False, 0)
- except Exception:
- pass
- # INNER: ticks onto the dark face (OUTER sat on chrome / under the arc).
- self.scale.set_mode(lv.scale.MODE.ROUND_INNER)
- self.scale.set_range(int(vmin), int(vmax))
- self.scale.set_angle_range(int(angle_range))
- self.scale.set_rotation(int(rotation))
- self.scale.set_total_tick_count(int(ticks))
- self.scale.set_major_tick_every(int(major_every))
- self.scale.set_label_show(False)
- maj = max(10, size // 16)
- mn = max(6, size // 26)
- _style_scale(self.scale, tick_w=2, minor_len=mn, major_len=maj)
-
- if redline is not None:
- _add_zone_section(self.scale, redline, vmax, theme.danger)
- elif vmax > vmin * 0.7:
- warn_at = vmin + (vmax - vmin) * 0.78
- _add_zone_section(self.scale, warn_at, vmax, theme.warn)
-
- self.needle_shadow = lv.line(self.scale)
- st_ns = lv.style_t()
- st_ns.init()
- st_ns.set_line_color(theme.chrome_lo())
- st_ns.set_line_width(5)
- st_ns.set_line_rounded(True)
- self.needle_shadow.add_style(st_ns, 0)
- _retain(st_ns)
-
- self.needle = lv.line(self.scale)
- st_n = lv.style_t()
- st_n.init()
- st_n.set_line_color(theme.needle())
- st_n.set_line_width(3)
- st_n.set_line_rounded(True)
- self.needle.add_style(st_n, 0)
- _retain(st_n)
- self._needle_style = st_n
-
- try:
- self.scale.move_foreground()
- except Exception:
- pass
-
- self.hub = _hub(self.face, max(12, size // 10))
- try:
- self.hub.move_foreground()
- except Exception:
- pass
-
- if label:
- self.title_lbl = lv.label(self.face)
- self.title_lbl.set_text(label)
- self.title_lbl.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(self.title_lbl, font_sm)
- self.title_lbl.set_style_pad_all(0, 0)
- self.title_lbl.align(lv.ALIGN.TOP_MID, 0, max(2, size // 20))
- else:
- self.title_lbl = None
-
- if show_value:
- self.value_lbl = lv.label(self.face)
- self.value_lbl.set_text("--")
- self.value_lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(self.value_lbl, font_lg)
- self.value_lbl.set_style_pad_all(0, 0)
- self.value_lbl.align(lv.ALIGN.BOTTOM_MID, 0, -max(4, size // 14))
- else:
- self.value_lbl = None
-
- self.cap_min_lbl = (
- _cap_label(self.face, cap_min, lv.ALIGN.BOTTOM_LEFT, max(8, size // 10), -size // 6)
- if cap_min
- else None
- )
- self.cap_max_lbl = (
- _cap_label(self.face, cap_max, lv.ALIGN.BOTTOM_RIGHT, -max(8, size // 10), -size // 6)
- if cap_max
- else None
- )
-
- # Labels stay above ticks; keep scale above the arc (hub already raised).
- try:
- self.scale.move_foreground()
- if self.hub is not None:
- self.hub.move_foreground()
- if self.title_lbl is not None:
- self.title_lbl.move_foreground()
- if self.value_lbl is not None:
- self.value_lbl.move_foreground()
- except Exception:
- pass
-
- self.set_value(vmin)
- if design_layout:
- self.apply_design_layout(design_layout)
-
- def apply_design_layout(self, kind=None):
- """Bake interactive design-session label / cap placement into the gauge."""
- if kind is None:
- kind = self._design_layout
- if not kind:
- return
- self._design_layout = kind
-
- try:
- lv.obj.update_layout(self.face)
- except Exception:
- pass
-
- for lbl in (self.title_lbl, self.value_lbl, self.cap_min_lbl, self.cap_max_lbl):
- if lbl is not None:
- _scale_2x(lbl)
-
- title = self.title_lbl
- if title is not None:
- h = max(1, title.get_height())
- half = h + h // 2
- if kind in ("rpm", "speed"):
- ty = 2 * half
- elif kind == "temp":
- ty = half + half // 2
- else: # fuel, oil
- ty = half
- title.set_style_translate_x(0, 0)
- title.set_style_translate_y(0, 0)
- title.align(lv.ALIGN.CENTER, 0, ty)
- _scale_2x(title)
-
- cap0 = self.cap_min_lbl
- cap10 = self.cap_max_lbl
- w0 = max(1, cap0.get_width()) if cap0 is not None else 0
- w10 = max(1, cap10.get_width()) if cap10 is not None else 0
- if kind == "rpm":
- _cap_mid(self, x_extra=2 * w10 + w0, bottom=True)
- elif kind == "speed":
- th = max(1, title.get_height()) if title is not None else 16
- ty = 2 * (th + th // 2)
- _cap_mid(self, x_extra=w0, y=ty)
- elif kind == "oil":
- _cap_mid(self, x_extra=w0)
- else:
- _cap_mid(self)
-
- if kind in ("oil", "temp", "speed") and self.value_lbl is not None:
- h = max(1, self.value_lbl.get_height())
- y = -max(4, self.size // 14) - h // 2
- self.value_lbl.set_style_translate_x(0, 0)
- self.value_lbl.set_style_translate_y(0, 0)
- self.value_lbl.align(lv.ALIGN.BOTTOM_MID, 0, y)
- _scale_2x(self.value_lbl)
-
- def _format_value(self, v):
- if self._value_fmt is not None:
- return self._value_fmt(v)
- iv = int(round(v))
- if self.unit:
- return "%d %s" % (iv, self.unit)
- return str(iv)
-
- def set_value(self, v):
- if v < self.vmin:
- v = self.vmin
- if v > self.vmax:
- v = self.vmax
- self.value = v
- iv = int(round(v))
- for needle, length in ((self.needle_shadow, self._needle_len + 1), (self.needle, self._needle_len)):
- try:
- self.scale.set_line_needle_value(needle, length, iv)
- except Exception:
- pass
- if self.sweep_arc is not None:
- self.sweep_arc.set_value(iv)
- if self.value_lbl is None:
- return
- self.value_lbl.set_text(self._format_value(v))
- if v >= self.vmax * 0.92:
- self.value_lbl.set_style_text_color(theme.danger(), 0)
- elif v >= self.vmax * 0.75:
- self.value_lbl.set_style_text_color(theme.warn(), 0)
- else:
- self.value_lbl.set_style_text_color(theme.text(), 0)
- if self._design_layout:
- _scale_2x(self.value_lbl)
-
- def set_pos(self, x, y):
- self.ring.set_pos(x, y)
-
- def widget(self):
- return self.ring
-
- def apply_theme(self):
- """Refresh accent-dependent colors after a scheme change."""
- self.ring.set_style_bg_color(theme.chrome_specular(), 0)
- self.ring.set_style_bg_grad_color(theme.chrome_lo(), 0)
- self.ring.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
- self.ring.set_style_border_color(theme.chrome_specular(), 0)
- self.face.set_style_bg_color(theme.face(), 0)
- self.face.set_style_border_color(theme.chrome_lo(), 0)
- self.inner_ring.set_style_bg_color(theme.face(), 0)
- self.inner_ring.set_style_border_color(theme.accent_dim(), 0)
- self.scale.set_style_line_color(theme.tick(), lv.PART.INDICATOR)
- self.scale.set_style_line_color(theme.chrome_mid(), lv.PART.ITEMS)
- if self._arc_ind_style is not None:
- self._arc_ind_style.set_arc_color(theme.accent())
- if getattr(self, "_needle_style", None) is not None:
- self._needle_style.set_line_color(theme.needle())
- try:
- self.hub.set_style_bg_color(theme.chrome_hi(), 0)
- self.hub.set_style_border_color(theme.accent_dim(), 0)
- self.hub.get_child(0).set_style_bg_color(theme.accent(), 0)
- self.hub.get_child(0).set_style_border_color(theme.accent_lite(), 0)
- self.hub.get_child(1).set_style_bg_color(theme.secondary(), 0)
- except Exception:
- pass
- if self.title_lbl is not None:
- self.title_lbl.set_style_text_color(theme.accent_lite(), 0)
- for lbl in (self.cap_min_lbl, self.cap_max_lbl):
- if lbl is not None:
- lbl.set_style_text_color(theme.text_dim(), 0)
- self.set_value(self.value)
- if self._design_layout:
- self.apply_design_layout(self._design_layout)
-
-
-class DigitalSpeed:
- def __init__(self, parent, w, h):
- self.value = 0
- self._box_w = w
- self._box_h = h
- self._num_scale = 256
- self._num_y_ofs = -h // 12
- self.box = lv.obj(parent)
- self.box.set_size(w, h)
- _no_scroll(self.box)
- theme.style_bg(self.box, theme.face(), radius=12, border_w=2, border_color=theme.accent_dim())
-
- font = theme.pick_font(max(w, h), self.box)
- font_sm = theme.pick_font(max(180, h // 2), self.box)
-
- # Full-width + text-align center: stays parent-centered as digits change.
- self.num_shadow = lv.label(self.box)
- self.num_shadow.set_text("0")
- self.num_shadow.set_style_text_color(theme.chrome_lo(), 0)
- theme.apply_font(self.num_shadow, font)
- self.num_shadow.set_width(w)
- self.num_shadow.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0)
-
- self.num = lv.label(self.box)
- self.num.set_text("0")
- self.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0)
- theme.apply_font(self.num, font)
- self.num.set_width(w)
- self.num.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0)
- try:
- self.num.set_style_text_letter_space(6, 0)
- self.num_shadow.set_style_text_letter_space(6, 0)
- except Exception:
- pass
-
- self._layout_num()
-
- self.unit = lv.label(self.box)
- self.unit.set_text("MPH")
- self.unit.set_style_text_color(theme.secondary(), 0)
- theme.apply_font(self.unit, font_sm)
- uh = max(1, self.unit.get_height())
- self.unit.align(lv.ALIGN.BOTTOM_MID, 0, -12 - uh)
-
- def _center_scaled(self, lbl, scale, x_ofs=0, y_ofs=0):
- box_w, box_h = self._box_w, self._box_h
- lbl.set_style_translate_x(0, 0)
- lbl.set_style_translate_y(0, 0)
- lbl.align(lv.ALIGN.CENTER, x_ofs, y_ofs)
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale_x(scale, 0)
- lbl.set_style_transform_scale_y(scale, 0)
- except Exception:
- lbl.set_style_transform_scale(scale, 0)
- cx = lbl.get_x() + w // 2
- cy = lbl.get_y() + h // 2
- lbl.set_style_translate_x(box_w // 2 + x_ofs - cx, 0)
- lbl.set_style_translate_y(box_h // 2 + y_ofs - cy, 0)
-
- def _layout_num(self):
- y_ofs = self._num_y_ofs
- scale = self._num_scale
- self._center_scaled(self.num, scale, 0, y_ofs)
- self._center_scaled(self.num_shadow, scale, 4, y_ofs + 4)
-
- def set_num_scale(self, scale):
- """Transform scale for the digital digits (256 = 1×, 2048 = 8×)."""
- self._num_scale = int(scale)
- self._layout_num()
-
- def set_value(self, v):
- v = int(_clamp_int(v, 0, 199))
- self.value = v
- text = "%d" % v
- self.num.set_text(text)
- self.num_shadow.set_text(text)
- if v >= 100:
- self.num.set_style_text_color(theme.warn(), 0)
- else:
- self.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0)
- self._layout_num()
-
- def set_pos(self, x, y):
- self.box.set_pos(x, y)
-
- def widget(self):
- return self.box
-
- def apply_theme(self):
- self.box.set_style_border_color(theme.accent_dim(), 0)
- self.set_value(self.value)
- self.unit.set_style_text_color(theme.secondary(), 0)
-
-
-def _clamp_int(v, lo, hi):
- v = int(v)
- if v < lo:
- return lo
- if v > hi:
- return hi
- return v
-
-
-def make_rpm_gauge(parent, size):
- g = AnalogGauge(
- parent,
- size,
- 0,
- 10000,
- angle_range=270,
- rotation=135,
- ticks=51,
- major_every=5,
- label="RPM",
- unit="",
- redline=6500,
- cap_min="0",
- cap_max="10",
- arc_mode=True,
- value_fmt=lambda v: "%.1f" % (v / 1000.0),
- design_layout="rpm",
- )
- return g
-
-
-def make_fuel_gauge(parent, size):
- return AnalogGauge(
- parent,
- size,
- 0,
- 100,
- angle_range=120,
- rotation=210,
- ticks=21,
- major_every=5,
- label="FUEL",
- unit="%",
- cap_min="E",
- cap_max="F",
- arc_mode=True,
- bg_angles=(210, 330),
- show_value=False,
- design_layout="fuel",
- )
-
-
-def make_temp_gauge(parent, size):
- return AnalogGauge(
- parent,
- size,
- 100,
- 260,
- angle_range=150,
- rotation=195,
- ticks=33,
- major_every=4,
- label="COOLANT",
- unit="°F",
- redline=212,
- cap_min="C",
- cap_max="H",
- arc_mode=True,
- bg_angles=(195, 345),
- design_layout="temp",
- )
-
-
-def make_oil_gauge(parent, size):
- return AnalogGauge(
- parent,
- size,
- 0,
- 80,
- angle_range=180,
- rotation=180,
- ticks=17,
- major_every=4,
- label="OIL",
- unit="psi",
- cap_min="0",
- cap_max="80",
- arc_mode=True,
- bg_angles=(180, 360),
- design_layout="oil",
- )
-
-
-def make_speed_gauge(parent, size):
- return AnalogGauge(
- parent,
- size,
- 0,
- 125,
- angle_range=270,
- rotation=135,
- ticks=26,
- major_every=5,
- label="SPEED",
- unit="mph",
- redline=100,
- cap_min="0",
- cap_max="125",
- arc_mode=True,
- design_layout="speed",
- )
-
-
-def make_digital_speed(parent, w, h):
- return DigitalSpeed(parent, w, h)
diff --git a/lib/examples/car_cluster/input_map.py b/lib/examples/car_cluster/input_map.py
deleted file mode 100644
index 1be7df27d..000000000
--- a/lib/examples/car_cluster/input_map.py
+++ /dev/null
@@ -1,169 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""SDL keycode → LVGL KEY_* mapping + multi-group focus navigation."""
-
-import lvgl as lv
-
-try:
- import keys
-except ImportError:
- keys = None
-
-try:
- import events
-except ImportError:
- events = None
-
-# Captured when display_driver expands HOST → VirtualDevices (see capture_virtual_devices).
-_keypad_vdev = None
-_vehicle = None
-_focus_nav = None
-_capture_done = False
-
-
-def _key_const(name, default):
- if keys is None:
- return default
- return getattr(keys, name, default)
-
-
-_SDL_UP = _key_const("K_UP", 1073741906)
-_SDL_DOWN = _key_const("K_DOWN", 1073741905)
-_SDL_LEFT = _key_const("K_LEFT", 1073741904)
-_SDL_RIGHT = _key_const("K_RIGHT", 1073741903)
-_SDL_RETURN = _key_const("K_RETURN", 13)
-_SDL_KP_ENTER = _key_const("K_KP_ENTER", 1073741912)
-
-_DIGIT_KEYS = {}
-for _i in range(10):
- _DIGIT_KEYS[_key_const("K_%d" % _i, 48 + _i)] = _i
-if keys is not None:
- _DIGIT_KEYS[keys.K_KP_0] = 0
- for _i in range(1, 10):
- _DIGIT_KEYS[getattr(keys, "K_KP_%d" % _i)] = _i
-
-
-def remap_nav_key(sdl_key):
- """Map appdev/SDL key codes to lv.KEY for group navigation."""
- if sdl_key == _SDL_UP:
- return lv.KEY.UP
- if sdl_key == _SDL_DOWN:
- return lv.KEY.DOWN
- if sdl_key == _SDL_LEFT:
- return lv.KEY.LEFT
- if sdl_key == _SDL_RIGHT:
- return lv.KEY.RIGHT
- if sdl_key in (_SDL_RETURN, _SDL_KP_ENTER):
- return lv.KEY.ENTER
- return None
-
-
-def digit_from_key(sdl_key):
- return _DIGIT_KEYS.get(sdl_key)
-
-
-def capture_virtual_devices():
- """Retained as a no-op for callers predating the local LVGL input bridge."""
-
-
-def _find_keypad_indev():
- """Return the LVGL keypad indev created by display_driver, if any."""
- global _keypad_vdev
- if _keypad_vdev is None:
- try:
- import display_driver
-
- for virtual in getattr(display_driver._driver_ref, "virtual_devices", ()):
- _keypad_vdev = virtual._vd_keypad
- break
- except Exception:
- pass
- if _keypad_vdev is not None:
- ud = getattr(_keypad_vdev, "user_data", None)
- if ud is not None:
- return ud
- # Fallback: scan indevs
- try:
- indev = lv.indev_get_next(None)
- while indev is not None:
- try:
- if indev.get_type() == lv.INDEV_TYPE.KEYPAD:
- return indev
- except Exception:
- pass
- try:
- indev = lv.indev_get_next(indev)
- except Exception:
- break
- except Exception:
- pass
- return None
-
-
-def _mapped_keypad_cb(event, indev, data):
- """Replacement for display_driver._keypad_cb.
-
- Arrows → FocusNav (KEYPAD indev only auto-navigates on KEY_NEXT/PREV).
- Enter → remapped lv.KEY.ENTER for the active group.
- Digits are ignored (vehicle drive profile is automatic).
- """
- if event is None or events is None:
- return
-
- nav = _focus_nav
-
- if event.type == events.KEYDOWN:
- if digit_from_key(event.key) is not None:
- data.state = lv.INDEV_STATE.RELEASED
- data.key = 0
- return
-
- mapped = remap_nav_key(event.key)
- if mapped is not None and mapped != lv.KEY.ENTER and nav is not None:
- # Consume arrows ourselves — KEYPAD won't focus_next on UP/DOWN.
- nav.handle_nav_key(mapped)
- data.state = lv.INDEV_STATE.RELEASED
- data.key = 0
- return
-
- data.state = lv.INDEV_STATE.PRESSED
- data.key = mapped if mapped is not None else event.key
-
- elif event.type == events.KEYUP:
- if digit_from_key(event.key) is not None:
- data.state = lv.INDEV_STATE.RELEASED
- data.key = 0
- return
-
- mapped = remap_nav_key(event.key)
- if mapped is not None and mapped != lv.KEY.ENTER and nav is not None:
- data.state = lv.INDEV_STATE.RELEASED
- data.key = 0
- return
-
- data.state = lv.INDEV_STATE.RELEASED
- data.key = mapped if mapped is not None else event.key
-
-
-class InputBridge:
- """Install SDL→LVGL key mapping and bind FocusNav to the keypad indev."""
-
- def __init__(self, app, vehicle, focus_nav=None):
- self.app = app
- self.vehicle = vehicle
- self.focus_nav = focus_nav
-
- def install(self):
- global _vehicle, _focus_nav
- _vehicle = self.vehicle
- _focus_nav = self.focus_nav
-
- import display_driver
-
- _find_keypad_indev()
- if _keypad_vdev is not None:
- _keypad_vdev.subscribe(_mapped_keypad_cb)
-
- if self.focus_nav is not None:
- indev = _find_keypad_indev()
- if indev is not None:
- self.focus_nav.bind_keypad_indev(indev)
diff --git a/lib/examples/car_cluster/key_probe.py b/lib/examples/car_cluster/key_probe.py
deleted file mode 100644
index 49d2bd893..000000000
--- a/lib/examples/car_cluster/key_probe.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# gallery: skip
-# SPDX-License-Identifier: MIT
-"""
-key_probe.py — print key codes from the host event pump.
-
-Run from pydevices-examples/lib::
-
- python examples/car_cluster/key_probe.py
-
-Focus the canvas, then press keys. Output shows SDL-style ``event.key``,
-``keys`` name when known, suggested ``lv.KEY`` mapping, and digit/throttle
-interpretation for the car cluster.
-
-Press Ctrl+Q (or your platform quit chord) to exit.
-"""
-
-import sys
-
-_file = __file__.replace("\\", "/")
-_PKG = _file.rsplit("/", 1)[0]
-_parts = _file.split("/")
-if "examples" in _parts:
- _idx = _parts.index("examples")
- _src = "/".join(_parts[:_idx]) if _idx else "."
-else:
- _src = "."
-if _src and _src not in sys.path:
- sys.path.insert(0, _src)
-if _PKG not in sys.path:
- sys.path.insert(0, _PKG)
-
-import display_driver # noqa: E402, F401
-from display_driver import app # noqa: E402
-import lvgl as lv # noqa: E402
-
-import events # noqa: E402
-import keys # noqa: E402
-
-try:
- from input_map import digit_from_key, remap_nav_key
-except ImportError:
- digit_from_key = None
- remap_nav_key = None
-
-_LV_KEY_NAMES = {
- lv.KEY.UP: "lv.KEY.UP",
- lv.KEY.DOWN: "lv.KEY.DOWN",
- lv.KEY.LEFT: "lv.KEY.LEFT",
- lv.KEY.RIGHT: "lv.KEY.RIGHT",
- lv.KEY.ENTER: "lv.KEY.ENTER",
- lv.KEY.ESC: "lv.KEY.ESC",
- lv.KEY.NEXT: "lv.KEY.NEXT",
- lv.KEY.PREV: "lv.KEY.PREV",
-}
-
-
-def _key_name(code):
- for name in dir(keys):
- if name.startswith("K_") and getattr(keys, name, None) == code:
- return name
- return "?"
-
-
-def _on_key(event):
- if event.type not in (events.KEYDOWN, events.KEYUP):
- return
- phase = "DOWN" if event.type == events.KEYDOWN else "UP"
- code = event.key
- name = _key_name(code)
- line = "KEY%s code=%s keys.%s" % (phase, code, name)
- if digit_from_key is not None:
- d = digit_from_key(code)
- if d is not None:
- gear = 10 if d == 0 else d
- line += " digit=%d gear=%d throttle=%.2f" % (d, gear, d / 10.0 if d else 0.05)
- if remap_nav_key is not None:
- mapped = remap_nav_key(code)
- if mapped is not None:
- line += " -> %s (%d)" % (_LV_KEY_NAMES.get(mapped, "lv.KEY"), mapped)
- print(line)
-
-
-print("key_probe: focus the canvas.")
-print("Try: arrow keys, Enter, digits 0-9 (top row and keypad).")
-print("Ctrl+Q to quit.\n")
-
-for et in (events.KEYDOWN, events.KEYUP):
- app.on(et, _on_key)
diff --git a/lib/examples/car_cluster/layout.py b/lib/examples/car_cluster/layout.py
deleted file mode 100644
index 5d06ce93a..000000000
--- a/lib/examples/car_cluster/layout.py
+++ /dev/null
@@ -1,188 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Root cluster layout: side gauges + center bezel with dual rails + tabview."""
-
-import lvgl as lv
-
-import chrome
-import gauges
-import rails
-import theme
-from screens import BUILDERS, TAB_NAMES
-
-
-class ClusterUI:
- def __init__(self, vehicle, focus_nav=None):
- self.vehicle = vehicle
- self.focus_nav = focus_nav
- if focus_nav is not None:
- self.left_group = focus_nav.left
- self.center_group = focus_nav.center
- self.right_group = focus_nav.right
- else:
- g = lv.group_get_default()
- if g is None:
- g = lv.group_create()
- g.set_default()
- self.left_group = g
- self.center_group = g
- self.right_group = g
- self.screens = []
- self.gauges = {}
- self.rails = None
- self.tabview = None
- self._build()
-
- def _build(self):
- scr = lv.screen_active()
- chrome.style_root(scr)
- W = scr.get_width()
- H = scr.get_height()
-
- # Outer perimeter bezel
- margin = 6
- outer, shell = chrome.make_bezel(scr, margin, margin, W - 2 * margin, H - 2 * margin, depth=3, radius=12, pad=3)
- self.outer = outer
- sw = shell.get_width()
- sh = shell.get_height()
- if sw < 100:
- sw = W - 40
- if sh < 100:
- sh = H - 40
-
- # Center column: slightly narrower than tall, nearly full height
- center_h = sh - 8
- center_w = int(center_h * 0.90) # slightly narrower than tall
- # Side columns share remaining width
- side_w = (sw - center_w - 16) // 2
- if side_w < 120:
- # shrink center to keep gauges usable
- side_w = max(110, (sw - 16) // 4)
- center_w = sw - 2 * side_w - 16
- cx = side_w + 8
- cy = 4
-
- # Left stack: RPM (large) + Fuel (smaller)
- left = lv.obj(shell)
- left.set_pos(4, 4)
- left.set_size(side_w, sh - 8)
- theme.style_bg(left, theme.bg(), radius=0)
- if hasattr(left, "remove_flag"):
- left.remove_flag(lv.obj.FLAG.SCROLLABLE)
-
- gs = theme.gauge_scale()
- col_h = sh - 8
- rpm_sz = int(min(side_w - 8, col_h * 0.58) * gs)
- fuel_sz = int(min(side_w - 8, col_h * 0.36) * gs)
- # Even vertical gaps: top / between / bottom.
- left_gap = max(4, (col_h - rpm_sz - fuel_sz) // 3)
- self.gauges["rpm"] = gauges.make_rpm_gauge(left, rpm_sz)
- self.gauges["rpm"].set_pos((side_w - rpm_sz) // 2, left_gap)
- self.gauges["fuel"] = gauges.make_fuel_gauge(left, fuel_sz)
- self.gauges["fuel"].set_pos((side_w - fuel_sz) // 2, left_gap + rpm_sz + left_gap)
-
- # Right stack: Temp + Oil
- right = lv.obj(shell)
- right.set_pos(sw - side_w - 4, 4)
- right.set_size(side_w, sh - 8)
- theme.style_bg(right, theme.bg(), radius=0)
- if hasattr(right, "remove_flag"):
- right.remove_flag(lv.obj.FLAG.SCROLLABLE)
-
- temp_sz = int(min(side_w - 8, col_h * 0.48) * gs)
- oil_sz = int(min(side_w - 8, col_h * 0.40) * gs)
- right_gap = max(4, (col_h - temp_sz - oil_sz) // 3)
- self.gauges["temp"] = gauges.make_temp_gauge(right, temp_sz)
- self.gauges["temp"].set_pos((side_w - temp_sz) // 2, right_gap)
- self.gauges["oil"] = gauges.make_oil_gauge(right, oil_sz)
- self.gauges["oil"].set_pos((side_w - oil_sz) // 2, right_gap + temp_sz + right_gap)
-
- # Center chrome
- c_outer, c_content = chrome.make_center_bezel(shell, cx, cy, center_w, center_h)
- self.center_outer = c_outer
- cw = c_content.get_width()
- ch = c_content.get_height()
- if cw < 80:
- cw = center_w - 24
- if ch < 80:
- ch = center_h - 24
-
- rail_w = max(56, cw // 7)
- mid_w = cw - 2 * rail_w - 8
- content_h = ch - 4 # slight inset; gear lives on Speed screen only
-
- # Tabview with hidden bar — pages fill content area
- self.tabview = lv.tabview(c_content)
- self.tabview.set_size(mid_w, content_h)
- self.tabview.set_pos(rail_w + 4, 0)
- bar = self.tabview.get_tab_bar()
- bar.add_flag(lv.obj.FLAG.HIDDEN)
- try:
- self.tabview.set_tab_bar_size(0)
- except Exception:
- pass
-
- try:
- content = self.tabview.get_content()
- page_w = content.get_width()
- page_h = content.get_height()
- except Exception:
- page_w = mid_w
- page_h = content_h
- if page_w < 40:
- page_w = mid_w
- if page_h < 40:
- page_h = content_h
-
- pages = []
- for name in TAB_NAMES:
- page = self.tabview.add_tab(name)
- page.set_size(lv.pct(100), lv.pct(100))
- pages.append(page)
-
- self.rails = rails.Rails(
- c_content,
- self.tabview,
- self.left_group,
- self.right_group,
- rail_w,
- content_h,
- pad=3,
- )
- self.rails.place(0, cw - rail_w, 0)
-
- for i, builder in enumerate(BUILDERS):
- self.screens.append(
- builder(pages[i], self.vehicle, self.center_group, page_w, page_h)
- )
-
- self.rails.select(0, anim=False)
- self.rails.drain_pending()
-
- # Seed defaults
- self.gauges["fuel"].set_value(int(self.vehicle.fuel_frac * 100))
- self.gauges["temp"].set_value(self.vehicle.coolant_f)
- self.gauges["oil"].set_value(self.vehicle.oil_psi)
- self.gauges["rpm"].set_value(self.vehicle.rpm)
-
- theme.on_change(self._on_theme_change)
-
- def _on_theme_change(self):
- chrome.apply_theme()
- for g in self.gauges.values():
- if hasattr(g, "apply_theme"):
- g.apply_theme()
- if self.rails is not None and hasattr(self.rails, "apply_theme"):
- self.rails.apply_theme()
- for scr in self.screens:
- if hasattr(scr, "apply_theme"):
- scr.apply_theme()
-
- def update(self):
- v = self.vehicle
- self.gauges["rpm"].set_value(v.rpm)
- self.gauges["fuel"].set_value(int(v.fuel_frac * 100))
- self.gauges["temp"].set_value(v.coolant_f)
- self.gauges["oil"].set_value(v.oil_psi)
- for scr in self.screens:
- if hasattr(scr, "update"):
- scr.update()
diff --git a/lib/examples/car_cluster/lv_util.py b/lib/examples/car_cluster/lv_util.py
deleted file mode 100644
index e9fb96407..000000000
--- a/lib/examples/car_cluster/lv_util.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Tiny LVGL helpers (flag + group API differences across bindings)."""
-
-import lvgl as lv
-
-
-def add_flag(obj, flag):
- obj.add_flag(flag)
-
-
-def remove_flag(obj, flag):
- if hasattr(obj, "remove_flag"):
- obj.remove_flag(flag)
- elif hasattr(obj, "clear_flag"):
- obj.clear_flag(flag)
-
-
-def hide(obj):
- add_flag(obj, lv.obj.FLAG.HIDDEN)
-
-
-def show(obj):
- remove_flag(obj, lv.obj.FLAG.HIDDEN)
-
-
-def hide_clickable(obj):
- remove_flag(obj, lv.obj.FLAG.CLICKABLE)
-
-
-def group_add(group, obj):
- if group is None:
- return
- if hasattr(group, "add_obj"):
- group.add_obj(obj)
- elif hasattr(lv, "group_add_obj"):
- lv.group_add_obj(group, obj)
diff --git a/lib/examples/car_cluster/rails.py b/lib/examples/car_cluster/rails.py
deleted file mode 100644
index 856f33b59..000000000
--- a/lib/examples/car_cluster/rails.py
+++ /dev/null
@@ -1,166 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Dual 4+4 focusable steering-wheel style menu rails."""
-
-import lvgl as lv
-
-import chrome
-import lv_util
-import theme
-
-# Left rail (0-3), right rail (4-7) — indices match tabview pages.
-RAIL_ITEMS = (
- # left
- ("SPD", "Speed", getattr(lv.SYMBOL, "HOME", "")),
- ("TR-A", "Trip A", getattr(lv.SYMBOL, "LOOP", "")),
- ("TR-B", "Trip B", getattr(lv.SYMBOL, "LOOP", "")),
- ("ENG", "Engine", getattr(lv.SYMBOL, "CHARGE", "")),
- # right
- ("LGT", "Lights", getattr(lv.SYMBOL, "EYE_OPEN", "")),
- ("TIR", "Tires", getattr(lv.SYMBOL, "SETTINGS", "")),
- ("AST", "Assist", getattr(lv.SYMBOL, "GPS", "")),
- ("THM", "Theme", getattr(lv.SYMBOL, "TINT", "")),
-)
-
-
-class Rails:
- def __init__(self, parent, tabview, left_group, right_group, rail_w, height, pad=4):
- self.tabview = tabview
- self.left_group = left_group
- self.right_group = right_group
- self.buttons = []
- self._icon_lbls = []
- self._text_lbls = []
- self._selected = 0
- self._selecting = False
- self._pending_tab = None
- self.left = lv.obj(parent)
- self.right = lv.obj(parent)
- for col in (self.left, self.right):
- col.set_size(rail_w, height)
- if hasattr(col, "remove_flag"):
- col.remove_flag(lv.obj.FLAG.SCROLLABLE)
- theme.style_bg(col, theme.panel(), radius=8, border_w=0)
- col.set_style_pad_all(pad, 0)
- col.set_style_pad_row(pad, 0)
- col.set_flex_flow(lv.FLEX_FLOW.COLUMN)
- col.set_flex_align(lv.FLEX_ALIGN.SPACE_EVENLY, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER)
-
- font = theme.pick_font(160, parent)
- font_icon = theme.pick_font(200, parent)
- btn_h = max(48, (height - pad * 5) // 4)
-
- for i, (short, _long, sym) in enumerate(RAIL_ITEMS):
- col = self.left if i < 4 else self.right
- group = left_group if i < 4 else right_group
- btn = lv.button(col)
- btn.set_size(lv.pct(100), btn_h)
- btn.set_style_pad_all(2, 0)
- btn.set_style_pad_row(2, 0)
- chrome.style_rail_button(btn, selected=(i == 0), initial=True)
- btn.set_flex_flow(lv.FLEX_FLOW.COLUMN)
- btn.set_flex_align(lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.CENTER)
-
- if sym:
- icon = lv.label(btn)
- icon.set_text(sym)
- icon.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(icon, font_icon)
- icon.set_style_pad_all(0, 0)
- self._icon_lbls.append(icon)
- else:
- self._icon_lbls.append(None)
-
- lbl = lv.label(btn)
- lbl.set_text(short)
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, font)
- lbl.set_style_pad_all(0, 0)
- self._text_lbls.append(lbl)
-
- # Exactly one group per control.
- lv_util.group_add(group, btn)
-
- def _make(idx):
- def _cb(e):
- self.select(idx)
-
- return _cb
-
- btn.add_event_cb(_make(i), lv.EVENT.CLICKED, None)
- # Arrow focus switches the center page; set_active is deferred via
- # drain_pending() on an app tick (and skipped while lv._nesting!=0).
- btn.add_event_cb(_make(i), lv.EVENT.FOCUSED, None)
- self.buttons.append(btn)
-
- def place(self, left_x, right_x, y):
- self.left.set_pos(left_x, y)
- self.right.set_pos(right_x, y)
-
- def select(self, index, anim=False, *, force=False):
- index = int(index) % len(self.buttons)
- # FOCUSED/CLICKED → select must not call tabview.set_active inline: that
- # re-enters the LVGL event/focus path inside task_handler and wedges the
- # soft timer under key storms. Style here; apply the tab on drain_pending().
- if self._selecting:
- return
- if not force and index == self._selected:
- return
- self._selecting = True
- try:
- nest = getattr(getattr(lv, "_nesting", None), "value", None)
- self._selected = index
- self._pending_tab = (index, 1 if anim else 0)
- # While nested in an LVGL FOCUSED callback, only record pending.
- # Restyling / add_style while nesting!=0 hard-locks under key storms.
- if nest:
- return
- self._apply_selection_styles(index)
- finally:
- self._selecting = False
-
- def _apply_selection_styles(self, index):
- for i, btn in enumerate(self.buttons):
- chrome.style_rail_button(btn, selected=(i == index))
- icon = self._icon_lbls[i]
- if icon is not None:
- icon.set_style_text_color(
- theme.text() if i == index else theme.accent_lite(), 0
- )
-
- def drain_pending(self):
- """Apply a deferred tabview change outside the LVGL event callback stack.
-
- Called from an app tick. Soft-timer ticks are delivered via
- ``micropython.schedule`` and can run while LVGL is mid-render (the VM
- resumes inside ``flush_cb``); ``lv._nesting`` != 0 there, and calling
- ``set_active`` would re-enter LVGL — the intermittent hard-wedge seen
- under Enter-key soak. Defer to the next tick instead.
- """
- pending = self._pending_tab
- if pending is None:
- return
- try:
- if lv._nesting.value != 0:
- return
- except AttributeError:
- pass
- self._pending_tab = None
- index, anim_en = pending
- # Apply deferred rail restyle outside the FOCUSED callback.
- self._apply_selection_styles(index)
- try:
- if self.tabview.get_tab_act() == index:
- return
- except Exception:
- pass
- try:
- self.tabview.set_active(index, anim_en)
- except TypeError:
- self.tabview.set_active(index, False)
-
- def apply_theme(self):
- self.select(self._selected, anim=False, force=True)
-
- @property
- def selected(self):
- return self._selected
diff --git a/lib/examples/car_cluster/ring.py b/lib/examples/car_cluster/ring.py
new file mode 100644
index 000000000..7c5386c06
--- /dev/null
+++ b/lib/examples/car_cluster/ring.py
@@ -0,0 +1,312 @@
+# SPDX-License-Identifier: MIT
+"""A neon ring gauge: the two big dials of the cluster.
+
+The ring is drawn once, as art, on a canvas: a dim track, the full colour
+sweep of the gauge as many short arc segments whose colours slide from one
+stop to the next, a tick ring outside it and a faint inner ring. The live
+value then needs only cheap moves each frame:
+
+* a *cover* arc, the colour of the empty track, hides the sweep beyond the
+ value, so the sweep appears to grow and shrink;
+* a wide, blurred arc under the canvas glows along the lit part;
+* a bright cap sits on the leading edge.
+
+Nothing here is an LVGL arc wearing its default look. The arcs are used for
+what they are cheap at (an angle that changes every frame) and the canvas for
+what the arc widget can't do (a colour gradient along the sweep).
+"""
+
+import math
+
+import lvgl as lv
+
+_CIRCLE = getattr(lv, "RADIUS_CIRCLE", 0x7FFF)
+
+
+def _color(rgb):
+ return lv.color_hex(rgb)
+
+
+def _mix(a, b, t):
+ """Blend two 0xRRGGBB colours; ``t`` from 0 (a) to 1 (b)."""
+ r = int(((a >> 16) & 0xFF) * (1 - t) + ((b >> 16) & 0xFF) * t)
+ g = int(((a >> 8) & 0xFF) * (1 - t) + ((b >> 8) & 0xFF) * t)
+ bl = int((a & 0xFF) * (1 - t) + (b & 0xFF) * t)
+ return (r << 16) | (g << 8) | bl
+
+
+def gradient_at(stops, t):
+ """Colour at ``t`` (0..1) along a list of ``(position, 0xRRGGBB)`` stops."""
+ if t <= stops[0][0]:
+ return stops[0][1]
+ for i in range(1, len(stops)):
+ pos, col = stops[i]
+ if t <= pos:
+ p0, c0 = stops[i - 1]
+ span = pos - p0
+ return _mix(c0, col, (t - p0) / span if span > 0 else 1.0)
+ return stops[-1][1]
+
+
+def plain(obj):
+ """An object that is only there to be looked at."""
+ obj.set_style_pad_all(0, 0)
+ obj.set_style_border_width(0, 0)
+ obj.set_style_outline_width(0, 0)
+ obj.set_style_bg_opa(lv.OPA.TRANSP, 0)
+ obj.remove_flag(lv.obj.FLAG.CLICKABLE)
+ obj.remove_flag(lv.obj.FLAG.SCROLLABLE)
+
+
+def scaled_font(label, target_px):
+ """The largest built-in Montserrat at or below ``target_px``, scaled up to it."""
+ chosen = None
+ for points in (48, 40, 32, 24, 16, 14):
+ font = getattr(lv, "font_montserrat_%d" % points, None)
+ if font is not None:
+ chosen = (font, points)
+ if points <= target_px:
+ break
+ if chosen is None:
+ return
+ font, points = chosen
+ label.set_style_text_font(font, 0)
+ scale = max(128, min(768, round(256 * target_px / points)))
+ if scale != 256:
+ label.set_style_transform_scale(scale, 0)
+
+
+class Ring:
+ """One dial. ``stops`` colours the sweep, ``vmax`` is the full-scale value."""
+
+ def __init__(
+ self,
+ parent,
+ size,
+ stops,
+ vmax,
+ *,
+ track=0x0E1E33,
+ bg=0x05080F,
+ glow=None,
+ start=135,
+ sweep=270,
+ unit="",
+ caption="",
+ ticks=54,
+ major_every=6,
+ ):
+ self.size = size
+ self.stops = stops
+ self.vmax = float(vmax)
+ self.start = start
+ self.sweep = sweep
+ self.value = 0.0
+ self._shown = None
+ self._glow_rgb = glow if glow is not None else stops[-1][1]
+
+ self.width = max(10, size // 15) # the sweep's thickness
+ self.radius = size // 2 - max(12, size // 11) # centre line of the sweep
+ cx = cy = size // 2
+ self.cx, self.cy = cx, cy
+
+ self.obj = lv.obj(parent)
+ self.obj.set_size(size, size)
+ plain(self.obj)
+
+ # Glow first, so everything else draws over it.
+ self.glow = lv.arc(self.obj)
+ self.glow.set_size(size, size)
+ self.glow.center()
+ self.glow.set_bg_angles(start, start + sweep)
+ self.glow.set_range(0, 1000)
+ self.glow.set_value(0)
+ self.glow.remove_style(None, lv.PART.KNOB)
+ plain(self.glow)
+ pad = size // 2 - self.radius - self.width // 2
+ self.glow.set_style_pad_all(pad, 0)
+ self.glow.set_style_arc_opa(lv.OPA.TRANSP, lv.PART.MAIN)
+ self.glow.set_style_arc_width(self.width + 16, lv.PART.INDICATOR)
+ self.glow.set_style_arc_color(_color(self._glow_rgb), lv.PART.INDICATOR)
+ self.glow.set_style_arc_opa(150, lv.PART.INDICATOR)
+ self.glow.set_style_arc_rounded(True, lv.PART.INDICATOR)
+ if hasattr(self.glow, "set_style_blur_radius"):
+ self.glow.set_style_blur_radius(max(6, size // 22), 0)
+
+ # The art: track, colour sweep, ticks, inner ring.
+ self.canvas = lv.canvas(self.obj)
+ self.canvas.set_size(size, size)
+ self.canvas.center()
+ plain(self.canvas)
+ self._buf = lv.draw_buf_create(size, size, lv.COLOR_FORMAT.ARGB8888, 0)
+ self.canvas.set_draw_buf(self._buf)
+ self.canvas.fill_bg(_color(0x000000), lv.OPA.TRANSP)
+ self._paint(track, ticks, major_every)
+
+ # The cover: the empty part of the track, painted over the sweep.
+ self.cover = lv.arc(self.obj)
+ self.cover.set_size(size, size)
+ self.cover.center()
+ self.cover.set_bg_angles(start, start + sweep)
+ self.cover.set_range(0, 1000)
+ self.cover.set_value(0)
+ self.cover.remove_style(None, lv.PART.KNOB)
+ plain(self.cover)
+ self.cover.set_style_pad_all(pad, 0)
+ self.cover.set_style_arc_width(self.width + 2, lv.PART.MAIN)
+ self.cover.set_style_arc_color(_color(track), lv.PART.MAIN)
+ self.cover.set_style_arc_opa(lv.OPA.COVER, lv.PART.MAIN)
+ self.cover.set_style_arc_rounded(False, lv.PART.MAIN)
+ self.cover.set_style_arc_opa(lv.OPA.TRANSP, lv.PART.INDICATOR)
+
+ # The dial face inside the sweep.
+ face = size - 2 * (size // 2 - self.radius) - self.width - max(10, size // 14)
+ self.face = lv.obj(self.obj)
+ self.face.set_size(face, face)
+ self.face.center()
+ plain(self.face)
+ self.face.set_style_radius(_CIRCLE, 0)
+ self.face.set_style_bg_color(_color(0x0B1530), 0)
+ self.face.set_style_bg_grad_color(_color(bg), 0)
+ self.face.set_style_bg_grad_dir(lv.GRAD_DIR.VER, 0)
+ self.face.set_style_bg_opa(lv.OPA.COVER, 0)
+ self.face.set_style_border_width(1, 0)
+ self.face.set_style_border_color(_color(self.stops[0][1]), 0)
+ self.face.set_style_border_opa(60, 0)
+
+ # The cap on the leading edge of the sweep.
+ cap = self.width + 6
+ self.cap = lv.obj(self.obj)
+ self.cap.set_size(cap, cap)
+ plain(self.cap)
+ self.cap.set_style_radius(_CIRCLE, 0)
+ self.cap.set_style_bg_color(_color(0xFFFFFF), 0)
+ self.cap.set_style_bg_opa(lv.OPA.COVER, 0)
+ self.cap.set_style_shadow_color(_color(self._glow_rgb), 0)
+ self.cap.set_style_shadow_width(cap * 2, 0)
+ self.cap.set_style_shadow_spread(2, 0)
+ self.cap.set_style_shadow_opa(220, 0)
+
+ # The reading.
+ self.unit = lv.label(self.face)
+ self.unit.set_text(unit)
+ self.unit.set_style_text_color(_color(0x7F93B5), 0)
+ self.unit.set_style_text_font(lv.font_montserrat_14, 0)
+ self.unit.align(lv.ALIGN.CENTER, 0, -face * 36 // 100)
+
+ px = max(24, face * 44 // 100)
+ self.number = lv.label(self.face)
+ self.number.set_text("0")
+ self.number.set_width(face)
+ self.number.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0)
+ self.number.set_style_text_color(_color(0xFFFFFF), 0)
+ self.number.set_style_text_letter_space(2, 0)
+ scaled_font(self.number, px)
+ self.number.set_style_transform_pivot_x(face // 2, 0)
+ self.number.set_style_transform_pivot_y(px // 2, 0)
+ self.number.align(lv.ALIGN.CENTER, 0, 0)
+
+ self.caption = lv.label(self.obj)
+ self.caption.set_text(caption)
+ self.caption.set_style_text_color(_color(0xB8C4DC), 0)
+ self.caption.set_style_text_font(lv.font_montserrat_16, 0)
+ self.caption.align(lv.ALIGN.BOTTOM_MID, 0, -max(2, size // 40))
+
+ self.set_value(0)
+
+ # -- art ----------------------------------------------------------------
+
+ def _paint(self, track, ticks, major_every):
+ layer = lv.layer_t()
+ self.canvas.init_layer(layer)
+ cx, cy, r = self.cx, self.cy, self.radius
+
+ arc = lv.draw_arc_dsc_t()
+ arc.init()
+ arc.center = lv.point_t({"x": cx, "y": cy})
+ arc.rounded = False
+
+ # Dim track under everything.
+ arc.color = _color(track)
+ arc.width = self.width + 2
+ arc.radius = r + arc.width // 2
+ arc.opa = lv.OPA.COVER
+ arc.start_angle = self.start
+ arc.end_angle = self.start + self.sweep
+ lv.draw_arc(layer, arc)
+
+ # The colour sweep: short segments, each a step along the gradient.
+ # Segments overlap by a hair so the joins don't show.
+ steps = max(24, self.sweep // 3)
+ arc.width = self.width
+ arc.radius = r + self.width // 2
+ for i in range(steps):
+ t0 = i / steps
+ t1 = (i + 1) / steps
+ arc.color = _color(gradient_at(self.stops, (t0 + t1) / 2))
+ arc.start_angle = self.start + self.sweep * t0
+ arc.end_angle = self.start + self.sweep * t1 + 0.6
+ lv.draw_arc(layer, arc)
+
+ # A hairline on the inside edge of the sweep.
+ arc.color = _color(0xFFFFFF)
+ arc.width = 1
+ arc.radius = r - self.width // 2 - 1
+ arc.opa = 60
+ arc.start_angle = self.start
+ arc.end_angle = self.start + self.sweep
+ lv.draw_arc(layer, arc)
+
+ # Tick ring outside the sweep.
+ line = lv.draw_line_dsc_t()
+ line.init()
+ line.round_start = True
+ line.round_end = True
+ r_out = self.size // 2 - 2
+ for i in range(ticks + 1):
+ t = i / ticks
+ a = math.radians(self.start + self.sweep * t)
+ major = i % major_every == 0
+ length = max(8, self.size // 24) if major else max(4, self.size // 48)
+ line.width = 3 if major else 2
+ line.color = _color(gradient_at(self.stops, t))
+ line.opa = 230 if major else 110
+ line.p1 = lv.point_precise_t({"x": cx + math.cos(a) * r_out, "y": cy + math.sin(a) * r_out})
+ line.p2 = lv.point_precise_t(
+ {"x": cx + math.cos(a) * (r_out - length), "y": cy + math.sin(a) * (r_out - length)}
+ )
+ lv.draw_line(layer, line)
+
+ self.canvas.finish_layer(layer)
+
+ # -- live ---------------------------------------------------------------
+
+ def set_value(self, v):
+ v = max(0.0, min(self.vmax, float(v)))
+ self.value = v
+ frac = v / self.vmax
+ a_val = self.start + self.sweep * frac
+ if frac >= 0.999:
+ self.cover.add_flag(lv.obj.FLAG.HIDDEN)
+ else:
+ self.cover.remove_flag(lv.obj.FLAG.HIDDEN)
+ self.cover.set_bg_angles(a_val, self.start + self.sweep)
+ self.glow.set_value(int(1000 * frac))
+ self.glow.set_style_arc_color(_color(gradient_at(self.stops, frac)), lv.PART.INDICATOR)
+ a = math.radians(a_val)
+ cap = self.cap.get_width() or (self.width + 6)
+ self.cap.set_pos(
+ int(self.cx + math.cos(a) * self.radius - cap / 2),
+ int(self.cy + math.sin(a) * self.radius - cap / 2),
+ )
+ self.cap.set_style_shadow_color(_color(gradient_at(self.stops, frac)), 0)
+ shown = int(v + 0.5)
+ if shown != self._shown:
+ self._shown = shown
+ self.number.set_text("%d" % shown)
+
+ def set_caption(self, text):
+ self.caption.set_text(text)
+
+ def set_pos(self, x, y):
+ self.obj.set_pos(x, y)
diff --git a/lib/examples/car_cluster/screens/__init__.py b/lib/examples/car_cluster/screens/__init__.py
deleted file mode 100644
index 40e94d047..000000000
--- a/lib/examples/car_cluster/screens/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Center tab pages for the instrument cluster."""
-
-from .assist import build as build_assist
-from .engine import build as build_engine
-from .lights import build as build_lights
-from .speed import build as build_speed
-from .theme_screen import build as build_theme
-from .tires import build as build_tires
-from .trip_a import build as build_trip_a
-from .trip_b import build as build_trip_b
-
-BUILDERS = (
- build_speed,
- build_trip_a,
- build_trip_b,
- build_engine,
- build_lights,
- build_tires,
- build_assist,
- build_theme,
-)
-
-TAB_NAMES = (
- "Speed",
- "Trip A",
- "Trip B",
- "Engine",
- "Lights",
- "Tires",
- "Assist",
- "Theme",
-)
diff --git a/lib/examples/car_cluster/screens/_common.py b/lib/examples/car_cluster/screens/_common.py
deleted file mode 100644
index 21acd83b4..000000000
--- a/lib/examples/car_cluster/screens/_common.py
+++ /dev/null
@@ -1,283 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Shared helpers for center tab pages."""
-
-import lvgl as lv
-
-import lv_util
-import theme
-
-_TRACK_H = 8 # slider / bar track thickness; knob matches this
-
-
-def no_scroll(obj):
- if hasattr(obj, "remove_flag"):
- obj.remove_flag(lv.obj.FLAG.SCROLLABLE)
- elif hasattr(obj, "clear_flag"):
- obj.clear_flag(lv.obj.FLAG.SCROLLABLE)
-
-
-def zero_pad(obj):
- obj.set_style_pad_all(0, 0)
- obj.set_style_pad_row(0, 0)
- obj.set_style_pad_column(0, 0)
- obj.set_style_margin_all(0, 0)
-
-
-def content_size(page, w, h):
- """Usable page area; ``w``/``h`` come from layout (tabview content)."""
- if w > 40 and h > 40:
- return int(w), int(h)
- pw = page.get_width()
- ph = page.get_height()
- if pw > 40 and ph > 40:
- return pw, ph
- return 360, 420
-
-
-def prep_page(page, w=0, h=0):
- no_scroll(page)
- page.set_size(lv.pct(100), lv.pct(100))
- theme.style_bg(page, theme.face(), radius=0)
- zero_pad(page)
- return page
-
-
-def section_label(parent, text, y, *, accent=False):
- lbl = lv.label(parent)
- lbl.set_text(text)
- lbl.set_style_text_color(theme.accent_lite() if accent else theme.text_dim(), 0)
- theme.apply_font(lbl, theme.pick_font(220, parent))
- zero_pad(lbl)
- lbl.set_pos(4, y)
- return lbl
-
-
-def scale_2x(lbl):
- """2× transform about the label center."""
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def scale_2x_edge(lbl, *, left=True):
- """2× transform pivoting on the left or right edge."""
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(0 if left else w, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def kv_row(parent, y, key, value_text, width):
- k = lv.label(parent)
- k.set_text(key)
- k.set_style_text_color(theme.text_dim(), 0)
- theme.apply_font(k, theme.pick_font(200, parent))
- zero_pad(k)
- k.set_pos(8, y)
-
- v = lv.label(parent)
- v.set_text(value_text)
- v.set_style_text_color(theme.text(), 0)
- theme.apply_font(v, theme.pick_font(220, parent))
- zero_pad(v)
- v.set_pos(max(width // 2, 140), y)
- return k, v
-
-
-def apply_kv_edge_scale(keys, vals, page_w, *, pad=14, dy=None, page=None):
- """Design-session layout: 2× edge-pivoted keys/values + pad/dy nudges."""
- if not keys:
- return
- if page is None:
- page = keys[0].get_parent()
- try:
- lv.obj.update_layout(page)
- except Exception:
- pass
- label_h = max(1, keys[0].get_height())
- if dy is None:
- dy = label_h
- for lbl in keys:
- scale_2x_edge(lbl, left=True)
- lbl.set_style_translate_x(pad - 8, 0)
- lbl.set_style_translate_y(dy, 0)
- for lbl in vals:
- w = max(1, lbl.get_width())
- scale_2x_edge(lbl, left=False)
- lbl.set_style_translate_x(page_w - pad - w - 152, 0)
- lbl.set_style_translate_y(dy, 0)
-
-
-def spread_rows(count, top, bottom, height):
- """Even row Y positions between ``top`` and ``height - bottom``."""
- usable = max(40, height - top - bottom)
- if count <= 1:
- return [top]
- step = usable // count
- return [top + i * step for i in range(count)]
-
-
-def make_button(parent, text, w, h, group=None):
- btn = lv.button(parent)
- btn.set_size(w, h)
- zero_pad(btn)
- theme.style_bg(btn, theme.panel_raised(), radius=8, border_w=1, border_color=theme.accent_dim())
- zero_pad(btn)
- focus = lv.style_t()
- focus.init()
- focus.set_outline_width(2)
- focus.set_outline_color(theme.accent())
- focus.set_outline_opa(lv.OPA.COVER)
- btn.add_style(focus, lv.STATE.FOCUSED)
- theme.retain_style(focus)
- lbl = lv.label(btn)
- lbl.set_text(text)
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, theme.pick_font(180, parent))
- zero_pad(lbl)
- lbl.center()
- if group is not None:
- lv_util.group_add(group, btn)
- return btn
-
-
-def apply_button_theme(btn):
- btn.set_style_bg_color(theme.panel_raised(), 0)
- btn.set_style_border_color(theme.accent_dim(), 0)
- btn.set_style_outline_color(theme.accent(), lv.STATE.FOCUSED)
- try:
- btn.get_child(0).set_style_text_color(theme.text(), 0)
- except Exception:
- pass
-
-
-def _style_switch(sw):
- st = lv.style_t()
- st.init()
- st.set_bg_color(theme.accent())
- st.set_bg_opa(lv.OPA.COVER)
- sw.add_style(st, lv.PART.INDICATOR | lv.STATE.CHECKED)
- theme.retain_style(st)
- st2 = lv.style_t()
- st2.init()
- st2.set_bg_color(theme.chrome_mid())
- st2.set_bg_opa(lv.OPA.COVER)
- sw.add_style(st2, lv.PART.MAIN)
- theme.retain_style(st2)
- # Keep the thumb compact
- st_k = lv.style_t()
- st_k.init()
- st_k.set_bg_color(theme.text())
- st_k.set_bg_opa(lv.OPA.COVER)
- st_k.set_pad_all(0)
- st_k.set_width(_TRACK_H)
- st_k.set_height(_TRACK_H)
- sw.add_style(st_k, lv.PART.KNOB)
- theme.retain_style(st_k)
-
-
-def make_switch(parent, group=None):
- sw = lv.switch(parent)
- zero_pad(sw)
- _style_switch(sw)
- if group is not None:
- lv_util.group_add(group, sw)
- return sw
-
-
-def apply_switch_theme(sw):
- sw.set_style_bg_color(theme.chrome_mid(), lv.PART.MAIN)
- sw.set_style_bg_color(theme.accent(), lv.PART.INDICATOR | lv.STATE.CHECKED)
- sw.set_style_bg_color(theme.text(), lv.PART.KNOB)
-
-
-def _style_slider(sl):
- # Track
- st_main = lv.style_t()
- st_main.init()
- st_main.set_bg_color(theme.chrome_lo())
- st_main.set_bg_opa(lv.OPA.COVER)
- st_main.set_radius(_TRACK_H // 2)
- st_main.set_height(_TRACK_H)
- st_main.set_pad_all(0)
- sl.add_style(st_main, lv.PART.MAIN)
- theme.retain_style(st_main)
-
- # Filled portion
- st_ind = lv.style_t()
- st_ind.init()
- st_ind.set_bg_color(theme.accent())
- st_ind.set_bg_opa(lv.OPA.COVER)
- st_ind.set_radius(_TRACK_H // 2)
- sl.add_style(st_ind, lv.PART.INDICATOR)
- theme.retain_style(st_ind)
-
- # Knob same size as track thickness
- st_knob = lv.style_t()
- st_knob.init()
- st_knob.set_bg_color(theme.accent_lite())
- st_knob.set_bg_opa(lv.OPA.COVER)
- st_knob.set_radius(_TRACK_H // 2)
- st_knob.set_pad_all(0)
- st_knob.set_width(_TRACK_H)
- st_knob.set_height(_TRACK_H)
- st_knob.set_border_width(0)
- st_knob.set_outline_width(0)
- sl.add_style(st_knob, lv.PART.KNOB)
- theme.retain_style(st_knob)
-
-
-def make_slider(parent, w, group=None):
- sl = lv.slider(parent)
- sl.set_width(w)
- sl.set_height(_TRACK_H)
- sl.set_range(0, 100)
- zero_pad(sl)
- _style_slider(sl)
- if group is not None:
- lv_util.group_add(group, sl)
- return sl
-
-
-def apply_slider_theme(sl):
- sl.set_style_bg_color(theme.chrome_lo(), lv.PART.MAIN)
- sl.set_style_bg_color(theme.accent(), lv.PART.INDICATOR)
- sl.set_style_bg_color(theme.accent_lite(), lv.PART.KNOB)
-
-
-def make_bar(parent, w, h):
- bar = lv.bar(parent)
- bar.set_size(w, h)
- bar.set_range(0, 100)
- zero_pad(bar)
- st = lv.style_t()
- st.init()
- st.set_bg_color(theme.chrome_lo())
- st.set_bg_opa(lv.OPA.COVER)
- st.set_radius(4)
- bar.add_style(st, lv.PART.MAIN)
- theme.retain_style(st)
- st2 = lv.style_t()
- st2.init()
- st2.set_bg_color(theme.accent())
- st2.set_bg_opa(lv.OPA.COVER)
- st2.set_radius(4)
- bar.add_style(st2, lv.PART.INDICATOR)
- theme.retain_style(st2)
- return bar
-
-
-def apply_bar_theme(bar):
- bar.set_style_bg_color(theme.chrome_lo(), lv.PART.MAIN)
- bar.set_style_bg_color(theme.accent(), lv.PART.INDICATOR)
diff --git a/lib/examples/car_cluster/screens/assist.py b/lib/examples/car_cluster/screens/assist.py
deleted file mode 100644
index 766f7d8b2..000000000
--- a/lib/examples/car_cluster/screens/assist.py
+++ /dev/null
@@ -1,127 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Driver assist / ADAS status."""
-
-import lvgl as lv
-
-import theme
-from screens._common import (
- apply_bar_theme,
- apply_button_theme,
- apply_switch_theme,
- content_size,
- make_bar,
- make_button,
- make_switch,
- prep_page,
- section_label,
-)
-
-
-class AssistScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
- font = theme.pick_font(200, page)
-
- # 10 evenly spaced content bands (design session): 3 switches, ACC,
- # SET buttons, PARKING header, 4 proximity rows.
- n = 10
- y0, y_last = 36, h - 40
- step = (y_last - y0) / (n - 1)
- anchors = [int(round(y0 + i * step)) for i in range(n)]
-
- hdr = section_label(page, "DRIVER ASSIST", 4)
- hdr.align(lv.ALIGN.TOP_MID, 0, 4)
-
- self.switches = {}
- for i, (key, label) in enumerate(
- (
- ("acc", "Adaptive cruise"),
- ("lane_keep", "Lane keep"),
- ("blind_spot", "Blind spot"),
- )
- ):
- y = anchors[i]
- lbl = lv.label(page)
- lbl.set_text(label)
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, font)
- lbl.set_pos(8, y + 8)
- sw = make_switch(page, group)
- sw.set_pos(w - 72, y + 4)
- if vehicle.assist.get(key):
- sw.add_state(lv.STATE.CHECKED)
-
- def _make(k, switch):
- def _cb(e):
- vehicle.assist[k] = bool(switch.has_state(lv.STATE.CHECKED))
-
- return _cb
-
- sw.add_event_cb(_make(key, sw), lv.EVENT.VALUE_CHANGED, None)
- self.switches[key] = sw
-
- self.acc_lbl = lv.label(page)
- self.acc_lbl.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(self.acc_lbl, font)
- self.acc_lbl.set_pos(8, anchors[3] + 4)
-
- minus = make_button(page, "− SET", 88, 36, group)
- plus = make_button(page, "+ SET", 88, 36, group)
- self.buttons = (minus, plus)
- minus.set_pos(8, anchors[4])
- plus.set_pos(104, anchors[4])
-
- def _dec(e):
- vehicle.assist["acc_set"] = max(25, int(vehicle.assist["acc_set"]) - 5)
-
- def _inc(e):
- vehicle.assist["acc_set"] = min(85, int(vehicle.assist["acc_set"]) + 5)
-
- minus.add_event_cb(_dec, lv.EVENT.CLICKED, None)
- plus.add_event_cb(_inc, lv.EVENT.CLICKED, None)
-
- park_hdr = section_label(page, "PARKING SENSORS", anchors[5])
- park_hdr.align(lv.ALIGN.TOP_MID, 0, anchors[5])
-
- self.bars = {}
- for i, (key, label) in enumerate(
- (
- ("park_fl", "FL"),
- ("park_fr", "FR"),
- ("park_rl", "RL"),
- ("park_rr", "RR"),
- )
- ):
- y = anchors[6 + i]
- lbl = lv.label(page)
- lbl.set_text(label)
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, font)
- lbl.set_pos(8, y + 2)
- bar = make_bar(page, w - 52, 16)
- bar.set_pos(44, y)
- self.bars[key] = bar
-
- self.update()
-
- def update(self):
- a = self.vehicle.assist
- on = "ON" if a.get("acc") else "OFF"
- self.acc_lbl.set_text("ACC set %d mph [%s]" % (int(a.get("acc_set", 0)), on))
- for key, bar in self.bars.items():
- prox = float(a.get(key, 0.5))
- bar.set_value(int((1.0 - prox) * 100), 0)
-
- def apply_theme(self):
- self.acc_lbl.set_style_text_color(theme.accent_lite(), 0)
- for sw in self.switches.values():
- apply_switch_theme(sw)
- for btn in self.buttons:
- apply_button_theme(btn)
- for bar in self.bars.values():
- apply_bar_theme(bar)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return AssistScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/engine.py b/lib/examples/car_cluster/screens/engine.py
deleted file mode 100644
index b94ad8281..000000000
--- a/lib/examples/car_cluster/screens/engine.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Engine information screen."""
-
-import lvgl as lv
-
-import theme
-from screens._common import (
- apply_bar_theme,
- apply_kv_edge_scale,
- content_size,
- kv_row,
- make_bar,
- prep_page,
- spread_rows,
-)
-
-
-class EngineScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
- self._page_w = w
- self._page = page
-
- keys = (
- "Coolant",
- "Oil temp",
- "Oil pres.",
- "Battery",
- "Intake air",
- "Hours",
- "RPM",
- )
- ys = spread_rows(len(keys), top=8, bottom=56, height=h)
- self.keys = []
- self.vals = []
- for lab, y in zip(keys, ys):
- k, v = kv_row(page, y, lab, "--", w)
- self.keys.append(k)
- self.vals.append(v)
-
- # Design session: 2× edge scale + extra vertical nudge (label_h + 16).
- try:
- lv.obj.update_layout(page)
- except Exception:
- pass
- label_h = max(1, self.keys[0].get_height())
- apply_kv_edge_scale(self.keys, self.vals, w, dy=label_h + 16, page=page)
-
- # No ENGINE LOAD header — keep the load bar only.
- load_y = h - 44
- self.load_bar = make_bar(page, w - 24, 18)
- self.load_bar.set_pos(8, load_y)
- self.update()
-
- def update(self):
- v = self.vehicle
- texts = (
- "%.1f °F" % v.coolant_f,
- "%.0f °F" % v.oil_temp_f,
- "%.1f psi" % v.oil_psi,
- "%.1f V" % v.battery_v,
- "%.0f °F" % v.iat_f,
- "%.1f h" % v.engine_hours,
- "%d" % int(v.rpm),
- )
- for lbl, text in zip(self.vals, texts):
- lbl.set_text(text)
- try:
- lv.obj.update_layout(self._page)
- except Exception:
- pass
- label_h = max(1, self.keys[0].get_height())
- apply_kv_edge_scale(self.keys, self.vals, self._page_w, dy=label_h + 16, page=self._page)
- if v.coolant_f >= 212:
- self.vals[0].set_style_text_color(theme.danger(), 0)
- else:
- self.vals[0].set_style_text_color(theme.text(), 0)
- self.load_bar.set_value(int(v.throttle * 100), 0)
-
- def apply_theme(self):
- apply_bar_theme(self.load_bar)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return EngineScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/lights.py b/lib/examples/car_cluster/screens/lights.py
deleted file mode 100644
index eea2d25d4..000000000
--- a/lib/examples/car_cluster/screens/lights.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Exterior / cabin light controls."""
-
-import lvgl as lv
-
-import theme
-from screens._common import apply_switch_theme, content_size, make_switch, prep_page, section_label
-
-
-class LightsScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
- font = theme.pick_font(200, page)
-
- # Even spacing of 9 rows (brightness row anchors left unused).
- n = 9
- y0, y_last = 32, min(436, h - 48)
- step = (y_last - y0) / (n - 1)
- anchors = [int(round(y0 + i * step)) for i in range(n)]
-
- ext = section_label(page, "EXTERIOR", 4)
- ext.align(lv.ALIGN.TOP_MID, -4, 4)
-
- self.switches = {}
- ext_rows = (
- ("autodim", "Auto dim"),
- ("delayed_off", "Delayed off"),
- ("drl", "Daytime running"),
- ("fog", "Fog lamps"),
- ("high_beam", "High beam"),
- )
- for i, (key, label) in enumerate(ext_rows):
- y = anchors[i]
- lbl = lv.label(page)
- lbl.set_text(label)
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, font)
- lbl.set_pos(8, y + 8)
- sw = make_switch(page, group)
- sw.set_pos(w - 72, y + 4)
- if vehicle.lights.get(key):
- sw.add_state(lv.STATE.CHECKED)
- else:
- sw.remove_state(lv.STATE.CHECKED)
-
- def _make(k, switch):
- def _cb(e):
- on = switch.has_state(lv.STATE.CHECKED)
- vehicle.lights[k] = bool(on)
- if k == "autodim" and on:
- theme.set_brightness(0.75)
- elif k == "cabin" and on:
- theme.set_brightness(1.0)
-
- return _cb
-
- sw.add_event_cb(_make(key, sw), lv.EVENT.VALUE_CHANGED, None)
- self.switches[key] = sw
-
- cab = section_label(page, "CABIN", anchors[5])
- cab.align(lv.ALIGN.TOP_MID, 0, anchors[5])
-
- y = anchors[6]
- lbl = lv.label(page)
- lbl.set_text("Cabin lights")
- lbl.set_style_text_color(theme.text(), 0)
- theme.apply_font(lbl, font)
- lbl.set_pos(8, y + 8)
- sw = make_switch(page, group)
- sw.set_pos(w - 72, y + 4)
- if vehicle.lights.get("cabin"):
- sw.add_state(lv.STATE.CHECKED)
-
- def _cabin_cb(e, s=sw):
- vehicle.lights["cabin"] = bool(s.has_state(lv.STATE.CHECKED))
- if vehicle.lights["cabin"]:
- theme.set_brightness(1.0)
-
- sw.add_event_cb(_cabin_cb, lv.EVENT.VALUE_CHANGED, None)
- self.switches["cabin"] = sw
-
- self.status = lv.label(page)
- self.status.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(self.status, font)
- self.status.align(lv.ALIGN.BOTTOM_LEFT, 8, -6)
- self.update()
-
- def update(self):
- lit = [k for k, on in self.vehicle.lights.items() if on and k != "brightness"]
- self.status.set_text("Active: " + (", ".join(lit) if lit else "none"))
-
- def apply_theme(self):
- for sw in self.switches.values():
- apply_switch_theme(sw)
- self.status.set_style_text_color(theme.accent_lite(), 0)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return LightsScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/speed.py b/lib/examples/car_cluster/screens/speed.py
deleted file mode 100644
index 0d3abc022..000000000
--- a/lib/examples/car_cluster/screens/speed.py
+++ /dev/null
@@ -1,164 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Default center screen: large speedometer + odometer."""
-
-import lvgl as lv
-
-import gauges
-import lv_util
-import theme
-from screens._common import apply_button_theme, content_size, make_button, prep_page
-
-
-def _scale_label(lbl, scale):
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(scale, 0)
- except Exception:
- lbl.set_style_transform_scale_x(scale, 0)
- lbl.set_style_transform_scale_y(scale, 0)
-
-
-class SpeedScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- self.page = page
- w, h = content_size(prep_page(page, w, h), w, h)
-
- pad = 8
- body_h = h - 72
- body_w = w - 2 * pad
-
- self.digital = gauges.make_digital_speed(page, body_w, int(body_h * 0.78))
- self.digital.set_pos(pad, 8)
-
- gauge_sz = min(body_w - 16, int(body_h * 0.78))
- self.analog = gauges.make_speed_gauge(page, gauge_sz)
- self.analog.set_pos((w - gauge_sz) // 2, 8)
- lv_util.hide(self.analog.widget())
-
- dig = self.digital.widget()
- self.digital.set_num_scale(2048)
- self.digital.num.set_style_text_color(lv.color_hex(0xFFFFFF), 0)
- uh = max(1, self.digital.unit.get_height())
- self.digital.unit.set_style_translate_x(0, 0)
- self.digital.unit.set_style_translate_y(0, 0)
- self.digital.unit.align(lv.ALIGN.BOTTOM_MID, 0, -12 - uh)
- _scale_label(self.digital.unit, 768)
-
- self.odo = lv.label(page)
- self.odo.set_style_text_color(theme.text(), 0)
- theme.apply_font(self.odo, theme.pick_font(220, page))
- self.odo.set_style_pad_all(0, 0)
- self.odo.align(lv.ALIGN.BOTTOM_MID, 0, -10)
-
- # Same parent as odo (the speed page). Positioned later via TOP_LEFT —
- # sticky BOTTOM_* align + translate was pushing it off-screen.
- self.gear = lv.label(page)
- self.gear.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(self.gear, theme.pick_font(240, page))
- self.gear.set_style_pad_all(0, 0)
- self.gear.set_text("GEAR P")
-
- self.flags = lv.label(page)
- self.flags.set_style_text_color(theme.warn(), 0)
- theme.apply_font(self.flags, theme.pick_font(180, page))
- self.flags.set_style_pad_all(0, 0)
- self.flags.align(lv.ALIGN.BOTTOM_MID, 0, -10)
-
- self.toggle = make_button(page, "DIG / GAUGE", min(140, w // 3), 36, group)
- self.toggle.align_to(dig, lv.ALIGN.OUT_BOTTOM_MID, 0, max(1, self.toggle.get_height()) // 2)
-
- def _toggle(e):
- vehicle.toggle_speedo_mode()
- self._apply_mode()
-
- self.toggle.add_event_cb(_toggle, lv.EVENT.CLICKED, None)
- lv_util.add_flag(self.digital.widget(), lv.obj.FLAG.CLICKABLE)
- self.digital.widget().add_event_cb(_toggle, lv.EVENT.CLICKED, None)
- self._apply_mode()
- self.update()
- self._layout_odo_gear()
-
- def _layout_odo_gear(self):
- page = self.page
- self.odo.set_style_translate_x(0, 0)
- self.odo.set_style_translate_y(0, 0)
- self.odo.align(lv.ALIGN.BOTTOM_MID, 0, -10)
- _scale_label(self.odo, 512)
- self.odo.set_style_translate_x(0, 0)
-
- # Keep gear on the same parent as odo, place with TOP_LEFT (not sticky BOTTOM).
- if self.gear.get_parent() is not self.odo.get_parent():
- try:
- self.gear.set_parent(self.odo.get_parent())
- except Exception:
- pass
- self.gear.set_style_translate_x(0, 0)
- self.gear.set_style_translate_y(0, 0)
- try:
- lv.obj.update_layout(page)
- except Exception:
- pass
- gw = max(1, self.gear.get_width())
- gh = max(1, self.gear.get_height())
- btn_bot = self.toggle.get_y() + self.toggle.get_height()
- gap_mid = (btn_bot + self.odo.get_y()) // 2
- ty = gap_mid - gh // 2 + int(1.5 * gh) + 8 - 24
- ty = max(btn_bot + 2, min(ty, self.odo.get_y() - gh - 2))
- tx = (page.get_width() - gw) // 2
- self.gear.align(lv.ALIGN.TOP_LEFT, tx, ty)
- try:
- lv.obj.update_layout(page)
- except Exception:
- pass
- _scale_label(self.gear, 512)
- try:
- self.gear.move_foreground()
- except Exception:
- pass
-
- def _apply_mode(self):
- digital = self.vehicle.speedo_mode == "digital"
- if digital:
- lv_util.show(self.digital.widget())
- lv_util.hide(self.analog.widget())
- else:
- lv_util.hide(self.digital.widget())
- lv_util.show(self.analog.widget())
-
- def update(self):
- v = self.vehicle
- mph = int(round(v.speed_mph))
- self._apply_mode()
- if v.speedo_mode == "digital":
- self.digital.set_value(mph)
- else:
- self.analog.set_value(min(125, mph))
- self.odo.set_text("%.1f mi" % float(v.odo_miles))
- self.gear.set_text("GEAR %s" % v.gear_label())
- self._layout_odo_gear()
- bits = []
- if v.lights.get("high_beam"):
- bits.append("HB")
- if v.lights.get("turn_left"):
- bits.append("<")
- if v.lights.get("turn_right"):
- bits.append(">")
- if v.redline:
- bits.append("REDLINE")
- self.flags.set_text(" ".join(bits) if bits else "")
-
- def apply_theme(self):
- self.digital.apply_theme()
- self.analog.apply_theme()
- self.gear.set_style_text_color(theme.accent_lite(), 0)
- self.odo.set_style_text_color(theme.text(), 0)
- self.flags.set_style_text_color(theme.warn(), 0)
- apply_button_theme(self.toggle)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return SpeedScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/theme_screen.py b/lib/examples/car_cluster/screens/theme_screen.py
deleted file mode 100644
index 2bc9b2a9f..000000000
--- a/lib/examples/car_cluster/screens/theme_screen.py
+++ /dev/null
@@ -1,150 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Color scheme, chrome shininess, and brightness."""
-
-import lvgl as lv
-
-import theme
-from screens._common import (
- apply_button_theme,
- apply_slider_theme,
- content_size,
- make_button,
- make_slider,
- prep_page,
- section_label,
- zero_pad,
-)
-
-
-def _scale_2x(lbl):
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def _center_label(lbl, y):
- """Horizontally center a label at ``y`` with no translate/x offset."""
- parent = lbl.get_parent()
- try:
- lv.obj.update_layout(parent)
- except Exception:
- pass
- lbl.set_style_translate_x(0, 0)
- lbl.set_style_translate_y(0, 0)
- # Clear any sticky align/pos (section_label starts at x=4).
- pw = max(1, parent.get_width())
- lw = max(1, lbl.get_width())
- lbl.set_pos((pw - lw) // 2, y)
- _scale_2x(lbl)
- # Re-measure after scale pivot setup; keep layout center (scale is visual).
- lw = max(1, lbl.get_width())
- lbl.set_pos((pw - lw) // 2, y)
- lbl.set_style_translate_x(0, 0)
-
-
-class ThemeScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
- self._sliders = []
- self._label_ys = []
- self._buttons = []
-
- # 6 evenly spaced bands: scheme name, PREV/NEXT, chrome header+slider,
- # brightness header+slider.
- n = 6
- y0, y_last = 36, h - 40
- step = (y_last - y0) / (n - 1)
- anchors = [int(round(y0 + i * step)) for i in range(n)]
- hdr = section_label(page, "COLOR SCHEME", 12, accent=True)
- _center_label(hdr, 12)
- self._labels = [hdr]
- self._label_ys = [12]
-
- self.scheme_lbl = lv.label(page)
- self.scheme_lbl.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(self.scheme_lbl, theme.pick_font(240, page))
- zero_pad(self.scheme_lbl)
- self._scheme_y = anchors[0] + 8
- _center_label(self.scheme_lbl, self._scheme_y)
- self._labels.append(self.scheme_lbl)
- self._label_ys.append(self._scheme_y)
-
- prev_b = make_button(page, "PREV", 96, 38, group)
- next_b = make_button(page, "NEXT", 96, 38, group)
- self._buttons.extend((prev_b, next_b))
- btn_w, btn_gap = 96, 8
- x0 = (w - (btn_w * 2 + btn_gap)) // 2
- prev_b.set_pos(x0, anchors[1])
- next_b.set_pos(x0 + btn_w + btn_gap, anchors[1])
-
- def _prev(e):
- theme.set_scheme(theme.scheme_index() - 1)
-
- def _next(e):
- theme.set_scheme(theme.scheme_index() + 1)
-
- prev_b.add_event_cb(_prev, lv.EVENT.CLICKED, None)
- next_b.add_event_cb(_next, lv.EVENT.CLICKED, None)
-
- controls = (
- ("Chrome shininess", theme.shininess, theme.set_shininess),
- ("Brightness", theme.brightness, theme.set_brightness),
- )
- for i, (label, getter, setter) in enumerate(controls):
- ay_hdr = anchors[2 + i * 2]
- ay_sl = anchors[3 + i * 2]
- sec = section_label(page, label.upper(), ay_hdr)
- # Nudge headers down 2× their height so they sit closer to their sliders.
- try:
- lv.obj.update_layout(page)
- except Exception:
- pass
- hy = ay_hdr + 2 * max(1, sec.get_height())
- _center_label(sec, hy)
- self._labels.append(sec)
- self._label_ys.append(hy)
- sl = make_slider(page, w - 24, group)
- sl.set_pos(8, ay_sl)
- sl.set_range(0, 100)
- sl.set_value(int(getter() * 100), 0)
- self._sliders.append(sl)
-
- def _bind(slider, setter_fn):
- def _cb(e):
- setter_fn(slider.get_value() / 100.0)
-
- return _cb
-
- sl.add_event_cb(_bind(sl, setter), lv.EVENT.VALUE_CHANGED, None)
-
- self.apply_theme()
-
- def apply_theme(self):
- self.scheme_lbl.set_text(
- "%s (%d/%d)" % (theme.scheme_name(), theme.scheme_index() + 1, theme.scheme_count())
- )
- for lbl, y in zip(self._labels, self._label_ys):
- lbl.set_style_text_color(theme.accent_lite(), 0)
- _center_label(lbl, y)
- for btn in self._buttons:
- apply_button_theme(btn)
- for sl in self._sliders:
- apply_slider_theme(sl)
-
- def update(self):
- # Scheme label only — avoid restyling all sliders every vehicle tick.
- self.scheme_lbl.set_text(
- "%s (%d/%d)" % (theme.scheme_name(), theme.scheme_index() + 1, theme.scheme_count())
- )
- _center_label(self.scheme_lbl, self._scheme_y)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return ThemeScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/tires.py b/lib/examples/car_cluster/screens/tires.py
deleted file mode 100644
index f7a0e73dd..000000000
--- a/lib/examples/car_cluster/screens/tires.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""TPMS tire pressure / temperature."""
-
-import lvgl as lv
-
-import theme
-from screens._common import content_size, prep_page, zero_pad
-
-
-def _scale_2x(lbl):
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w // 2, 0)
- lbl.set_style_transform_pivot_y(h // 2, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def _scale_2x_bottom_right(lbl):
- """2× scale with BR pivot so growth stays inside the card."""
- w = max(1, lbl.get_width())
- h = max(1, lbl.get_height())
- lbl.set_style_transform_pivot_x(w, 0)
- lbl.set_style_transform_pivot_y(h, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-def _scale_2x_top_left(lbl):
- """2× scale with TL pivot — opposite of bottom-right temps."""
- lbl.set_style_transform_pivot_x(0, 0)
- lbl.set_style_transform_pivot_y(0, 0)
- try:
- lbl.set_style_transform_scale(512, 0)
- except Exception:
- lbl.set_style_transform_scale_x(512, 0)
- lbl.set_style_transform_scale_y(512, 0)
-
-
-class TiresScreen:
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
-
- self.cards = []
- font = theme.pick_font(220, page)
- font_sm = theme.pick_font(180, page)
- gap = 8
- spare_h = 44
- cw = (w - gap * 3) // 2
- ch = (h - gap * 3 - spare_h) // 2
- positions = (
- (gap, gap),
- (gap + cw + gap, gap),
- (gap, gap + ch + gap),
- (gap + cw + gap, gap + ch + gap),
- )
- for i, tire in enumerate(vehicle.tires[:4]):
- x, y = positions[i]
- card = lv.obj(page)
- card.set_size(cw, ch)
- card.set_pos(x, y)
- zero_pad(card)
- theme.style_bg(
- card, theme.panel_raised(), radius=10, border_w=1, border_color=theme.accent_dim()
- )
- zero_pad(card)
- try:
- card.add_flag(lv.obj.FLAG.OVERFLOW_VISIBLE)
- except Exception:
- pass
- name = lv.label(card)
- name.set_text(tire["name"])
- name.set_style_text_color(theme.accent_lite(), 0)
- theme.apply_font(name, font_sm)
- zero_pad(name)
- name.align(lv.ALIGN.TOP_LEFT, 8, 8)
- psi = lv.label(card)
- psi.set_text("")
- psi.set_style_text_color(theme.text(), 0)
- theme.apply_font(psi, font)
- zero_pad(psi)
- psi.align(lv.ALIGN.CENTER, 0, -4)
- temp = lv.label(card)
- temp.set_text("")
- temp.set_style_text_color(theme.text_dim(), 0)
- theme.apply_font(temp, font_sm)
- zero_pad(temp)
- # Bottom-right of card, inset a little up and left.
- temp.align(lv.ALIGN.BOTTOM_RIGHT, -8, -8)
- self.cards.append((card, name, psi, temp))
-
- # Spare: single compact row — no large centered psi that looks like a stray
- spare = vehicle.tires[4]
- card = lv.obj(page)
- card.set_size(w - 2 * gap, spare_h)
- card.set_pos(gap, h - spare_h - gap)
- zero_pad(card)
- theme.style_bg(card, theme.panel(), radius=8, border_w=1, border_color=theme.chrome_mid())
- zero_pad(card)
-
- name = lv.label(card)
- name.set_text(spare["name"])
- name.set_style_text_color(theme.text_dim(), 0)
- theme.apply_font(name, font_sm)
- zero_pad(name)
- name.align(lv.ALIGN.LEFT_MID, 12, 0)
-
- psi = lv.label(card)
- psi.set_text("")
- psi.set_style_text_color(theme.text(), 0)
- theme.apply_font(psi, font_sm)
- zero_pad(psi)
- psi.align(lv.ALIGN.CENTER, 0, 0)
-
- temp = lv.label(card)
- temp.set_text("")
- temp.set_style_text_color(theme.text_dim(), 0)
- theme.apply_font(temp, font_sm)
- zero_pad(temp)
- temp.align(lv.ALIGN.RIGHT_MID, -12, 0)
- self.cards.append((card, name, psi, temp))
- self.update()
-
- def update(self):
- for i, (tire, entry) in enumerate(zip(self.vehicle.tires, self.cards)):
- _card, name_lbl, psi_lbl, temp_lbl = entry
- psi = tire["psi"]
- psi_lbl.set_text("%.1f psi" % psi)
- temp_lbl.set_text("%.0f °F" % tire["temp_f"])
- if psi < 30 or psi > 42:
- psi_lbl.set_style_text_color(theme.danger(), 0)
- elif psi < 32 or psi > 38:
- psi_lbl.set_style_text_color(theme.warn(), 0)
- else:
- psi_lbl.set_style_text_color(theme.ok(), 0)
- # Corner cards: keep all three lines at 2× after text changes.
- if i < 4:
- name_lbl.align(lv.ALIGN.TOP_LEFT, 8, 8)
- temp_lbl.align(lv.ALIGN.BOTTOM_RIGHT, -8, -8)
- _scale_2x_top_left(name_lbl)
- _scale_2x(psi_lbl)
- _scale_2x_bottom_right(temp_lbl)
-
- def apply_theme(self):
- for i, (card, name_lbl, psi_lbl, temp_lbl) in enumerate(self.cards):
- card.set_style_bg_color(theme.panel_raised() if i < 4 else theme.panel(), 0)
- card.set_style_border_color(theme.accent_dim() if i < 4 else theme.chrome_mid(), 0)
- name_lbl.set_style_text_color(theme.accent_lite() if i < 4 else theme.text_dim(), 0)
- temp_lbl.set_style_text_color(theme.text_dim(), 0)
- self.update()
-
-
-def build(page, vehicle, group, w=0, h=0):
- return TiresScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/trip_a.py b/lib/examples/car_cluster/screens/trip_a.py
deleted file mode 100644
index bfbfcbc62..000000000
--- a/lib/examples/car_cluster/screens/trip_a.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Trip computer A."""
-
-import lvgl as lv
-
-import theme
-from screens._common import (
- apply_button_theme,
- apply_kv_edge_scale,
- content_size,
- kv_row,
- make_button,
- prep_page,
- scale_2x,
- spread_rows,
-)
-
-
-class TripScreen:
- which = "a"
- title = "TRIP A"
-
- def __init__(self, page, vehicle, group, w=0, h=0):
- self.vehicle = vehicle
- w, h = content_size(prep_page(page, w, h), w, h)
-
- labels = ("Distance", "Avg speed", "Avg MPG", "Elapsed", "Max speed")
- ys = spread_rows(len(labels), top=12, bottom=52, height=h)
- self.keys = []
- self.rows = []
- for lab, y in zip(labels, ys):
- k, v = kv_row(page, y, lab, "--", w)
- self.keys.append(k)
- self.rows.append(v)
-
- # Design session: 2× edge-pivoted rows + pad/dy nudges; RESET 2×.
- apply_kv_edge_scale(self.keys, self.rows, w, page=page)
- self.reset = make_button(page, "RESET TRIP", min(180, w - 24), 40, group)
- self.reset.align(lv.ALIGN.BOTTOM_MID, 0, -8)
- try:
- lv.obj.update_layout(self.reset)
- lbl = self.reset.get_child(0)
- lbl.align(lv.ALIGN.CENTER, 0, 0)
- scale_2x(lbl)
- # Re-center after transform so the scaled label sits mid H+V.
- lbl.align(lv.ALIGN.CENTER, 0, 0)
- lbl.set_style_translate_x(0, 0)
- lbl.set_style_translate_y(0, 0)
- except Exception:
- pass
-
- which = self.which
-
- def _reset(e):
- vehicle.reset_trip(which)
-
- self.reset.add_event_cb(_reset, lv.EVENT.CLICKED, None)
- self._page = page
- self._page_w = w
- self.update()
-
- def update(self):
- v = self.vehicle
- trip = v.trip_a if self.which == "a" else v.trip_b
- mins = int(trip["time_s"] // 60)
- secs = int(trip["time_s"] % 60)
- vals = (
- "%.1f mi" % trip["distance"],
- "%.1f mph" % v.trip_avg_speed(self.which),
- "%.1f" % v.trip_mpg(self.which),
- "%d:%02d" % (mins, secs),
- "%.0f mph" % trip["max_speed"],
- )
- for lbl, text in zip(self.rows, vals):
- lbl.set_text(text)
- # Re-apply right-edge pivots after value width changes.
- apply_kv_edge_scale(self.keys, self.rows, self._page_w, page=self._page)
-
- def apply_theme(self):
- apply_button_theme(self.reset)
-
-
-def build(page, vehicle, group, w=0, h=0):
- return TripScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/screens/trip_b.py b/lib/examples/car_cluster/screens/trip_b.py
deleted file mode 100644
index f71ce464f..000000000
--- a/lib/examples/car_cluster/screens/trip_b.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Trip computer B."""
-
-from screens.trip_a import TripScreen
-
-
-class TripBScreen(TripScreen):
- which = "b"
- title = "TRIP B"
-
-
-def build(page, vehicle, group, w=0, h=0):
- return TripBScreen(page, vehicle, group, w, h)
diff --git a/lib/examples/car_cluster/theme.py b/lib/examples/car_cluster/theme.py
deleted file mode 100644
index a91633c71..000000000
--- a/lib/examples/car_cluster/theme.py
+++ /dev/null
@@ -1,320 +0,0 @@
-# SPDX-License-Identifier: MIT
-"""Material accent schemes, chrome shininess, and shared style retention."""
-
-import lvgl as lv
-
-_styles = []
-_listeners = []
-
-# Named schemes: Material palette accents for the cluster.
-SCHEMES = (
- {"name": "Cyan", "accent": lv.PALETTE.CYAN, "secondary": lv.PALETTE.BLUE_GREY},
- {"name": "Amber", "accent": lv.PALETTE.AMBER, "secondary": lv.PALETTE.ORANGE},
- {"name": "Indigo", "accent": lv.PALETTE.INDIGO, "secondary": lv.PALETTE.DEEP_PURPLE},
- {"name": "Teal", "accent": lv.PALETTE.TEAL, "secondary": lv.PALETTE.GREEN},
- {"name": "Deep Orange", "accent": lv.PALETTE.DEEP_ORANGE, "secondary": lv.PALETTE.RED},
- {"name": "Light Blue", "accent": lv.PALETTE.LIGHT_BLUE, "secondary": lv.PALETTE.BLUE},
-)
-
-_scheme_index = 5 # Light Blue
-_shininess = 0.5
-_gauge_scale = 1.0
-_brightness = 0.92
-
-
-def retain_style(style):
- _styles.append(style)
- return style
-
-
-def on_change(cb):
- if cb not in _listeners:
- _listeners.append(cb)
-
-
-def notify():
- for cb in _listeners:
- try:
- cb()
- except Exception:
- pass
-
-
-def scheme_name():
- return SCHEMES[_scheme_index]["name"]
-
-
-def scheme_count():
- return len(SCHEMES)
-
-
-def set_scheme(index):
- global _scheme_index
- n = len(SCHEMES)
- _scheme_index = int(index) % n
- notify()
-
-
-def set_shininess(value):
- global _shininess
- if value < 0.0:
- value = 0.0
- if value > 1.0:
- value = 1.0
- _shininess = value
- notify()
-
-
-def set_gauge_scale(value):
- global _gauge_scale
- if value < 0.85:
- value = 0.85
- if value > 1.15:
- value = 1.15
- _gauge_scale = value
- notify()
-
-
-def set_brightness(value):
- global _brightness
- if value < 0.3:
- value = 0.3
- if value > 1.0:
- value = 1.0
- _brightness = value
- notify()
-
-
-def shininess():
- return _shininess
-
-
-def gauge_scale():
- return _gauge_scale
-
-
-def brightness():
- return _brightness
-
-
-def scheme_index():
- return _scheme_index
-
-
-def _hex(rgb):
- return lv.color_hex(rgb & 0xFFFFFF)
-
-
-def _blend_toward(rgb, target, amount):
- r = (rgb >> 16) & 0xFF
- g = (rgb >> 8) & 0xFF
- b = rgb & 0xFF
- tr = (target >> 16) & 0xFF
- tg = (target >> 8) & 0xFF
- tb = target & 0xFF
- r = int(r + (tr - r) * amount)
- g = int(g + (tg - g) * amount)
- b = int(b + (tb - b) * amount)
- return (r << 16) | (g << 8) | b
-
-
-def _dim_rgb(rgb, factor):
- r = int(((rgb >> 16) & 0xFF) * factor)
- g = int(((rgb >> 8) & 0xFF) * factor)
- b = int((rgb & 0xFF) * factor)
- if r > 255:
- r = 255
- if g > 255:
- g = 255
- if b > 255:
- b = 255
- return (r << 16) | (g << 8) | b
-
-
-def _accent_palette():
- return SCHEMES[_scheme_index]["accent"]
-
-
-def _secondary_palette():
- return SCHEMES[_scheme_index]["secondary"]
-
-
-def secondary():
- return lv.palette_main(_secondary_palette())
-
-
-def secondary_dim():
- return lv.palette_darken(_secondary_palette(), 1)
-
-
-def bg():
- # Slightly lifted from near-black so chrome/bezels read against it.
- return _hex(_dim_rgb(0x14161C, _brightness))
-
-
-def panel():
- return _hex(_dim_rgb(0x1A1E28, _brightness))
-
-
-def panel_raised():
- return _hex(_dim_rgb(0x222833, _brightness))
-
-
-def chrome_hi():
- # Bright rim — shininess pushes toward polished silver.
- base = _blend_toward(0x4A5160, 0xE8EEF5, _shininess * 0.95)
- return _hex(_dim_rgb(base, _brightness))
-
-
-def chrome_mid():
- base = _blend_toward(0x2A303C, 0xA8B2C0, _shininess * 0.85)
- return _hex(_dim_rgb(base, _brightness))
-
-
-def chrome_lo():
- base = _blend_toward(0x0C0E12, 0x3A4250, _shininess * 0.55)
- return _hex(_dim_rgb(base, _brightness))
-
-
-def chrome_specular():
- """Near-white highlight for metallic top edges."""
- base = _blend_toward(0x6A7384, 0xFFFFFF, min(1.0, _shininess * 1.05))
- return _hex(_dim_rgb(base, _brightness))
-
-
-def accent():
- return lv.palette_main(_accent_palette())
-
-
-def accent_dim():
- return lv.palette_darken(_accent_palette(), 2)
-
-
-def accent_lite():
- return lv.palette_lighten(_accent_palette(), 1)
-
-
-def danger():
- return lv.palette_main(lv.PALETTE.RED)
-
-
-def warn():
- return lv.palette_main(lv.PALETTE.AMBER)
-
-
-def ok():
- return lv.palette_main(lv.PALETTE.GREEN)
-
-
-def text():
- return _hex(_dim_rgb(0xF2F4F8, _brightness))
-
-
-def text_dim():
- return _hex(_dim_rgb(0x8A93A3, _brightness))
-
-
-def tick():
- return _hex(_dim_rgb(0xB8C0CC, _brightness))
-
-
-def needle():
- return accent_lite()
-
-
-def face():
- return _hex(_dim_rgb(0x0E1016, _brightness))
-
-
-def pick_font(unit, ref_obj=None):
- if unit >= 520:
- candidates = (28, 24, 16, 14)
- elif unit >= 360:
- candidates = (24, 16, 14)
- elif unit >= 200:
- candidates = (18, 16, 14)
- else:
- candidates = (16, 14, 24)
- for size in candidates:
- font = getattr(lv, "font_montserrat_" + str(size), None)
- if font is not None:
- return font
- if ref_obj is not None:
- for getter in ("theme_get_font_large", "theme_get_font_normal", "theme_get_font_small"):
- fn = getattr(lv, getter, None)
- if fn is None:
- continue
- try:
- font = fn(ref_obj)
- if font is not None:
- return font
- except Exception:
- pass
- fn = getattr(lv, "font_get_default", None)
- if fn is not None:
- try:
- return fn()
- except Exception:
- pass
- return None
-
-
-def apply_font(obj, font):
- if font is not None:
- obj.set_style_text_font(font, 0)
-
-
-def style_bg(obj, color, radius=0, border_w=0, border_color=None):
- style = lv.style_t()
- style.init()
- style.set_bg_color(color)
- style.set_bg_opa(lv.OPA.COVER)
- style.set_radius(radius)
- style.set_border_width(border_w)
- if border_color is not None and border_w:
- style.set_border_color(border_color)
- style.set_border_opa(lv.OPA.COVER)
- style.set_pad_all(0)
- obj.add_style(style, 0)
- return retain_style(style)
-
-
-def style_chrome(
- obj,
- *,
- radius=0,
- border_w=1,
- border_color=None,
- vertical=True,
- highlight=False,
-):
- """Metallic fill: bright→dark gradient, optional specular top."""
- style = lv.style_t()
- style.init()
- hi = chrome_specular() if highlight else chrome_hi()
- lo = chrome_lo()
- style.set_bg_color(hi)
- style.set_bg_grad_color(lo)
- style.set_bg_grad_dir(lv.GRAD_DIR.VER if vertical else lv.GRAD_DIR.HOR)
- style.set_bg_opa(lv.OPA.COVER)
- style.set_radius(radius)
- style.set_border_width(border_w)
- if border_w:
- style.set_border_color(border_color if border_color is not None else chrome_mid())
- style.set_border_opa(lv.OPA.COVER)
- style.set_pad_all(0)
- obj.add_style(style, 0)
- return retain_style(style)
-
-
-def apply_chrome_local(obj, *, highlight=False, vertical=True, border_color=None):
- """Update chrome colors in place (theme / shininess changes)."""
- hi = chrome_specular() if highlight else chrome_hi()
- obj.set_style_bg_color(hi, 0)
- obj.set_style_bg_grad_color(chrome_lo(), 0)
- obj.set_style_bg_grad_dir(lv.GRAD_DIR.VER if vertical else lv.GRAD_DIR.HOR, 0)
- obj.set_style_bg_opa(lv.OPA.COVER, 0)
- if border_color is not None:
- obj.set_style_border_color(border_color, 0)
- else:
- obj.set_style_border_color(chrome_mid(), 0)
diff --git a/lib/examples/car_cluster/vehicle.py b/lib/examples/car_cluster/vehicle.py
index 8eee753e8..a9d3277ff 100644
--- a/lib/examples/car_cluster/vehicle.py
+++ b/lib/examples/car_cluster/vehicle.py
@@ -1,236 +1,132 @@
# SPDX-License-Identifier: MIT
-"""Believable vehicle state for the instrument cluster demo.
-
-Drive state is automatic: a looping profile varies gear, speed, RPM, and oil
-pressure over time. Digit keys are not used. Speed tracks roughly 10× gear.
+"""An electric car for the cluster to show.
+
+The car drives itself round a 90 second loop: out of a parking space in
+reverse, into town, a fast stretch, an exit with the indicator on, back to a
+stop. Speed follows the loop's targets with believable acceleration, motor
+power comes from the physics of pushing the car along, and regenerative
+braking shows as negative power. The odometer and the efficiency figure
+follow from those.
+
+``tick(dt)`` advances the car by ``dt`` seconds. Everything the cluster
+draws is an attribute, in the units an American car shows: ``speed_mph``,
+``power_kw``, ``gear``, ``odometer_mi``, ``mi_per_kwh``, ``left``, ``right``.
+The physics underneath is metric.
"""
import math
-_IDLE_RPM = 1000
-_MAX_ACCEL_RPM = 7000
-_REDLINE = 6500
-_CYCLE_S = 72.0
+_CYCLE_S = 90.0
-# Piecewise drive profile: (end_time_s, gear, throttle).
-# Speed target is ~gear × 10 mph. Throttle drives RPM and accel feel.
+# (end of the segment in seconds, gear, target speed in mph, left, right)
_PROFILE = (
- (4.0, 0, 0.0), # park / idle
- (8.0, 1, 0.05), # creep
- (16.0, 3, 0.75), # pull away
- (24.0, 5, 0.55), # build to ~50
- (34.0, 6, 0.35), # cruise ~60
- (42.0, 8, 1.0), # hard accel → ~7k RPM
- (50.0, 7, 0.40), # settle ~70
- (60.0, 3, 0.0), # coast / brake
- (66.0, 1, 0.0), # idle in gear
- (72.0, 0, 0.0), # park
+ (3.0, "P", 0, 0, 0),
+ (7.0, "R", 4, 0, 0),
+ (9.0, "N", 0, 0, 0),
+ (17.0, "D", 28, 1, 0),
+ (28.0, "D", 37, 0, 0),
+ (38.0, "S", 75, 0, 0),
+ (48.0, "S", 86, 0, 0),
+ (58.0, "D", 50, 0, 1),
+ (70.0, "D", 19, 0, 0),
+ (78.0, "D", 0, 0, 0),
+ (84.0, "P", 0, 0, 0),
+ (90.0, "P", 0, 0, 0),
)
+_MPH = 2.236936 # m/s to mph
+_KM_PER_MI = 1.609344
-def _clamp(v, lo, hi):
- if v < lo:
- return lo
- if v > hi:
- return hi
- return v
+_MASS_KG = 1850.0
+_DRAG = 0.5 * 1.2 * 0.62 # half air density times drag area
+_ROLLING = 0.011 * _MASS_KG * 9.81
+_ACCEL = 2.6 # m/s^2, flat out
+_BRAKE = 4.2
+_MAX_KW = 200.0
+_MAX_REGEN_KW = 70.0
-def _lerp(a, b, t):
- return a + (b - a) * t
+def _clamp(v, lo, hi):
+ return lo if v < lo else hi if v > hi else v
-def _profile_at(t):
- """Return (gear, throttle) for absolute time ``t`` (seconds)."""
+def _segment(t):
u = t % _CYCLE_S
- prev_t, prev_g, prev_thr = 0.0, 0, 0.0
- for end_t, gear, thr in _PROFILE:
- if u <= end_t:
- span = end_t - prev_t
- if span <= 1e-6:
- return gear, thr
- f = (u - prev_t) / span
- # Hold gear as integer steps; blend throttle smoothly.
- g = gear if f >= 0.35 else prev_g
- if prev_g == 0 and gear > 0 and f < 0.15:
- g = 0
- return int(g), _lerp(prev_thr, thr, f)
- prev_t, prev_g, prev_thr = end_t, gear, thr
- return 0, 0.0
+ for end, gear, target, left, right in _PROFILE:
+ if u <= end:
+ return gear, target, left, right
+ return "P", 0, 0, 0
class Vehicle:
def __init__(self):
- self.gear = 0 # 0 = Park, 1..10
- self.throttle = 0.0
+ self.t = 0.0
self.speed_mph = 0.0
- self.rpm = float(_IDLE_RPM)
- self.fuel_frac = 0.625 # fixed ~5/8 tank
- self.coolant_f = 210.6 # fixed
- self.oil_psi = 22.0
- self.oil_temp_f = 205.0
- self.battery_v = 14.2
- self.iat_f = 95.0
- self.engine_hours = 128.4
- self.odo_miles = 48217.6
- self.speedo_mode = "digital" # or "analog"
- self.trip_a = self._new_trip()
- self.trip_b = self._new_trip()
- self.lights = {
- "autodim": True,
- "delayed_off": True,
- "drl": True,
- "fog": False,
- "cabin": False,
- "brightness": 0.85,
- "high_beam": False,
- "turn_left": False,
- "turn_right": False,
- }
- self.tires = [
- {"name": "FL", "psi": 34.0, "temp_f": 86.0},
- {"name": "FR", "psi": 34.5, "temp_f": 87.0},
- {"name": "RL", "psi": 33.0, "temp_f": 90.0},
- {"name": "RR", "psi": 33.5, "temp_f": 89.0},
- {"name": "Spare", "psi": 40.0, "temp_f": 72.0},
- ]
- self.assist = {
- "acc": True,
- "acc_set": 65,
- "lane_keep": True,
- "blind_spot": True,
- "park_fl": 0.15,
- "park_fr": 0.18,
- "park_rl": 0.55,
- "park_rr": 0.52,
- }
- self.redline = False
- self._t = 0.0
- self._accel = 0.0 # mph/s, for oil response
-
- def _new_trip(self):
- return {
- "distance": 0.0,
- "time_s": 0.0,
- "fuel_used": 0.0,
- "max_speed": 0.0,
- }
-
- def gear_label(self):
- if self.gear <= 0:
- return "P"
- return str(int(self.gear))
-
- def set_gear(self, g):
- self.gear = int(_clamp(g, 0, 10))
-
- def set_throttle(self, t):
- self.throttle = _clamp(float(t), 0.0, 1.0)
-
- def toggle_speedo_mode(self):
- self.speedo_mode = "analog" if self.speedo_mode == "digital" else "digital"
-
- def reset_trip(self, which):
- if which == "a":
- self.trip_a = self._new_trip()
- elif which == "b":
- self.trip_b = self._new_trip()
-
- def trip_avg_speed(self, which):
- trip = self.trip_a if which == "a" else self.trip_b
- if trip["time_s"] < 1.0:
- return 0.0
- return trip["distance"] / (trip["time_s"] / 3600.0)
-
- def trip_mpg(self, which):
- trip = self.trip_a if which == "a" else self.trip_b
- if trip["fuel_used"] < 0.01:
- return 0.0
- return trip["distance"] / trip["fuel_used"]
-
- def tick(self, dt_s):
- if dt_s <= 0:
+ self.power_kw = 0.0
+ self.gear = "P"
+ self.odometer_mi = 13488.0
+ self.mi_per_kwh = 3.7
+ self.left = False
+ self.right = False
+ self.running = True
+ # A press of the left or right arrow holds that indicator on until
+ # the next press; the drive loop's own signals are ignored meanwhile.
+ self.manual_signal = None
+
+ def toggle_signal(self, side):
+ self.manual_signal = None if self.manual_signal == side else side
+
+ def tick(self, dt):
+ if not self.running or dt <= 0:
return
- if dt_s > 0.2:
- dt_s = 0.2
-
- self._t += dt_s
- gear_tgt, thr_tgt = _profile_at(self._t)
- self.gear = int(gear_tgt)
- self.throttle = _lerp(self.throttle, thr_tgt, min(1.0, dt_s * 3.0))
-
- # Speed tracks roughly 10 × gear.
- if self.gear <= 0:
- target_speed = 0.0
- else:
- target_speed = float(self.gear * 10)
-
- prev_speed = self.speed_mph
- # Accelerate a bit faster than we decelerate so pulls feel eager.
- speed_rate = 2.8 if target_speed > self.speed_mph else 1.8
- self.speed_mph = _lerp(self.speed_mph, target_speed, min(1.0, dt_s * speed_rate))
- if self.speed_mph < 0.2:
- self.speed_mph = 0.0
- self._accel = (self.speed_mph - prev_speed) / dt_s
-
- # RPM: 1k idle → 7k at strongest acceleration.
- idle = self.gear <= 0 or (self.speed_mph < 1.5 and self.throttle < 0.08)
- if idle:
- target_rpm = float(_IDLE_RPM)
+ self.t += dt
+ gear, target, left, right = _segment(self.t)
+ self.gear = gear
+ if self.manual_signal is None:
+ self.left = bool(left)
+ self.right = bool(right)
else:
- accel_n = _clamp(self._accel / 10.0, 0.0, 1.0)
- # Hard throttle and rising speed push toward redline; cruise is calmer.
- demand = _clamp(max(self.throttle, accel_n * 0.9), 0.0, 1.0)
- if self.throttle < 0.12 and self._accel <= 0.5:
- demand = _clamp(0.12 + self.speed_mph / 120.0, 0.12, 0.35)
- target_rpm = _IDLE_RPM + (_MAX_ACCEL_RPM - _IDLE_RPM) * demand
-
- self.rpm = _lerp(self.rpm, target_rpm, min(1.0, dt_s * 4.5))
- self.redline = self.rpm >= _REDLINE
-
- # Oil pressure: 15–30 idle, 35–65 driving; rises on accel, falls on decel.
- if idle:
- # Mild idle wander with a quiet sine so the needle isn't frozen.
- wobble = 0.5 + 0.5 * math.sin(self._t * 0.7)
- target_oil = 18.0 + 10.0 * wobble # ~18..28 within 15–30
+ self.left = self.manual_signal == "left"
+ self.right = self.manual_signal == "right"
+
+ v = self.speed_mph / _MPH
+ want = target / _MPH
+ if want > v:
+ # Pull harder from a standstill, ease off near the target.
+ a = _ACCEL * _clamp((want - v) / 8.0 + 0.25, 0.3, 1.0)
+ v = min(want, v + a * dt)
+ elif want < v:
+ a = _BRAKE * _clamp((v - want) / 10.0 + 0.2, 0.25, 1.0)
+ v = max(want, v - a * dt)
+ a = -a
else:
- drive = _clamp(self.speed_mph / 80.0, 0.0, 1.0)
- target_oil = 40.0 + 15.0 * drive # ~40..55 baseline while moving
- accel_n = _clamp(self._accel / 10.0, -1.0, 1.0)
- target_oil += accel_n * 12.0
- target_oil = _clamp(target_oil, 35.0, 65.0)
- oil_rate = 3.5 if abs(self._accel) > 1.0 else 2.0
- self.oil_psi = _lerp(self.oil_psi, target_oil, min(1.0, dt_s * oil_rate))
- if idle:
- self.oil_psi = _clamp(self.oil_psi, 15.0, 30.0)
+ a = 0.0
+ if v < 0.05:
+ v = 0.0
+
+ # Power to move the car: inertia plus drag plus rolling resistance.
+ force = _MASS_KG * a + _DRAG * v * v + _ROLLING * (1.0 if v > 0 else 0.0)
+ kw = force * v / 1000.0
+ if kw < 0:
+ kw = max(-_MAX_REGEN_KW, kw * 0.7)
else:
- self.oil_psi = _clamp(self.oil_psi, 35.0, 65.0)
-
- # Fuel + coolant stay fixed. Ancillary temps still breathe a little.
- load = _clamp(self.throttle * 0.7 + (self.rpm / 10000.0) * 0.3, 0.0, 1.0)
- self.oil_temp_f = _lerp(self.oil_temp_f, 190.0 + load * 30.0, min(1.0, dt_s * 0.1))
- self.iat_f = _lerp(self.iat_f, 85.0 + load * 25.0, min(1.0, dt_s * 0.08))
- self.battery_v = _lerp(
- self.battery_v, 13.8 + (0.6 if self.rpm > 500 else 0.0), min(1.0, dt_s)
- )
-
- dist = self.speed_mph * (dt_s / 3600.0)
- self.odo_miles += dist
- if self.rpm > 100:
- self.engine_hours += dt_s / 3600.0
-
- # Trip fuel_used for MPG only — does not drain the fuel gauge.
- burn = (0.000005 + self.throttle * 0.00004 + self.speed_mph * 0.00000015) * dt_s
- for trip in (self.trip_a, self.trip_b):
- if self.speed_mph > 0.5:
- trip["distance"] += dist
- trip["time_s"] += dt_s
- trip["fuel_used"] += burn * 12.0
- if self.speed_mph > trip["max_speed"]:
- trip["max_speed"] = self.speed_mph
-
- for tire in self.tires:
- if tire["name"] == "Spare":
- continue
- target_t = 75.0 + self.speed_mph * 0.25
- tire["temp_f"] = _lerp(tire["temp_f"], target_t, min(1.0, dt_s * 0.05))
+ kw = min(_MAX_KW, kw / 0.9)
+ if v == 0.0:
+ kw = 0.0
+ # The motor can't change its mind instantly.
+ self.power_kw += (kw - self.power_kw) * _clamp(dt * 6.0, 0.0, 1.0)
+
+ self.speed_mph = v * _MPH
+ self.odometer_mi += v * dt / 1000.0 / _KM_PER_MI
+ # Efficiency: miles per kWh at the motor's draw right now, averaged
+ # over the last half minute or so, so a hard pull shows but doesn't
+ # own the number, and regen brings it back up.
+ if self.speed_mph > 6.0:
+ wh_per_mi = _clamp(self.power_kw * 1000.0 / self.speed_mph, -500.0, 1500.0)
+ now = 1000.0 / wh_per_mi if wh_per_mi > 100.0 else 9.9
+ self.mi_per_kwh += (now - self.mi_per_kwh) * _clamp(dt / 20.0, 0.0, 1.0)
+ self.mi_per_kwh = _clamp(self.mi_per_kwh, 1.2, 6.5)
+
+ def blink_on(self):
+ """Indicator lamps blink at 90 per minute, on for the first half."""
+ return (self.t * 1.5) % 1.0 < 0.5
diff --git a/packages/car_cluster.json b/packages/car_cluster.json
index c8096cf97..8ec78c6af 100644
--- a/packages/car_cluster.json
+++ b/packages/car_cluster.json
@@ -9,84 +9,16 @@
"../lib/examples/car_cluster/car_cluster.py"
],
[
- "car_cluster/chrome.py",
- "../lib/examples/car_cluster/chrome.py"
+ "car_cluster/cluster.py",
+ "../lib/examples/car_cluster/cluster.py"
],
[
- "car_cluster/focus_nav.py",
- "../lib/examples/car_cluster/focus_nav.py"
- ],
- [
- "car_cluster/gauges.py",
- "../lib/examples/car_cluster/gauges.py"
- ],
- [
- "car_cluster/input_map.py",
- "../lib/examples/car_cluster/input_map.py"
- ],
- [
- "car_cluster/key_probe.py",
- "../lib/examples/car_cluster/key_probe.py"
- ],
- [
- "car_cluster/layout.py",
- "../lib/examples/car_cluster/layout.py"
- ],
- [
- "car_cluster/lv_util.py",
- "../lib/examples/car_cluster/lv_util.py"
- ],
- [
- "car_cluster/rails.py",
- "../lib/examples/car_cluster/rails.py"
- ],
- [
- "car_cluster/theme.py",
- "../lib/examples/car_cluster/theme.py"
+ "car_cluster/ring.py",
+ "../lib/examples/car_cluster/ring.py"
],
[
"car_cluster/vehicle.py",
"../lib/examples/car_cluster/vehicle.py"
- ],
- [
- "car_cluster/screens/__init__.py",
- "../lib/examples/car_cluster/screens/__init__.py"
- ],
- [
- "car_cluster/screens/_common.py",
- "../lib/examples/car_cluster/screens/_common.py"
- ],
- [
- "car_cluster/screens/assist.py",
- "../lib/examples/car_cluster/screens/assist.py"
- ],
- [
- "car_cluster/screens/engine.py",
- "../lib/examples/car_cluster/screens/engine.py"
- ],
- [
- "car_cluster/screens/lights.py",
- "../lib/examples/car_cluster/screens/lights.py"
- ],
- [
- "car_cluster/screens/speed.py",
- "../lib/examples/car_cluster/screens/speed.py"
- ],
- [
- "car_cluster/screens/theme_screen.py",
- "../lib/examples/car_cluster/screens/theme_screen.py"
- ],
- [
- "car_cluster/screens/tires.py",
- "../lib/examples/car_cluster/screens/tires.py"
- ],
- [
- "car_cluster/screens/trip_a.py",
- "../lib/examples/car_cluster/screens/trip_a.py"
- ],
- [
- "car_cluster/screens/trip_b.py",
- "../lib/examples/car_cluster/screens/trip_b.py"
]
],
"version": "0.0.1"
diff --git a/packages/examples.json b/packages/examples.json
index c28b773eb..45280ea07 100644
--- a/packages/examples.json
+++ b/packages/examples.json
@@ -333,85 +333,17 @@
"github:PyDevices/pydevices-examples/lib/examples/car_cluster/car_cluster.py"
],
[
- "car_cluster/chrome.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/chrome.py"
+ "car_cluster/cluster.py",
+ "github:PyDevices/pydevices-examples/lib/examples/car_cluster/cluster.py"
],
[
- "car_cluster/focus_nav.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/focus_nav.py"
- ],
- [
- "car_cluster/gauges.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/gauges.py"
- ],
- [
- "car_cluster/input_map.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/input_map.py"
- ],
- [
- "car_cluster/key_probe.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/key_probe.py"
- ],
- [
- "car_cluster/layout.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/layout.py"
- ],
- [
- "car_cluster/lv_util.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/lv_util.py"
- ],
- [
- "car_cluster/rails.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/rails.py"
- ],
- [
- "car_cluster/theme.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/theme.py"
+ "car_cluster/ring.py",
+ "github:PyDevices/pydevices-examples/lib/examples/car_cluster/ring.py"
],
[
"car_cluster/vehicle.py",
"github:PyDevices/pydevices-examples/lib/examples/car_cluster/vehicle.py"
],
- [
- "car_cluster/screens/__init__.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/__init__.py"
- ],
- [
- "car_cluster/screens/_common.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/_common.py"
- ],
- [
- "car_cluster/screens/assist.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/assist.py"
- ],
- [
- "car_cluster/screens/engine.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/engine.py"
- ],
- [
- "car_cluster/screens/lights.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/lights.py"
- ],
- [
- "car_cluster/screens/speed.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/speed.py"
- ],
- [
- "car_cluster/screens/theme_screen.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/theme_screen.py"
- ],
- [
- "car_cluster/screens/tires.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/tires.py"
- ],
- [
- "car_cluster/screens/trip_a.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/trip_a.py"
- ],
- [
- "car_cluster/screens/trip_b.py",
- "github:PyDevices/pydevices-examples/lib/examples/car_cluster/screens/trip_b.py"
- ],
[
"cast/__init__.py",
"github:PyDevices/pydevices-examples/lib/examples/cast/__init__.py"
diff --git a/pydevices-examples.toml b/pydevices-examples.toml
index 7cd84db2b..50be1fc6e 100644
--- a/pydevices-examples.toml
+++ b/pydevices-examples.toml
@@ -38,25 +38,8 @@
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/calc_widgets.py" = "/examples/calc_widgets.py"
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/__init__.py" = "/examples/car_cluster/__init__.py"
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/car_cluster.py" = "/examples/car_cluster/car_cluster.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/chrome.py" = "/examples/car_cluster/chrome.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/focus_nav.py" = "/examples/car_cluster/focus_nav.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/gauges.py" = "/examples/car_cluster/gauges.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/input_map.py" = "/examples/car_cluster/input_map.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/key_probe.py" = "/examples/car_cluster/key_probe.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/layout.py" = "/examples/car_cluster/layout.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/lv_util.py" = "/examples/car_cluster/lv_util.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/rails.py" = "/examples/car_cluster/rails.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/__init__.py" = "/examples/car_cluster/screens/__init__.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/_common.py" = "/examples/car_cluster/screens/_common.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/assist.py" = "/examples/car_cluster/screens/assist.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/engine.py" = "/examples/car_cluster/screens/engine.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/lights.py" = "/examples/car_cluster/screens/lights.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/speed.py" = "/examples/car_cluster/screens/speed.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/theme_screen.py" = "/examples/car_cluster/screens/theme_screen.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/tires.py" = "/examples/car_cluster/screens/tires.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/trip_a.py" = "/examples/car_cluster/screens/trip_a.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/screens/trip_b.py" = "/examples/car_cluster/screens/trip_b.py"
-"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/theme.py" = "/examples/car_cluster/theme.py"
+"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/cluster.py" = "/examples/car_cluster/cluster.py"
+"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/ring.py" = "/examples/car_cluster/ring.py"
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/car_cluster/vehicle.py" = "/examples/car_cluster/vehicle.py"
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/cast/__init__.py" = "/examples/cast/__init__.py"
"https://raw.githubusercontent.com/PyDevices/pydevices-examples/main/lib/examples/cast/_common.py" = "/examples/cast/_common.py"
diff --git a/tests/test_gallery_frame.py b/tests/test_gallery_frame.py
index 03eb349d4..7bd7b7c77 100644
--- a/tests/test_gallery_frame.py
+++ b/tests/test_gallery_frame.py
@@ -204,8 +204,8 @@ def test_interpreter_loaders_set_browser_defaults_without_importing_board(self):
def test_car_cluster_forces_its_browser_resolution(self):
source = _read(ROOT / "lib" / "examples" / "car_cluster" / "car_cluster.py")
- assert 'env_set("PYDEVICES_WIDTH", "1024")' in source
- assert 'env_set("PYDEVICES_HEIGHT", "512")' in source
+ assert 'env_set("PYDEVICES_WIDTH", "800")' in source
+ assert 'env_set("PYDEVICES_HEIGHT", "480")' in source
assert 'if env_get("PYDEVICES_WIDTH")' not in source
assert 'if env_get("PYDEVICES_HEIGHT")' not in source