Skip to content
Snippets Groups Projects
Commit 016b68b3 authored by q3k's avatar q3k
Browse files

py: implement settings, overlays, debug overlay

parent abd6fc9e
Branches
Tags
No related merge requests found
......@@ -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()
......@@ -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",
]
......@@ -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()
......
"""
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)
......@@ -29,6 +29,7 @@ class Ctx(ABCBase):
)
CENTER = "center"
RIGHT = "right"
MIDDLE = "middle"
@abstractmethod
......
......@@ -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):
......
"""
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()
......@@ -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)
......
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment