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

184 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

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.config import Settings 

8from acb.depends import depends 

9 

10from ._base import IconsBase 

11 

12 

13class PhosphorSettings(Settings): # type: ignore[misc] 

14 """Settings for Phosphor icons adapter.""" 

15 

16 # Required ACB 0.19.0+ metadata 

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

18 MODULE_STATUS: str = "stable" 

19 

20 # Phosphor configuration 

21 version: str = "2.0.8" 

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

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

24 default_size: str = "1em" 

25 

26 # Variant settings 

27 enabled_variants: list[str] = [ 

28 "regular", 

29 "thin", 

30 "light", 

31 "bold", 

32 "fill", 

33 "duotone", 

34 ] 

35 

36 # Icon mapping for common names 

37 icon_aliases: dict[str, str] = { 

38 "home": "house", 

39 "user": "user-circle", 

40 "settings": "gear", 

41 "search": "magnifying-glass", 

42 "menu": "list", 

43 "close": "x", 

44 "check": "check", 

45 "error": "warning-circle", 

46 "info": "info", 

47 "success": "check-circle", 

48 "warning": "warning", 

49 "edit": "pencil", 

50 "delete": "trash", 

51 "save": "floppy-disk", 

52 "download": "download", 

53 "upload": "upload", 

54 "email": "envelope", 

55 "phone": "phone", 

56 "location": "map-pin", 

57 "calendar": "calendar", 

58 "clock": "clock", 

59 "heart": "heart", 

60 "star": "star", 

61 "share": "share", 

62 "link": "link", 

63 "copy": "copy", 

64 "cut": "scissors", 

65 "paste": "clipboard", 

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

67 "redo": "arrow-clockwise", 

68 "refresh": "arrow-clockwise", 

69 "logout": "sign-out", 

70 "login": "sign-in", 

71 } 

72 

73 

74class PhosphorAdapter(IconsBase): 

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

76 

77 # Required ACB 0.19.0+ metadata 

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

79 MODULE_STATUS: str = "stable" 

80 

81 def __init__(self) -> None: 

82 """Initialize Phosphor adapter.""" 

83 super().__init__() 

84 self.settings: PhosphorSettings | None = None 

85 

86 # Register with ACB dependency system 

87 with suppress(Exception): 

88 depends.set(self) 

89 

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

91 """Get Phosphor icons stylesheet links.""" 

92 if not self.settings: 

93 self.settings = PhosphorSettings() 

94 

95 links = [] 

96 

97 # Add CSS for each enabled variant 

98 for variant in self.settings.enabled_variants: 

99 css_url = ( 

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

101 ) 

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

103 

104 # Add base Phosphor CSS if needed 

105 base_css = self._generate_phosphor_css() 

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

107 

108 return links 

109 

110 def _generate_phosphor_css(self) -> str: 

111 """Generate Phosphor-specific CSS.""" 

112 if not self.settings: 

113 self.settings = PhosphorSettings() 

114 

115 return f""" 

116/* Phosphor Icons Base Styles */ 

117.ph {{ 

118 display: inline-block; 

119 font-style: normal; 

120 font-variant: normal; 

121 text-rendering: auto; 

122 line-height: 1; 

123 vertical-align: -0.125em; 

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

125}} 

126 

127/* Size variants */ 

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

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

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

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

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

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

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

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

136 

137/* Rotation and transformation */ 

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

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

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

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

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

143 

144/* Animation support */ 

145.ph-spin {{ 

146 animation: ph-spin 2s linear infinite; 

147}} 

148 

149.ph-pulse {{ 

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

151}} 

152 

153@keyframes ph-spin {{ 

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

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

156}} 

157 

158@keyframes ph-pulse {{ 

159 from {{ opacity: 1; }} 

160 to {{ opacity: 0.25; }} 

161}} 

162 

163/* Color utilities */ 

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

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

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

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

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

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

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

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

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

173 

174/* Interactive states */ 

175.ph-interactive {{ 

176 cursor: pointer; 

177 transition: all 0.2s ease; 

178}} 

179 

180.ph-interactive:hover {{ 

181 transform: scale(1.1); 

182 opacity: 0.8; 

183}} 

184 

185/* Alignment utilities */ 

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

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

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

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

190""" 

191 

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

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

194 if not self.settings: 

195 self.settings = PhosphorSettings() 

196 

197 # Resolve icon aliases 

