Files
pyWebLayout/pyWebLayout/concrete/functional.py
T
dtourolleandClaude Opus 5 0ce1aeaa87 feat(ereader): wire pointer interaction into EreaderLayoutManager (R7)
concrete/interaction_handler.py was 310 lines reachable only from
examples/07_pressed_state_demo.py - no library code, no tests. Press and
hover feedback existed but could not be used through the library's own
interface.

Adds to the manager:

    handle_hover(point)        -> frame if hover changed, else None
    handle_touch_down(point)   -> frame showing the pressed state
    handle_touch_up(point)     -> (frame, callback result)
    reset_interaction_state()

Returning None when nothing changed visually lets a UI skip a redraw it
does not need - which matters on e-ink.

Press state belongs to one rendered page, so the InteractionStateManager
is bound lazily and rebound whenever the displayed page changes, resetting
the outgoing one so a press cannot survive a page turn.

Wiring it up immediately surfaced a real bug it had been hiding.
LinkText.render passed [origin, origin + size] - a list of two numpy
arrays - to PIL's draw.rectangle, which needs a flat four-scalar box.
Rendering any hovered or pressed link raised

    TypeError: coordinate list must contain exactly 2 coordinates

so the entire feature was broken on this PIL version. Fixed by building
the box explicitly, and the two branches now share it instead of
duplicating the call.

Tests cover hover/press/release, no-op paths, that an unchanged hover
reports no change, state rebinding across navigation, reset, and
regressions for the rectangle crash.

916 passed. examples/07_pressed_state_demo.py still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:20:40 +02:00

525 lines
19 KiB
Python

