quantum-framework 0.18.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2567 @@
1
+ """
2
+ UI Engine - HTML/CSS Adapter
3
+
4
+ Transforms UI AST nodes into a standalone HTML/CSS page.
5
+ Uses the design tokens system for normalized styling across targets.
6
+
7
+ Inside a served page, quantum/runtime/ui_page_renderer.py reuses this drawing
8
+ with the page's runtime (UI-1); the desktop is that page in a window (UI-4).
9
+ """
10
+
11
+ import calendar as _calendar
12
+ import re
13
+ from datetime import date as _date
14
+ from typing import Dict, List, Optional, Set
15
+
16
+ from quantum.core.ast_nodes import QuantumNode
17
+ from quantum.core.features.conditionals.src.ast_node import IfNode
18
+ from quantum.core.features.loops.src.ast_node import LoopNode
19
+ from quantum.core.features.state_management.src.ast_node import SetNode
20
+ from quantum.core.features.ui_engine.src.ast_nodes import (
21
+ UIWindowNode, UIHBoxNode, UIVBoxNode, UIPanelNode,
22
+ UITabPanelNode, UITabNode, UIGridNode, UIAccordionNode,
23
+ UISectionNode, UIDividedBoxNode, UIFormNode, UIFormItemNode,
24
+ UISpacerNode, UIScrollBoxNode,
25
+ UITextNode, UIButtonNode, UIInputNode, UICheckboxNode,
26
+ UIRadioNode, UISwitchNode, UISelectNode, UITableNode,
27
+ UIColumnNode, UIListNode, UIItemNode, UIImageNode,
28
+ UILinkNode, UIProgressNode, UITreeNode, UIMenuNode,
29
+ UIOptionNode, UILogNode, UIMarkdownNode, UIHeaderNode,
30
+ UIFooterNode, UIRuleNode, UILoadingNode, UIBadgeNode,
31
+ UILayoutMixin, UIValidatorNode, UIAnimateNode, UIAnimationMixin,
32
+ # Component Library nodes
33
+ UICardNode, UICardHeaderNode, UICardBodyNode, UICardFooterNode,
34
+ UIModalNode, UIChartNode, UIAvatarNode, UITooltipNode,
35
+ UIDropdownNode, UIAlertNode, UIBreadcrumbNode, UIBreadcrumbItemNode,
36
+ UIPaginationNode, UISkeletonNode,
37
+ # Toast / carousel / stepper / calendar nodes
38
+ UIToastNode, UIToastContainerNode,
39
+ UICarouselNode, UISlideNode,
40
+ UIStepperNode, UIStepNode,
41
+ UICalendarNode, UIDatePickerNode,
42
+ )
43
+ from quantum.runtime.ui_html_templates import (
44
+ HtmlBuilder, HTML_TEMPLATE, CSS_RESET, CSS_THEME, TAB_JS,
45
+ VALIDATION_JS, VALIDATION_CSS, RESPONSIVE_CSS,
46
+ CSS_ANIMATIONS, ANIMATION_JS,
47
+ TOAST_JS, CAROUSEL_JS, STEPPER_JS, CALENDAR_JS,
48
+ )
49
+ from quantum.runtime.ui_tokens import TokenConverter
50
+ # Optional theming imports (may not be available)
51
+ try:
52
+ from quantum.core.features.theming.src import (
53
+ UIThemeNode,
54
+ get_theme_css,
55
+ get_theme_switch_js,
56
+ THEME_PRESETS,
57
+ )
58
+ HAS_THEMING = True
59
+ except ImportError:
60
+ HAS_THEMING = False
61
+ UIThemeNode = None
62
+ get_theme_css = None
63
+ get_theme_switch_js = None
64
+ THEME_PRESETS = None
65
+
66
+
67
+ # ==========================================================================
68
+ # Calendar labels (kept in sync with CALENDAR_JS in ui_html_templates.py)
69
+ # ==========================================================================
70
+
71
+ MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
72
+ 'July', 'August', 'September', 'October', 'November', 'December']
73
+ DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
74
+
75
+ # Default toast icons per variant (same glyphs used by TOAST_JS)
76
+ TOAST_ICONS = {
77
+ 'info': 'ⓘ',
78
+ 'success': '✓',
79
+ 'warning': '⚠',
80
+ 'danger': '✗',
81
+ }
82
+
83
+
84
+ # ==========================================================================
85
+ # Date picker runtime (input + calendar popup)
86
+ # ==========================================================================
87
+
88
+ DATE_PICKER_JS = """\
89
+ <script>
90
+ // Quantum UI Date Picker (text input + q-calendar popup)
91
+ window.__quantumDatePicker = (function() {
92
+ 'use strict';
93
+
94
+ var pickers = {};
95
+
96
+ function root(id) { return document.getElementById(id); }
97
+ function dropdown(id) { return document.getElementById(id + '-dropdown'); }
98
+ function inputOf(id) { return document.getElementById(id + '-input'); }
99
+ function valueOf(id) { return document.getElementById(id + '-value'); }
100
+
101
+ function format(iso, pattern) {
102
+ if (!iso) return '';
103
+ var parts = String(iso).split('-');
104
+ if (parts.length !== 3) return String(iso);
105
+ return (pattern || 'YYYY-MM-DD')
106
+ .replace('YYYY', parts[0])
107
+ .replace('YY', parts[0].slice(2))
108
+ .replace('MM', parts[1])
109
+ .replace('DD', parts[2]);
110
+ }
111
+
112
+ function open(id) {
113
+ closeAll(id);
114
+ var d = dropdown(id);
115
+ if (d) d.classList.add('open');
116
+ }
117
+
118
+ function close(id) {
119
+ var d = dropdown(id);
120
+ if (d) d.classList.remove('open');
121
+ }
122
+
123
+ function toggle(id) {
124
+ var d = dropdown(id);
125
+ if (!d) return;
126
+ if (d.classList.contains('open')) close(id); else open(id);
127
+ }
128
+
129
+ function closeAll(except) {
130
+ Object.keys(pickers).forEach(function(pid) {
131
+ if (pid !== except) close(pid);
132
+ });
133
+ }
134
+
135
+ function setValue(id, iso) {
136
+ var opts = pickers[id] || {};
137
+ var input = inputOf(id);
138
+ var hidden = valueOf(id);
139
+ if (input) input.value = format(iso, opts.format);
140
+ if (hidden) hidden.value = iso || '';
141
+ close(id);
142
+ if (opts.onChange) opts.onChange(iso || '');
143
+ }
144
+
145
+ function clear(id) {
146
+ setValue(id, '');
147
+ }
148
+
149
+ function getValue(id) {
150
+ var hidden = valueOf(id);
151
+ return hidden ? hidden.value : '';
152
+ }
153
+
154
+ function register(id, options) {
155
+ options = options || {};
156
+ pickers[id] = options;
157
+ if (options.disabled) return;
158
+ var input = inputOf(id);
159
+ if (input) {
160
+ input.addEventListener('click', function() { toggle(id); });
161
+ input.addEventListener('keydown', function(e) {
162
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
163
+ if (e.key === 'Escape') close(id);
164
+ });
165
+ }
166
+ }
167
+
168
+ document.addEventListener('click', function(e) {
169
+ Object.keys(pickers).forEach(function(pid) {
170
+ var el = root(pid);
171
+ if (el && !el.contains(e.target)) close(pid);
172
+ });
173
+ });
174
+
175
+ return {
176
+ register: register,
177
+ open: open,
178
+ close: close,
179
+ toggle: toggle,
180
+ clear: clear,
181
+ setValue: setValue,
182
+ getValue: getValue,
183
+ format: format
184
+ };
185
+ })();
186
+ </script>
187
+ """
188
+
189
+
190
+ # ==========================================================================
191
+ # Week numbers for ui:calendar (re-applied after every calendar re-render)
192
+ # ==========================================================================
193
+
194
+ CALENDAR_WEEKS_JS = """\
195
+ <script>
196
+ // Quantum UI - week number column for calendars with show-week-numbers
197
+ window.__quantumCalendarWeeks = (function() {
198
+ 'use strict';
199
+
200
+ function isoWeek(d) {
201
+ var t = new Date(d.getFullYear(), d.getMonth(), d.getDate());
202
+ t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7));
203
+ var firstThursday = new Date(t.getFullYear(), 0, 4);
204
+ firstThursday.setDate(firstThursday.getDate() + 3 - ((firstThursday.getDay() + 6) % 7));
205
+ return 1 + Math.round((t - firstThursday) / 604800000);
206
+ }
207
+
208
+ function apply(id) {
209
+ var state = window.__quantumCalendar && __quantumCalendar.get(id);
210
+ var cal = document.getElementById(id);
211
+ if (!state || !cal) return;
212
+ var grid = cal.querySelector('.q-calendar-grid');
213
+ if (!grid) return;
214
+ var days = grid.querySelectorAll('.q-calendar-day:not(.week-number)');
215
+ if (!days.length) return;
216
+
217
+ var first = new Date(state.viewYear, state.viewMonth, 1);
218
+ var offset = (first.getDay() - (state.firstDayOfWeek || 0) + 7) % 7;
219
+ for (var i = 0; i < days.length; i += 7) {
220
+ var rowDate = new Date(state.viewYear, state.viewMonth, 1 - offset + i);
221
+ var cell = document.createElement('div');
222
+ cell.className = 'q-calendar-day week-number';
223
+ cell.textContent = isoWeek(rowDate);
224
+ grid.insertBefore(cell, days[i]);
225
+ }
226
+ }
227
+
228
+ return { apply: apply };
229
+ })();
230
+ </script>
231
+ """
232
+
233
+
234
+ # ==========================================================================
235
+ # Visibility bindings for declarative toasts (data-q-show)
236
+ # ==========================================================================
237
+
238
+ SHOW_BINDING_JS = """\
239
+ <script>
240
+ // Quantum UI - toggles elements carrying data-q-show when their state changes
241
+ (function() {
242
+ 'use strict';
243
+
244
+ function apply(name, value) {
245
+ var visible = !(value === undefined || value === null || value === false ||
246
+ value === '' || value === 0 || value === 'false');
247
+ document.querySelectorAll('[data-q-show="' + name + '"]').forEach(function(el) {
248
+ el.style.display = visible ? '' : 'none';
249
+ });
250
+ }
251
+
252
+ window.__quantumShowBinding = apply;
253
+
254
+ document.addEventListener('DOMContentLoaded', function() {
255
+ var previous = window.__quantumStateUpdate;
256
+ window.__quantumStateUpdate = function(name, value) {
257
+ if (previous) previous(name, value);
258
+ apply(name, value);
259
+ };
260
+ var state = window.__quantumState || {};
261
+ Object.keys(state).forEach(function(key) { apply(key, state[key]); });
262
+ });
263
+ })();
264
+ </script>
265
+ """
266
+
267
+
268
+ def completed_step_indices(steps, current: int) -> List[int]:
269
+ """Quais passos de um ui:stepper contam como concluidos.
270
+
271
+ Uma unica autoridade, de proposito. Esta regra vivia so aqui, e a ponte
272
+ do adapter desktop tinha a SUA versao — `list(range(current)) if linear
273
+ else []`. As duas discordavam de dois jeitos:
274
+
275
+ - `linear` nao tem nada a ver com isso. Num stepper nao-linear a ponte
276
+ emitia lista vazia e APAGAVA, no boot, as marcas que este arquivo tinha
277
+ desenhado corretamente. O usuario via o progresso sumir.
278
+ - `completed` e tri-state: nao setado (None), "true" e "false". A ponte
279
+ colapsava None e "false" no mesmo valor, entao um passo marcado
280
+ explicitamente como `completed="false"` antes do atual ganhava a marca
281
+ de volta.
282
+
283
+ A regra: o atributo explicito vence; sem atributo, um passo esta
284
+ concluido se esta atras do atual.
285
+ """
286
+ indices: List[int] = []
287
+ for i, step in enumerate(steps):
288
+ explicito = getattr(step, 'completed', None)
289
+ if explicito is not None:
290
+ # Mesma lista de permissao de UIHtmlAdapter._as_bool. Uma lista de
291
+ # NEGACAO trataria `completed="talvez"` como concluido, o oposto
292
+ # do que este arquivo sempre fez.
293
+ if explicito is True or (
294
+ not isinstance(explicito, bool)
295
+ and str(explicito).strip().lower() in ('true', '1', 'yes', 'on')):
296
+ indices.append(i)
297
+ elif i < current:
298
+ indices.append(i)
299
+ return indices
300
+
301
+
302
+
303
+ def _is_checked(valor) -> bool:
304
+ """UI-6: checked="true" (or an expression that resolves to a true value)."""
305
+ return str(valor).strip().lower() in ('true', '1', 'on', 'yes', 'checked') if valor is not None else False
306
+
307
+
308
+ def _option_attrs(valor: str, escolhido) -> dict:
309
+ attrs = {'value': valor}
310
+ if escolhido is not None and valor == str(escolhido).strip():
311
+ attrs['selected'] = True
312
+ return attrs
313
+
314
+ class UIHtmlAdapter:
315
+ """Generates HTML/CSS from UI AST nodes."""
316
+
317
+ def __init__(self):
318
+ self._tab_counter = 0
319
+ self._has_tabs = False
320
+ self._has_validation = False # Track if we have forms with validation
321
+ self._has_animations = False # Track if we have animations
322
+ self._tokens = TokenConverter('html')
323
+ self._features_used: Set[str] = set() # Track features for compatibility
324
+
325
+
326
+ # Form validation tracking
327
+ self._form_counter = 0
328
+ self._form_validators: Dict[str, List[UIValidatorNode]] = {} # form_id -> validators
329
+
330
+ # Animation tracking
331
+ self._animate_counter = 0
332
+
333
+ # Toast / carousel / stepper / calendar tracking
334
+ self._has_toast = False
335
+ self._has_carousel = False
336
+ self._has_stepper = False
337
+ self._has_calendar = False
338
+ self._has_date_picker = False
339
+ self._has_calendar_weeks = False
340
+ self._has_show_bindings = False
341
+ self._toast_counter = 0
342
+ self._toast_container_counter = 0
343
+ self._carousel_counter = 0
344
+ self._stepper_counter = 0
345
+ self._calendar_counter = 0
346
+ self._date_picker_counter = 0
347
+
348
+ # Inline init statements executed on DOMContentLoaded
349
+ self._component_inits: List[str] = []
350
+ # Extra CSS rules emitted for specific component instances
351
+ self._extra_css: List[str] = []
352
+
353
+ # Theme configuration
354
+ self._theme: Optional[UIThemeNode] = None
355
+
356
+ def generate(self, windows: List[QuantumNode], ui_children: List[QuantumNode],
357
+ title: str = "Quantum UI",
358
+ theme: Optional[UIThemeNode] = None) -> str:
359
+ """Generate complete HTML page from UI AST.
360
+
361
+ Args:
362
+ windows: List of UIWindowNode instances
363
+ ui_children: List of top-level UI nodes
364
+ title: Page title
365
+ theme: Optional UIThemeNode for theme configuration
366
+ """
367
+ # Store theme for reference
368
+ self._theme = theme
369
+
370
+ body_builder = HtmlBuilder()
371
+
372
+ # Render windows
373
+ for window in windows:
374
+ self._render_node(window, body_builder)
375
+
376
+ # Render top-level children (outside windows)
377
+ for child in ui_children:
378
+ self._render_node(child, body_builder)
379
+
380
+ body_html = body_builder.build()
381
+
382
+ # Assemble CSS
383
+ css = CSS_RESET + '\n'
384
+
385
+ # Add theme CSS (replaces or augments CSS_THEME)
386
+ if HAS_THEMING and theme and get_theme_css:
387
+ preset = theme.preset or 'light'
388
+ overrides = theme.get_color_overrides() if hasattr(theme, 'get_color_overrides') else {}
389
+ auto_switch = getattr(theme, 'auto_switch', False)
390
+ theme_css = get_theme_css(preset, overrides, auto_switch)
391
+ css += theme_css + '\n'
392
+ else:
393
+ css += CSS_THEME + '\n'
394
+
395
+ css += RESPONSIVE_CSS + '\n'
396
+ if self._has_validation:
397
+ css += VALIDATION_CSS + '\n'
398
+ if self._has_animations:
399
+ css += CSS_ANIMATIONS + '\n'
400
+ if self._extra_css:
401
+ css += '\n'.join(self._extra_css) + '\n'
402
+
403
+ # Assemble JS
404
+ js = ''
405
+
406
+ # Add theme switching JS if we have a theme
407
+ if HAS_THEMING and theme and get_theme_switch_js:
408
+ js += get_theme_switch_js() + '\n'
409
+
410
+ if self._has_tabs:
411
+ js += TAB_JS + '\n'
412
+ if self._has_validation:
413
+ js += VALIDATION_JS + '\n'
414
+ # Add form-specific validators
415
+ js += self._generate_validators_script() + '\n'
416
+
417
+ # Add animation JS if needed
418
+ if self._has_animations:
419
+ js += ANIMATION_JS + '\n'
420
+
421
+ # Component runtimes (toast, carousel, stepper, calendar, date picker)
422
+ if self._has_toast:
423
+ js += self._emit_runtime(TOAST_JS) + '\n'
424
+ if self._has_carousel:
425
+ js += self._emit_runtime(CAROUSEL_JS) + '\n'
426
+ if self._has_stepper:
427
+ js += self._emit_runtime(STEPPER_JS) + '\n'
428
+ if self._has_calendar or self._has_date_picker:
429
+ js += self._emit_runtime(CALENDAR_JS) + '\n'
430
+ if self._has_calendar_weeks:
431
+ js += CALENDAR_WEEKS_JS + '\n'
432
+ if self._has_date_picker:
433
+ js += DATE_PICKER_JS + '\n'
434
+ if self._has_show_bindings:
435
+ js += SHOW_BINDING_JS + '\n'
436
+ if self._component_inits:
437
+ js += self._generate_component_init_script() + '\n'
438
+
439
+ return HTML_TEMPLATE.format(
440
+ title=title,
441
+ css=css,
442
+ body=body_html,
443
+ js=js,
444
+ )
445
+
446
+ def _generate_validators_script(self) -> str:
447
+ """Generate JS to register custom validators for each form."""
448
+ if not self._form_validators:
449
+ return ''
450
+
451
+ lines = ['<script>', 'document.addEventListener("DOMContentLoaded", function() {']
452
+
453
+ for form_id, validators in self._form_validators.items():
454
+ for validator in validators:
455
+ validator_obj = self._validator_to_js_object(validator)
456
+ lines.append(f" __qValidation.registerValidator('{form_id}', {validator_obj});")
457
+
458
+ lines.append('});')
459
+ lines.append('</script>')
460
+ return '\n'.join(lines)
461
+
462
+ def _emit_runtime(self, source: str) -> str:
463
+ """Emit a shared JS runtime from ui_html_templates.
464
+
465
+ Isto consertava o JS na SAIDA com um regex, porque TOAST_JS e
466
+ CALENDAR_JS viviam em strings triplas nao-raw: o Python comia as
467
+ barras que escapavam aspas dentro de literais JS aninhados, e a
468
+ pagina recebia `dismiss('' + id + '')` — erro de sintaxe que derrubava
469
+ o modulo inteiro no browser.
470
+
471
+ Consertar a saida deixava a causa de pe: o proximo runtime escrito do
472
+ mesmo jeito voltaria a quebrar, em silencio. As strings viraram raw na
473
+ fonte, entao aqui nao ha mais o que reparar.
474
+ """
475
+ return source
476
+
477
+ def _generate_component_init_script(self) -> str:
478
+ """Generate the DOMContentLoaded script that boots interactive components.
479
+
480
+ Every component gets its own listener: a failing user callback in one
481
+ component must not stop the remaining ones from being initialised.
482
+ """
483
+ if not self._component_inits:
484
+ return ''
485
+
486
+ lines = ['<script>']
487
+ for snippet in self._component_inits:
488
+ lines.append('document.addEventListener("DOMContentLoaded", function() {')
489
+ for raw in snippet.split('\n'):
490
+ lines.append((' ' + raw) if raw else '')
491
+ lines.append('});')
492
+ lines.append('</script>')
493
+ return '\n'.join(lines)
494
+
495
+ def _validator_to_js_object(self, validator: UIValidatorNode) -> str:
496
+ """Convert a UIValidatorNode to a JavaScript object literal."""
497
+ parts = []
498
+ parts.append(f"name: '{validator.name}'")
499
+
500
+ if validator.field:
501
+ parts.append(f"field: '{validator.field}'")
502
+ if validator.rule_type:
503
+ parts.append(f"type: '{validator.rule_type}'")
504
+ if validator.pattern:
505
+ # Escape special characters for JS string
506
+ escaped_pattern = validator.pattern.replace('\\', '\\\\').replace("'", "\\'")
507
+ parts.append(f"pattern: '{escaped_pattern}'")
508
+ if validator.match:
509
+ parts.append(f"match: '{validator.match}'")
510
+ if validator.min:
511
+ parts.append(f"min: '{validator.min}'")
512
+ if validator.max:
513
+ parts.append(f"max: '{validator.max}'")
514
+ if validator.minlength is not None:
515
+ parts.append(f"minlength: {validator.minlength}")
516
+ if validator.maxlength is not None:
517
+ parts.append(f"maxlength: {validator.maxlength}")
518
+ if validator.expression:
519
+ # Custom JS expression - wrap in function
520
+ parts.append(f"expression: function(value, form) {{ return {validator.expression}; }}")
521
+ if validator.message:
522
+ escaped_msg = validator.message.replace("'", "\\'")
523
+ parts.append(f"message: '{escaped_msg}'")
524
+ if validator.trigger:
525
+ parts.append(f"trigger: '{validator.trigger}'")
526
+
527
+ return '{' + ', '.join(parts) + '}'
528
+
529
+ # ------------------------------------------------------------------
530
+ # Layout style helper
531
+ # ------------------------------------------------------------------
532
+
533
+ def _layout_style(self, node) -> str:
534
+ """Build inline CSS from layout attributes."""
535
+ parts = []
536
+ if hasattr(node, 'gap') and node.gap:
537
+ self._features_used.add('gap')
538
+ parts.append(f"gap: {self._css_size(node.gap)}")
539
+ if hasattr(node, 'padding') and node.padding:
540
+ parts.append(f"padding: {self._css_size(node.padding)}")
541
+ if hasattr(node, 'margin') and node.margin:
542
+ parts.append(f"margin: {self._css_size(node.margin)}")
543
+ if hasattr(node, 'align') and node.align:
544
+ parts.append(f"align-items: {self._css_align(node.align)}")
545
+ if hasattr(node, 'justify') and node.justify:
546
+ if node.justify in ('between', 'around'):
547
+ self._features_used.add(f'justify_{node.justify}')
548
+ parts.append(f"justify-content: {self._css_justify(node.justify)}")
549
+ if hasattr(node, 'width') and node.width:
550
+ if node.width.isdigit() or node.width.endswith('px'):
551
+ self._features_used.add('pixel_units')
552
+ parts.append(f"width: {self._css_dimension(node.width)}")
553
+ if hasattr(node, 'height') and node.height:
554
+ if node.height.isdigit() or node.height.endswith('px'):
555
+ self._features_used.add('pixel_units')
556
+ parts.append(f"height: {self._css_dimension(node.height)}")
557
+ if hasattr(node, 'background') and node.background:
558
+ parts.append(f"background-color: {self._css_color(node.background)}")
559
+ if hasattr(node, 'color') and node.color:
560
+ parts.append(f"color: {self._css_color(node.color)}")
561
+ if hasattr(node, 'border') and node.border:
562
+ parts.append(f"border: {node.border}")
563
+ return '; '.join(parts)
564
+
565
+ def get_features_used(self) -> Set[str]:
566
+ """Return set of features used during generation (for compatibility checking)."""
567
+ return self._features_used.copy()
568
+
569
+ def _layout_attrs(self, node) -> dict:
570
+ """Build HTML attributes from layout properties."""
571
+ attrs = {}
572
+ style = self._layout_style(node)
573
+ if style:
574
+ attrs['style'] = style
575
+ if hasattr(node, 'ui_id') and node.ui_id:
576
+ attrs['id'] = node.ui_id
577
+ if hasattr(node, 'ui_class') and node.ui_class:
578
+ attrs['class'] = node.ui_class
579
+ # UI-2: responsive classes
580
+ responsivo = []
581
+ if getattr(node, 'stack_below', None):
582
+ responsivo.append(f'q-stack-{node.stack_below}')
583
+ if getattr(node, 'hide_below', None):
584
+ responsivo.append(f'q-hide-{node.hide_below}')
585
+ if getattr(node, 'hide_above', None):
586
+ responsivo.append(f'q-hide-above-{node.hide_above}')
587
+ if str(getattr(node, 'grow', '') or '').lower() == 'true':
588
+ responsivo.append('q-grow')
589
+ if responsivo:
590
+ attrs['class'] = ' '.join(filter(None, [attrs.get('class')] + responsivo))
591
+ if hasattr(node, 'visible') and node.visible == 'false':
592
+ existing = attrs.get('style', '')
593
+ attrs['style'] = (existing + '; ' if existing else '') + 'display: none'
594
+ return attrs
595
+
596
+ def _merge_attrs(self, base: dict, layout: dict) -> dict:
597
+ """Merge class and style attrs."""
598
+ result = dict(base)
599
+ for k, v in layout.items():
600
+ if k == 'class' and k in result:
601
+ result[k] = result[k] + ' ' + v
602
+ elif k == 'style' and k in result:
603
+ result[k] = result[k] + '; ' + v
604
+ elif k == 'id' and k not in result:
605
+ result[k] = v
606
+ else:
607
+ result[k] = v
608
+ return result
609
+
610
+ # ------------------------------------------------------------------
611
+ # CSS value helpers (using TokenConverter for normalization)
612
+ # ------------------------------------------------------------------
613
+
614
+ def _css_size(self, val: str) -> str:
615
+ """Convert spacing value using tokens system."""
616
+ return self._tokens.spacing(val)
617
+
618
+ def _css_dimension(self, val: str) -> str:
619
+ """Convert size/dimension value using tokens system."""
620
+ return self._tokens.size(val)
621
+
622
+ def _css_align(self, val: str) -> str:
623
+ """Convert align value using tokens system."""
624
+ return self._tokens.align(val)
625
+
626
+ def _css_justify(self, val: str) -> str:
627
+ """Convert justify value using tokens system."""
628
+ return self._tokens.justify(val)
629
+
630
+ def _css_color(self, val: str) -> str:
631
+ """Convert color value using tokens system."""
632
+ return self._tokens.color(val)
633
+
634
+ def _css_font_size(self, val: str) -> str:
635
+ """Convert font size using tokens system."""
636
+ return self._tokens.font_size(val)
637
+
638
+ # ------------------------------------------------------------------
639
+ # Attribute / JS literal helpers
640
+ # ------------------------------------------------------------------
641
+
642
+ def _as_int(self, value, default: int = 0) -> int:
643
+ """Coerce an attribute to int (parsers may hand over raw strings)."""
644
+ if value is None:
645
+ return default
646
+ if isinstance(value, bool):
647
+ return int(value)
648
+ try:
649
+ return int(str(value).strip())
650
+ except (TypeError, ValueError):
651
+ return default
652
+
653
+ def _as_bool(self, value, default: bool = False) -> bool:
654
+ """Coerce an attribute to bool (parsers may hand over raw strings)."""
655
+ if value is None:
656
+ return default
657
+ if isinstance(value, bool):
658
+ return value
659
+ return str(value).strip().lower() in ('true', '1', 'yes', 'on')
660
+
661
+ def _js_str(self, value) -> str:
662
+ """Quote a value as a single-quoted JS string literal."""
663
+ text = '' if value is None else str(value)
664
+ text = (text.replace('\\', '\\\\')
665
+ .replace("'", "\\'")
666
+ .replace('\r', '')
667
+ .replace('\n', '\\n'))
668
+ return f"'{text}'"
669
+
670
+ def _js_bool(self, value) -> str:
671
+ return 'true' if value else 'false'
672
+
673
+ def _js_statement(self, handler: str, arg: str) -> str:
674
+ """Turn an on-* handler attribute into a JS statement.
675
+
676
+ ``on-change="doThing()"`` is used verbatim, ``on-change="doThing"``
677
+ is called with the component value.
678
+ """
679
+ code = handler.strip()
680
+ if not code.endswith((')', '}', ';')):
681
+ code = f"{code}({arg})"
682
+ if not code.endswith(';'):
683
+ code += ';'
684
+ return code
685
+
686
+ def _js_change_handler(self, handler: Optional[str], bind: Optional[str],
687
+ arg: str = 'value') -> Optional[str]:
688
+ """Build the JS callback used by interactive components.
689
+
690
+ Writes the new value back to the bound state variable (when ``bind``
691
+ is set) and then invokes the author's handler.
692
+ """
693
+ statements: List[str] = []
694
+ if bind:
695
+ statements.append('window.__quantumState = window.__quantumState || {};')
696
+ statements.append(f"window.__quantumState[{self._js_str(bind)}] = {arg};")
697
+ statements.append(
698
+ f"if (window.__quantumShowBinding) window.__quantumShowBinding({self._js_str(bind)}, {arg});")
699
+ if handler:
700
+ statements.append(self._js_statement(handler, arg))
701
+ if not statements:
702
+ return None
703
+ return 'function(' + arg + ') { ' + ' '.join(statements) + ' }'
704
+
705
+ def _js_options(self, options: Dict[str, str]) -> str:
706
+ """Render an ordered dict of already-encoded JS values as an object literal."""
707
+ parts = [f"{k}: {v}" for k, v in options.items() if v is not None]
708
+ return '{' + ', '.join(parts) + '}'
709
+
710
+ def _parse_iso_date(self, value) -> Optional[_date]:
711
+ """Parse an ISO (YYYY-MM-DD) date, returning None when unusable."""
712
+ if not value:
713
+ return None
714
+ try:
715
+ return _date.fromisoformat(str(value).strip()[:10])
716
+ except (TypeError, ValueError):
717
+ return None
718
+
719
+ def _format_date(self, value: _date, pattern: str) -> str:
720
+ """Format a date with the picker's display pattern (mirrors DATE_PICKER_JS)."""
721
+ iso = value.isoformat()
722
+ year, month, day = iso.split('-')
723
+ return (pattern or 'YYYY-MM-DD') \
724
+ .replace('YYYY', year) \
725
+ .replace('YY', year[2:]) \
726
+ .replace('MM', month) \
727
+ .replace('DD', day)
728
+
729
+ # ------------------------------------------------------------------
730
+ # Node rendering dispatch
731
+ # ------------------------------------------------------------------
732
+
733
+ def _render_node(self, node: QuantumNode, b: HtmlBuilder):
734
+ """Dispatch node to its render method."""
735
+ # Containers
736
+ if isinstance(node, UIWindowNode):
737
+ self._render_window(node, b)
738
+ elif isinstance(node, UIHBoxNode):
739
+ self._render_hbox(node, b)
740
+ elif isinstance(node, UIVBoxNode):
741
+ self._render_vbox(node, b)
742
+ elif isinstance(node, UIPanelNode):
743
+ self._render_panel(node, b)
744
+ elif isinstance(node, UITabPanelNode):
745
+ self._render_tabpanel(node, b)
746
+ elif isinstance(node, UITabNode):
747
+ self._render_tab(node, b)
748
+ elif isinstance(node, UIGridNode):
749
+ self._render_grid(node, b)
750
+ elif isinstance(node, UIAccordionNode):
751
+ self._render_accordion(node, b)
752
+ elif isinstance(node, UISectionNode):
753
+ self._render_section(node, b)
754
+ elif isinstance(node, UIDividedBoxNode):
755
+ self._render_dividedbox(node, b)
756
+ elif isinstance(node, UIFormNode):
757
+ self._render_form(node, b)
758
+ elif isinstance(node, UIFormItemNode):
759
+ self._render_formitem(node, b)
760
+ elif isinstance(node, UISpacerNode):
761
+ self._render_spacer(node, b)
762
+ elif isinstance(node, UIScrollBoxNode):
763
+ self._render_scrollbox(node, b)
764
+ elif isinstance(node, UIAnimateNode):
765
+ self._render_animate(node, b)
766
+ # Widgets
767
+ elif isinstance(node, UITextNode):
768
+ self._render_text(node, b)
769
+ elif isinstance(node, UIButtonNode):
770
+ self._render_button(node, b)
771
+ elif isinstance(node, UIInputNode):
772
+ self._render_input(node, b)
773
+ elif isinstance(node, UICheckboxNode):
774
+ self._render_checkbox(node, b)
775
+ elif isinstance(node, UIRadioNode):
776
+ self._render_radio(node, b)
777
+ elif isinstance(node, UISwitchNode):
778
+ self._render_switch(node, b)
779
+ elif isinstance(node, UISelectNode):
780
+ self._render_select(node, b)
781
+ elif isinstance(node, UITableNode):
782
+ self._render_table(node, b)
783
+ elif isinstance(node, UIListNode):
784
+ self._render_list(node, b)
785
+ elif isinstance(node, UIItemNode):
786
+ self._render_item(node, b)
787
+ elif isinstance(node, UIImageNode):
788
+ self._render_image(node, b)
789
+ elif isinstance(node, UILinkNode):
790
+ self._render_link(node, b)
791
+ elif isinstance(node, UIProgressNode):
792
+ self._render_progress(node, b)
793
+ elif isinstance(node, UITreeNode):
794
+ self._render_tree(node, b)
795
+ elif isinstance(node, UIMenuNode):
796
+ self._render_menu(node, b)
797
+ elif isinstance(node, UIOptionNode):
798
+ self._render_option(node, b)
799
+ elif isinstance(node, UILogNode):
800
+ self._render_log(node, b)
801
+ elif isinstance(node, UIMarkdownNode):
802
+ self._render_markdown(node, b)
803
+ elif isinstance(node, UIHeaderNode):
804
+ self._render_header(node, b)
805
+ elif isinstance(node, UIFooterNode):
806
+ self._render_footer(node, b)
807
+ elif isinstance(node, UIRuleNode):
808
+ self._render_rule(node, b)
809
+ elif isinstance(node, UILoadingNode):
810
+ self._render_loading(node, b)
811
+ elif isinstance(node, UIBadgeNode):
812
+ self._render_badge(node, b)
813
+ # Component Library
814
+ elif isinstance(node, UICardNode):
815
+ self._render_card(node, b)
816
+ elif isinstance(node, UICardHeaderNode):
817
+ self._render_card_header(node, b)
818
+ elif isinstance(node, UICardBodyNode):
819
+ self._render_card_body(node, b)
820
+ elif isinstance(node, UICardFooterNode):
821
+ self._render_card_footer(node, b)
822
+ elif isinstance(node, UIModalNode):
823
+ self._render_modal(node, b)
824
+ elif isinstance(node, UIChartNode):
825
+ self._render_chart(node, b)
826
+ elif isinstance(node, UIAvatarNode):
827
+ self._render_avatar(node, b)
828
+ elif isinstance(node, UITooltipNode):
829
+ self._render_tooltip(node, b)
830
+ elif isinstance(node, UIDropdownNode):
831
+ self._render_dropdown(node, b)
832
+ elif isinstance(node, UIAlertNode):
833
+ self._render_alert(node, b)
834
+ elif isinstance(node, UIBreadcrumbNode):
835
+ self._render_breadcrumb(node, b)
836
+ elif isinstance(node, UIBreadcrumbItemNode):
837
+ self._render_breadcrumb_item(node, b)
838
+ elif isinstance(node, UIPaginationNode):
839
+ self._render_pagination(node, b)
840
+ elif isinstance(node, UISkeletonNode):
841
+ self._render_skeleton(node, b)
842
+ # Toast / carousel / stepper / calendar
843
+ elif isinstance(node, UIToastNode):
844
+ self._render_toast(node, b)
845
+ elif isinstance(node, UIToastContainerNode):
846
+ self._render_toast_container(node, b)
847
+ elif isinstance(node, UICarouselNode):
848
+ self._render_carousel(node, b)
849
+ elif isinstance(node, UISlideNode):
850
+ self._render_slide(node, b)
851
+ elif isinstance(node, UIStepperNode):
852
+ self._render_stepper(node, b)
853
+ elif isinstance(node, UIStepNode):
854
+ self._render_step(node, b)
855
+ elif isinstance(node, UICalendarNode):
856
+ self._render_calendar(node, b)
857
+ elif isinstance(node, UIDatePickerNode):
858
+ self._render_date_picker(node, b)
859
+ # Quantum nodes passthrough
860
+ elif isinstance(node, SetNode):
861
+ b.comment(f'q:set {node.name} = {node.value}')
862
+ elif isinstance(node, LoopNode):
863
+ b.comment(f'q:loop {node.var_name}')
864
+ elif isinstance(node, IfNode):
865
+ b.comment(f'q:if {node.condition}')
866
+ # UIColumnNode is handled inside table render
867
+
868
+ def _render_children(self, children: list, b: HtmlBuilder):
869
+ for child in children:
870
+ self._render_node(child, b)
871
+
872
+ # ------------------------------------------------------------------
873
+ # Container renders
874
+ # ------------------------------------------------------------------
875
+
876
+ def _render_window(self, node: UIWindowNode, b: HtmlBuilder):
877
+ attrs = self._merge_attrs({'class': 'q-window'}, self._layout_attrs(node))
878
+ b.open_tag('div', attrs)
879
+ b.indent()
880
+ self._render_children(node.children, b)
881
+ b.dedent()
882
+ b.close_tag('div')
883
+
884
+ def _render_hbox(self, node: UIHBoxNode, b: HtmlBuilder):
885
+ base_style = 'display: flex; flex-direction: row'
886
+ attrs = self._merge_attrs({'style': base_style}, self._layout_attrs(node))
887
+ b.open_tag('div', attrs)
888
+ b.indent()
889
+ self._render_children(node.children, b)
890
+ b.dedent()
891
+ b.close_tag('div')
892
+
893
+ def _render_vbox(self, node: UIVBoxNode, b: HtmlBuilder):
894
+ base_style = 'display: flex; flex-direction: column'
895
+ attrs = self._merge_attrs({'style': base_style}, self._layout_attrs(node))
896
+ b.open_tag('div', attrs)
897
+ b.indent()
898
+ self._render_children(node.children, b)
899
+ b.dedent()
900
+ b.close_tag('div')
901
+
902
+ def _render_panel(self, node: UIPanelNode, b: HtmlBuilder):
903
+ attrs = self._merge_attrs({'class': 'q-panel'}, self._layout_attrs(node))
904
+ b.open_tag('div', attrs)
905
+ b.indent()
906
+ if node.title:
907
+ b.open_tag('div', {'class': 'q-panel-title'})
908
+ b.indent()
909
+ b.text(node.title)
910
+ b.dedent()
911
+ b.close_tag('div')
912
+ self._render_children(node.children, b)
913
+ b.dedent()
914
+ b.close_tag('div')
915
+
916
+ def _render_tabpanel(self, node: UITabPanelNode, b: HtmlBuilder):
917
+ self._has_tabs = True
918
+ self._tab_counter += 1
919
+ group_id = f"tabs-{self._tab_counter}"
920
+
921
+ attrs = self._merge_attrs({'class': 'q-tabs'}, self._layout_attrs(node))
922
+ b.open_tag('div', attrs)
923
+ b.indent()
924
+
925
+ # Tab headers
926
+ b.open_tag('div', {'class': 'q-tab-headers'})
927
+ b.indent()
928
+ tabs = [c for c in node.children if isinstance(c, UITabNode)]
929
+ for i, tab in enumerate(tabs):
930
+ tab_id = f"{group_id}-tab-{i}"
931
+ cls = 'q-tab-header active' if i == 0 else 'q-tab-header'
932
+ b.open_tag('button', {'class': cls, 'data-tab': tab_id, 'data-tab-group': group_id})
933
+ b.indent()
934
+ b.text(tab.title)
935
+ b.dedent()
936
+ b.close_tag('button')
937
+ b.dedent()
938
+ b.close_tag('div')
939
+
940
+ # Tab contents
941
+ for i, tab in enumerate(tabs):
942
+ tab_id = f"{group_id}-tab-{i}"
943
+ cls = 'q-tab-content active' if i == 0 else 'q-tab-content'
944
+ b.open_tag('div', {'id': tab_id, 'class': cls, 'data-tab-group': group_id})
945
+ b.indent()
946
+ self._render_children(tab.children, b)
947
+ b.dedent()
948
+ b.close_tag('div')
949
+
950
+ b.dedent()
951
+ b.close_tag('div')
952
+
953
+ def _render_tab(self, node: UITabNode, b: HtmlBuilder):
954
+ # Tabs are rendered inside tabpanel, standalone tab renders as div
955
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
956
+ b.open_tag('div', attrs)
957
+ b.indent()
958
+ self._render_children(node.children, b)
959
+ b.dedent()
960
+ b.close_tag('div')
961
+
962
+ def _render_grid(self, node: UIGridNode, b: HtmlBuilder):
963
+ cols = (node.columns or '1').strip()
964
+ base = {'style': 'display: grid'}
965
+ if ':' in cols or ' ' in cols:
966
+ # UI-2: "1 sm:2 lg:3" — 1 column, 2 from sm, 3 from lg (widest first wins)
967
+ classes = []
968
+ for parte in cols.split():
969
+ faixa, _, n = parte.rpartition(':')
970
+ if not n.isdigit() or int(n) not in range(1, 7) or faixa not in ('', 'sm', 'md', 'lg'):
971
+ raise ValueError(f'<ui:grid columns="{cols}">: each part is N or sm:N / md:N / lg:N, N from 1 to 6')
972
+ classes.append(f'q-{faixa}-cols-{n}' if faixa else f'q-cols-{n}')
973
+ base['class'] = ' '.join(classes)
974
+ else:
975
+ try:
976
+ n = int(cols)
977
+ base['style'] = f"display: grid; grid-template-columns: repeat({n}, 1fr)"
978
+ except ValueError:
979
+ base['style'] = f"display: grid; grid-template-columns: {cols}"
980
+ attrs = self._merge_attrs(base, self._layout_attrs(node))
981
+ b.open_tag('div', attrs)
982
+ b.indent()
983
+ self._render_children(node.children, b)
984
+ b.dedent()
985
+ b.close_tag('div')
986
+
987
+ def _render_accordion(self, node: UIAccordionNode, b: HtmlBuilder):
988
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
989
+ b.open_tag('div', attrs)
990
+ b.indent()
991
+ self._render_children(node.children, b)
992
+ b.dedent()
993
+ b.close_tag('div')
994
+
995
+ def _render_section(self, node: UISectionNode, b: HtmlBuilder):
996
+ detail_attrs = {'class': 'q-section'}
997
+ if node.expanded:
998
+ detail_attrs['open'] = True
999
+ attrs = self._merge_attrs(detail_attrs, self._layout_attrs(node))
1000
+ b.open_tag('details', attrs)
1001
+ b.indent()
1002
+ b.open_tag('summary')
1003
+ b.indent()
1004
+ b.text(node.title)
1005
+ b.dedent()
1006
+ b.close_tag('summary')
1007
+ b.open_tag('div', {'class': 'q-section-content'})
1008
+ b.indent()
1009
+ self._render_children(node.children, b)
1010
+ b.dedent()
1011
+ b.close_tag('div')
1012
+ b.dedent()
1013
+ b.close_tag('details')
1014
+
1015
+ def _render_dividedbox(self, node: UIDividedBoxNode, b: HtmlBuilder):
1016
+ dir_class = 'q-dividedbox-h' if node.direction == 'horizontal' else 'q-dividedbox-v'
1017
+ attrs = self._merge_attrs({'class': f'q-dividedbox {dir_class}'}, self._layout_attrs(node))
1018
+ b.open_tag('div', attrs)
1019
+ b.indent()
1020
+ for i, child in enumerate(node.children):
1021
+ if i > 0:
1022
+ b.open_tag('div', {'class': 'q-divider'}, self_closing=False)
1023
+ b.close_tag('div')
1024
+ self._render_node(child, b)
1025
+ b.dedent()
1026
+ b.close_tag('div')
1027
+
1028
+ def _render_form(self, node: UIFormNode, b: HtmlBuilder):
1029
+ # Track form for validation
1030
+ self._form_counter += 1
1031
+ form_id = f"q-form-{self._form_counter}"
1032
+
1033
+ form_attrs = {'class': 'q-form', 'id': form_id}
1034
+
1035
+ # Validation mode attributes
1036
+ if node.validation_mode in ('client', 'both'):
1037
+ self._has_validation = True
1038
+ form_attrs['data-validation'] = node.validation_mode
1039
+ form_attrs['data-error-display'] = node.error_display
1040
+
1041
+ # HTML5 novalidate attribute (use our custom JS validation instead)
1042
+ if node.novalidate or node.validation_mode in ('client', 'both'):
1043
+ form_attrs['novalidate'] = True
1044
+
1045
+ if node.on_submit:
1046
+ # Add validation check before submit
1047
+ if node.validation_mode in ('client', 'both'):
1048
+ form_attrs['onsubmit'] = f"return __qValidation.validateForm('{form_id}') && ({node.on_submit})"
1049
+ else:
1050
+ form_attrs['onsubmit'] = node.on_submit
1051
+
1052
+ attrs = self._merge_attrs(form_attrs, self._layout_attrs(node))
1053
+ b.open_tag('form', attrs)
1054
+ b.indent()
1055
+
1056
+ # Register custom validators for this form
1057
+ if node.validators:
1058
+ self._form_validators[form_id] = node.validators
1059
+
1060
+ # Render error summary if configured
1061
+ if node.error_display in ('summary', 'both'):
1062
+ b.open_tag('div', {'class': 'q-validation-summary', 'id': f'{form_id}-summary', 'style': 'display:none'})
1063
+ b.close_tag('div')
1064
+
1065
+ self._render_children(node.children, b)
1066
+ b.dedent()
1067
+ b.close_tag('form')
1068
+
1069
+ def _render_formitem(self, node: UIFormItemNode, b: HtmlBuilder):
1070
+ attrs = self._merge_attrs({'class': 'q-formitem'}, self._layout_attrs(node))
1071
+ b.open_tag('div', attrs)
1072
+ b.indent()
1073
+ if node.label:
1074
+ b.open_tag('label', {'class': 'q-formitem-label'})
1075
+ b.indent()
1076
+ b.text(node.label)
1077
+ b.dedent()
1078
+ b.close_tag('label')
1079
+ self._render_children(node.children, b)
1080
+ b.dedent()
1081
+ b.close_tag('div')
1082
+
1083
+ def _render_spacer(self, node: UISpacerNode, b: HtmlBuilder):
1084
+ style = 'flex: 1'
1085
+ if node.size:
1086
+ style = f"flex: 0 0 {self._css_size(node.size)}"
1087
+ attrs = self._merge_attrs({'class': 'q-spacer', 'style': style}, self._layout_attrs(node))
1088
+ b.open_tag('div', attrs, self_closing=False)
1089
+ b.close_tag('div')
1090
+
1091
+ def _render_scrollbox(self, node: UIScrollBoxNode, b: HtmlBuilder):
1092
+ attrs = self._merge_attrs({'class': 'q-scrollbox'}, self._layout_attrs(node))
1093
+ b.open_tag('div', attrs)
1094
+ b.indent()
1095
+ self._render_children(node.children, b)
1096
+ b.dedent()
1097
+ b.close_tag('div')
1098
+
1099
+ # ------------------------------------------------------------------
1100
+ # Widget renders
1101
+ # ------------------------------------------------------------------
1102
+
1103
+ def _render_text(self, node: UITextNode, b: HtmlBuilder):
1104
+ style_parts = []
1105
+ if node.size:
1106
+ self._features_used.add('font_size')
1107
+ style_parts.append(f"font-size: {self._css_font_size(node.size)}")
1108
+ if node.weight:
1109
+ style_parts.append(f"font-weight: {node.weight}")
1110
+ base_attrs = {}
1111
+ if style_parts:
1112
+ base_attrs['style'] = '; '.join(style_parts)
1113
+ attrs = self._merge_attrs(base_attrs, self._layout_attrs(node))
1114
+ b.open_tag('span', attrs)
1115
+ b.indent()
1116
+ content = node.content
1117
+ b.text(content)
1118
+ b.dedent()
1119
+ b.close_tag('span')
1120
+
1121
+ def _render_button(self, node: UIButtonNode, b: HtmlBuilder):
1122
+ cls = 'q-btn'
1123
+ if node.variant:
1124
+ cls += f' q-btn-{node.variant}'
1125
+ btn_attrs = {'class': cls}
1126
+ if node.disabled:
1127
+ btn_attrs['disabled'] = True
1128
+ if node.on_click:
1129
+ btn_attrs['onclick'] = node.on_click
1130
+ attrs = self._merge_attrs(btn_attrs, self._layout_attrs(node))
1131
+ b.open_tag('button', attrs)
1132
+ b.indent()
1133
+ # Render content with binding support
1134
+ content = node.content
1135
+ b.text(content)
1136
+ b.dedent()
1137
+ b.close_tag('button')
1138
+
1139
+ def _render_input(self, node: UIInputNode, b: HtmlBuilder):
1140
+ input_attrs = {'class': 'q-input', 'type': node.input_type}
1141
+ if getattr(node, 'value', None) not in (None, ''):
1142
+ input_attrs['value'] = node.value
1143
+ if node.placeholder:
1144
+ input_attrs['placeholder'] = node.placeholder
1145
+ if node.bind:
1146
+ input_attrs['name'] = node.bind
1147
+
1148
+ # HTML5 validation attributes
1149
+ if node.required:
1150
+ input_attrs['required'] = True
1151
+ if node.min is not None:
1152
+ input_attrs['min'] = node.min
1153
+ if node.max is not None:
1154
+ input_attrs['max'] = node.max
1155
+ if node.minlength is not None:
1156
+ input_attrs['minlength'] = str(node.minlength)
1157
+ if node.maxlength is not None:
1158
+ input_attrs['maxlength'] = str(node.maxlength)
1159
+ if node.pattern:
1160
+ input_attrs['pattern'] = node.pattern
1161
+
1162
+ # Custom error message for JS validation
1163
+ if node.error_message:
1164
+ input_attrs['data-error-message'] = node.error_message
1165
+
1166
+ # Custom validators (comma-separated list)
1167
+ if node.validators:
1168
+ input_attrs['data-validators'] = ','.join(node.validators)
1169
+
1170
+ # Check if input has any validation
1171
+ has_validation = (node.error_message or node.required or node.validators or
1172
+ node.pattern or node.min or node.max or
1173
+ node.minlength or node.maxlength)
1174
+
1175
+ if has_validation:
1176
+ self._has_validation = True
1177
+ input_id = getattr(node, 'ui_id', None) or node.bind or f"input-{id(node)}"
1178
+ input_attrs['id'] = input_id
1179
+ attrs = self._merge_attrs(input_attrs, self._layout_attrs(node))
1180
+
1181
+ # Create wrapper for input + error message
1182
+ b.open_tag('div', {'class': 'q-input-wrapper'})
1183
+ b.indent()
1184
+ b.open_tag('input', attrs, self_closing=True)
1185
+ b.open_tag('div', {'class': 'q-error-message', 'id': f'{input_id}-error', 'style': 'display:none'})
1186
+ if node.error_message:
1187
+ b.text(node.error_message)
1188
+ b.close_tag('div')
1189
+ b.dedent()
1190
+ b.close_tag('div')
1191
+ else:
1192
+ attrs = self._merge_attrs(input_attrs, self._layout_attrs(node))
1193
+ b.open_tag('input', attrs, self_closing=True)
1194
+
1195
+ def _render_checkbox(self, node: UICheckboxNode, b: HtmlBuilder):
1196
+ attrs = self._merge_attrs({'class': 'q-checkbox'}, self._layout_attrs(node))
1197
+ b.open_tag('label', attrs)
1198
+ b.indent()
1199
+ cb_attrs = {'type': 'checkbox'}
1200
+ if node.bind:
1201
+ cb_attrs['name'] = node.bind
1202
+ if _is_checked(getattr(node, 'checked', None)):
1203
+ cb_attrs['checked'] = True
1204
+ b.open_tag('input', cb_attrs, self_closing=True)
1205
+ if node.label:
1206
+ b.text(node.label)
1207
+ b.dedent()
1208
+ b.close_tag('label')
1209
+
1210
+ def _render_radio(self, node: UIRadioNode, b: HtmlBuilder):
1211
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
1212
+ b.open_tag('div', attrs)
1213
+ b.indent()
1214
+ if node.options:
1215
+ for opt in node.options.split(','):
1216
+ opt = opt.strip()
1217
+ b.open_tag('label', {'class': 'q-checkbox'})
1218
+ b.indent()
1219
+ radio_attrs = {'type': 'radio', 'value': opt}
1220
+ if node.bind:
1221
+ radio_attrs['name'] = node.bind
1222
+ if getattr(node, 'value', None) is not None and opt == str(node.value).strip():
1223
+ radio_attrs['checked'] = True
1224
+ b.open_tag('input', radio_attrs, self_closing=True)
1225
+ b.text(opt)
1226
+ b.dedent()
1227
+ b.close_tag('label')
1228
+ b.dedent()
1229
+ b.close_tag('div')
1230
+
1231
+ def _render_switch(self, node: UISwitchNode, b: HtmlBuilder):
1232
+ attrs = self._merge_attrs({'class': 'q-switch'}, self._layout_attrs(node))
1233
+ b.open_tag('label', attrs)
1234
+ b.indent()
1235
+ cb_attrs = {'type': 'checkbox'}
1236
+ if node.bind:
1237
+ cb_attrs['name'] = node.bind
1238
+ if _is_checked(getattr(node, 'checked', None)):
1239
+ cb_attrs['checked'] = True
1240
+ b.open_tag('input', cb_attrs, self_closing=True)
1241
+ b.open_tag('span', {'class': 'q-switch-track'})
1242
+ b.indent()
1243
+ b.open_tag('span', {'class': 'q-switch-thumb'}, self_closing=False)
1244
+ b.close_tag('span')
1245
+ b.dedent()
1246
+ b.close_tag('span')
1247
+ if node.label:
1248
+ b.text(node.label)
1249
+ b.dedent()
1250
+ b.close_tag('label')
1251
+
1252
+ def _render_select(self, node: UISelectNode, b: HtmlBuilder):
1253
+ select_attrs = {'class': 'q-select'}
1254
+ if node.bind:
1255
+ select_attrs['name'] = node.bind
1256
+ attrs = self._merge_attrs(select_attrs, self._layout_attrs(node))
1257
+ b.open_tag('select', attrs)
1258
+ b.indent()
1259
+ # Inline options
1260
+ if node.options:
1261
+ for opt in node.options.split(','):
1262
+ opt = opt.strip()
1263
+ b.open_tag('option', _option_attrs(opt, getattr(node, 'value', None)))
1264
+ b.indent()
1265
+ b.text(opt)
1266
+ b.dedent()
1267
+ b.close_tag('option')
1268
+ # Child option nodes
1269
+ for child in getattr(node, 'children', []):
1270
+ if isinstance(child, UIOptionNode):
1271
+ b.open_tag('option', _option_attrs(child.value or '', getattr(node, 'value', None)))
1272
+ b.indent()
1273
+ b.text(child.label or child.value or '')
1274
+ b.dedent()
1275
+ b.close_tag('option')
1276
+ b.dedent()
1277
+ b.close_tag('select')
1278
+
1279
+ def _render_table(self, node: UITableNode, b: HtmlBuilder):
1280
+ attrs = self._merge_attrs({'class': 'q-table'}, self._layout_attrs(node))
1281
+ b.open_tag('table', attrs)
1282
+ b.indent()
1283
+ columns = [c for c in node.children if isinstance(c, UIColumnNode)]
1284
+ if columns:
1285
+ b.open_tag('thead')
1286
+ b.indent()
1287
+ b.open_tag('tr')
1288
+ b.indent()
1289
+ for col in columns:
1290
+ th_attrs = {}
1291
+ if col.align:
1292
+ th_attrs['style'] = f"text-align: {col.align}"
1293
+ if col.column_width:
1294
+ existing = th_attrs.get('style', '')
1295
+ th_attrs['style'] = (existing + '; ' if existing else '') + f"width: {self._css_dimension(col.column_width)}"
1296
+ b.open_tag('th', th_attrs if th_attrs else None)
1297
+ b.indent()
1298
+ b.text(col.label or col.key or '')
1299
+ b.dedent()
1300
+ b.close_tag('th')
1301
+ b.dedent()
1302
+ b.close_tag('tr')
1303
+ b.dedent()
1304
+ b.close_tag('thead')
1305
+ b.open_tag('tbody')
1306
+ b.indent()
1307
+ if node.source:
1308
+ b.comment(f'Data source: {node.source}')
1309
+ b.dedent()
1310
+ b.close_tag('tbody')
1311
+ b.dedent()
1312
+ b.close_tag('table')
1313
+
1314
+ def _render_list(self, node: UIListNode, b: HtmlBuilder):
1315
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
1316
+ b.open_tag('ul', attrs)
1317
+ b.indent()
1318
+ if node.source:
1319
+ b.comment(f'Data source: {node.source}')
1320
+ self._render_children(node.children, b)
1321
+ b.dedent()
1322
+ b.close_tag('ul')
1323
+
1324
+ def _render_item(self, node: UIItemNode, b: HtmlBuilder):
1325
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
1326
+ b.open_tag('li', attrs)
1327
+ b.indent()
1328
+ self._render_children(node.children, b)
1329
+ b.dedent()
1330
+ b.close_tag('li')
1331
+
1332
+ def _render_image(self, node: UIImageNode, b: HtmlBuilder):
1333
+ self._features_used.add('image')
1334
+ img_attrs = {}
1335
+ if node.src:
1336
+ img_attrs['src'] = node.src
1337
+ if node.alt:
1338
+ img_attrs['alt'] = node.alt
1339
+ attrs = self._merge_attrs(img_attrs, self._layout_attrs(node))
1340
+ b.open_tag('img', attrs, self_closing=True)
1341
+
1342
+ def _render_link(self, node: UILinkNode, b: HtmlBuilder):
1343
+ if node.external:
1344
+ self._features_used.add('link_external')
1345
+ a_attrs = {}
1346
+ if node.to:
1347
+ a_attrs['href'] = node.to
1348
+ if node.external:
1349
+ a_attrs['target'] = '_blank'
1350
+ a_attrs['rel'] = 'noopener noreferrer'
1351
+ attrs = self._merge_attrs(a_attrs, self._layout_attrs(node))
1352
+ b.open_tag('a', attrs)
1353
+ b.indent()
1354
+ b.text(node.content)
1355
+ b.dedent()
1356
+ b.close_tag('a')
1357
+
1358
+ def _render_progress(self, node: UIProgressNode, b: HtmlBuilder):
1359
+ prog_attrs = {}
1360
+ if node.value:
1361
+ prog_attrs['value'] = node.value
1362
+ if node.max:
1363
+ prog_attrs['max'] = node.max
1364
+ attrs = self._merge_attrs(prog_attrs, self._layout_attrs(node))
1365
+ b.open_tag('progress', attrs)
1366
+ b.close_tag('progress')
1367
+
1368
+ def _render_tree(self, node: UITreeNode, b: HtmlBuilder):
1369
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
1370
+ b.open_tag('div', attrs)
1371
+ b.indent()
1372
+ if node.source:
1373
+ b.comment(f'Tree source: {node.source}')
1374
+ b.dedent()
1375
+ b.close_tag('div')
1376
+
1377
+ def _render_menu(self, node: UIMenuNode, b: HtmlBuilder):
1378
+ attrs = self._merge_attrs({'class': 'q-menu'}, self._layout_attrs(node))
1379
+ b.open_tag('nav', attrs)
1380
+ b.indent()
1381
+ self._render_children(node.children, b)
1382
+ b.dedent()
1383
+ b.close_tag('nav')
1384
+
1385
+ def _render_option(self, node: UIOptionNode, b: HtmlBuilder):
1386
+ opt_attrs = {'class': 'q-menu-item'}
1387
+ if node.on_click:
1388
+ opt_attrs['onclick'] = node.on_click
1389
+ b.open_tag('span', opt_attrs)
1390
+ b.indent()
1391
+ b.text(node.label or node.value or '')
1392
+ b.dedent()
1393
+ b.close_tag('span')
1394
+
1395
+ def _render_log(self, node: UILogNode, b: HtmlBuilder):
1396
+ log_attrs = {'class': 'q-log'}
1397
+ attrs = self._merge_attrs(log_attrs, self._layout_attrs(node))
1398
+ b.open_tag('div', attrs)
1399
+ b.close_tag('div')
1400
+
1401
+ def _render_markdown(self, node: UIMarkdownNode, b: HtmlBuilder):
1402
+ attrs = self._merge_attrs({}, self._layout_attrs(node))
1403
+ b.open_tag('div', attrs)
1404
+ b.indent()
1405
+ b.text(node.content)
1406
+ b.dedent()
1407
+ b.close_tag('div')
1408
+
1409
+ def _render_header(self, node: UIHeaderNode, b: HtmlBuilder):
1410
+ attrs = self._merge_attrs({'class': 'q-header'}, self._layout_attrs(node))
1411
+ b.open_tag('header', attrs)
1412
+ b.indent()
1413
+ if node.title:
1414
+ b.text(node.title)
1415
+ elif node.content:
1416
+ b.text(node.content)
1417
+ self._render_children(node.children, b)
1418
+ b.dedent()
1419
+ b.close_tag('header')
1420
+
1421
+ def _render_footer(self, node: UIFooterNode, b: HtmlBuilder):
1422
+ attrs = self._merge_attrs({'class': 'q-footer'}, self._layout_attrs(node))
1423
+ b.open_tag('footer', attrs)
1424
+ b.indent()
1425
+ if node.content:
1426
+ b.text(node.content)
1427
+ self._render_children(node.children, b)
1428
+ b.dedent()
1429
+ b.close_tag('footer')
1430
+
1431
+ def _render_rule(self, node: UIRuleNode, b: HtmlBuilder):
1432
+ b.open_tag('hr', None, self_closing=True)
1433
+
1434
+ def _render_loading(self, node: UILoadingNode, b: HtmlBuilder):
1435
+ attrs = self._merge_attrs({'class': 'q-loading'}, self._layout_attrs(node))
1436
+ b.open_tag('div', attrs)
1437
+ b.indent()
1438
+ b.open_tag('div', {'class': 'q-spinner'}, self_closing=False)
1439
+ b.close_tag('div')
1440
+ if node.text:
1441
+ b.text(node.text)
1442
+ b.dedent()
1443
+ b.close_tag('div')
1444
+
1445
+ def _render_badge(self, node: UIBadgeNode, b: HtmlBuilder):
1446
+ cls = 'q-badge'
1447
+ if node.variant:
1448
+ cls += f' q-badge-{node.variant}'
1449
+ badge_attrs = {'class': cls}
1450
+ if node.badge_color:
1451
+ badge_attrs['style'] = f"background-color: {self._css_color(node.badge_color)}"
1452
+ attrs = self._merge_attrs(badge_attrs, self._layout_attrs(node))
1453
+ b.open_tag('span', attrs)
1454
+ b.indent()
1455
+ b.text(node.content)
1456
+ b.dedent()
1457
+ b.close_tag('span')
1458
+
1459
+ # ------------------------------------------------------------------
1460
+ # Animation render
1461
+ # ------------------------------------------------------------------
1462
+
1463
+ def _render_animate(self, node: UIAnimateNode, b: HtmlBuilder):
1464
+ """Render a ui:animate wrapper with animation effects.
1465
+
1466
+ The ui:animate node wraps child elements and applies CSS animations
1467
+ based on the animation type, trigger, duration, and other properties.
1468
+
1469
+ Attributes supported:
1470
+ - type/anim_type: fade, slide, scale, rotate, bounce, pulse, shake, etc.
1471
+ - animate: Animation preset name (alternative to type)
1472
+ - duration: Animation duration (e.g., '300', '300ms', '0.3s')
1473
+ - delay: Animation delay
1474
+ - easing: Easing function (ease, ease-in, ease-out, linear, spring, bounce)
1475
+ - repeat: Repeat count (1, 2, 'infinite')
1476
+ - trigger: Animation trigger (on-load, on-hover, on-click, on-visible)
1477
+ - direction: Animation direction (normal, reverse, alternate)
1478
+ """
1479
+ self._has_animations = True
1480
+ self._animate_counter += 1
1481
+
1482
+ # Build CSS classes
1483
+ classes = ['q-animate']
1484
+
1485
+ # Animation type/preset
1486
+ anim_type = node.anim_type or node.animate
1487
+ if anim_type:
1488
+ # Normalize animation type name
1489
+ anim_class = f"q-anim-{anim_type.replace('_', '-')}"
1490
+ classes.append(anim_class)
1491
+
1492
+ # Trigger class
1493
+ trigger = node.anim_trigger or 'on-load'
1494
+ if trigger != 'on-load':
1495
+ classes.append(f"q-trigger-{trigger}")
1496
+
1497
+ # Easing class
1498
+ if node.anim_easing:
1499
+ easing = node.anim_easing.replace('_', '-')
1500
+ classes.append(f"q-easing-{easing}")
1501
+
1502
+ # Build inline styles for animation variables
1503
+ style_parts = []
1504
+
1505
+ # Duration
1506
+ if node.anim_duration:
1507
+ duration = node.anim_duration
1508
+ # Add 'ms' if no unit specified
1509
+ if duration.isdigit():
1510
+ duration = f"{duration}ms"
1511
+ style_parts.append(f"--q-anim-duration: {duration}")
1512
+
1513
+ # Delay
1514
+ if node.anim_delay:
1515
+ delay = node.anim_delay
1516
+ if delay.isdigit():
1517
+ delay = f"{delay}ms"
1518
+ style_parts.append(f"--q-anim-delay: {delay}")
1519
+
1520
+ # Repeat
1521
+ if node.anim_repeat:
1522
+ style_parts.append(f"--q-anim-repeat: {node.anim_repeat}")
1523
+
1524
+ # Direction
1525
+ if node.anim_direction:
1526
+ style_parts.append(f"--q-anim-direction: {node.anim_direction}")
1527
+
1528
+ # Build attributes
1529
+ anim_attrs = {'class': ' '.join(classes)}
1530
+
1531
+ if style_parts:
1532
+ anim_attrs['style'] = '; '.join(style_parts)
1533
+
1534
+ # Add data attribute for once-only visibility trigger
1535
+ if trigger == 'on-visible':
1536
+ anim_attrs['data-anim-once'] = 'true'
1537
+
1538
+ # Generate unique ID if needed for JS targeting
1539
+ anim_id = f"q-anim-{self._animate_counter}"
1540
+ anim_attrs['id'] = anim_id
1541
+
1542
+ # Merge with layout attributes
1543
+ attrs = self._merge_attrs(anim_attrs, self._layout_attrs(node))
1544
+
1545
+ # Render the wrapper div
1546
+ b.open_tag('div', attrs)
1547
+ b.indent()
1548
+
1549
+ # Render children
1550
+ self._render_children(node.children, b)
1551
+
1552
+ b.dedent()
1553
+ b.close_tag('div')
1554
+
1555
+ def _animation_attrs(self, node) -> dict:
1556
+ """Build animation-related HTML attributes from animation properties.
1557
+
1558
+ This method can be used to add animation attributes to any UI node
1559
+ that supports inline animations via the 'animate' or 'transition' attributes.
1560
+ """
1561
+ attrs = {}
1562
+
1563
+ # Check if node has animation mixin
1564
+ if not hasattr(node, 'animate'):
1565
+ return attrs
1566
+
1567
+ # Handle inline animate attribute
1568
+ if node.animate:
1569
+ self._has_animations = True
1570
+ classes = ['q-animate', f"q-anim-{node.animate.replace('_', '-')}"]
1571
+ attrs['class'] = ' '.join(classes)
1572
+
1573
+ # Handle inline transition attribute (e.g., "scale:0.95:100ms")
1574
+ if hasattr(node, 'transition') and node.transition:
1575
+ self._has_animations = True
1576
+ attrs['data-transition'] = node.transition
1577
+
1578
+ return attrs
1579
+
1580
+ # ------------------------------------------------------------------
1581
+ # Component Library renders
1582
+ # ------------------------------------------------------------------
1583
+
1584
+ def _render_card(self, node: UICardNode, b: HtmlBuilder):
1585
+ cls = 'q-card'
1586
+ if node.variant:
1587
+ cls += f' q-card-{node.variant}'
1588
+ attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
1589
+ b.open_tag('div', attrs)
1590
+ b.indent()
1591
+ if node.image:
1592
+ b.open_tag('div', {'class': 'q-card-image'})
1593
+ b.open_tag('img', {'src': node.image, 'alt': node.title or ''}, self_closing=True)
1594
+ b.close_tag('div')
1595
+ # UI-7: the title shows even next to a ui:card-header (it used to vanish).
1596
+ if node.title:
1597
+ b.open_tag('div', {'class': 'q-card-header'})
1598
+ b.open_tag('div', {'class': 'q-card-title'})
1599
+ b.text(node.title)
1600
+ b.close_tag('div')
1601
+ if node.subtitle:
1602
+ b.open_tag('div', {'class': 'q-card-subtitle'})
1603
+ b.text(node.subtitle)
1604
+ b.close_tag('div')
1605
+ b.close_tag('div')
1606
+ self._render_children(node.children, b)
1607
+ b.dedent()
1608
+ b.close_tag('div')
1609
+
1610
+ def _render_card_header(self, node: UICardHeaderNode, b: HtmlBuilder):
1611
+ attrs = self._merge_attrs({'class': 'q-card-header'}, self._layout_attrs(node))
1612
+ b.open_tag('div', attrs)
1613
+ b.indent()
1614
+ self._render_children(node.children, b)
1615
+ b.dedent()
1616
+ b.close_tag('div')
1617
+
1618
+ def _render_card_body(self, node: UICardBodyNode, b: HtmlBuilder):
1619
+ attrs = self._merge_attrs({'class': 'q-card-body'}, self._layout_attrs(node))
1620
+ b.open_tag('div', attrs)
1621
+ b.indent()
1622
+ self._render_children(node.children, b)
1623
+ b.dedent()
1624
+ b.close_tag('div')
1625
+
1626
+ def _render_card_footer(self, node: UICardFooterNode, b: HtmlBuilder):
1627
+ attrs = self._merge_attrs({'class': 'q-card-footer'}, self._layout_attrs(node))
1628
+ b.open_tag('div', attrs)
1629
+ b.indent()
1630
+ self._render_children(node.children, b)
1631
+ b.dedent()
1632
+ b.close_tag('div')
1633
+
1634
+ def _render_modal(self, node: UIModalNode, b: HtmlBuilder):
1635
+ self._features_used.add('modal')
1636
+ modal_id = node.modal_id or f'modal-{id(node)}'
1637
+ overlay_class = 'q-modal-overlay' + (' q-modal-open' if node.open else '')
1638
+ b.open_tag('div', {'class': overlay_class, 'id': modal_id})
1639
+ b.indent()
1640
+ dialog_class = 'q-modal' + (f' q-modal-{node.size}' if node.size else '')
1641
+ attrs = self._merge_attrs({'class': dialog_class, 'role': 'dialog'}, self._layout_attrs(node))
1642
+ b.open_tag('div', attrs)
1643
+ b.indent()
1644
+ if node.title or node.closable:
1645
+ b.open_tag('div', {'class': 'q-modal-header'})
1646
+ if node.title:
1647
+ b.open_tag('h3', {'class': 'q-modal-title'})
1648
+ b.text(node.title)
1649
+ b.close_tag('h3')
1650
+ if node.closable:
1651
+ b.open_tag('button', {'class': 'q-modal-close', 'onclick': f"document.getElementById('{modal_id}').classList.remove('q-modal-open')"})
1652
+ b.text('x')
1653
+ b.close_tag('button')
1654
+ b.close_tag('div')
1655
+ b.open_tag('div', {'class': 'q-modal-body'})
1656
+ self._render_children(node.children, b)
1657
+ b.close_tag('div')
1658
+ b.dedent()
1659
+ b.close_tag('div')
1660
+ b.dedent()
1661
+ b.close_tag('div')
1662
+
1663
+ def _render_chart(self, node: UIChartNode, b: HtmlBuilder):
1664
+ self._features_used.add('chart')
1665
+ attrs = self._merge_attrs({'class': f'q-chart q-chart-{node.chart_type}'}, self._layout_attrs(node))
1666
+ b.open_tag('div', attrs)
1667
+ b.indent()
1668
+ if node.title:
1669
+ b.open_tag('div', {'class': 'q-chart-title'})
1670
+ b.text(node.title)
1671
+ b.close_tag('div')
1672
+ b.open_tag('div', {'class': 'q-chart-container'})
1673
+ if node.labels and node.values:
1674
+ labels = [l.strip() for l in node.labels.split(',')]
1675
+ values = [v.strip() for v in node.values.split(',')]
1676
+ colors = [c.strip() for c in node.colors.split(',')] if node.colors else []
1677
+ default_colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6']
1678
+ max_val = max(float(v) for v in values) if values else 100
1679
+ for i, (label, value) in enumerate(zip(labels, values)):
1680
+ color = colors[i] if i < len(colors) else default_colors[i % len(default_colors)]
1681
+ percent = (float(value) / max_val) * 100 if max_val > 0 else 0
1682
+ if node.chart_type == 'bar':
1683
+ b.open_tag('div', {'class': 'q-chart-bar-group'})
1684
+ b.open_tag('div', {'class': 'q-chart-label'})
1685
+ b.text(label)
1686
+ b.close_tag('div')
1687
+ b.open_tag('div', {'class': 'q-chart-bar', 'style': f'width:{percent}%;background:{color}'})
1688
+ b.open_tag('span', {'class': 'q-chart-value'})
1689
+ b.text(value)
1690
+ b.close_tag('span')
1691
+ b.close_tag('div')
1692
+ b.close_tag('div')
1693
+ b.close_tag('div')
1694
+ b.dedent()
1695
+ b.close_tag('div')
1696
+
1697
+ def _render_avatar(self, node: UIAvatarNode, b: HtmlBuilder):
1698
+ cls = 'q-avatar' + (f' q-avatar-{node.size}' if node.size else '')
1699
+ if node.shape == 'square':
1700
+ cls += ' q-avatar-square'
1701
+ attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
1702
+ b.open_tag('div', attrs)
1703
+ if node.src:
1704
+ b.open_tag('img', {'src': node.src, 'alt': node.name or ''}, self_closing=True)
1705
+ elif node.name:
1706
+ initials = ''.join(p[0].upper() for p in node.name.split()[:2])
1707
+ b.open_tag('span', {'class': 'q-avatar-initials'})
1708
+ b.text(initials or '?')
1709
+ b.close_tag('span')
1710
+ if node.status:
1711
+ b.open_tag('span', {'class': f'q-avatar-status q-avatar-status-{node.status}'})
1712
+ b.close_tag('span')
1713
+ b.close_tag('div')
1714
+
1715
+ def _render_tooltip(self, node: UITooltipNode, b: HtmlBuilder):
1716
+ self._features_used.add('tooltip')
1717
+ attrs = self._merge_attrs({'class': f'q-tooltip-wrapper q-tooltip-{node.position}'}, self._layout_attrs(node))
1718
+ b.open_tag('div', attrs)
1719
+ self._render_children(node.children, b)
1720
+ b.open_tag('span', {'class': 'q-tooltip-text', 'role': 'tooltip'})
1721
+ b.text(node.content)
1722
+ b.close_tag('span')
1723
+ b.close_tag('div')
1724
+
1725
+ def _render_dropdown(self, node: UIDropdownNode, b: HtmlBuilder):
1726
+ self._features_used.add('dropdown')
1727
+ cls = f'q-dropdown q-dropdown-{node.dropdown_align}'
1728
+ if node.trigger == 'hover':
1729
+ cls += ' q-dropdown-hover'
1730
+ attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
1731
+ b.open_tag('div', attrs)
1732
+ b.open_tag('button', {'class': 'q-dropdown-trigger'})
1733
+ b.text(node.label or 'Menu')
1734
+ b.open_tag('span', {'class': 'q-dropdown-arrow'})
1735
+ b.text(' v')
1736
+ b.close_tag('span')
1737
+ b.close_tag('button')
1738
+ b.open_tag('div', {'class': 'q-dropdown-menu'})
1739
+ self._render_children(node.children, b)
1740
+ b.close_tag('div')
1741
+ b.close_tag('div')
1742
+
1743
+ def _render_alert(self, node: UIAlertNode, b: HtmlBuilder):
1744
+ cls = f'q-alert q-alert-{node.variant}'
1745
+ if node.dismissible:
1746
+ cls += ' q-alert-dismissible'
1747
+ alert_id = f'alert-{id(node)}'
1748
+ attrs = self._merge_attrs({'class': cls, 'role': 'alert', 'id': alert_id}, self._layout_attrs(node))
1749
+ b.open_tag('div', attrs)
1750
+ if node.icon:
1751
+ b.open_tag('span', {'class': 'q-alert-icon'})
1752
+ b.text(node.icon)
1753
+ b.close_tag('span')
1754
+ b.open_tag('div', {'class': 'q-alert-content'})
1755
+ if node.title:
1756
+ b.open_tag('div', {'class': 'q-alert-title'})
1757
+ b.text(node.title)
1758
+ b.close_tag('div')
1759
+ if node.content:
1760
+ b.open_tag('div', {'class': 'q-alert-message'})
1761
+ b.text(node.content)
1762
+ b.close_tag('div')
1763
+ self._render_children(node.children, b)
1764
+ b.close_tag('div')
1765
+ if node.dismissible:
1766
+ b.open_tag('button', {'class': 'q-alert-dismiss', 'onclick': f"document.getElementById('{alert_id}').style.display='none'"})
1767
+ b.text('x')
1768
+ b.close_tag('button')
1769
+ b.close_tag('div')
1770
+
1771
+ def _render_breadcrumb(self, node: UIBreadcrumbNode, b: HtmlBuilder):
1772
+ attrs = self._merge_attrs({'class': 'q-breadcrumb'}, self._layout_attrs(node))
1773
+ b.open_tag('nav', attrs)
1774
+ b.open_tag('ol', {'class': 'q-breadcrumb-list'})
1775
+ for i, child in enumerate(node.children):
1776
+ if isinstance(child, UIBreadcrumbItemNode):
1777
+ is_last = i == len(node.children) - 1
1778
+ self._render_breadcrumb_item_internal(child, b, node.separator, is_last)
1779
+ b.close_tag('ol')
1780
+ b.close_tag('nav')
1781
+
1782
+ def _render_breadcrumb_item(self, node: UIBreadcrumbItemNode, b: HtmlBuilder):
1783
+ self._render_breadcrumb_item_internal(node, b, '/', False)
1784
+
1785
+ def _render_breadcrumb_item_internal(self, node: UIBreadcrumbItemNode, b: HtmlBuilder, sep: str, is_last: bool):
1786
+ cls = 'q-breadcrumb-item' + (' q-breadcrumb-current' if is_last else '')
1787
+ b.open_tag('li', {'class': cls})
1788
+ if node.to and not is_last:
1789
+ b.open_tag('a', {'href': node.to, 'class': 'q-breadcrumb-link'})
1790
+ b.text(node.label)
1791
+ b.close_tag('a')
1792
+ else:
1793
+ b.open_tag('span', {'class': 'q-breadcrumb-text'})
1794
+ b.text(node.label)
1795
+ b.close_tag('span')
1796
+ if not is_last:
1797
+ b.open_tag('span', {'class': 'q-breadcrumb-separator'})
1798
+ b.text(sep)
1799
+ b.close_tag('span')
1800
+ b.close_tag('li')
1801
+
1802
+ def _render_pagination(self, node: UIPaginationNode, b: HtmlBuilder):
1803
+ self._features_used.add('pagination')
1804
+ attrs = self._merge_attrs({'class': 'q-pagination'}, self._layout_attrs(node))
1805
+ b.open_tag('nav', attrs)
1806
+ if node.show_total:
1807
+ b.open_tag('span', {'class': 'q-pagination-total'})
1808
+ b.text(f'Total: {node.total or 0} items')
1809
+ b.close_tag('span')
1810
+ b.open_tag('div', {'class': 'q-pagination-controls'})
1811
+ b.open_tag('button', {'class': 'q-pagination-prev'})
1812
+ b.text('Prev')
1813
+ b.close_tag('button')
1814
+ b.open_tag('span', {'class': 'q-pagination-pages'})
1815
+ b.text(f'Page {node.current}')
1816
+ b.close_tag('span')
1817
+ b.open_tag('button', {'class': 'q-pagination-next'})
1818
+ b.text('Next')
1819
+ b.close_tag('button')
1820
+ b.close_tag('div')
1821
+ if node.show_jump:
1822
+ b.open_tag('div', {'class': 'q-pagination-jump'})
1823
+ b.text('Go to ')
1824
+ b.open_tag('input', {'type': 'number', 'min': '1', 'class': 'q-pagination-input'}, self_closing=True)
1825
+ b.close_tag('div')
1826
+ b.close_tag('nav')
1827
+
1828
+ def _render_skeleton(self, node: UISkeletonNode, b: HtmlBuilder):
1829
+ cls = f'q-skeleton q-skeleton-{node.variant}'
1830
+ if node.animated:
1831
+ cls += ' q-skeleton-animated'
1832
+ attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
1833
+ if node.variant == 'text':
1834
+ for _ in range(node.lines):
1835
+ b.open_tag('div', attrs)
1836
+ b.close_tag('div')
1837
+ elif node.variant == 'card':
1838
+ b.open_tag('div', {'class': 'q-skeleton-card'})
1839
+ b.open_tag('div', {'class': 'q-skeleton q-skeleton-rect q-skeleton-animated', 'style': 'height:120px'})
1840
+ b.close_tag('div')
1841
+ b.open_tag('div', {'class': 'q-skeleton q-skeleton-text q-skeleton-animated', 'style': 'width:60%'})
1842
+ b.close_tag('div')
1843
+ b.open_tag('div', {'class': 'q-skeleton q-skeleton-text q-skeleton-animated', 'style': 'width:80%'})
1844
+ b.close_tag('div')
1845
+ b.close_tag('div')
1846
+ else:
1847
+ b.open_tag('div', attrs)
1848
+ b.close_tag('div')
1849
+
1850
+ # ------------------------------------------------------------------
1851
+ # Toast renders
1852
+ # ------------------------------------------------------------------
1853
+
1854
+ def _render_toast(self, node: UIToastNode, b: HtmlBuilder):
1855
+ """Render ui:toast as a positioned, dismissible notification."""
1856
+ self._features_used.add('toast')
1857
+ self._has_toast = True
1858
+ self._toast_counter += 1
1859
+
1860
+ toast_id = node.ui_id or f'q-toast-{self._toast_counter}'
1861
+ variant = node.variant or 'info'
1862
+ position = node.position or 'top-right'
1863
+ duration = self._as_int(node.duration, 3000)
1864
+ dismissible = self._as_bool(node.dismissible, True)
1865
+
1866
+ # Fixed wrapper so a declared toast lands on the requested corner
1867
+ b.open_tag('div', {'class': f'q-toast-container q-toast-{position}'})
1868
+ b.indent()
1869
+
1870
+ toast_attrs = {'class': f'q-toast q-toast-{variant}', 'id': toast_id, 'role': 'status'}
1871
+ if node.show:
1872
+ # Hidden until the bound variable becomes truthy (SHOW_BINDING_JS)
1873
+ self._has_show_bindings = True
1874
+ toast_attrs['data-q-show'] = node.show
1875
+ toast_attrs['style'] = 'display: none'
1876
+ attrs = self._merge_attrs(toast_attrs, self._layout_attrs(node))
1877
+ b.open_tag('div', attrs)
1878
+ b.indent()
1879
+
1880
+ icon = node.icon or TOAST_ICONS.get(variant, TOAST_ICONS['info'])
1881
+ b.open_tag('span', {'class': 'q-toast-icon', 'aria-hidden': 'true'})
1882
+ b.indent()
1883
+ b.text(icon)
1884
+ b.dedent()
1885
+ b.close_tag('span')
1886
+
1887
+ b.open_tag('div', {'class': 'q-toast-content'})
1888
+ b.indent()
1889
+ if node.title:
1890
+ b.open_tag('div', {'class': 'q-toast-title'})
1891
+ b.indent()
1892
+ b.text(node.title)
1893
+ b.dedent()
1894
+ b.close_tag('div')
1895
+ b.open_tag('div', {'class': 'q-toast-message'})
1896
+ b.indent()
1897
+ b.text(node.message)
1898
+ b.dedent()
1899
+ b.close_tag('div')
1900
+ b.dedent()
1901
+ b.close_tag('div')
1902
+
1903
+ if dismissible:
1904
+ actions = []
1905
+ if node.on_close:
1906
+ actions.append(self._js_statement(node.on_close, self._js_str(toast_id)))
1907
+ actions.append(f"__quantumToast.dismiss({self._js_str(toast_id)});")
1908
+ b.open_tag('button', {'class': 'q-toast-close', 'type': 'button',
1909
+ 'aria-label': 'Close', 'onclick': ' '.join(actions)})
1910
+ b.indent()
1911
+ b.text('&times;')
1912
+ b.dedent()
1913
+ b.close_tag('button')
1914
+
1915
+ b.dedent()
1916
+ b.close_tag('div')
1917
+ b.dedent()
1918
+ b.close_tag('div')
1919
+
1920
+ # Auto-dismiss (skipped while a show-binding controls visibility)
1921
+ if duration > 0 and not node.show:
1922
+ self._component_inits.append(
1923
+ f"setTimeout(function() {{ if (window.__quantumToast) "
1924
+ f"__quantumToast.dismiss({self._js_str(toast_id)}); }}, {duration});"
1925
+ )
1926
+
1927
+ def _render_toast_container(self, node: UIToastContainerNode, b: HtmlBuilder):
1928
+ """Render ui:toast-container and make it the sink for runtime toasts."""
1929
+ self._features_used.add('toast')
1930
+ self._has_toast = True
1931
+ self._toast_container_counter += 1
1932
+
1933
+ container_id = node.ui_id or f'q-toast-container-{self._toast_container_counter}'
1934
+ position = node.position or 'top-right'
1935
+ max_toasts = self._as_int(node.max_toasts, 5)
1936
+
1937
+ base = {'class': f'q-toast-container q-toast-{position}', 'id': container_id,
1938
+ 'data-max-toasts': str(max_toasts), 'aria-live': 'polite', 'role': 'region'}
1939
+ attrs = self._merge_attrs(base, self._layout_attrs(node))
1940
+ b.open_tag('div', attrs)
1941
+ b.close_tag('div')
1942
+
1943
+ cid = self._js_str(container_id)
1944
+ pos = self._js_str(position)
1945
+ self._component_inits.append(f"""(function() {{
1946
+ var container = document.getElementById({cid});
1947
+ if (!container || !window.__quantumToast) return;
1948
+ var max = {max_toasts};
1949
+ var showToast = __quantumToast.show;
1950
+ __quantumToast.show = function(options) {{
1951
+ options = options || {{}};
1952
+ if (!options.position) options.position = {pos};
1953
+ var id = showToast(options);
1954
+ if (options.position === {pos}) {{
1955
+ var toast = document.getElementById(id);
1956
+ if (toast) container.appendChild(toast);
1957
+ // Toasts already fading out do not count towards the limit
1958
+ var items = container.querySelectorAll('.q-toast:not(.q-toast-out)');
1959
+ for (var i = 0; i < items.length - max; i++) {{
1960
+ __quantumToast.dismiss(items[i].id);
1961
+ }}
1962
+ }}
1963
+ return id;
1964
+ }};
1965
+ ['info', 'success', 'warning', 'danger'].forEach(function(variant) {{
1966
+ __quantumToast[variant] = function(message, options) {{
1967
+ options = options || {{}};
1968
+ options.message = message;
1969
+ options.variant = variant;
1970
+ return __quantumToast.show(options);
1971
+ }};
1972
+ }});
1973
+ __quantumToast.error = __quantumToast.danger;
1974
+ }})();""")
1975
+
1976
+ # ------------------------------------------------------------------
1977
+ # Carousel renders
1978
+ # ------------------------------------------------------------------
1979
+
1980
+ def _render_carousel(self, node: UICarouselNode, b: HtmlBuilder):
1981
+ """Render ui:carousel with track, arrows and indicators."""
1982
+ self._features_used.add('carousel')
1983
+ self._has_carousel = True
1984
+ self._carousel_counter += 1
1985
+
1986
+ carousel_id = node.ui_id or f'q-carousel-{self._carousel_counter}'
1987
+ animation = node.animation or 'slide'
1988
+ show_arrows = self._as_bool(node.show_arrows, True)
1989
+ show_indicators = self._as_bool(node.show_indicators, True)
1990
+ auto_play = self._as_bool(node.auto_play, False)
1991
+ loop = self._as_bool(node.loop, True)
1992
+ interval = self._as_int(node.interval, 5000)
1993
+
1994
+ # Slides: explicit ui:slide children, otherwise one slide per child
1995
+ slide_nodes = [c for c in node.children if isinstance(c, UISlideNode)]
1996
+ if slide_nodes:
1997
+ slides = [(s, s.children) for s in slide_nodes]
1998
+ else:
1999
+ slides = [(None, [c]) for c in node.children]
2000
+
2001
+ total = len(slides)
2002
+ current = self._as_int(node.current, 0)
2003
+ current = max(0, min(current, total - 1)) if total else 0
2004
+
2005
+ cls = 'q-carousel'
2006
+ if animation == 'fade':
2007
+ cls += ' q-carousel-fade'
2008
+ attrs = self._merge_attrs({'class': cls, 'id': carousel_id}, self._layout_attrs(node))
2009
+ b.open_tag('div', attrs)
2010
+ b.indent()
2011
+
2012
+ b.open_tag('div', {'class': 'q-carousel-track'})
2013
+ b.indent()
2014
+ for i, (slide_node, children) in enumerate(slides):
2015
+ slide_cls = 'q-carousel-slide'
2016
+ if i == current:
2017
+ slide_cls += ' active'
2018
+ slide_attrs = {'class': slide_cls, 'data-slide': str(i)}
2019
+ if slide_node is not None:
2020
+ slide_attrs = self._merge_attrs(slide_attrs, self._layout_attrs(slide_node))
2021
+ b.open_tag('div', slide_attrs)
2022
+ b.indent()
2023
+ self._render_children(children, b)
2024
+ b.dedent()
2025
+ b.close_tag('div')
2026
+ b.dedent()
2027
+ b.close_tag('div')
2028
+
2029
+ if show_arrows and total > 1:
2030
+ b.open_tag('button', {'class': 'q-carousel-arrow q-carousel-prev', 'type': 'button',
2031
+ 'aria-label': 'Previous slide'})
2032
+ b.indent()
2033
+ b.text('&#10094;')
2034
+ b.dedent()
2035
+ b.close_tag('button')
2036
+ b.open_tag('button', {'class': 'q-carousel-arrow q-carousel-next', 'type': 'button',
2037
+ 'aria-label': 'Next slide'})
2038
+ b.indent()
2039
+ b.text('&#10095;')
2040
+ b.dedent()
2041
+ b.close_tag('button')
2042
+
2043
+ if show_indicators and total > 1:
2044
+ b.open_tag('div', {'class': 'q-carousel-indicators'})
2045
+ b.indent()
2046
+ for i in range(total):
2047
+ ind_cls = 'q-carousel-indicator' + (' active' if i == current else '')
2048
+ b.open_tag('button', {'class': ind_cls, 'type': 'button',
2049
+ 'aria-label': f'Go to slide {i + 1}'})
2050
+ b.close_tag('button')
2051
+ b.dedent()
2052
+ b.close_tag('div')
2053
+
2054
+ b.dedent()
2055
+ b.close_tag('div')
2056
+
2057
+ options = {
2058
+ 'current': str(current),
2059
+ 'autoPlay': self._js_bool(auto_play),
2060
+ 'interval': str(interval),
2061
+ 'loop': self._js_bool(loop),
2062
+ 'animation': self._js_str(animation),
2063
+ }
2064
+ on_change = self._js_change_handler(node.on_change, node.bind, 'index')
2065
+ if on_change:
2066
+ options['onChange'] = on_change
2067
+
2068
+ cid = self._js_str(carousel_id)
2069
+ self._component_inits.append(f"""(function() {{
2070
+ if (!window.__quantumCarousel) return;
2071
+ var api = __quantumCarousel.init({cid}, {self._js_options(options)});
2072
+ var state = __quantumCarousel.get({cid});
2073
+ if (api && state) {{
2074
+ for (var key in api) {{ state[key] = api[key]; }}
2075
+ }}
2076
+ }})();""")
2077
+
2078
+ def _render_slide(self, node: UISlideNode, b: HtmlBuilder):
2079
+ """Render a standalone ui:slide (outside a carousel)."""
2080
+ attrs = self._merge_attrs({'class': 'q-carousel-slide'}, self._layout_attrs(node))
2081
+ b.open_tag('div', attrs)
2082
+ b.indent()
2083
+ self._render_children(node.children, b)
2084
+ b.dedent()
2085
+ b.close_tag('div')
2086
+
2087
+ # ------------------------------------------------------------------
2088
+ # Stepper renders
2089
+ # ------------------------------------------------------------------
2090
+
2091
+ def _render_stepper(self, node: UIStepperNode, b: HtmlBuilder):
2092
+ """Render ui:stepper with step indicators and per-step content."""
2093
+ self._features_used.add('stepper')
2094
+ self._has_stepper = True
2095
+ self._stepper_counter += 1
2096
+
2097
+ stepper_id = node.ui_id or f'q-stepper-{self._stepper_counter}'
2098
+ orientation = node.orientation or 'horizontal'
2099
+ show_labels = self._as_bool(node.show_labels, True)
2100
+ clickable = self._as_bool(node.clickable, False)
2101
+ linear = self._as_bool(node.linear, True)
2102
+
2103
+ steps = [c for c in node.children if isinstance(c, UIStepNode)]
2104
+ total = len(steps)
2105
+ current = self._as_int(node.current, 0)
2106
+ current = max(0, min(current, total - 1)) if total else 0
2107
+
2108
+ completed = completed_step_indices(steps, current)
2109
+
2110
+ attrs = self._merge_attrs(
2111
+ {'class': f'q-stepper q-stepper-{orientation}', 'id': stepper_id},
2112
+ self._layout_attrs(node))
2113
+ b.open_tag('div', attrs)
2114
+ b.indent()
2115
+
2116
+ # Step indicators
2117
+ b.open_tag('div', {'class': 'q-stepper-header'})
2118
+ b.indent()
2119
+ for i, step in enumerate(steps):
2120
+ item_cls = 'q-step-item'
2121
+ if i == current:
2122
+ item_cls += ' active'
2123
+ elif i in completed:
2124
+ item_cls += ' completed'
2125
+ if step.error:
2126
+ item_cls += ' error'
2127
+ b.open_tag('div', {'class': item_cls, 'data-step': str(i)})
2128
+ b.indent()
2129
+
2130
+ b.open_tag('div', {'class': 'q-step-indicator'})
2131
+ b.indent()
2132
+ b.text(step.icon if step.icon else str(i + 1))
2133
+ b.dedent()
2134
+ b.close_tag('div')
2135
+
2136
+ if show_labels and (step.title or step.description or step.error):
2137
+ b.open_tag('div', {'class': 'q-step-labels'})
2138
+ b.indent()
2139
+ if step.title:
2140
+ b.open_tag('div', {'class': 'q-step-title'})
2141
+ b.indent()
2142
+ b.text(step.title)
2143
+ b.dedent()
2144
+ b.close_tag('div')
2145
+ if step.description:
2146
+ b.open_tag('div', {'class': 'q-step-description'})
2147
+ b.indent()
2148
+ b.text(step.description)
2149
+ b.dedent()
2150
+ b.close_tag('div')
2151
+ if step.error:
2152
+ b.open_tag('div', {'class': 'q-step-description q-step-error'})
2153
+ b.indent()
2154
+ b.text(step.error)
2155
+ b.dedent()
2156
+ b.close_tag('div')
2157
+ if self._as_bool(step.optional, False):
2158
+ b.open_tag('div', {'class': 'q-step-optional'})
2159
+ b.indent()
2160
+ b.text('Optional')
2161
+ b.dedent()
2162
+ b.close_tag('div')
2163
+ b.dedent()
2164
+ b.close_tag('div')
2165
+
2166
+ if i < total - 1:
2167
+ b.open_tag('div', {'class': 'q-step-connector'})
2168
+ b.close_tag('div')
2169
+
2170
+ b.dedent()
2171
+ b.close_tag('div')
2172
+ b.dedent()
2173
+ b.close_tag('div')
2174
+
2175
+ # Step contents (only the current one is visible)
2176
+ b.open_tag('div', {'class': 'q-stepper-content'})
2177
+ b.indent()
2178
+ for i, step in enumerate(steps):
2179
+ content_cls = 'q-step-content' + (' active' if i == current else '')
2180
+ content_attrs = self._merge_attrs({'class': content_cls, 'data-step': str(i)},
2181
+ self._layout_attrs(step))
2182
+ b.open_tag('div', content_attrs)
2183
+ b.indent()
2184
+ self._render_children(step.children, b)
2185
+ b.dedent()
2186
+ b.close_tag('div')
2187
+ b.dedent()
2188
+ b.close_tag('div')
2189
+
2190
+ b.dedent()
2191
+ b.close_tag('div')
2192
+
2193
+ options = {
2194
+ 'current': str(current),
2195
+ 'linear': self._js_bool(linear),
2196
+ 'clickable': self._js_bool(clickable),
2197
+ }
2198
+ on_change = self._js_change_handler(node.on_change, node.bind, 'index')
2199
+ if on_change:
2200
+ options['onChange'] = on_change
2201
+ if node.on_complete:
2202
+ complete_body = self._js_statement(node.on_complete, '')
2203
+ options['onComplete'] = 'function() { ' + complete_body + ' }'
2204
+
2205
+ sid = self._js_str(stepper_id)
2206
+ completed_js = '[' + ', '.join(str(i) for i in completed) + ']'
2207
+ self._component_inits.append(f"""(function() {{
2208
+ if (!window.__quantumStepper) return;
2209
+ var api = __quantumStepper.init({sid}, {self._js_options(options)});
2210
+ var state = __quantumStepper.get({sid});
2211
+ if (!api || !state) return;
2212
+ for (var key in api) {{ state[key] = api[key]; }}
2213
+ {completed_js}.forEach(function(index) {{ api.complete(index); }});
2214
+ }})();""")
2215
+
2216
+ def _render_step(self, node: UIStepNode, b: HtmlBuilder):
2217
+ """Render a standalone ui:step (outside a stepper)."""
2218
+ attrs = self._merge_attrs({'class': 'q-step-item'}, self._layout_attrs(node))
2219
+ b.open_tag('div', attrs)
2220
+ b.indent()
2221
+ if node.title or node.description:
2222
+ b.open_tag('div', {'class': 'q-step-labels'})
2223
+ b.indent()
2224
+ if node.title:
2225
+ b.open_tag('div', {'class': 'q-step-title'})
2226
+ b.indent()
2227
+ b.text(node.title)
2228
+ b.dedent()
2229
+ b.close_tag('div')
2230
+ if node.description:
2231
+ b.open_tag('div', {'class': 'q-step-description'})
2232
+ b.indent()
2233
+ b.text(node.description)
2234
+ b.dedent()
2235
+ b.close_tag('div')
2236
+ b.dedent()
2237
+ b.close_tag('div')
2238
+ b.open_tag('div', {'class': 'q-step-content active'})
2239
+ b.indent()
2240
+ self._render_children(node.children, b)
2241
+ b.dedent()
2242
+ b.close_tag('div')
2243
+ b.dedent()
2244
+ b.close_tag('div')
2245
+
2246
+ # ------------------------------------------------------------------
2247
+ # Calendar / date picker renders
2248
+ # ------------------------------------------------------------------
2249
+
2250
+ def _calendar_settings(self, node) -> Dict:
2251
+ """Collect the parsed calendar configuration shared by both renders."""
2252
+ mode = getattr(node, 'mode', 'single') or 'single'
2253
+ raw = [v.strip() for v in str(getattr(node, 'value', '') or '').split(',') if v.strip()]
2254
+ dates = [d for d in (self._parse_iso_date(v) for v in raw) if d]
2255
+ disabled_dates = {d for d in
2256
+ (self._parse_iso_date(v) for v in
2257
+ str(getattr(node, 'disabled_dates', '') or '').split(','))
2258
+ if d}
2259
+ disabled_days = {self._as_int(v, -1) for v in
2260
+ str(getattr(node, 'disabled_days', '') or '').split(',') if v.strip()}
2261
+ return {
2262
+ 'mode': mode,
2263
+ 'selected': dates[0] if dates else None,
2264
+ 'range_start': dates[0] if mode == 'range' and dates else None,
2265
+ 'range_end': dates[1] if mode == 'range' and len(dates) > 1 else None,
2266
+ 'multiple': dates if mode == 'multiple' else [],
2267
+ 'min_date': self._parse_iso_date(getattr(node, 'min_date', None)),
2268
+ 'max_date': self._parse_iso_date(getattr(node, 'max_date', None)),
2269
+ 'disabled_dates': disabled_dates,
2270
+ 'disabled_days': disabled_days,
2271
+ 'first_day': self._as_int(getattr(node, 'first_day_of_week', 0), 0) % 7,
2272
+ 'show_weeks': self._as_bool(getattr(node, 'show_week_numbers', False), False),
2273
+ }
2274
+
2275
+ def _calendar_day_disabled(self, day: _date, settings: Dict) -> bool:
2276
+ """Whether a day cannot be selected (min/max, weekday or explicit list)."""
2277
+ if settings['min_date'] and day < settings['min_date']:
2278
+ return True
2279
+ if settings['max_date'] and day > settings['max_date']:
2280
+ return True
2281
+ # JS weekday numbering: 0 = Sunday
2282
+ if ((day.weekday() + 1) % 7) in settings['disabled_days']:
2283
+ return True
2284
+ return day in settings['disabled_dates']
2285
+
2286
+ def _calendar_selection_classes(self, day: _date, settings: Dict) -> List[str]:
2287
+ """Selection related CSS classes for a single day cell."""
2288
+ mode = settings['mode']
2289
+ classes: List[str] = []
2290
+ if mode == 'range':
2291
+ if settings['range_start'] == day:
2292
+ classes.extend(['range-start', 'selected'])
2293
+ if settings['range_end'] == day:
2294
+ classes.extend(['range-end', 'selected'])
2295
+ if (settings['range_start'] and settings['range_end']
2296
+ and settings['range_start'] < day < settings['range_end']):
2297
+ classes.append('in-range')
2298
+ elif mode == 'multiple':
2299
+ if day in settings['multiple']:
2300
+ classes.append('selected')
2301
+ elif settings['selected'] == day:
2302
+ classes.append('selected')
2303
+ return classes
2304
+
2305
+ # The month navigation buttons carry .q-calendar-nav themselves (that is
2306
+ # what CALENDAR_JS renders), while CSS_THEME styles `.q-calendar-nav button`.
2307
+ CALENDAR_NAV_CSS = (
2308
+ ".q-calendar-header .q-calendar-nav { display: inline-flex; align-items: center;"
2309
+ " justify-content: center; width: 28px; height: 28px; background: none;"
2310
+ " border: 1px solid var(--q-border); border-radius: var(--q-radius); cursor: pointer; }\n"
2311
+ ".q-calendar-header .q-calendar-nav:hover { background: var(--q-light); }"
2312
+ )
2313
+
2314
+ def _render_calendar_markup(self, b: HtmlBuilder, target_id: str, settings: Dict):
2315
+ """Render the month header and day grid of a calendar.
2316
+
2317
+ The markup mirrors what CALENDAR_JS re-renders on interaction, so the
2318
+ page shows a usable month grid before (and without) JavaScript.
2319
+ """
2320
+ if self.CALENDAR_NAV_CSS not in self._extra_css:
2321
+ self._extra_css.append(self.CALENDAR_NAV_CSS)
2322
+ today = _date.today()
2323
+ year, month = today.year, today.month
2324
+ target = self._js_str(target_id)
2325
+
2326
+ # Same element shape CALENDAR_JS re-renders, so markup stays stable
2327
+ # once the runtime takes over.
2328
+ b.open_tag('div', {'class': 'q-calendar-header'})
2329
+ b.indent()
2330
+ b.open_tag('button', {'class': 'q-calendar-nav', 'type': 'button',
2331
+ 'aria-label': 'Previous month',
2332
+ 'onclick': f"__quantumCalendar.prevMonth({target})"})
2333
+ b.indent()
2334
+ b.text('&lsaquo;')
2335
+ b.dedent()
2336
+ b.close_tag('button')
2337
+ b.open_tag('span', {'class': 'q-calendar-title'})
2338
+ b.indent()
2339
+ b.text(f'{MONTH_NAMES[month - 1]} {year}')
2340
+ b.dedent()
2341
+ b.close_tag('span')
2342
+ b.open_tag('button', {'class': 'q-calendar-nav', 'type': 'button',
2343
+ 'aria-label': 'Next month',
2344
+ 'onclick': f"__quantumCalendar.nextMonth({target})"})
2345
+ b.indent()
2346
+ b.text('&rsaquo;')
2347
+ b.dedent()
2348
+ b.close_tag('button')
2349
+ b.dedent()
2350
+ b.close_tag('div')
2351
+
2352
+ first_day = settings['first_day']
2353
+ b.open_tag('div', {'class': 'q-calendar-grid'})
2354
+ b.indent()
2355
+
2356
+ if settings['show_weeks']:
2357
+ b.open_tag('div', {'class': 'q-calendar-weekday'})
2358
+ b.close_tag('div')
2359
+ for name in DAY_NAMES[first_day:] + DAY_NAMES[:first_day]:
2360
+ b.open_tag('div', {'class': 'q-calendar-weekday'})
2361
+ b.indent()
2362
+ b.text(name)
2363
+ b.dedent()
2364
+ b.close_tag('div')
2365
+
2366
+ # calendar.Calendar uses 0 = Monday, the node uses 0 = Sunday
2367
+ weeks = _calendar.Calendar((first_day + 6) % 7).monthdatescalendar(year, month)
2368
+ for week in weeks:
2369
+ if settings['show_weeks']:
2370
+ b.open_tag('div', {'class': 'q-calendar-day week-number'})
2371
+ b.indent()
2372
+ b.text(str(week[0].isocalendar()[1]))
2373
+ b.dedent()
2374
+ b.close_tag('div')
2375
+ for day in week:
2376
+ classes = ['q-calendar-day']
2377
+ onclick = None
2378
+ if day.month != month:
2379
+ classes.append('other-month')
2380
+ else:
2381
+ if day == today:
2382
+ classes.append('today')
2383
+ if self._calendar_day_disabled(day, settings):
2384
+ classes.append('disabled')
2385
+ else:
2386
+ onclick = f"__quantumCalendar.selectDate({target}, {day.day})"
2387
+ classes.extend(self._calendar_selection_classes(day, settings))
2388
+ day_attrs = {'class': ' '.join(classes)}
2389
+ if onclick:
2390
+ day_attrs['onclick'] = onclick
2391
+ day_attrs['role'] = 'button'
2392
+ b.open_tag('div', day_attrs)
2393
+ b.indent()
2394
+ b.text(str(day.day))
2395
+ b.dedent()
2396
+ b.close_tag('div')
2397
+
2398
+ b.dedent()
2399
+ b.close_tag('div')
2400
+
2401
+ def _calendar_init_options(self, node, settings: Dict,
2402
+ on_change: Optional[str]) -> Dict[str, str]:
2403
+ """Build the option literal handed to __quantumCalendar.init."""
2404
+ options = {
2405
+ 'mode': self._js_str(settings['mode']),
2406
+ 'firstDayOfWeek': str(settings['first_day']),
2407
+ 'showWeekNumbers': self._js_bool(settings['show_weeks']),
2408
+ }
2409
+ if getattr(node, 'value', None):
2410
+ options['value'] = self._js_str(node.value)
2411
+ if getattr(node, 'min_date', None):
2412
+ options['minDate'] = self._js_str(node.min_date)
2413
+ if getattr(node, 'max_date', None):
2414
+ options['maxDate'] = self._js_str(node.max_date)
2415
+ if getattr(node, 'disabled_dates', None):
2416
+ options['disabledDates'] = self._js_str(node.disabled_dates)
2417
+ if getattr(node, 'disabled_days', None):
2418
+ options['disabledDays'] = self._js_str(node.disabled_days)
2419
+ if on_change:
2420
+ options['onChange'] = on_change
2421
+ return options
2422
+
2423
+ def _calendar_init_script(self, calendar_id: str, options: Dict[str, str],
2424
+ show_weeks: bool) -> str:
2425
+ """Boot a calendar and expose its API through __quantumCalendar.get()."""
2426
+ cid = self._js_str(calendar_id)
2427
+ weeks_block = ''
2428
+ if show_weeks:
2429
+ weeks_block = f"""
2430
+ ['selectDate', 'prevMonth', 'nextMonth'].forEach(function(name) {{
2431
+ var fn = api[name];
2432
+ state[name] = function() {{
2433
+ var result = fn.apply(null, arguments);
2434
+ __quantumCalendarWeeks.apply({cid});
2435
+ return result;
2436
+ }};
2437
+ }});
2438
+ __quantumCalendarWeeks.apply({cid});"""
2439
+ return f"""(function() {{
2440
+ if (!window.__quantumCalendar) return;
2441
+ var api = __quantumCalendar.init({cid}, {self._js_options(options)});
2442
+ var state = __quantumCalendar.get({cid});
2443
+ if (!api || !state) return;
2444
+ for (var key in api) {{ state[key] = api[key]; }}{weeks_block}
2445
+ }})();"""
2446
+
2447
+ def _render_calendar(self, node: UICalendarNode, b: HtmlBuilder):
2448
+ """Render ui:calendar as an interactive month grid."""
2449
+ self._features_used.add('calendar')
2450
+ self._has_calendar = True
2451
+ self._calendar_counter += 1
2452
+
2453
+ calendar_id = node.ui_id or f'q-calendar-{self._calendar_counter}'
2454
+ settings = self._calendar_settings(node)
2455
+
2456
+ attrs = self._merge_attrs(
2457
+ {'class': 'q-calendar', 'id': calendar_id, 'data-mode': settings['mode']},
2458
+ self._layout_attrs(node))
2459
+ b.open_tag('div', attrs)
2460
+ b.indent()
2461
+ self._render_calendar_markup(b, calendar_id, settings)
2462
+ b.dedent()
2463
+ b.close_tag('div')
2464
+
2465
+ if settings['show_weeks']:
2466
+ self._has_calendar_weeks = True
2467
+ # One extra column for the week numbers
2468
+ self._extra_css.append(
2469
+ f"#{calendar_id} .q-calendar-grid {{ grid-template-columns: repeat(8, 1fr); }}")
2470
+
2471
+ on_change = self._js_change_handler(node.on_change, node.bind, 'value')
2472
+ options = self._calendar_init_options(node, settings, on_change)
2473
+ self._component_inits.append(
2474
+ self._calendar_init_script(calendar_id, options, settings['show_weeks']))
2475
+
2476
+ def _render_date_picker(self, node: UIDatePickerNode, b: HtmlBuilder):
2477
+ """Render ui:date-picker as an input backed by a calendar popup."""
2478
+ self._features_used.add('date_picker')
2479
+ self._has_date_picker = True
2480
+ self._date_picker_counter += 1
2481
+
2482
+ picker_id = node.ui_id or f'q-date-picker-{self._date_picker_counter}'
2483
+ pattern = node.format or 'YYYY-MM-DD'
2484
+ selected = self._parse_iso_date(node.value)
2485
+ disabled = self._as_bool(node.disabled, False)
2486
+ clearable = self._as_bool(node.clearable, True)
2487
+ required = self._as_bool(node.required, False)
2488
+
2489
+ settings = self._calendar_settings(node)
2490
+ settings['mode'] = 'single'
2491
+
2492
+ attrs = self._merge_attrs({'class': 'q-date-picker', 'id': picker_id},
2493
+ self._layout_attrs(node))
2494
+ b.open_tag('div', attrs)
2495
+ b.indent()
2496
+
2497
+ input_attrs = {
2498
+ 'type': 'text',
2499
+ 'class': 'q-date-picker-input',
2500
+ 'id': f'{picker_id}-input',
2501
+ 'value': self._format_date(selected, pattern) if selected else '',
2502
+ 'placeholder': node.placeholder or 'Select date',
2503
+ 'data-format': pattern,
2504
+ 'autocomplete': 'off',
2505
+ 'aria-haspopup': 'dialog',
2506
+ 'readonly': True,
2507
+ }
2508
+ if required:
2509
+ input_attrs['required'] = True
2510
+ if disabled:
2511
+ input_attrs['disabled'] = True
2512
+ b.open_tag('input', input_attrs, self_closing=True)
2513
+
2514
+ # Canonical ISO value, so forms submit a machine readable date
2515
+ hidden_attrs = {'type': 'hidden', 'id': f'{picker_id}-value',
2516
+ 'value': selected.isoformat() if selected else ''}
2517
+ if node.bind:
2518
+ hidden_attrs['name'] = node.bind
2519
+ b.open_tag('input', hidden_attrs, self_closing=True)
2520
+
2521
+ if clearable and not disabled:
2522
+ b.open_tag('button', {
2523
+ 'class': 'q-date-picker-clear', 'type': 'button', 'aria-label': 'Clear date',
2524
+ 'onclick': f"__quantumDatePicker.clear({self._js_str(picker_id)})"})
2525
+ b.indent()
2526
+ b.text('&times;')
2527
+ b.dedent()
2528
+ b.close_tag('button')
2529
+
2530
+ b.open_tag('span', {'class': 'q-date-picker-icon', 'aria-hidden': 'true'})
2531
+ b.indent()
2532
+ b.text('&#128197;')
2533
+ b.dedent()
2534
+ b.close_tag('span')
2535
+
2536
+ calendar_id = f'{picker_id}-cal'
2537
+ b.open_tag('div', {'class': 'q-date-picker-dropdown', 'id': f'{picker_id}-dropdown',
2538
+ 'role': 'dialog'})
2539
+ b.indent()
2540
+ b.open_tag('div', {'class': 'q-calendar', 'id': calendar_id, 'data-mode': 'single'})
2541
+ b.indent()
2542
+ self._render_calendar_markup(b, calendar_id, settings)
2543
+ b.dedent()
2544
+ b.close_tag('div')
2545
+ b.dedent()
2546
+ b.close_tag('div')
2547
+
2548
+ b.dedent()
2549
+ b.close_tag('div')
2550
+
2551
+ pid = self._js_str(picker_id)
2552
+ picker_change = (f"function(value) {{ __quantumDatePicker.setValue({pid}, value); }}")
2553
+ options = self._calendar_init_options(node, settings, picker_change)
2554
+ options['mode'] = self._js_str('single')
2555
+
2556
+ register_options = {
2557
+ 'format': self._js_str(pattern),
2558
+ 'disabled': self._js_bool(disabled),
2559
+ }
2560
+ on_change = self._js_change_handler(node.on_change, node.bind, 'value')
2561
+ if on_change:
2562
+ register_options['onChange'] = on_change
2563
+
2564
+ init = self._calendar_init_script(calendar_id, options, settings['show_weeks'])
2565
+ init += (f"\nif (window.__quantumDatePicker) __quantumDatePicker.register({pid}, "
2566
+ f"{self._js_options(register_options)});")
2567
+ self._component_inits.append(init)