198 if icon_name in self.settings.icon_aliases: 

199 icon_name = self.settings.icon_aliases[icon_name] 

200 

201 # Use default variant if not specified 

202 if not variant: 

203 variant = self.settings.default_variant 

204 

205 # Validate variant 

206 if variant not in self.settings.enabled_variants: 

207 variant = self.settings.default_variant 

208 

209 # Build class name based on variant 

210 if variant == "regular": 

211 return f"ph ph-{icon_name}" 

212 

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

214 

215 def _apply_size_class( 

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

217 ) -> str: 

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

219 if not size: 

220 return icon_class 

221 

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

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

224 

225 # Custom size via style 

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

227 return icon_class 

228 

229 def _apply_transformations( 

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

231 ) -> str: 

232 """Apply rotation and flip transformations.""" 

233 if "rotate" in attributes: 

234 rotation = attributes.pop("rotate") 

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

236 

237 if "flip" in attributes: 

238 flip = attributes.pop("flip") 

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

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

241 

242 return icon_class 

243 

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

245 """Apply animation classes.""" 

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

247 icon_class += " ph-spin" 

248 

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

250 icon_class += " ph-pulse" 

251 

252 return icon_class 

253 

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

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

256 if "color" not in attributes: 

257 return icon_class 

258 

259 color = attributes.pop("color") 

260 semantic_colors = ( 

261 "primary", 

262 "secondary", 

263 "success", 

264 "warning", 

265 "danger", 

266 "info", 

267 "light", 

268 "dark", 

269 "muted", 

270 ) 

271 

272 if color in semantic_colors: 

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

274 

275 # Custom color via style 

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

277 return icon_class 

278 

279 def _apply_interactive_and_alignment( 

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

281 ) -> str: 

282 """Apply interactive and alignment classes.""" 

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

284 icon_class += " ph-interactive" 

285 

286 if "align" in attributes: 

287 align = attributes.pop("align") 

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

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

290 

291 return icon_class 

292 

293 def get_icon_tag( # type: ignore[override] 

294 self, 

295 icon_name: str, 

296 variant: str | None = None, 

297 size: str | None = None, 

298 **attributes: Any, 

299 ) -> str: 

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

301 icon_class = self.get_icon_class(icon_name, variant) 

302 

303 # Add custom classes first 

304 if "class" in attributes: 

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

306 

307 # Apply all styling and features 

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

309 icon_class = self._apply_transformations(icon_class, attributes) 

310 icon_class = self._apply_animations(icon_class, attributes) 

311 icon_class = self._apply_color_styling(icon_class, attributes) 

312 icon_class = self._apply_interactive_and_alignment(icon_class, attributes) 

313 

314 # Build final attributes 

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

316 

317 # Add accessibility attributes 

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

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

320 

321 # Generate tag 

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

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

324 

325 def get_duotone_icon_tag( 

326 self, 

327 icon_name: str, 

328 primary_color: str | None = None, 

329 secondary_color: str | None = None, 

330 **attributes: Any, 

331 ) -> str: 

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

333 # Force duotone variant 

334 attributes["variant"] = "duotone" 

335 

336 # Handle duotone colors via CSS custom properties 

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

338 if primary_color: 

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

340 if secondary_color: 

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

342 

343 if style: 

344 attributes["style"] = style 

345 

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

347 

348 def get_icon_sprite_tag( 

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

350 ) -> str: 

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

352 if not self.settings: 

353 self.settings = PhosphorSettings() 

354 

355 if not variant: 

356 variant = self.settings.default_variant 

357 

358 # Resolve icon aliases 

359 if icon_name in self.settings.icon_aliases: 

360 icon_name = self.settings.icon_aliases[icon_name] 

361 

362 # Build SVG tag 

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

364 if "class" in attributes: 

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

366 

367 # Default attributes for SVG 

368 svg_attrs = { 

369 "class": svg_class, 

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

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

372 "fill": "currentColor", 

373 } | attributes 

374 

375 # Add accessibility 

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

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

378 

379 attr_string = " ".join( 

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

381 ) 

382 

383 # Use symbol reference (assumes sprite is loaded) 

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

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

386 

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

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

389 # This would typically come from the Phosphor icon registry 

390 # For now, return a sample of common categories 

