diff --git a/python_payload/main.py b/python_payload/main.py index 44d53748545c61df99df44963980060cac5dce5e..df12e6501e2ac6f0a19b67c51e91e1eaba73f13c 100644 --- a/python_payload/main.py +++ b/python_payload/main.py @@ -11,6 +11,11 @@ log.info(f"free memory: {gc.mem_free()}") import st3m from st3m.goose import Optional, List, ABCBase, abstractmethod + +from st3m import settings + +settings.load_all() + from st3m.ui.view import View, ViewManager, ViewTransitionBlend from st3m.ui.menu import ( MenuItemBack, @@ -19,6 +24,7 @@ from st3m.ui.menu import ( ) from st3m.ui.elements.menus import FlowerMenu, SimpleMenu, SunMenu +from st3m.ui.elements import overlays log.info("import apps done") log.info(f"free memory: {gc.mem_free()}") @@ -39,8 +45,9 @@ leds.set_rgb(0, 255, 0, 0) vm = ViewManager(ViewTransitionBlend()) +# Preload all applications. +# TODO(q3k): only load these on demand. from apps.demo_worms4 import app as worms - from apps.harmonic_demo import app as harmonic from apps.melodic_demo import app as melodic from apps.nick import app as nick @@ -54,6 +61,10 @@ melodic._view_manager = vm nick._view_manager = vm captouch_demo._view_manager = vm +# Build menu structure + +menu_settings = settings.build_menu(vm) + menu_music = SimpleMenu( [ MenuItemBack(), @@ -88,14 +99,32 @@ menu_main = SunMenu( MenuItemForeground("Badge", menu_badge), MenuItemForeground("Music", menu_music), MenuItemForeground("Apps", menu_apps), - MenuItemNoop("Settings"), + MenuItemForeground("Settings", menu_settings), ], vm, ) + vm.push(menu_main) reactor = st3m.Reactor() -reactor.set_top(vm) -# reactor.set_top(pr) + +# Set up top-level compositor (for combining viewmanager with overlays). +compositor = overlays.Compositor(vm) + + +# Tie compositor's debug overlay to setting. +def _onoff_debug_update() -> None: + compositor.enabled[overlays.OverlayKind.Debug] = settings.onoff_debug.value + + +_onoff_debug_update() +settings.onoff_debug.subscribe(_onoff_debug_update) + +# Configure debug overlay fragments. +debug = overlays.OverlayDebug() +debug.add_fragment(overlays.DebugReactorStats(reactor)) +compositor.add_overlay(debug) + +reactor.set_top(compositor) reactor.run() diff --git a/python_payload/st3m/goose.py b/python_payload/st3m/goose.py index 979cba2e7154855955355f70b7c4bc52d84fcab5..51b32bf5c3d21123e10a8d613ebf5872c867da06 100644 --- a/python_payload/st3m/goose.py +++ b/python_payload/st3m/goose.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: class ABCBase(metaclass=ABCMeta): pass - from typing import List, Optional, Tuple, Dict, Any + from typing import List, Optional, Tuple, Dict, Any, Callable from enum import Enum else: # We're in CPython or Micropython. @@ -31,7 +31,7 @@ else: return _fail try: - from typing import List, Optional, Tuple, Dict, Any + from typing import List, Optional, Tuple, Dict, Any, Callable from enum import Enum except ImportError: # We're in Micropython. @@ -40,6 +40,7 @@ else: Tuple = None Dict = None Any = None + Callable = None class Enum: pass @@ -55,4 +56,5 @@ __all__ = [ "Tuple", "Dict", "Any", + "Callable", ] diff --git a/python_payload/st3m/reactor.py b/python_payload/st3m/reactor.py index 0957f7f99df8f89e4312f61a3654ec59a0944a28..2d0b7906b66d4fc25a585a242fb07b837c1c988e 100644 --- a/python_payload/st3m/reactor.py +++ b/python_payload/st3m/reactor.py @@ -48,6 +48,24 @@ class Responder(ABCBase): pass +class ReactorStats: + def __init__(self) -> None: + self.run_times: List[int] = [] + self.render_times: List[int] = [] + + def record_run_time(self, ms: int) -> None: + self.run_times.append(ms) + delete = len(self.run_times) - 20 + if delete > 0: + self.run_times = self.run_times[delete:] + + def record_render_time(self, ms: int) -> None: + self.render_times.append(ms) + delete = len(self.render_times) - 20 + if delete > 0: + self.render_times = self.render_times[delete:] + + class Reactor: """ The Reactor is the main Micropython scheduler of the st3m system and any @@ -57,14 +75,24 @@ class Reactor: that saturates the display rasterization/blitting pipeline. """ - __slots__ = ("_top", "_tickrate_ms", "_last_tick", "_ctx", "_ts") + __slots__ = ( + "_top", + "_tickrate_ms", + "_last_tick", + "_ctx", + "_ts", + "_last_ctx_get", + "stats", + ) def __init__(self) -> None: self._top: Optional[Responder] = None self._tickrate_ms: int = 20 self._ts: int = 0 self._last_tick: Optional[int] = None + self._last_ctx_get: Optional[int] = None self._ctx: Optional[Ctx] = None + self.stats = ReactorStats() def set_top(self, top: Responder) -> None: """ @@ -89,11 +117,11 @@ class Reactor: self._run_top(start) end = time.ticks_ms() + elapsed = end - start + self.stats.record_run_time(elapsed) wait = deadline - end if wait > 0: hardware.freertos_sleep(wait) - else: - print("too long", wait) def _run_top(self, start: int) -> None: # Skip if we have no top Responder. @@ -117,6 +145,11 @@ class Reactor: if self._ctx is None: self._ctx = hardware.get_ctx() if self._ctx is not None: + if self._last_ctx_get is not None: + diff = start - self._last_ctx_get + self.stats.record_render_time(diff) + self._last_ctx_get = start + self._ctx.save() self._top.draw(self._ctx) self._ctx.restore() diff --git a/python_payload/st3m/settings.py b/python_payload/st3m/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..9581b66eaf61ac3bd28632dbad33ad7cec338408 --- /dev/null +++ b/python_payload/st3m/settings.py @@ -0,0 +1,344 @@ +""" +Settings framework for flow3r badge. + +We call settings 'tunables', trying to emphasize that they are some kind of +value that can be changed. + +Settings are persisted in /flash/settings.json, loaded on startup and saved on +request. +""" + +import json + +from st3m import Ctx, InputState, Responder, logging +from st3m.goose import ABCBase, abstractmethod, Any, Dict, List, Optional, Callable +from st3m.ui.menu import MenuController, MenuItem, MenuItemBack +from st3m.ui.elements.menus import SimpleMenu +from st3m.ui.view import ViewManager +from st3m.utils import lerp, ease_out_cubic, reduce + +log = logging.Log(__name__, level=logging.INFO) + + +class Tunable(ABCBase): + """ + Base class for all settings. An instance of a Tunable is some kind of + setting with some kind of value. Each setting has exactly one instance of + Tunable in memory that represents its current configuration. + + Other than a common interface, this also implements a mechanism for + downstream consumers to subscribe to tunable changes, and allows notifying + them. + """ + + def __init__(self) -> None: + self._subscribers: List[Callable[[], None]] = [] + + def subscribe(self, s: Callable[[], None]) -> None: + """ + Subscribe to be updated when this tunable value's changes. + """ + self._subscribers.append(s) + + def notify(self) -> None: + """ + Notify all subscribers. + """ + for s in self._subscribers: + s() + + @abstractmethod + def name(self) -> str: + """ + Human-readable name of this setting. + """ + ... + + @abstractmethod + def get_widget(self) -> "TunableWidget": + """ + Widget that will be used to render this setting in menus. + """ + ... + + @abstractmethod + def save(self) -> Dict[str, Any]: + """ + Return dictionary that contains this setting's persistance data. Will be + merged with all other tunable's results. + """ + ... + + @abstractmethod + def load(self, d: Dict[str, Any]) -> None: + """ + Load in-memory state from persisted data. + """ + ... + + +class TunableWidget(Responder): + """ + A tunable's widget as rendered in menus. + """ + + @abstractmethod + def press(self, vm: Optional[ViewManager]) -> None: + """ + Called when the menu item is 'pressed', ie. selected/activated. A widget + should react to this as the primary way to let the users manipulate the + value of the tunable from a menu. + """ + ... + + +class UnaryTunable(Tunable): + """ + Basic implementation of a Tunable for single values. Most settings will are + UnaryTunables, with notable exceptions being things like lists or optional + settings. + + UnaryTunable implements persistence by always being saved/loaded to same + json.style.path (only traversing nested dictionaries). + """ + + def __init__(self, name: str, key: str, default: Any): + """ + Create an UnaryTunable with a given human-readable name, some + persistence key and some default value. + """ + super().__init__() + self.key = key + self._name = name + self.value: Any = default + + def name(self) -> str: + return self._name + + def set_value(self, v: Any) -> None: + """ + Call to set value in-memory and notify all listeners. + """ + self.value = v + self.notify() + + def save(self) -> Dict[str, Any]: + res: Dict[str, Any] = {} + sub = res + + parts = self.key.split(".") + for i, part in enumerate(parts): + if i == len(parts) - 1: + sub[part] = self.value + else: + sub[part] = {} + sub = sub[part] + return res + + def load(self, d: Dict[str, Any]) -> None: + def _get(v: Dict[str, Any], k: str) -> Any: + if k in v: + return v[k] + else: + return {} + + path = self.key.split(".") + k = path[-1] + d = reduce(_get, path[:-1], d) + if k in d: + self.value = d[k] + + +class OnOffTunable(UnaryTunable): + """ + OnOffTunable is a UnaryTunable that has two values: on or off, and is + rendered accordingly as a slider switch. + """ + + def __init__(self, name: str, key: str, default: bool) -> None: + super().__init__(name, key, default) + + def get_widget(self) -> TunableWidget: + return OnOffWidget(self) + + def press(self, vm: Optional[ViewManager]) -> None: + if self.value == True: + self.set_value(False) + else: + self.set_value(True) + + +class OnOffWidget(TunableWidget): + """ + OnOffWidget is a TunableWidget for OnOffTunables. It renders a slider + switch. + """ + + def __init__(self, tunable: "OnOffTunable") -> None: + self._tunable = tunable + + # Value from 0 to animation_duration indicating animation progress + # (starts at animation_duration, ends at 0). + self._animating: float = 0 + + # Last and previous read value from tunable. + self._state = tunable.value == True + self._prev_state = self._state + + # Value from 0 to 1, representing desired animation position. Linear + # between both. 1 represents rendering _state, 0 represents render the + # opposite of _state. + self._progress = 1.0 + + def think(self, ins: InputState, delta_ms: int) -> None: + animation_duration = 0.2 + + self._state = self._tunable.value == True + + if self._prev_state != self._state: + # State switched. + + # Start new animation, making sure to take into consideration + # whatever animation is already taking place. + self._animating = animation_duration - self._animating + else: + # Continue animation. + self._animating -= delta_ms / 1000 + if self._animating < 0: + self._animating = 0 + + # Calculate progress value. + self._progress = 1.0 - (self._animating / animation_duration) + self._prev_state = self._state + + def draw(self, ctx: Ctx) -> None: + ctx.scale(0.8, 0.8) + # TODO(pippin): graphical glitches without the two next lines + ctx.rectangle(-200, -200, 10, 10) + ctx.fill() + + value = self._state + v = self._progress + v = ease_out_cubic(v) + if not value: + v = 1.0 - v + + ctx.rgb(lerp(0, 0.4, v), lerp(0, 0.6, v), lerp(0, 0.4, v)) + + ctx.round_rectangle(0, -10, 40, 20, 5) + ctx.line_width = 2 + ctx.fill() + + ctx.round_rectangle(0, -10, 40, 20, 5) + ctx.line_width = 2 + ctx.gray(lerp(0.3, 1, v)) + ctx.stroke() + + ctx.gray(1) + ctx.round_rectangle(lerp(2, 22, v), -8, 16, 16, 5) + ctx.fill() + + def press(self, vm: Optional[ViewManager]) -> None: + self._tunable.set_value(not self._state) + + +class SettingsMenuItem(MenuItem): + """ + A MenuItem which draws its label offset to the left, and a Tunable's widget + to the right. + """ + + def __init__(self, tunable: UnaryTunable): + self.tunable = tunable + self.widget = tunable.get_widget() + + def press(self, vm: Optional[ViewManager]) -> None: + self.widget.press(vm) + + def label(self) -> str: + return self.tunable._name + + def draw(self, ctx: Ctx) -> None: + ctx.move_to(40, 0) + ctx.text_align = ctx.RIGHT + super().draw(ctx) + ctx.stroke() + + ctx.save() + ctx.translate(50, 0) + self.widget.draw(ctx) + ctx.restore() + + def think(self, ins: InputState, delta_ms: int) -> None: + self.widget.think(ins, delta_ms) + + +class SettingsMenuItemBack(MenuItemBack): + """ + Extends MenuItemBack to save settings on exit. + """ + + def press(self, vm: Optional[ViewManager]) -> None: + save_all() + super().press(vm) + + +class SettingsMenu(SimpleMenu): + """ + SimpleMenu but smol. + """ + + SIZE_LARGE = 20 + SIZE_SMALL = 15 + + +# Actual tunables / settings. +onoff_debug = OnOffTunable("Debug Overlay", "system.debug", False) +all_settings: List[UnaryTunable] = [ + onoff_debug, +] + + +def load_all() -> None: + """ + Load all settings from flash. + """ + data = {} + try: + with open("/flash/settings.json", "r") as f: + data = json.load(f) + except Exception as e: + log.warning("Could not load settings: " + str(e)) + return + + log.info("Loaded settings from flash") + for setting in all_settings: + setting.load(data) + + +def save_all() -> None: + """ + Save all settings to flash. + """ + res = {} + for setting in all_settings: + res.update(setting.save()) + try: + with open("/flash/settings.json", "w") as f: + json.dump(res, f) + except Exception as e: + log.warning("Could not save settings: " + str(e)) + return + + log.info("Saved settings to flash") + + +def build_menu(vm: ViewManager) -> SimpleMenu: + """ + Return a SimpleMenu for all settings. + """ + mib: MenuItem = SettingsMenuItemBack() + positions: List[MenuItem] = [ + mib, + ] + [SettingsMenuItem(t) for t in all_settings] + return SettingsMenu(positions, vm) diff --git a/python_payload/st3m/ui/ctx.py b/python_payload/st3m/ui/ctx.py index 094e336084c0debaa18ad62f7e613436f1027524..5c9c7aedb404af49af9a21c6891e989e80793479 100644 --- a/python_payload/st3m/ui/ctx.py +++ b/python_payload/st3m/ui/ctx.py @@ -29,6 +29,7 @@ class Ctx(ABCBase): ) CENTER = "center" + RIGHT = "right" MIDDLE = "middle" @abstractmethod diff --git a/python_payload/st3m/ui/elements/menus.py b/python_payload/st3m/ui/elements/menus.py index 69f3414daf631a8876a7e9f21022af6801cab150..48170deebf190192d6d2de677447d9b43fa59333 100644 --- a/python_payload/st3m/ui/elements/menus.py +++ b/python_payload/st3m/ui/elements/menus.py @@ -14,21 +14,30 @@ class SimpleMenu(MenuController): A simple line-by-line menu. """ + SIZE_LARGE = 30 + SIZE_SMALL = 20 + def draw(self, ctx: Ctx) -> None: ctx.gray(0) ctx.rectangle(-120, -120, 240, 240).fill() - ctx.text_align = ctx.CENTER - ctx.text_baseline = ctx.MIDDLE - current = self._scroll_controller.current_position() ctx.gray(1) for ix, item in enumerate(self._items): - offs = (ix - current) * 30 - size = lerp(30, 20, abs(offs / 20)) - ctx.font_size = size - ctx.move_to(0, offs).text(item.label()) + offs = (ix - current) * self.SIZE_LARGE + ctx.save() + ctx.move_to(0, 0) + ctx.font_size = self.SIZE_LARGE + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + ctx.translate(0, offs) + scale = lerp( + 1, self.SIZE_SMALL / self.SIZE_LARGE, abs(offs / self.SIZE_SMALL) + ) + ctx.scale(scale, scale) + item.draw(ctx) + ctx.restore() class SunMenu(MenuController): @@ -51,8 +60,8 @@ class SunMenu(MenuController): self._sun.think(ins, delta_ms) self._ts += delta_ms - def _draw_text_angled( - self, ctx: Ctx, text: str, angle: float, activity: float + def _draw_item_angled( + self, ctx: Ctx, item: MenuItem, angle: float, activity: float ) -> None: size = lerp(20, 40, activity) color = lerp(0, 1, activity) @@ -62,7 +71,8 @@ class SunMenu(MenuController): ctx.save() ctx.translate(-120, 0).rotate(angle).translate(140, 0) ctx.font_size = size - ctx.rgba(1.0, 1.0, 1.0, color).move_to(0, 0).text(text) + ctx.rgba(1.0, 1.0, 1.0, color).move_to(0, 0) + item.draw(ctx) ctx.restore() def draw(self, ctx: Ctx) -> None: @@ -81,7 +91,7 @@ class SunMenu(MenuController): for ix, item in enumerate(self._items): rot = (ix - current) * angle_per_item - self._draw_text_angled(ctx, item.label(), rot, 1 - abs(rot)) + self._draw_item_angled(ctx, item, rot, 1 - abs(rot)) class FlowerMenu(MenuController): diff --git a/python_payload/st3m/ui/elements/overlays.py b/python_payload/st3m/ui/elements/overlays.py new file mode 100644 index 0000000000000000000000000000000000000000..a02cceccd964a25baae1c9c0173cf164c3cc485f --- /dev/null +++ b/python_payload/st3m/ui/elements/overlays.py @@ -0,0 +1,140 @@ +""" +Composition/overlay system. + +This is different from a menu system. Instead of navigation, it should be used +for persistent, anchored symbols like charging symbols, toasts, debug overlays, +etc. +""" + +from st3m import Responder, Ctx, InputState, Reactor +from st3m.goose import Dict, Enum, List, ABCBase, abstractmethod + + +class OverlayKind(Enum): + # Battery, USB, ... + Indicators = 0 + # Naughty debug developers for naughty developers and debuggers. + Debug = 1 + + +_all_kinds = [ + OverlayKind.Indicators, + OverlayKind.Debug, +] + + +class Overlay(Responder): + """ + An Overlay is a Responder with some kind of OverlayKind attached.g + """ + + kind: OverlayKind + + +class Compositor(Responder): + """ + A Compositor blends together some main Responder (usually a ViewManager) + alongside with active Overlays. Overlays can be enabled/disabled by kind. + """ + + def __init__(self, main: Responder): + self.main = main + self.overlays: Dict[OverlayKind, List[Responder]] = {} + self.enabled: Dict[OverlayKind, bool] = { + OverlayKind.Indicators: True, + OverlayKind.Debug: True, + } + + def _enabled_overlays(self) -> List[Responder]: + res: List[Responder] = [] + for kind in _all_kinds: + if not self.enabled.get(kind, False): + continue + for overlay in self.overlays.get(kind, []): + res.append(overlay) + return res + + def think(self, ins: InputState, delta_ms: int) -> None: + self.main.think(ins, delta_ms) + for overlay in self._enabled_overlays(): + overlay.think(ins, delta_ms) + + def draw(self, ctx: Ctx) -> None: + self.main.draw(ctx) + for overlay in self._enabled_overlays(): + overlay.draw(ctx) + + def add_overlay(self, ov: Overlay) -> None: + """ + Add some Overlay to the Compositor. It will be drawn if enabled. + """ + if ov.kind not in self.overlays: + self.overlays[ov.kind] = [] + self.overlays[ov.kind].append(ov) + + +class DebugFragment(ABCBase): + """ + Something which wishes to provide some text to the OverlayDebug. + """ + + @abstractmethod + def text(self) -> str: + ... + + +class DebugReactorStats(DebugFragment): + """ + DebugFragment which provides the OverlayDebug with information about the + active reactor. + """ + + def __init__(self, reactor: Reactor): + self._stats = reactor.stats + + def text(self) -> str: + res = [] + + rts = self._stats.run_times + if len(rts) > 0: + avg = sum(rts) / len(rts) + res.append(f"tick: {int(avg)}ms") + + rts = self._stats.render_times + if len(rts) > 0: + avg = sum(rts) / len(rts) + res.append(f"fps: {int(1/(avg/1000))}") + + return " ".join(res) + + +class OverlayDebug(Overlay): + """ + Overlay which renders a text bar at the bottom of the screen with random + bits of trivia. + """ + + kind = OverlayKind.Debug + + def __init__(self) -> None: + self.fragments: List[DebugFragment] = [] + + def add_fragment(self, f: DebugFragment) -> None: + self.fragments.append(f) + + def think(self, ins: InputState, delta_ms: int) -> None: + pass + + def text(self) -> str: + text = [f.text() for f in self.fragments if f.text() != ""] + return " ".join(text) + + def draw(self, ctx: Ctx) -> None: + ctx.save() + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + ctx.font = ctx.get_font_name(0) + ctx.font_size = 10 + ctx.gray(0).rectangle(-120, 80, 240, 12).fill() + ctx.gray(1).move_to(0, 86).text(self.text()) + ctx.restore() diff --git a/python_payload/st3m/ui/menu.py b/python_payload/st3m/ui/menu.py index 329e90e55b692353d48241f1a5048ea10fb0fc57..51d81a15955e4939f6817d886a3be870269fc274 100644 --- a/python_payload/st3m/ui/menu.py +++ b/python_payload/st3m/ui/menu.py @@ -14,11 +14,16 @@ from st3m.ui.interactions import ScrollController from st3m.ui.ctx import Ctx -class MenuItem(ABCBase): +class MenuItem(Responder): """ An abstract MenuItem to be implemented by concrete impementations. A MenuItem implementation can be added to a MenuController + + Every MenuItems is also a Responder that will be called whenever said + MenuItem should be rendered within a Menu. A Default draw/think + implementation is provided which simply render's this MenuItem's label as + text. """ @abstractmethod @@ -38,6 +43,12 @@ class MenuItem(ABCBase): """ pass + def draw(self, ctx: Ctx) -> None: + ctx.text(self.label()) + + def think(self, ins: InputState, delta_ms: int) -> None: + pass + class MenuItemForeground(MenuItem): """ @@ -128,6 +139,8 @@ class MenuController(ViewWithInputState): def think(self, ins: InputState, delta_ms: int) -> None: super().think(ins, delta_ms) + for item in self._items: + item.think(ins, delta_ms) self._scroll_controller.think(ins, delta_ms) diff --git a/python_payload/st3m/utils.py b/python_payload/st3m/utils.py index cdcafc4b559e8bab7236a12c8f4299f038ed209e..e45779251c44feb48b9d5f8b978cc30fc10d22bf 100644 --- a/python_payload/st3m/utils.py +++ b/python_payload/st3m/utils.py @@ -1,7 +1,10 @@ import math +from st3m.goose import Any + def lerp(a: float, b: float, v: float) -> float: + """Interpolate between a and b, based on v in [0, 1].""" if v <= 0: return a if v >= 1.0: @@ -9,8 +12,33 @@ def lerp(a: float, b: float, v: float) -> float: return a + (b - a) * v +def ease_cubic(x: float) -> float: + """Cubic ease-in/ease-out function. Maps [0, 1] to [0, 1].""" + if x < 0.5: + return 4 * x * x * x + else: + return 1 - ((-2 * x + 2) ** 3) / 2 + + +def ease_out_cubic(x: float) -> float: + """Cubic ease-out function. Maps [0, 1] to [0, 1].""" + return 1 - (1 - x) ** 3 + + def xy_from_polar(r: float, phi: float) -> tuple[float, float]: return (r * math.sin(phi), r * math.cos(phi)) # x # y +def reduce(function: Any, iterable: Any, initializer: Any = None) -> Any: + """functools.reduce but portable and poorly typed.""" + it = iter(iterable) + if initializer is None: + value = next(it) + else: + value = initializer + for element in it: + value = function(value, element) + return value + + tau = math.pi * 2