Coverage for fastblocks / adapters / icons / phosphor.py: 23%

186 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-26 03:30 -0800

1"""Phosphor icons adapter for FastBlocks with multiple variants.""" 

2 

3from contextlib import suppress 

4from typing import Any 

5from uuid import UUID 

6 

7from acb.depends import depends 

8 

9from ._base import IconsBase, IconsBaseSettings 

10 

11 

12class PhosphorIconsSettings(IconsBaseSettings): 

13 """Settings for Phosphor icons adapter.""" 

14 

15 # Required ACB 0.19.0+ metadata 

16 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7 

17 MODULE_STATUS: str = "stable" 

18 

19 # Phosphor configuration 

20 version: str = "2.0.8" 

21 cdn_url: str = "https://unpkg.com/@phosphor-icons/web" 

22 default_variant: str = "regular" # regular, thin, light, bold, fill, duotone 

23 default_size: str = "1em" 

24 

25 # Variant settings 

26 enabled_variants: list[str] = [ 

27 "regular", 

28 "thin", 

29 "light", 

30 "bold", 

31 "fill", 

32 "duotone", 

33 ] 

34 

35 # Icon mapping for common names 

36 icon_aliases: dict[str, str] = { 

37 "home": "house", 

38 "user": "user-circle", 

39 "settings": "gear", 

40 "search": "magnifying-glass", 

41 "menu": "list", 

42 "close": "x", 

43 "check": "check", 

44 "error": "warning-circle", 

45 "info": "info", 

46 "success": "check-circle", 

47 "warning": "warning", 

48 "edit": "pencil", 

49 "delete": "trash", 

50 "save": "floppy-disk", 

51 "download": "download", 

52 "upload": "upload", 

53 "email": "envelope", 

54 "phone": "phone", 

55 "location": "map-pin", 

56 "calendar": "calendar", 

57 "clock": "clock", 

58 "heart": "heart", 

59 "star": "star", 

60 "share": "share", 

61 "link": "link", 

62 "copy": "copy", 

63 "cut": "scissors", 

64 "paste": "clipboard", 

65 "undo": "arrow-counter-clockwise", 

66 "redo": "arrow-clockwise", 

67 "refresh": "arrow-clockwise", 

68 "logout": "sign-out", 

69 "login": "sign-in", 

70 } 

71 

72 

73class PhosphorIcons(IconsBase): 

74 """Phosphor icons adapter with multiple variants support.""" 

75 

76 # Required ACB 0.19.0+ metadata 

77 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7 

78 MODULE_STATUS: str = "stable" 

79 

80 def __init__(self) -> None: 

81 """Initialize Phosphor adapter.""" 

82 super().__init__() 

83 self.settings: PhosphorIconsSettings | None = None 

84 

85 # Register with ACB dependency system 

86 with suppress(Exception): 

87 depends.set(self) 

88 

89 def get_stylesheet_links(self) -> list[str]: 

90 """Get Phosphor icons stylesheet links.""" 

91 if not self.settings: 

92 self.settings = PhosphorIconsSettings() 

93 

94 links = [] 

95 

96 # Add CSS for each enabled variant 

97 for variant in self.settings.enabled_variants: 

98 css_url = ( 

99 f"{self.settings.cdn_url}@{self.settings.version}/{variant}/style.css" 

100 ) 

101 links.append(f'<link rel="stylesheet" href="{css_url}">') 

102 

103 # Add base Phosphor CSS if needed 

104 base_css = self._generate_phosphor_css() 

105 links.append(f"<style>{base_css}</style>") 

106 

107 return links 

108 

109 def _generate_phosphor_css(self) -> str: 

110 """Generate Phosphor-specific CSS.""" 

111 if not self.settings: 

112 self.settings = PhosphorIconsSettings() 

113 

