Coverage for pyWebLayout/concrete/functional.py: 89%
190 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1from __future__ import annotations
2from typing import Optional, Tuple
3import numpy as np
4from PIL import ImageDraw
6from pyWebLayout.core.base import Interactable, Queriable
7from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
8from pyWebLayout.style import Font, TextDecoration
9from .text import Text
12class LinkText(Text, Interactable, Queriable):
13 """
14 A Text subclass that can handle Link interactions.
15 Combines text rendering with clickable link functionality.
16 """
18 def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
19 source=None, line=None, page=None):
20 """
21 Initialize a linkable text object.
23 Args:
24 link: The abstract Link object to handle interactions
25 text: The text content to render
26 font: The base font style
27 draw: The drawing context
28 source: Optional source object
29 line: Optional line container
30 page: Optional parent page (for dirty flag management)
31 """
32 # Create link-styled font (underlined and colored based on link type)
33 link_font = font.with_decoration(TextDecoration.UNDERLINE)
34 if link.link_type == LinkType.INTERNAL:
35 link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
36 elif link.link_type == LinkType.EXTERNAL:
37 link_font = link_font.with_colour(
38 (0, 0, 180)) # Darker blue for external links
39 elif link.link_type == LinkType.API:
40 link_font = link_font.with_colour((150, 0, 0)) # Red for API links
41 elif link.link_type == LinkType.FUNCTION: 41 ↛ 45line 41 didn't jump to line 45 because the condition on line 41 was always true
42 link_font = link_font.with_colour((0, 120, 0)) # Green for function links
44 # Initialize Text with the styled font
45 Text.__init__(self, text, link_font, draw, source, line)
47 # Initialize Interactable with the link's execute method
48 Interactable.__init__(self, link.execute)
50 # Store the link object and page reference
51 self._link = link
52 self._page = page
53 self._hovered = False
54 self._pressed = False
56 # Ensure _origin is initialized as numpy array
57 if not hasattr(self, '_origin') or self._origin is None: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 self._origin = np.array([0, 0])
60 @property
61 def link(self) -> Link:
62 """Get the associated Link object"""
63 return self._link
65 def set_hovered(self, hovered: bool):
66 """Set the hover state for visual feedback"""
67 self._hovered = hovered
68 self._mark_page_dirty()
70 def set_pressed(self, pressed: bool):
71 """Set the pressed state for visual feedback"""
72 self._pressed = pressed
73 self._mark_page_dirty()
75 def _mark_page_dirty(self):
76 """Mark the parent page as dirty if available"""
77 if self._page and hasattr(self._page, 'mark_dirty'): 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 self._page.mark_dirty()
80 def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
81 """
82 Render the link text with optional hover and pressed effects.
84 Args:
85 next_text: The next Text object in the line (if any)
86 spacing: The spacing to the next text object
87 """
88 # Handle mock objects in tests
89 size = self.size
90 if hasattr(size, '__call__'): # It's a Mock 90 ↛ 92line 90 didn't jump to line 92 because the condition on line 90 was never true
91 # Use default size for tests
92 size = np.array([100, 20])
93 else:
94 size = np.array(size)
96 # Ensure origin is a numpy array
97 origin = np.array(
98 self._origin) if not isinstance(
99 self._origin,
100 np.ndarray) else self._origin
102 # Draw background based on state (before text is rendered).
103 # PIL wants a flat sequence of four scalars; handing it a list of two
104 # numpy arrays raises "coordinate list must contain exactly 2
105 # coordinates".
106 if self._pressed or self._hovered:
107 far = origin + size
108 box = (int(origin[0]), int(origin[1]), int(far[0]), int(far[1]))
109 if self._pressed:
110 # Pressed state - stronger, darker highlight
111 bg_color = (180, 180, 255, 180)
112 else:
113 # Hover state - subtle highlight
114 bg_color = (220, 220, 255, 100)
115 self._draw.rectangle(box, fill=bg_color)
117 # Call the parent Text render method with parameters
118 super().render(next_text, spacing)
121class ButtonText(Text, Interactable, Queriable):
122 """
123 A Text subclass that can handle Button interactions.
124 Renders text as a clickable button with visual states.
125 """
127 def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
128 padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
129 source=None, line=None, page=None):
130 """
131 Initialize a button text object.
133 Args:
134 button: The abstract Button object to handle interactions
135 font: The base font style
136 draw: The drawing context
137 padding: Padding around the button text (top, right, bottom, left)
138 source: Optional source object
139 line: Optional line container
140 page: Optional parent page (for dirty flag management)
141 """
142 # Initialize Text with the button label
143 Text.__init__(self, button.label, font, draw, source, line)
145 # Initialize Interactable with the button's execute method
146 Interactable.__init__(self, button.execute)
148 # Store button properties
149 self._button = button
150 self._padding = padding
151 self._page = page
152 self._pressed = False
153 self._hovered = False
155 # Recalculate dimensions to include padding
156 # Use getattr to handle mock objects in tests
157 text_width = getattr(
158 self, '_width', 0) if not hasattr(
159 self._width, '__call__') else 0
160 self._padded_width = text_width + padding[1] + padding[3]
162 # Size the button from the text's visual height (ascent + descent), not
163 # from the nominal font size. The two differ by several pixels - DejaVu at
164 # 14px measures 17 - so sizing by font_size leaves the button too short to
165 # centre its own label in.
166 self._text_height = self._visual_text_height()
167 self._padded_height = self._text_height + padding[0] + padding[2]
169 def _visual_text_height(self) -> int:
170 """Height of the rendered text, ascender to descender."""
171 try:
172 ascent, descent = self._style.font.getmetrics()
173 return int(ascent + descent)
174 except (AttributeError, TypeError, ValueError):
175 # Mock or unusual font object; the nominal size is the best guess.
176 return int(getattr(self._style, 'font_size', 0) or 0)
178 @property
179 def button(self) -> Button:
180 """Get the associated Button object"""
181 return self._button
183 @property
184 def size(self) -> np.ndarray:
185 """Get the padded size of the button"""
186 return np.array([self._padded_width, self._padded_height])
188 def set_pressed(self, pressed: bool):
189 """Set the pressed state"""
190 self._pressed = pressed
191 self._mark_page_dirty()
193 def set_hovered(self, hovered: bool):
194 """Set the hover state"""
195 self._hovered = hovered
196 self._mark_page_dirty()
198 def set_page(self, page):
199 """
200 Set the parent page reference for dirty flag management.
202 Args:
203 page: The Page object containing this element
204 """
205 self._page = page
207 def _mark_page_dirty(self):
208 """Mark the parent page as dirty if available"""
209 if self._page and hasattr(self._page, 'mark_dirty'): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 self._page.mark_dirty()
212 def render(self):
213 """
214 Render the button with background, border, and text.
215 """
216 # Determine button colors based on state
217 if not self._button.enabled:
218 # Disabled button
219 bg_color = (200, 200, 200)
220 border_color = (150, 150, 150)
221 text_color = (100, 100, 100)
222 elif self._pressed: 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was never true
223 # Pressed button
224 bg_color = (70, 130, 180)
225 border_color = (50, 100, 150)
226 text_color = (255, 255, 255)
227 elif self._hovered: 227 ↛ 229line 227 didn't jump to line 229 because the condition on line 227 was never true
228 # Hovered button
229 bg_color = (100, 160, 220)
230 border_color = (70, 130, 180)
231 text_color = (255, 255, 255)
232 else:
233 # Normal button
234 bg_color = (100, 150, 200)
235 border_color = (70, 120, 170)
236 text_color = (255, 255, 255)
238 # Draw button background with rounded corners
239 # rounded_rectangle expects [x0, y0, x1, y1] format
240 button_rect = [
241 int(self._origin[0]),
242 int(self._origin[1]),
243 int(self._origin[0] + self.size[0]),
244 int(self._origin[1] + self.size[1])
245 ]
246 self._draw.rounded_rectangle(button_rect, fill=bg_color,
247 outline=border_color, width=1, radius=4)
249 # Update text color and render text centered within padding
250 self._style = self._style.with_colour(text_color)
251 text_x = self._origin[0] + self._padding[3] # left padding
253 # Center text vertically within button
254 # Get font metrics to properly center the baseline
255 ascent, descent = self._style.font.getmetrics()
257 # Total button height minus top and bottom padding gives us text area height
258 text_area_height = self._padded_height - self._padding[0] - self._padding[2]
260 # Centre the text's visual height (ascent + descent) within the text area.
261 # text_y is the baseline, since Text renders with anchor "ls".
262 #
263 # top of glyphs = area_top + (area_height - (ascent + descent)) / 2
264 # baseline = top of glyphs + ascent
265 #
266 # The previous form, area_top + area_height/2 + descent/2, is only
267 # equivalent when ascent == 2 * descent. Real fonts sit nearer 4:1, so the
268 # label rendered several pixels above centre, against the top edge.
269 text_top = self._origin[1] + self._padding[0] \
270 + (text_area_height - (ascent + descent)) / 2
271 text_y = text_top + ascent
273 # Temporarily set origin for text rendering
274 original_origin = self._origin.copy()
275 self._origin = np.array([text_x, text_y])
277 # Call parent render method for the text
278 super().render()
280 # Restore original origin
281 self._origin = original_origin
283 def in_object(self, point) -> bool:
284 """
285 Check if a point is within this button.
287 Args:
288 point: The coordinates to check
290 Returns:
291 True if the point is within the button bounds (including padding)
292 """
293 point_array = np.array(point)
294 relative_point = point_array - self._origin
296 # Check if the point is within the padded button boundaries
297 return (0 <= relative_point[0] < self._padded_width and
298 0 <= relative_point[1] < self._padded_height)
301class FormFieldText(Text, Interactable, Queriable):
302 """
303 A Text subclass that can handle FormField interactions.
304 Renders form field labels and input areas.
306 The origin is the top-left of the whole control: label, then a gap, then the
307 input box. Text itself draws from a baseline, so the label is offset down by
308 its ascent when rendering; without that the glyphs would sit above the origin
309 and overprint whatever is above, which for a stacked form is the previous
310 field's input box.
311 """
313 # Vertical gap between the label and its input box, in pixels.
314 LABEL_GAP = 5
316 def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
317 field_height: int = 24, source=None, line=None):
318 """
319 Initialize a form field text object.
321 Args:
322 field: The abstract FormField object to handle interactions
323 font: The base font style for the label
324 draw: The drawing context
325 field_height: Height of the input field area
326 source: Optional source object
327 line: Optional line container
328 """
329 # Initialize Text with the field label
330 Text.__init__(self, field.label, font, draw, source, line)
332 # Initialize Interactable - form fields don't have direct callbacks
333 # but can notify of focus/value changes
334 Interactable.__init__(self, None)
336 # Store field properties
337 self._field = field
338 self._field_height = field_height
339 self._focused = False
341 # Calculate total height (label + gap + field). The label's height is its
342 # ink height, ascender to descender, not the nominal font size - the two
343 # differ by several pixels and the gap between label and box is only 5.
344 self._label_height = self._visual_label_height()
345 self._total_height = self._label_height + self.LABEL_GAP + field_height
347 # Field width should be at least as wide as the label
348 # Use getattr to handle mock objects in tests
349 text_width = getattr(
350 self, '_width', 0) if not hasattr(
351 self._width, '__call__') else 0
352 self._field_width = max(text_width, 150)
354 def _visual_label_height(self) -> int:
355 """Height of the rendered label, ascender to descender."""
356 try:
357 ascent, descent = self._style.font.getmetrics()
358 return int(ascent + descent)
359 except (AttributeError, TypeError, ValueError):
360 # Mock or unusual font object; the nominal size is the best guess.
361 return int(getattr(self._style, 'font_size', 0) or 0)
363 @property
364 def field_area_offset(self) -> int:
365 """Distance from this control's origin to the top of its input box."""
366 return self._label_height + self.LABEL_GAP
368 @property
369 def field(self) -> FormField:
370 """Get the associated FormField object"""
371 return self._field
373 @property
374 def size(self) -> np.ndarray:
375 """Get the total size including label and field"""
376 return np.array([self._field_width, self._total_height])
378 def set_focused(self, focused: bool):
379 """Set the focus state"""
380 self._focused = focused
382 def render(self):
383 """
384 Render the form field with label and input area.
385 """
386 # Render the label. Text draws from the baseline, so shift down by the
387 # ascent to make the origin the top of the label rather than its baseline.
388 try:
389 label_ascent = self._style.font.getmetrics()[0]
390 except (AttributeError, TypeError, ValueError):
391 label_ascent = self._label_height
393 label_origin = self._origin
394 self._origin = np.array([label_origin[0], label_origin[1] + label_ascent])
395 super().render()
396 self._origin = label_origin
398 # Calculate field position (below the label, with the standard gap)
399 field_x = self._origin[0]
400 field_y = self._origin[1] + self.field_area_offset
402 # Draw field background and border
403 bg_color = (255, 255, 255)
404 border_color = (100, 150, 200) if self._focused else (200, 200, 200)
406 field_rect = [(field_x, field_y),
407 (field_x + self._field_width, field_y + self._field_height)]
408 self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
410 # Render field value if present
411 if self._field.value is not None:
412 value_text = str(self._field.value)
414 # For password fields, mask the text
415 if self._field.field_type == FormFieldType.PASSWORD:
416 value_text = "•" * len(value_text)
418 # Create a temporary Text object for the value
419 value_font = self._style.with_colour((0, 0, 0))
421 # Position value text within field (with some padding)
422 # Get font metrics to properly center the baseline
423 ascent, descent = value_font.font.getmetrics()
425 # Centre the value within the input box. As in ButtonText, the
426 # baseline sits at the top of the glyphs plus the ascent; centring on
427 # half the box height plus half the descent only works for a 2:1
428 # ascent/descent ratio and otherwise rides high.
429 value_x = field_x + 5
430 value_y = field_y + (self._field_height - (ascent + descent)) / 2 + ascent
432 # Draw the value text
433 self._draw.text((value_x, value_y), value_text,
434 font=value_font.font, fill=value_font.colour, anchor="ls")
436 def handle_click(self, point) -> bool:
437 """
438 Handle clicks on the form field.
440 Args:
441 point: The click coordinates relative to this field
443 Returns:
444 True if the field was clicked and focused
445 """
446 # Calculate field area
447 field_y = self.field_area_offset
449 # Check if click is within the input field area (not just the label)
450 if (0 <= point[0] <= self._field_width and
451 field_y <= point[1] <= field_y + self._field_height):
452 self.set_focused(True)
453 return True
455 return False
457 def in_object(self, point) -> bool:
458 """
459 Check if a point is within this form field (including label and input area).
461 Args:
462 point: The coordinates to check
464 Returns:
465 True if the point is within the field bounds
466 """
467 point_array = np.array(point)
468 relative_point = point_array - self._origin
470 # Check if the point is within the total field area
471 return (0 <= relative_point[0] < self._field_width and
472 0 <= relative_point[1] < self._total_height)
475# Factory functions for creating functional text objects
476def create_link_text(link: Link, text: str, font: Font,
477 draw: ImageDraw.Draw) -> LinkText:
478 """
479 Factory function to create a LinkText object.
481 Args:
482 link: The Link object to associate with the text
483 text: The text content to display
484 font: The base font style
485 draw: The drawing context
487 Returns:
488 A LinkText object ready for rendering and interaction
489 """
490 return LinkText(link, text, font, draw)
493def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
494 padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
495 """
496 Factory function to create a ButtonText object.
498 Args:
499 button: The Button object to associate with the text
500 font: The base font style
501 draw: The drawing context
502 padding: Padding around the button text
504 Returns:
505 A ButtonText object ready for rendering and interaction
506 """
507 return ButtonText(button, font, draw, padding)
510def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
511 field_height: int = 24) -> FormFieldText:
512 """
513 Factory function to create a FormFieldText object.
515 Args:
516 field: The FormField object to associate with the text
517 font: The base font style for the label
518 draw: The drawing context
519 field_height: Height of the input field area
521 Returns:
522 A FormFieldText object ready for rendering and interaction
523 """
524 return FormFieldText(field, font, draw, field_height)