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

1"""Remix Icon adapter for FastBlocks with extensive icon library.""" 

2 

3from contextlib import suppress 

4from typing import Any 

5from uuid import UUID 

6 

7from acb.depends import depends 

8 

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) 

18 

19 

20class RemixIconSettings(IconsBaseSettings): 

21 """Settings for Remix Icon adapter.""" 

22 

23 # Required ACB 0.19.0+ metadata 

24 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7 

25 MODULE_STATUS: str = "stable" 

26 

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" 

32 

33 # Icon variants 

34 enabled_variants: list[str] = ["line", "fill"] 

35 

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 } 

78 

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 } 

91 

92 

93class RemixIcon(IconsBase): 

94 """Remix Icon adapter with extensive icon library.""" 

95 

96 # Required ACB 0.19.0+ metadata 

97 MODULE_ID: UUID = UUID("01937d86-be9a-d3ab-fc2d-1b2c3d4e5f60") # Static UUID7 

98 MODULE_STATUS: str = "stable" 

99 

100 def __init__(self) -> None: 

101 """Initialize Remix Icon adapter.""" 

102 super().__init__() 

103 self.settings: RemixIconSettings | None = None 

104 

105 # Register with ACB dependency system 

106 with suppress(Exception): 

107 depends.set(self) 

108 

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

110 """Get Remix Icon stylesheet links.""" 

111 if not self.settings: 

112 self.settings = RemixIconSettings() 

113 

114 links = [] 

115 

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}">') 

119 

120 # Custom Remix Icon CSS 

121 remix_css = self._generate_remixicon_css() 

122 links.append(f"<style>{remix_css}</style>") 

123 

124 return links 

125 

126 def _generate_remixicon_css(self) -> str: 

127 """Generate Remix Icon-specific CSS.""" 

128 if not self.settings: 

129 self.settings = RemixIconSettings() 

130 

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}} 

142 

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; }} 

153 

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; }} 

160 

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); }} 

167 

168/* Animation support */ 

169.ri-spin {{ 

170 animation: ri-spin 2s linear infinite; 

171}} 

172 

173.ri-pulse {{ 

174 animation: ri-pulse 2s ease-in-out infinite alternate; 

175}} 

176 

177.ri-bounce {{ 

178 animation: ri-bounce 1s ease-in-out infinite; 

179}} 

180 

181.ri-shake {{ 

182 animation: ri-shake 0.82s cubic-bezier(.36,.07,.19,.97) both; 

183}} 

184 

185@keyframes ri-spin {{ 

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

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

188}} 

189 

190@keyframes ri-pulse {{ 

191 from {{ opacity: 1; }} 

192 to {{ opacity: 0.25; }} 

193}} 

194 

195@keyframes ri-bounce {{ 

196 0%, 100% {{ transform: translateY(0); }} 

197 50% {{ transform: translateY(-25%); }} 

198}} 

199 

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}} 

206 

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; }} 

219 

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}} 

227 

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}} 

234 

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}} 

241 

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}} 

248 

249/* Interactive states */ 

250.ri-interactive {{ 

251 cursor: pointer; 

252 transition: all 0.2s ease; 

253}} 

254 

255.ri-interactive:hover {{ 

256 transform: scale(1.1); 

257 opacity: 0.8; 

258}} 

259 

260.ri-interactive:active {{ 

261 transform: scale(0.95); 

262}} 

263 

264/* States */ 

265.ri-disabled {{ 

266 opacity: 0.5; 

267 cursor: not-allowed; 

268}} 

269 

270.ri-loading {{ 

271 opacity: 0.6; 

272}} 

273 

274/* Button integration */ 

275.btn .ri {{ 

276 margin-right: 0.5rem; 

277 vertical-align: -0.125em; 

278}} 

279 

280.btn .ri:last-child {{ 

281 margin-right: 0; 

282 margin-left: 0.5rem; 

283}} 

284 

285.btn .ri:only-child {{ 

286 margin: 0; 

287}} 

288 

289.btn-sm .ri {{ 

290 font-size: 0.875em; 

291}} 