114 return f""" 

115/* Phosphor Icons Base Styles */ 

116.ph {{ 

117 display: inline-block; 

118 font-style: normal; 

119 font-variant: normal; 

120 text-rendering: auto; 

121 line-height: 1; 

122 vertical-align: -0.125em; 

123 font-size: {self.settings.default_size}; 

124}} 

125 

126/* Size variants */ 

127.ph-xs {{ font-size: 0.75em; }} 

128.ph-sm {{ font-size: 0.875em; }} 

129.ph-lg {{ font-size: 1.125em; }} 

130.ph-xl {{ font-size: 1.25em; }} 

131.ph-2x {{ font-size: 2em; }} 

132.ph-3x {{ font-size: 3em; }} 

133.ph-4x {{ font-size: 4em; }} 

134.ph-5x {{ font-size: 5em; }} 

135 

136/* Rotation and transformation */ 

137.ph-rotate-90 {{ transform: rotate(90deg); }} 

138.ph-rotate-180 {{ transform: rotate(180deg); }} 

139.ph-rotate-270 {{ transform: rotate(270deg); }} 

140.ph-flip-horizontal {{ transform: scaleX(-1); }} 

141.ph-flip-vertical {{ transform: scaleY(-1); }} 

142 

143/* Animation support */ 

144.ph-spin {{ 

145 animation: ph-spin 2s linear infinite; 

146}} 

147 

148.ph-pulse {{ 

149 animation: ph-pulse 2s ease-in-out infinite alternate; 

150}} 

151 

152@keyframes ph-spin {{ 

153 0% {{ transform: rotate(0deg); }} 

154 100% {{ transform: rotate(360deg); }} 

155}} 

156 

157@keyframes ph-pulse {{ 

158 from {{ opacity: 1; }} 

159 to {{ opacity: 0.25; }} 

160}} 

161 

162/* Color utilities */ 

163.ph-primary {{ color: var(--primary-color, #007bff); }} 

164.ph-secondary {{ color: var(--secondary-color, #6c757d); }} 

165.ph-success {{ color: var(--success-color, #28a745); }} 

166.ph-warning {{ color: var(--warning-color, #ffc107); }} 

167.ph-danger {{ color: var(--danger-color, #dc3545); }} 

168.ph-info {{ color: var(--info-color, #17a2b8); }} 

169.ph-light {{ color: var(--light-color, #f8f9fa); }} 

170.ph-dark {{ color: var(--dark-color, #343a40); }} 

171.ph-muted {{ color: var(--muted-color, #6c757d); }} 

172 

173/* Interactive states */ 

174.ph-interactive {{ 

175 cursor: pointer; 

176 transition: all 0.2s ease; 

177}} 

178 

179.ph-interactive:hover {{ 

180 transform: scale(1.1); 

181 opacity: 0.8; 

182}} 

183 

184/* Alignment utilities */ 

185.ph-align-top {{ vertical-align: top; }} 

186.ph-align-middle {{ vertical-align: middle; }} 

187.ph-align-bottom {{ vertical-align: bottom; }} 

188.ph-align-baseline {{ vertical-align: baseline; }} 

189""" 

190 

191 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str: 

192 """Get Phosphor icon class with variant support.""" 

193 if not self.settings: 

194 self.settings = PhosphorIconsSettings() 

195 

196 # Resolve icon aliases 

197 if icon_name in self.settings.icon_aliases: 

198 icon_name = self.settings.icon_aliases[icon_name] 

199 

200 # Use default variant if not specified 

201 if not variant: 

202 variant = self.settings.default_variant 

203 

204 # Validate variant 

205 if variant not in self.settings.enabled_variants: 

206 variant = self.settings.default_variant 

207 

208 # Build class name based on variant 

209 if variant == "regular": 

210 return f"ph ph-{icon_name}" 

211 

212 return f"ph-{variant} ph-{icon_name}" 

213 

214 def _apply_size_class( 

215 self, size: str | None, icon_class: str, attributes: dict[str, Any] 

216 ) -> str: 

217 """Apply size styling to icon class.""" 

218 if not size: 

219 return icon_class 

220 

221 if size in ("xs", "sm", "lg", "xl", "2x", "3x", "4x", "5x"): 

222 return f"{icon_class} ph-{size}" 

223 

224 # Custom size via style 

225 attributes["style"] = f"font-size: {size}; {attributes.get('style', '')}" 

226 return icon_class 

227 

228 def _apply_transformations( 

229 self, icon_class: str, attributes: dict[str, Any] 

230 ) -> str: 

