Added press state, fixed font registry
This commit is contained in:
parent
9ae8ddddca
commit
849ba2f60f
396
examples/07_pressed_state_demo.py
Normal file
396
examples/07_pressed_state_demo.py
Normal file
@ -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)
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
310
pyWebLayout/concrete/interaction_handler.py
Normal file
310
pyWebLayout/concrete/interaction_handler.py
Normal file
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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={},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user