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
« 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."""
3from contextlib import suppress
4from typing import Any
5from uuid import UUID
7from acb.config import Settings
8from acb.depends import depends
10from ._base import IconsBase
13class PhosphorSettings(Settings): # type: ignore[misc]
14 """Settings for Phosphor icons adapter."""
16 # Required ACB 0.19.0+ metadata
17 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7
18 MODULE_STATUS: str = "stable"
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"
26 # Variant settings
27 enabled_variants: list[str] = [
28 "regular",
29 "thin",
30 "light",
31 "bold",
32 "fill",
33 "duotone",
34 ]
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 }
74class PhosphorAdapter(IconsBase):
75 """Phosphor icons adapter with multiple variants support."""
77 # Required ACB 0.19.0+ metadata
78 MODULE_ID: UUID = UUID("01937d86-9c7e-b18f-da0b-f9a0b1c2d3e4") # Static UUID7
79 MODULE_STATUS: str = "stable"
81 def __init__(self) -> None:
82 """Initialize Phosphor adapter."""
83 super().__init__()
84 self.settings: PhosphorSettings | None = None
86 # Register with ACB dependency system
87 with suppress(Exception):
88 depends.set(self)
90 def get_stylesheet_links(self) -> list[str]:
91 """Get Phosphor icons stylesheet links."""
92 if not self.settings:
93 self.settings = PhosphorSettings()
95 links = []
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}">')
104 # Add base Phosphor CSS if needed
105 base_css = self._generate_phosphor_css()
106 links.append(f"<style>{base_css}</style>")
108 return links
110 def _generate_phosphor_css(self) -> str:
111 """Generate Phosphor-specific CSS."""
112 if not self.settings:
113 self.settings = PhosphorSettings()
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}}
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; }}
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); }}
144/* Animation support */
145.ph-spin {{
146 animation: ph-spin 2s linear infinite;
147}}
149.ph-pulse {{
150 animation: ph-pulse 2s ease-in-out infinite alternate;
151}}
153@keyframes ph-spin {{
154 0% {{ transform: rotate(0deg); }}
155 100% {{ transform: rotate(360deg); }}
156}}
158@keyframes ph-pulse {{
159 from {{ opacity: 1; }}
160 to {{ opacity: 0.25; }}
161}}
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); }}
174/* Interactive states */
175.ph-interactive {{
176 cursor: pointer;
177 transition: all 0.2s ease;
178}}
180.ph-interactive:hover {{
181 transform: scale(1.1);
182 opacity: 0.8;
183}}
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"""
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()
197 # Resolve icon aliases
198 if icon_name in self.settings.icon_aliases:
199 icon_name = self.settings.icon_aliases[icon_name]
201 # Use default variant if not specified
202 if not variant:
203 variant = self.settings.default_variant
205 # Validate variant
206 if variant not in self.settings.enabled_variants:
207 variant = self.settings.default_variant
209 # Build class name based on variant
210 if variant == "regular":
211 return f"ph ph-{icon_name}"
213 return f"ph-{variant} ph-{icon_name}"
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
222 if size in ("xs", "sm", "lg", "xl", "2x", "3x", "4x", "5x"):
223 return f"{icon_class} ph-{size}"
225 # Custom size via style
226 attributes["style"] = f"font-size: {size}; {attributes.get('style', '')}"
227 return icon_class
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}"
237 if "flip" in attributes:
238 flip = attributes.pop("flip")
239 if flip in ("horizontal", "vertical"):
240 icon_class += f" ph-flip-{flip}"
242 return icon_class
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"
249 if "pulse" in attributes and attributes.pop("pulse"):
250 icon_class += " ph-pulse"
252 return icon_class
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
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 )
272 if color in semantic_colors:
273 return f"{icon_class} ph-{color}"
275 # Custom color via style
276 attributes["style"] = f"color: {color}; {attributes.get('style', '')}"
277 return icon_class
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"
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}"
291 return icon_class
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)
303 # Add custom classes first
304 if "class" in attributes:
305 icon_class += f" {attributes.pop('class')}"
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)
314 # Build final attributes
315 attrs = {"class": icon_class} | attributes
317 # Add accessibility attributes
318 if "aria-label" not in attrs and "title" not in attrs:
319 attrs["aria-hidden"] = "true"
321 # Generate tag
322 attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
323 return f"<i {attr_string}></i>"
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"
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};"
343 if style:
344 attributes["style"] = style
346 return self.get_icon_tag(icon_name, **attributes)
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()
355 if not variant:
356 variant = self.settings.default_variant
358 # Resolve icon aliases
359 if icon_name in self.settings.icon_aliases:
360 icon_name = self.settings.icon_aliases[icon_name]
362 # Build SVG tag
363 svg_class = f"ph ph-{icon_name}"
364 if "class" in attributes:
365 svg_class += f" {attributes.pop('class')}"
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
375 # Add accessibility
376 if "aria-label" not in svg_attrs and "title" not in svg_attrs:
377 svg_attrs["aria-hidden"] = "true"
379 attr_string = " ".join(
380 f'{k}="{v}"' for k, v in svg_attrs.items() if v is not None
381 )
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>'
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 }
470# Template filter registration for FastBlocks
471def _register_ph_basic_filters(env: Any) -> None:
472 """Register basic Phosphor filters."""
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} -->"
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}"
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 ""
504def _register_ph_duotone_functions(env: Any) -> None:
505 """Register Phosphor duotone functions."""
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 -->"
523def _register_ph_interactive_functions(env: Any) -> None:
524 """Register Phosphor interactive functions."""
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} -->"
538 attributes["interactive"] = True
539 if action:
540 attributes["onclick"] = action
541 attributes["style"] = f"cursor: pointer; {attributes.get('style', '')}"
543 return icons.get_icon_tag(icon_name, variant, **attributes)
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>"
558 icon_tag = icons.get_icon_tag(icon_name, variant, class_="ph-sm")
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
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>"
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)
581# ACB 0.19.0+ compatibility
582__all__ = ["PhosphorAdapter", "PhosphorSettings", "register_phosphor_filters"]