From 0ce1aeaa870ca0b8e884c013ec77956a3e6e1d00 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 8 Aug 2026 13:20:40 +0200 Subject: [PATCH] feat(ereader): wire pointer interaction into EreaderLayoutManager (R7) concrete/interaction_handler.py was 310 lines reachable only from examples/07_pressed_state_demo.py - no library code, no tests. Press and hover feedback existed but could not be used through the library's own interface. Adds to the manager: handle_hover(point) -> frame if hover changed, else None handle_touch_down(point) -> frame showing the pressed state handle_touch_up(point) -> (frame, callback result) reset_interaction_state() Returning None when nothing changed visually lets a UI skip a redraw it does not need - which matters on e-ink. Press state belongs to one rendered page, so the InteractionStateManager is bound lazily and rebound whenever the displayed page changes, resetting the outgoing one so a press cannot survive a page turn. Wiring it up immediately surfaced a real bug it had been hiding. LinkText.render passed [origin, origin + size] - a list of two numpy arrays - to PIL's draw.rectangle, which needs a flat four-scalar box. Rendering any hovered or pressed link raised TypeError: coordinate list must contain exactly 2 coordinates so the entire feature was broken on this PIL version. Fixed by building the box explicitly, and the two branches now share it instead of duplicating the call. Tests cover hover/press/release, no-op paths, that an unchanged hover reports no change, state rebinding across navigation, reset, and regressions for the rectangle crash. 916 passed. examples/07_pressed_state_demo.py still runs. Co-Authored-By: Claude Opus 5 --- pyWebLayout/concrete/functional.py | 23 ++-- pyWebLayout/layout/ereader_manager.py | 67 ++++++++++++ tests/layout/test_ereader_interaction.py | 129 +++++++++++++++++++++++ 3 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 tests/layout/test_ereader_interaction.py 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)