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
+14 -9
View File
@@ -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)
+67
View File
@@ -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.
+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)