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