292 

293.btn-lg .ri {{ 

294 font-size: 1.125em; 

295}} 

296 

297/* Badge integration */ 

298.badge .ri {{ 

299 font-size: 0.875em; 

300 margin-right: 0.25rem; 

301 vertical-align: baseline; 

302}} 

303 

304/* Navigation integration */ 

305.nav-link .ri {{ 

306 margin-right: 0.5rem; 

307 font-size: 1.125em; 

308}} 

309 

310/* Input group integration */ 

311.input-group-text .ri {{ 

312 color: inherit; 

313}} 

314 

315/* Alert integration */ 

316.alert .ri {{ 

317 margin-right: 0.5rem; 

318 font-size: 1.125em; 

319}} 

320 

321/* Card integration */ 

322.card-title .ri {{ 

323 margin-right: 0.5rem; 

324}} 

325 

326/* List group integration */ 

327.list-group-item .ri {{ 

328 margin-right: 0.75rem; 

329 color: var(--bs-text-muted, #6c757d); 

330}} 

331 

332/* Dropdown integration */ 

333.dropdown-item .ri {{ 

334 margin-right: 0.5rem; 

335 width: 1em; 

336 text-align: center; 

337}} 

338 

339/* Breadcrumb integration */ 

340.breadcrumb-item .ri {{ 

341 margin-right: 0.25rem; 

342}} 

343 

344/* Responsive utilities */ 

345@media (max-width: 576px) {{ 

346 .ri-responsive {{ 

347 font-size: 0.875em; 

348 }} 

349}} 

350 

351@media (max-width: 768px) {{ 

352 .ri-md-hide {{ 

353 display: none; 

354 }} 

355}} 

356""" 

357 

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() 

362 

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}" 

372 

373 # Ensure proper ri- prefix 

374 if not resolved_name.startswith("ri-"): 

375 resolved_name = f"ri-{resolved_name}" 

376 

377 return f"ri {resolved_name}" 

378 

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) 

388 

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 ) 

397 

398 # Add custom classes 

399 if "class" in attributes: 

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

401 

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 ) 

407 

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") 

430 

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}" 

436 

437 # Combine all classes 

438 icon_class += ( 

439 transform_classes + animation_classes + color_class + state_classes 

440 ) 

441 

442 # Build attributes and add accessibility 

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

444 attrs = add_accessibility_attributes(attrs) 

445 

446 # Generate tag 

447 attr_string = build_attr_string(attrs) 

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

449 

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 ) 

463 

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 ) 

468 

469 # Container attributes 

470 container_class = "ri-stack " + attributes.pop("class", "") 

471 container_attrs = {"class": container_class.strip()} | attributes 

472 

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

474 

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 """ 

488 

489 return f""" 

490 <style>{stack_css}</style> 

491 <span {attr_string}> 

492 {bg_icon} 

493 {fg_icon} 

494 </span> 

495 """ 

496 

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 } 

626 

627 

628# Template filter registration for FastBlocks 

629def _register_ri_basic_filters(env: Any) -> None: 

630 """Register basic Remix Icon filters.""" 

631 

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} -->" 

644 

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}" 

652 

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 "" 

660 

661 

662def _register_ri_advanced_functions(env: Any) -> None: 

663 """Register advanced Remix Icon functions.""" 

664 

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} -->" 

684 

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 -->" 

698 

699 

700def _register_ri_button_functions(env: Any) -> None: 

701 """Register Remix Icon button functions.""" 

702 

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>" 

715 

716 btn_class = attributes.pop("class", "btn btn-primary") 

717 

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 

728 

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>" 

733 

734 

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) 

740 

741 

742IconsSettings = RemixIconSettings 

743Icons = RemixIcon 

744 

745depends.set(Icons, "remixicon") 

746 

747 

748# ACB 0.19.0+ compatibility 

749__all__ = [ 

750 "RemixIcon", 

751 "RemixIconSettings", 

752 "register_remixicon_filters", 

753 "Icons", 

754 "IconsSettings", 

755]