Some clean up and added interactable images.
All checks were successful
Python CI / test (push) Successful in 6m34s

This commit is contained in:
Duncan Tourolle 2025-11-07 22:56:35 +01:00
parent 15305011dc
commit 49d4e551f8
7 changed files with 391 additions and 58 deletions

View File

@ -1,6 +1,7 @@
from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
from .block import HorizontalRule, Image from .block import HorizontalRule, Image
from .interactive_image import InteractiveImage
from .inline import Word, FormattedSpan, LineBreak from .inline import Word, FormattedSpan, LineBreak
from .document import Document, MetadataType, Chapter, Book from .document import Document, MetadataType, Chapter, Book
from .functional import Link, LinkType, Button, Form, FormField, FormFieldType from .functional import Link, LinkType, Button, Form, FormField, FormFieldType

View File

@ -61,18 +61,21 @@ class Link(Interactable):
"""Get the title/tooltip for this link""" """Get the title/tooltip for this link"""
return self._title return self._title
def execute(self) -> Any: def execute(self, point=None) -> Any:
""" """
Execute the link action based on its type. Execute the link action based on its type.
For internal and external links, returns the location. For internal and external links, returns the location.
For API and function links, executes the callback with the provided parameters. For API and function links, executes the callback with the provided parameters.
Args:
point: Optional interaction point passed from the interact() method
Returns: Returns:
The result of the link execution, which depends on the link type. The result of the link execution, which depends on the link type.
""" """
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback: if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **self._params) return self._callback(self._location, point, **self._params)
else: else:
# For INTERNAL and EXTERNAL links, return the location # For INTERNAL and EXTERNAL links, return the location
# The renderer/browser will handle the navigation # The renderer/browser will handle the navigation
@ -129,15 +132,18 @@ class Button(Interactable):
"""Get the button parameters""" """Get the button parameters"""
return self._params return self._params
def execute(self) -> Any: def execute(self, point=None) -> Any:
""" """
Execute the button's callback function if the button is enabled. Execute the button's callback function if the button is enabled.
Args:
point: Optional interaction point passed from the interact() method
Returns: Returns:
The result of the callback function, or None if the button is disabled. The result of the callback function, or None if the button is disabled.
""" """
if self._enabled and self._callback: if self._enabled and self._callback:
return self._callback(**self._params) return self._callback(point, **self._params)
return None return None

View File

