diff --git a/pyWebLayout/abstract/__init__.py b/pyWebLayout/abstract/__init__.py index 82cca42..946e964 100644 --- a/pyWebLayout/abstract/__init__.py +++ b/pyWebLayout/abstract/__init__.py @@ -1,6 +1,7 @@ from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell from .block import HorizontalRule, Image +from .interactive_image import InteractiveImage from .inline import Word, FormattedSpan, LineBreak from .document import Document, MetadataType, Chapter, Book from .functional import Link, LinkType, Button, Form, FormField, FormFieldType diff --git a/pyWebLayout/abstract/functional.py b/pyWebLayout/abstract/functional.py index 4fa47cc..679c14d 100644 --- a/pyWebLayout/abstract/functional.py +++ b/pyWebLayout/abstract/functional.py @@ -61,18 +61,21 @@ class Link(Interactable): """Get the title/tooltip for this link""" return self._title - def execute(self) -> Any: + def execute(self, point=None) -> Any: """ Execute the link action based on its type. - + For internal and external links, returns the location. For API and function links, executes the callback with the provided parameters. - + + Args: + point: Optional interaction point passed from the interact() method + Returns: The result of the link execution, which depends on the link type. """ 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: # For INTERNAL and EXTERNAL links, return the location # The renderer/browser will handle the navigation @@ -129,15 +132,18 @@ class Button(Interactable): """Get the button parameters""" return self._params - def execute(self) -> Any: + def execute(self, point=None) -> Any: """ Execute the button's callback function if the button is enabled. - + + Args: + point: Optional interaction point passed from the interact() method + Returns: The result of the callback function, or None if the button is disabled. """ if self._enabled and self._callback: - return self._callback(**self._params) + return self._callback(point, **self._params) return None diff --git a/pyWebLayout/abstract/interactive_image.py b/pyWebLayout/abstract/interactive_image.py new file mode 100644 index 0000000..76605b5 --- /dev/null +++ b/pyWebLayout/abstract/interactive_image.py @@ -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 diff --git a/pyWebLayout/concrete/functional.py b/pyWebLayout/concrete/functional.py index b95677d..0e0922a 100644 --- a/pyWebLayout/concrete/functional.py +++ b/pyWebLayout/concrete/functional.py @@ -62,21 +62,7 @@ class LinkText(Text, Interactable, Queriable): """Set the hover state for visual feedback""" 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): """ Render the link text with optional hover effects. @@ -165,21 +151,7 @@ class ButtonText(Text, Interactable, Queriable): """Set the hover state""" 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): """ Render the button with background, border, and text. diff --git a/tests/abstract/test_abstract_functional.py b/tests/abstract/test_abstract_functional.py index 3c41ec1..12cf039 100644 --- a/tests/abstract/test_abstract_functional.py +++ b/tests/abstract/test_abstract_functional.py @@ -88,11 +88,11 @@ class TestLink(unittest.TestCase): callback=self.mock_callback, params=params ) - + result = link.execute() - - # Should call callback with location and params - self.mock_callback.assert_called_once_with("/api/save", action="save", id=123) + + # Should call callback with location, point (None when not provided), and params + self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123) self.assertEqual(result, "callback_result") def test_function_link_execution(self): @@ -104,11 +104,11 @@ class TestLink(unittest.TestCase): callback=self.mock_callback, params=params ) - + result = link.execute() - - # Should call callback with location and params - self.mock_callback.assert_called_once_with("save_document", data="test") + + # Should call callback with location, point (None when not provided), and params + self.mock_callback.assert_called_once_with("save_document", None, data="test") self.assertEqual(result, "callback_result") def test_api_link_without_callback(self): @@ -204,11 +204,11 @@ class TestButton(unittest.TestCase): """Test executing enabled button.""" params = {"data": "test_data"} button = Button("Test", self.mock_callback, params=params, enabled=True) - + result = button.execute() - - # Should call callback with params - self.mock_callback.assert_called_once_with(data="test_data") + + # Should call callback with point (None when not provided) and params + self.mock_callback.assert_called_once_with(None, data="test_data") self.assertEqual(result, "button_clicked") def test_button_execute_disabled(self): diff --git a/tests/concrete/test_concrete_functional.py b/tests/concrete/test_concrete_functional.py index 064f6a5..585c82d 100644 --- a/tests/concrete/test_concrete_functional.py +++ b/tests/concrete/test_concrete_functional.py @@ -442,28 +442,37 @@ class TestInteractionCallbacks(unittest.TestCase): self.font = Font(font_size=12, colour=(0, 0, 0)) self.mock_draw = Mock() 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): """Test that LinkText properly handles interaction""" # 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) - + # Simulate interaction result = renderable.interact(np.array([10, 10])) - + # Should execute the link's callback self.assertEqual(result, self.callback_result) - + def test_button_text_interaction(self): """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) - + # Simulate interaction result = renderable.interact(np.array([10, 10])) - + # Should execute the button's callback self.assertEqual(result, self.callback_result) diff --git a/tests/test_interactive_image.py b/tests/test_interactive_image.py new file mode 100644 index 0000000..c59135d --- /dev/null +++ b/tests/test_interactive_image.py @@ -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()