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
|
- ↔️ **Text Alignment** - Left, center, right, and justified text
|
||||||
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
|
- 📖 **Rich Content** - Headings, paragraphs, bold, italic, and more
|
||||||
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
|
- 📊 **Table Rendering** - Full HTML table support with headers, borders, and styling
|
||||||
|
- 🔘 **Interactive Elements** - Buttons, forms, and links with callback support
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
- **Abstract/Concrete Separation** - Clean separation between content structure and rendering
|
- **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>
|
<em>HTML tables with headers and styling</em>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@ -123,6 +131,7 @@ The `examples/` directory contains working demonstrations:
|
|||||||
- **[03_page_layouts.py](examples/03_page_layouts.py)** - Different page configurations
|
- **[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
|
- **[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
|
- **[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
|
### Advanced Examples
|
||||||
- **[html_multipage_simple.py](examples/html_multipage_simple.py)** - Multi-page HTML rendering
|
- **[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
|
## Advanced Examples
|
||||||
|
|
||||||
### HTML Rendering
|
### HTML Rendering
|
||||||
@ -106,6 +124,7 @@ python 02_text_and_layout.py
|
|||||||
python 03_page_layouts.py
|
python 03_page_layouts.py
|
||||||
python 04_table_rendering.py
|
python 04_table_rendering.py
|
||||||
python 05_table_with_images.py
|
python 05_table_with_images.py
|
||||||
|
python 06_functional_elements_demo.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Output images are saved to the `docs/images/` directory.
|
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.
|
or to trigger API calls for functionality like settings management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
location: str,
|
location: str,
|
||||||
link_type: LinkType = LinkType.INTERNAL,
|
link_type: LinkType = LinkType.INTERNAL,
|
||||||
callback: Optional[Callable] = None,
|
callback: Optional[Callable] = None,
|
||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
title: Optional[str] = None):
|
title: Optional[str] = None,
|
||||||
|
html_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize a link.
|
Initialize a link.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
location: The target location or identifier for this link
|
location: The target location or identifier for this link
|
||||||
link_type: The type of link (internal, external, API, function)
|
link_type: The type of link (internal, external, API, function)
|
||||||
callback: Optional callback function to execute when the link is activated
|
callback: Optional callback function to execute when the link is activated
|
||||||
params: Optional parameters to pass to the callback or API
|
params: Optional parameters to pass to the callback or API
|
||||||
title: Optional title/tooltip for the link
|
title: Optional title/tooltip for the link
|
||||||
|
html_id: Optional HTML id attribute (from <a id="...">) for callback binding
|
||||||
"""
|
"""
|
||||||
super().__init__(callback)
|
super().__init__(callback)
|
||||||
self._location = location
|
self._location = location
|
||||||
self._link_type = link_type
|
self._link_type = link_type
|
||||||
self._params = params or {}
|
self._params = params or {}
|
||||||
self._title = title
|
self._title = title
|
||||||
|
self._html_id = html_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def location(self) -> str:
|
def location(self) -> str:
|
||||||
@ -60,7 +63,12 @@ class Link(Interactable):
|
|||||||
def title(self) -> Optional[str]:
|
def title(self) -> Optional[str]:
|
||||||
"""Get the title/tooltip for this link"""
|
"""Get the title/tooltip for this link"""
|
||||||
return self._title
|
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:
|
def execute(self, point=None) -> Any:
|
||||||
"""
|
"""
|
||||||
Execute the link action based on its type.
|
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.
|
Buttons are similar to function links but are rendered differently.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
label: str,
|
label: str,
|
||||||
callback: Callable,
|
callback: Callable,
|
||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
enabled: bool = True):
|
enabled: bool = True,
|
||||||
|
html_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize a button.
|
Initialize a button.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
label: The text label for the button
|
label: The text label for the button
|
||||||
callback: The function to execute when the button is clicked
|
callback: The function to execute when the button is clicked
|
||||||
params: Optional parameters to pass to the callback
|
params: Optional parameters to pass to the callback
|
||||||
enabled: Whether the button is initially enabled
|
enabled: Whether the button is initially enabled
|
||||||
|
html_id: Optional HTML id attribute (from <button id="...">) for callback binding
|
||||||
"""
|
"""
|
||||||
super().__init__(callback)
|
super().__init__(callback)
|
||||||
self._label = label
|
self._label = label
|
||||||
self._params = params or {}
|
self._params = params or {}
|
||||||
self._enabled = enabled
|
self._enabled = enabled
|
||||||
|
self._html_id = html_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
@ -131,7 +142,12 @@ class Button(Interactable):
|
|||||||
def params(self) -> Dict[str, Any]:
|
def params(self) -> Dict[str, Any]:
|
||||||
"""Get the button parameters"""
|
"""Get the button parameters"""
|
||||||
return self._params
|
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:
|
def execute(self, point=None) -> Any:
|
||||||
"""
|
"""
|
||||||
Execute the button's callback function if the button is enabled.
|
Execute the button's callback function if the button is enabled.
|
||||||
@ -153,22 +169,25 @@ class Form(Interactable):
|
|||||||
Forms can be used for user input and settings configuration.
|
Forms can be used for user input and settings configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
form_id: str,
|
form_id: str,
|
||||||
action: Optional[str] = None,
|
action: Optional[str] = None,
|
||||||
callback: Optional[Callable] = None):
|
callback: Optional[Callable] = None,
|
||||||
|
html_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Initialize a form.
|
Initialize a form.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
form_id: The unique identifier for this form
|
form_id: The unique identifier for this form
|
||||||
action: The action URL or endpoint for form submission
|
action: The action URL or endpoint for form submission
|
||||||
callback: Optional callback function to execute on 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)
|
super().__init__(callback)
|
||||||
self._form_id = form_id
|
self._form_id = form_id
|
||||||
self._action = action
|
self._action = action
|
||||||
self._fields: Dict[str, FormField] = {}
|
self._fields: Dict[str, FormField] = {}
|
||||||
|
self._html_id = html_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def form_id(self) -> str:
|
def form_id(self) -> str:
|
||||||
@ -179,7 +198,12 @@ class Form(Interactable):
|
|||||||
def action(self) -> Optional[str]:
|
def action(self) -> Optional[str]:
|
||||||
"""Get the form action"""
|
"""Get the form action"""
|
||||||
return self._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):
|
def add_field(self, field: FormField):
|
||||||
"""
|
"""
|
||||||
Add a field to this form.
|
Add a field to this form.
|
||||||
|
|||||||
@ -179,15 +179,33 @@ class ButtonText(Text, Interactable, Queriable):
|
|||||||
text_color = (255, 255, 255)
|
text_color = (255, 255, 255)
|
||||||
|
|
||||||
# Draw button background with rounded corners
|
# Draw button background with rounded corners
|
||||||
button_rect = [self._origin, self._origin + self.size]
|
# rounded_rectangle expects [x0, y0, x1, y1] format
|
||||||
self._draw.rounded_rectangle(button_rect, fill=bg_color,
|
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)
|
outline=border_color, width=1, radius=4)
|
||||||
|
|
||||||
# Update text color and render text centered within padding
|
# Update text color and render text centered within padding
|
||||||
self._style = self._style.with_colour(text_color)
|
self._style = self._style.with_colour(text_color)
|
||||||
text_x = self._origin[0] + self._padding[3] # left padding
|
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
|
# Temporarily set origin for text rendering
|
||||||
original_origin = self._origin.copy()
|
original_origin = self._origin.copy()
|
||||||
self._origin = np.array([text_x, text_y])
|
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))
|
value_font = self._style.with_colour((0, 0, 0))
|
||||||
|
|
||||||
# Position value text within field (with some padding)
|
# 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_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
|
# Draw the value text
|
||||||
self._draw.text((value_x, value_y), 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.base import Renderable, Layoutable, Queriable
|
||||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||||
|
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||||
from pyWebLayout.style.page_style import PageStyle
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
from pyWebLayout.style import Alignment
|
from pyWebLayout.style import Alignment
|
||||||
from .box import Box
|
from .box import Box
|
||||||
@ -33,6 +34,8 @@ class Page(Renderable, Queriable):
|
|||||||
# For subsequent lines, baseline-to-baseline spacing is used
|
# For subsequent lines, baseline-to-baseline spacing is used
|
||||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
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
|
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]:
|
def free_space(self) -> Tuple[int, int]:
|
||||||
"""Get the remaining space on the page"""
|
"""Get the remaining space on the page"""
|
||||||
@ -102,6 +105,11 @@ class Page(Renderable, Queriable):
|
|||||||
"""Get the page style"""
|
"""Get the page style"""
|
||||||
return self._style
|
return self._style
|
||||||
|
|
||||||
|
@property
|
||||||
|
def callbacks(self) -> CallbackRegistry:
|
||||||
|
"""Get the callback registry for managing interactable elements"""
|
||||||
|
return self._callbacks
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def draw(self) -> Optional[ImageDraw.Draw]:
|
def draw(self) -> Optional[ImageDraw.Draw]:
|
||||||
"""Get the ImageDraw object for drawing on this page's canvas"""
|
"""Get the ImageDraw object for drawing on this page's canvas"""
|
||||||
@ -153,6 +161,8 @@ class Page(Renderable, Queriable):
|
|||||||
"""
|
"""
|
||||||
self._children.clear()
|
self._children.clear()
|
||||||
self._canvas = None
|
self._canvas = None
|
||||||
|
# Clear callback registry when clearing children
|
||||||
|
self._callbacks.clear()
|
||||||
# Reset y_offset to start of content area (after border and padding)
|
# Reset y_offset to start of content area (after border and padding)
|
||||||
self._current_y_offset = self._style.border_width + self._style.padding_top
|
self._current_y_offset = self._style.border_width + self._style.padding_top
|
||||||
return self
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Tuple, Optional, Union
|
from typing import List, Tuple, Optional, Union
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from pyWebLayout.concrete import Page, Line, Text
|
from pyWebLayout.concrete import Page, Line, Text
|
||||||
from pyWebLayout.concrete.image import RenderableImage
|
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.concrete.table import TableRenderer, TableStyle
|
||||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
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.abstract.inline import LinkedWord
|
||||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||||
from pyWebLayout.style import Font, Alignment
|
from pyWebLayout.style import Font, Alignment
|
||||||
@ -340,6 +342,157 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
|||||||
return True
|
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:
|
class DocumentLayouter:
|
||||||
"""
|
"""
|
||||||
Document layouter that orchestrates layout of various abstract elements.
|
Document layouter that orchestrates layout of various abstract elements.
|
||||||
@ -415,14 +568,46 @@ class DocumentLayouter:
|
|||||||
"""
|
"""
|
||||||
return table_layouter(table, self.page, style)
|
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:
|
This method delegates to specialized layouters based on element type:
|
||||||
- Paragraphs are handled by layout_paragraph
|
- Paragraphs are handled by layout_paragraph
|
||||||
- Images are handled by layout_image
|
- Images are handled by layout_image
|
||||||
- Tables are handled by layout_table
|
- Tables are handled by layout_table
|
||||||
|
- Buttons are handled by layout_button
|
||||||
|
- Forms are handled by layout_form
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
elements: List of abstract elements to layout
|
elements: List of abstract elements to layout
|
||||||
@ -443,5 +628,13 @@ class DocumentLayouter:
|
|||||||
success = self.layout_table(element)
|
success = self.layout_table(element)
|
||||||
if not success:
|
if not success:
|
||||||
return False
|
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
|
# Future: elif isinstance(element, CodeBlock): use code_layouter
|
||||||
return True
|
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