@ -0,0 +1,154 @@
"""
Interactive and queryable image for pyWebLayout.
Provides an InteractiveImage class that combines Image with Interactable
and Queriable capabilities, allowing images to respond to tap events with
proper bounding box detection.
"""
from typing import Optional, Callable, Tuple
import numpy as np
from .block import Image, BlockType
from ..core.base import Interactable, Queriable
class InteractiveImage(Image, Interactable, Queriable):
"""
An image that can be interacted with and queried for hit detection.
This combines pyWebLayout's Image block with Interactable and Queriable
capabilities, allowing the image to:
- Have a callback that fires when tapped
- Know its rendered position (origin)
- Detect if a point is within its bounds
Example:
>>> img = InteractiveImage(
... source="cover.png",
... alt_text="Book Title",
... callback=lambda point: "/path/to/book.epub"
... )
>>> # After rendering, origin is set automatically
>>> # Check if tap is inside
>>> result = img.interact((120, 250))
>>> # Returns "/path/to/book.epub" if inside, None if outside
"""
def __init__(
self,
source: str = "",
alt_text: str = "",
width: Optional[int] = None,
height: Optional[int] = None,
callback: Optional[Callable] = None
):
"""
Initialize an interactive image.
Args:
source: The image source URL or path
alt_text: Alternative text for accessibility
width: Optional image width in pixels
height: Optional image height in pixels
callback: Function to call when image is tapped (receives point coordinates)
"""
# Initialize Image
Image.__init__(self, source=source, alt_text=alt_text, width=width, height=height)
# Initialize Interactable
Interactable.__init__(self, callback=callback)
# Initialize position tracking
self._origin = np.array([0, 0]) # Will be set during rendering
self.size = (width or 0, height or 0) # Will be updated during rendering
def interact(self, point: np.generic) -> Optional[any]:
"""
Handle interaction at the given point.
Only triggers the callback if the point is within the image bounds.
Args:
point: The coordinates of the interaction (x, y)
Returns:
The result of the callback if point is inside, None otherwise
"""
# Check if point is inside this image
if self.in_object(point):
# Point is inside, trigger callback
if self._callback is not None:
return self._callback(point)
return None
def in_object(self, point: np.generic) -> bool:
"""
Check if a point is within the image bounds.
Args:
point: The coordinates to check (x, y)
Returns:
True if point is inside the image, False otherwise
"""
point_array = np.array(point)
relative_point = point_array - self._origin
return np.all((0 <= relative_point) & (relative_point < self.size))
@classmethod
def create_and_add_to(
cls,
parent,
source: str,
alt_text: str = "",
width: Optional[int] = None,
height: Optional[int] = None,
callback: Optional[Callable] = None
) -> 'InteractiveImage':
"""
Create an interactive image and add it to a parent block.
This is a convenience method that mimics the Image.create_and_add_to API
but creates an InteractiveImage instead.
Args:
parent: Parent block to add this image to
source: The image source URL or path
alt_text: Alternative text for accessibility
width: Optional image width in pixels
height: Optional image height in pixels
callback: Function to call when image is tapped
Returns:
The created InteractiveImage instance
"""
img = cls(
source=source,
alt_text=alt_text,
width=width,
height=height,
callback=callback
)
# Add to parent's children
if hasattr(parent, 'add_child'):
parent.add_child(img)
elif hasattr(parent, '_children'):
parent._children.append(img)
return img
def set_rendered_bounds(self, origin: Tuple[int, int], size: Tuple[int, int]):
"""
Set the rendered position and size of this image.
This should be called by the renderer after it places the image.
Args:
origin: (x, y) coordinates of top-left corner
size: (width, height) of the rendered image
"""
self._origin = np.array(origin)
self.size = size

View File

@ -62,21 +62,7 @@ class LinkText(Text, Interactable, Queriable):
"""Set the hover state for visual feedback""" """Set the hover state for visual feedback"""
self._hovered = hovered self._hovered = hovered
def interact(self, point: np.generic):
"""
Handle interaction at the given point.
Override to call the callback without passing the point.
Args:
point: The coordinates of the interaction
Returns:
The result of calling the callback function
"""
if self._callback is None:
return None
return self._callback() # Don't pass the point to the callback
def render(self, next_text: Optional['Text'] = None, spacing: int = 0): def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
""" """
Render the link text with optional hover effects. Render the link text with optional hover effects.
@ -165,21 +151,7 @@ class ButtonText(Text, Interactable, Queriable):
"""Set the hover state""" """Set the hover state"""
self._hovered = hovered self._hovered = hovered
def interact(self, point: np.generic):
"""
Handle interaction at the given point.
Override to call the callback without passing the point.
Args:
point: The coordinates of the interaction
Returns:
The result of calling the callback function
"""
if self._callback is None:
return None
return self._callback() # Don't pass the point to the callback
def render(self): def render(self):
""" """
Render the button with background, border, and text. Render the button with background, border, and text.

View File

