From 849ba2f60fbb6c0d728488a11e24fac519f822cb Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Nov 2025 17:45:53 +0100 Subject: [PATCH] Added press state, fixed font registry --- examples/07_pressed_state_demo.py | 396 ++++++++++++++++++++ pyWebLayout/concrete/functional.py | 86 +++-- pyWebLayout/concrete/interaction_handler.py | 310 +++++++++++++++ pyWebLayout/concrete/page.py | 18 + pyWebLayout/io/readers/html_extraction.py | 9 +- pyWebLayout/layout/document_layouter.py | 31 +- pyWebLayout/style/fonts.py | 43 ++- 7 files changed, 854 insertions(+), 39 deletions(-) create mode 100644 examples/07_pressed_state_demo.py create mode 100644 pyWebLayout/concrete/interaction_handler.py diff --git a/examples/07_pressed_state_demo.py b/examples/07_pressed_state_demo.py new file mode 100644 index 0000000..dccc1e7 --- /dev/null +++ b/examples/07_pressed_state_demo.py @@ -0,0 +1,396 @@ +""" +Demonstration of pressed/depressed states for buttons and links with visual feedback. + +This example shows: +1. How to use the InteractionHandler for automatic press/release cycles +2. How to manually manage states for custom event loops +3. How the dirty flag system tracks when re-rendering is needed +4. Visual differences between normal, hovered, and pressed states + +The demo creates a page with buttons and links, then simulates clicking them +with proper visual feedback timing. +""" + +from pyWebLayout.concrete import Page +from pyWebLayout.concrete.interaction_handler import InteractionHandler, InteractionStateManager +from pyWebLayout.abstract.functional import Button, Link, LinkType +from pyWebLayout.abstract import Paragraph, Word +from pyWebLayout.abstract.inline import LinkedWord +from pyWebLayout.style import Font +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.layout.document_layouter import DocumentLayouter +import numpy as np +import time + + +def create_interactive_demo_page(): + """ + Create a page with various interactive elements demonstrating state changes. + """ + # Create page + page = Page(size=(600, 500), style=PageStyle(border_width=10)) + layouter = DocumentLayouter(page) + + # Create fonts + title_font = Font(font_size=24, colour=(0, 0, 100)) + body_font = Font(font_size=16, colour=(0, 0, 0)) + button_font = Font(font_size=14, colour=(255, 255, 255)) + + # Title + title = Paragraph(title_font) + title.add_word(Word("Interactive", title_font)) + title.add_word(Word("Elements", title_font)) + title.add_word(Word("Demo", title_font)) + layouter.layout_paragraph(title) + page._current_y_offset += 15 + + # Description + desc = Paragraph(body_font) + desc.add_word(Word("Click", body_font)) + desc.add_word(Word("the", body_font)) + desc.add_word(Word("buttons", body_font)) + desc.add_word(Word("and", body_font)) + desc.add_word(Word("links", body_font)) + desc.add_word(Word("below", body_font)) + desc.add_word(Word("to", body_font)) + desc.add_word(Word("see", body_font)) + desc.add_word(Word("pressed", body_font)) + desc.add_word(Word("state", body_font)) + desc.add_word(Word("feedback!", body_font)) + layouter.layout_paragraph(desc) + page._current_y_offset += 20 + + # Callback functions + def on_save(): + print("💾 Save button clicked!") + return "saved" + + def on_cancel(): + print("❌ Cancel button clicked!") + return "cancelled" + + def on_link_click(location, point): + print(f"🔗 Link clicked: {location} at {point}") + return location + + # Create buttons + save_button = Button( + label="Save Document", + callback=lambda point, **kwargs: on_save(), + html_id="save-btn" + ) + + cancel_button = Button( + label="Cancel", + callback=lambda point, **kwargs: on_cancel(), + html_id="cancel-btn" + ) + + # Layout buttons + success1, save_id = layouter.layout_button(save_button, font=button_font) + page._current_y_offset += 12 + success2, cancel_id = layouter.layout_button(cancel_button, font=button_font) + page._current_y_offset += 25 + + # Create paragraph with links + link_para = Paragraph(body_font) + link_para.add_word(Word("Visit", body_font)) + link_para.add_word(Word("our", body_font)) + + # Add a link + internal_link = Link( + location="https://example.com", + link_type=LinkType.EXTERNAL, + callback=on_link_click, + title="Example website" + ) + link_para.add_word(LinkedWord( + "website", + body_font, + location="https://example.com", + link_type=LinkType.EXTERNAL, + callback=on_link_click, + title="Example website" + )) + link_para.add_word(Word("or", body_font)) + + # Add another link + docs_link = Link( + location="/docs", + link_type=LinkType.INTERNAL, + callback=on_link_click, + title="Documentation" + ) + link_para.add_word(LinkedWord( + "documentation", + body_font, + location="/docs", + link_type=LinkType.INTERNAL, + callback=on_link_click, + title="Documentation" + )) + link_para.add_word(Word("page.", body_font)) + + layouter.layout_paragraph(link_para) + + return page, save_id, cancel_id + + +def demo_automatic_interaction(): + """ + Demonstrate automatic interaction handling with InteractionHandler. + + This shows the simplest usage pattern where InteractionHandler manages + the complete press/release cycle automatically. + """ + print("=" * 70) + print("Demo 1: Automatic Interaction with Visual Feedback") + print("=" * 70) + print() + + # Create the page + page, save_id, cancel_id = create_interactive_demo_page() + + # Create interaction handler + handler = InteractionHandler(page, press_duration_ms=150) + + print("Initial render:") + initial_render = page.render() + initial_render.save("demo_07_initial.png") + print(f" ✓ Saved: demo_07_initial.png") + print(f" ✓ Page dirty flag: {page.is_dirty}") + print() + + # Get the save button + save_button = page.callbacks.get_by_id("save-btn") + click_point = np.array([50, 150]) + + print("Simulating button click with automatic feedback...") + print(f" → Setting pressed state at t=0ms") + + # Execute with automatic feedback + pressed_frame, released_frame, result = handler.execute_with_feedback( + save_button, + click_point + ) + + print(f" → Showing pressed state for 150ms") + print(f" → Executing callback") + print(f" → Result: {result}") + print(f" → Setting released state") + + # Save the frames + pressed_frame.save("demo_07_pressed.png") + print(f" ✓ Saved: demo_07_pressed.png") + + released_frame.save("demo_07_released.png") + print(f" ✓ Saved: demo_07_released.png") + print() + + +def demo_manual_state_management(): + """ + Demonstrate manual state management for custom event loops. + + This shows how an application with its own event loop can manage + states and check the dirty flag before re-rendering. + """ + print("=" * 70) + print("Demo 2: Manual State Management with Dirty Flag Checking") + print("=" * 70) + print() + + # Create the page + page, save_id, cancel_id = create_interactive_demo_page() + + # Initial render + print("Initial render:") + current_frame = page.render() + print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)") + print() + + # Get the cancel button + cancel_button = page.callbacks.get_by_id("cancel-btn") + + # Simulate mouse down + print("Mouse down event:") + # Set page reference if not already set + if not hasattr(cancel_button, '_page') or cancel_button._page is None: + cancel_button.set_page(page) + cancel_button.set_pressed(True) + print(f" ✓ Set pressed state") + print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)") + + # Check if we need to re-render + if page.is_dirty: + print(" → Re-rendering (dirty flag is set)") + current_frame = page.render() + current_frame.save("demo_07_manual_pressed.png") + print(f" ✓ Saved: demo_07_manual_pressed.png") + print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)") + print() + + # Wait a bit + print("Waiting 150ms for visual feedback...") + time.sleep(0.15) + print() + + # Execute callback + print("Executing callback:") + result = cancel_button.interact(np.array([50, 200])) + print(f" ✓ Result: {result}") + print() + + # Simulate mouse up + print("Mouse up event:") + cancel_button.set_pressed(False) + print(f" ✓ Set released state") + print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)") + + # Check if we need to re-render + if page.is_dirty: + print(" → Re-rendering (dirty flag is set)") + current_frame = page.render() + current_frame.save("demo_07_manual_released.png") + print(f" ✓ Saved: demo_07_manual_released.png") + print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)") + print() + + +def demo_state_manager(): + """ + Demonstrate the InteractionStateManager for hover/press tracking. + + This shows how to use the high-level state manager that automatically + handles hover and press states based on cursor position. + """ + print("=" * 70) + print("Demo 3: InteractionStateManager for Hover and Press Tracking") + print("=" * 70) + print() + + # Create the page + page, save_id, cancel_id = create_interactive_demo_page() + + # Create state manager + state_mgr = InteractionStateManager(page) + + # Initial render + print("Initial render:") + current_frame = page.render() + print(f" ✓ Rendered initial state") + print() + + # Simulate cursor moving over a button + button_center = (150, 150) + print(f"Cursor moves to button position {button_center}:") + hover_frame = state_mgr.update_hover(button_center) + if hover_frame: + print(f" ✓ Hover state changed, page re-rendered") + hover_frame.save("demo_07_hover.png") + print(f" ✓ Saved: demo_07_hover.png") + print() + + # Simulate mouse down + print(f"Mouse down at {button_center}:") + pressed_frame = state_mgr.handle_mouse_down(button_center) + if pressed_frame: + print(f" ✓ Pressed state set, page re-rendered") + pressed_frame.save("demo_07_state_mgr_pressed.png") + print(f" ✓ Saved: demo_07_state_mgr_pressed.png") + print() + + # Wait for visual feedback + time.sleep(0.15) + + # Simulate mouse up + print(f"Mouse up at {button_center}:") + released_frame, result = state_mgr.handle_mouse_up(button_center) + if released_frame: + print(f" ✓ Released state set, page re-rendered") + print(f" ✓ Callback result: {result}") + released_frame.save("demo_07_state_mgr_released.png") + print(f" ✓ Saved: demo_07_state_mgr_released.png") + print() + + # Simulate cursor moving away + away_point = (50, 50) + print(f"Cursor moves away to {away_point}:") + away_frame = state_mgr.update_hover(away_point) + if away_frame: + print(f" ✓ Hover state cleared, page re-rendered") + away_frame.save("demo_07_no_hover.png") + print(f" ✓ Saved: demo_07_no_hover.png") + print() + + +def demo_performance_optimization(): + """ + Demonstrate how the dirty flag prevents unnecessary re-renders. + """ + print("=" * 70) + print("Demo 4: Performance Optimization with Dirty Flag") + print("=" * 70) + print() + + # Create the page + page, save_id, cancel_id = create_interactive_demo_page() + + print("Scenario: Multiple state queries without changes") + print() + + # Initial render + page.render() + print(f"1. After initial render - dirty: {page.is_dirty}") + + # Check if dirty before rendering again + print(f"2. Check dirty flag: {page.is_dirty}") + if not page.is_dirty: + print(" → Skipping render (no changes)") + print() + + # Now make a change + button = page.callbacks.get_by_id("save-btn") + print("3. Setting button to pressed state") + # Ensure page reference is set + if not hasattr(button, '_page') or button._page is None: + button.set_page(page) + button.set_pressed(True) + print(f" → dirty: {page.is_dirty}") + print() + + # This time we need to render + print(f"4. Check dirty flag: {page.is_dirty}") + if page.is_dirty: + print(" → Re-rendering (state changed)") + page.render() + print(f" → dirty after render: {page.is_dirty}") + print() + + print("Benefit: Only render when actual changes occur!") + print() + + +if __name__ == "__main__": + print("\n") + print("╔" + "═" * 68 + "╗") + print("║" + " " * 15 + "PRESSED STATE DEMONSTRATION" + " " * 26 + "║") + print("╚" + "═" * 68 + "╝") + print() + + # Run all demos + demo_automatic_interaction() + print("\n") + + demo_manual_state_management() + print("\n") + + demo_state_manager() + print("\n") + + demo_performance_optimization() + print("\n") + + print("=" * 70) + print("All demos complete! Check the generated PNG files.") + print("=" * 70) diff --git a/pyWebLayout/concrete/functional.py b/pyWebLayout/concrete/functional.py index 3e3d917..557b139 100644 --- a/pyWebLayout/concrete/functional.py +++ b/pyWebLayout/concrete/functional.py @@ -16,7 +16,7 @@ class LinkText(Text, Interactable, Queriable): """ def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw, - source=None, line=None): + source=None, line=None, page=None): """ Initialize a linkable text object. @@ -27,6 +27,7 @@ class LinkText(Text, Interactable, Queriable): draw: The drawing context source: Optional source object line: Optional line container + page: Optional parent page (for dirty flag management) """ # Create link-styled font (underlined and colored based on link type) link_font = font.with_decoration(TextDecoration.UNDERLINE) @@ -46,9 +47,11 @@ class LinkText(Text, Interactable, Queriable): # Initialize Interactable with the link's execute method Interactable.__init__(self, link.execute) - # Store the link object + # Store the link object and page reference self._link = link + self._page = page self._hovered = False + self._pressed = False # Ensure _origin is initialized as numpy array if not hasattr(self, '_origin') or self._origin is None: @@ -62,40 +65,53 @@ class LinkText(Text, Interactable, Queriable): def set_hovered(self, hovered: bool): """Set the hover state for visual feedback""" self._hovered = hovered + self._mark_page_dirty() + + def set_pressed(self, pressed: bool): + """Set the pressed state for visual feedback""" + self._pressed = pressed + self._mark_page_dirty() + + def _mark_page_dirty(self): + """Mark the parent page as dirty if available""" + if self._page and hasattr(self._page, 'mark_dirty'): + self._page.mark_dirty() 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 and pressed effects. Args: next_text: The next Text object in the line (if any) spacing: The spacing to the next text object """ + # Handle mock objects in tests + size = self.size + if hasattr(size, '__call__'): # It's a Mock + # Use default size for tests + size = np.array([100, 20]) + else: + size = np.array(size) + + # Ensure origin is a numpy array + origin = np.array( + self._origin) if not isinstance( + 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) + # Call the parent Text render method with parameters super().render(next_text, spacing) - # Add hover effect if needed - if self._hovered: - # Draw a subtle highlight background - highlight_color = (220, 220, 255, 100) # Light blue with alpha - - # Handle mock objects in tests - size = self.size - if hasattr(size, '__call__'): # It's a Mock - # Use default size for tests - size = np.array([100, 20]) - else: - size = np.array(size) - - # Ensure origin is a numpy array - origin = np.array( - self._origin) if not isinstance( - self._origin, - np.ndarray) else self._origin - - self._draw.rectangle([origin, origin + size], - fill=highlight_color) - class ButtonText(Text, Interactable, Queriable): """ @@ -105,7 +121,7 @@ class ButtonText(Text, Interactable, Queriable): def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw, padding: Tuple[int, int, int, int] = (4, 8, 4, 8), - source=None, line=None): + source=None, line=None, page=None): """ Initialize a button text object. @@ -116,6 +132,7 @@ class ButtonText(Text, Interactable, Queriable): padding: Padding around the button text (top, right, bottom, left) source: Optional source object line: Optional line container + page: Optional parent page (for dirty flag management) """ # Initialize Text with the button label Text.__init__(self, button.label, font, draw, source, line) @@ -126,6 +143,7 @@ class ButtonText(Text, Interactable, Queriable): # Store button properties self._button = button self._padding = padding + self._page = page self._pressed = False self._hovered = False @@ -150,10 +168,26 @@ class ButtonText(Text, Interactable, Queriable): def set_pressed(self, pressed: bool): """Set the pressed state""" self._pressed = pressed + self._mark_page_dirty() def set_hovered(self, hovered: bool): """Set the hover state""" self._hovered = hovered + self._mark_page_dirty() + + def set_page(self, page): + """ + Set the parent page reference for dirty flag management. + + Args: + page: The Page object containing this element + """ + self._page = page + + def _mark_page_dirty(self): + """Mark the parent page as dirty if available""" + if self._page and hasattr(self._page, 'mark_dirty'): + self._page.mark_dirty() def render(self): """ diff --git a/pyWebLayout/concrete/interaction_handler.py b/pyWebLayout/concrete/interaction_handler.py new file mode 100644 index 0000000..ddf6501 --- /dev/null +++ b/pyWebLayout/concrete/interaction_handler.py @@ -0,0 +1,310 @@ +""" +Interaction handler for managing button/link press-release lifecycle with visual feedback. + +This module provides utilities for handling interactive element states and rendering +frames at different stages of interaction (pressed, released). +""" + +from typing import Optional, Tuple, Callable, Any +from PIL import Image +import time +import numpy as np + +from pyWebLayout.concrete.functional import LinkText, ButtonText +from pyWebLayout.concrete.page import Page + + +class InteractionHandler: + """ + Manages the press-release lifecycle for interactive elements. + + This class handles the timing and state management needed to show + visual feedback when buttons or links are clicked. It can generate + multiple rendered frames showing the pressed and released states. + + Usage patterns: + + Pattern A - Simple one-shot with automatic frames: + handler = InteractionHandler(page) + frames = handler.execute_with_feedback(button_element, point) + # Returns: [pressed_frame, released_frame] + # Show frames in sequence with brief delay + + Pattern B - Manual state management for custom event loops: + handler = InteractionHandler(page) + handler.set_pressed_state(button_element, True) + pressed_frame = handler.render_current_state() + # ... show frame, wait, execute action ... + handler.set_pressed_state(button_element, False) + released_frame = handler.render_current_state() + """ + + def __init__(self, page: Page, press_duration_ms: int = 150): + """ + Initialize the interaction handler. + + Args: + page: The Page object containing the interactive elements + press_duration_ms: How long to show the pressed state (default: 150ms) + """ + self._page = page + self._press_duration_ms = press_duration_ms + + def set_pressed_state(self, element, pressed: bool): + """ + Set the pressed state of an interactive element. + + Args: + element: A LinkText or ButtonText object + pressed: True to show pressed, False to show released + """ + if isinstance(element, (LinkText, ButtonText)): + # Ensure element has page reference for dirty flag + if not hasattr(element, '_page') or element._page is None: + element.set_page(self._page) + element.set_pressed(pressed) + else: + raise TypeError( + f"Element must be LinkText or ButtonText, got {type(element)}") + + def set_hovered_state(self, element, hovered: bool): + """ + Set the hovered state of an interactive element. + + Args: + element: A LinkText or ButtonText object + hovered: True to show hovered, False for normal + """ + if isinstance(element, (LinkText, ButtonText)): + # Ensure element has page reference for dirty flag + if not hasattr(element, '_page') or element._page is None: + element.set_page(self._page) + element.set_hovered(hovered) + else: + raise TypeError( + f"Element must be LinkText or ButtonText, got {type(element)}") + + def render_current_state(self) -> Image.Image: + """ + Render the page with current element states. + + Returns: + PIL Image of the rendered page + """ + return self._page.render() + + def execute_with_feedback( + self, + element, + point: Optional[np.ndarray] = None, + callback: Optional[Callable] = None) -> Tuple[Image.Image, Image.Image, Any]: + """ + Execute an interaction with visual feedback at each stage. + + This is the high-level "all-in-one" method that: + 1. Sets pressed state and renders + 2. Waits for press_duration_ms + 3. Executes the element's callback (or provided callback) + 4. Sets released state and renders + + Args: + element: A LinkText or ButtonText object + point: Optional point where interaction occurred + callback: Optional custom callback (overrides element's callback) + + Returns: + Tuple of (pressed_frame, released_frame, callback_result) + """ + # Step 1: Render pressed state + self.set_pressed_state(element, True) + pressed_frame = self.render_current_state() + + # Step 2: Wait for visual feedback duration + time.sleep(self._press_duration_ms / 1000.0) + + # Step 3: Execute callback + callback_result = None + if callback: + callback_result = callback(point) if point is not None else callback() + elif hasattr(element, 'interact'): + callback_result = element.interact(point) + + # Step 4: Render released state + self.set_pressed_state(element, False) + released_frame = self.render_current_state() + + return pressed_frame, released_frame, callback_result + + def execute_async_with_feedback( + self, + element, + point: Optional[np.ndarray] = None) -> Tuple[Image.Image, Callable, Image.Image]: + """ + Execute an interaction with visual feedback, returning frames immediately + without blocking. + + This method returns the frames and a callback to execute later, allowing + the caller to control when the action actually happens. + + Args: + element: A LinkText or ButtonText object + point: Optional point where interaction occurred + + Returns: + Tuple of (pressed_frame, execute_callback, released_frame) + where execute_callback is a function that will execute the interaction + """ + # Render pressed state + self.set_pressed_state(element, True) + pressed_frame = self.render_current_state() + + # Create callback that will execute the interaction and reset state + def execute_callback(): + result = None + if hasattr(element, 'interact'): + result = element.interact(point) + self.set_pressed_state(element, False) + return result + + # Pre-render the released state (element state is still pressed) + # We'll return this frame but the caller controls when to show it + self.set_pressed_state(element, False) + released_frame = self.render_current_state() + + # Reset back to pressed for consistency + # (caller will call execute_callback which sets to False) + self.set_pressed_state(element, True) + + return pressed_frame, execute_callback, released_frame + + +class InteractionStateManager: + """ + Manages interaction states for multiple elements on a page. + + Useful for applications that need to track hover/press states + across many interactive elements simultaneously. + """ + + def __init__(self, page: Page): + """ + Initialize the state manager. + + Args: + page: The Page object containing interactive elements + """ + self._page = page + self._hovered_element = None + self._pressed_element = None + + def update_hover(self, point: Tuple[int, int]) -> Optional[Image.Image]: + """ + Update hover state based on cursor position. + + Queries the page to find what's under the cursor and updates + hover states accordingly. + + Args: + point: Cursor position (x, y) + + Returns: + New rendered frame if hover state changed, None otherwise + """ + # Query what's at this point + result = self._page.query_point(point) + + if not result or not result.is_interactive: + # Nothing interactive under cursor + if self._hovered_element: + # Clear previous hover + if isinstance(self._hovered_element, (LinkText, ButtonText)): + self._hovered_element.set_hovered(False) + self._hovered_element = None + return self._page.render() + return None + + # Something interactive is under cursor + element = result.object + if element != self._hovered_element: + # Hover changed + # Clear old hover + if self._hovered_element and isinstance( + self._hovered_element, (LinkText, ButtonText)): + self._hovered_element.set_hovered(False) + + # Set new hover + if isinstance(element, (LinkText, ButtonText)): + element.set_hovered(True) + + self._hovered_element = element + return self._page.render() + + return None + + def handle_mouse_down(self, point: Tuple[int, int]) -> Optional[Image.Image]: + """ + Handle mouse button press at a point. + + Args: + point: Click position (x, y) + + Returns: + New rendered frame showing pressed state, or None if nothing interactive + """ + result = self._page.query_point(point) + + if not result or not result.is_interactive: + return None + + element = result.object + if isinstance(element, (LinkText, ButtonText)): + element.set_pressed(True) + self._pressed_element = element + return self._page.render() + + return None + + def handle_mouse_up( + self, + point: Tuple[int, + int]) -> Tuple[Optional[Image.Image], + Any]: + """ + Handle mouse button release at a point. + + Args: + point: Release position (x, y) + + Returns: + Tuple of (rendered_frame, callback_result) + Frame shows released state, result is from executing the callback + """ + if not self._pressed_element: + return None, None + + # Execute the interaction + callback_result = None + if hasattr(self._pressed_element, 'interact'): + callback_result = self._pressed_element.interact( + np.array(point)) + + # Release the pressed state + if isinstance(self._pressed_element, (LinkText, ButtonText)): + self._pressed_element.set_pressed(False) + + self._pressed_element = None + + return self._page.render(), callback_result + + def reset(self): + """Reset all interaction states.""" + if self._hovered_element and isinstance( + self._hovered_element, (LinkText, ButtonText)): + self._hovered_element.set_hovered(False) + + if self._pressed_element and isinstance( + self._pressed_element, (LinkText, ButtonText)): + self._pressed_element.set_pressed(False) + + self._hovered_element = None + self._pressed_element = None diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index 348559f..2dd6d12 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -35,6 +35,8 @@ class Page(Renderable, Queriable): self._is_first_line = True # Track if we're placing the first line # Callback registry for managing interactable elements self._callbacks = CallbackRegistry() + # Dirty flag to track if page needs re-rendering due to state changes + self._dirty = True def free_space(self) -> Tuple[int, int]: """Get the remaining space on the page""" @@ -113,6 +115,19 @@ class Page(Renderable, Queriable): """Get the callback registry for managing interactable elements""" return self._callbacks + @property + def is_dirty(self) -> bool: + """Check if the page needs re-rendering due to state changes""" + return self._dirty + + def mark_dirty(self): + """Mark the page as needing re-rendering""" + self._dirty = True + + def mark_clean(self): + """Mark the page as clean (up-to-date render)""" + self._dirty = False + @property def draw(self) -> Optional[ImageDraw.Draw]: """Get the ImageDraw object for drawing on this page's canvas""" @@ -232,6 +247,9 @@ class Page(Renderable, Queriable): # Render all children - they draw directly onto the canvas self.render_children() + # Mark as clean after rendering + self._dirty = False + return self._canvas def _create_canvas(self) -> Image.Image: diff --git a/pyWebLayout/io/readers/html_extraction.py b/pyWebLayout/io/readers/html_extraction.py index 152be3b..65f3caf 100644 --- a/pyWebLayout/io/readers/html_extraction.py +++ b/pyWebLayout/io/readers/html_extraction.py @@ -82,8 +82,15 @@ def create_base_context( Returns: StyleContext with default values """ + # Use document's font registry if available, otherwise create default font + if base_font is None: + if document and hasattr(document, 'get_or_create_font'): + base_font = document.get_or_create_font() + else: + base_font = Font() + return StyleContext( - font=base_font or Font(), + font=base_font, background=None, css_classes=set(), css_styles={}, diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py index c8396db..e5a4263 100644 --- a/pyWebLayout/layout/document_layouter.py +++ b/pyWebLayout/layout/document_layouter.py @@ -100,15 +100,28 @@ def paragraph_layouter(paragraph: Paragraph, # Cap font size to page maximum if needed if font.font_size > page.style.max_font_size: - font = Font( - font_path=font._font_path, - font_size=page.style.max_font_size, - colour=font.colour, - weight=font.weight, - style=font.style, - decoration=font.decoration, - background=font.background - ) + # Use paragraph's font registry to create the capped font + if hasattr(paragraph, 'get_or_create_font'): + font = paragraph.get_or_create_font( + font_path=font._font_path, + font_size=page.style.max_font_size, + colour=font.colour, + weight=font.weight, + style=font.style, + decoration=font.decoration, + background=font.background + ) + else: + # Fallback to direct creation (will still use global cache) + font = Font( + font_path=font._font_path, + font_size=page.style.max_font_size, + colour=font.colour, + weight=font.weight, + style=font.style, + decoration=font.decoration, + background=font.background + ) # Calculate baseline-to-baseline spacing: font size + additional line spacing # This is the vertical distance between baselines of consecutive lines diff --git a/pyWebLayout/style/fonts.py b/pyWebLayout/style/fonts.py index 90748da..e6f109f 100644 --- a/pyWebLayout/style/fonts.py +++ b/pyWebLayout/style/fonts.py @@ -2,13 +2,20 @@ # e.g. bold, italic, regular from PIL import ImageFont from enum import Enum -from typing import Tuple, Optional +from typing import Tuple, Optional, Dict import os import logging # Set up logging for font loading logger = logging.getLogger(__name__) +# Global cache for PIL ImageFont objects to avoid reloading fonts from disk +# Key: (font_path, font_size), Value: PIL ImageFont object +_FONT_CACHE: Dict[Tuple[Optional[str], int], ImageFont.FreeTypeFont] = {} + +# Cache for bundled font path to avoid repeated filesystem lookups +_BUNDLED_FONT_PATH: Optional[str] = None + class FontWeight(Enum): NORMAL = "normal" @@ -70,7 +77,14 @@ class Font: self._load_font() def _get_bundled_font_path(self): - """Get the path to the bundled font""" + """Get the path to the bundled font (cached)""" + global _BUNDLED_FONT_PATH + + # Return cached path if available + if _BUNDLED_FONT_PATH is not None: + return _BUNDLED_FONT_PATH + + # First time - determine the path and cache it # Get the directory containing this module current_dir = os.path.dirname(os.path.abspath(__file__)) # Navigate to the assets/fonts directory @@ -86,13 +100,31 @@ class Font: if os.path.exists(bundled_font_path): logger.info(f"Found bundled font at: {bundled_font_path}") + _BUNDLED_FONT_PATH = bundled_font_path return bundled_font_path else: logger.warning(f"Bundled font not found at: {bundled_font_path}") + # Cache None to indicate bundled font is not available + _BUNDLED_FONT_PATH = "" # Use empty string instead of None to differentiate from "not checked yet" return None def _load_font(self): - """Load the font using PIL's ImageFont with consistent bundled font""" + """Load the font using PIL's ImageFont with consistent bundled font and caching""" + # Determine the actual font path to use + font_path_to_use = self._font_path + if not font_path_to_use: + font_path_to_use = self._get_bundled_font_path() + + # Create cache key + cache_key = (font_path_to_use, self._font_size) + + # Check if font is already cached + if cache_key in _FONT_CACHE: + self._font = _FONT_CACHE[cache_key] + logger.debug(f"Reusing cached font: {font_path_to_use} at size {self._font_size}") + return + + # Font not cached, need to load it try: if self._font_path: # Use specified font path @@ -119,10 +151,15 @@ class Font: "Bundled font not available, falling back to PIL default font") self._font = ImageFont.load_default() + # Cache the loaded font + _FONT_CACHE[cache_key] = self._font + logger.debug(f"Cached font: {font_path_to_use} at size {self._font_size}") + except Exception as e: # Ultimate fallback to default font logger.error(f"Failed to load font: {e}, falling back to PIL default font") self._font = ImageFont.load_default() + # Don't cache the default font as it doesn't have a path @property def font(self):