Coverage for fastblocks / adapters / icons / remixicon.py: 28%
138 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"""Remix Icon adapter for FastBlocks with extensive icon library."""
3from contextlib import suppress
4from typing import Any
5from uuid import UUID
7from acb.depends import depends
9from ._base import IconsBase, IconsBaseSettings
10from ._utils import (
11 add_accessibility_attributes,
12 build_attr_string,
13 process_animations,
14 process_semantic_colors,
15 process_state_attributes,
16 process_transformations,
17)
20class RemixIconSettings(IconsBaseSettings):
21 """Settings for Remix Icon adapter."""
23 # Required ACB 0.19.0+ metadata
24 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7
25 MODULE_STATUS: str = "stable"
27 # Remix Icon configuration
28 version: str = "4.2.0"
29 cdn_url: str = "https://cdn.jsdelivr.net/npm/remixicon"
30 default_variant: str = "line" # line, fill
31 default_size: str = "1em"
33 # Icon variants
34 enabled_variants: list[str] = ["line", "fill"]
36 # Icon mapping for common names
37 icon_aliases: dict[str, str] = {
38 "home": "home-line",
39 "user": "user-line",
40 "settings": "settings-line",
41 "search": "search-line",
42 "menu": "menu-line",
43 "close": "close-line",
44 "check": "check-line",
45 "error": "error-warning-line",
46 "info": "information-line",
47 "success": "checkbox-circle-line",
48 "warning": "alert-line",
49 "edit": "edit-line",
50 "delete": "delete-bin-line",
51 "save": "save-line",
52 "download": "download-line",
53 "upload": "upload-line",
54 "email": "mail-line",
55 "phone": "phone-line",
56 "location": "map-pin-line",
57 "calendar": "calendar-line",
58 "clock": "time-line",
59 "heart": "heart-line",
60 "star": "star-line",
61 "share": "share-line",
62 "link": "external-link-line",
63 "copy": "file-copy-line",
64 "cut": "scissors-cut-line",
65 "paste": "clipboard-line",
66 "undo": "arrow-go-back-line",
67 "redo": "arrow-go-forward-line",
68 "refresh": "refresh-line",
69 "logout": "logout-box-r-line",
70 "login": "login-box-line",
71 "plus": "add-line",
72 "minus": "subtract-line",
73 "eye": "eye-line",
74 "eye-off": "eye-off-line",
75 "lock": "lock-line",
76 "unlock": "lock-unlock-line",
77 }
79 # Size presets
80 size_presets: dict[str, str] = {
81 "xs": "0.75em",
82 "sm": "0.875em",
83 "md": "1em",
84 "lg": "1.125em",
85 "xl": "1.25em",
86 "2xl": "1.5em",
87 "3xl": "1.875em",
88 "4xl": "2.25em",
89 "5xl": "3em",
90 }
93class RemixIcon(IconsBase):
94 """Remix Icon adapter with extensive icon library."""
96 # Required ACB 0.19.0+ metadata
97 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7
98 MODULE_STATUS: str = "stable"
100 def __init__(self) -> None:
101 """Initialize Remix Icon adapter."""
102 super().__init__()
103 self.settings: RemixIconSettings | None = None
105 # Register with ACB dependency system
106 with suppress(Exception):
107 depends.set(self)
109 def get_stylesheet_links(self) -> list[str]:
110 """Get Remix Icon stylesheet links."""
111 if not self.settings:
112 self.settings = RemixIconSettings()
114 links = []
116 # Remix Icon CSS from CDN
117 css_url = f"{self.settings.cdn_url}@{self.settings.version}/fonts/remixicon.css"
118 links.append(f'<link rel="stylesheet" href="{css_url}">')
120 # Custom Remix Icon CSS
121 remix_css = self._generate_remixicon_css()
122 links.append(f"<style>{remix_css}</style>")
124 return links
126 def _generate_remixicon_css(self) -> str:
127 """Generate Remix Icon-specific CSS."""
128 if not self.settings:
129 self.settings = RemixIconSettings()
131 return f"""
132/* Remix Icon Base Styles */
133.ri {{
134 display: inline-block;
135 font-style: normal;
136 font-variant: normal;
137 text-rendering: auto;
138 line-height: 1;
139 vertical-align: -0.125em;
140 font-size: {self.settings.default_size};
141}}
143/* Size variants */
144.ri-xs {{ font-size: 0.75em; }}
145.ri-sm {{ font-size: 0.875em; }}
146.ri-md {{ font-size: 1em; }}
147.ri-lg {{ font-size: 1.125em; }}
148.ri-xl {{ font-size: 1.25em; }}
149.ri-2xl {{ font-size: 1.5em; }}
150.ri-3xl {{ font-size: 1.875em; }}
151.ri-4xl {{ font-size: 2.25em; }}
152.ri-5xl {{ font-size: 3em; }}
154/* Weight variants (for consistency with other icon sets) */
155.ri-thin {{ font-weight: 100; }}
156.ri-light {{ font-weight: 300; }}
157.ri-regular {{ font-weight: 400; }}
158.ri-medium {{ font-weight: 500; }}
159.ri-bold {{ font-weight: 700; }}
161/* Rotation and transformation */
162.ri-rotate-90 {{ transform: rotate(90deg); }}
163.ri-rotate-180 {{ transform: rotate(180deg); }}
164.ri-rotate-270 {{ transform: rotate(270deg); }}
165.ri-flip-horizontal {{ transform: scaleX(-1); }}
166.ri-flip-vertical {{ transform: scaleY(-1); }}
168/* Animation support */
169.ri-spin {{
170 animation: ri-spin 2s linear infinite;
171}}
173.ri-pulse {{
174 animation: ri-pulse 2s ease-in-out infinite alternate;
175}}
177.ri-bounce {{
178 animation: ri-bounce 1s ease-in-out infinite;
179}}
181.ri-shake {{
182 animation: ri-shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
183}}
185@keyframes ri-spin {{
186 0% {{ transform: rotate(0deg); }}
187 100% {{ transform: rotate(360deg); }}
188}}
190@keyframes ri-pulse {{
191 from {{ opacity: 1; }}
192 to {{ opacity: 0.25; }}
193}}
195@keyframes ri-bounce {{
196 0%, 100% {{ transform: translateY(0); }}
197 50% {{ transform: translateY(-25%); }}
198}}
200@keyframes ri-shake {{
201 10%, 90% {{ transform: translate3d(-1px, 0, 0); }}
202 20%, 80% {{ transform: translate3d(2px, 0, 0); }}
203 30%, 50%, 70% {{ transform: translate3d(-4px, 0, 0); }}
204 40%, 60% {{ transform: translate3d(4px, 0, 0); }}
205}}
207/* Color utilities */
208.ri-primary {{ color: var(--primary-color, #007bff); }}
209.ri-secondary {{ color: var(--secondary-color, #6c757d); }}
210.ri-success {{ color: var(--success-color, #28a745); }}
211.ri-warning {{ color: var(--warning-color, #ffc107); }}
212.ri-danger {{ color: var(--danger-color, #dc3545); }}
213.ri-info {{ color: var(--info-color, #17a2b8); }}
214.ri-light {{ color: var(--light-color, #f8f9fa); }}
215.ri-dark {{ color: var(--dark-color, #343a40); }}
216.ri-muted {{ color: var(--muted-color, #6c757d); }}
217.ri-white {{ color: white; }}
218.ri-black {{ color: black; }}
220/* Gradient colors */
221.ri-gradient-primary {{
222 background: linear-gradient(45deg, #007bff, #0056b3);
223 -webkit-background-clip: text;
224 -webkit-text-fill-color: transparent;
225 background-clip: text;
226}}
228.ri-gradient-success {{
229 background: linear-gradient(45deg, #28a745, #155724);
230 -webkit-background-clip: text;
231 -webkit-text-fill-color: transparent;
232 background-clip: text;
233}}
235.ri-gradient-warning {{
236 background: linear-gradient(45deg, #ffc107, #856404);
237 -webkit-background-clip: text;
238 -webkit-text-fill-color: transparent;
239 background-clip: text;
240}}
242.ri-gradient-danger {{
243 background: linear-gradient(45deg, #dc3545, #721c24);
244 -webkit-background-clip: text;
245 -webkit-text-fill-color: transparent;
246 background-clip: text;
247}}
249/* Interactive states */
250.ri-interactive {{
251 cursor: pointer;
252 transition: all 0.2s ease;
253}}
255.ri-interactive:hover {{
256 transform: scale(1.1);
257 opacity: 0.8;
258}}
260.ri-interactive:active {{
261 transform: scale(0.95);
262}}
264/* States */
265.ri-disabled {{
266 opacity: 0.5;
267 cursor: not-allowed;
268}}
270.ri-loading {{
271 opacity: 0.6;
272}}
274/* Button integration */
275.btn .ri {{
276 margin-right: 0.5rem;
277 vertical-align: -0.125em;
278}}
280.btn .ri:last-child {{
281 margin-right: 0;
282 margin-left: 0.5rem;
283}}
285.btn .ri:only-child {{
286 margin: 0;
287}}
289.btn-sm .ri {{
290 font-size: 0.875em;
291}}
293.btn-lg .ri {{
294 font-size: 1.125em;
295}}
297/* Badge integration */
298.badge .ri {{
299 font-size: 0.875em;
300 margin-right: 0.25rem;
301 vertical-align: baseline;
302}}
304/* Navigation integration */
305.nav-link .ri {{
306 margin-right: 0.5rem;
307 font-size: 1.125em;
308}}
310/* Input group integration */
311.input-group-text .ri {{
312 color: inherit;
313}}
315/* Alert integration */
316.alert .ri {{
317 margin-right: 0.5rem;
318 font-size: 1.125em;
319}}
321/* Card integration */
322.card-title .ri {{
323 margin-right: 0.5rem;
324}}
326/* List group integration */
327.list-group-item .ri {{
328 margin-right: 0.75rem;
329 color: var(--bs-text-muted, #6c757d);
330}}
332/* Dropdown integration */
333.dropdown-item .ri {{
334 margin-right: 0.5rem;
335 width: 1em;
336 text-align: center;
337}}
339/* Breadcrumb integration */
340.breadcrumb-item .ri {{
341 margin-right: 0.25rem;
342}}
344/* Responsive utilities */
345@media (max-width: 576px) {{
346 .ri-responsive {{
347 font-size: 0.875em;
348 }}
349}}
351@media (max-width: 768px) {{
352 .ri-md-hide {{
353 display: none;
354 }}
355}}
356"""
358 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str:
359 """Get Remix Icon class with variant support."""
360 if not self.settings:
361 self.settings = RemixIconSettings()
363 # Resolve icon aliases
364 resolved_name = icon_name
365 if icon_name in self.settings.icon_aliases:
366 resolved_name = self.settings.icon_aliases[icon_name]
367 elif not icon_name.endswith(("-line", "-fill")):
368 # Auto-append variant if not present
369 if not variant:
370 variant = self.settings.default_variant
371 resolved_name = f"{icon_name}-{variant}"
373 # Ensure proper ri- prefix
374 if not resolved_name.startswith("ri-"):
375 resolved_name = f"ri-{resolved_name}"
377 return f"ri {resolved_name}"
379 def get_icon_tag(
380 self,
381 icon_name: str,
382 variant: str | None = None,
383 size: str | None = None,
384 **attributes: Any,
385 ) -> str:
386 """Generate Remix Icon tag with full customization."""
387 icon_class = self.get_icon_class(icon_name, variant)
389 # Add size class or custom size
390 if size:
391 if self.settings and size in self.settings.size_presets:
392 icon_class += f" ri-{size}"
393 else:
394 attributes["style"] = (
395 f"font-size: {size}; {attributes.get('style', '')}"
396 )
398 # Add custom classes
399 if "class" in attributes:
400 icon_class += f" {attributes.pop('class')}"
402 # Process attributes using shared utilities
403 transform_classes, attributes = process_transformations(attributes, "ri")
404 animation_classes, attributes = process_animations(
405 attributes, ["spin", "pulse", "bounce", "shake"], "ri"
406 )
408 # Extended semantic colors including gradients
409 semantic_colors = [
410 "primary",
411 "secondary",
412 "success",
413 "warning",
414 "danger",
415 "info",
416 "light",
417 "dark",
418 "muted",
419 "white",
420 "black",
421 "gradient-primary",
422 "gradient-success",
423 "gradient-warning",
424 "gradient-danger",
425 ]
426 color_class, attributes = process_semantic_colors(
427 attributes, semantic_colors, "ri"
428 )
429 state_classes, attributes = process_state_attributes(attributes, "ri")
431 # Handle weight (Remix-specific feature)
432 if "weight" in attributes:
433 weight = attributes.pop("weight")
434 if weight in ("thin", "light", "regular", "medium", "bold"):
435 icon_class += f" ri-{weight}"
437 # Combine all classes
438 icon_class += (
439 transform_classes + animation_classes + color_class + state_classes
440 )
442 # Build attributes and add accessibility
443 attrs = {"class": icon_class} | attributes
444 attrs = add_accessibility_attributes(attrs)
446 # Generate tag
447 attr_string = build_attr_string(attrs)
448 return f"<i {attr_string}></i>"
450 def get_stacked_icons(
451 self,
452 background_icon: str,
453 foreground_icon: str,
454 background_variant: str = "fill",
455 foreground_variant: str = "line",
456 **attributes: Any,
457 ) -> str:
458 """Generate stacked Remix Icons for layered effects."""
459 # Background icon (larger, usually filled)
460 bg_icon = self.get_icon_tag(
461 background_icon, background_variant, size="lg", class_="ri-stack-background"
462 )
464 # Foreground icon (smaller, usually line)
465 fg_icon = self.get_icon_tag(
466 foreground_icon, foreground_variant, size="sm", class_="ri-stack-foreground"
467 )
469 # Container attributes
470 container_class = "ri-stack " + attributes.pop("class", "")
471 container_attrs = {"class": container_class.strip()} | attributes
473 attr_string = " ".join(f'{k}="{v}"' for k, v in container_attrs.items())
475 # Additional CSS for stacking (inline)
476 stack_css = """
477 .ri-stack {
478 position: relative;
479 display: inline-block;
480 }
481 .ri-stack .ri-stack-foreground {
482 position: absolute;
483 top: 50%;
484 left: 50%;
485 transform: translate(-50%, -50%);
486 }
487 """
489 return f"""
490 <style>{stack_css}</style>
491 <span {attr_string}>
492 {bg_icon}
493 {fg_icon}
494 </span>
495 """
497 @staticmethod
498 def get_available_icons() -> 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_sync("icons")
641 if isinstance(icons, RemixIcon):
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_sync("icons")
649 if isinstance(icons, RemixIcon):
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_sync("icons")
657 if isinstance(icons, RemixIcon):
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_sync("icons")
675 if isinstance(icons, RemixIcon):
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_sync("icons")
694 if isinstance(icons, RemixIcon):
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_sync("icons")
713 if not isinstance(icons, RemixIcon):
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)
742IconsSettings = RemixIconSettings
743Icons = RemixIcon
745depends.set(Icons, "remixicon")
748# ACB 0.19.0+ compatibility
749__all__ = [
750 "RemixIcon",
751 "RemixIconSettings",
752 "register_remixicon_filters",
753 "Icons",
754 "IconsSettings",
755]