@ -88,11 +88,11 @@ class TestLink(unittest.TestCase):
callback=self.mock_callback, callback=self.mock_callback,
params=params params=params
) )
result = link.execute() result = link.execute()
# Should call callback with location and params # Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("/api/save", action="save", id=123) self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123)
self.assertEqual(result, "callback_result") self.assertEqual(result, "callback_result")
def test_function_link_execution(self): def test_function_link_execution(self):
@ -104,11 +104,11 @@ class TestLink(unittest.TestCase):
callback=self.mock_callback, callback=self.mock_callback,
params=params params=params
) )
result = link.execute() result = link.execute()
# Should call callback with location and params # Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("save_document", data="test") self.mock_callback.assert_called_once_with("save_document", None, data="test")
self.assertEqual(result, "callback_result") self.assertEqual(result, "callback_result")
def test_api_link_without_callback(self): def test_api_link_without_callback(self):
@ -204,11 +204,11 @@ class TestButton(unittest.TestCase):
"""Test executing enabled button.""" """Test executing enabled button."""
params = {"data": "test_data"} params = {"data": "test_data"}
button = Button("Test", self.mock_callback, params=params, enabled=True) button = Button("Test", self.mock_callback, params=params, enabled=True)
result = button.execute() result = button.execute()
# Should call callback with params # Should call callback with point (None when not provided) and params
self.mock_callback.assert_called_once_with(data="test_data") self.mock_callback.assert_called_once_with(None, data="test_data")
self.assertEqual(result, "button_clicked") self.assertEqual(result, "button_clicked")
def test_button_execute_disabled(self): def test_button_execute_disabled(self):

View File