231 """Apply rotation and flip transformations.""" 

232 if "rotate" in attributes: 

233 rotation = attributes.pop("rotate") 

234 icon_class += f" ph-rotate-{rotation}" 

235 

236 if "flip" in attributes: 

237 flip = attributes.pop("flip") 

238 if flip in ("horizontal", "vertical"): 

239 icon_class += f" ph-flip-{flip}" 

240 

241 return icon_class 

242 

243 def _apply_animations(self, icon_class: str, attributes: dict[str, Any]) -> str: 

244 """Apply animation classes.""" 

245 if "spin" in attributes and attributes.pop("spin"): 

246 icon_class += " ph-spin" 

247 

248 if "pulse" in attributes and attributes.pop("pulse"): 

249 icon_class += " ph-pulse" 

250 

251 return icon_class 

252 

253 def _apply_color_styling(self, icon_class: str, attributes: dict[str, Any]) -> str: 

254 """Apply color styling (semantic or custom).""" 

255 if "color" not in attributes: 

256 return icon_class 

257 

258 color = attributes.pop("color") 

259 semantic_colors = ( 

260 "primary", 

261 "secondary", 

262 "success", 

263 "warning", 

264 "danger", 

265 "info", 

266 "light", 

267 "dark", 

268 "muted", 

269 ) 

270 

271 if color in semantic_colors: 

272 return f"{icon_class} ph-{color}" 

273 

274 # Custom color via style 

275 attributes["style"] = f"color: {color}; {attributes.get('style', '')}" 

276 return icon_class 

277 

278 def _apply_interactive_and_alignment( 

279 self, icon_class: str, attributes: dict[str, Any] 

280 ) -> str: 

281 """Apply interactive and alignment classes.""" 

282 if "interactive" in attributes and attributes.pop("interactive"): 

283 icon_class += " ph-interactive" 

284 

285 if "align" in attributes: 

286 align = attributes.pop("align") 

287 if align in ("top", "middle", "bottom", "baseline"): 

288 icon_class += f" ph-align-{align}" 

289 

290 return icon_class 

291 

292 def get_icon_tag( 

293 self, 

294 icon_name: str, 

295 variant: str | None = None, 

296 size: str | None = None, 

297 **attributes: Any, 

298 ) -> str: 

299 """Generate Phosphor icon tag with full customization.""" 

300 icon_class = self.get_icon_class(icon_name, variant) 

301 

302 # Add custom classes first 

303 if "class" in attributes: 

304 icon_class += f" {attributes.pop('class')}" 

305 

306 # Apply all styling and features 

307 icon_class = self._apply_size_class(size, icon_class, attributes) 

308 icon_class = self._apply_transformations(icon_class, attributes) 

309 icon_class = self._apply_animations(icon_class, attributes) 

310 icon_class = self._apply_color_styling(icon_class, attributes) 

311 icon_class = self._apply_interactive_and_alignment(icon_class, attributes) 

312 

313 # Build final attributes 

314 attrs = {"class": icon_class} | attributes 

315 

316 # Add accessibility attributes 

317 if "aria-label" not in attrs and "title" not in attrs: 

318 attrs["aria-hidden"] = "true" 

319 

320 # Generate tag 

321 attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) 

322 return f"<i {attr_string}></i>" 

323 

324 def get_duotone_icon_tag( 

325 self, 

326 icon_name: str, 

327 primary_color: str | None = None, 

328 secondary_color: str | None = None, 

329 **attributes: Any, 

330 ) -> str: 

331 """Generate duotone Phosphor icon with custom colors.""" 

332 # Force duotone variant 

333 attributes["variant"] = "duotone" 

334 

335 # Handle duotone colors via CSS custom properties 

336 style = attributes.get("style", "") 

337 if primary_color: 

338 style += f" --ph-duotone-primary: {primary_color};" 

339 if secondary_color: 

340 style += f" --ph-duotone-secondary: {secondary_color};" 

341 

342 if style: 

343 attributes["style"] = style 

344 

345 return self.get_icon_tag(icon_name, **attributes) 

346 

