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 @@

Widgets Clinic Queue

Car Cluster

-

Flagship LVGL automobile instrument cluster.

+

An electric car's instrument cluster, 800x480, in LVGL.

diff --git a/.site/gallery/python-files.json b/.site/gallery/python-files.json index cab149052..d5817faeb 100644 --- a/.site/gallery/python-files.json +++ b/.site/gallery/python-files.json @@ -16,25 +16,8 @@ "calc_widgets.py", "car_cluster/__init__.py", "car_cluster/car_cluster.py", - "car_cluster/chrome.py", - "car_cluster/focus_nav.py", - "car_cluster/gauges.py", - "car_cluster/input_map.py", - "car_cluster/key_probe.py", - "car_cluster/layout.py", - "car_cluster/lv_util.py", - "car_cluster/rails.py", - "car_cluster/screens/__init__.py", - "car_cluster/screens/_common.py", - "car_cluster/screens/assist.py", - "car_cluster/screens/engine.py", - "car_cluster/screens/lights.py", - "car_cluster/screens/speed.py", - "car_cluster/screens/theme_screen.py", - "car_cluster/screens/tires.py", - "car_cluster/screens/trip_a.py", - "car_cluster/screens/trip_b.py", - "car_cluster/theme.py", + "car_cluster/cluster.py", + "car_cluster/ring.py", "car_cluster/vehicle.py", "chango/__init__.py", "chango/chango.py", diff --git a/.site/pyscript/index.html b/.site/pyscript/index.html index cf5b8c2c5..c848d547e 100644 --- a/.site/pyscript/index.html +++ b/.site/pyscript/index.html @@ -130,7 +130,7 @@

Widgets Clinic Queue

Car Cluster

-

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 @@ | ![tiny_toasters.gif](tiny_toasters.gif) | ![piano.png](piano.png) | ![google_photos.png](google_photos.png) | | ![google_photos_view.png](google_photos_view.png) | ![google_photos_connect.png](google_photos_connect.png) | ![spectrum_800x480.gif](spectrum_800x480.gif) | | ![spectrum_800x480.png](spectrum_800x480.png) | ![spectrum_800x480_segmented.png](spectrum_800x480_segmented.png) | ![spectrum_320x170.png](spectrum_320x170.png) | +| ![car_cluster.png](car_cluster.png) | | | 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