From 097cf5b50e79cfa5801f7bfb769896d37d87c334 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:04:16 -0600 Subject: [PATCH 1/2] appkit: switching apps no longer splits the free heap it checks After closing an app the launcher checks the largest free block and restarts the program when it is too small. On a board that keeps call frames on the heap, the check ran inside the calls that delivered the switch, and those calls, the request's timer and its closure were all allocated while the old app ran, among its objects. They split the space the close had just freed, so a board with a 228 KB heap restarted on a plain System-to-Home switch. A request now uses two timers made with the launcher, closes the old app from the timer, and checks the heap and starts the next app on the main loop's next turn (a scheduled call where nothing runs the loop). System's close no longer imports wifi_manager just to cancel it. On a Waveshare RP2040-Touch-LCD-1.28, twenty Home-System round trips ran without a restart, the largest free block after each close staying at 49-62 KB (38 KB at the first, threshold 28 KB); before, it fell to 13 KB and the board restarted within ten trips. The launcher contract passes on CPython and unix MicroPython, with a new check for each path. --- apps/system/__init__.py | 14 +-- lib/appkit/foreground.py | 26 ++++++ lib/appkit/launcher.py | 131 +++++++++++++++++++++------ tests/appkit/launcher_contract.py | 55 ++++++++--- tests/appkit/sample_apps/redirect.py | 8 ++ 5 files changed, 191 insertions(+), 43 deletions(-) create mode 100644 tests/appkit/sample_apps/redirect.py diff --git a/apps/system/__init__.py b/apps/system/__init__.py index fedd760..f92949f 100644 --- a/apps/system/__init__.py +++ b/apps/system/__init__.py @@ -786,12 +786,14 @@ def close(): _st["seconds"] = None if not measuring and hasattr(display_drv, "measure_fps"): display_drv.measure_fps(False) - try: - import wifi_manager - - wifi_manager.cancel() - except Exception: - pass + # Only when Wi-Fi setup loaded it: importing it here would keep a + # module in memory that System never used. + wifi_manager = sys.modules.get("wifi_manager") + if wifi_manager is not None: + try: + wifi_manager.cancel() + except Exception: + pass scope.on_close(close) diff --git a/lib/appkit/foreground.py b/lib/appkit/foreground.py index 6f19395..371130c 100644 --- a/lib/appkit/foreground.py +++ b/lib/appkit/foreground.py @@ -58,6 +58,7 @@ def frame(): _token = None # changes with the function: a stale timer step sees it _calls = 0 # calls of the foreground function in progress _after_calls = [] # what to run once no call is in progress +_turns = [] # what runs at the loop's next turn (`next_turn`) _serving = False _stop = False @@ -169,6 +170,29 @@ def serving(): return _serving +def next_turn(fn, arg): + """Call ``fn(arg)`` from `serve`'s loop at its next turn, outside every + callback, and return True; return False when nothing runs the loop. + + The launcher starts the next app this way after closing one, so the + calls below the start are the loop's own and none made while the old app + ran. An error in *fn* is reported and the loop carries on. + """ + if not _serving: + return False + _turns.append((fn, arg)) + return True + + +def _run_turns(): + while _turns: + fn, arg = _turns.pop(0) + try: + fn(arg) + except Exception as exc: + _print_exc(exc) + + def stop(): """Have `serve` return after the call it's in.""" global _stop @@ -292,6 +316,8 @@ def serve(app=None): _serving = True try: while not _done(app): + if _turns: + _run_turns() if console is not None: console() fn = _fn diff --git a/lib/appkit/launcher.py b/lib/appkit/launcher.py index 24911ef..cfae9e9 100644 --- a/lib/appkit/launcher.py +++ b/lib/appkit/launcher.py @@ -80,6 +80,9 @@ MICROPYTHON = sys.implementation.name == "micropython" +#: Whether this host can read its heap, and so checks it after a close. +_HEAP = hasattr(gc, "mem_free") + def _print_exc(exc): pe = getattr(sys, "print_exception", None) @@ -349,9 +352,14 @@ def _browser_take(clear=True): return str(name), count, (str(path) if path else None) +#: True while a launcher's scheduled start (`Launcher._resume`) runs. +_resuming = False + + def _in_callback(): - """True while multimer is delivering timer callbacks.""" - return bool(getattr(multimer._dispatch, "_in_deliver", False)) + """True while multimer is delivering timer callbacks, or a launcher's + scheduled start runs.""" + return _resuming or bool(getattr(multimer._dispatch, "_in_deliver", False)) def restart_into(name, count=1, path=None): @@ -569,7 +577,23 @@ def __init__(self, app=None, apps=None, *, home=None, shell=None, min_free=None, #: How the last switch went: ``(how, ms, free)`` with how one of #: ``"start"``, ``"switch"``, ``"restart"``. self.last = None + #: The app a `request` is waiting to switch to, or None. self._pending = None + # The request timers and their callback are made here, so a request + # allocates nothing. Made while an app runs, they would land among its + # objects and, still alive when the heap is checked after its close, + # split the free space it left. Two, because a request made while one + # is firing (an app's main() switching on) can't re-arm that one. + self._request_timers = ( + multimer.Timer(-1, name="launcher.switch"), + multimer.Timer(-1, name="launcher.switch"), + ) + self._firing = None + self._on_request_cb = self._on_request + self._resume_cb = self._resume + #: ``(t0, reason, free)`` from closing an app for a request whose + #: heap check and start are still to come; see `_on_request`. + self._closed = None self._restarting = False # -- registry ------------------------------------------------------- @@ -723,17 +747,60 @@ def request(self, name): """ if name not in self._apps: raise ValueError("no app named %r" % (name,)) - self._cancel_request() # Under a hold: a host that delivers between bytecodes could otherwise - # fire it before it's stored, and the fired timer would stay pending. + # fire it before the name is stored. with multimer.hold(): - self._pending = multimer.after(1, lambda t: self.switch(name), name="launcher.switch") + self._cancel_request() + self._pending = name + a, b = self._request_timers + t = b if self._firing is a else a + t.init(mode=multimer.Timer.ONE_SHOT, period=1, callback=self._on_request_cb) + + def _on_request(self, timer): + name = self._pending + if name is None: + return + self._firing = timer + try: + if self.scope is not None and _HEAP and name not in self._scripts: + # Close the app now, and check the heap and start the next one + # once the calls that delivered this one have returned. Where + # an interpreter keeps a call's frame on the heap, those calls + # began while the old app ran, so their frames sit among its + # objects, and while they last they split the free space the + # check measures. The main loop's next turn has none of them + # below it; without the loop, a scheduled call has fewer. + t0 = multimer.ticks_ms() + self._closed = (t0,) + self._close_running() + if not foreground.next_turn(self._resume_cb, False): + try: + multimer.schedule(self._resume_cb, True) + except RuntimeError: # the scheduler's queue is full + self.request(name) + return + self.switch(name) + finally: + self._firing = None + + def _resume(self, scheduled): + # The second half of a request `_on_request` began: the old app is + # closed, so check the heap and start the next one, unless a newer + # request's switch already has. *scheduled* is True from a scheduled + # call, which swallows a soft reset as a timer callback does. + global _resuming + name = self._pending + if name is None or self._closed is None: + return + _resuming = scheduled + try: + self.switch(name) + finally: + _resuming = False def _cancel_request(self): - p = self._pending self._pending = None - if p is not None: - p.deinit() + for t in self._request_timers: + t.deinit() def switch(self, name): """Close the running app and start app *name*. @@ -750,25 +817,19 @@ def switch(self, name): self._cancel_request() if name in self._scripts: return self._run_script(name) - t0 = multimer.ticks_ms() - reason = None - old = self.scope - how = "switch" if old is not None else "start" - if old is not None: - self.scope = None - self.current = None - try: - problems = old.close() - except Exception as exc: - problems = [_error("close() of %r raised" % (old.name,), exc)] - self.problems = list(problems) - if problems: - reason = "%r did not close cleanly: %s" % (old.name, problems[0]) - # close() has just collected and read it. - free = getattr(old, "free_after_close", None) - old = problems = None + closed = self._closed + self._closed = None + if closed is not None: # a request closed the app already + t0, reason, free = closed + how = "switch" + elif self.scope is not None: + t0 = multimer.ticks_ms() + reason, free = self._close_running() + how = "switch" else: - free = None + t0 = multimer.ticks_ms() + reason = free = None + how = "start" if free is None: # Before the baseline: its probe allocates, and a buffer it leaves # briefly reachable mustn't count against the first app. @@ -799,9 +860,26 @@ def switch(self, name): self.last = (how, multimer.ticks_diff(multimer.ticks_ms(), t0), free) return how + def _close_running(self): + """Close the running app; return ``(reason to restart or None, free heap after)``.""" + old = self.scope + self.scope = None + self.current = None + reason = None + try: + problems = old.close() + except Exception as exc: + problems = [_error("close() of %r raised" % (old.name,), exc)] + self.problems = list(problems) + if problems: + reason = "%r did not close cleanly: %s" % (old.name, problems[0]) + # close() has just collected and read it. + return reason, getattr(old, "free_after_close", None) + def _run_script(self, name): """Close the running app and restart the program into script *name*.""" module, path = self._scripts[name] + self._closed = None old = self.scope self.scope = None self.current = None @@ -864,6 +942,7 @@ def _start(self, name): def stop(self): """Close the running app and cancel a pending switch.""" self._cancel_request() + self._closed = None old = self.scope self.scope = None self.current = None diff --git a/tests/appkit/launcher_contract.py b/tests/appkit/launcher_contract.py index ccb50fe..140f2af 100644 --- a/tests/appkit/launcher_contract.py +++ b/tests/appkit/launcher_contract.py @@ -38,6 +38,7 @@ import multimer from appdev import App +from appkit import foreground from appkit import launcher as L from appkit.scope import open_scopes from sample_apps import shared @@ -157,25 +158,26 @@ def request_defers_to_a_safe_point(): la.stop() -class _EagerTimers: - """multimer, on a host that delivers between bytecodes: a timer due at - once fires before ``after()`` has returned, unless a hold is on.""" +class _EagerTimer(multimer.Timer): + """A timer on a host that delivers between bytecodes: armed due at once, + it fires before ``init()`` has returned, unless a hold is on.""" + + def init(self, **kw): + multimer.Timer.init(self, **kw) + multimer.sleep_ms(kw.get("period", 1) + 2) # delivers what's due, except under a hold - def __getattr__(self, name): - return getattr(multimer, name) - def after(self, ms, callback=None, **kw): - t = multimer.after(ms, callback, **kw) - multimer.sleep_ms(ms + 2) # delivers what's due, except under a hold - return t +class _EagerTimers: + def __getattr__(self, name): + return _EagerTimer if name == "Timer" else getattr(multimer, name) @check def request_that_fires_at_once_leaves_nothing_pending(): - la = _launcher() - la.boot("clock") real, L.multimer = L.multimer, _EagerTimers() try: + la = _launcher() # its request timers are eager ones + la.boot("clock") la.request("counter") finally: L.multimer = real @@ -185,6 +187,37 @@ def request_that_fires_at_once_leaves_nothing_pending(): la.stop() +@check +def request_from_a_requested_apps_main_still_switches(): + la = _launcher() + la.add("redirect", "sample_apps.redirect") + la.boot("clock") + la.request("redirect") + multimer.sleep_ms(20) + expect(la.current == "counter", "a request made while a requested switch ran was lost: %r" % (la.current,)) + la.stop() + + +@check +def request_under_the_loop_starts_on_its_next_turn(): + # With the main loop running, a host that checks its heap closes the old + # app from the request's timer and starts the next one from the loop. + la = _launcher() + la.boot("clock") + foreground._serving = True + try: + la.request("counter") + multimer.sleep_ms(20) + if L._HEAP: + expect(la.current is None, "the next app started inside the request's callback: %r" % (la.current,)) + foreground._run_turns() + expect(la.current == "counter", "the request never switched: %r" % (la.current,)) + finally: + foreground._serving = False + del foreground._turns[:] + la.stop() + + @check def unknown_app_is_refused(): la = _launcher() diff --git a/tests/appkit/sample_apps/redirect.py b/tests/appkit/sample_apps/redirect.py new file mode 100644 index 0000000..65450c1 --- /dev/null +++ b/tests/appkit/sample_apps/redirect.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A sample app that hands straight on to counter from its main().""" + + +def main(scope): + scope.switch("counter") From 0ae7247d9a403157aab0fb28cdcb7934158a00d3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:04:16 -0600 Subject: [PATCH 2/2] Home and appkit's Home button: stay inside a round screen On a round display the shell's Home button sat in the top-right corner, outside the circle, and Home's title, gear and tab bar were clipped. appkit.shell.is_round() now tells a round panel (the board's driver says so, or it is a GC9A01). There the Home button is a small round button on the diagonal, and Home puts its pages and tabs in the square inside the circle, two apps by two, with the gear centred above them. Checked on a Waveshare RP2040-Touch-LCD-1.28 from the frames sent to the panel: every object of Home and System lies inside the circle. Home on 720x720 and 800x480 renders pixel for pixel as before. --- apps/home/__init__.py | 98 ++++++++++++++++++++++++++++++++----------- lib/appkit/README.md | 3 +- lib/appkit/shell.py | 55 ++++++++++++++++++++++-- 3 files changed, 128 insertions(+), 28 deletions(-) diff --git a/apps/home/__init__.py b/apps/home/__init__.py index b40fee1..e3995f8 100644 --- a/apps/home/__init__.py +++ b/apps/home/__init__.py @@ -11,10 +11,15 @@ A tap on a tab switches pages at once; a swipe across the page slides to the next one. Home comes back to the tab it was last on. + +On a round screen (see ``appkit.shell.is_round``) the pages and their tabs +fill the square inside the circle, two apps by two, and the gear sits +centred above them, so nothing falls outside the circle. """ import lvgl as lv +from appkit.shell import ROUND_MARGIN, is_round from touchkit import ui DEFAULT_SYMBOL = "LIST" @@ -30,34 +35,47 @@ TAB_ON = 0x1A2438 -def _grid(scope, page, W, H, apps): +def _grid(scope, page, cw, ch, apps, cols=COLS, rows=ROWS, small=False): + """The app tiles on one page: a card *cw* by *ch*, *cols* by *rows* tiles. + *small* tiles take the smaller fonts and stack the icon over the title.""" launcher = scope.launcher - cw, ch = W - 2 * MARGIN, H - TOP - TAB_BAR - 2 * MARGIN + gap = GAP // 2 if small else GAP card = lv.obj(page) card.remove_style_all() card.set_pos(MARGIN, MARGIN) card.set_size(cw, ch) card.set_style_bg_color(lv.color_hex(CARD), 0) card.set_style_bg_opa(lv.OPA.COVER, 0) - card.set_style_radius(20, 0) + card.set_style_radius(12 if small else 20, 0) card.set_style_border_width(1, 0) card.set_style_border_color(lv.color_hex(LINE), 0) card.remove_flag(lv.obj.FLAG.SCROLLABLE) card.add_flag(lv.obj.FLAG.EVENT_BUBBLE) # a swipe on the card still turns the page - w = (cw - (COLS + 1) * GAP) // COLS - h = (ch - (ROWS + 1) * GAP) // ROWS - for i, (name, sym, color) in enumerate(apps[: COLS * ROWS]): - col, row = i % COLS, i // COLS + w = (cw - (cols + 1) * gap) // cols + h = (ch - (rows + 1) * gap) // rows + for i, (name, sym, color) in enumerate(apps[: cols * rows]): + col, row = i % cols, i // cols b = lv.button(card) b.set_size(w, h) - b.set_pos(GAP + col * (w + GAP), GAP + row * (h + GAP)) + b.set_pos(gap + col * (w + gap), gap + row * (h + gap)) b.set_style_bg_color(lv.color_hex(color), 0) - b.set_style_radius(18, 0) - icon = ui.label(b, sym, ui.huge, ui.WHITE) - icon.align(lv.ALIGN.CENTER, 0, -22) title = launcher.info.get(name, {}).get("title", name) - lbl = ui.label(b, title, ui.font, ui.WHITE) - lbl.align(lv.ALIGN.CENTER, 0, 40) + if small: + b.set_style_radius(10, 0) + b.set_style_pad_all(2, 0) + icon = ui.label(b, sym, ui.big, ui.WHITE) + icon.align(lv.ALIGN.CENTER, 0, -ui.tiny.line_height // 2) + lbl = ui.label(b, title, ui.tiny, ui.WHITE) + lbl.set_long_mode(lv.label.LONG_MODE.DOTS) + lbl.set_width(w - 4) + lbl.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) + lbl.align(lv.ALIGN.BOTTOM_MID, 0, 0) + else: + b.set_style_radius(18, 0) + icon = ui.label(b, sym, ui.huge, ui.WHITE) + icon.align(lv.ALIGN.CENTER, 0, -22) + lbl = ui.label(b, title, ui.font, ui.WHITE) + lbl.align(lv.ALIGN.CENTER, 0, 40) b.add_event_cb((lambda n: lambda e: scope.switch(n))(name), lv.EVENT.CLICKED, None) @@ -94,23 +112,52 @@ def main(scope): for name in scope.launcher.names: if info.get(name, {}).get("gear"): gear_app = name - title = ui.label(scr, "Home", ui.big, ui.ACCENT) - title.set_pos(GAP + 4, 14) + # Dark whether or not the shell put a dark theme on the display. + scr.set_style_bg_color(ui.BG, 0) + scr.set_style_bg_opa(lv.OPA.COVER, 0) + # A round panel gets its own layout when Home has the whole screen. + round_ = W == H and getattr(scope, "rect", (0, 0, W, H))[:2] == (0, 0) and is_round() + + if round_: + # The square inside the circle holds the pages and their tabs; the + # gear goes in the cap of the circle above it, centred. + inner = W // 2 - ROUND_MARGIN + side = int(inner * 1.4142) & ~1 + x0 = (W - side) // 2 + tab_bar = ui.small.line_height + 14 + gear_w = 48 + # As high as the circle is gear_w wide there, and down to the square. + gear_y = W // 2 - int((inner * inner - (gear_w // 2) ** 2) ** 0.5) + 1 + gear_h = max(24, min(TOP - 12, x0 - gear_y - 2)) + else: + title = ui.label(scr, "Home", ui.big, ui.ACCENT) + title.set_pos(GAP + 4, 14) if gear_app is not None: gear = lv.button(scr) - gear.set_size(64, TOP - 12) - gear.align(lv.ALIGN.TOP_RIGHT, -GAP, 6) gear.set_style_bg_color(lv.color_hex(0x44505E), 0) - gear.set_style_radius(12, 0) - ui.label(gear, lv.SYMBOL.SETTINGS, ui.big, ui.WHITE).center() + if round_: + gear.set_size(gear_w, gear_h) + gear.set_pos((W - gear_w) // 2, gear_y) + gear.set_style_radius(gear_h // 2, 0) + gear.set_style_pad_all(0, 0) + ui.label(gear, lv.SYMBOL.SETTINGS, ui.font, ui.WHITE).center() + else: + gear.set_size(64, TOP - 12) + gear.align(lv.ALIGN.TOP_RIGHT, -GAP, 6) + gear.set_style_radius(12, 0) + ui.label(gear, lv.SYMBOL.SETTINGS, ui.big, ui.WHITE).center() gear.add_event_cb(lambda e: scope.switch(gear_app), lv.EVENT.CLICKED, None) tv = lv.tabview(scr) - tv.set_pos(0, TOP) - tv.set_size(W, H - TOP) + if round_: + tv.set_pos(x0, x0) + tv.set_size(side, side) + else: + tv.set_pos(0, TOP) + tv.set_size(W, H - TOP) tv.set_tab_bar_position(lv.DIR.BOTTOM) - tv.set_tab_bar_size(TAB_BAR) + tv.set_tab_bar_size(tab_bar if round_ else TAB_BAR) tv.set_style_bg_color(ui.BG, 0) tv.get_content().set_style_bg_color(ui.BG, 0) @@ -120,7 +167,10 @@ def main(scope): page.set_style_pad_all(0, 0) page.set_style_bg_color(ui.BG, 0) page.remove_flag(lv.obj.FLAG.SCROLLABLE) - _grid(scope, page, W, H, apps) + if round_: + _grid(scope, page, side - 2 * MARGIN, side - tab_bar - 2 * MARGIN, apps, 2, 2, True) + else: + _grid(scope, page, W - 2 * MARGIN, H - TOP - TAB_BAR - 2 * MARGIN, apps) bar = tv.get_tab_bar() bar.set_style_bg_color(ui.PANEL, 0) @@ -132,7 +182,7 @@ def main(scope): for i in range(bar.get_child_count()): t = bar.get_child(i) t.set_style_radius(0, 0) - t.set_style_text_font(ui.font, 0) + t.set_style_text_font(ui.small if round_ else ui.font, 0) t.set_style_text_color(ui.MUTED, 0) t.set_style_bg_opa(lv.OPA.TRANSP, 0) t.set_style_border_width(0, 0) diff --git a/lib/appkit/README.md b/lib/appkit/README.md index fd2d02d..73e019b 100644 --- a/lib/appkit/README.md +++ b/lib/appkit/README.md @@ -198,7 +198,8 @@ home screen instead of its own list of buttons. home screen. An app set that wants its own home screen, chrome or launcher setup builds a `Launcher` itself. Its `Shell` subclass gives apps a smaller area by overriding `make_root()`, and its own Home button by overriding -`make_home_button()`. +`make_home_button()`. The default Home button sits at the top right; on a +round screen it's a small round button on the diagonal, inside the circle. [`examples/appkit_simpletest`](examples/appkit_simpletest) is an app set of two apps, a counter and a greeter, laid out this way. diff --git a/lib/appkit/shell.py b/lib/appkit/shell.py index 0bb3ffa..65e49c6 100644 --- a/lib/appkit/shell.py +++ b/lib/appkit/shell.py @@ -4,17 +4,54 @@ """Shell: what's around an app, and what it hands each app's scope. This one is the plainest: an app gets the whole screen, and a Home button -sits in the corner when there's a home screen to go back to. A board that +sits in the corner when there's a home screen to go back to. On a round +screen (`is_round`) the button is a circle with the Home symbol, set in +from the top right along the diagonal so all of it is inside the circle. A board that wants a header, its own look or its own buttons subclasses it: override `Shell.make_root` to give apps a smaller area, `Shell.make_home_button` for its own Home button, or `Shell.prepare` to hand them other services. """ +import sys + import lvgl as lv from . import data +#: How far a round screen's Home button keeps in from the circle's edge, in pixels. +ROUND_MARGIN = 4 + + +def is_round(): + """Whether the default display is a round panel. + + The board's display driver says so with a ``round`` attribute; without + one, a GC9A01 is taken to be round, since the controller is made for + round panels. A screen that isn't square never is. It only looks at a + ``board_config`` something has already imported, so it imports nothing. + """ + disp = lv.display_get_default() + if disp is None or disp.get_horizontal_resolution() != disp.get_vertical_resolution(): + return False + board = sys.modules.get("board_config") + drv = getattr(board, "display_drv", None) + if drv is None: + return False + r = getattr(drv, "round", None) + if r is None: + r = type(drv).__name__.upper().startswith("GC9A01") + return bool(r) + + +def round_corner(size, d, margin=ROUND_MARGIN): + """Where a *d*-pixel circle goes at the top right of a round screen + *size* pixels across: ``(x, y)`` of its top left, on the diagonal, as + far out as it can go and stay *margin* pixels inside the edge.""" + r = size // 2 + off = int((r - margin - d / 2) * 0.7071) # along the diagonal, cos 45 + return r + off - d // 2, r - off - d // 2 + class Shell: """*data_root* moves every app's ``data_dir``, and the launcher's @@ -39,8 +76,20 @@ def make_home_button(self): """ launcher = self.launcher b = lv.button(lv.layer_top()) - b.align(lv.ALIGN.TOP_RIGHT, -8, 8) - lv.label(b).set_text("Home") + if is_round(): + # The corner is outside the circle: a round button on the diagonal. + size = lv.display_get_default().get_horizontal_resolution() + d = max(36, size // 6) + b.set_size(d, d) + b.set_style_radius(lv.RADIUS_CIRCLE, 0) + b.set_style_pad_all(0, 0) + lbl = lv.label(b) + lbl.set_text(lv.SYMBOL.HOME) + lbl.center() + b.set_pos(*round_corner(size, d)) + else: + b.align(lv.ALIGN.TOP_RIGHT, -8, 8) + lv.label(b).set_text("Home") b.add_event_cb(lambda e: launcher.request(launcher.home), lv.EVENT.CLICKED, None) return b