integration of functional elements
All checks were successful
Python CI / test (push) Successful in 6m46s

This commit is contained in:
Duncan Tourolle 2025-11-08 10:17:01 +01:00
parent ea93681aaf
commit 39622c7dd7
11 changed files with 1082 additions and 25 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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()

View File

@ -83,6 +83,24 @@ Demonstrates:
![Table with Images Example](../docs/images/example_05_table_with_images.png)
### 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
![Functional Elements Example](../docs/images/example_06_functional_elements.png)
## 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.

View File

@ -1,4 +0,0 @@
{
"document_id": "test",
"highlights": []
}

View File

@ -24,7 +24,8 @@ class Link(Interactable):
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.
@ -34,12 +35,14 @@ class Link(Interactable):
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:
@ -61,6 +64,11 @@ class Link(Interactable):
"""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.
@ -92,7 +100,8 @@ class Button(Interactable):
label: str,
callback: Callable,
params: Optional[Dict[str, Any]] = None,
enabled: bool = True):
enabled: bool = True,
html_id: Optional[str] = None):
"""
Initialize a button.
@ -101,11 +110,13 @@ class Button(Interactable):
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:
@ -132,6 +143,11 @@ class Button(Interactable):
"""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.
@ -156,7 +172,8 @@ class Form(Interactable):
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.
@ -164,11 +181,13 @@ class Form(Interactable):
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:
@ -180,6 +199,11 @@ class Form(Interactable):
"""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.

View File

@ -179,14 +179,32 @@ class ButtonText(Text, Interactable, Queriable):
text_color = (255, 255, 255)
# Draw button background with rounded corners
button_rect = [self._origin, self._origin + self.size]
# 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()
@ -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,

View File

@ -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

View 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})"

View File

@ -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

View 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()