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>
525 lines
19 KiB
Python
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)
|