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 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 13:20:40 +02:00
co-authored by Claude Opus 5
parent 8746d3f549
commit 0ce1aeaa87
3 changed files with 210 additions and 9 deletions
+129
View File
@@ -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(
'<p>Tap <a href="action:go">this link</a> please.</p>'
'<p>' + " ".join(f"w{i}" for i in range(400)) + '</p>')
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)