diff --git a/python_payload/main_st4m.py b/python_payload/main_st4m.py new file mode 100644 index 0000000000000000000000000000000000000000..44a1c642cabd378d92a32f2fe5c4337e7f627583 --- /dev/null +++ b/python_payload/main_st4m.py @@ -0,0 +1,171 @@ +""" +Experimental/Research UI/UX framework (st4m). + +To run, rename this file to main.py. + +See st4m/README.md for more information. +""" + + +import st4m + +from st4m.goose import Optional, List, ABCBase, abstractmethod +from st4m.ui.view import View, ViewManager, ViewTransitionBlend +from st4m.ui.menu import MenuItem, MenuController, MenuItemBack, MenuItemForeground, MenuItemNoop +from st4m import Responder, InputState, Ctx + +import math + + +vm = ViewManager(ViewTransitionBlend()) + + +def lerp(a: float, b: float, v: float) -> float: + if v <= 0: + return a + if v >= 1.0: + return b + return a + (b - a) * v + + +class Sun(Responder): + """ + A rotating sun widget. + """ + def __init__(self) -> None: + self.x = 0.0 + self.y = 0.0 + self.size = 50.0 + self.ts = 1.0 + + def think(self, ins: InputState, delta_ms: int) -> None: + self.ts += delta_ms + pass + + def draw(self, ctx: Ctx) -> None: + + nrays = 10 + angle_per_ray = 6.28 / nrays + for i in range(nrays): + angle = i * angle_per_ray + self.ts / 4000 + angle %= 3.14159*2 + + if angle > 2 and angle < 4: + continue + + ctx.save() + ctx.rgb(0.5, 0.5, 0) + ctx.line_width = 30 + ctx.translate(-120, 0).rotate(angle) + ctx.move_to(20, 0) + ctx.line_to(260, 0) + ctx.stroke() + ctx.restore() + + ctx.save() + ctx.rgb(0.92, 0.89, 0) + ctx.translate(-120, 0) + + ctx.arc(self.x, self.y, self.size, 0, 6.29, 0) + ctx.fill() + ctx.restore() + + +class MainMenu(MenuController): + """ + A circular menu with a rotating sun. + """ + + __slots__ = ( + '_ts', + '_sun', + ) + + def __init__(self, items: List[MenuItem], vm: ViewManager) -> None: + self._ts = 0 + self._sun = Sun() + super().__init__(items, vm) + + def think(self, ins: InputState, delta_ms: int) -> None: + super().think(ins, delta_ms) + self._sun.think(ins, delta_ms) + self._ts += delta_ms + + def _draw_text_angled(self, ctx: Ctx, text: str, angle: float, activity: float) -> None: + size = lerp(20, 40, activity) + color = lerp(0, 1, activity) + if color < 0.01: + return + + 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.restore() + + def draw(self, ctx: Ctx) -> None: + ctx.gray(0) + ctx.rectangle(-120, -120, 240, 240).fill() + + self._sun.draw(ctx) + + ctx.font_size = 40 + ctx.text_align = ctx.CENTER + ctx.text_baseline = ctx.MIDDLE + + angle_per_item = 0.4 + + current = self._scroll_controller.current_position() + + for ix, item in enumerate(self._items): + rot = (ix - current) * angle_per_item + self._draw_text_angled(ctx, item.label(), rot, 1-abs(rot)) + + +class SimpleMenu(MenuController): + """ + A simple line-by-line menu. + """ + 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()) + + +menu_music = SimpleMenu([ + MenuItemBack(), + MenuItemNoop("Harmonic"), + MenuItemNoop("Melodic"), + MenuItemNoop("TinySynth"), + MenuItemNoop("CrazySynth"), + MenuItemNoop("Sequencer"), +], vm) + +menu_apps = SimpleMenu([ + MenuItemBack(), + MenuItemNoop("captouch"), + MenuItemNoop("worms"), +], vm) + +menu_main = MainMenu([ + MenuItemForeground("Music", menu_music), + MenuItemForeground("Apps", menu_apps), + MenuItemNoop("Settings"), +], vm) + +vm.push(menu_main) + +reactor = st4m.Reactor() +reactor.set_top(vm) +reactor.run() diff --git a/python_payload/mypystubs/hardware.pyi b/python_payload/mypystubs/hardware.pyi new file mode 100644 index 0000000000000000000000000000000000000000..e5fc2f9a52e66bca5d3771ebd13bc7b1f7693745 --- /dev/null +++ b/python_payload/mypystubs/hardware.pyi @@ -0,0 +1,18 @@ +import time + +from st4m.ui.ctx import Ctx + + +def freertos_sleep(ms: int) -> None: ... + +def get_ctx() -> Ctx: ... + +def display_pipe_full() -> bool: ... + +def display_update(c: Ctx) -> None: ... + +def get_captouch(ix: int) -> bool: ... + +def left_button_get() -> int: ... + +def right_button_get() -> int: ... \ No newline at end of file diff --git a/python_payload/mypystubs/time.pyi b/python_payload/mypystubs/time.pyi new file mode 100644 index 0000000000000000000000000000000000000000..2a276106b5b96623eaca8a784f2df1184c13e462 --- /dev/null +++ b/python_payload/mypystubs/time.pyi @@ -0,0 +1 @@ +def ticks_ms() -> int: pass \ No newline at end of file diff --git a/python_payload/st4m/README.md b/python_payload/st4m/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f1aafcbdb4226fbeddf9005a9c3bc56a97b012e7 --- /dev/null +++ b/python_payload/st4m/README.md @@ -0,0 +1,24 @@ +st4m +==== + +Experimental, heavily-typed flow3r UI/UX framework. + +Proof of concept of: + 1. Strongly typed Python (using MyPy) which is also used as docs. + 2. A deep class hierarchy that is still performant enough. + 3. Pretty UX. + +Does not implement application lifecycle, but that could be built upon +View/ViewWithInput. + +Should be trickle-merged into st3m. + +Typechecking +------------ + + MYPYPATH=$(pwd)/python_payload/mypystubs mypy python_payload/main_st4m.py --strict + +Running +------- + +Move `main_st4m.py` to `main.py` then either run on badge or in simulator. diff --git a/python_payload/st4m/__init__.py b/python_payload/st4m/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..033876cbeb5b3f2b0756b1d02df4342ebf8ba835 --- /dev/null +++ b/python_payload/st4m/__init__.py @@ -0,0 +1,11 @@ +from st4m.reactor import Reactor, Responder +from st4m.ui.ctx import Ctx +from st4m.input import InputState, InputController + +__all__ = [ + 'Reactor', 'Responder', + + 'InputState', 'InputController', + + 'Ctx', +] \ No newline at end of file diff --git a/python_payload/st4m/goose.py b/python_payload/st4m/goose.py new file mode 100644 index 0000000000000000000000000000000000000000..77b9ecaa797300e2dac5a5c7268095c5536694af --- /dev/null +++ b/python_payload/st4m/goose.py @@ -0,0 +1,49 @@ +""" +Goose implements Goose Typing (tm), a set of shims which allows the st4m +codebase to use both Python type annotations, Abstract Base Classes and run +under Micropython. +""" + +# Detect whether we're in MyPy or just executing. +try: + from typing import TYPE_CHECKING +except: + TYPE_CHECKING = False + +if TYPE_CHECKING: + # We're in MyPy. + from abc import ABCMeta, abstractmethod + class ABCBase(metaclass=ABCMeta): + pass + + from typing import List, Optional + from enum import Enum +else: + # We're in CPython or Micropython. + class ABCBase: + pass + + def abstractmethod(f): + def _fail(): + raise Exception("abstract method call") + return _fail + + try: + from typing import List, Optional + from enum import Enum + except ImportError: + # We're in Micropython. + List = None + Optional = None + + class Enum: + pass + +__all__ = [ + 'TYPE_CHECKING', + 'ABCBase', 'abstractmethod', + + 'List', 'Optional', + + 'Enum', +] \ No newline at end of file diff --git a/python_payload/st4m/input.py b/python_payload/st4m/input.py new file mode 100644 index 0000000000000000000000000000000000000000..576bb906cbd084f911d5e46c1361169c05b51262 --- /dev/null +++ b/python_payload/st4m/input.py @@ -0,0 +1,271 @@ +from st4m.goose import List, Optional, Enum +from st4m.ui.ctx import Ctx + +import hardware + + +class InputState: + """ + Current state of inputs from badge user. Passed via think() to every + Responder. + + If you want to detect edges, use the stateful InputController. + """ + def __init__(self, petal_pressed: List[bool], left_button: int, right_button: int) -> None: + self.petal_pressed = petal_pressed + self.left_button = left_button + self.right_button = right_button + + @classmethod + def gather(cls) -> 'InputState': + """ + Build InputState from current hardware state. Should only be used by the + Reactor. + """ + petal_pressed = [ + hardware.get_captouch(i) + for i in range(10) + ] + left_button = hardware.left_button_get() + right_button = hardware.right_button_get() + + return InputState(petal_pressed, left_button, right_button) + + +class RepeatSettings: + def __init__(self, first: float, subsequent: float) -> None: + self.first = first + self.subsequent = subsequent + + +class Pressable: + """ + A pressable button or button-acting object (like captouch petal in button + mode). + + Carries information about current and previous state of button, allowing to + detect edges (pressed/released) and state (down/up). Additionally implements + button repeating. + """ + + PRESSED = 'pressed' + REPEATED = 'repeated' + RELEASED = 'released' + DOWN = 'down' + UP = 'up' + + def __init__(self, state: bool) -> None: + self._state = state + self._prev_state = state + self._repeat = RepeatSettings(400, 200) + + self._pressed_at: Optional[float] = None + self._repeating = False + self._repeated = False + + self._ignoring = 0 + + def _update(self, ts: int, state: bool) -> None: + if self._ignoring > 0: + self._ignoring -= 1 + + self._prev_state = self._state + self._state = state + self._repeated = False + + if state == False: + self._pressed_at = None + self._repeating = False + else: + if self._pressed_at is None: + self._pressed_at = ts + + repeat = self._repeat + if state and repeat is not None and self._pressed_at is not None: + if not self._repeating: + if ts > self._pressed_at + repeat.first: + self._repeating = True + self._repeated = True + self._prev_state = False + self._pressed_at = ts + else: + if ts > self._pressed_at + repeat.subsequent: + self._prev_state = False + self._pressed_at = ts + self._repeated = True + + + @property + def state(self) -> str: + """ + Returns one of Pressable.{UP,DOWN,PRESSED,RELEASED,REPEATED}. + """ + prev = self._prev_state + cur = self._state + + if self._ignoring > 0: + return self.UP + + if self._repeated: + return self.REPEATED + + if cur and not prev: + return self.PRESSED + if not cur and prev: + return self.RELEASED + if cur and prev: + return self.DOWN + return self.UP + + @property + def pressed(self) -> bool: + """ + True if the button has just been pressed. + """ + return self.state == self.PRESSED + + @property + def repeated(self) -> bool: + """ + True if the button has been held long enough that a virtual 'repeat' + press should be acted upon. + """ + return self.state == self.REPEATED + + @property + def released(self) -> bool: + """ + True if the button has just been released. + """ + return self.state == self.RELEASED + + @property + def down(self) -> bool: + """ + True if the button is held down, after first being pressed. + """ + return self.state == self.DOWN + + @property + def up(self) -> bool: + """ + True if the button is currently not being held down. + """ + return self.state == self.UP + + def _ignore_pressed(self) -> None: + """ + Pretend the button isn't being pressed for the next two update + iterations. Used to prevent spurious presses to be routed to apps that + have just been foregrounded. + """ + self._ignoring = 2 + self._repeating = False + self._repeated = False + + def __repr__(self) -> str: + return '<Pressable: ' + self.state + '>' + + +class PetalState(Pressable): + def __init__(self, ix: int) -> None: + self.ix = ix + super().__init__(False) + + +class CaptouchState: + """ + State of capacitive touch petals. + + The petals are indexed from 0 to 9 (inclusive). Petal 0 is above the USB-C + socket, then the numbering continues counter-clockwise. + """ + __slots__ = ('petals') + + def __init__(self) -> None: + self.petals = [ + PetalState(i) + for i in range(10) + ] + + def _update(self, ts: int, hr: InputState) -> None: + for i, petal in enumerate(self.petals): + petal._update(ts, hr.petal_pressed[i]) + + def _ignore_pressed(self) -> None: + for petal in self.petals: + petal._ignore_pressed() + + +class TriSwitchHandedness(Enum): + """ + Left or right shoulder button. + """ + left = 'left' + right = 'right' + + +class TriSwitchState: + """ + State of a tri-stat shoulder button + """ + __slots__ = ('left', 'middle', 'right', 'handedness') + + def __init__(self, h: TriSwitchHandedness) -> None: + self.handedness = h + + self.left = Pressable(False) + self.middle = Pressable(False) + self.right = Pressable(False) + + def _update(self, ts: int, hr: InputState) -> None: + st = hr.left_button if self.handedness == TriSwitchHandedness.left else hr.right_button + self.left._update(ts, st == -1) + self.middle._update(ts, st == 2) + self.right._update(ts, st == 1) + + def _ignore_pressed(self) -> None: + self.left._ignore_pressed() + self.middle._ignore_pressed() + self.right._ignore_pressed() + + +class InputController: + """ + A stateful input controller. It accepts InputState updates from the Reactor + and allows a Responder to detect input events, like a button having just + been pressed. + + To use, instantiate within a Responder and call think() from your + responder's think(). + + Then, access the captouch/left_shoulder/right_shoulder fields. + """ + __slots__ = ( + 'captouch', + 'left_shoulder', + 'right_shoulder', + '_ts', + ) + def __init__(self) -> None: + self.captouch = CaptouchState() + self.left_shoulder = TriSwitchState(TriSwitchHandedness.left) + self.right_shoulder = TriSwitchState(TriSwitchHandedness.right) + self._ts = 0 + + def think(self, hr: InputState, delta_ms: int) -> None: + self._ts += delta_ms + self.captouch._update(self._ts, hr) + self.left_shoulder._update(self._ts, hr) + self.right_shoulder._update(self._ts, hr) + + def _ignore_pressed(self) -> None: + """ + Pretend input buttons aren't being pressed for the next two update + iterations. Used to prevent spurious presses to be routed to apps that + have just been foregrounded. + """ + self.captouch._ignore_pressed() + self.left_shoulder._ignore_pressed() + self.right_shoulder._ignore_pressed() + diff --git a/python_payload/st4m/reactor.py b/python_payload/st4m/reactor.py new file mode 100644 index 0000000000000000000000000000000000000000..558186f254396ff104bbecca74d9fb5f869f6206 --- /dev/null +++ b/python_payload/st4m/reactor.py @@ -0,0 +1,122 @@ +from st4m.goose import ABCBase, abstractmethod, List, Optional +from st4m.input import InputState +from st4m.ui.ctx import Ctx + +import time, hardware + +class Responder(ABCBase): + """ + Responder is an interface from the Reactor to any running Micropython code + that wishes to access input state or draw to the screen. + + A Responder can be a system menu, an application, a graphical widget, etc. + + The Reactor will call think and draw methods at a somewhat-constant pace in + order to maintain a smooth system-wide update rate and framerate. + """ + @abstractmethod + def think(self, ins: InputState, delta_ms: int) -> None: + """ + think() will be called when the Responder should process the InputState + and perform internal logic. delta_ms will be set to the number of + milliseconds elapsed since the last reactor think loop. + + The code must not sleep or block during this callback, as that will + impact the system tickrate and framerate. + """ + pass + + @abstractmethod + def draw(self, ctx: Ctx) -> None: + """ + draw() will be called when the Responder should draw, ie. generate a drawlist by performing calls on the given ctx object. + + Depending on what calls the Responder, the ctx might either represent + the surface of the entire screen, or some composited-subview (eg. an + application screen that is currently being transitioned out by sliding + left). Unless specified otherwise by the compositing stack, the screen + coordinates are +/- 120 in both X and Y (positive numbers towards up and + right), with 0,0 being the middle of the screen. + + The Reactor will then rasterize and blit the result. + + The code must not sleep or block during this callback, as that will + impact the system tickrate and framerate. + """ + pass + + +class Reactor: + """ + The Reactor is the main Micropython scheduler of the st4m system and any + running payloads. + + It will attempt to run a top Responder with a fixed tickrate a framerate + that saturates the display rasterization/blitting pipeline. + """ + __slots__ = ('_top', '_tickrate_ms', '_last_tick', '_ctx', '_ts') + 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._ctx: Optional[Ctx] = None + + def set_top(self, top: Responder) -> None: + """ + Set top Responder. It will be called by the reactor in a loop once run() + is called. + + This can be also called after the reactor is started. + """ + self._top = top + + def run(self) -> None: + """ + Run the reactor forever, processing the top Responder in a loop. + """ + while True: + self._run_once() + + def _run_once(self) -> None: + start = time.ticks_ms() + deadline = start + self._tickrate_ms + + self._run_top(start) + + end = time.ticks_ms() + 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. + if self._top is None: + return + + # Calculate delta (default to tickrate if running first iteration). + delta = self._tickrate_ms + if self._last_tick is not None: + delta = start - self._last_tick + self._last_tick = start + + self._ts += delta + + hr = InputState.gather() + + # Think! + self._top.think(hr, delta) + + # Draw! + if self._ctx is None: + self._ctx = hardware.get_ctx() + if self._ctx is not None: + self._ctx.save() + self._top.draw(self._ctx) + self._ctx.restore() + if self._ctx is not None and not hardware.display_pipe_full(): + hardware.display_update(self._ctx) + self._ctx = None + diff --git a/python_payload/st4m/ui/__init__.py b/python_payload/st4m/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/python_payload/st4m/ui/ctx.py b/python_payload/st4m/ui/ctx.py new file mode 100644 index 0000000000000000000000000000000000000000..7a442cfaec90ed3b51cfc22072e4d15547cf2f8d --- /dev/null +++ b/python_payload/st4m/ui/ctx.py @@ -0,0 +1,315 @@ +from st4m.goose import ABCBase, abstractmethod, List + +class Ctx(ABCBase): + """ + Ctx is the rendering/rasterization API used by st4m. + + It's a WebCanvas-style vector API, with an implicit pen which can be moved + and can draw lines, arcs, text, etc. + + In st4m, the Ctx object is backed by a drawlist generator. That is, any + operation performed on the ctx object will cause an entry to be added to an + in-memory draw list. Then, when the rasterizer is ready, it will rasterize + said drawlist to pixels in a separate thread. + + A Ctx object is passed to all draw() calls to Responder. This object should + only be used within the lifecycle of the draw method and must not be + persisted. + + For more information, see: https://ctx.graphics/ + """ + __slots__ = ('font_size', 'text_align', 'text_baseline', 'line_width', 'global_alpha') + + CENTER = 'center' + MIDDLE = 'middle' + + @abstractmethod + def __init__(self) -> None: + self.font_size: float = 10 + self.text_align: str = 'start' + self.text_baseline: str = 'alphabetic' + self.line_width: float = 1.0 + self.global_alpha: float = 1.0 + + @abstractmethod + def begin_path(self) -> 'Ctx': + """ + Clears the current path if any. + """ + pass + + @abstractmethod + def save(self) -> 'Ctx': + """ + Stores the transform, clipping state, fill and stroke sources, font + size, stroking and dashing options. + """ + pass + + @abstractmethod + def restore(self) -> 'Ctx': + """ + Restores the state previously saved with save, calls to save/restore + should be balanced. + """ + pass + + @abstractmethod + def start_frame(self) -> 'Ctx': + """ + Prepare for rendering a new frame, clears internal drawlist and + initializes the state. + + TODO(q3k): we probably shouldn't expose this + """ + pass + + @abstractmethod + def end_frame(self) -> 'Ctx': + """ + We're done rendering a frame, this does nothing on a context created for + a framebuffer, where drawing commands are immediate. + + TODO(q3k): we probably shouldn't expose this + """ + pass + + @abstractmethod + def start_group(self) -> 'Ctx': + """ + Start a compositing group. + """ + pass + + @abstractmethod + def end_group(self) -> 'Ctx': + """ + End a compositing group, the global alpha, compositing mode and blend + mode set before this call is used to apply the group. + """ + pass + + @abstractmethod + def clip(self) -> 'Ctx': + """ + Use the current path as a clipping mask, subsequent draw calls are + limited by the path. The only way to increase the visible area is to + first call save and then later restore to undo the clip. + """ + pass + + @abstractmethod + def rotate(self, x: float) -> 'Ctx': + """ + Add rotation to the user to device space transform. + """ + pass + + @abstractmethod + def scale(self, x: float, y: float) -> 'Ctx': + """ + Scales the user to device transform. + """ + pass + + @abstractmethod + def translate(self, x: float, y: float) -> 'Ctx': + """ + Adds translation to the user to device transform. + """ + pass + + @abstractmethod + def apply_transform(self, a: float, b: float, c: float, d: float, e: float, f: float, g: float, h: float, i: float) -> 'Ctx': + """ + Adds a 3x3 matrix on top of the existing user to device space transform. + + TODO(q3k): we probably shouldn't expose this + """ + pass + + @abstractmethod + def line_to(self, x: float, y: float) -> 'Ctx': + """ + Draws a line segment from the position of the last + {line,move,curve,quad}_to) to the given coordinates. + """ + pass + + @abstractmethod + def move_to(self, x: float, y: float) -> 'Ctx': + """ + Moves the virtual pen to the given coordinates without drawing anything. + """ + pass + + @abstractmethod + def curve_to(self, cx0: float, cy0: float, cx1: float, cy1: float, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def quad_to(self, cx: float, cy: float, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def gray(self, a: float) -> 'Ctx': + """ + Set current draw color to a floating point grayscale value from 0 to 1. + """ + pass + + @abstractmethod + def rgb(self, r: float, g: float, b: float) -> 'Ctx': + """ + Set current draw color to an RGB color defined by component values from + 0 to 1. + """ + pass + + @abstractmethod + def rgba(self, r: float, g: float, b: float, a: float) -> 'Ctx': + """ + Set current draw color to an RGBA color defined by component values from + 0 to 1. + """ + pass + + @abstractmethod + def arc_to(self, x1: float, y1: float, x2: float, y2: float, radius: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rel_line_to(self, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rel_move_to(self, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rel_curve_to(self, cx0: float, cy0: float, cx1: float, cy1: float, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rel_quad_to(self, cx: float, cy: float, x: float, y: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rel_arc_to(self, x1: float, y1: float, x2: float, y2: float, radius: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def rectangle(self, x: float, y: float, w: float, h: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def round_rectangle(self, x: float, y: float, w: float, h: float, r: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def arc(self, x: float, y: float, radius: float, angle1: float, angle2: float, direction: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def close_path(self) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def preserve(self) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def fill(self) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def stroke(self) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def paint(self) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def linear_gradient(self, x0: float, y0: float, x1: float, y1: float) -> 'Ctx': + """ + Change the source to a linear gradient from x0,y0 to x1,y1, by default + an empty gradient from black to white exists, add stops with + gradient_add_stop to specify a custom gradient. + + TODO(q3k): check gradient_add_stop + """ + pass + + @abstractmethod + def radial_gradient(self, x0: float, y0: float, r0: float, x1: float, y1: float, r1: float) -> 'Ctx': + """ + Change the source to a radial gradient from a circle x0,y0 with radius0 + to an outer circle x1,y1 with radidus r1. + + NOTE: currently only the second circle's origin is used, but both + radiuses are in use. + """ + pass + + @abstractmethod + def logo(self, x: float, y: float, dim: float) -> 'Ctx': + """ + TOD(q3k): document + """ + pass + + @abstractmethod + def text(self, text: str) -> 'Ctx': + """ + TOD(q3k): document + """ + pass \ No newline at end of file diff --git a/python_payload/st4m/ui/interactions.py b/python_payload/st4m/ui/interactions.py new file mode 100644 index 0000000000000000000000000000000000000000..a665d17df56f38db5030b8067ca686b0e4c769cb --- /dev/null +++ b/python_payload/st4m/ui/interactions.py @@ -0,0 +1,151 @@ +import st4m + +from st4m.input import InputState +from st4m.ui.ctx import Ctx +from st4m import Responder + + +class ScrollController(st4m.Responder): + """ + ScrolLController is a controller for physically scrollable one-dimensional + lists. + + It allows navigating with explicit 'left'/'right' commands (triggered by eg. + buttons). In the future it will also allow navigating by captouch gestures. + + Its output is the information about a 'target position' (integer from 0 to + Nitems-1) a 'current position' (float from -inf to +inf). + + The target position is the user intent, ie. what the user likely selected. + This value should be used to interpret what the user has currently selected. + + The current position is the animated 'current' position, which includes + effects like acceleration and past-end-of-list bounce-back. This value + should be used to render the current state of the scrolling list. + """ + __slots__ = ( + '_nitems', + '_target_position', + '_current_position', + '_velocity', + ) + + def __init__(self) -> None: + self._nitems = 0 + self._target_position = 0 + self._current_position = 0.0 + self._velocity: float = 0.0 + + def set_item_count(self, count: int) -> None: + """ + Set how many items this scrollable list contains. Currently, updating + the item count does not gracefully handle current/target position + switching - it will be clamped to the new length. A different API should + provide the ability to remove a [start,end) range of items from the list + and update positions gracefully according to that. + """ + if count < 0: + count = 0 + self._nitems = count + + def scroll_left(self) -> None: + """ + Call when the user wants to scroll left by discrete action (eg. button + press). + """ + self._target_position -= 1 + self._velocity = -10 + + def scroll_right(self) -> None: + """ + Call when the user wants to scroll right by discrete action (eg. button + press). + """ + self._target_position += 1 + self._velocity = 10 + + def think(self, ins: InputState, delta_ms: int) -> None: + if self._nitems == 0: + self._target_position = 0 + self._current_position = 0 + self._velocity = 0 + return + + if self._target_position < 0: + self._target_position = 0 + if self._target_position >= self._nitems: + self._target_position = self._nitems - 1 + + self._physics_step(delta_ms / 1000.0) + + def draw(self, ctx: Ctx) -> None: + pass + + def current_position(self) -> float: + """ + Return current position, a float from -inf to +inf. + + In general, this value will be within [O, Nitems), but might go out of + bounds when rendering the bounceback effect. + + With every tick, this value moves closer and closer to target_position(). + + Use this value to animate the scroll list. + """ + return self._current_position + + def target_position(self) -> int: + """ + Return user-selected 'target' position, an integer in [0, Nitems). + + Use this value to interpret the user selection when eg. a 'select' + button is pressed. + """ + return self._target_position + + def at_left_limit(self) -> bool: + """ + Returns true if the scrollable list is at its leftmost (0) position. + """ + return self._target_position <= 0 + + def at_right_limit(self) -> bool: + """ + Returns true if the scrollable list is as its rightmost (Nitems-1) + position. + """ + return self._target_position >= self._nitems - 1 + + def _physics_step(self, delta: float) -> None: + diff = float(self._target_position) - self._current_position + max_velocity = 500 + velocity = self._velocity + + if abs(diff) > 0.1: + # Apply force to reach target position. + if diff > 0: + velocity += 80 * delta + else: + velocity -= 80 * delta + + # Clamp velocity. + if velocity > max_velocity: + velocity = max_velocity + if velocity < -max_velocity: + velocity = -max_velocity + self._velocity = velocity + else: + # Try to snap to target position. + pos = self._velocity > 0 and diff > 0 + neg = self._velocity < 0 and diff < 0 + if pos or neg: + self._current_position = self._target_position + self._velocity = 0 + + self._physics_integrate(delta) + + def _physics_integrate(self, delta: float) -> None: + self._velocity -= self._velocity * delta * 10 + self._current_position += self._velocity * delta + + diff --git a/python_payload/st4m/ui/menu.py b/python_payload/st4m/ui/menu.py new file mode 100644 index 0000000000000000000000000000000000000000..a2d85b4fbe58c9a9b41eced349d5ac227fd0ecad --- /dev/null +++ b/python_payload/st4m/ui/menu.py @@ -0,0 +1,138 @@ +import st4m + +from st4m import Responder +from st4m.goose import ABCBase, abstractmethod, List, Optional +from st4m.input import InputState, InputController +from st4m.ui.view import ViewWithInputState, View, ViewManager, ViewTransitionSwipeLeft, ViewTransitionSwipeRight +from st4m.ui.interactions import ScrollController +from st4m.ui.ctx import Ctx + + +class MenuItem(ABCBase): + """ + An abstract MenuItem to be implemented by concrete impementations. + + A MenuItem implementation can be added to a MenuController + """ + + @abstractmethod + def press(self, vm: Optional[ViewManager]) -> None: + """ + Called when the menu item is 'pressed'/'activated'. + + vm will be set the the active ViewManager if the menu is used in + ViewManager context. + """ + pass + + @abstractmethod + def label(self) -> str: + """ + Return the printable label of the menu item. + """ + pass + + +class MenuItemForeground(MenuItem): + """ + A MenuItem which, when activated, navigates to the given View. + """ + def __init__(self, label: str, r: View) -> None: + self._r = r + self._label = label + + def press(self, vm: Optional[ViewManager]) -> None: + if vm is not None: + vm.push(self._r, ViewTransitionSwipeLeft()) + + def label(self) -> str: + return self._label + + +class MenuItemNoop(MenuItem): + """ + A MenuItem which does nothing. + """ + def __init__(self, label: str) -> None: + self._label = label + + def press(self, vm: Optional[ViewManager]) -> None: + pass + + def label(self) -> str: + return self._label + + +class MenuItemBack(MenuItem): + """ + A MenuItem which, when activatd, navigates back in history. + """ + def press(self, vm: Optional[ViewManager]) -> None: + if vm is not None: + vm.pop(ViewTransitionSwipeRight()) + + def label(self) -> str: + return "Back" + + +class MenuController(ViewWithInputState): + """ + Base class for menus. Reacts to canonical inputs (left shoulder button) to + move across and select actions from the menu. + + Implementers must implement draw() and use self._items and + self._scroll_controller to display menu items accordingly. + """ + __slots__ = ( + '_items', + '_scroll_controller', + '_view_manager', + ) + + def __init__(self, items: List[MenuItem], vm: Optional[ViewManager]) -> None: + self._items = items + self._scroll_controller = ScrollController() + self._scroll_controller.set_item_count(len(items)) + self._view_manager = vm + + super().__init__() + + def _parse_state(self) -> None: + left = self.input.left_shoulder.left + right = self.input.left_shoulder.right + + if left.pressed: + self._scroll_controller.scroll_left() + return + if right.pressed: + self._scroll_controller.scroll_right() + return + + if not self._scroll_controller.at_left_limit() and left.repeated: + self._scroll_controller.scroll_left() + return + + if not self._scroll_controller.at_right_limit() and right.repeated: + self._scroll_controller.scroll_right() + return + + def think(self, ins: InputState, delta_ms: int) -> None: + super().think(ins, delta_ms) + + self._scroll_controller.think(ins, delta_ms) + + if self.input.left_shoulder.middle.pressed: + self.select() + self._parse_state() + + def draw(self, ctx: Ctx) -> None: + pass + + def select(self) -> None: + """ + Call to activate the currently selected item. + + Automatically called on canonical user input. + """ + self._items[self._scroll_controller.target_position()].press(self._view_manager) + diff --git a/python_payload/st4m/ui/view.py b/python_payload/st4m/ui/view.py new file mode 100644 index 0000000000000000000000000000000000000000..a66d9194f357d2854ae3333791ac216e2477dbfe --- /dev/null +++ b/python_payload/st4m/ui/view.py @@ -0,0 +1,198 @@ +from st4m.reactor import Responder +from st4m.goose import ABCBase, abstractmethod, Optional, List +from st4m.input import InputState, InputController +from st4m.ui.ctx import Ctx + + +class View(Responder): + """ + A View extends a reactor Responder with callbacks related to the Responder's + lifecycle in terms of being foregrounded or backgrounded. + + These signals can be used to alter input processing, se the + ViewWithInputState class. + """ + + def on_enter(self) -> None: + """ + Called when the View has just become active. This is guaranteed to be + called before think(). + """ + pass + + +class ViewWithInputState(View): + """ + A base class helper for implementing views which respond to inputs and who + want to do their own, separate input processing. + + Derive this class, then use self.input to access the InputController. + + Remember to call super().think() in think()! + """ + __slots__ = ( + 'input', + ) + + def __init__(self) -> None: + self.input = InputController() + + def on_enter(self) -> None: + self.input._ignore_pressed() + + def think(self, ins: InputState, delta_ms: int) -> None: + self.input.think(ins, delta_ms) + + +class ViewTransition(ABCBase): + """ + A transition from one View/Responder to another. + + Can be implemented by the user to provide transition animations. + """ + @abstractmethod + def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None: + """ + Called when the ViewManager performs a transition from the outgoing + responder to the incoming responder. The implementer should draw both + Responders when appropriate. + + The transition value is a float from 0 to 1 which represents the + progress of the transition. + """ + pass + + +class ViewTransitionBlend(ViewTransition): + """ + Transition from one view to another by opacity blending. + """ + def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None: + ctx.start_group() + outgoing.draw(ctx) + ctx.end_group() + + ctx.start_group() + ctx.global_alpha = transition + incoming.draw(ctx) + ctx.end_group() + + +class ViewTransitionSwipeLeft(ViewTransition): + """ + Swipe the outoing view to the left and replace it with the incoming view. + """ + def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None: + ctx.save() + ctx.translate(transition * -240, 0) + outgoing.draw(ctx) + ctx.restore() + + ctx.save() + ctx.translate(240 + transition * -240, 0) + incoming.draw(ctx) + ctx.restore() + + +class ViewTransitionSwipeRight(ViewTransition): + """ + Swipe the outoing view to the right and replace it with the incoming view. + """ + def draw(self, ctx: Ctx, transition: float, incoming: Responder, outgoing: Responder) -> None: + ctx.save() + ctx.translate(transition * 240, 0) + outgoing.draw(ctx) + ctx.restore() + + ctx.save() + ctx.translate(-240 + transition * 240, 0) + incoming.draw(ctx) + ctx.restore() + + +class ViewManager(Responder): + """ + The ViewManager implements stateful routing between Views. + + It manages a history of Views, to which new Views can be pushed and then + popped. + """ + def __init__(self, vt: ViewTransition) -> None: + """ + Create a new ViewManager with a default ViewTransition. + """ + self._incoming: Optional[View] = None + self._outgoing: Optional[View] = None + + # Transition time. + self._time_ms = 150 + + self._default_vt = vt + self._overriden_vt: Optional[ViewTransition] = None + + self._transitioning = False + self._transition = 0.0 + self._history: List[View] = [] + + def think(self, ins: InputState, delta_ms: int) -> None: + if self._transitioning: + self._transition += (delta_ms / 1000.0) * (1000 / self._time_ms) + if self._transition >= 1.0: + self._transition = 0 + self._transitioning = False + + self._outgoing = None + + if self._outgoing is not None: + self._outgoing.think(ins, delta_ms) + if self._incoming is not None: + self._incoming.think(ins, delta_ms) + + def draw(self, ctx: Ctx) -> None: + if self._transitioning: + vt = self._default_vt + if self._overriden_vt is not None: + vt = self._overriden_vt + + if self._incoming is not None and self._outgoing is not None: + vt.draw(ctx, self._transition, self._incoming, self._outgoing) + return + if self._incoming is not None: + self._incoming.draw(ctx) + + def replace(self, r: View, overide_vt: Optional[ViewTransition] = None) -> None: + """ + Replace the existing view with the given View, optionally using a given + ViewTransition instead of the default. + + The new view will _not_ be added to history! + """ + self._outgoing = self._incoming + self._incoming = r + self._incoming.on_enter() + self._overriden_vt = overide_vt + if self._outgoing is None: + return + + self._transitioning = True + self._transition = 0.0 + + def push(self, r: View, override_vt: Optional[ViewTransition] = None) -> None: + """ + Push a View to the history stack and start transitioning to it. If set, + override_vt will be used instead of the default ViewTransition + animation. + """ + if self._incoming is not None: + self._history.append(self._incoming) + + self.replace(r, override_vt) + + def pop(self, override_vt: Optional[ViewTransition] = None) -> None: + """ + Pop a view from the history stack and start transitioning to it. If set, + override_vt will be used instead of the default ViewTransition + animation. + """ + r = self._history.pop() + self.replace(r, override_vt) \ No newline at end of file