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

1from __future__ import annotations 

2from typing import Optional, Tuple 

3import numpy as np 

4from PIL import ImageDraw 

5 

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 

10 

11 

12class LinkText(Text, Interactable, Queriable): 

13 """ 

14 A Text subclass that can handle Link interactions. 

15 Combines text rendering with clickable link functionality. 

16 """ 

17 

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. 

22 

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 

43 

44 # Initialize Text with the styled font 

45 Text.__init__(self, text, link_font, draw, source, line) 

46 

47 # Initialize Interactable with the link's execute method 

48 Interactable.__init__(self, link.execute) 

49 

50 # Store the link object and page reference 

51 self._link = link 

52 self._page = page 

53 self._hovered = False 

54 self._pressed = False 

55 

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]) 

59 

60 @property 

61 def link(self) -> Link: 

62 """Get the associated Link object""" 

63 return self._link 

64 

65 def set_hovered(self, hovered: bool): 

66 """Set the hover state for visual feedback""" 

67 self._hovered = hovered 

68 self._mark_page_dirty() 

69 

70 def set_pressed(self, pressed: bool): 

71 """Set the pressed state for visual feedback""" 

72 self._pressed = pressed 

73 self._mark_page_dirty() 

74 

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

79 

80 def render(self, next_text: Optional['Text'] = None, spacing: int = 0): 

81 """ 

82 Render the link text with optional hover and pressed effects. 

83 

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) 

95 

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 

101 

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) 

116 

117 # Call the parent Text render method with parameters 

118 super().render(next_text, spacing) 

119 

120 

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

126 

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. 

132 

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) 

144 

145 # Initialize Interactable with the button's execute method 

146 Interactable.__init__(self, button.execute) 

147 

148 # Store button properties 

149 self._button = button 

150 self._padding = padding 

151 self._page = page 

152 self._pressed = False 

153 self._hovered = False 

154 

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] 

161 

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] 

168 

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) 

177 

178 @property 

179 def button(self) -> Button: 

180 """Get the associated Button object""" 

181 return self._button 

182 

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]) 

187 

188 def set_pressed(self, pressed: bool): 

189 """Set the pressed state""" 

190 self._pressed = pressed 

191 self._mark_page_dirty() 

192 

193 def set_hovered(self, hovered: bool): 

194 """Set the hover state""" 

195 self._hovered = hovered 

196 self._mark_page_dirty() 

197 

198 def set_page(self, page): 

199 """ 

200 Set the parent page reference for dirty flag management. 

201 

202 Args: 

203 page: The Page object containing this element 

204 """ 

205 self._page = page 

206 

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

211 

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) 

237 

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) 

248 

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 

252 

253 # Center text vertically within button 

254 # Get font metrics to properly center the baseline 

255 ascent, descent = self._style.font.getmetrics() 

256 

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] 

259 

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 

272 

273 # Temporarily set origin for text rendering 

274 original_origin = self._origin.copy() 

275 self._origin = np.array([text_x, text_y]) 

276 

277 # Call parent render method for the text 

278 super().render() 

279 

280 # Restore original origin 

281 self._origin = original_origin 

282 

283 def in_object(self, point) -> bool: 

284 """ 

285 Check if a point is within this button. 

286 

287 Args: 

288 point: The coordinates to check 

289 

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 

295 

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) 

299 

300 

301class FormFieldText(Text, Interactable, Queriable): 

302 """ 

303 A Text subclass that can handle FormField interactions. 

304 Renders form field labels and input areas. 

305 

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

312 

313 # Vertical gap between the label and its input box, in pixels. 

314 LABEL_GAP = 5 

315 

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. 

320 

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) 

331 

332 # Initialize Interactable - form fields don't have direct callbacks 

333 # but can notify of focus/value changes 

334 Interactable.__init__(self, None) 

335 

336 # Store field properties 

337 self._field = field 

338 self._field_height = field_height 

339 self._focused = False 

340 

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 

346 

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) 

353 

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) 

362 

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 

367 

368 @property 

369 def field(self) -> FormField: 

370 """Get the associated FormField object""" 

371 return self._field 

372 

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]) 

377 

378 def set_focused(self, focused: bool): 

379 """Set the focus state""" 

380 self._focused = focused 

381 

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 

392 

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 

397 

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 

401 

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) 

405 

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) 

409 

410 # Render field value if present 

411 if self._field.value is not None: 

412 value_text = str(self._field.value) 

413 

414 # For password fields, mask the text 

415 if self._field.field_type == FormFieldType.PASSWORD: 

416 value_text = "•" * len(value_text) 

417 

418 # Create a temporary Text object for the value 

419 value_font = self._style.with_colour((0, 0, 0)) 

420 

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

424 

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 

431 

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

435 

436 def handle_click(self, point) -> bool: 

437 """ 

438 Handle clicks on the form field. 

439 

440 Args: 

441 point: The click coordinates relative to this field 

442 

443 Returns: 

444 True if the field was clicked and focused 

445 """ 

446 # Calculate field area 

447 field_y = self.field_area_offset 

448 

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 

454 

455 return False 

456 

457 def in_object(self, point) -> bool: 

458 """ 

459 Check if a point is within this form field (including label and input area). 

460 

461 Args: 

462 point: The coordinates to check 

463 

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 

469 

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) 

473 

474 

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. 

480 

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 

486 

487 Returns: 

488 A LinkText object ready for rendering and interaction 

489 """ 

490 return LinkText(link, text, font, draw) 

491 

492 

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. 

497 

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 

503 

504 Returns: 

505 A ButtonText object ready for rendering and interaction 

506 """ 

507 return ButtonText(button, font, draw, padding) 

508 

509 

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. 

514 

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 

520 

521 Returns: 

522 A FormFieldText object ready for rendering and interaction 

523 """ 

524 return FormFieldText(field, font, draw, field_height)