from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
from PIL import ImageDraw
from pyWebLayout.core.base import Interactable, Queriable
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration
from .text import Text
class LinkText(Text, Interactable, Queriable):
"""
A Text subclass that can handle Link interactions.
Combines text rendering with clickable link functionality.
"""
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None, page=None):
"""
Initialize a linkable text object.
Args:
link: The abstract Link object to handle interactions
text: The text content to render
font: The base font style
draw: The drawing context
source: Optional source object
line: Optional line container
page: Optional parent page (for dirty flag management)
"""
# Create link-styled font (underlined and colored based on link type)
link_font = font.with_decoration(TextDecoration.UNDERLINE)
if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
elif link.link_type == LinkType.EXTERNAL:
link_font = link_font.with_colour(
(0, 0, 180)) # Darker blue for external links
elif link.link_type == LinkType.API:
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
elif link.link_type == LinkType.FUNCTION:
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
# Initialize Text with the styled font
Text.__init__(self, text, link_font, draw, source, line)
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Store the link object and page reference
self._link = link
self._page = page
self._hovered = False
self._pressed = False
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
self._origin = np.array([0, 0])
@property
def link(self) -> Link:
"""Get the associated Link object"""
return self._link
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
self._mark_page_dirty()
def set_pressed(self, pressed: bool):
"""Set the pressed state for visual feedback"""
self._pressed = pressed
self._mark_page_dirty()
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the link text with optional hover and pressed effects.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
# Handle mock objects in tests
size = self.size
if hasattr(size, '__call__'): # It's a Mock
# Use default size for tests
size = np.array([100, 20])
else:
size = np.array(size)
# Ensure origin is a numpy array
origin = np.array(
self._origin) if not isinstance(
self._origin,
np.ndarray) else self._origin
# Draw background based on state (before text is rendered).
# PIL wants a flat sequence of four scalars; handing it a list of two
# numpy arrays raises "coordinate list must contain exactly 2
# coordinates".
if self._pressed or self._hovered:
far = origin + size
box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
if self._pressed:
# Pressed state - stronger, darker highlight
bg_color = (180, 180, 255, 180)
else:
# Hover state - subtle highlight
bg_color = (220, 220, 255, 100)
self._draw.rectangle(box, fill=bg_color)
# Call the parent Text render method with parameters
super().render(next_text, spacing)
class ButtonText(Text, Interactable, Queriable):
"""
A Text subclass that can handle Button interactions.
Renders text as a clickable button with visual states.
"""
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None, page=None):
"""
Initialize a button text object.
Args:
button: The abstract Button object to handle interactions
font: The base font style
draw: The drawing context
padding: Padding around the button text (top, right, bottom, left)
source: Optional source object
line: Optional line container
page: Optional parent page (for dirty flag management)
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
# Initialize Interactable with the button's execute method
Interactable.__init__(self, button.execute)
# Store button properties
self._button = button
self._padding = padding
self._page = page
self._pressed = False
self._hovered = False
# Recalculate dimensions to include padding
# Use getattr to handle mock objects in tests
text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3]
# Size the button from the text's visual height (ascent + descent), not
# from the nominal font size. The two differ by several pixels - DejaVu at
# 14px measures 17 - so sizing by font_size leaves the button too short to
# centre its own label in.
self._text_height = self._visual_text_height()
self._padded_height = self._text_height + padding[0] + padding[2]
def _visual_text_height(self) -> int:
"""Height of the rendered text, ascender to descender."""
try:
ascent, descent = self._style.font.getmetrics()
return int(ascent + descent)
except (AttributeError, TypeError, ValueError):
# Mock or unusual font object; the nominal size is the best guess.
return int(getattr(self._style, 'font_size', 0) or 0)
@property
def button(self) -> Button:
"""Get the associated Button object"""
return self._button
@property
def size(self) -> np.ndarray:
"""Get the padded size of the button"""
return np.array([self._padded_width, self._padded_height])
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
self._mark_page_dirty()
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
self._mark_page_dirty()
def set_page(self, page):
"""
Set the parent page reference for dirty flag management.
Args:
page: The Page object containing this element
"""
self._page = page
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self):
"""
Render the button with background, border, and text.
"""
# Determine button colors based on state
if not self._button.enabled:
# Disabled button
bg_color = (200, 200, 200)
border_color = (150, 150, 150)
text_color = (100, 100, 100)
elif self._pressed:
# Pressed button
bg_color = (70, 130, 180)
border_color = (50, 100, 150)
text_color = (255, 255, 255)
elif self._hovered:
# Hovered button
bg_color = (100, 160, 220)
border_color = (70, 130, 180)
text_color = (255, 255, 255)
else:
# Normal button
bg_color = (100, 150, 200)
border_color = (70, 120, 170)
text_color = (255, 255, 255)
# Draw button background with rounded corners
# 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
# 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]
# Centre the text's visual height (ascent + descent) within the text area.
# text_y is the baseline, since Text renders with anchor "ls".
#
# top of glyphs = area_top + (area_height - (ascent + descent)) / 2
# baseline = top of glyphs + ascent
#
# The previous form, area_top + area_height/2 + descent/2, is only
# equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
# label rendered several pixels above centre, against the top edge.
text_top = self._origin[1] + self._padding[0] \
+ (text_area_height - (ascent + descent)) / 2
text_y = text_top + ascent
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
self._origin = np.array([text_x, text_y])
# Call parent render method for the text
super().render()
# Restore original origin
self._origin = original_origin
def in_object(self, point) -> bool:
"""
Check if a point is within this button.
Args:
point: The coordinates to check
Returns:
True if the point is within the button bounds (including padding)
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the padded button boundaries
return (0 <= relative_point[0] < self._padded_width and
0 <= relative_point[1] < self._padded_height)
class FormFieldText(Text, Interactable, Queriable):
"""
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
The origin is the top-left of the whole control: label, then a gap, then the
input box. Text itself draws from a baseline, so the label is offset down by
its ascent when rendering; without that the glyphs would sit above the origin
and overprint whatever is above, which for a stacked form is the previous
field's input box.
"""
# Vertical gap between the label and its input box, in pixels.
LABEL_GAP = 5
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
Initialize a form field text object.
Args:
field: The abstract FormField object to handle interactions
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
source: Optional source object
line: Optional line container
"""
# Initialize Text with the field label
Text.__init__(self, field.label, font, draw, source, line)
# Initialize Interactable - form fields don't have direct callbacks
# but can notify of focus/value changes
Interactable.__init__(self, None)
# Store field properties
self._field = field
self._field_height = field_height
self._focused = False
# Calculate total height (label + gap + field). The label's height is its
# ink height, ascender to descender, not the nominal font size - the two
# differ by several pixels and the gap between label and box is only 5.
self._label_height = self._visual_label_height()
self._total_height = self._label_height + self.LABEL_GAP + field_height
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._field_width = max(text_width, 150)
def _visual_label_height(self) -> int:
"""Height of the rendered label, ascender to descender."""
try:
ascent, descent = self._style.font.getmetrics()
return int(ascent + descent)
except (AttributeError, TypeError, ValueError):
# Mock or unusual font object; the nominal size is the best guess.
return int(getattr(self._style, 'font_size', 0) or 0)
@property
def field_area_offset(self) -> int:
"""Distance from this control's origin to the top of its input box."""
return self._label_height + self.LABEL_GAP
@property
def field(self) -> FormField:
"""Get the associated FormField object"""
return self._field
@property
def size(self) -> np.ndarray:
"""Get the total size including label and field"""
return np.array([self._field_width, self._total_height])
def set_focused(self, focused: bool):
"""Set the focus state"""
self._focused = focused
def render(self):
"""
Render the form field with label and input area.
"""
# Render the label. Text draws from the baseline, so shift down by the
# ascent to make the origin the top of the label rather than its baseline.
try:
label_ascent = self._style.font.getmetrics()[0]
except (AttributeError, TypeError, ValueError):
label_ascent = self._label_height
label_origin = self._origin
self._origin = np.array([label_origin[0], label_origin[1] + label_ascent])
super().render()
self._origin = label_origin
# Calculate field position (below the label, with the standard gap)
field_x = self._origin[0]
field_y = self._origin[1] + self.field_area_offset
# Draw field background and border
bg_color = (255, 255, 255)
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
field_rect = [(field_x, field_y),
(field_x + self._field_width, field_y + self._field_height)]
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
# Render field value if present
if self._field.value is not None:
value_text = str(self._field.value)
# For password fields, mask the text
if self._field.field_type == FormFieldType.PASSWORD:
value_text = "•" * len(value_text)
# Create a temporary Text object for the value
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()
# Centre the value within the input box. As in ButtonText, the
# baseline sits at the top of the glyphs plus the ascent; centring on
# half the box height plus half the descent only works for a 2:1
# ascent/descent ratio and otherwise rides high.
value_x = field_x + 5
value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
# Draw the value text
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
def handle_click(self, point) -> bool:
"""
Handle clicks on the form field.
Args:
point: The click coordinates relative to this field
Returns:
True if the field was clicked and focused
"""
# Calculate field area
field_y = self.field_area_offset
# Check if click is within the input field area (not just the label)
if (0 <= point[0] <= self._field_width and
field_y <= point[1] <= field_y + self._field_height):
self.set_focused(True)
return True
return False
def in_object(self, point) -> bool:
"""
Check if a point is within this form field (including label and input area).
Args:
point: The coordinates to check
Returns:
True if the point is within the field bounds
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the total field area
return (0 <= relative_point[0] < self._field_width and
0 <= relative_point[1] < self._total_height)
# Factory functions for creating functional text objects
def create_link_text(link: Link, text: str, font: Font,
draw: ImageDraw.Draw) -> LinkText:
"""
Factory function to create a LinkText object.
Args:
link: The Link object to associate with the text
text: The text content to display
font: The base font style
draw: The drawing context
Returns:
A LinkText object ready for rendering and interaction
"""
return LinkText(link, text, font, draw)
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
"""
Factory function to create a ButtonText object.
Args:
button: The Button object to associate with the text
font: The base font style
draw: The drawing context
padding: Padding around the button text
Returns:
A ButtonText object ready for rendering and interaction
"""
return ButtonText(button, font, draw, padding)
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24) -> FormFieldText:
"""
Factory function to create a FormFieldText object.
Args:
field: The FormField object to associate with the text
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
Returns:
A FormFieldText object ready for rendering and interaction
"""
return FormFieldText(field, font, draw, field_height)