diff --git a/dreader/gesture.py b/dreader/gesture.py new file mode 100644 index 0000000..70c98f1 --- /dev/null +++ b/dreader/gesture.py @@ -0,0 +1,127 @@ +""" +Gesture event types for touch input. + +This module defines touch gestures that can be received from a HAL (Hardware Abstraction Layer) +or touch input system, and the response format for actions to be performed. +""" + +from __future__ import annotations +from enum import Enum +from dataclasses import dataclass +from typing import Optional, Dict, Any + + +class GestureType(Enum): + """Touch gesture types from HAL""" + TAP = "tap" # Single finger tap + LONG_PRESS = "long_press" # Hold for 500ms+ + SWIPE_LEFT = "swipe_left" # Swipe left (page forward) + SWIPE_RIGHT = "swipe_right" # Swipe right (page back) + SWIPE_UP = "swipe_up" # Swipe up (scroll down) + SWIPE_DOWN = "swipe_down" # Swipe down (scroll up) + PINCH_IN = "pinch_in" # Pinch fingers together (zoom out) + PINCH_OUT = "pinch_out" # Spread fingers apart (zoom in) + DRAG_START = "drag_start" # Start dragging/selection + DRAG_MOVE = "drag_move" # Continue dragging + DRAG_END = "drag_end" # End dragging/selection + + +@dataclass +class TouchEvent: + """ + Touch event from HAL. + + Represents a single touch gesture with its coordinates and metadata. + """ + gesture: GestureType + x: int # Primary touch point X coordinate + y: int # Primary touch point Y coordinate + x2: Optional[int] = None # Secondary point X (for pinch/drag) + y2: Optional[int] = None # Secondary point Y (for pinch/drag) + timestamp_ms: float = 0 # Timestamp in milliseconds + + @classmethod + def from_hal(cls, hal_data: dict) -> 'TouchEvent': + """ + Parse a touch event from HAL format. + + Args: + hal_data: Dictionary with gesture data from HAL + Expected keys: 'gesture', 'x', 'y', optionally 'x2', 'y2', 'timestamp' + + Returns: + TouchEvent instance + + Example: + >>> event = TouchEvent.from_hal({ + ... 'gesture': 'tap', + ... 'x': 450, + ... 'y': 320 + ... }) + """ + return cls( + gesture=GestureType(hal_data['gesture']), + x=hal_data['x'], + y=hal_data['y'], + x2=hal_data.get('x2'), + y2=hal_data.get('y2'), + timestamp_ms=hal_data.get('timestamp', 0) + ) + + def to_dict(self) -> dict: + """Convert to dictionary for serialization""" + return { + 'gesture': self.gesture.value, + 'x': self.x, + 'y': self.y, + 'x2': self.x2, + 'y2': self.y2, + 'timestamp_ms': self.timestamp_ms + } + + +@dataclass +class GestureResponse: + """ + Response from handling a gesture. + + This encapsulates the action that should be performed by the UI + in response to a gesture, keeping all business logic in the library. + """ + action: str # Action type: "navigate", "define", "select", "zoom", "page_turn", "none", etc. + data: Dict[str, Any] # Action-specific data + + def to_dict(self) -> dict: + """ + Convert to dictionary for Flask JSON response. + + Returns: + Dictionary with action and data + """ + return { + 'action': self.action, + 'data': self.data + } + + +# Action type constants for clarity +class ActionType: + """Constants for gesture response action types""" + NONE = "none" + PAGE_TURN = "page_turn" + NAVIGATE = "navigate" + DEFINE = "define" + SELECT = "select" + ZOOM = "zoom" + BOOK_LOADED = "book_loaded" + WORD_SELECTED = "word_selected" + SHOW_MENU = "show_menu" + SELECTION_START = "selection_start" + SELECTION_UPDATE = "selection_update" + SELECTION_COMPLETE = "selection_complete" + AT_START = "at_start" + AT_END = "at_end" + ERROR = "error" + OVERLAY_OPENED = "overlay_opened" + OVERLAY_CLOSED = "overlay_closed" + CHAPTER_SELECTED = "chapter_selected" diff --git a/tests/test_gesture.py b/tests/test_gesture.py new file mode 100644 index 0000000..a50fbcb --- /dev/null +++ b/tests/test_gesture.py @@ -0,0 +1,287 @@ +""" +Unit tests for gesture event system. + +Tests TouchEvent, GestureType, GestureResponse, and HAL integration. +""" + +import unittest +from dreader.gesture import ( + GestureType, + TouchEvent, + GestureResponse, + ActionType +) + + +class TestGestureType(unittest.TestCase): + """Test GestureType enum""" + + def test_gesture_types_exist(self): + """Test all gesture types are defined""" + self.assertEqual(GestureType.TAP.value, "tap") + self.assertEqual(GestureType.LONG_PRESS.value, "long_press") + self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left") + self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right") + self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up") + self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down") + self.assertEqual(GestureType.PINCH_IN.value, "pinch_in") + self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out") + self.assertEqual(GestureType.DRAG_START.value, "drag_start") + self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move") + self.assertEqual(GestureType.DRAG_END.value, "drag_end") + + +class TestTouchEvent(unittest.TestCase): + """Test TouchEvent dataclass""" + + def test_init_basic(self): + """Test basic TouchEvent creation""" + event = TouchEvent( + gesture=GestureType.TAP, + x=450, + y=320 + ) + + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + self.assertIsNone(event.x2) + self.assertIsNone(event.y2) + self.assertEqual(event.timestamp_ms, 0) + + def test_init_with_secondary_point(self): + """Test TouchEvent with secondary point (pinch/drag)""" + event = TouchEvent( + gesture=GestureType.PINCH_OUT, + x=400, + y=300, + x2=450, + y2=350, + timestamp_ms=12345.678 + ) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.y, 300) + self.assertEqual(event.x2, 450) + self.assertEqual(event.y2, 350) + self.assertEqual(event.timestamp_ms, 12345.678) + + def test_from_hal_basic(self): + """Test parsing TouchEvent from HAL format""" + hal_data = { + 'gesture': 'tap', + 'x': 450, + 'y': 320 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + + def test_from_hal_complete(self): + """Test parsing TouchEvent with all fields from HAL""" + hal_data = { + 'gesture': 'pinch_out', + 'x': 400, + 'y': 300, + 'x2': 450, + 'y2': 350, + 'timestamp': 12345.678 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.y, 300) + self.assertEqual(event.x2, 450) + self.assertEqual(event.y2, 350) + self.assertEqual(event.timestamp_ms, 12345.678) + + def test_to_dict(self): + """Test TouchEvent serialization""" + event = TouchEvent( + gesture=GestureType.SWIPE_LEFT, + x=600, + y=400, + timestamp_ms=12345.0 + ) + + d = event.to_dict() + + self.assertEqual(d['gesture'], 'swipe_left') + self.assertEqual(d['x'], 600) + self.assertEqual(d['y'], 400) + self.assertIsNone(d['x2']) + self.assertIsNone(d['y2']) + self.assertEqual(d['timestamp_ms'], 12345.0) + + +class TestGestureResponse(unittest.TestCase): + """Test GestureResponse dataclass""" + + def test_init(self): + """Test GestureResponse creation""" + response = GestureResponse( + action="page_turn", + data={"direction": "forward", "progress": 0.42} + ) + + self.assertEqual(response.action, "page_turn") + self.assertEqual(response.data['direction'], "forward") + self.assertEqual(response.data['progress'], 0.42) + + def test_to_dict(self): + """Test GestureResponse serialization""" + response = GestureResponse( + action="define", + data={"word": "ephemeral", "bounds": (100, 200, 50, 20)} + ) + + d = response.to_dict() + + self.assertEqual(d['action'], "define") + self.assertEqual(d['data']['word'], "ephemeral") + self.assertEqual(d['data']['bounds'], (100, 200, 50, 20)) + + def test_to_dict_empty_data(self): + """Test GestureResponse with empty data""" + response = GestureResponse(action="none", data={}) + + d = response.to_dict() + + self.assertEqual(d['action'], "none") + self.assertEqual(d['data'], {}) + + +class TestActionType(unittest.TestCase): + """Test ActionType constants""" + + def test_action_types_defined(self): + """Test all action type constants are defined""" + self.assertEqual(ActionType.NONE, "none") + self.assertEqual(ActionType.PAGE_TURN, "page_turn") + self.assertEqual(ActionType.NAVIGATE, "navigate") + self.assertEqual(ActionType.DEFINE, "define") + self.assertEqual(ActionType.SELECT, "select") + self.assertEqual(ActionType.ZOOM, "zoom") + self.assertEqual(ActionType.BOOK_LOADED, "book_loaded") + self.assertEqual(ActionType.WORD_SELECTED, "word_selected") + self.assertEqual(ActionType.SHOW_MENU, "show_menu") + self.assertEqual(ActionType.SELECTION_START, "selection_start") + self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update") + self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete") + self.assertEqual(ActionType.AT_START, "at_start") + self.assertEqual(ActionType.AT_END, "at_end") + self.assertEqual(ActionType.ERROR, "error") + + +class TestHALIntegration(unittest.TestCase): + """Test HAL integration scenarios""" + + def test_hal_tap_flow(self): + """Test complete HAL tap event flow""" + # Simulate HAL sending tap event + hal_data = { + 'gesture': 'tap', + 'x': 450, + 'y': 320, + 'timestamp': 1234567890.123 + } + + # Parse event + event = TouchEvent.from_hal(hal_data) + + # Verify event + self.assertEqual(event.gesture, GestureType.TAP) + self.assertEqual(event.x, 450) + self.assertEqual(event.y, 320) + + # Simulate business logic response + response = GestureResponse( + action=ActionType.WORD_SELECTED, + data={"word": "hello", "bounds": (440, 310, 50, 20)} + ) + + # Serialize for Flask + response_dict = response.to_dict() + + self.assertEqual(response_dict['action'], "word_selected") + self.assertEqual(response_dict['data']['word'], "hello") + + def test_hal_pinch_flow(self): + """Test complete HAL pinch event flow""" + # Simulate HAL sending pinch event with two touch points + hal_data = { + 'gesture': 'pinch_out', + 'x': 400, + 'y': 500, + 'x2': 500, + 'y2': 500, + 'timestamp': 1234567891.456 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.PINCH_OUT) + self.assertEqual(event.x, 400) + self.assertEqual(event.x2, 500) + + def test_hal_swipe_flow(self): + """Test complete HAL swipe event flow""" + hal_data = { + 'gesture': 'swipe_left', + 'x': 600, + 'y': 400 + } + + event = TouchEvent.from_hal(hal_data) + + self.assertEqual(event.gesture, GestureType.SWIPE_LEFT) + + # Expected response + response = GestureResponse( + action=ActionType.PAGE_TURN, + data={"direction": "forward", "progress": 0.25} + ) + + self.assertEqual(response.action, "page_turn") + + def test_hal_drag_selection_flow(self): + """Test complete drag selection flow""" + # Drag start + start_data = { + 'gesture': 'drag_start', + 'x': 100, + 'y': 200 + } + + start_event = TouchEvent.from_hal(start_data) + self.assertEqual(start_event.gesture, GestureType.DRAG_START) + + # Drag move + move_data = { + 'gesture': 'drag_move', + 'x': 300, + 'y': 250 + } + + move_event = TouchEvent.from_hal(move_data) + self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE) + + # Drag end + end_data = { + 'gesture': 'drag_end', + 'x': 500, + 'y': 300 + } + + end_event = TouchEvent.from_hal(end_data) + self.assertEqual(end_event.gesture, GestureType.DRAG_END) + + +if __name__ == '__main__': + unittest.main()