347 def get_icon_sprite_tag( 

348 self, icon_name: str, variant: str | None = None, **attributes: Any 

349 ) -> str: 

350 """Generate SVG sprite-based icon tag (alternative approach).""" 

351 if not self.settings: 

352 self.settings = PhosphorIconsSettings() 

353 

354 if not variant: 

355 variant = self.settings.default_variant 

356 

357 # Resolve icon aliases 

358 if icon_name in self.settings.icon_aliases: 

359 icon_name = self.settings.icon_aliases[icon_name] 

360 

361 # Build SVG tag 

362 svg_class = f"ph ph-{icon_name}" 

363 if "class" in attributes: 

364 svg_class += f" {attributes.pop('class')}" 

365 

366 # Default attributes for SVG 

367 svg_attrs = { 

368 "class": svg_class, 

369 "width": attributes.pop("width", self.settings.default_size), 

370 "height": attributes.pop("height", self.settings.default_size), 

371 "fill": "currentColor", 

372 } | attributes 

373 

374 # Add accessibility 

375 if "aria-label" not in svg_attrs and "title" not in svg_attrs: 

376 svg_attrs["aria-hidden"] = "true" 

377 

378 attr_string = " ".join( 

379 f'{k}="{v}"' for k, v in svg_attrs.items() if v is not None 

380 ) 

381 

382 # Use symbol reference (assumes sprite is loaded) 

383 symbol_id = f"ph-{variant}-{icon_name}" 

384 return f'<svg {attr_string}><use href="#{symbol_id}"></use></svg>' 

385 

386 def get_available_icons(self) -> dict[str, list[str]]: 

387 """Get list of available icons by category.""" 

388 # This would typically come from the Phosphor icon registry 

389 # For now, return a sample of common categories 

390 return { 

391 "general": [ 

392 "house", 

393 "user-circle", 

394 "gear", 

395 "magnifying-glass", 

396 "list", 

397 "x", 

398 "check", 

399 "warning-circle", 

400 "info", 

401 "check-circle", 

402 ], 

403 "communication": [ 

404 "envelope", 

405 "phone", 

406 "chat-circle", 

407 "paper-plane-right", 

408 "bell", 

409 "speaker-high", 

410 "microphone", 

411 "video-camera", 

412 ], 

413 "media": [ 

414 "play", 

415 "pause", 

416 "stop", 

417 "skip-back", 

418 "skip-forward", 

419 "volume-high", 

420 "volume-low", 

421 "volume-x", 

422 "music-note", 

423 ], 

424 "navigation": [ 

425 "arrow-left", 

426 "arrow-right", 

427 "arrow-up", 

428 "arrow-down", 

429 "caret-left", 

430 "caret-right", 

431 "caret-up", 

432 "caret-down", 

433 ], 

434 "file": [ 

435 "file", 

436 "folder", 

437 "download", 

438 "upload", 

439 "floppy-disk", 

440 "file-text", 

441 "file-image", 

442 "file-video", 

443 "file-audio", 

444 ], 

445 "business": [ 

446 "briefcase", 

447 "calendar", 

448 "clock", 

449 "chart-line", 

450 "currency-dollar", 

451 "credit-card", 

452 "receipt", 

453 "invoice", 

454 ], 

455 "social": [ 

456 "heart", 

457 "star", 

458 "share", 

459 "thumbs-up", 

460 "thumbs-down", 

461 "bookmark", 

462 "flag", 

463 "gift", 

464 "trophy", 

465 ], 

466 } 

467 

468 

469# Template filter registration for FastBlocks 

470def _register_ph_basic_filters(env: Any) -> None: 

471 """Register basic Phosphor filters.""" 

472 

473 @env.filter("ph_icon") # type: ignore[misc] 

474 def ph_icon_filter( 

475 icon_name: str, 

476 variant: str = "regular", 

477 size: str | None = None, 

478 **attributes: Any, 

479 ) -> str: 

480 """Template filter for Phosphor icons.""" 

481 icons = depends.get_sync("icons") 

482 if isinstance(icons, PhosphorIcons): 

483 return icons.get_icon_tag(icon_name, variant, size, **attributes) 

484 return f"<!-- {icon_name} -->" 

