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:
@@ -99,15 +99,20 @@ class LinkText(Text, Interactable, Queriable):
|
|||||||
self._origin,
|
self._origin,
|
||||||
np.ndarray) else self._origin
|
np.ndarray) else self._origin
|
||||||
|
|
||||||
# Draw background based on state (before text is rendered)
|
# Draw background based on state (before text is rendered).
|
||||||
if self._pressed:
|
# PIL wants a flat sequence of four scalars; handing it a list of two
|
||||||
# Pressed state - stronger, darker highlight
|
# numpy arrays raises "coordinate list must contain exactly 2
|
||||||
bg_color = (180, 180, 255, 180) # Stronger blue with more opacity
|
# coordinates".
|
||||||
self._draw.rectangle([origin, origin + size], fill=bg_color)
|
if self._pressed or self._hovered:
|
||||||
elif self._hovered:
|
far = origin + size
|
||||||
# Hover state - subtle highlight
|
box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
|
||||||
bg_color = (220, 220, 255, 100) # Light blue with alpha
|
if self._pressed:
|
||||||
self._draw.rectangle([origin, origin + size], fill=bg_color)
|
# 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
|
# Call the parent Text render method with parameters
|
||||||
super().render(next_text, spacing)
|
super().render(next_text, spacing)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from pyWebLayout.layout.document_layouter import image_layouter
|
|||||||
from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \
|
from pyWebLayout.core.highlight import Highlight, HighlightColor, HighlightManager, \
|
||||||
create_highlight_from_query_result
|
create_highlight_from_query_result
|
||||||
from pyWebLayout.core.persistence import ensure_dir, read_json, write_json
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -212,6 +214,10 @@ class EreaderLayoutManager:
|
|||||||
self.current_position = saved_position
|
self.current_position = saved_position
|
||||||
self._on_cover_page = False # If we have a saved position, we're past the cover
|
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
|
# Callbacks for UI updates
|
||||||
self.position_changed_callback: Optional[Callable[[
|
self.position_changed_callback: Optional[Callable[[
|
||||||
RenderingPosition], None]] = None
|
RenderingPosition], None]] = None
|
||||||
@@ -942,6 +948,67 @@ class EreaderLayoutManager:
|
|||||||
"""Remove every highlight in this document."""
|
"""Remove every highlight in this document."""
|
||||||
self.highlight_manager.clear_all()
|
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:
|
def get_reading_progress(self) -> float:
|
||||||
"""
|
"""
|
||||||
Get reading progress as a percentage.
|
Get reading progress as a percentage.
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user