diff --git a/pyWebLayout/concrete/functional.py b/pyWebLayout/concrete/functional.py index a2eb920..639d9af 100644 --- a/pyWebLayout/concrete/functional.py +++ b/pyWebLayout/concrete/functional.py @@ -99,15 +99,20 @@ class LinkText(Text, Interactable, Queriable): self._origin, np.ndarray) else self._origin - # Draw background based on state (before text is rendered) - if self._pressed: - # Pressed state - stronger, darker highlight - bg_color = (180, 180, 255, 180) # Stronger blue with more opacity - self._draw.rectangle([origin, origin + size], fill=bg_color) - elif self._hovered: - # Hover state - subtle highlight - bg_color = (220, 220, 255, 100) # Light blue with alpha - self._draw.rectangle([origin, origin + size], fill=bg_color) + # Draw background based on state (before text is rendered). + # PIL wants a flat sequence of four scalars; handing it a list of two + # numpy arrays raises "coordinate list must contain exactly 2 + # coordinates". + if self._pressed or self._hovered: + far = origin + size + box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1])) + if self._pressed: + # Pressed state - stronger, darker highlight + bg_color = (180, 180, 255, 180) + else: + # Hover state - subtle highlight + bg_color = (220, 220, 255, 100) + self._draw.rectangle(box, fill=bg_color) # Call the parent Text render method with parameters super().render(next_text, spacing) diff --git a/pyWebLayout/layout/ereader_manager.py b/pyWebLayout/layout/ereader_manager.py index c6f63ea..ae368b2 100644 --- a/pyWebLayout/layout/ereader_manager.py +++ b/pyWebLayout/layout/ereader_manager.py @@ -21,6 +21,8 @@ from pyWebLayout.layout.document_layouter import image_layouter from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \ create_highlight_from_query_result from pyWebLayout.core.persistence import ensure_dir, read_json, write_json +from pyWebLayout.concrete.interaction_handler import InteractionStateManager +from PIL import Image as Image_ logger = logging.getLogger(__name__) @@ -212,6 +214,10 @@ class EreaderLayoutManager: self.current_position = saved_position self._on_cover_page = False # If we have a saved position, we're past the cover + # Pointer interaction state, rebound whenever the displayed page changes + self._interaction_state_manager: Optional[InteractionStateManager] = None + self._interaction_page: Optional[Page] = None + # Callbacks for UI updates self.position_changed_callback: Optional[Callable[[ RenderingPosition], None]] = None @@ -942,6 +948,67 @@ class EreaderLayoutManager: """Remove every highlight in this document.""" self.highlight_manager.clear_all() + # ------------------------------------------------------------------ + # Pointer interaction + # + # Press/hover feedback is state that belongs to one rendered page, so the + # state machine is rebound whenever the displayed page changes. Callers get + # a fresh frame back when something changed visually, and None when nothing + # did - so a UI can skip a redraw it does not need. + # ------------------------------------------------------------------ + + def _interaction_state(self) -> InteractionStateManager: + """The state machine for the page currently displayed.""" + page = self.get_current_page() + if self._interaction_page is not page: + if self._interaction_state_manager is not None: + self._interaction_state_manager.reset() + self._interaction_state_manager = InteractionStateManager(page) + self._interaction_page = page + return self._interaction_state_manager + + def handle_hover(self, point: Tuple[int, int]) -> Optional[Image_.Image]: + """ + Update hover feedback for a pointer at `point`. + + Args: + point: (x, y) in page coordinates + + Returns: + A re-rendered frame if the hover state changed, else None. + """ + return self._interaction_state().update_hover(point) + + def handle_touch_down(self, point: Tuple[int, int]) -> Optional[Image_.Image]: + """ + Show pressed feedback for whatever interactive element is at `point`. + + Args: + point: (x, y) in page coordinates + + Returns: + A frame showing the pressed state, or None if nothing interactive + is there. + """ + return self._interaction_state().handle_mouse_down(point) + + def handle_touch_up(self, point: Tuple[int, int]) -> Tuple[Optional[Image_.Image], Any]: + """ + Release the pressed element and run its action. + + Args: + point: (x, y) in page coordinates + + Returns: + (frame, callback_result). Both are None if no element was pressed. + """ + return self._interaction_state().handle_mouse_up(point) + + def reset_interaction_state(self) -> None: + """Clear any hover or press feedback, e.g. when the pointer leaves.""" + if self._interaction_state_manager is not None: + self._interaction_state_manager.reset() + def get_reading_progress(self) -> float: """ Get reading progress as a percentage. diff --git a/tests/layout/test_ereader_interaction.py b/tests/layout/test_ereader_interaction.py new file mode 100644 index 0000000..c7a8274 --- /dev/null +++ b/tests/layout/test_ereader_interaction.py @@ -0,0 +1,129 @@ +""" +Tests for pointer interaction on EreaderLayoutManager (R7). + +concrete/interaction_handler.py was 310 lines reachable only from +examples/07_pressed_state_demo.py - no library code, no tests. These cover the +wiring; the press/hover state on the elements themselves lives in +tests/concrete/. +""" + +import pytest +from PIL import Image + +from pyWebLayout.io.readers.html_extraction import parse_html_string +from pyWebLayout.layout.ereader_manager import EreaderLayoutManager + + +@pytest.fixture +def manager(tmp_path): + blocks = parse_html_string( + '