485 

486 @env.filter("ph_class") # type: ignore[misc] 

487 def ph_class_filter(icon_name: str, variant: str = "regular") -> str: 

488 """Template filter for Phosphor icon classes.""" 

489 icons = depends.get_sync("icons") 

490 if isinstance(icons, PhosphorIcons): 

491 return icons.get_icon_class(icon_name, variant) 

492 return f"ph-{icon_name}" 

493 

494 @env.global_("phosphor_stylesheet_links") # type: ignore[misc] 

495 def phosphor_stylesheet_links() -> str: 

496 """Global function for Phosphor stylesheet links.""" 

497 icons = depends.get_sync("icons") 

498 if isinstance(icons, PhosphorIcons): 

499 return "\n".join(icons.get_stylesheet_links()) 

500 return "" 

501 

502 

503def _register_ph_duotone_functions(env: Any) -> None: 

504 """Register Phosphor duotone functions.""" 

505 

506 @env.global_("ph_duotone") # type: ignore[misc] 

507 def ph_duotone( 

508 icon_name: str, 

509 primary_color: str | None = None, 

510 secondary_color: str | None = None, 

511 **attributes: Any, 

512 ) -> str: 

513 """Generate duotone Phosphor icon.""" 

514 icons = depends.get_sync("icons") 

515 if isinstance(icons, PhosphorIcons): 

516 return icons.get_duotone_icon_tag( 

517 icon_name, primary_color, secondary_color, **attributes 

518 ) 

519 return f"<!-- {icon_name} duotone -->" 

520 

521 

522def _register_ph_interactive_functions(env: Any) -> None: 

523 """Register Phosphor interactive functions.""" 

524 

525 @env.global_("ph_interactive") # type: ignore[misc] 

526 def ph_interactive( 

527 icon_name: str, 

528 variant: str = "regular", 

529 action: str | None = None, 

530 **attributes: Any, 

531 ) -> str: 

532 """Generate interactive Phosphor icon with action.""" 

533 icons = depends.get_sync("icons") 

534 if not isinstance(icons, PhosphorIcons): 

535 return f"<!-- {icon_name} -->" 

536 

537 attributes["interactive"] = True 

538 if action: 

539 attributes["onclick"] = action 

540 attributes["style"] = f"cursor: pointer; {attributes.get('style', '')}" 

541 

542 return icons.get_icon_tag(icon_name, variant, **attributes) 

543 

544 @env.global_("ph_button_icon") # type: ignore[misc] 

545 def ph_button_icon( 

546 icon_name: str, 

547 text: str | None = None, 

548 variant: str = "regular", 

549 position: str = "left", 

550 **attributes: Any, 

551 ) -> str: 

552 """Generate button with Phosphor icon.""" 

553 icons = depends.get_sync("icons") 

554 if not isinstance(icons, PhosphorIcons): 

555 return f"<button>{text or icon_name}</button>" 

556 

557 icon_tag = icons.get_icon_tag(icon_name, variant, class_="ph-sm") 

558 

559 if text: 

560 content = ( 

561 f"{icon_tag} {text}" if position == "left" else f"{text} {icon_tag}" 

562 ) 

563 else: 

564 content = icon_tag 

565 

566 btn_class = attributes.pop("class", "btn") 

567 attr_string = " ".join( 

568 f'{k}="{v}"' for k, v in ({"class": btn_class} | attributes).items() 

569 ) 

570 return f"<button {attr_string}>{content}</button>" 

571 

572 

573def register_phosphor_filters(env: Any) -> None: 

574 """Register Phosphor filters for Jinja2 templates.""" 

575 _register_ph_basic_filters(env) 

576 _register_ph_duotone_functions(env) 

577 _register_ph_interactive_functions(env) 

578 

579 

580IconsSettings = PhosphorIconsSettings 

581Icons = PhosphorIcons 

582 

583depends.set(Icons, "phosphor") 

584 

585# ACB 0.19.0+ compatibility 

586__all__ = [ 

587 "PhosphorIcons", 

588 "PhosphorIconsSettings", 

589 "register_phosphor_filters", 

590 "Icons", 

591 "IconsSettings", 

592]