391 return { 

392 "general": [ 

393 "house", 

394 "user-circle", 

395 "gear", 

396 "magnifying-glass", 

397 "list", 

398 "x", 

399 "check", 

400 "warning-circle", 

401 "info", 

402 "check-circle", 

403 ], 

404 "communication": [ 

405 "envelope", 

406 "phone", 

407 "chat-circle", 

408 "paper-plane-right", 

409 "bell", 

410 "speaker-high", 

411 "microphone", 

412 "video-camera", 

413 ], 

414 "media": [ 

415 "play", 

416 "pause", 

417 "stop", 

418 "skip-back", 

419 "skip-forward", 

420 "volume-high", 

421 "volume-low", 

422 "volume-x", 

423 "music-note", 

424 ], 

425 "navigation": [ 

426 "arrow-left", 

427 "arrow-right", 

428 "arrow-up", 

429 "arrow-down", 

430 "caret-left", 

431 "caret-right", 

432 "caret-up", 

433 "caret-down", 

434 ], 

435 "file": [ 

436 "file", 

437 "folder", 

438 "download", 

439 "upload", 

440 "floppy-disk", 

441 "file-text", 

442 "file-image", 

443 "file-video", 

444 "file-audio", 

445 ], 

446 "business": [ 

447 "briefcase", 

448 "calendar", 

449 "clock", 

450 "chart-line", 

451 "currency-dollar", 

452 "credit-card", 

453 "receipt", 

454 "invoice", 

455 ], 

456 "social": [ 

457 "heart", 

458 "star", 

459 "share", 

460 "thumbs-up", 

461 "thumbs-down", 

462 "bookmark", 

463 "flag", 

464 "gift", 

465 "trophy", 

466 ], 

467 } 

468 

469 

470# Template filter registration for FastBlocks 

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

472 """Register basic Phosphor filters.""" 

473 

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

475 def ph_icon_filter( 

476 icon_name: str, 

477 variant: str = "regular", 

478 size: str | None = None, 

479 **attributes: Any, 

480 ) -> str: 

481 """Template filter for Phosphor icons.""" 

482 icons = depends.get("icons") 

483 if isinstance(icons, PhosphorAdapter): 

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

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

486 

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

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

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

490 icons = depends.get("icons") 

491 if isinstance(icons, PhosphorAdapter): 

492 return icons.get_icon_class(icon_name, variant) 

493 return f"ph-{icon_name}" 

494 

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

496 def phosphor_stylesheet_links() -> str: 

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

498 icons = depends.get("icons") 

499 if isinstance(icons, PhosphorAdapter): 

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

501 return "" 

502 

503 

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

505 """Register Phosphor duotone functions.""" 

506 

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

508 def ph_duotone( 

509 icon_name: str, 

510 primary_color: str | None = None, 

511 secondary_color: str | None = None, 

512 **attributes: Any, 

513 ) -> str: 

514 """Generate duotone Phosphor icon.""" 

515 icons = depends.get("icons") 

516 if isinstance(icons, PhosphorAdapter): 

517 return icons.get_duotone_icon_tag( 

518 icon_name, primary_color, secondary_color, **attributes 

519 ) 

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

521 

522 

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

524 """Register Phosphor interactive functions.""" 

525 

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

527 def ph_interactive( 

528 icon_name: str, 

529 variant: str = "regular", 

530 action: str | None = None, 

531 **attributes: Any, 

532 ) -> str: 

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

534 icons = depends.get("icons") 

535 if not isinstance(icons, PhosphorAdapter): 

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

537 

538 attributes["interactive"] = True 

539 if action: 

540 attributes["onclick"] = action 

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

542 

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

544 

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

546 def ph_button_icon( 

547 icon_name: str, 

548 text: str | None = None, 

549 variant: str = "regular", 

550 position: str = "left", 

551 **attributes: Any, 

552 ) -> str: 

553 """Generate button with Phosphor icon.""" 

554 icons = depends.get("icons") 

555 if not isinstance(icons, PhosphorAdapter): 

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

557 

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

559 

560 if text: 

561 content = ( 

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

563 ) 

564 else: 

565 content = icon_tag 

566 

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

568 attr_string = " ".join( 

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

570 ) 

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

572 

573 

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

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

576 _register_ph_basic_filters(env) 

577 _register_ph_duotone_functions(env) 

578 _register_ph_interactive_functions(env) 

579 

580 

581# ACB 0.19.0+ compatibility 

582__all__ = ["PhosphorAdapter", "PhosphorSettings", "register_phosphor_filters"]