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/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/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/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/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 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")