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)