integration of functional elements
All checks were successful
Python CI / test (push) Successful in 6m46s
All checks were successful
Python CI / test (push) Successful in 6m46s
This commit is contained in:
parent
ea93681aaf
commit
39622c7dd7
@ -28,6 +28,7 @@ PyWebLayout is a Python library for HTML-like layout and rendering to paginated
|
||||
- ↔️ **Text Alignment** - Left, center, right, and justified text
|
||||
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
|
||||
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
|
||||
- 🔘 **Interactive Elements** - Buttons, forms, and links with callback support
|
||||
|
||||
### Architecture
|
||||
- **Abstract/Concrete Separation** - Clean separation between content structure and rendering
|
||||
@ -111,6 +112,13 @@ The library supports various page layouts and configurations:
|
||||
<em>HTML tables with headers and styling</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>Interactive Elements</b><br>
|
||||
<img src="docs/images/example_06_functional_elements.png" width="300" alt="Interactive Elements"><br>
|
||||
<em>Buttons, forms, and callback binding</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Examples
|
||||
@ -123,6 +131,7 @@ The `examples/` directory contains working demonstrations:
|
||||
- **[03_page_layouts.py](examples/03_page_layouts.py)** - Different page configurations
|
||||
- **[04_table_rendering.py](examples/04_table_rendering.py)** - HTML table rendering with styling
|
||||
- **[05_table_with_images.py](examples/05_table_with_images.py)** - Tables with embedded images
|
||||
- **[06_functional_elements_demo.py](examples/06_functional_elements_demo.py)** - Interactive buttons and forms with callbacks
|
||||
|
||||
### Advanced Examples
|
||||
- **[html_multipage_simple.py](examples/html_multipage_simple.py)** - Multi-page HTML rendering
|
||||
|
||||
BIN
docs/images/example_06_functional_elements.png
Normal file
BIN
docs/images/example_06_functional_elements.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
292
examples/06_functional_elements_demo.py
Normal file
292
examples/06_functional_elements_demo.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""
|
||||
Demonstration of functional elements (buttons, forms, links) with callback binding.
|
||||
|
||||
This example shows how to:
|
||||
1. Create functional elements programmatically
|
||||
2. Layout them on a page
|
||||
3. Bind callbacks after layout using the CallbackRegistry
|
||||
4. Simulate user interactions
|
||||
|
||||
This pattern is useful for:
|
||||
- Manual GUI construction
|
||||
- Applications where callbacks need access to runtime state
|
||||
- Interactive document interfaces
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete import Page
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField, FormFieldType
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
import numpy as np
|
||||
|
||||
|
||||
class SimpleApp:
|
||||
"""
|
||||
A simple application that demonstrates functional element usage.
|
||||
|
||||
This app has:
|
||||
- A settings form
|
||||
- Save and Cancel buttons
|
||||
- Application state that callbacks can access
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = {
|
||||
"username": "",
|
||||
"theme": "light",
|
||||
"notifications": True
|
||||
}
|
||||
self.saved = False
|
||||
|
||||
def on_save_click(self, point, **kwargs):
|
||||
"""Callback for save button"""
|
||||
print(f"Save button clicked at {point}")
|
||||
self.saved = True
|
||||
print("Settings saved!")
|
||||
return "saved"
|
||||
|
||||
def on_cancel_click(self, point, **kwargs):
|
||||
"""Callback for cancel button"""
|
||||
print(f"Cancel button clicked at {point}")
|
||||
print("Changes cancelled!")
|
||||
return "cancelled"
|
||||
|
||||
def on_reset_click(self, point, **kwargs):
|
||||
"""Callback for reset button"""
|
||||
print(f"Reset button clicked at {point}")
|
||||
self.settings = {
|
||||
"username": "",
|
||||
"theme": "light",
|
||||
"notifications": True
|
||||
}
|
||||
print("Settings reset to defaults!")
|
||||
return "reset"
|
||||
|
||||
|
||||
def create_settings_page():
|
||||
"""
|
||||
Create a settings page with functional elements.
|
||||
|
||||
Returns:
|
||||
Tuple of (page, app, element_ids) where element_ids maps
|
||||
semantic names to registered callback ids
|
||||
"""
|
||||
# Create the application instance
|
||||
app = SimpleApp()
|
||||
|
||||
# Create page
|
||||
page = Page(size=(600, 800), style=PageStyle(border_width=10))
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Create content
|
||||
font = Font(font_size=16, colour=(0, 0, 0))
|
||||
|
||||
# Title paragraph
|
||||
title_font = Font(font_size=24, colour=(0, 0, 100))
|
||||
title = Paragraph(title_font)
|
||||
title.add_word(Word("Settings", title_font))
|
||||
|
||||
# Description paragraph
|
||||
desc = Paragraph(font)
|
||||
desc.add_word(Word("Configure", font))
|
||||
desc.add_word(Word("your", font))
|
||||
desc.add_word(Word("application", font))
|
||||
desc.add_word(Word("preferences", font))
|
||||
desc.add_word(Word("below.", font))
|
||||
|
||||
# Layout title and description
|
||||
layouter.layout_paragraph(title)
|
||||
page._current_y_offset += 10 # Add some spacing
|
||||
layouter.layout_paragraph(desc)
|
||||
page._current_y_offset += 20 # Add more spacing before form
|
||||
|
||||
# Create form
|
||||
settings_form = Form(
|
||||
form_id="settings-form",
|
||||
action="/save-settings",
|
||||
html_id="settings-form"
|
||||
)
|
||||
|
||||
# Add form fields
|
||||
username_field = FormField(
|
||||
name="username",
|
||||
field_type=FormFieldType.TEXT,
|
||||
label="Username",
|
||||
value="john_doe"
|
||||
)
|
||||
|
||||
theme_field = FormField(
|
||||
name="theme",
|
||||
field_type=FormFieldType.SELECT,
|
||||
label="Theme",
|
||||
value="light",
|
||||
options=[("light", "Light"), ("dark", "Dark")]
|
||||
)
|
||||
|
||||
notifications_field = FormField(
|
||||
name="notifications",
|
||||
field_type=FormFieldType.CHECKBOX,
|
||||
label="Enable Notifications",
|
||||
value=True
|
||||
)
|
||||
|
||||
settings_form.add_field(username_field)
|
||||
settings_form.add_field(theme_field)
|
||||
settings_form.add_field(notifications_field)
|
||||
|
||||
# Layout the form
|
||||
success, field_ids = layouter.layout_form(settings_form)
|
||||
|
||||
if not success:
|
||||
print("Warning: Form didn't fit on page!")
|
||||
|
||||
page._current_y_offset += 20 # Spacing before buttons
|
||||
|
||||
# Create buttons (NO callbacks yet - will be bound later)
|
||||
save_button = Button(
|
||||
label="Save Settings",
|
||||
callback=None, # No callback yet!
|
||||
html_id="save-btn"
|
||||
)
|
||||
|
||||
cancel_button = Button(
|
||||
label="Cancel",
|
||||
callback=None, # No callback yet!
|
||||
html_id="cancel-btn"
|
||||
)
|
||||
|
||||
reset_button = Button(
|
||||
label="Reset to Defaults",
|
||||
callback=None, # No callback yet!
|
||||
html_id="reset-btn"
|
||||
)
|
||||
|
||||
# Layout buttons
|
||||
button_font = Font(font_size=14, colour=(255, 255, 255))
|
||||
success1, save_id = layouter.layout_button(save_button, font=button_font)
|
||||
page._current_y_offset += 10 # Spacing between buttons
|
||||
success2, cancel_id = layouter.layout_button(cancel_button, font=button_font)
|
||||
page._current_y_offset += 10
|
||||
success3, reset_id = layouter.layout_button(reset_button, font=button_font)
|
||||
|
||||
# ==============================================================
|
||||
# IMPORTANT: Callbacks are bound AFTER layout is complete
|
||||
# This allows callbacks to access the application instance
|
||||
# ==============================================================
|
||||
|
||||
# Bind callbacks using the page's callback registry
|
||||
page.callbacks.set_callback("save-btn", app.on_save_click)
|
||||
page.callbacks.set_callback("cancel-btn", app.on_cancel_click)
|
||||
page.callbacks.set_callback("reset-btn", app.on_reset_click)
|
||||
|
||||
# Track element ids for later reference
|
||||
element_ids = {
|
||||
"save_button": save_id,
|
||||
"cancel_button": cancel_id,
|
||||
"reset_button": reset_id,
|
||||
"form_fields": field_ids
|
||||
}
|
||||
|
||||
return page, app, element_ids
|
||||
|
||||
|
||||
def demonstrate_callback_binding():
|
||||
"""Demonstrate various callback binding patterns"""
|
||||
|
||||
print("=" * 60)
|
||||
print("Functional Elements Demo: Manual GUI Construction")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Create the page
|
||||
page, app, element_ids = create_settings_page()
|
||||
|
||||
print(f"Page created with {page.callbacks.count()} interactable elements")
|
||||
print()
|
||||
|
||||
# Show what's registered
|
||||
print("Registered interactables:")
|
||||
for id_name in page.callbacks.get_all_ids():
|
||||
print(f" - {id_name}")
|
||||
print()
|
||||
|
||||
# Show breakdown by type
|
||||
print("Breakdown by type:")
|
||||
for type_name in page.callbacks.get_all_types():
|
||||
count = page.callbacks.count_by_type(type_name)
|
||||
print(f" - {type_name}: {count}")
|
||||
print()
|
||||
|
||||
# Simulate user clicking the save button
|
||||
print("Simulating user interaction:")
|
||||
print("-" * 60)
|
||||
print()
|
||||
|
||||
# Get the save button
|
||||
save_button = page.callbacks.get_by_id("save-btn")
|
||||
print(f"Retrieved save button: {save_button}")
|
||||
|
||||
# Simulate a click at position (50, 200)
|
||||
click_point = np.array([50, 200])
|
||||
print(f"Simulating click at {click_point}...")
|
||||
result = save_button.interact(click_point)
|
||||
print(f"Button interaction returned: {result}")
|
||||
print(f"App.saved state: {app.saved}")
|
||||
print()
|
||||
|
||||
# Simulate clicking cancel
|
||||
cancel_button = page.callbacks.get_by_id("cancel-btn")
|
||||
print("Simulating cancel button click...")
|
||||
result = cancel_button.interact(click_point)
|
||||
print(f"Button interaction returned: {result}")
|
||||
print()
|
||||
|
||||
# Simulate clicking reset
|
||||
reset_button = page.callbacks.get_by_id("reset-btn")
|
||||
print("Simulating reset button click...")
|
||||
result = reset_button.interact(click_point)
|
||||
print(f"Button interaction returned: {result}")
|
||||
print()
|
||||
|
||||
# Demonstrate batch callback modification
|
||||
print("-" * 60)
|
||||
print("Demonstrating batch callback modification:")
|
||||
print()
|
||||
|
||||
def log_all_clicks(point, **kwargs):
|
||||
"""Generic click logger"""
|
||||
print(f" [LOG] Button clicked at {point}")
|
||||
return "logged"
|
||||
|
||||
# Set this callback for all buttons
|
||||
count = page.callbacks.set_callbacks_by_type("button", log_all_clicks)
|
||||
print(f"Set logging callback for {count} buttons")
|
||||
print()
|
||||
|
||||
# Now clicking any button will just log
|
||||
print("Clicking save button again (now with logging callback):")
|
||||
result = save_button.interact(click_point)
|
||||
print(f"Returned: {result}")
|
||||
print()
|
||||
|
||||
# Render the page
|
||||
print("-" * 60)
|
||||
print("Rendering page...")
|
||||
image = page.render()
|
||||
print(f"Page rendered: {image.size}")
|
||||
|
||||
# Save to file
|
||||
output_path = "functional_elements_demo.png"
|
||||
image.save(output_path)
|
||||
print(f"Saved to: {output_path}")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("Demo complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demonstrate_callback_binding()
|
||||
@ -83,6 +83,24 @@ Demonstrates:
|
||||
|
||||

|
||||
|
||||
### 06. Functional Elements (Interactive)
|
||||
**`06_functional_elements_demo.py`** - Interactive buttons and forms with callbacks
|
||||
|
||||
```bash
|
||||
python 06_functional_elements_demo.py
|
||||
```
|
||||
|
||||
Demonstrates:
|
||||
- Creating interactive buttons
|
||||
- Building forms with multiple field types
|
||||
- Post-layout callback binding
|
||||
- CallbackRegistry system for managing interactables
|
||||
- Accessing application state from callbacks
|
||||
- Batch callback operations
|
||||
- Simulating user interactions
|
||||
|
||||

|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### HTML Rendering
|
||||
@ -106,6 +124,7 @@ python 02_text_and_layout.py
|
||||
python 03_page_layouts.py
|
||||
python 04_table_rendering.py
|
||||
python 05_table_with_images.py
|
||||
python 06_functional_elements_demo.py
|
||||
```
|
||||
|
||||
Output images are saved to the `docs/images/` directory.
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"document_id": "test",
|
||||
"highlights": []
|
||||
}
|
||||
@ -19,27 +19,30 @@ class Link(Interactable):
|
||||
or to trigger API calls for functionality like settings management.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
location: str,
|
||||
def __init__(self,
|
||||
location: str,
|
||||
link_type: LinkType = LinkType.INTERNAL,
|
||||
callback: Optional[Callable] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
title: Optional[str] = None):
|
||||
title: Optional[str] = None,
|
||||
html_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize a link.
|
||||
|
||||
|
||||
Args:
|
||||
location: The target location or identifier for this link
|
||||
link_type: The type of link (internal, external, API, function)
|
||||
callback: Optional callback function to execute when the link is activated
|
||||
params: Optional parameters to pass to the callback or API
|
||||
title: Optional title/tooltip for the link
|
||||
html_id: Optional HTML id attribute (from <a id="...">) for callback binding
|
||||
"""
|
||||
super().__init__(callback)
|
||||
self._location = location
|
||||
self._link_type = link_type
|
||||
self._params = params or {}
|
||||
self._title = title
|
||||
self._html_id = html_id
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
@ -60,7 +63,12 @@ class Link(Interactable):
|
||||
def title(self) -> Optional[str]:
|
||||
"""Get the title/tooltip for this link"""
|
||||
return self._title
|
||||
|
||||
|
||||
@property
|
||||
def html_id(self) -> Optional[str]:
|
||||
"""Get the HTML id attribute for callback binding"""
|
||||
return self._html_id
|
||||
|
||||
def execute(self, point=None) -> Any:
|
||||
"""
|
||||
Execute the link action based on its type.
|
||||
@ -88,24 +96,27 @@ class Button(Interactable):
|
||||
Buttons are similar to function links but are rendered differently.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
def __init__(self,
|
||||
label: str,
|
||||
callback: Callable,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
enabled: bool = True):
|
||||
enabled: bool = True,
|
||||
html_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize a button.
|
||||
|
||||
|
||||
Args:
|
||||
label: The text label for the button
|
||||
callback: The function to execute when the button is clicked
|
||||
params: Optional parameters to pass to the callback
|
||||
enabled: Whether the button is initially enabled
|
||||
html_id: Optional HTML id attribute (from <button id="...">) for callback binding
|
||||
"""
|
||||
super().__init__(callback)
|
||||
self._label = label
|
||||
self._params = params or {}
|
||||
self._enabled = enabled
|
||||
self._html_id = html_id
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
@ -131,7 +142,12 @@ class Button(Interactable):
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the button parameters"""
|
||||
return self._params
|
||||
|
||||
|
||||
@property
|
||||
def html_id(self) -> Optional[str]:
|
||||
"""Get the HTML id attribute for callback binding"""
|
||||
return self._html_id
|
||||
|
||||
def execute(self, point=None) -> Any:
|
||||
"""
|
||||
Execute the button's callback function if the button is enabled.
|
||||
@ -153,22 +169,25 @@ class Form(Interactable):
|
||||
Forms can be used for user input and settings configuration.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
def __init__(self,
|
||||
form_id: str,
|
||||
action: Optional[str] = None,
|
||||
callback: Optional[Callable] = None):
|
||||
callback: Optional[Callable] = None,
|
||||
html_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize a form.
|
||||
|
||||
|
||||
Args:
|
||||
form_id: The unique identifier for this form
|
||||
action: The action URL or endpoint for form submission
|
||||
callback: Optional callback function to execute on form submission
|
||||
html_id: Optional HTML id attribute (from <form id="...">) for callback binding
|
||||
"""
|
||||
super().__init__(callback)
|
||||
self._form_id = form_id
|
||||
self._action = action
|
||||
self._fields: Dict[str, FormField] = {}
|
||||
self._html_id = html_id
|
||||
|
||||
@property
|
||||
def form_id(self) -> str:
|
||||
@ -179,7 +198,12 @@ class Form(Interactable):
|
||||
def action(self) -> Optional[str]:
|
||||
"""Get the form action"""
|
||||
return self._action
|
||||
|
||||
|
||||
@property
|
||||
def html_id(self) -> Optional[str]:
|
||||
"""Get the HTML id attribute for callback binding"""
|
||||
return self._html_id
|
||||
|
||||
def add_field(self, field: FormField):
|
||||
"""
|
||||
Add a field to this form.
|
||||
|
||||
@ -179,15 +179,33 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
# Draw button background with rounded corners
|
||||
button_rect = [self._origin, self._origin + self.size]
|
||||
self._draw.rounded_rectangle(button_rect, fill=bg_color,
|
||||
# rounded_rectangle expects [x0, y0, x1, y1] format
|
||||
button_rect = [
|
||||
int(self._origin[0]),
|
||||
int(self._origin[1]),
|
||||
int(self._origin[0] + self.size[0]),
|
||||
int(self._origin[1] + self.size[1])
|
||||
]
|
||||
self._draw.rounded_rectangle(button_rect, fill=bg_color,
|
||||
outline=border_color, width=1, radius=4)
|
||||
|
||||
# Update text color and render text centered within padding
|
||||
self._style = self._style.with_colour(text_color)
|
||||
text_x = self._origin[0] + self._padding[3] # left padding
|
||||
text_y = self._origin[1] + self._padding[0] # top padding
|
||||
|
||||
|
||||
# Center text vertically within button
|
||||
# Get font metrics to properly center the baseline
|
||||
ascent, descent = self._style.font.getmetrics()
|
||||
|
||||
# Total button height minus top and bottom padding gives us text area height
|
||||
text_area_height = self._padded_height - self._padding[0] - self._padding[2]
|
||||
|
||||
# Center the text visual height (ascent + descent) within the text area
|
||||
# The y position is where the baseline sits
|
||||
# Visual center = area_height/2, baseline should be at center + descent/2
|
||||
vertical_center = text_area_height / 2
|
||||
text_y = self._origin[1] + self._padding[0] + vertical_center + (descent / 2)
|
||||
|
||||
# Temporarily set origin for text rendering
|
||||
original_origin = self._origin.copy()
|
||||
self._origin = np.array([text_x, text_y])
|
||||
@ -300,8 +318,14 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
value_font = self._style.with_colour((0, 0, 0))
|
||||
|
||||
# Position value text within field (with some padding)
|
||||
# Get font metrics to properly center the baseline
|
||||
ascent, descent = value_font.font.getmetrics()
|
||||
|
||||
# Center the text vertically within the field
|
||||
# The y coordinate is where the baseline sits (anchor="ls")
|
||||
vertical_center = self._field_height / 2
|
||||
value_x = field_x + 5
|
||||
value_y = field_y + (self._field_height - self._style.font_size) // 2
|
||||
value_y = field_y + vertical_center + (descent / 2)
|
||||
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
|
||||
@ -4,6 +4,7 @@ from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
@ -33,6 +34,8 @@ class Page(Renderable, Queriable):
|
||||
# For subsequent lines, baseline-to-baseline spacing is used
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
self._is_first_line = True # Track if we're placing the first line
|
||||
# Callback registry for managing interactable elements
|
||||
self._callbacks = CallbackRegistry()
|
||||
|
||||
def free_space(self) -> Tuple[int, int]:
|
||||
"""Get the remaining space on the page"""
|
||||
@ -102,6 +105,11 @@ class Page(Renderable, Queriable):
|
||||
"""Get the page style"""
|
||||
return self._style
|
||||
|
||||
@property
|
||||
def callbacks(self) -> CallbackRegistry:
|
||||
"""Get the callback registry for managing interactable elements"""
|
||||
return self._callbacks
|
||||
|
||||
@property
|
||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
||||
"""Get the ImageDraw object for drawing on this page's canvas"""
|
||||
@ -153,6 +161,8 @@ class Page(Renderable, Queriable):
|
||||
"""
|
||||
self._children.clear()
|
||||
self._canvas = None
|
||||
# Clear callback registry when clearing children
|
||||
self._callbacks.clear()
|
||||
# Reset y_offset to start of content area (after border and padding)
|
||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||
return self
|
||||
|
||||
278
pyWebLayout/core/callback_registry.py
Normal file
278
pyWebLayout/core/callback_registry.py
Normal file
@ -0,0 +1,278 @@
|
||||
"""
|
||||
Callback Registry for managing interactable elements and their callbacks.
|
||||
|
||||
This module provides a registry system for tracking interactive elements (links, buttons, forms)
|
||||
and managing their callbacks. Supports multiple binding strategies:
|
||||
- HTML id attributes for HTML-generated content
|
||||
- Auto-generated ids for programmatic construction
|
||||
- Type-based batch operations
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from pyWebLayout.core.base import Interactable
|
||||
|
||||
|
||||
class CallbackRegistry:
|
||||
"""
|
||||
Registry for managing interactable callbacks with multiple binding strategies.
|
||||
|
||||
Supports:
|
||||
- Direct references by object id
|
||||
- HTML id attributes (from parsed HTML)
|
||||
- Type-based queries (all buttons, all links, etc.)
|
||||
- Auto-generated ids for programmatic construction
|
||||
|
||||
This enables flexible callback binding for both HTML-generated content
|
||||
and manually constructed UIs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty callback registry."""
|
||||
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
|
||||
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
|
||||
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
|
||||
self._auto_counter: int = 0
|
||||
|
||||
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Register an interactable object with optional HTML id.
|
||||
|
||||
The object is always registered by reference (using Python's id()).
|
||||
If an html_id is provided, it's also registered by that id.
|
||||
If no html_id is provided, an auto-generated id is created.
|
||||
|
||||
Args:
|
||||
obj: The interactable object to register
|
||||
html_id: Optional HTML id attribute value (e.g., from <button id="save-btn">)
|
||||
|
||||
Returns:
|
||||
The id used for registration (either html_id or auto-generated)
|
||||
|
||||
Example:
|
||||
>>> button = ButtonText(...)
|
||||
>>> registry.register(button, html_id="save-btn")
|
||||
'save-btn'
|
||||
>>> registry.register(other_button) # No html_id
|
||||
'auto_button_0'
|
||||
"""
|
||||
# Always register by Python object id for direct lookups
|
||||
obj_id = id(obj)
|
||||
self._by_reference[obj_id] = obj
|
||||
|
||||
# Determine type name and register by type
|
||||
type_name = self._get_type_name(obj)
|
||||
if type_name not in self._by_type:
|
||||
self._by_type[type_name] = []
|
||||
self._by_type[type_name].append(obj)
|
||||
|
||||
# Register by HTML id or generate auto id
|
||||
if html_id:
|
||||
# Use provided HTML id
|
||||
self._by_id[html_id] = obj
|
||||
return html_id
|
||||
else:
|
||||
# Generate automatic id
|
||||
auto_id = f"auto_{type_name}_{self._auto_counter}"
|
||||
self._auto_counter += 1
|
||||
self._by_id[auto_id] = obj
|
||||
return auto_id
|
||||
|
||||
def get_by_id(self, identifier: str) -> Optional[Interactable]:
|
||||
"""
|
||||
Get an interactable by its id (HTML id or auto-generated id).
|
||||
|
||||
Args:
|
||||
identifier: The id to lookup (e.g., "save-btn" or "auto_button_0")
|
||||
|
||||
Returns:
|
||||
The interactable object, or None if not found
|
||||
|
||||
Example:
|
||||
>>> button = registry.get_by_id("save-btn")
|
||||
>>> if button:
|
||||
... button._callback = my_save_function
|
||||
"""
|
||||
return self._by_id.get(identifier)
|
||||
|
||||
def get_by_type(self, type_name: str) -> List[Interactable]:
|
||||
"""
|
||||
Get all interactables of a specific type.
|
||||
|
||||
Args:
|
||||
type_name: The type name (e.g., "link", "button", "form_field")
|
||||
|
||||
Returns:
|
||||
List of interactable objects of that type (may be empty)
|
||||
|
||||
Example:
|
||||
>>> all_buttons = registry.get_by_type("button")
|
||||
>>> for button in all_buttons:
|
||||
... print(button.text)
|
||||
"""
|
||||
return self._by_type.get(type_name, []).copy()
|
||||
|
||||
def get_all_ids(self) -> List[str]:
|
||||
"""
|
||||
Get all registered ids (both HTML ids and auto-generated ids).
|
||||
|
||||
Returns:
|
||||
List of all ids in the registry
|
||||
|
||||
Example:
|
||||
>>> ids = registry.get_all_ids()
|
||||
>>> print(ids)
|
||||
['save-btn', 'cancel-btn', 'auto_link_0', 'auto_button_1']
|
||||
"""
|
||||
return list(self._by_id.keys())
|
||||
|
||||
def get_all_types(self) -> List[str]:
|
||||
"""
|
||||
Get all registered type names.
|
||||
|
||||
Returns:
|
||||
List of type names that have registered objects
|
||||
|
||||
Example:
|
||||
>>> types = registry.get_all_types()
|
||||
>>> print(types)
|
||||
['link', 'button', 'form_field']
|
||||
"""
|
||||
return list(self._by_type.keys())
|
||||
|
||||
def set_callback(self, identifier: str, callback: Callable) -> bool:
|
||||
"""
|
||||
Set the callback for an interactable by its id.
|
||||
|
||||
Args:
|
||||
identifier: The id of the interactable
|
||||
callback: The callback function to set
|
||||
|
||||
Returns:
|
||||
True if the interactable was found and callback set, False otherwise
|
||||
|
||||
Example:
|
||||
>>> def on_save(point):
|
||||
... print("Save clicked!")
|
||||
>>> registry.set_callback("save-btn", on_save)
|
||||
True
|
||||
"""
|
||||
obj = self.get_by_id(identifier)
|
||||
if obj:
|
||||
obj._callback = callback
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_callbacks_by_type(self, type_name: str, callback: Callable) -> int:
|
||||
"""
|
||||
Set the callback for all interactables of a specific type.
|
||||
|
||||
Useful for batch operations like setting a default click sound
|
||||
for all buttons, or a default link handler for all links.
|
||||
|
||||
Args:
|
||||
type_name: The type name (e.g., "button", "link")
|
||||
callback: The callback function to set
|
||||
|
||||
Returns:
|
||||
Number of objects that had their callback set
|
||||
|
||||
Example:
|
||||
>>> def play_click_sound(point):
|
||||
... audio.play("click.wav")
|
||||
>>> count = registry.set_callbacks_by_type("button", play_click_sound)
|
||||
>>> print(f"Set callback for {count} buttons")
|
||||
"""
|
||||
objects = self.get_by_type(type_name)
|
||||
for obj in objects:
|
||||
obj._callback = callback
|
||||
return len(objects)
|
||||
|
||||
def unregister(self, identifier: str) -> bool:
|
||||
"""
|
||||
Unregister an interactable by its id.
|
||||
|
||||
Args:
|
||||
identifier: The id of the interactable to unregister
|
||||
|
||||
Returns:
|
||||
True if the interactable was found and unregistered, False otherwise
|
||||
"""
|
||||
obj = self._by_id.pop(identifier, None)
|
||||
if obj:
|
||||
# Remove from reference map
|
||||
self._by_reference.pop(id(obj), None)
|
||||
|
||||
# Remove from type map
|
||||
type_name = self._get_type_name(obj)
|
||||
if type_name in self._by_type:
|
||||
try:
|
||||
self._by_type[type_name].remove(obj)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self):
|
||||
"""Clear all registered interactables."""
|
||||
self._by_reference.clear()
|
||||
self._by_id.clear()
|
||||
self._by_type.clear()
|
||||
self._auto_counter = 0
|
||||
|
||||
def count(self) -> int:
|
||||
"""
|
||||
Get the total number of registered interactables.
|
||||
|
||||
Returns:
|
||||
Total count of registered objects
|
||||
"""
|
||||
return len(self._by_id)
|
||||
|
||||
def count_by_type(self, type_name: str) -> int:
|
||||
"""
|
||||
Get the count of interactables of a specific type.
|
||||
|
||||
Args:
|
||||
type_name: The type name to count
|
||||
|
||||
Returns:
|
||||
Number of objects of that type
|
||||
"""
|
||||
return len(self._by_type.get(type_name, []))
|
||||
|
||||
def _get_type_name(self, obj: Interactable) -> str:
|
||||
"""
|
||||
Get a normalized type name for an interactable object.
|
||||
|
||||
Args:
|
||||
obj: The interactable object
|
||||
|
||||
Returns:
|
||||
Type name string (e.g., "link", "button", "form_field")
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
|
||||
if isinstance(obj, LinkText):
|
||||
return "link"
|
||||
elif isinstance(obj, ButtonText):
|
||||
return "button"
|
||||
elif isinstance(obj, FormFieldText):
|
||||
return "form_field"
|
||||
else:
|
||||
# Fallback to class name
|
||||
return obj.__class__.__name__.lower()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Support len() to get count of registered interactables."""
|
||||
return self.count()
|
||||
|
||||
def __contains__(self, identifier: str) -> bool:
|
||||
"""Support 'in' operator to check if an id is registered."""
|
||||
return identifier in self._by_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation showing registry statistics."""
|
||||
type_counts = {t: len(objs) for t, objs in self._by_type.items()}
|
||||
return f"CallbackRegistry(total={self.count()}, types={type_counts})"
|
||||
@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple, Optional, Union
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.concrete import Page, Line, Text
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.concrete.functional import LinkText
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
@ -340,6 +342,157 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
return True
|
||||
|
||||
|
||||
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
"""
|
||||
Layout a button within a given page and register it for callback binding.
|
||||
|
||||
This function creates a ButtonText renderable, positions it on the page,
|
||||
and registers it in the page's callback registry using the button's html_id
|
||||
(if available) or an auto-generated id.
|
||||
|
||||
Args:
|
||||
button: The abstract Button object to layout
|
||||
page: The page to layout the button on
|
||||
font: Optional font for button text (defaults to page default)
|
||||
padding: Padding around button text (top, right, bottom, left)
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- bool: True if button was successfully laid out, False if page ran out of space
|
||||
- str: The id used to register the button in the callback registry
|
||||
"""
|
||||
# Use provided font or create a default one
|
||||
if font is None:
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
|
||||
# Create ButtonText renderable
|
||||
button_text = ButtonText(button, font, page.draw, padding=padding)
|
||||
|
||||
# Check if button fits on current page
|
||||
button_height = button_text.size[1]
|
||||
if button_height > available_height:
|
||||
return False, ""
|
||||
|
||||
# Position the button
|
||||
x_offset = page.border_size
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
button_text.set_origin(np.array([x_offset, y_offset]))
|
||||
|
||||
# Register in callback registry
|
||||
html_id = button.html_id
|
||||
registered_id = page.callbacks.register(button_text, html_id=html_id)
|
||||
|
||||
# Add to page
|
||||
page.add_child(button_text)
|
||||
|
||||
return True, registered_id
|
||||
|
||||
|
||||
def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = None,
|
||||
field_height: int = 24) -> Tuple[bool, str]:
|
||||
"""
|
||||
Layout a form field within a given page and register it for callback binding.
|
||||
|
||||
This function creates a FormFieldText renderable, positions it on the page,
|
||||
and registers it in the page's callback registry.
|
||||
|
||||
Args:
|
||||
field: The abstract FormField object to layout
|
||||
page: The page to layout the field on
|
||||
font: Optional font for field label (defaults to page default)
|
||||
field_height: Height of the input field area
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- bool: True if field was successfully laid out, False if page ran out of space
|
||||
- str: The id used to register the field in the callback registry
|
||||
"""
|
||||
# Use provided font or create a default one
|
||||
if font is None:
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
|
||||
# Create FormFieldText renderable
|
||||
field_text = FormFieldText(field, font, page.draw, field_height=field_height)
|
||||
|
||||
# Check if field fits on current page
|
||||
total_field_height = field_text.size[1]
|
||||
if total_field_height > available_height:
|
||||
return False, ""
|
||||
|
||||
# Position the field
|
||||
x_offset = page.border_size
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
field_text.set_origin(np.array([x_offset, y_offset]))
|
||||
|
||||
# Register in callback registry (use field name as html_id fallback)
|
||||
html_id = getattr(field, '_html_id', None) or field.name
|
||||
registered_id = page.callbacks.register(field_text, html_id=html_id)
|
||||
|
||||
# Add to page
|
||||
page.add_child(field_text)
|
||||
|
||||
return True, registered_id
|
||||
|
||||
|
||||
def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
|
||||
field_spacing: int = 10) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Layout a complete form with all its fields within a given page.
|
||||
|
||||
This function creates FormFieldText renderables for all fields in the form,
|
||||
positions them vertically, and registers both the form and its fields in
|
||||
the page's callback registry.
|
||||
|
||||
Args:
|
||||
form: The abstract Form object to layout
|
||||
page: The page to layout the form on
|
||||
font: Optional font for field labels (defaults to page default)
|
||||
field_spacing: Vertical spacing between fields in pixels
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- bool: True if form was successfully laid out, False if page ran out of space
|
||||
- List[str]: List of registered ids for all fields (empty if layout failed)
|
||||
"""
|
||||
# Use provided font or create a default one
|
||||
if font is None:
|
||||
font = Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Track registered field ids
|
||||
field_ids = []
|
||||
|
||||
# Layout each field in the form
|
||||
for field_name, field in form._fields.items():
|
||||
# Add spacing before each field (except the first)
|
||||
if field_ids:
|
||||
page._current_y_offset += field_spacing
|
||||
|
||||
# Layout the field
|
||||
success, field_id = form_field_layouter(field, page, font)
|
||||
|
||||
if not success:
|
||||
# Couldn't fit this field, return failure
|
||||
return False, []
|
||||
|
||||
field_ids.append(field_id)
|
||||
|
||||
# Register the form itself (optional, for form submission)
|
||||
# Note: The form doesn't have a visual representation, but we can track it
|
||||
# for submission callbacks
|
||||
# form_id = page.callbacks.register(form, html_id=form.html_id)
|
||||
|
||||
return True, field_ids
|
||||
|
||||
|
||||
class DocumentLayouter:
|
||||
"""
|
||||
Document layouter that orchestrates layout of various abstract elements.
|
||||
@ -415,14 +568,46 @@ class DocumentLayouter:
|
||||
"""
|
||||
return table_layouter(table, self.page, style)
|
||||
|
||||
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table]]) -> bool:
|
||||
def layout_button(self, button: Button, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
"""
|
||||
Layout a list of abstract elements (paragraphs, images, and tables).
|
||||
Layout a button using the button_layouter.
|
||||
|
||||
Args:
|
||||
button: The abstract Button object to layout
|
||||
font: Optional font for button text
|
||||
padding: Padding around button text
|
||||
|
||||
Returns:
|
||||
Tuple of (success, registered_id)
|
||||
"""
|
||||
return button_layouter(button, self.page, font, padding)
|
||||
|
||||
def layout_form(self, form: Form, font: Optional[Font] = None,
|
||||
field_spacing: int = 10) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Layout a form using the form_layouter.
|
||||
|
||||
Args:
|
||||
form: The abstract Form object to layout
|
||||
font: Optional font for field labels
|
||||
field_spacing: Vertical spacing between fields
|
||||
|
||||
Returns:
|
||||
Tuple of (success, list_of_field_ids)
|
||||
"""
|
||||
return form_layouter(form, self.page, font, field_spacing)
|
||||
|
||||
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
||||
"""
|
||||
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
|
||||
|
||||
This method delegates to specialized layouters based on element type:
|
||||
- Paragraphs are handled by layout_paragraph
|
||||
- Images are handled by layout_image
|
||||
- Tables are handled by layout_table
|
||||
- Buttons are handled by layout_button
|
||||
- Forms are handled by layout_form
|
||||
|
||||
Args:
|
||||
elements: List of abstract elements to layout
|
||||
@ -443,5 +628,13 @@ class DocumentLayouter:
|
||||
success = self.layout_table(element)
|
||||
if not success:
|
||||
return False
|
||||
elif isinstance(element, Button):
|
||||
success, _ = self.layout_button(element)
|
||||
if not success:
|
||||
return False
|
||||
elif isinstance(element, Form):
|
||||
success, _ = self.layout_form(element)
|
||||
if not success:
|
||||
return False
|
||||
# Future: elif isinstance(element, CodeBlock): use code_layouter
|
||||
return True
|
||||
|
||||
212
tests/test_callback_registry.py
Normal file
212
tests/test_callback_registry.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
Unit tests for the CallbackRegistry system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||
from pyWebLayout.core.base import Interactable
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
class TestCallbackRegistry(unittest.TestCase):
|
||||
"""Test cases for CallbackRegistry class."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.registry = CallbackRegistry()
|
||||
self.font = Font(font_size=12, colour=(0, 0, 0))
|
||||
self.mock_draw = Mock()
|
||||
|
||||
def test_registry_initialization(self):
|
||||
"""Test that registry initializes empty."""
|
||||
self.assertEqual(self.registry.count(), 0)
|
||||
self.assertEqual(len(self.registry.get_all_ids()), 0)
|
||||
self.assertEqual(len(self.registry.get_all_types()), 0)
|
||||
|
||||
def test_register_with_html_id(self):
|
||||
"""Test registering an interactable with HTML id."""
|
||||
link = Link("https://example.com", LinkType.EXTERNAL, html_id="my-link")
|
||||
link_text = LinkText(link, "Example", self.font, self.mock_draw)
|
||||
|
||||
registered_id = self.registry.register(link_text, html_id="my-link")
|
||||
|
||||
self.assertEqual(registered_id, "my-link")
|
||||
self.assertEqual(self.registry.count(), 1)
|
||||
self.assertIn("my-link", self.registry)
|
||||
|
||||
def test_register_without_html_id(self):
|
||||
"""Test registering an interactable without HTML id (auto-generated)."""
|
||||
button = Button("Click Me", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
|
||||
registered_id = self.registry.register(button_text)
|
||||
|
||||
# Should generate auto id like "auto_button_0"
|
||||
self.assertTrue(registered_id.startswith("auto_button_"))
|
||||
self.assertEqual(self.registry.count(), 1)
|
||||
|
||||
def test_get_by_id(self):
|
||||
"""Test retrieving an interactable by id."""
|
||||
link = Link("test", html_id="test-link")
|
||||
link_text = LinkText(link, "Test", self.font, self.mock_draw)
|
||||
self.registry.register(link_text, html_id="test-link")
|
||||
|
||||
retrieved = self.registry.get_by_id("test-link")
|
||||
|
||||
self.assertEqual(retrieved, link_text)
|
||||
|
||||
def test_get_by_id_not_found(self):
|
||||
"""Test retrieving non-existent id returns None."""
|
||||
result = self.registry.get_by_id("nonexistent")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_by_type(self):
|
||||
"""Test retrieving all interactables of a type."""
|
||||
# Register multiple buttons
|
||||
button1 = Button("Button 1", callback=None)
|
||||
button2 = Button("Button 2", callback=None)
|
||||
button_text1 = ButtonText(button1, self.font, self.mock_draw)
|
||||
button_text2 = ButtonText(button2, self.font, self.mock_draw)
|
||||
|
||||
self.registry.register(button_text1, html_id="btn1")
|
||||
self.registry.register(button_text2, html_id="btn2")
|
||||
|
||||
# Also register a link
|
||||
link = Link("test")
|
||||
link_text = LinkText(link, "Test", self.font, self.mock_draw)
|
||||
self.registry.register(link_text, html_id="link1")
|
||||
|
||||
buttons = self.registry.get_by_type("button")
|
||||
links = self.registry.get_by_type("link")
|
||||
|
||||
self.assertEqual(len(buttons), 2)
|
||||
self.assertIn(button_text1, buttons)
|
||||
self.assertIn(button_text2, buttons)
|
||||
|
||||
self.assertEqual(len(links), 1)
|
||||
self.assertIn(link_text, links)
|
||||
|
||||
def test_set_callback(self):
|
||||
"""Test setting a callback by id."""
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text, html_id="test-btn")
|
||||
|
||||
def my_callback(point):
|
||||
return "clicked"
|
||||
|
||||
success = self.registry.set_callback("test-btn", my_callback)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(button_text._callback, my_callback)
|
||||
|
||||
def test_set_callback_not_found(self):
|
||||
"""Test setting callback for non-existent id returns False."""
|
||||
result = self.registry.set_callback("nonexistent", lambda: None)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_set_callbacks_by_type(self):
|
||||
"""Test setting callbacks for all objects of a type."""
|
||||
# Register multiple buttons
|
||||
button1 = Button("Button 1", callback=None)
|
||||
button2 = Button("Button 2", callback=None)
|
||||
button_text1 = ButtonText(button1, self.font, self.mock_draw)
|
||||
button_text2 = ButtonText(button2, self.font, self.mock_draw)
|
||||
|
||||
self.registry.register(button_text1)
|
||||
self.registry.register(button_text2)
|
||||
|
||||
def click_handler(point):
|
||||
return "clicked"
|
||||
|
||||
count = self.registry.set_callbacks_by_type("button", click_handler)
|
||||
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(button_text1._callback, click_handler)
|
||||
self.assertEqual(button_text2._callback, click_handler)
|
||||
|
||||
def test_unregister(self):
|
||||
"""Test unregistering an interactable."""
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text, html_id="test-btn")
|
||||
|
||||
self.assertEqual(self.registry.count(), 1)
|
||||
|
||||
success = self.registry.unregister("test-btn")
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(self.registry.count(), 0)
|
||||
self.assertNotIn("test-btn", self.registry)
|
||||
|
||||
def test_clear(self):
|
||||
"""Test clearing all registrations."""
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
link = Link("test")
|
||||
link_text = LinkText(link, "Test", self.font, self.mock_draw)
|
||||
|
||||
self.registry.register(button_text)
|
||||
self.registry.register(link_text)
|
||||
|
||||
self.assertEqual(self.registry.count(), 2)
|
||||
|
||||
self.registry.clear()
|
||||
|
||||
self.assertEqual(self.registry.count(), 0)
|
||||
self.assertEqual(len(self.registry.get_all_types()), 0)
|
||||
|
||||
def test_count_by_type(self):
|
||||
"""Test counting objects by type."""
|
||||
# Register 2 buttons and 1 link
|
||||
for i in range(2):
|
||||
button = Button(f"Button {i}", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text)
|
||||
|
||||
link = Link("test")
|
||||
link_text = LinkText(link, "Test", self.font, self.mock_draw)
|
||||
self.registry.register(link_text)
|
||||
|
||||
self.assertEqual(self.registry.count_by_type("button"), 2)
|
||||
self.assertEqual(self.registry.count_by_type("link"), 1)
|
||||
self.assertEqual(self.registry.count_by_type("form_field"), 0)
|
||||
|
||||
def test_contains_operator(self):
|
||||
"""Test 'in' operator for checking if id exists."""
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text, html_id="test-btn")
|
||||
|
||||
self.assertIn("test-btn", self.registry)
|
||||
self.assertNotIn("nonexistent", self.registry)
|
||||
|
||||
def test_len_operator(self):
|
||||
"""Test len() operator."""
|
||||
self.assertEqual(len(self.registry), 0)
|
||||
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text)
|
||||
|
||||
self.assertEqual(len(self.registry), 1)
|
||||
|
||||
def test_repr(self):
|
||||
"""Test string representation."""
|
||||
button = Button("Test", callback=None)
|
||||
button_text = ButtonText(button, self.font, self.mock_draw)
|
||||
self.registry.register(button_text)
|
||||
|
||||
repr_str = repr(self.registry)
|
||||
|
||||
self.assertIn("CallbackRegistry", repr_str)
|
||||
self.assertIn("total=1", repr_str)
|
||||
self.assertIn("button", repr_str)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user