Tap this link please.

' + '

' + " ".join(f"w{i}" for i in range(400)) + '

') + manager = EreaderLayoutManager(blocks, page_size=(400, 600), + document_id="interaction", + bookmarks_dir=str(tmp_path)) + yield manager + manager.shutdown() + + +@pytest.fixture +def link_point(manager): + """A page coordinate that lands on the interactive link.""" + page = manager.get_current_page() + page.render() + for y in range(0, 120, 2): + for x in range(0, 400, 2): + result = page.query_point((x, y)) + if result is not None and result.is_interactive: + return (x, y) + pytest.fail("fixture document rendered no interactive element") + + +EMPTY_POINT = (399, 599) + + +class TestHover: + def test_hovering_an_element_produces_a_frame(self, manager, link_point): + assert isinstance(manager.handle_hover(link_point), Image.Image) + + def test_hovering_the_same_element_again_reports_no_change(self, manager, link_point): + manager.handle_hover(link_point) + + assert manager.handle_hover(link_point) is None, \ + "an unchanged hover should not force the caller to redraw" + + def test_moving_off_the_element_clears_the_hover(self, manager, link_point): + manager.handle_hover(link_point) + + assert isinstance(manager.handle_hover(EMPTY_POINT), Image.Image) + + +class TestPress: + def test_pressing_an_element_produces_a_frame(self, manager, link_point): + assert isinstance(manager.handle_touch_down(link_point), Image.Image) + + def test_pressing_empty_space_does_nothing(self, manager): + manager.get_current_page().render() + + assert manager.handle_touch_down(EMPTY_POINT) is None + + def test_release_runs_the_link_action(self, manager, link_point): + manager.handle_touch_down(link_point) + + frame, result = manager.handle_touch_up(link_point) + + assert isinstance(frame, Image.Image) + assert result == "action:go" + + def test_release_without_a_press_is_a_no_op(self, manager): + manager.get_current_page().render() + + assert manager.handle_touch_up(EMPTY_POINT) == (None, None) + + def test_a_full_press_release_cycle_leaves_no_state(self, manager, link_point): + manager.handle_touch_down(link_point) + manager.handle_touch_up(link_point) + + assert manager.handle_touch_up(link_point) == (None, None) + + +class TestStateFollowsTheDisplayedPage: + def test_navigating_rebinds_the_state_machine(self, manager, link_point): + before = manager._interaction_state() + + manager.next_page() + + assert manager._interaction_state() is not before, \ + "press state belongs to one rendered page" + + def test_state_survives_repeated_access_on_one_page(self, manager, link_point): + assert manager._interaction_state() is manager._interaction_state() + + def test_reset_is_safe_before_any_interaction(self, manager): + manager.reset_interaction_state() # must not raise + + def test_reset_clears_a_pending_press(self, manager, link_point): + manager.handle_touch_down(link_point) + + manager.reset_interaction_state() + + assert manager.handle_touch_up(link_point) == (None, None) + + +class TestPressedRenderingRegression: + """ + LinkText.render passed [origin, origin + size] - two numpy arrays - to + PIL's draw.rectangle, which needs a flat four-scalar box. Rendering any + hovered or pressed link raised TypeError. Nothing caught it because the + only caller was an example. + """ + + def test_rendering_a_hovered_link_does_not_raise(self, manager, link_point): + manager.handle_hover(link_point) + + assert isinstance(manager.get_current_page().render(), Image.Image) + + def test_rendering_a_pressed_link_does_not_raise(self, manager, link_point): + manager.handle_touch_down(link_point) + + assert isinstance(manager.get_current_page().render(), Image.Image)