Coverage for fastblocks/adapters/icons/remixicon.py: 0%
135 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
1"""Remix Icon adapter for FastBlocks with extensive icon library."""
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
11from ._utils import (
12 add_accessibility_attributes,
13 build_attr_string,
14 process_animations,
15 process_semantic_colors,
16 process_state_attributes,
17 process_transformations,
18)
21class RemixIconSettings(Settings): # type: ignore[misc]
22 """Settings for Remix Icon adapter."""
24 # Required ACB 0.19.0+ metadata
25 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7
26 MODULE_STATUS: str = "stable"
28 # Remix Icon configuration
29 version: str = "4.2.0"
30 cdn_url: str = "https://cdn.jsdelivr.net/npm/remixicon"
31 default_variant: str = "line" # line, fill
32 default_size: str = "1em"
34 # Icon variants
35 enabled_variants: list[str] = ["line", "fill"]
37 # Icon mapping for common names
38 icon_aliases: dict[str, str] = {
39 "home": "home-line",
40 "user": "user-line",
41 "settings": "settings-line",
42 "search": "search-line",
43 "menu": "menu-line",
44 "close": "close-line",
45 "check": "check-line",
46 "error": "error-warning-line",
47 "info": "information-line",
48 "success": "checkbox-circle-line",
49 "warning": "alert-line",
50 "edit": "edit-line",
51 "delete": "delete-bin-line",
52 "save": "save-line",
53 "download": "download-line",
54 "upload": "upload-line",
55 "email": "mail-line",
56 "phone": "phone-line",
57 "location": "map-pin-line",
58 "calendar": "calendar-line",
59 "clock": "time-line",
60 "heart": "heart-line",
61 "star": "star-line",
62 "share": "share-line",
63 "link": "external-link-line",
64 "copy": "file-copy-line",
65 "cut": "scissors-cut-line",
66 "paste": "clipboard-line",
67 "undo": "arrow-go-back-line",
68 "redo": "arrow-go-forward-line",
69 "refresh": "refresh-line",
70 "logout": "logout-box-r-line",
71 "login": "login-box-line",
72 "plus": "add-line",
73 "minus": "subtract-line",
74 "eye": "eye-line",
75 "eye-off": "eye-off-line",
76 "lock": "lock-line",
77 "unlock": "lock-unlock-line",
78 }
80 # Size presets
81 size_presets: dict[str, str] = {
82 "xs": "0.75em",
83 "sm": "0.875em",
84 "md": "1em",
85 "lg": "1.125em",
86 "xl": "1.25em",
87 "2xl": "1.5em",
88 "3xl": "1.875em",
89 "4xl": "2.25em",
90 "5xl": "3em",
91 }
94class RemixIconAdapter(IconsBase):
95 """Remix Icon adapter with extensive icon library."""
97 # Required ACB 0.19.0+ metadata
98 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7
99 MODULE_STATUS: str = "stable"
101 def __init__(self) -> None:
102 """Initialize Remix Icon adapter."""
103 super().__init__()
104 self.settings: RemixIconSettings | None = None
106 # Register with ACB dependency system
107 with suppress(Exception):
108 depends.set(self)
110 def get_stylesheet_links(self) -> list[str]:
111 """Get Remix Icon stylesheet links."""
112 if not self.settings:
113 self.settings = RemixIconSettings()
115 links = []
117 # Remix Icon CSS from CDN
118 css_url = f"{self.settings.cdn_url}@{self.settings.version}/fonts/remixicon.css"
119 links.append(f'<link rel="stylesheet" href="{css_url}">')
121 # Custom Remix Icon CSS
122 remix_css = self._generate_remixicon_css()
123 links.append(f"<style>{remix_css}</style>")
125 return links
127 def _generate_remixicon_css(self) -> str:
128 """Generate Remix Icon-specific CSS."""
129 if not self.settings:
130 self.settings = RemixIconSettings()
132 return f"""
133/* Remix Icon Base Styles */
134.ri {{
135 display: inline-block;
136 font-style: normal;
137 font-variant: normal;
138 text-rendering: auto;
139 line-height: 1;
140 vertical-align: -0.125em;
141 font-size: {self.settings.default_size};
142}}
144/* Size variants */
145.ri-xs {{ font-size: 0.75em; }}
146.ri-sm {{ font-size: 0.875em; }}
147.ri-md {{ font-size: 1em; }}
148.ri-lg {{ font-size: 1.125em; }}
149.ri-xl {{ font-size: 1.25em; }}
150.ri-2xl {{ font-size: 1.5em; }}
151.ri-3xl {{ font-size: 1.875em; }}
152.ri-4xl {{ font-size: 2.25em; }}
153.ri-5xl {{ font-size: 3em; }}
155/* Weight variants (for consistency with other icon sets) */
156.ri-thin {{ font-weight: 100; }}
157.ri-light {{ font-weight: 300; }}
158.ri-regular {{ font-weight: 400; }}
159.ri-medium {{ font-weight: 500; }}
160.ri-bold {{ font-weight: 700; }}
162/* Rotation and transformation */
163.ri-rotate-90 {{ transform: rotate(90deg); }}
164.ri-rotate-180 {{ transform: rotate(180deg); }}
165.ri-rotate-270 {{ transform: rotate(270deg); }}
166.ri-flip-horizontal {{ transform: scaleX(-1); }}
167.ri-flip-vertical {{ transform: scaleY(-1); }}
169/* Animation support */
170.ri-spin {{
171 animation: ri-spin 2s linear infinite;
172}}
174.ri-pulse {{
175 animation: ri-pulse 2s ease-in-out infinite alternate;
176}}
178.ri-bounce {{
179 animation: ri-bounce 1s ease-in-out infinite;
180}}
182.ri-shake {{
183 animation: ri-shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
184}}
186@keyframes ri-spin {{
187 0% {{ transform: rotate(0deg); }}
188 100% {{ transform: rotate(360deg); }}
189}}
191@keyframes ri-pulse {{
192 from {{ opacity: 1; }}
193 to {{ opacity: 0.25; }}
194}}
196@keyframes ri-bounce {{
197 0%, 100% {{ transform: translateY(0); }}
198 50% {{ transform: translateY(-25%); }}
199}}
201@keyframes ri-shake {{
202 10%, 90% {{ transform: translate3d(-1px, 0, 0); }}
203 20%, 80% {{ transform: translate3d(2px, 0, 0); }}
204 30%, 50%, 70% {{ transform: translate3d(-4px, 0, 0); }}
205 40%, 60% {{ transform: translate3d(4px, 0, 0); }}
206}}
208/* Color utilities */
209.ri-primary {{ color: var(--primary-color, #007bff); }}
210.ri-secondary {{ color: var(--secondary-color, #6c757d); }}
211.ri-success {{ color: var(--success-color, #28a745); }}
212.ri-warning {{ color: var(--warning-color, #ffc107); }}
213.ri-danger {{ color: var(--danger-color, #dc3545); }}
214.ri-info {{ color: var(--info-color, #17a2b8); }}
215.ri-light {{ color: var(--light-color, #f8f9fa); }}
216.ri-dark {{ color: var(--dark-color, #343a40); }}
217.ri-muted {{ color: var(--muted-color, #6c757d); }}
218.ri-white {{ color: white; }}
219.ri-black {{ color: black; }}
221/* Gradient colors */
222.ri-gradient-primary {{
223 background: linear-gradient(45deg, #007bff, #0056b3);
224 -webkit-background-clip: text;
225 -webkit-text-fill-color: transparent;
226 background-clip: text;
227}}
229.ri-gradient-success {{
230 background: linear-gradient(45deg, #28a745, #155724);
231 -webkit-background-clip: text;
232 -webkit-text-fill-color: transparent;
233 background-clip: text;
234}}
236.ri-gradient-warning {{
237 background: linear-gradient(45deg, #ffc107, #856404);
238 -webkit-background-clip: text;
239 -webkit-text-fill-color: transparent;
240 background-clip: text;
241}}
243.ri-gradient-danger {{
244 background: linear-gradient(45deg, #dc3545, #721c24);
245 -webkit-background-clip: text;
246 -webkit-text-fill-color: transparent;
247 background-clip: text;
248}}
250/* Interactive states */
251.ri-interactive {{
252 cursor: pointer;
253 transition: all 0.2s ease;
254}}
256.ri-interactive:hover {{
257 transform: scale(1.1);
258 opacity: 0.8;
259}}
261.ri-interactive:active {{
262 transform: scale(0.95);
263}}
265/* States */
266.ri-disabled {{
267 opacity: 0.5;
268 cursor: not-allowed;
269}}
271.ri-loading {{
272 opacity: 0.6;
273}}
275/* Button integration */
276.btn .ri {{
277 margin-right: 0.5rem;
278 vertical-align: -0.125em;
279}}
281.btn .ri:last-child {{
282 margin-right: 0;
283 margin-left: 0.5rem;
284}}
286.btn .ri:only-child {{
287 margin: 0;
288}}
290.btn-sm .ri {{
291 font-size: 0.875em;
292}}
294.btn-lg .ri {{
295 font-size: 1.125em;
296}}
298/* Badge integration */
299.badge .ri {{
300 font-size: 0.875em;
301 margin-right: 0.25rem;
302 vertical-align: baseline;
303}}
305/* Navigation integration */
306.nav-link .ri {{
307 margin-right: 0.5rem;
308 font-size: 1.125em;
309}}
311/* Input group integration */
312.input-group-text .ri {{
313 color: inherit;
314}}
316/* Alert integration */
317.alert .ri {{
318 margin-right: 0.5rem;
319 font-size: 1.125em;
320}}
322/* Card integration */
323.card-title .ri {{
324 margin-right: 0.5rem;
325}}
327/* List group integration */
328.list-group-item .ri {{
329 margin-right: 0.75rem;
330 color: var(--bs-text-muted, #6c757d);
331}}
333/* Dropdown integration */
334.dropdown-item .ri {{
335 margin-right: 0.5rem;
336 width: 1em;
337 text-align: center;
338}}
340/* Breadcrumb integration */
341.breadcrumb-item .ri {{
342 margin-right: 0.25rem;
343}}
345/* Responsive utilities */
346@media (max-width: 576px) {{
347 .ri-responsive {{
348 font-size: 0.875em;
349 }}
350}}
352@media (max-width: 768px) {{
353 .ri-md-hide {{
354 display: none;
355 }}
356}}
357"""
359 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str:
360 """Get Remix Icon class with variant support."""
361 if not self.settings:
362 self.settings = RemixIconSettings()
364 # Resolve icon aliases
365 resolved_name = icon_name
366 if icon_name in self.settings.icon_aliases:
367 resolved_name = self.settings.icon_aliases[icon_name]
368 elif not icon_name.endswith(("-line", "-fill")):
369 # Auto-append variant if not present
370 if not variant:
371 variant = self.settings.default_variant
372 resolved_name = f"{icon_name}-{variant}"
374 # Ensure proper ri- prefix
375 if not resolved_name.startswith("ri-"):
376 resolved_name = f"ri-{resolved_name}"
378 return f"ri {resolved_name}"
380 def get_icon_tag( # type: ignore[override] # Intentional API extension with variant/size
381 self,
382 icon_name: str,
383 variant: str | None = None,
384 size: str | None = None,
385 **attributes: Any,
386 ) -> str:
387 """Generate Remix Icon tag with full customization."""
388 icon_class = self.get_icon_class(icon_name, variant)
390 # Add size class or custom size
391 if size:
392 if self.settings and size in self.settings.size_presets:
393 icon_class += f" ri-{size}"
394 else:
395 attributes["style"] = (
396 f"font-size: {size}; {attributes.get('style', '')}"
397 )
399 # Add custom classes
400 if "class" in attributes:
401 icon_class += f" {attributes.pop('class')}"
403 # Process attributes using shared utilities
404 transform_classes, attributes = process_transformations(attributes, "ri")
405 animation_classes, attributes = process_animations(
406 attributes, ["spin", "pulse", "bounce", "shake"], "ri"
407 )
409 # Extended semantic colors including gradients
410 semantic_colors = [
411 "primary",
412 "secondary",
413 "success",
414 "warning",
415 "danger",
416 "info",
417 "light",
418 "dark",
419 "muted",
420 "white",
421 "black",
422 "gradient-primary",
423 "gradient-success",
424 "gradient-warning",
425 "gradient-danger",
426 ]
427 color_class, attributes = process_semantic_colors(
428 attributes, semantic_colors, "ri"
429 )
430 state_classes, attributes = process_state_attributes(attributes, "ri")
432 # Handle weight (Remix-specific feature)
433 if "weight" in attributes:
434 weight = attributes.pop("weight")
435 if weight in ("thin", "light", "regular", "medium", "bold"):
436 icon_class += f" ri-{weight}"
438 # Combine all classes
439 icon_class += (
440 transform_classes + animation_classes + color_class + state_classes
441 )
443 # Build attributes and add accessibility
444 attrs = {"class": icon_class} | attributes
445 attrs = add_accessibility_attributes(attrs)
447 # Generate tag
448 attr_string = build_attr_string(attrs)
449 return f"<i {attr_string}></i>"
451 def get_stacked_icons(
452 self,
453 background_icon: str,
454 foreground_icon: str,
455 background_variant: str = "fill",
456 foreground_variant: str = "line",
457 **attributes: Any,
458 ) -> str:
459 """Generate stacked Remix Icons for layered effects."""
460 # Background icon (larger, usually filled)
461 bg_icon = self.get_icon_tag(
462 background_icon, background_variant, size="lg", class_="ri-stack-background"
463 )
465 # Foreground icon (smaller, usually line)
466 fg_icon = self.get_icon_tag(
467 foreground_icon, foreground_variant, size="sm", class_="ri-stack-foreground"
468 )
470 # Container attributes
471 container_class = "ri-stack " + attributes.pop("class", "")
472 container_attrs = {"class": container_class.strip()} | attributes
474 attr_string = " ".join(f'{k}="{v}"' for k, v in container_attrs.items())
476 # Additional CSS for stacking (inline)
477 stack_css = """
478 .ri-stack {
479 position: relative;
480 display: inline-block;
481 }
482 .ri-stack .ri-stack-foreground {
483 position: absolute;
484 top: 50%;
485 left: 50%;
486 transform: translate(-50%, -50%);
487 }
488 """
490 return f"""
491 <style>{stack_css}</style>
492 <span {attr_string}>
493 {bg_icon}
494 {fg_icon}
495 </span>
496 """
498 def get_available_icons(self) -> dict[str, list[str]]:
499 """Get list of available icons by category."""
500 return {
501 "general": [
502 "home-line",
503 "user-line",
504 "settings-line",
505 "search-line",
506 "menu-line",
507 "close-line",
508 "check-line",
509 "add-line",
510 "subtract-line",
511 "more-line",
512 ],
513 "communication": [
514 "mail-line",
515 "phone-line",
516 "chat-1-line",
517 "message-2-line",
518 "notification-line",
519 "speak-line",
520 "mic-line",
521 "vidicon-line",
522 ],
523 "media": [
524 "play-line",
525 "pause-line",
526 "stop-line",
527 "skip-back-line",
528 "skip-forward-line",
529 "volume-up-line",
530 "volume-down-line",
531 "volume-mute-line",
532 "music-2-line",
533 ],
534 "navigation": [
535 "arrow-left-line",
536 "arrow-right-line",
537 "arrow-up-line",
538 "arrow-down-line",
539 "arrow-left-s-line",
540 "arrow-right-s-line",
541 "arrow-up-s-line",
542 "arrow-down-s-line",
543 ],
544 "file": [
545 "file-line",
546 "folder-line",
547 "download-line",
548 "upload-line",
549 "save-line",
550 "file-text-line",
551 "image-line",
552 "video-line",
553 ],
554 "editing": [
555 "edit-line",
556 "delete-bin-line",
557 "file-copy-line",
558 "scissors-cut-line",
559 "clipboard-line",
560 "eye-line",
561 "eye-off-line",
562 "lock-line",
563 ],
564 "business": [
565 "briefcase-line",
566 "calendar-line",
567 "time-line",
568 "bar-chart-line",
569 "money-dollar-circle-line",
570 "bank-card-line",
571 "receipt-line",
572 "invoice-line",
573 ],
574 "social": [
575 "heart-line",
576 "star-line",
577 "share-line",
578 "thumb-up-line",
579 "thumb-down-line",
580 "bookmark-line",
581 "flag-line",
582 "gift-line",
583 "trophy-line",
584 ],
585 "weather": [
586 "sun-line",
587 "moon-line",
588 "cloudy-line",
589 "rainy-line",
590 "snowy-line",
591 "thunderstorms-line",
592 "mist-line",
593 "temp-hot-line",
594 ],
595 "technology": [
596 "smartphone-line",
597 "computer-line",
598 "tv-line",
599 "camera-line",
600 "headphone-line",
601 "keyboard-line",
602 "mouse-line",
603 "router-line",
604 ],
605 "transportation": [
606 "car-line",
607 "bus-line",
608 "subway-line",
609 "taxi-line",
610 "bike-line",
611 "walk-line",
612 "flight-takeoff-line",
613 "ship-line",
614 ],
615 "health": [
616 "heart-pulse-line",
617 "medicine-bottle-line",
618 "hospital-line",
619 "first-aid-kit-line",
620 "capsule-line",
621 "stethoscope-line",
622 "thermometer-line",
623 "mental-health-line",
624 ],
625 }
628# Template filter registration for FastBlocks
629def _register_ri_basic_filters(env: Any) -> None:
630 """Register basic Remix Icon filters."""
632 @env.filter("ri") # type: ignore[misc]
633 def ri_filter(
634 icon_name: str,
635 variant: str | None = None,
636 size: str | None = None,
637 **attributes: Any,
638 ) -> str:
639 """Template filter for Remix Icons."""
640 icons = depends.get("icons")
641 if isinstance(icons, RemixIconAdapter):
642 return icons.get_icon_tag(icon_name, variant, size, **attributes)
643 return f"<!-- {icon_name} -->"
645 @env.filter("ri_class") # type: ignore[misc]
646 def ri_class_filter(icon_name: str, variant: str | None = None) -> str:
647 """Template filter for Remix Icon classes."""
648 icons = depends.get("icons")
649 if isinstance(icons, RemixIconAdapter):
650 return icons.get_icon_class(icon_name, variant)
651 return f"ri-{icon_name}"
653 @env.global_("remixicon_stylesheet_links") # type: ignore[misc]
654 def remixicon_stylesheet_links() -> str:
655 """Global function for Remix Icon stylesheet links."""
656 icons = depends.get("icons")
657 if isinstance(icons, RemixIconAdapter):
658 return "\n".join(icons.get_stylesheet_links())
659 return ""
662def _register_ri_advanced_functions(env: Any) -> None:
663 """Register advanced Remix Icon functions."""
665 @env.global_("ri_stacked") # type: ignore[misc]
666 def ri_stacked(
667 background_icon: str,
668 foreground_icon: str,
669 background_variant: str = "fill",
670 foreground_variant: str = "line",
671 **attributes: Any,
672 ) -> str:
673 """Generate stacked Remix Icons."""
674 icons = depends.get("icons")
675 if isinstance(icons, RemixIconAdapter):
676 return icons.get_stacked_icons(
677 background_icon,
678 foreground_icon,
679 background_variant,
680 foreground_variant,
681 **attributes,
682 )
683 return f"<!-- {background_icon} + {foreground_icon} -->"
685 @env.global_("ri_gradient") # type: ignore[misc]
686 def ri_gradient(
687 icon_name: str,
688 gradient_type: str = "primary",
689 variant: str = "fill",
690 **attributes: Any,
691 ) -> str:
692 """Generate gradient Remix Icon."""
693 icons = depends.get("icons")
694 if isinstance(icons, RemixIconAdapter):
695 attributes["color"] = f"gradient-{gradient_type}"
696 return icons.get_icon_tag(icon_name, variant, **attributes)
697 return f"<!-- {icon_name} gradient -->"
700def _register_ri_button_functions(env: Any) -> None:
701 """Register Remix Icon button functions."""
703 @env.global_("ri_button") # type: ignore[misc] # Jinja2 decorator preserves signature
704 def ri_button(
705 text: str,
706 icon: str | None = None,
707 variant: str = "line",
708 icon_position: str = "left",
709 **attributes: Any,
710 ) -> str:
711 """Generate button with Remix Icon."""
712 icons = depends.get("icons")
713 if not isinstance(icons, RemixIconAdapter):
714 return f"<button>{text}</button>"
716 btn_class = attributes.pop("class", "btn btn-primary")
718 if icon:
719 icon_tag = icons.get_icon_tag(icon, variant, size="sm")
720 position_map = {
721 "left": f"{icon_tag} {text}",
722 "right": f"{text} {icon_tag}",
723 "only": icon_tag,
724 }
725 content = position_map.get(icon_position, text)
726 else:
727 content = text
729 attr_string = " ".join(
730 f'{k}="{v}"' for k, v in ({"class": btn_class} | attributes).items()
731 )
732 return f"<button {attr_string}>{content}</button>"
735def register_remixicon_filters(env: Any) -> None:
736 """Register Remix Icon filters for Jinja2 templates."""
737 _register_ri_basic_filters(env)
738 _register_ri_advanced_functions(env)
739 _register_ri_button_functions(env)
742# ACB 0.19.0+ compatibility
743__all__ = ["RemixIconAdapter", "RemixIconSettings", "register_remixicon_filters"]