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

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.config import Settings 

8from acb.depends import depends 

9 

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) 

19 

20 

21class RemixIconSettings(Settings): # type: ignore[misc] 

22 """Settings for Remix Icon adapter.""" 

23 

24 # Required ACB 0.19.0+ metadata 

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

26 MODULE_STATUS: str = "stable" 

27 

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" 

33 

34 # Icon variants 

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

36 

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 } 

79 

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 } 

92 

93 

94class RemixIconAdapter(IconsBase): 

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

96 

97 # Required ACB 0.19.0+ metadata 

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

99 MODULE_STATUS: str = "stable" 

100 

101 def __init__(self) -> None: 

102 """Initialize Remix Icon adapter.""" 

103 super().__init__() 

104 self.settings: RemixIconSettings | None = None 

105 

106 # Register with ACB dependency system 

107 with suppress(Exception): 

108 depends.set(self) 

109 

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

111 """Get Remix Icon stylesheet links.""" 

112 if not self.settings: 

113 self.settings = RemixIconSettings() 

114 

115 links = [] 

116 

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

120 

121 # Custom Remix Icon CSS 

122 remix_css = self._generate_remixicon_css() 

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

124 

125 return links 

126 

127 def _generate_remixicon_css(self) -> str: 

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

129 if not self.settings: 

130 self.settings = RemixIconSettings() 

131 

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

143 

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

154 

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

161 

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

168 

169/* Animation support */ 

170.ri-spin {{ 

171 animation: ri-spin 2s linear infinite; 

172}} 

173 

174.ri-pulse {{ 

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

176}} 

177 

178.ri-bounce {{ 

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

180}} 

181 

182.ri-shake {{ 

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

184}} 

185 

186@keyframes ri-spin {{ 

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

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

189}} 

190 

191@keyframes ri-pulse {{ 

192 from {{ opacity: 1; }} 

193 to {{ opacity: 0.25; }} 

194}} 

195 

196@keyframes ri-bounce {{ 

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

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

199}} 

200 

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

207 

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

220 

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

228 

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

235 

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

242 

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

249 

250/* Interactive states */ 

251.ri-interactive {{ 

252 cursor: pointer; 

253 transition: all 0.2s ease; 

254}} 

255 

256.ri-interactive:hover {{ 

257 transform: scale(1.1); 

258 opacity: 0.8; 

259}} 

260 

261.ri-interactive:active {{ 

262 transform: scale(0.95); 

263}} 

264 

265/* States */ 

266.ri-disabled {{ 

267 opacity: 0.5; 

268 cursor: not-allowed; 

269}} 

270 

271.ri-loading {{ 

272 opacity: 0.6; 

273}} 

274 

275/* Button integration */ 

276.btn .ri {{ 

277 margin-right: 0.5rem; 

278 vertical-align: -0.125em; 

279}} 

280 

281.btn .ri:last-child {{ 

282 margin-right: 0; 

283 margin-left: 0.5rem; 

284}} 

285 

286.btn .ri:only-child {{ 

287 margin: 0; 

288}} 

289 

290.btn-sm .ri {{ 

291 font-size: 0.875em; 

292}} 

293 

294.btn-lg .ri {{ 

295 font-size: 1.125em; 

296}} 

297 

298/* Badge integration */ 

299.badge .ri {{ 

300 font-size: 0.875em; 

301 margin-right: 0.25rem; 

302 vertical-align: baseline; 

303}} 

304 

305/* Navigation integration */ 

306.nav-link .ri {{ 

307 margin-right: 0.5rem; 

308 font-size: 1.125em; 

309}} 

310 

311/* Input group integration */ 

312.input-group-text .ri {{ 

313 color: inherit; 

314}} 

315 

316/* Alert integration */ 

317.alert .ri {{ 

318 margin-right: 0.5rem; 

319 font-size: 1.125em; 

320}} 

321 

322/* Card integration */ 

323.card-title .ri {{ 

324 margin-right: 0.5rem; 

325}} 

326 

327/* List group integration */ 

328.list-group-item .ri {{ 

329 margin-right: 0.75rem; 

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

331}} 

332 

333/* Dropdown integration */ 

334.dropdown-item .ri {{ 

335 margin-right: 0.5rem; 

336 width: 1em; 

337 text-align: center; 

338}} 

339 

340/* Breadcrumb integration */ 

341.breadcrumb-item .ri {{ 

342 margin-right: 0.25rem; 

343}} 

344 

345/* Responsive utilities */ 

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

347 .ri-responsive {{ 

348 font-size: 0.875em; 

349 }} 

350}} 

351 

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

353 .ri-md-hide {{ 

354 display: none; 

355 }} 

356}} 

357""" 

358 

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

363 

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

373 

374 # Ensure proper ri- prefix 

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

376 resolved_name = f"ri-{resolved_name}" 

377 

378 return f"ri {resolved_name}" 

379 

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) 

389 

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 ) 

398 

399 # Add custom classes 

400 if "class" in attributes: 

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

402 

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 ) 

408 

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

431 

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

437 

438 # Combine all classes 

439 icon_class += ( 

440 transform_classes + animation_classes + color_class + state_classes 

441 ) 

442 

443 # Build attributes and add accessibility 

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

445 attrs = add_accessibility_attributes(attrs) 

446 

447 # Generate tag 

448 attr_string = build_attr_string(attrs) 

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

450 

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 ) 

464 

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 ) 

469 

470 # Container attributes 

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

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

473 

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

475 

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

489 

490 return f""" 

491 <style>{stack_css}</style> 

492 <span {attr_string}> 

493 {bg_icon} 

494 {fg_icon} 

495 </span> 

496 """ 

497 

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 } 

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

641 if isinstance(icons, RemixIconAdapter): 

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

649 if isinstance(icons, RemixIconAdapter): 

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

657 if isinstance(icons, RemixIconAdapter): 

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

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

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

713 if not isinstance(icons, RemixIconAdapter): 

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 

742# ACB 0.19.0+ compatibility 

743__all__ = ["RemixIconAdapter", "RemixIconSettings", "register_remixicon_filters"]