diff --git a/python_payload/st3m/input.py b/python_payload/st3m/input.py index ed5f77d342276183750927f0cf07623bdb90f42c..ef771bddff62f0b65110bdbd8f06bf2b4cfd141a 100644 --- a/python_payload/st3m/input.py +++ b/python_payload/st3m/input.py @@ -46,6 +46,14 @@ class RepeatSettings: self.subsequent = subsequent +class PressableState(Enum): + PRESSED = "pressed" + REPEATED = "repeated" + RELEASED = "released" + DOWN = "down" + UP = "up" + + class Pressable: """ A pressable button or button-acting object (like captouch petal in button @@ -56,11 +64,11 @@ class Pressable: button repeating. """ - PRESSED = "pressed" - REPEATED = "repeated" - RELEASED = "released" - DOWN = "down" - UP = "up" + PRESSED = PressableState.PRESSED + REPEATED = PressableState.REPEATED + RELEASED = PressableState.RELEASED + DOWN = PressableState.DOWN + UP = PressableState.UP def __init__(self, state: bool) -> None: self._state = state @@ -103,9 +111,9 @@ class Pressable: self._repeated = True @property - def state(self) -> str: + def state(self) -> PressableState: """ - Returns one of Pressable.{UP,DOWN,PRESSED,RELEASED,REPEATED}. + Returns one of PressableState.{UP,DOWN,PRESSED,RELEASED,REPEATED}. """ prev = self._prev_state cur = self._state @@ -171,7 +179,195 @@ class Pressable: self._repeated = False def __repr__(self) -> str: - return "<Pressable: " + self.state + ">" + return "<Pressable: " + str(self.state) + ">" + + +class TouchableState(Enum): + UP = "up" + BEGIN = "begin" + RESTING = "resting" + MOVED = "moved" + ENDED = "ended" + + +class Touchable: + """ + A Touchable processes incoming captouch positional state into higher-level + simple gestures. + + The Touchable can be in one of four states: + + UP: not currently being interacted with + BEGIN: some gesture has just started + MOVED: a gesture is continuing + ENDED: a gesture has just ended + + The state can be retrieved by calling phase(). + + The main API for consumers is current_gesture(), which returns a + Touchable.Gesture defining the current state of the gesture, from the + beginning of the touch event up until now (or until the end, if the current + phase is ENDED). + + Under the hood, the Touchable keeps a log of recent samples from the + captouch petal position, and processes them to eliminate initial noise from + the beginning of a gesture. + + All positional output state is the same format/range as in the low-level + CaptouchState. + """ + + UP = TouchableState.UP + BEGIN = TouchableState.BEGIN + MOVED = TouchableState.MOVED + ENDED = TouchableState.ENDED + + class Entry: + """ + A Touchable's log entry, containing some position measurement at some + timestamp. + """ + + __slots__ = ["ts", "phi", "rad"] + + def __init__(self, ts: int, phi: float, rad: float) -> None: + self.ts = ts + self.phi = phi + self.rad = rad + + def __repr__(self) -> str: + return f"{self.ts}: ({self.rad}, {self.phi})" + + class Gesture: + """ + A simple captouch gesture, currently definined as a movement between two + points: the beginning of the gesture (when the user touched the petal) + and to the current state. If the gesture is still active, the current + state is averaged/filtered to reduce noise. If the gesture has ended, + the current state is the last measured position. + """ + + def __init__(self, start: "Touchable.Entry", end: "Touchable.Entry") -> None: + self.start = start + self.end = end + + @property + def distance(self) -> Tuple[float, float]: + """ + Distance traveled by this gesture. + """ + delta_rad = self.end.rad - self.start.rad + delta_phi = self.end.phi - self.start.phi + return (delta_rad, delta_phi) + + @property + def velocity(self) -> Tuple[float, float]: + """ + Velocity vector of this gesture. + """ + delta_rad = self.end.rad - self.start.rad + delta_phi = self.end.phi - self.start.phi + if self.end.ts == self.start.ts: + return (0, 0) + delta_s = (self.end.ts - self.start.ts) / 1000 + return (delta_rad / delta_s, delta_phi / delta_s) + + def __init__(self, pos: tuple[float, float] = (0.0, 0.0)) -> None: + # Entry log, used for filtering. + self._log: List[Touchable.Entry] = [] + + # What the beginning of the gesture is defined as. This is ampled a few + # entries into the log as the initial press stabilizes. + self._start: Optional[Touchable.Entry] = None + self._start_ts: int = 0 + + # Current and previous 'pressed' state from the petal, used to begin + # gesture tracking. + self._pressed = False + self._prev_pressed = self._pressed + + self._state = self.UP + + # If not nil, amount of update() calls to wait until the gesture has + # been considered as started. This is part of the mechanism which + # eliminates early parts of a gesture while the pressure on the sensor + # grows and the user's touch contact point changes. + self._begin_wait: Optional[int] = None + + self._last_ts: int = 0 + + def _append_entry(self, ts: int, petal: captouch.CaptouchPetalState) -> None: + """ + Append an Entry to the log based on a given CaptouchPetalState. + """ + (rad, phi) = petal.position + entry = self.Entry(ts, phi, rad) + self._log.append(entry) + overflow = len(self._log) - 10 + if overflow > 0: + self._log = self._log[overflow:] + + def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None: + """ + Called when the Touchable is being processed by an InputController. + """ + self._last_ts = ts + self._prev_pressed = self._pressed + self._pressed = petal.pressed + + if not self._pressed: + if not self._prev_pressed: + self._state = self.UP + else: + self._state = self.ENDED + return + + self._append_entry(ts, petal) + + if not self._prev_pressed: + # Wait 5 samples until we consider the gesture started. + # TODO(q3k): do better than hardcoding this. Maybe use pressure data? + self._begin_wait = 5 + elif self._begin_wait is not None: + self._begin_wait -= 1 + if self._begin_wait < 0: + self._begin_wait = None + # Okay, the gesture has officially started. + self._state = self.BEGIN + # Grab latest log entry as gesture start. + self._start = self._log[-1] + self._start_ts = ts + # Prune log. + self._log = self._log[-1:] + else: + self._state = self.MOVED + + def phase(self) -> TouchableState: + """ + Returns the current phase of a gesture as tracked by this Touchable (petal). + """ + return self._state + + def current_gesture(self) -> Optional[Gesture]: + if self._start is None: + return None + + assert self._start_ts is not None + delta_ms = self._last_ts - self._start_ts + + first = self._start + last = self._log[-1] + # If this gesture hasn't ended, grab last 5 log entries for average of + # current position. This filters out a bunch of noise. + if self.phase() != self.ENDED: + log = self._log[-5:] + phis = [el.phi for el in log] + rads = [el.rad for el in log] + phi_avg = sum(phis) / len(phis) + rad_avg = sum(rads) / len(rads) + last = self.Entry(last.ts, phi_avg, rad_avg) + + return self.Gesture(first, last) class PetalState: @@ -179,88 +375,12 @@ class PetalState: self.ix = ix self.whole = Pressable(False) self.pressure = 0 + self.gesture = Touchable() def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None: self.whole._update(ts, petal.pressed) self.pressure = petal.pressure - - -# class Touchable(Pressable): -# """ -# An object that can receive touch gestures (captouch petal) -# """ -# -# BEGIN = "begin" -# RESTING = "resting" -# MOVED = "moved" -# ENDED = "ended" -# -# def __init__(self, pos: tuple[int,int] = (0, 0)) -> None: -# super().__init__(False) -# self._pos = pos -# self._prev_pos = pos -# self._polar = self._prev_polar = (0, 0) -# self._dx = 0.0 -# self._dy = 0.0 -# self._dphi = 0.0 -# self._dr = 0.0 -# -# def _update(self, ts, state, pos) -> None: -# self._prev_pos = self._pos -# self._pos = pos -# -# self._prev_polar = self._polar -# -# self._dx = self._pos[0] - self._prev_pos[0] -# self._dy = self._pos[1] - self._prev_pos[1] -# -# x0 = -pos[0] / 500 -# x1 = pos[1] / 500 -# -# phi = math.atan2(x0, x1) - math.pi / 2 -# r = math.sqrt(x0 * x0 + x1 * x1) -# self._polar = (r, phi) -# -# self._dr = self._polar[0] - self._prev_polar[0] -# -# v = self._polar[1] - self._prev_polar[1] -# -# for sign in [1, -1]: -# t = v + sign * 2 * math.pi -# if abs(t) < abs(v): -# v = t -# -# self._dphi = v -# -# super()._update(ts, state) -# if self.state != self.DOWN: -# self._dx = self._dy = self._dphi = self._dr = 0 -# else: -# pass -# # print(r, phi, self._dr, self._dphi) -# -# def phase(self) -> str: -# if self.state == self.UP: -# return self.UP -# if self.state == self.RELEASED: -# return self.ENDED -# if self.state == self.PRESSED: -# return self.BEGIN -# if self.state == self.DOWN or self.state == self.REPEATED: -# if abs(self._dr) > 1 or abs(self._dphi) > 0.01: -# return self.MOVED -# else: -# return self.RESTING -# return "HUHUHU" - - -# class PetalGestureState(Touchable): -# def __init__(self, ix: int) -> None: -# self.ix = ix -# super().__init__() -# -# def _update(self, ts: int, hr: InputState) -> None: -# super()._update(ts, hr.petal_pressed[self.ix], hr.petal_pos[self.ix]) + self.gesture._update(ts, petal) class CaptouchState: @@ -382,17 +502,3 @@ class InputController: self.captouch._ignore_pressed() self.left_shoulder._ignore_pressed() self.right_shoulder._ignore_pressed() - - -# class PetalController: -# def __init__(self, ix): -# self._ts = 0 -# self._input = PetalGestureState(ix) -# -# def think(self, hr: InputState, delta_ms: int) -> None: -# self._ts += delta_ms -# self._input._update(self._ts, hr) -# -# def _ignore_pressed(self) -> None: -# self._input._ignore_pressed() -# diff --git a/python_payload/st3m/ui/interactions.py b/python_payload/st3m/ui/interactions.py index 488d4648e649e8430d4823b1bd6f513b41ce8b18..d1d55019109ab259027a794491c9f0e424b78dc9 100644 --- a/python_payload/st3m/ui/interactions.py +++ b/python_payload/st3m/ui/interactions.py @@ -1,7 +1,8 @@ import st3m -from st3m.input import InputState +from st3m.input import InputState, Touchable from st3m.ui.ctx import Ctx +from st3m.goose import Optional, Tuple from st3m import Responder @@ -149,129 +150,69 @@ class ScrollController(st3m.Responder): self._current_position += self._velocity * delta -# class GestureScrollController(ScrollController): -# """ -# GestureScrollController extends ScrollController to also react to swipe gestures -# on a configurable petal. -# -# #TODO (iggy): rewrite both to extend a e.g. "PhysicsController" -# """ -# -# def __init__(self, petal_index, wrap=False): -# super().__init__(wrap) -# self._petal = PetalController(petal_index) -# self._speedbuffer = [0.0] -# self._ignore = 0 -# -# def scroll_left(self) -> None: -# """ -# Call when the user wants to scroll left by discrete action (eg. button -# press). -# """ -# # self._target_position -= 1 -# self._speedbuffer = [] -# self._velocity = -1 -# self._current_position -= 0.3 -# -# def scroll_right(self) -> None: -# """ -# Call when the user wants to scroll right by discrete action (eg. button -# press). -# """ -# # self._target_position += 1 -# self._speedbuffer = [] -# self._velocity = 1 -# self._current_position += 0.3 -# -# def think(self, ins: InputState, delta_ms: int) -> None: -# # super().think(ins, delta_ms) -# -# self._petal.think(ins, delta_ms) -# -# if self._ignore: -# self._ignore -= 1 -# return -# -# dphi = self._petal._input._dphi -# phase = self._petal._input.phase() -# -# self._speedbuffer.append(self._velocity) -# -# while len(self._speedbuffer) > 3: -# self._speedbuffer.pop(0) -# -# speed = sum(self._speedbuffer) / len(self._speedbuffer) -# -# speed = min(speed, 0.008) -# speed = max(speed, -0.008) -# -# if phase == self._petal._input.ENDED: -# # self._speedbuffer = [0 if abs(speed) < 0.005 else speed] -# while len(self._speedbuffer) > 3: -# print("sb:", self._speedbuffer) -# self._speedbuffer.pop() -# elif phase == self._petal._input.UP: -# pass -# elif phase == self._petal._input.BEGIN: -# self._ignore = 5 -# # self._speedbuffer = [0.0] -# elif phase == self._petal._input.RESTING: -# self._speedbuffer.append(0.0) -# elif phase == self._petal._input.MOVED: -# impulse = -dphi / delta_ms -# self._speedbuffer.append(impulse) -# -# if abs(speed) < 0.0001: -# speed = 0 -# -# self._velocity = speed -# -# self._current_position = self._current_position + self._velocity * delta_ms -# -# if self.wrap: -# self._current_position = self._current_position % self._nitems -# elif round(self._current_position) < 0: -# self._current_position = 0 -# elif round(self._current_position) >= self._nitems: -# self._current_position = self._nitems - 1 -# -# if phase != self._petal._input.UP: -# return -# -# pos = round(self._current_position) -# microstep = round(self._current_position) - self._current_position -# # print("micro:", microstep) -# # print("v", self._velocity) -# # print("pos", self._current_position) -# -# if ( -# abs(microstep) < 0.1 -# and abs(self._velocity) < 0.001 -# # and abs(self._velocity) -# ): -# self._velocity = 0 -# self._speedbuffer.append(0) -# self._current_position = round(self._current_position) -# self._target_position = self._current_position -# # print("LOCK") -# return -# -# if abs(self._velocity) > 0.001: -# self._speedbuffer.append(-self._velocity) -# # print("BREAKING") -# return -# -# if self._velocity >= 0 and microstep > 0: -# self._speedbuffer.append(max(self._velocity, 0.01) * microstep * 10) -# # print("1") -# elif self._velocity < 0 and microstep > 0: -# self._speedbuffer.append(-self._velocity) -# # print("2") -# elif self._velocity > 0 and microstep < 0: -# self._speedbuffer.append(-self._velocity * 0.5) -# # print("3") -# -# elif self._velocity <= 0 and microstep < 0: -# self._speedbuffer.append(min(self._velocity, -0.01) * abs(microstep) * 10) -# # print("4") -# +class CapScrollController: + """ + A Capacitive Touch based Scroll Controller. + + You can think of it as a virtual trackball controlled by a touch petal. It + has a current position (in arbitrary units, but as a tuple corresponding to + the two different axes of capacitive touch positions) and a + momentum/velocity vector. + + To use this, instantiate this in your application/responder, and call + update() on every think() with a Touchable as retrieved from an + InputController. The CapScrolController will then translate gestures + received from the InputController's Touchable into the motion of the scroll + mechanism. + + TODO(q3k): dynamic precision based on gesture magnitude/speed + TODO(q3k): notching into predefined positions, for use in menus + """ + + def __init__(self) -> None: + self.position = (0.0, 0.0) + self.momentum = (0.0, 0.0) + # Current timestamp. + self._ts = 0 + # Position when touch started, and time at which touch started. + self._grab_start: Optional[Tuple[float, float]] = None + self._grab_start_ms: Optional[int] = None + + def update(self, t: Touchable, delta_ms: int) -> None: + """ + Call this in your think() method. + """ + self._ts += delta_ms + if t.phase() == t.BEGIN: + self._grab_start = self.position + self._grab_start_ms = self._ts + self.momentum = (0.0, 0.0) + + if t.phase() == t.MOVED and self._grab_start is not None: + move = t.current_gesture() + assert move is not None + assert self._grab_start is not None + drad, dphi = move.distance + drad /= 1000 + dphi /= 1000 + srad = self._grab_start[0] + sphi = self._grab_start[1] + self.position = (srad + drad, sphi + dphi) + + if t.phase() == t.ENDED: + move = t.current_gesture() + assert move is not None + vrad, vphi = move.velocity + vrad /= 1000 + vphi /= 1000 + self.momentum = (vrad, vphi) + + if t.phase() == t.UP: + rad_p, phi_p = self.position + rad_m, phi_m = self.momentum + rad_p += rad_m / (1000 / delta_ms) + phi_p += phi_m / (1000 / delta_ms) + rad_m *= 0.99 + phi_m *= 0.99 + self.momentum = (rad_m, phi_m) + self.position = (rad_p, phi_p)