@ -442,28 +442,37 @@ class TestInteractionCallbacks(unittest.TestCase):
self.font = Font(font_size=12, colour=(0, 0, 0)) self.font = Font(font_size=12, colour=(0, 0, 0))
self.mock_draw = Mock() self.mock_draw = Mock()
self.callback_result = "callback_executed" self.callback_result = "callback_executed"
self.callback = Mock(return_value=self.callback_result)
# Link callback: receives (location, point, **params)
def link_callback(location, point, **params):
return "callback_executed"
self.link_callback = link_callback
# Button callback: receives (point, **params)
def button_callback(point, **params):
return "callback_executed"
self.button_callback = button_callback
def test_link_text_interaction(self): def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction""" """Test that LinkText properly handles interaction"""
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location # Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
link = Link("test_function", LinkType.FUNCTION, self.callback) link = Link("test_function", LinkType.FUNCTION, self.link_callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw) renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
# Simulate interaction # Simulate interaction
result = renderable.interact(np.array([10, 10])) result = renderable.interact(np.array([10, 10]))
# Should execute the link's callback # Should execute the link's callback
self.assertEqual(result, self.callback_result) self.assertEqual(result, self.callback_result)
def test_button_text_interaction(self): def test_button_text_interaction(self):
"""Test that ButtonText properly handles interaction""" """Test that ButtonText properly handles interaction"""
button = Button("Test Button", self.callback) button = Button("Test Button", self.button_callback)
renderable = ButtonText(button, self.font, self.mock_draw) renderable = ButtonText(button, self.font, self.mock_draw)
# Simulate interaction # Simulate interaction
result = renderable.interact(np.array([10, 10])) result = renderable.interact(np.array([10, 10]))
# Should execute the button's callback # Should execute the button's callback
self.assertEqual(result, self.callback_result) self.assertEqual(result, self.callback_result)

View File

@ -0,0 +1,191 @@
"""
Unit tests for InteractiveImage functionality.
These tests verify that InteractiveImage can properly detect taps
and trigger callbacks when used in rendered content.
"""
import unittest
import tempfile
from pathlib import Path
from PIL import Image as PILImage
import numpy as np
from pyWebLayout.abstract.interactive_image import InteractiveImage
class TestInteractiveImage(unittest.TestCase):
"""Test InteractiveImage interaction and bounds detection"""
def setUp(self):
"""Create a temporary test image"""
self.temp_dir = tempfile.mkdtemp()
self.test_image_path = Path(self.temp_dir) / "test.png"
# Create a simple test image
img = PILImage.new('RGB', (100, 100), color='red')
img.save(self.test_image_path)
def test_create_interactive_image(self):
"""Test that InteractiveImage can be created"""
callback_called = []
def callback(point):
callback_called.append(point)
return "clicked!"
img = InteractiveImage(
source=str(self.test_image_path),
alt_text="Test Image",
callback=callback
)
self.assertIsNotNone(img)
self.assertEqual(img._source, str(self.test_image_path))
self.assertEqual(img._alt_text, "Test Image")
self.assertIsNotNone(img._callback)
def test_interact_with_bounds_set(self):
"""Test that interaction works when bounds are properly set"""
callback_result = []
def callback(point):
callback_result.append("Book selected!")
return "/path/to/book.epub"
img = InteractiveImage(
source=str(self.test_image_path),
alt_text="Test Image",
width=100,
height=100,
callback=callback
)
# Simulate what a renderer would do: set the bounds
img.set_rendered_bounds(origin=(50, 50), size=(100, 100))
# Tap inside the image bounds
result = img.interact((75, 75))
# Should trigger callback and return result
self.assertEqual(result, "/path/to/book.epub")
self.assertEqual(len(callback_result), 1)
def test_interact_outside_bounds(self):
"""Test that interaction fails when point is outside bounds"""
callback_called = []
def callback(point):
callback_called.append(True)
return "clicked!"
img = InteractiveImage(
source=str(self.test_image_path),
alt_text="Test Image",
width=100,
height=100,
callback=callback
)
# Set bounds: image at (50, 50) with size (100, 100)
img.set_rendered_bounds(origin=(50, 50), size=(100, 100))
# Tap outside the image bounds
result = img.interact((25, 25)) # Above and left of image
# Should NOT trigger callback
self.assertIsNone(result)
self.assertEqual(len(callback_called), 0)
def test_in_object_detection(self):
"""Test that in_object correctly detects points inside/outside bounds"""
img = InteractiveImage(
source=str(self.test_image_path),
width=100,
height=100
)
# Set bounds: image at (100, 200) with size (100, 100)
# So it occupies x: 100-200, y: 200-300
img.set_rendered_bounds(origin=(100, 200), size=(100, 100))
# Test points inside
self.assertTrue(img.in_object((100, 200))) # Top-left corner
self.assertTrue(img.in_object((150, 250))) # Center
self.assertTrue(img.in_object((199, 299))) # Bottom-right (just inside)
# Test points outside
self.assertFalse(img.in_object((99, 200))) # Just left
self.assertFalse(img.in_object((100, 199))) # Just above
self.assertFalse(img.in_object((200, 200))) # Just right
self.assertFalse(img.in_object((100, 300))) # Just below
self.assertFalse(img.in_object((50, 50))) # Far away
def test_create_and_add_to(self):
"""Test the convenience factory method"""
callback_result = []
def callback(point):
return "added!"
# Create a mock parent with children list
class MockParent:
def __init__(self):
self._children = []
parent = MockParent()
img = InteractiveImage.create_and_add_to(
parent,
source=str(self.test_image_path),
alt_text="Test",
callback=callback
)
# Should be added to parent's children
self.assertIn(img, parent._children)
self.assertIsInstance(img, InteractiveImage)
def test_no_callback_returns_none(self):
"""Test that interact returns None when no callback is set"""
img = InteractiveImage(
source=str(self.test_image_path),
width=100,
height=100,
callback=None # No callback
)
img.set_rendered_bounds(origin=(0, 0), size=(100, 100))
# Tap inside bounds
result = img.interact((50, 50))
# Should return None (no callback to call)
self.assertIsNone(result)
def test_multiple_images_independent_bounds(self):
"""Test that multiple InteractiveImages have independent bounds"""
def callback1(point):
return "image1"
def callback2(point):
return "image2"
img1 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback1)
img2 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback2)
# Set different bounds
img1.set_rendered_bounds(origin=(0, 0), size=(50, 50))
img2.set_rendered_bounds(origin=(100, 100), size=(50, 50))
# Tap in img1's bounds
self.assertEqual(img1.interact((25, 25)), "image1")
self.assertIsNone(img2.interact((25, 25)))
# Tap in img2's bounds
self.assertIsNone(img1.interact((125, 125)))
self.assertEqual(img2.interact((125, 125)), "image2")
if __name__ == '__main__':
unittest.main()