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
« 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."""
3from contextlib import suppress
4from typing import Any
5from uuid import UUID
7from acb.depends import depends
9from ._base import IconsBase, IconsBaseSettings
12class PhosphorIconsSettings(IconsBaseSettings):
13 """Settings for Phosphor icons adapter."""
15 # Required ACB 0.19.0+ metadata
16 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7
17 MODULE_STATUS: str = "stable"
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"
25 # Variant settings
26 enabled_variants: list[str] = [
27 "regular",
28 "thin",
29 "light",
30 "bold",
31 "fill",
32 "duotone",
33 ]
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 }
73class PhosphorIcons(IconsBase):
74 """Phosphor icons adapter with multiple variants support."""
76 # Required ACB 0.19.0+ metadata
77 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7
78 MODULE_STATUS: str = "stable"
80 def __init__(self) -> None:
81 """Initialize Phosphor adapter."""
82 super().__init__()
83 self.settings: PhosphorIconsSettings | None = None
85 # Register with ACB dependency system
86 with suppress(Exception):
87 depends.set(self)
89 def get_stylesheet_links(self) -> list[str]:
90 """Get Phosphor icons stylesheet links."""
91 if not self.settings:
92 self.settings = PhosphorIconsSettings()
94 links = []
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}">')
103 # Add base Phosphor CSS if needed
104 base_css = self._generate_phosphor_css()
105 links.append(f"<style>{base_css}</style>")
107 return links
109 def _generate_phosphor_css(self) -> str:
110 """Generate Phosphor-specific CSS."""
111 if not self.settings:
112 self.settings = PhosphorIconsSettings()
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}}
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; }}
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); }}
143/* Animation support */
144.ph-spin {{
145 animation: ph-spin 2s linear infinite;
146}}
148.ph-pulse {{
149 animation: ph-pulse 2s ease-in-out infinite alternate;
150}}
152@keyframes ph-spin {{
153 0% {{ transform: rotate(0deg); }}
154 100% {{ transform: rotate(360deg); }}
155}}
157@keyframes ph-pulse {{
158 from {{ opacity: 1; }}
159 to {{ opacity: 0.25; }}
160}}
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); }}
173/* Interactive states */
174.ph-interactive {{
175 cursor: pointer;
176 transition: all 0.2s ease;
177}}
179.ph-interactive:hover {{
180 transform: scale(1.1);
181 opacity: 0.8;
182}}
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"""
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()
196 # Resolve icon aliases
197 if icon_name in self.settings.icon_aliases:
198 icon_name = self.settings.icon_aliases[icon_name]
200 # Use default variant if not specified
201 if not variant:
202 variant = self.settings.default_variant
204 # Validate variant
205 if variant not in self.settings.enabled_variants:
206 variant = self.settings.default_variant
208 # Build class name based on variant
209 if variant == "regular":
210 return f"ph ph-{icon_name}"
212 return f"ph-{variant} ph-{icon_name}"
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
221 if size in ("xs", "sm", "lg", "xl", "2x", "3x", "4x", "5x"):
222 return f"{icon_class} ph-{size}"
224 # Custom size via style
225 attributes["style"] = f"font-size: {size}; {attributes.get('style', '')}"
226 return icon_class
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}"
236 if "flip" in attributes:
237 flip = attributes.pop("flip")
238 if flip in ("horizontal", "vertical"):
239 icon_class += f" ph-flip-{flip}"
241 return icon_class
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"
248 if "pulse" in attributes and attributes.pop("pulse"):
249 icon_class += " ph-pulse"
251 return icon_class
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
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 )
271 if color in semantic_colors:
272 return f"{icon_class} ph-{color}"
274 # Custom color via style
275 attributes["style"] = f"color: {color}; {attributes.get('style', '')}"
276 return icon_class
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"
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}"
290 return icon_class
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)
302 # Add custom classes first
303 if "class" in attributes:
304 icon_class += f" {attributes.pop('class')}"
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)
313 # Build final attributes
314 attrs = {"class": icon_class} | attributes
316 # Add accessibility attributes
317 if "aria-label" not in attrs and "title" not in attrs:
318 attrs["aria-hidden"] = "true"
320 # Generate tag
321 attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
322 return f"<i {attr_string}></i>"
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"
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};"
342 if style:
343 attributes["style"] = style
345 return self.get_icon_tag(icon_name, **attributes)
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()
354 if not variant:
355 variant = self.settings.default_variant
357 # Resolve icon aliases
358 if icon_name in self.settings.icon_aliases:
359 icon_name = self.settings.icon_aliases[icon_name]
361 # Build SVG tag
362 svg_class = f"ph ph-{icon_name}"
363 if "class" in attributes:
364 svg_class += f" {attributes.pop('class')}"
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
374 # Add accessibility
375 if "aria-label" not in svg_attrs and "title" not in svg_attrs:
376 svg_attrs["aria-hidden"] = "true"
378 attr_string = " ".join(
379 f'{k}="{v}"' for k, v in svg_attrs.items() if v is not None
380 )
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>'
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 }
469# Template filter registration for FastBlocks
470def _register_ph_basic_filters(env: Any) -> None:
471 """Register basic Phosphor filters."""
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} -->"
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}"
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 ""
503def _register_ph_duotone_functions(env: Any) -> None:
504 """Register Phosphor duotone functions."""
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 -->"
522def _register_ph_interactive_functions(env: Any) -> None:
523 """Register Phosphor interactive functions."""
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} -->"
537 attributes["interactive"] = True
538 if action:
539 attributes["onclick"] = action
540 attributes["style"] = f"cursor: pointer; {attributes.get('style', '')}"
542 return icons.get_icon_tag(icon_name, variant, **attributes)
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>"
557 icon_tag = icons.get_icon_tag(icon_name, variant, class_="ph-sm")
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
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>"
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)
580IconsSettings = PhosphorIconsSettings
581Icons = PhosphorIcons
583depends.set(Icons, "phosphor")
585# ACB 0.19.0+ compatibility
586__all__ = [
587 "PhosphorIcons",
588 "PhosphorIconsSettings",
589 "register_phosphor_filters",
590 "Icons",
591 "IconsSettings",
592]