quantum-framework 0.18.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1866 @@
1
+ """
2
+ UI Engine - Textual Adapter
3
+
4
+ Transforms UI AST nodes into a standalone Python Textual (TUI) application.
5
+ Uses the design tokens system for normalized styling across targets.
6
+ """
7
+
8
+ from datetime import date as _date
9
+ from typing import Any, List, Optional, Set, Dict
10
+
11
+ from quantum.core.ast_nodes import QuantumNode
12
+ from quantum.core.features.conditionals.src.ast_node import IfNode
13
+ from quantum.core.features.loops.src.ast_node import LoopNode
14
+ from quantum.core.features.state_management.src.ast_node import SetNode
15
+ from quantum.core.features.ui_engine.src.ast_nodes import (
16
+ UIWindowNode, UIHBoxNode, UIVBoxNode, UIPanelNode,
17
+ UITabPanelNode, UITabNode, UIGridNode, UIAccordionNode,
18
+ UISectionNode, UIDividedBoxNode, UIFormNode, UIFormItemNode,
19
+ UISpacerNode, UIScrollBoxNode,
20
+ UITextNode, UIButtonNode, UIInputNode, UICheckboxNode,
21
+ UIRadioNode, UISwitchNode, UISelectNode, UITableNode,
22
+ UIColumnNode, UIListNode, UIItemNode, UIImageNode,
23
+ UILinkNode, UIProgressNode, UITreeNode, UIMenuNode,
24
+ UIOptionNode, UILogNode, UIMarkdownNode, UIHeaderNode,
25
+ UIFooterNode, UIRuleNode, UILoadingNode, UIBadgeNode,
26
+ UICarouselNode, UISlideNode, UIStepperNode, UIStepNode,
27
+ UICalendarNode, UIDatePickerNode, UIToastNode, UIToastContainerNode,
28
+ )
29
+ from quantum.runtime.terminal_templates import PyBuilder, py_string, py_id
30
+ from quantum.runtime.ui_tokens import TokenConverter, get_compatibility_warnings
31
+ from quantum.runtime.ui_html_adapter import completed_step_indices
32
+
33
+
34
+ # ==========================================================================
35
+ # Generated runtime helpers
36
+ #
37
+ # These blocks are emitted (verbatim, at class level) into the generated
38
+ # Textual app only when the matching ui: component is present in the AST.
39
+ # ==========================================================================
40
+
41
+ _CAROUSEL_METHODS = '''
42
+ def _q_carousel_init(self) -> None:
43
+ """Set the initial slide / indicator and start auto-play timers."""
44
+ for cid, info in self._q_carousels.items():
45
+ info["index"] = max(0, min(info["index"], len(info["slides"]) - 1))
46
+ self._q_carousel_apply(cid)
47
+ if info["auto_play"] and len(info["slides"]) > 1:
48
+ self.set_interval(info["interval"], lambda cid=cid: self._q_carousel_go(cid, 1))
49
+
50
+ def _q_carousel_apply(self, cid: str) -> None:
51
+ """Show the current slide of one carousel and refresh its indicator."""
52
+ info = self._q_carousels[cid]
53
+ index = info["index"]
54
+ self.query_one("#" + cid + "__switcher", ContentSwitcher).current = info["slides"][index]
55
+ for indicator in self.query("#" + cid + "__indicator"):
56
+ indicator.update("%d / %d" % (index + 1, len(info["slides"])))
57
+ if info["bind"]:
58
+ self._q_set_state(info["bind"], index)
59
+
60
+ def _q_carousel_go(self, cid: str, delta: int) -> None:
61
+ """Move a carousel by delta slides (honouring the loop attribute)."""
62
+ info = self._q_carousels[cid]
63
+ total = len(info["slides"])
64
+ index = info["index"] + delta
65
+ if info["loop"]:
66
+ index %= total
67
+ else:
68
+ index = max(0, min(total - 1, index))
69
+ info["index"] = index
70
+ self._q_carousel_apply(cid)
71
+
72
+ def _q_carousel_focused(self) -> str:
73
+ """The carousel that currently holds focus, else the first one."""
74
+ node = self.focused
75
+ while node is not None:
76
+ if getattr(node, "id", None) in self._q_carousels:
77
+ return node.id
78
+ node = node.parent
79
+ return next(iter(self._q_carousels))
80
+
81
+ def action_q_carousel_next(self) -> None:
82
+ self._q_carousel_go(self._q_carousel_focused(), 1)
83
+
84
+ def action_q_carousel_prev(self) -> None:
85
+ self._q_carousel_go(self._q_carousel_focused(), -1)
86
+ '''
87
+
88
+
89
+ _STEPPER_METHODS = '''
90
+ def _q_stepper_init(self) -> None:
91
+ for sid in self._q_steppers:
92
+ self._q_stepper_apply(sid)
93
+
94
+ def _q_stepper_apply(self, sid: str) -> None:
95
+ """Show the current step and repaint the step indicators."""
96
+ info = self._q_steppers[sid]
97
+ steps = info["steps"]
98
+ index = info["index"]
99
+ self.query_one("#" + sid + "__switcher", ContentSwitcher).current = steps[index]
100
+ explicit = info.get("explicit_done") or {}
101
+ for position in range(len(steps)):
102
+ # Mesma regra do adapter html: a marca explicita vence; sem ela, o
103
+ # passo esta concluido se esta atras do atual.
104
+ done = explicit[position] if position in explicit else position < index
105
+ for indicator in self.query("#" + sid + "__ind_" + str(position)):
106
+ indicator.set_class(position == index, "q-step-active")
107
+ indicator.set_class(done, "q-step-done")
108
+ for button in self.query("#" + sid + "__prev"):
109
+ button.disabled = index == 0
110
+ for button in self.query("#" + sid + "__next"):
111
+ button.disabled = index >= len(steps) - 1
112
+ if info["bind"]:
113
+ self._q_set_state(info["bind"], index)
114
+
115
+ def _q_stepper_goto(self, sid: str, index: int) -> None:
116
+ """Jump to a step. A linear stepper never skips ahead more than one step."""
117
+ info = self._q_steppers[sid]
118
+ if info["linear"] and index > info["index"] + 1:
119
+ index = info["index"] + 1
120
+ info["index"] = max(0, min(len(info["steps"]) - 1, index))
121
+ self._q_stepper_apply(sid)
122
+
123
+ def _q_stepper_focused(self) -> str:
124
+ node = self.focused
125
+ while node is not None:
126
+ if getattr(node, "id", None) in self._q_steppers:
127
+ return node.id
128
+ node = node.parent
129
+ return next(iter(self._q_steppers))
130
+
131
+ def action_q_stepper_next(self) -> None:
132
+ sid = self._q_stepper_focused()
133
+ self._q_stepper_goto(sid, self._q_steppers[sid]["index"] + 1)
134
+
135
+ def action_q_stepper_prev(self) -> None:
136
+ sid = self._q_stepper_focused()
137
+ self._q_stepper_goto(sid, self._q_steppers[sid]["index"] - 1)
138
+ '''
139
+
140
+
141
+ _CALENDAR_METHODS = '''
142
+ def _q_calendar_init(self) -> None:
143
+ for cid, info in self._q_calendars.items():
144
+ anchor = info["selected"][0] if info["selected"] else date.today()
145
+ info["year"] = anchor.year
146
+ info["month"] = anchor.month
147
+ self._q_calendar_render(cid)
148
+
149
+ def _q_calendar_labels(self, cid: str):
150
+ """Weekday abbreviations + month title, localised when the locale exists."""
151
+ info = self._q_calendars[cid]
152
+ first = (info["first_day"] - 1) % 7
153
+ if info["locale"]:
154
+ try:
155
+ localized = calendar.LocaleTextCalendar(first, info["locale"])
156
+ heads = localized.formatweekheader(3).split()
157
+ title = localized.formatmonthname(info["year"], info["month"], 0, withyear=True).strip()
158
+ if len(heads) == 7 and title:
159
+ return heads, title
160
+ except Exception:
161
+ pass
162
+ heads = [calendar.day_abbr[(first + offset) % 7] for offset in range(7)]
163
+ return heads, "%s %d" % (calendar.month_name[info["month"]], info["year"])
164
+
165
+ def _q_calendar_disabled(self, cid: str, day) -> bool:
166
+ info = self._q_calendars[cid]
167
+ if info["min"] and day < info["min"]:
168
+ return True
169
+ if info["max"] and day > info["max"]:
170
+ return True
171
+ if day.isoformat() in info["disabled_dates"]:
172
+ return True
173
+ return ((day.weekday() + 1) % 7) in info["disabled_days"]
174
+
175
+ def _q_calendar_render(self, cid: str) -> None:
176
+ """Repaint the month grid. info["cells"] maps grid coordinates to dates."""
177
+ info = self._q_calendars[cid]
178
+ heads, title = self._q_calendar_labels(cid)
179
+ table = self.query_one("#" + cid + "__grid", DataTable)
180
+ table.clear(columns=True)
181
+ table.add_columns(*((["Wk"] if info["week_numbers"] else []) + heads))
182
+ selected = info["selected"]
183
+ in_range = info["mode"] == "range" and len(selected) == 2
184
+ info["cells"] = []
185
+ for week in calendar.Calendar((info["first_day"] - 1) % 7).monthdatescalendar(
186
+ info["year"], info["month"]):
187
+ row = []
188
+ cells = []
189
+ if info["week_numbers"]:
190
+ row.append(Text(str(week[0].isocalendar()[1]), style="dim"))
191
+ cells.append(None)
192
+ for day in week:
193
+ if day.month != info["month"]:
194
+ row.append(Text(""))
195
+ cells.append(None)
196
+ continue
197
+ if self._q_calendar_disabled(cid, day):
198
+ row.append(Text(str(day.day), style="dim strike"))
199
+ cells.append(None)
200
+ continue
201
+ if day in selected:
202
+ row.append(Text(str(day.day), style="reverse bold"))
203
+ elif in_range and selected[0] < day < selected[1]:
204
+ row.append(Text(str(day.day), style="underline"))
205
+ else:
206
+ row.append(Text(str(day.day)))
207
+ cells.append(day)
208
+ table.add_row(*row)
209
+ info["cells"].append(cells)
210
+ for label in self.query("#" + cid + "__title"):
211
+ label.update(title)
212
+ self._q_calendar_show_value(cid)
213
+
214
+ def _q_calendar_show_value(self, cid: str) -> None:
215
+ info = self._q_calendars[cid]
216
+ selected = info["selected"]
217
+ if info["mode"] == "range" and len(selected) == 2:
218
+ text = selected[0].isoformat() + " -> " + selected[1].isoformat()
219
+ else:
220
+ text = ", ".join(day.isoformat() for day in selected)
221
+ if info["bind"]:
222
+ self._q_set_state(info["bind"], text)
223
+ for label in self.query("#" + cid + "__value"):
224
+ label.update(text or "No date selected")
225
+
226
+ def _q_calendar_move(self, cid: str, delta: int) -> None:
227
+ info = self._q_calendars[cid]
228
+ month = info["month"] + delta - 1
229
+ info["year"] = info["year"] + month // 12
230
+ info["month"] = month % 12 + 1
231
+ self._q_calendar_render(cid)
232
+
233
+ def _q_calendar_select(self, cid: str, day) -> None:
234
+ info = self._q_calendars[cid]
235
+ selected = info["selected"]
236
+ if info["mode"] == "multiple":
237
+ if day in selected:
238
+ selected.remove(day)
239
+ else:
240
+ selected.append(day)
241
+ selected.sort()
242
+ elif info["mode"] == "range":
243
+ if len(selected) == 1 and day != selected[0]:
244
+ selected[:] = sorted([selected[0], day])
245
+ else:
246
+ selected[:] = [day]
247
+ else:
248
+ selected[:] = [day]
249
+ self._q_calendar_render(cid)
250
+
251
+ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
252
+ table_id = event.data_table.id or ""
253
+ if not table_id.endswith("__grid"):
254
+ return
255
+ info = self._q_calendars.get(table_id[: -len("__grid")])
256
+ if info is None:
257
+ return
258
+ row = event.coordinate.row
259
+ column = event.coordinate.column
260
+ if row >= len(info["cells"]) or column >= len(info["cells"][row]):
261
+ return
262
+ day = info["cells"][row][column]
263
+ if day is not None:
264
+ self._q_calendar_select(table_id[: -len("__grid")], day)
265
+ '''
266
+
267
+
268
+ _DATE_PICKER_METHODS = '''
269
+ def _q_date_picker_init(self) -> None:
270
+ for pid in self._q_date_pickers:
271
+ value = ""
272
+ for field in self.query("#" + pid + "__input"):
273
+ value = field.value
274
+ self._q_date_validate(pid, value)
275
+
276
+ def _q_date_validate(self, pid: str, raw: str) -> None:
277
+ """Parse the typed date against the declared format / min / max."""
278
+ info = self._q_date_pickers[pid]
279
+ raw = (raw or "").strip()
280
+ valid = True
281
+ value = ""
282
+ message = info["format"]
283
+ if not raw:
284
+ if info["required"]:
285
+ valid = False
286
+ message = "This date is required"
287
+ else:
288
+ try:
289
+ parsed = datetime.strptime(raw, info["strptime"]).date()
290
+ except ValueError:
291
+ valid = False
292
+ message = "Expected format: " + info["format"]
293
+ else:
294
+ if info["min"] and parsed < info["min"]:
295
+ valid = False
296
+ message = "Earliest allowed: " + info["min"].isoformat()
297
+ elif info["max"] and parsed > info["max"]:
298
+ valid = False
299
+ message = "Latest allowed: " + info["max"].isoformat()
300
+ else:
301
+ value = parsed.isoformat()
302
+ message = parsed.strftime(info["strptime"])
303
+ for hint in self.query("#" + pid + "__hint"):
304
+ hint.update(message)
305
+ hint.set_class(not valid, "q-invalid")
306
+ for field in self.query("#" + pid + "__input"):
307
+ field.set_class(not valid, "q-invalid")
308
+ if info["bind"]:
309
+ self._q_set_state(info["bind"], value)
310
+
311
+ def on_input_changed(self, event: Input.Changed) -> None:
312
+ input_id = event.input.id or ""
313
+ if input_id.endswith("__input") and input_id[: -len("__input")] in self._q_date_pickers:
314
+ self._q_date_validate(input_id[: -len("__input")], event.value)
315
+ '''
316
+
317
+
318
+ _STATE_ACCESSOR = '''
319
+ def _q_truthy(self, value) -> bool:
320
+ """Mesma nocao de verdadeiro que o alvo html usa num binding."""
321
+ if value is None or value is False:
322
+ return False
323
+ if isinstance(value, str):
324
+ return value.strip().lower() not in ('', 'false', '0', 'no', 'off')
325
+ return bool(value)
326
+
327
+ def _q_set_state(self, name: str, value) -> None:
328
+ """Ponto unico de escrita do estado ligado por bind=.
329
+
330
+ Os widgets escreviam direto em self._q_state, entao nada podia REAGIR a
331
+ uma mudanca — e era por isso que `show=` de um ui:toast so podia virar
332
+ comentario. Com um ponto unico, quem precisa observar, observa.
333
+ """
334
+ self._q_state[name] = value
335
+ reagir = getattr(self, '_q_toast_on_state', None)
336
+ if reagir is not None:
337
+ reagir(name)
338
+
339
+ def q_state(self) -> dict:
340
+ """Current value of every bound ui: widget (bind="..." attributes)."""
341
+ return dict(self._q_state)
342
+ '''
343
+
344
+
345
+ class UITextualAdapter:
346
+ """Generates Python Textual app from UI AST nodes."""
347
+
348
+ # ui:toast position -> Textual ToastRack `align` value
349
+ TOAST_ALIGN = {
350
+ 'top-right': 'right top',
351
+ 'top-left': 'left top',
352
+ 'bottom-right': 'right bottom',
353
+ 'bottom-left': 'left bottom',
354
+ 'top-center': 'center top',
355
+ 'bottom-center': 'center bottom',
356
+ }
357
+
358
+ # ui:toast variant -> Textual notification severity
359
+ TOAST_SEVERITY = {
360
+ 'info': 'information',
361
+ 'success': 'information',
362
+ 'warning': 'warning',
363
+ 'danger': 'error',
364
+ }
365
+
366
+ def __init__(self):
367
+ self._indent = 2 # compose() body starts at indent 2
368
+ self._tokens = TokenConverter('textual')
369
+ self._features_used: Set[str] = set()
370
+ self._css_rules: List[str] = []
371
+ self._widget_counter = 0
372
+ self._reset_component_state()
373
+
374
+ def _reset_component_state(self):
375
+ """Reset per-generation state for the stateful components."""
376
+ self._extra_imports: List[str] = []
377
+ self._datetime_imports: Set[str] = set()
378
+ self._carousels: Dict[str, Dict[str, Any]] = {}
379
+ self._steppers: Dict[str, Dict[str, Any]] = {}
380
+ self._calendars: Dict[str, Dict[str, Any]] = {}
381
+ self._date_pickers: Dict[str, Dict[str, Any]] = {}
382
+ self._toasts: List[Dict[str, Any]] = []
383
+ self._toast_rack_position: Optional[str] = None
384
+ self._toast_first_position: Optional[str] = None
385
+ self._max_toasts: Optional[int] = None
386
+
387
+ def _next_id(self, prefix: str = 'q') -> str:
388
+ """Generate unique widget ID."""
389
+ self._widget_counter += 1
390
+ return f"{prefix}_{self._widget_counter}"
391
+
392
+ def _need_import(self, statement: str):
393
+ """Register an import needed by a generated component (deduplicated)."""
394
+ if statement not in self._extra_imports:
395
+ self._extra_imports.append(statement)
396
+
397
+ def _need_datetime(self, *names: str):
398
+ """Register names for a single merged `from datetime import ...` line."""
399
+ self._datetime_imports.update(names)
400
+
401
+ # ------------------------------------------------------------------
402
+ # Attribute coercion helpers
403
+ #
404
+ # AST nodes declare ints/bools, but attributes arriving straight from XML
405
+ # are strings ("true", "3000"). Coerce defensively so a parser that keeps
406
+ # the raw text still produces a working app.
407
+ # ------------------------------------------------------------------
408
+
409
+ @staticmethod
410
+ def _as_int(value, default: int = 0) -> int:
411
+ if isinstance(value, bool):
412
+ return int(value)
413
+ if isinstance(value, int):
414
+ return value
415
+ try:
416
+ return int(str(value).strip())
417
+ except (TypeError, ValueError):
418
+ return default
419
+
420
+ @staticmethod
421
+ def _as_bool(value, default: bool = False) -> bool:
422
+ if value is None:
423
+ return default
424
+ if isinstance(value, bool):
425
+ return value
426
+ text = str(value).strip().lower()
427
+ if text in ('true', '1', 'yes', 'on'):
428
+ return True
429
+ if text in ('false', '0', 'no', 'off', ''):
430
+ return False
431
+ return default
432
+
433
+ def generate(self, windows: List[QuantumNode], ui_children: List[QuantumNode],
434
+ title: str = "Quantum UI") -> str:
435
+ """Generate complete Python Textual app from UI AST."""
436
+ # Reset state
437
+ self._css_rules = []
438
+ self._widget_counter = 0
439
+ self._features_used = set()
440
+ self._reset_component_state()
441
+
442
+ py = PyBuilder()
443
+
444
+ # Build compose body
445
+ compose_lines = PyBuilder()
446
+ compose_lines._indent = 2 # inside class > compose method
447
+
448
+ # Render windows
449
+ for window in windows:
450
+ self._render_node(window, compose_lines)
451
+
452
+ # Render top-level children
453
+ for child in ui_children:
454
+ self._render_node(child, compose_lines)
455
+
456
+ compose_body = compose_lines.build()
457
+ if not self._has_statement(compose_body):
458
+ # Nothing (or only source-mapping comments) reached compose():
459
+ # a bare comment body would be a syntax error.
460
+ compose_body = f'{compose_body}\n yield Static("Empty UI")'.lstrip('\n')
461
+
462
+ # Build CSS
463
+ css = self._build_css()
464
+
465
+ # Use template
466
+ from quantum.runtime.ui_textual_templates import UI_TEXTUAL_APP_TEMPLATE
467
+ code = UI_TEXTUAL_APP_TEMPLATE.format(
468
+ title=py_string(title),
469
+ css=css,
470
+ compose_body=compose_body,
471
+ extra_imports=self._build_extra_imports(),
472
+ bindings=self._build_bindings(),
473
+ methods=self._build_methods(),
474
+ )
475
+
476
+ return code
477
+
478
+ @staticmethod
479
+ def _has_statement(body: str) -> bool:
480
+ """True when a generated block contains real code (not just comments)."""
481
+ return any(line.strip() and not line.strip().startswith('#')
482
+ for line in body.split('\n'))
483
+
484
+ def _build_extra_imports(self) -> str:
485
+ """Imports required by the generated components (empty when unused)."""
486
+ lines = list(self._extra_imports)
487
+ if self._datetime_imports:
488
+ lines.append('from datetime import ' + ', '.join(sorted(self._datetime_imports)))
489
+ return '\n'.join(lines)
490
+
491
+ def _build_css(self) -> str:
492
+ """Generate Textual CSS including dynamic rules from layout attributes."""
493
+ base_css = [
494
+ "/* Quantum UI Textual CSS */",
495
+ "",
496
+ "/* Base styles */",
497
+ ".q-panel { border: solid $primary; padding: 1 2; }",
498
+ ".q-panel-title { text-style: bold; color: $text; }",
499
+ "",
500
+ "/* Badge styles */",
501
+ ".q-badge { padding: 0 1; }",
502
+ ".q-badge-primary { background: $primary; color: $text; }",
503
+ ".q-badge-secondary { background: $secondary; color: $text; }",
504
+ ".q-badge-success { background: $success; color: $text; }",
505
+ ".q-badge-danger { background: $error; color: $text; }",
506
+ ".q-badge-warning { background: $warning; color: $text; }",
507
+ "",
508
+ "/* Image placeholder */",
509
+ ".q-image-placeholder { color: $text-muted; text-style: italic; }",
510
+ "",
511
+ "/* Link style */",
512
+ ".q-link { color: $primary; text-style: underline; }",
513
+ "",
514
+ ]
515
+
516
+ # Component styles - only emitted when the component is actually used
517
+ if self._carousels:
518
+ base_css.extend([
519
+ "/* Carousel */",
520
+ ".q-carousel { height: auto; }",
521
+ ".q-slide { height: auto; }",
522
+ ".q-carousel-nav { height: auto; align-horizontal: center; }",
523
+ ".q-carousel-arrow { min-width: 5; }",
524
+ ".q-carousel-indicator { padding: 1 2; color: $text-muted; }",
525
+ "",
526
+ ])
527
+
528
+ if self._steppers:
529
+ base_css.extend([
530
+ "/* Stepper */",
531
+ ".q-stepper { height: auto; }",
532
+ ".q-stepper-indicators { height: auto; }",
533
+ ".q-step { height: auto; }",
534
+ ".q-step-indicator { padding: 0 1; color: $text-muted; }",
535
+ ".q-step-indicator.q-step-active { color: $primary; text-style: bold; }",
536
+ ".q-step-indicator.q-step-done { color: $success; }",
537
+ ".q-step-title { text-style: bold; }",
538
+ ".q-step-description { color: $text-muted; text-style: italic; }",
539
+ ".q-step-error { color: $error; }",
540
+ ".q-stepper-nav { height: auto; align-horizontal: right; }",
541
+ "",
542
+ ])
543
+
544
+ if self._calendars:
545
+ base_css.extend([
546
+ "/* Calendar */",
547
+ ".q-calendar { height: auto; }",
548
+ ".q-calendar-header { height: auto; align-horizontal: center; }",
549
+ ".q-calendar-title { padding: 1 2; text-style: bold; }",
550
+ ".q-calendar-nav { min-width: 5; }",
551
+ ".q-calendar-grid { height: auto; max-height: 10; }",
552
+ ".q-calendar-value { color: $text-muted; }",
553
+ "",
554
+ ])
555
+
556
+ if self._date_pickers:
557
+ base_css.extend([
558
+ "/* Date picker */",
559
+ ".q-date-picker { height: auto; }",
560
+ ".q-date-picker-row { height: auto; }",
561
+ ".q-date-picker-clear { min-width: 5; }",
562
+ ".q-date-picker-hint { color: $text-muted; }",
563
+ ".q-invalid { color: $error; }",
564
+ "",
565
+ ])
566
+
567
+ if self._carousels or self._steppers:
568
+ base_css.extend([
569
+ "/* Empty container feedback */",
570
+ ".q-empty { color: $text-muted; text-style: italic; }",
571
+ "",
572
+ ])
573
+
574
+ toast_position = self._toast_rack_position or self._toast_first_position
575
+ if toast_position:
576
+ align = self.TOAST_ALIGN.get(toast_position, 'right bottom')
577
+ base_css.extend([
578
+ "/* Toast notifications (ui:toast-container position) */",
579
+ f"ToastRack {{ align: {align}; }}",
580
+ "",
581
+ ])
582
+
583
+ # Add dynamic rules
584
+ if self._css_rules:
585
+ base_css.append("/* Dynamic layout styles */")
586
+ base_css.extend(self._css_rules)
587
+
588
+ return '\\n'.join(base_css)
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 get_compatibility_warnings(self) -> List[str]:
595
+ """Get warnings about features that don't translate well to Textual."""
596
+ return get_compatibility_warnings(self._features_used, 'textual')
597
+
598
+ # ------------------------------------------------------------------
599
+ # Layout CSS helpers
600
+ # ------------------------------------------------------------------
601
+
602
+ def _layout_css_rules(self, node, widget_id: str) -> Optional[str]:
603
+ """Generate Textual CSS rule for a node's layout attributes."""
604
+ rules = []
605
+
606
+ if hasattr(node, 'padding') and node.padding:
607
+ val = self._tokens.spacing(node.padding)
608
+ rules.append(f"padding: {val}")
609
+
610
+ if hasattr(node, 'margin') and node.margin:
611
+ val = self._tokens.spacing(node.margin)
612
+ rules.append(f"margin: {val}")
613
+
614
+ if hasattr(node, 'width') and node.width:
615
+ val = self._tokens.size(node.width)
616
+ if val and val != 'auto':
617
+ rules.append(f"width: {val}")
618
+ if node.width.isdigit() or str(node.width).endswith('px'):
619
+ self._features_used.add('pixel_units')
620
+
621
+ if hasattr(node, 'height') and node.height:
622
+ val = self._tokens.size(node.height)
623
+ if val and val != 'auto':
624
+ rules.append(f"height: {val}")
625
+ if node.height.isdigit() or str(node.height).endswith('px'):
626
+ self._features_used.add('pixel_units')
627
+
628
+ if hasattr(node, 'background') and node.background:
629
+ val = self._tokens.color(node.background)
630
+ rules.append(f"background: {val}")
631
+
632
+ if hasattr(node, 'color') and node.color:
633
+ val = self._tokens.color(node.color)
634
+ rules.append(f"color: {val}")
635
+
636
+ if hasattr(node, 'border') and node.border:
637
+ # Simplified border handling
638
+ rules.append(f"border: solid $primary")
639
+
640
+ if hasattr(node, 'align') and node.align:
641
+ # Textual align is different - it's a content alignment
642
+ # align-horizontal: left | center | right
643
+ val = self._tokens.align(node.align)
644
+ rules.append(f"align-horizontal: {val}")
645
+
646
+ if hasattr(node, 'justify') and node.justify:
647
+ # Textual doesn't have justify-content
648
+ # We track it for compatibility warnings
649
+ if node.justify in ('between', 'around'):
650
+ self._features_used.add(f'justify_{node.justify}')
651
+
652
+ if hasattr(node, 'gap') and node.gap:
653
+ # Textual doesn't support gap directly
654
+ # Track for compatibility warning
655
+ self._features_used.add('gap')
656
+
657
+ if hasattr(node, 'visible') and node.visible == 'false':
658
+ rules.append("display: none")
659
+
660
+ if rules:
661
+ return f"#{widget_id} {{ {'; '.join(rules)}; }}"
662
+ return None
663
+
664
+ def _get_widget_attrs(self, node, default_id: str = None,
665
+ default_classes: str = None) -> Dict[str, str]:
666
+ """Build widget constructor attributes from node."""
667
+ attrs = {}
668
+
669
+ # Use node's ui_id or generate one
670
+ widget_id = None
671
+ if hasattr(node, 'ui_id') and node.ui_id:
672
+ widget_id = node.ui_id
673
+ elif default_id:
674
+ widget_id = default_id
675
+
676
+ if widget_id:
677
+ attrs['id'] = widget_id
678
+
679
+ # Collect classes
680
+ classes = []
681
+ if default_classes:
682
+ classes.append(default_classes)
683
+ if hasattr(node, 'ui_class') and node.ui_class:
684
+ classes.append(node.ui_class)
685
+
686
+ if classes:
687
+ attrs['classes'] = ' '.join(classes)
688
+
689
+ # Generate CSS rules if we have an ID
690
+ if widget_id:
691
+ css_rule = self._layout_css_rules(node, widget_id)
692
+ if css_rule:
693
+ self._css_rules.append(css_rule)
694
+
695
+ return attrs
696
+
697
+ def _format_widget_attrs(self, attrs: Dict[str, str]) -> str:
698
+ """Format widget attributes for Python constructor."""
699
+ if not attrs:
700
+ return ''
701
+ parts = []
702
+ for k, v in attrs.items():
703
+ parts.append(f'{k}={py_string(v)}')
704
+ return ', '.join(parts)
705
+
706
+ # ------------------------------------------------------------------
707
+ # Node rendering dispatch
708
+ # ------------------------------------------------------------------
709
+
710
+ def _render_node(self, node: QuantumNode, py: PyBuilder):
711
+ """Dispatch node to its render method."""
712
+ # Containers
713
+ if isinstance(node, UIWindowNode):
714
+ self._render_window(node, py)
715
+ elif isinstance(node, UIHBoxNode):
716
+ self._render_hbox(node, py)
717
+ elif isinstance(node, UIVBoxNode):
718
+ self._render_vbox(node, py)
719
+ elif isinstance(node, UIPanelNode):
720
+ self._render_panel(node, py)
721
+ elif isinstance(node, UITabPanelNode):
722
+ self._render_tabpanel(node, py)
723
+ elif isinstance(node, UITabNode):
724
+ self._render_tab(node, py)
725
+ elif isinstance(node, UIGridNode):
726
+ self._render_grid(node, py)
727
+ elif isinstance(node, UIAccordionNode):
728
+ self._render_accordion(node, py)
729
+ elif isinstance(node, UISectionNode):
730
+ self._render_section(node, py)
731
+ elif isinstance(node, UIScrollBoxNode):
732
+ self._render_scrollbox(node, py)
733
+ elif isinstance(node, UIFormNode):
734
+ self._render_form(node, py)
735
+ elif isinstance(node, UIFormItemNode):
736
+ self._render_formitem(node, py)
737
+ elif isinstance(node, UISpacerNode):
738
+ self._render_spacer(node, py)
739
+ # Widgets
740
+ elif isinstance(node, UITextNode):
741
+ self._render_text(node, py)
742
+ elif isinstance(node, UIButtonNode):
743
+ self._render_button(node, py)
744
+ elif isinstance(node, UIInputNode):
745
+ self._render_input(node, py)
746
+ elif isinstance(node, UICheckboxNode):
747
+ self._render_checkbox(node, py)
748
+ elif isinstance(node, UISwitchNode):
749
+ self._render_switch(node, py)
750
+ elif isinstance(node, UISelectNode):
751
+ self._render_select(node, py)
752
+ elif isinstance(node, UITableNode):
753
+ self._render_table(node, py)
754
+ elif isinstance(node, UIProgressNode):
755
+ self._render_progress(node, py)
756
+ elif isinstance(node, UITreeNode):
757
+ self._render_tree(node, py)
758
+ elif isinstance(node, UILogNode):
759
+ self._render_log(node, py)
760
+ elif isinstance(node, UIMarkdownNode):
761
+ self._render_markdown(node, py)
762
+ elif isinstance(node, UIHeaderNode):
763
+ self._render_header(node, py)
764
+ elif isinstance(node, UIFooterNode):
765
+ self._render_footer(node, py)
766
+ elif isinstance(node, UIRuleNode):
767
+ self._render_rule(node, py)
768
+ elif isinstance(node, UILoadingNode):
769
+ self._render_loading(node, py)
770
+ elif isinstance(node, UIImageNode):
771
+ self._render_image(node, py)
772
+ elif isinstance(node, UILinkNode):
773
+ self._render_link(node, py)
774
+ elif isinstance(node, UIBadgeNode):
775
+ self._render_badge(node, py)
776
+ elif isinstance(node, UIListNode):
777
+ self._render_list(node, py)
778
+ elif isinstance(node, UIItemNode):
779
+ self._render_item(node, py)
780
+ elif isinstance(node, UIMenuNode):
781
+ self._render_menu(node, py)
782
+ elif isinstance(node, UIOptionNode):
783
+ self._render_option(node, py)
784
+ elif isinstance(node, UIDividedBoxNode):
785
+ self._render_dividedbox(node, py)
786
+ elif isinstance(node, UIRadioNode):
787
+ self._render_radio(node, py)
788
+ # Component library
789
+ elif isinstance(node, UICarouselNode):
790
+ self._render_carousel(node, py)
791
+ elif isinstance(node, UISlideNode):
792
+ self._render_slide(node, py)
793
+ elif isinstance(node, UIStepperNode):
794
+ self._render_stepper(node, py)
795
+ elif isinstance(node, UIStepNode):
796
+ self._render_step(node, py)
797
+ elif isinstance(node, UICalendarNode):
798
+ self._render_calendar(node, py)
799
+ elif isinstance(node, UIDatePickerNode):
800
+ self._render_date_picker(node, py)
801
+ elif isinstance(node, UIToastNode):
802
+ self._render_toast(node, py)
803
+ elif isinstance(node, UIToastContainerNode):
804
+ self._render_toast_container(node, py)
805
+ # Quantum passthrough
806
+ elif isinstance(node, SetNode):
807
+ py.comment(f'q:set {node.name} = {node.value}')
808
+
809
+ def _render_children(self, children: list, py: PyBuilder):
810
+ """Render children inside an already-opened `with` block.
811
+
812
+ Emits `pass` when the children produced no statement (empty container,
813
+ or only comment lines such as a ui:toast) so the block stays valid
814
+ Python.
815
+ """
816
+ start = len(py._lines)
817
+ for child in children:
818
+ self._render_node(child, py)
819
+ if not self._has_statement('\n'.join(py._lines[start:])):
820
+ py.line('pass')
821
+
822
+ # ------------------------------------------------------------------
823
+ # Container renders
824
+ # ------------------------------------------------------------------
825
+
826
+ def _render_window(self, node: UIWindowNode, py: PyBuilder):
827
+ widget_id = self._next_id('window')
828
+ attrs = self._get_widget_attrs(node, widget_id)
829
+ attrs_str = self._format_widget_attrs(attrs)
830
+
831
+ if attrs_str:
832
+ py.line(f'with Vertical({attrs_str}):')
833
+ else:
834
+ py.line(f'with Vertical():')
835
+ py.indent()
836
+ self._render_children(node.children, py)
837
+ py.dedent()
838
+
839
+ def _render_hbox(self, node: UIHBoxNode, py: PyBuilder):
840
+ widget_id = self._next_id('hbox')
841
+ attrs = self._get_widget_attrs(node, widget_id)
842
+ attrs_str = self._format_widget_attrs(attrs)
843
+
844
+ if attrs_str:
845
+ py.line(f'with Horizontal({attrs_str}):')
846
+ else:
847
+ py.line(f'with Horizontal():')
848
+ py.indent()
849
+ self._render_children(node.children, py)
850
+ py.dedent()
851
+
852
+ def _render_vbox(self, node: UIVBoxNode, py: PyBuilder):
853
+ widget_id = self._next_id('vbox')
854
+ attrs = self._get_widget_attrs(node, widget_id)
855
+ attrs_str = self._format_widget_attrs(attrs)
856
+
857
+ if attrs_str:
858
+ py.line(f'with Vertical({attrs_str}):')
859
+ else:
860
+ py.line(f'with Vertical():')
861
+ py.indent()
862
+ self._render_children(node.children, py)
863
+ py.dedent()
864
+
865
+ def _render_panel(self, node: UIPanelNode, py: PyBuilder):
866
+ widget_id = self._next_id('panel')
867
+ attrs = self._get_widget_attrs(node, widget_id, 'q-panel')
868
+ attrs_str = self._format_widget_attrs(attrs)
869
+
870
+ py.line(f'with Vertical({attrs_str}):')
871
+ py.indent()
872
+ if node.title:
873
+ py.line(f'yield Static({py_string(node.title)}, classes="q-panel-title")')
874
+ self._render_children(node.children, py)
875
+ py.dedent()
876
+
877
+ def _render_tabpanel(self, node: UITabPanelNode, py: PyBuilder):
878
+ widget_id = self._next_id('tabs')
879
+ attrs = self._get_widget_attrs(node, widget_id)
880
+ attrs_str = self._format_widget_attrs(attrs)
881
+
882
+ if attrs_str:
883
+ py.line(f'with TabbedContent({attrs_str}):')
884
+ else:
885
+ py.line(f'with TabbedContent():')
886
+ py.indent()
887
+ tabs = [c for c in node.children if isinstance(c, UITabNode)]
888
+ for tab in tabs:
889
+ py.line(f'with TabPane({py_string(tab.title)}):')
890
+ py.indent()
891
+ self._render_children(tab.children, py)
892
+ py.dedent()
893
+ py.dedent()
894
+
895
+ def _render_tab(self, node: UITabNode, py: PyBuilder):
896
+ # Standalone tab (should be inside tabpanel normally)
897
+ widget_id = self._next_id('tab')
898
+ attrs = self._get_widget_attrs(node, widget_id)
899
+ attrs_str = self._format_widget_attrs(attrs)
900
+
901
+ if attrs_str:
902
+ py.line(f'with Vertical({attrs_str}):')
903
+ else:
904
+ py.line(f'with Vertical():')
905
+ py.indent()
906
+ self._render_children(node.children, py)
907
+ py.dedent()
908
+
909
+ def _render_grid(self, node: UIGridNode, py: PyBuilder):
910
+ widget_id = self._next_id('grid')
911
+ attrs = self._get_widget_attrs(node, widget_id)
912
+ attrs_str = self._format_widget_attrs(attrs)
913
+
914
+ if attrs_str:
915
+ py.line(f'with Grid({attrs_str}):')
916
+ else:
917
+ py.line(f'with Grid():')
918
+ py.indent()
919
+ self._render_children(node.children, py)
920
+ py.dedent()
921
+
922
+ def _render_accordion(self, node: UIAccordionNode, py: PyBuilder):
923
+ # Render sections as individual Collapsible widgets
924
+ for child in node.children:
925
+ self._render_node(child, py)
926
+
927
+ def _render_section(self, node: UISectionNode, py: PyBuilder):
928
+ widget_id = self._next_id('section')
929
+ attrs = self._get_widget_attrs(node, widget_id)
930
+
931
+ collapsed = 'False' if node.expanded else 'True'
932
+ attrs_parts = [f'title={py_string(node.title)}', f'collapsed={collapsed}']
933
+ if 'id' in attrs:
934
+ attrs_parts.append(f'id={py_string(attrs["id"])}')
935
+ if 'classes' in attrs:
936
+ attrs_parts.append(f'classes={py_string(attrs["classes"])}')
937
+
938
+ py.line(f'with Collapsible({", ".join(attrs_parts)}):')
939
+ py.indent()
940
+ self._render_children(node.children, py)
941
+ py.dedent()
942
+
943
+ def _render_scrollbox(self, node: UIScrollBoxNode, py: PyBuilder):
944
+ widget_id = self._next_id('scroll')
945
+ attrs = self._get_widget_attrs(node, widget_id)
946
+ attrs_str = self._format_widget_attrs(attrs)
947
+
948
+ if attrs_str:
949
+ py.line(f'with ScrollableContainer({attrs_str}):')
950
+ else:
951
+ py.line(f'with ScrollableContainer():')
952
+ py.indent()
953
+ self._render_children(node.children, py)
954
+ py.dedent()
955
+
956
+ def _render_form(self, node: UIFormNode, py: PyBuilder):
957
+ widget_id = self._next_id('form')
958
+ attrs = self._get_widget_attrs(node, widget_id)
959
+ attrs_str = self._format_widget_attrs(attrs)
960
+
961
+ if attrs_str:
962
+ py.line(f'with Vertical({attrs_str}):')
963
+ else:
964
+ py.line(f'with Vertical():')
965
+ py.indent()
966
+ self._render_children(node.children, py)
967
+ py.dedent()
968
+
969
+ def _render_formitem(self, node: UIFormItemNode, py: PyBuilder):
970
+ widget_id = self._next_id('formitem')
971
+ attrs = self._get_widget_attrs(node, widget_id)
972
+ attrs_str = self._format_widget_attrs(attrs)
973
+
974
+ if attrs_str:
975
+ py.line(f'with Horizontal({attrs_str}):')
976
+ else:
977
+ py.line(f'with Horizontal():')
978
+ py.indent()
979
+ if node.label:
980
+ py.line(f'yield Label({py_string(node.label)})')
981
+ self._render_children(node.children, py)
982
+ py.dedent()
983
+
984
+ def _render_spacer(self, node: UISpacerNode, py: PyBuilder):
985
+ widget_id = self._next_id('spacer')
986
+ py.line(f'yield Static("", id={py_string(widget_id)})')
987
+ # Add CSS rule for spacer
988
+ self._css_rules.append(f'#{widget_id} {{ height: 1; }}')
989
+
990
+ def _render_dividedbox(self, node: UIDividedBoxNode, py: PyBuilder):
991
+ widget_id = self._next_id('divided')
992
+ attrs = self._get_widget_attrs(node, widget_id)
993
+ attrs_str = self._format_widget_attrs(attrs)
994
+
995
+ container = 'Horizontal' if node.direction == 'horizontal' else 'Vertical'
996
+ if attrs_str:
997
+ py.line(f'with {container}({attrs_str}):')
998
+ else:
999
+ py.line(f'with {container}():')
1000
+ py.indent()
1001
+ self._render_children(node.children, py)
1002
+ py.dedent()
1003
+
1004
+ # ------------------------------------------------------------------
1005
+ # Widget renders
1006
+ # ------------------------------------------------------------------
1007
+
1008
+ def _render_text(self, node: UITextNode, py: PyBuilder):
1009
+ widget_id = self._next_id('text')
1010
+ attrs = self._get_widget_attrs(node, widget_id)
1011
+
1012
+ # Handle font size (limited in Textual)
1013
+ if node.size:
1014
+ self._features_used.add('font_size')
1015
+
1016
+ # Handle weight with text-style CSS
1017
+ if node.weight == 'bold':
1018
+ self._css_rules.append(f'#{widget_id} {{ text-style: bold; }}')
1019
+
1020
+ parts = [py_string(node.content)]
1021
+ if 'id' in attrs:
1022
+ parts.append(f'id={py_string(attrs["id"])}')
1023
+ if 'classes' in attrs:
1024
+ parts.append(f'classes={py_string(attrs["classes"])}')
1025
+
1026
+ py.line(f'yield Static({", ".join(parts)})')
1027
+
1028
+ def _render_button(self, node: UIButtonNode, py: PyBuilder):
1029
+ widget_id = self._next_id('btn')
1030
+ attrs = self._get_widget_attrs(node, widget_id)
1031
+
1032
+ # Map HTML variants to Textual variants
1033
+ # Textual accepts: default, error, primary, success, warning
1034
+ variant_map = {
1035
+ 'secondary': 'default',
1036
+ 'danger': 'error',
1037
+ 'primary': 'primary',
1038
+ 'success': 'success',
1039
+ 'warning': 'warning',
1040
+ }
1041
+
1042
+ parts = [py_string(node.content)]
1043
+ if node.variant:
1044
+ mapped_variant = variant_map.get(node.variant, 'default')
1045
+ parts.append(f'variant={py_string(mapped_variant)}')
1046
+ if 'id' in attrs:
1047
+ parts.append(f'id={py_string(attrs["id"])}')
1048
+ if 'classes' in attrs:
1049
+ parts.append(f'classes={py_string(attrs["classes"])}')
1050
+
1051
+ py.line(f'yield Button({", ".join(parts)})')
1052
+
1053
+ def _render_input(self, node: UIInputNode, py: PyBuilder):
1054
+ widget_id = self._next_id('input')
1055
+ attrs = self._get_widget_attrs(node, widget_id)
1056
+
1057
+ parts = []
1058
+ if node.placeholder:
1059
+ parts.append(f'placeholder={py_string(node.placeholder)}')
1060
+ if 'id' in attrs:
1061
+ parts.append(f'id={py_string(attrs["id"])}')
1062
+ if 'classes' in attrs:
1063
+ parts.append(f'classes={py_string(attrs["classes"])}')
1064
+
1065
+ py.line(f'yield Input({", ".join(parts)})')
1066
+
1067
+ def _render_checkbox(self, node: UICheckboxNode, py: PyBuilder):
1068
+ widget_id = self._next_id('cb')
1069
+ attrs = self._get_widget_attrs(node, widget_id)
1070
+
1071
+ parts = []
1072
+ if node.label:
1073
+ parts.append(py_string(node.label))
1074
+ if 'id' in attrs:
1075
+ parts.append(f'id={py_string(attrs["id"])}')
1076
+ if 'classes' in attrs:
1077
+ parts.append(f'classes={py_string(attrs["classes"])}')
1078
+
1079
+ py.line(f'yield Checkbox({", ".join(parts)})')
1080
+
1081
+ def _render_radio(self, node: UIRadioNode, py: PyBuilder):
1082
+ widget_id = self._next_id('radio')
1083
+ attrs = self._get_widget_attrs(node, widget_id)
1084
+ attrs_str = self._format_widget_attrs(attrs)
1085
+
1086
+ # RadioSet in Textual or OptionList
1087
+ if node.options:
1088
+ opts = [o.strip() for o in node.options.split(',')]
1089
+ opts_str = ', '.join(py_string(o) for o in opts)
1090
+ py.line(f'yield OptionList({opts_str})')
1091
+ else:
1092
+ py.line(f'yield OptionList()')
1093
+
1094
+ def _render_switch(self, node: UISwitchNode, py: PyBuilder):
1095
+ widget_id = self._next_id('sw')
1096
+ attrs = self._get_widget_attrs(node, widget_id)
1097
+
1098
+ parts = []
1099
+ if 'id' in attrs:
1100
+ parts.append(f'id={py_string(attrs["id"])}')
1101
+ if 'classes' in attrs:
1102
+ parts.append(f'classes={py_string(attrs["classes"])}')
1103
+
1104
+ py.line(f'yield Switch({", ".join(parts)})')
1105
+
1106
+ def _render_select(self, node: UISelectNode, py: PyBuilder):
1107
+ widget_id = self._next_id('sel')
1108
+ attrs = self._get_widget_attrs(node, widget_id)
1109
+
1110
+ if node.options:
1111
+ opts = [o.strip() for o in node.options.split(',')]
1112
+ opts_str = ', '.join(f'({py_string(o)}, {py_string(o)})' for o in opts)
1113
+ py.line(f'yield Select([{opts_str}])')
1114
+ else:
1115
+ py.line(f'yield Select([])')
1116
+
1117
+ def _render_table(self, node: UITableNode, py: PyBuilder):
1118
+ widget_id = self._next_id('table')
1119
+ attrs = self._get_widget_attrs(node, widget_id)
1120
+
1121
+ parts = []
1122
+ if 'id' in attrs:
1123
+ parts.append(f'id={py_string(attrs["id"])}')
1124
+ if 'classes' in attrs:
1125
+ parts.append(f'classes={py_string(attrs["classes"])}')
1126
+
1127
+ py.line(f'yield DataTable({", ".join(parts)})')
1128
+
1129
+ def _render_progress(self, node: UIProgressNode, py: PyBuilder):
1130
+ widget_id = self._next_id('prog')
1131
+ attrs = self._get_widget_attrs(node, widget_id)
1132
+
1133
+ parts = []
1134
+ if 'id' in attrs:
1135
+ parts.append(f'id={py_string(attrs["id"])}')
1136
+ if 'classes' in attrs:
1137
+ parts.append(f'classes={py_string(attrs["classes"])}')
1138
+
1139
+ py.line(f'yield ProgressBar({", ".join(parts)})')
1140
+
1141
+ def _render_tree(self, node: UITreeNode, py: PyBuilder):
1142
+ widget_id = self._next_id('tree')
1143
+ attrs = self._get_widget_attrs(node, widget_id)
1144
+
1145
+ parts = [py_string("Tree")]
1146
+ if 'id' in attrs:
1147
+ parts.append(f'id={py_string(attrs["id"])}')
1148
+ if 'classes' in attrs:
1149
+ parts.append(f'classes={py_string(attrs["classes"])}')
1150
+
1151
+ py.line(f'yield Tree({", ".join(parts)})')
1152
+
1153
+ def _render_log(self, node: UILogNode, py: PyBuilder):
1154
+ widget_id = self._next_id('log')
1155
+ attrs = self._get_widget_attrs(node, widget_id)
1156
+
1157
+ parts = []
1158
+ if 'id' in attrs:
1159
+ parts.append(f'id={py_string(attrs["id"])}')
1160
+ if 'classes' in attrs:
1161
+ parts.append(f'classes={py_string(attrs["classes"])}')
1162
+
1163
+ py.line(f'yield RichLog({", ".join(parts)})')
1164
+
1165
+ def _render_markdown(self, node: UIMarkdownNode, py: PyBuilder):
1166
+ widget_id = self._next_id('md')
1167
+ attrs = self._get_widget_attrs(node, widget_id)
1168
+
1169
+ parts = [py_string(node.content)]
1170
+ if 'id' in attrs:
1171
+ parts.append(f'id={py_string(attrs["id"])}')
1172
+ if 'classes' in attrs:
1173
+ parts.append(f'classes={py_string(attrs["classes"])}')
1174
+
1175
+ py.line(f'yield Markdown({", ".join(parts)})')
1176
+
1177
+ def _render_header(self, node: UIHeaderNode, py: PyBuilder):
1178
+ py.line(f'yield Header()')
1179
+
1180
+ def _render_footer(self, node: UIFooterNode, py: PyBuilder):
1181
+ py.line(f'yield Footer()')
1182
+
1183
+ def _render_rule(self, node: UIRuleNode, py: PyBuilder):
1184
+ py.line(f'yield Rule()')
1185
+
1186
+ def _render_loading(self, node: UILoadingNode, py: PyBuilder):
1187
+ widget_id = self._next_id('loading')
1188
+ attrs = self._get_widget_attrs(node, widget_id)
1189
+
1190
+ parts = []
1191
+ if 'id' in attrs:
1192
+ parts.append(f'id={py_string(attrs["id"])}')
1193
+ if 'classes' in attrs:
1194
+ parts.append(f'classes={py_string(attrs["classes"])}')
1195
+
1196
+ py.line(f'yield LoadingIndicator({", ".join(parts)})')
1197
+
1198
+ def _render_image(self, node: UIImageNode, py: PyBuilder):
1199
+ """Render image as placeholder text (graceful degradation)."""
1200
+ self._features_used.add('image')
1201
+ widget_id = self._next_id('img')
1202
+
1203
+ # Create descriptive placeholder
1204
+ src = node.src or ''
1205
+ alt = node.alt or src.split('/')[-1] if src else 'image'
1206
+ placeholder = f"🖼 [{alt}]"
1207
+
1208
+ py.line(f'yield Static({py_string(placeholder)}, id={py_string(widget_id)}, classes="q-image-placeholder")')
1209
+
1210
+ def _render_link(self, node: UILinkNode, py: PyBuilder):
1211
+ """Render link as styled text (graceful degradation)."""
1212
+ if node.external:
1213
+ self._features_used.add('link_external')
1214
+
1215
+ widget_id = self._next_id('link')
1216
+ content = node.content
1217
+ if node.to:
1218
+ content = f"{content} → {node.to}"
1219
+
1220
+ py.line(f'yield Static({py_string(content)}, id={py_string(widget_id)}, classes="q-link")')
1221
+
1222
+ def _render_badge(self, node: UIBadgeNode, py: PyBuilder):
1223
+ widget_id = self._next_id('badge')
1224
+ classes = 'q-badge'
1225
+ if node.variant:
1226
+ classes += f' q-badge-{node.variant}'
1227
+
1228
+ py.line(f'yield Static({py_string(node.content)}, id={py_string(widget_id)}, classes={py_string(classes)})')
1229
+
1230
+ def _render_list(self, node: UIListNode, py: PyBuilder):
1231
+ widget_id = self._next_id('list')
1232
+ attrs = self._get_widget_attrs(node, widget_id)
1233
+ attrs_str = self._format_widget_attrs(attrs)
1234
+
1235
+ if attrs_str:
1236
+ py.line(f'with Vertical({attrs_str}):')
1237
+ else:
1238
+ py.line(f'with Vertical():')
1239
+ py.indent()
1240
+ self._render_children(node.children, py)
1241
+ py.dedent()
1242
+
1243
+ def _render_item(self, node: UIItemNode, py: PyBuilder):
1244
+ widget_id = self._next_id('item')
1245
+ attrs = self._get_widget_attrs(node, widget_id)
1246
+ attrs_str = self._format_widget_attrs(attrs)
1247
+
1248
+ if attrs_str:
1249
+ py.line(f'with Horizontal({attrs_str}):')
1250
+ else:
1251
+ py.line(f'with Horizontal():')
1252
+ py.indent()
1253
+ self._render_children(node.children, py)
1254
+ py.dedent()
1255
+
1256
+ def _render_menu(self, node: UIMenuNode, py: PyBuilder):
1257
+ # OptionList for menu
1258
+ widget_id = self._next_id('menu')
1259
+ options = [c for c in node.children if isinstance(c, UIOptionNode)]
1260
+ if options:
1261
+ opts_str = ', '.join(py_string(o.label or o.value or '') for o in options)
1262
+ py.line(f'yield OptionList({opts_str}, id={py_string(widget_id)})')
1263
+ else:
1264
+ py.line(f'yield OptionList(id={py_string(widget_id)})')
1265
+
1266
+ def _render_option(self, node: UIOptionNode, py: PyBuilder):
1267
+ # Standalone options are unusual, render as Static
1268
+ widget_id = self._next_id('opt')
1269
+ py.line(f'yield Static({py_string(node.label or node.value or "")}, id={py_string(widget_id)})')
1270
+
1271
+ # ------------------------------------------------------------------
1272
+ # Component library renders
1273
+ # ------------------------------------------------------------------
1274
+
1275
+ def _pane_ids(self, owner_id: str, panes: list, pane_type: type,
1276
+ prefix: str) -> List[str]:
1277
+ """Stable ids for ContentSwitcher panes.
1278
+
1279
+ A pane keeps its own ui_id when it has one; anything else gets a
1280
+ generated id so we never duplicate a child widget's id.
1281
+ """
1282
+ ids = []
1283
+ for index, pane in enumerate(panes):
1284
+ own = getattr(pane, 'ui_id', None) if isinstance(pane, pane_type) else None
1285
+ ids.append(own or f'{owner_id}__{prefix}_{index}')
1286
+ return ids
1287
+
1288
+ def _render_carousel(self, node: UICarouselNode, py: PyBuilder):
1289
+ """One slide visible at a time (ContentSwitcher) + arrows/indicator.
1290
+
1291
+ A terminal has no horizontal swipe, so navigation is the arrow
1292
+ buttons (Tab/Enter), ctrl+left / ctrl+right, and the auto-play timer.
1293
+ """
1294
+ attrs = self._get_widget_attrs(node, self._next_id('carousel'), 'q-carousel')
1295
+ cid = attrs['id']
1296
+
1297
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1298
+ py.indent()
1299
+
1300
+ slides = [c for c in node.children if isinstance(c, UISlideNode)]
1301
+ if not slides:
1302
+ # Keep any non-slide content instead of dropping it
1303
+ slides = list(node.children)
1304
+
1305
+ if not slides:
1306
+ py.line('yield Static("(carousel has no slides)", classes="q-empty")')
1307
+ py.dedent()
1308
+ return
1309
+
1310
+ slide_ids = self._pane_ids(cid, slides, UISlideNode, 'slide')
1311
+ self._need_import('from textual.widgets import ContentSwitcher')
1312
+
1313
+ py.line(f'with ContentSwitcher(initial={py_string(slide_ids[0])}, '
1314
+ f'id={py_string(cid + "__switcher")}):')
1315
+ py.indent()
1316
+ for slide_id, slide in zip(slide_ids, slides):
1317
+ if isinstance(slide, UISlideNode):
1318
+ self._render_slide(slide, py, pane_id=slide_id)
1319
+ else:
1320
+ py.line(f'with Vertical(id={py_string(slide_id)}, classes="q-slide"):')
1321
+ py.indent()
1322
+ self._render_children([slide], py)
1323
+ py.dedent()
1324
+ py.dedent()
1325
+
1326
+ show_arrows = self._as_bool(node.show_arrows, True)
1327
+ show_indicators = self._as_bool(node.show_indicators, True)
1328
+ if show_arrows or show_indicators:
1329
+ py.line('with Horizontal(classes="q-carousel-nav"):')
1330
+ py.indent()
1331
+ if show_arrows:
1332
+ py.line(f'yield Button("<", id={py_string(cid + "__prev")}, '
1333
+ f'classes="q-carousel-arrow")')
1334
+ if show_indicators:
1335
+ py.line(f'yield Static({py_string(f"1 / {len(slides)}")}, '
1336
+ f'id={py_string(cid + "__indicator")}, '
1337
+ f'classes="q-carousel-indicator")')
1338
+ if show_arrows:
1339
+ py.line(f'yield Button(">", id={py_string(cid + "__next")}, '
1340
+ f'classes="q-carousel-arrow")')
1341
+ py.dedent()
1342
+
1343
+ py.dedent()
1344
+
1345
+ if node.animation and node.animation != 'slide':
1346
+ # Textual switches panes instantly; a fade has no terminal analogue
1347
+ self._features_used.add('carousel_fade')
1348
+
1349
+ self._carousels[cid] = {
1350
+ 'slides': slide_ids,
1351
+ 'index': max(0, min(self._as_int(node.current, 0), len(slides) - 1)),
1352
+ 'loop': self._as_bool(node.loop, True),
1353
+ 'auto_play': self._as_bool(node.auto_play, False),
1354
+ 'interval': max(0.1, self._as_int(node.interval, 5000) / 1000.0),
1355
+ 'bind': node.bind,
1356
+ }
1357
+
1358
+ def _render_slide(self, node: UISlideNode, py: PyBuilder,
1359
+ pane_id: Optional[str] = None):
1360
+ """A slide is a plain container; inside a carousel it is a switcher pane."""
1361
+ attrs = self._get_widget_attrs(node, pane_id or self._next_id('slide'), 'q-slide')
1362
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1363
+ py.indent()
1364
+ self._render_children(node.children, py)
1365
+ py.dedent()
1366
+
1367
+ def _render_stepper(self, node: UIStepperNode, py: PyBuilder):
1368
+ """Indicator row/column + one step pane visible at a time + Prev/Next."""
1369
+ attrs = self._get_widget_attrs(node, self._next_id('stepper'), 'q-stepper')
1370
+ sid = attrs['id']
1371
+
1372
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1373
+ py.indent()
1374
+
1375
+ steps = [c for c in node.children if isinstance(c, UIStepNode)]
1376
+ if not steps:
1377
+ py.line('yield Static("(stepper has no steps)", classes="q-empty")')
1378
+ py.dedent()
1379
+ return
1380
+
1381
+ step_ids = self._pane_ids(sid, steps, UIStepNode, 'step')
1382
+ current = max(0, min(self._as_int(node.current, 0), len(steps) - 1))
1383
+ show_labels = self._as_bool(node.show_labels, True)
1384
+ clickable = self._as_bool(node.clickable, False)
1385
+ self._need_import('from textual.widgets import ContentSwitcher')
1386
+
1387
+ # A MESMA regra do adapter html. Aqui havia uma terceira versao —
1388
+ # `index < current or _as_bool(step.completed, False)` — que ignorava
1389
+ # `completed="false"` explicito, porque _as_bool colapsa "nao setado"
1390
+ # e "setado como false".
1391
+ concluidos = set(completed_step_indices(steps, current))
1392
+
1393
+ # Step indicators
1394
+ indicators = 'Horizontal' if node.orientation != 'vertical' else 'Vertical'
1395
+ py.line(f'with {indicators}(classes="q-stepper-indicators"):')
1396
+ py.indent()
1397
+ for index, step in enumerate(steps):
1398
+ # O adapter html usa `step.icon if step.icon else str(i + 1)` no
1399
+ # indicador. Aqui o icone era silenciosamente descartado — nem no
1400
+ # indicador, nem no painel. Atributo documentado que nao fazia
1401
+ # nada, sem aviso nenhum.
1402
+ marca = step.icon if step.icon else f'{index + 1}'
1403
+ label = marca
1404
+ if show_labels and step.title:
1405
+ label = f'{marca}. {step.title}'
1406
+ if self._as_bool(step.optional, False):
1407
+ label += ' (optional)'
1408
+ classes = 'q-step-indicator'
1409
+ if index == current:
1410
+ classes += ' q-step-active'
1411
+ elif index in concluidos:
1412
+ classes += ' q-step-done'
1413
+ widget = 'Button' if clickable else 'Static'
1414
+ py.line(f'yield {widget}({py_string(label)}, '
1415
+ f'id={py_string(f"{sid}__ind_{index}")}, '
1416
+ f'classes={py_string(classes)})')
1417
+ py.dedent()
1418
+
1419
+ # Step content
1420
+ py.line(f'with ContentSwitcher(initial={py_string(step_ids[current])}, '
1421
+ f'id={py_string(sid + "__switcher")}):')
1422
+ py.indent()
1423
+ for step_id, step in zip(step_ids, steps):
1424
+ self._render_step(step, py, pane_id=step_id)
1425
+ py.dedent()
1426
+
1427
+ # Navigation
1428
+ py.line('with Horizontal(classes="q-stepper-nav"):')
1429
+ py.indent()
1430
+ py.line(f'yield Button("Previous", id={py_string(sid + "__prev")})')
1431
+ py.line(f'yield Button("Next", variant="primary", id={py_string(sid + "__next")})')
1432
+ py.dedent()
1433
+
1434
+ py.dedent()
1435
+
1436
+ # `completed` e tri-state — nao setado, "true", "false" — e a marca
1437
+ # explicita nao muda quando o usuario navega, enquanto a posicional
1438
+ # muda. Guardar so um booleano por passo perderia a diferenca, que e
1439
+ # exatamente o que acontecia: _q_stepper_apply fazia
1440
+ # `set_class(position < index, "q-step-done")` e apagava, depois do
1441
+ # mount, o que o compose() tinha desenhado a partir de `completed`.
1442
+ # O atributo nao tinha efeito nenhum.
1443
+ explicito = {}
1444
+ for i, filho in enumerate(steps):
1445
+ if getattr(filho, 'completed', None) is not None:
1446
+ explicito[i] = self._as_bool(filho.completed, False)
1447
+
1448
+ self._steppers[sid] = {
1449
+ 'steps': step_ids,
1450
+ 'index': current,
1451
+ 'linear': self._as_bool(node.linear, True),
1452
+ 'bind': node.bind,
1453
+ 'explicit_done': explicito,
1454
+ }
1455
+
1456
+ def _render_step(self, node: UIStepNode, py: PyBuilder,
1457
+ pane_id: Optional[str] = None):
1458
+ """A step is a container; its title lives in the stepper indicator."""
1459
+ attrs = self._get_widget_attrs(node, pane_id or self._next_id('step'), 'q-step')
1460
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1461
+ py.indent()
1462
+ if node.title and pane_id is None:
1463
+ # Standalone step: nothing else shows the title
1464
+ py.line(f'yield Static({py_string(node.title)}, classes="q-step-title")')
1465
+ if node.description:
1466
+ py.line(f'yield Static({py_string(node.description)}, classes="q-step-description")')
1467
+ if node.error:
1468
+ py.line(f'yield Static({py_string(node.error)}, classes="q-step-error")')
1469
+ self._render_children(node.children, py)
1470
+ py.dedent()
1471
+
1472
+ def _render_calendar(self, node: UICalendarNode, py: PyBuilder):
1473
+ """Month grid as a DataTable: cell cursor selects a day, arrows change month."""
1474
+ attrs = self._get_widget_attrs(node, self._next_id('calendar'), 'q-calendar')
1475
+ cid = attrs['id']
1476
+ self._need_import('import calendar')
1477
+ self._need_import('from rich.text import Text')
1478
+ self._need_datetime('date')
1479
+
1480
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1481
+ py.indent()
1482
+ py.line('with Horizontal(classes="q-calendar-header"):')
1483
+ py.indent()
1484
+ py.line(f'yield Button("<", id={py_string(cid + "__prev")}, classes="q-calendar-nav")')
1485
+ py.line(f'yield Static("", id={py_string(cid + "__title")}, classes="q-calendar-title")')
1486
+ py.line(f'yield Button(">", id={py_string(cid + "__next")}, classes="q-calendar-nav")')
1487
+ py.dedent()
1488
+ py.line(f'yield DataTable(id={py_string(cid + "__grid")}, '
1489
+ f'cursor_type="cell", classes="q-calendar-grid")')
1490
+ py.line(f'yield Static("", id={py_string(cid + "__value")}, classes="q-calendar-value")')
1491
+ py.dedent()
1492
+
1493
+ mode = node.mode if node.mode in ('single', 'range', 'multiple') else 'single'
1494
+ self._calendars[cid] = {
1495
+ 'mode': mode,
1496
+ 'selected': self._split_dates(node.value),
1497
+ 'min': (node.min_date or '').strip(),
1498
+ 'max': (node.max_date or '').strip(),
1499
+ 'disabled_dates': self._split_dates(node.disabled_dates),
1500
+ 'disabled_days': [self._as_int(d, -1) for d in self._split_list(node.disabled_days)],
1501
+ 'week_numbers': self._as_bool(node.show_week_numbers, False),
1502
+ 'first_day': self._as_int(node.first_day_of_week, 0) % 7,
1503
+ 'locale': (node.locale or '').strip(),
1504
+ 'bind': node.bind,
1505
+ }
1506
+ if not self._as_bool(node.inline, True):
1507
+ # A popup calendar has no terminal analogue - it is always inline
1508
+ self._features_used.add('calendar_popup')
1509
+
1510
+ def _render_date_picker(self, node: UIDatePickerNode, py: PyBuilder):
1511
+ """Text input validated against the declared format, plus a clear button."""
1512
+ attrs = self._get_widget_attrs(node, self._next_id('datepicker'), 'q-date-picker')
1513
+ pid = attrs['id']
1514
+ self._need_datetime('date', 'datetime')
1515
+
1516
+ fmt = node.format or 'YYYY-MM-DD'
1517
+ disabled = self._as_bool(node.disabled, False)
1518
+
1519
+ py.line(f'with Vertical({self._format_widget_attrs(attrs)}):')
1520
+ py.indent()
1521
+ py.line('with Horizontal(classes="q-date-picker-row"):')
1522
+ py.indent()
1523
+ parts = []
1524
+ if node.value:
1525
+ parts.append(f'value={py_string(node.value)}')
1526
+ parts.append(f'placeholder={py_string(node.placeholder or "Select date")}')
1527
+ parts.append(f'id={py_string(pid + "__input")}')
1528
+ parts.append('classes="q-date-picker-input"')
1529
+ if disabled:
1530
+ parts.append('disabled=True')
1531
+ py.line(f'yield Input({", ".join(parts)})')
1532
+ if self._as_bool(node.clearable, True) and not disabled:
1533
+ py.line(f'yield Button("X", id={py_string(pid + "__clear")}, '
1534
+ f'classes="q-date-picker-clear")')
1535
+ py.dedent()
1536
+ py.line(f'yield Static({py_string(fmt)}, id={py_string(pid + "__hint")}, '
1537
+ f'classes="q-date-picker-hint")')
1538
+ py.dedent()
1539
+
1540
+ self._date_pickers[pid] = {
1541
+ 'format': fmt,
1542
+ 'strptime': self._to_strptime(fmt),
1543
+ 'min': (node.min_date or '').strip(),
1544
+ 'max': (node.max_date or '').strip(),
1545
+ 'required': self._as_bool(node.required, False),
1546
+ 'bind': node.bind,
1547
+ }
1548
+
1549
+ def _render_toast(self, node: UIToastNode, py: PyBuilder):
1550
+ """A ui:toast becomes a real Textual notification raised on mount."""
1551
+ index = len(self._toasts) + 1
1552
+ method = f'q_toast_{index}'
1553
+ variant = node.variant or 'info'
1554
+ severity = self.TOAST_SEVERITY.get(variant, 'information')
1555
+
1556
+ message = (node.message or '').strip()
1557
+ title = (node.title or '').strip()
1558
+ if not message:
1559
+ message, title = (title or variant.title()), ''
1560
+ if node.icon:
1561
+ # Textual notifications have no icon slot: prefix whatever is shown
1562
+ if title:
1563
+ title = f'{node.icon} {title}'
1564
+ else:
1565
+ message = f'{node.icon} {message}'
1566
+
1567
+ duration = self._as_int(node.duration, 3000)
1568
+ # Textual always auto-dismisses; duration="0" becomes a full day.
1569
+ timeout = 86400.0 if duration <= 0 else duration / 1000.0
1570
+
1571
+ if self._toast_first_position is None:
1572
+ self._toast_first_position = node.position
1573
+ if variant == 'success':
1574
+ # Textual only knows information/warning/error
1575
+ self._features_used.add('toast_success_severity')
1576
+ if not self._as_bool(node.dismissible, True):
1577
+ # Textual toasts are always click-dismissible
1578
+ self._features_used.add('toast_not_dismissible')
1579
+
1580
+ self._toasts.append({
1581
+ 'method': method,
1582
+ 'message': message,
1583
+ 'title': title,
1584
+ 'severity': severity,
1585
+ 'timeout': timeout,
1586
+ 'variant': variant,
1587
+ 'show': node.show,
1588
+ })
1589
+ py.comment(f'ui:toast ({variant}) -> self.{method}() / App.notify()')
1590
+
1591
+ def _render_toast_container(self, node: UIToastContainerNode, py: PyBuilder):
1592
+ """Textual owns a single ToastRack; the container positions and caps it."""
1593
+ position = node.position or 'top-right'
1594
+ self._toast_rack_position = position
1595
+ self._max_toasts = max(0, self._as_int(node.max_toasts, 5))
1596
+ py.comment(f'ui:toast-container -> ToastRack (position={position}, '
1597
+ f'max-toasts={self._max_toasts})')
1598
+
1599
+ # ------------------------------------------------------------------
1600
+ # Value helpers for the component library
1601
+ # ------------------------------------------------------------------
1602
+
1603
+ @staticmethod
1604
+ def _split_list(value) -> List[str]:
1605
+ if not value:
1606
+ return []
1607
+ if isinstance(value, (list, tuple)):
1608
+ items = list(value)
1609
+ else:
1610
+ items = str(value).split(',')
1611
+ return [str(item).strip() for item in items if str(item).strip()]
1612
+
1613
+ def _split_dates(self, value) -> List[str]:
1614
+ """Keep only ISO dates - anything else would crash date.fromisoformat."""
1615
+ dates = []
1616
+ for item in self._split_list(value):
1617
+ try:
1618
+ _date.fromisoformat(item)
1619
+ except ValueError:
1620
+ continue
1621
+ dates.append(item)
1622
+ return dates
1623
+
1624
+ # Longest token first so YYYY wins over YY and MMMM over MM.
1625
+ _FORMAT_TOKENS = (
1626
+ ('YYYY', '%Y'), ('YY', '%y'),
1627
+ ('MMMM', '%B'), ('MMM', '%b'), ('MM', '%m'),
1628
+ ('DDDD', '%A'), ('DDD', '%a'), ('DD', '%d'),
1629
+ ('HH', '%H'), ('mm', '%M'), ('ss', '%S'),
1630
+ )
1631
+
1632
+ @classmethod
1633
+ def _to_strptime(cls, fmt: str) -> str:
1634
+ """Translate a date-picker display format into a strptime pattern."""
1635
+ out = []
1636
+ i = 0
1637
+ while i < len(fmt):
1638
+ for token, code in cls._FORMAT_TOKENS:
1639
+ if fmt.startswith(token, i):
1640
+ out.append(code)
1641
+ i += len(token)
1642
+ break
1643
+ else:
1644
+ if fmt[i] == '%':
1645
+ out.append('%%')
1646
+ else:
1647
+ out.append(fmt[i])
1648
+ i += 1
1649
+ return ''.join(out)
1650
+
1651
+ # ------------------------------------------------------------------
1652
+ # Generated class body: BINDINGS and methods
1653
+ # ------------------------------------------------------------------
1654
+
1655
+ def _build_bindings(self) -> str:
1656
+ """Keyboard navigation for the components that need it."""
1657
+ rows = []
1658
+ if self._carousels:
1659
+ rows.append('("ctrl+right", "q_carousel_next", "Next slide"),')
1660
+ rows.append('("ctrl+left", "q_carousel_prev", "Previous slide"),')
1661
+ if self._steppers:
1662
+ rows.append('("ctrl+down", "q_stepper_next", "Next step"),')
1663
+ rows.append('("ctrl+up", "q_stepper_prev", "Previous step"),')
1664
+ if not rows:
1665
+ return ''
1666
+ body = '\n'.join(f' {row}' for row in rows)
1667
+ return f' BINDINGS = [\n{body}\n ]\n'
1668
+
1669
+ def _emit_block(self, py: PyBuilder, source: str):
1670
+ """Emit a verbatim source block at the builder's current indent."""
1671
+ for line in source.strip('\n').split('\n'):
1672
+ if line.strip():
1673
+ py.line(line)
1674
+ else:
1675
+ py.blank()
1676
+
1677
+ def _build_methods(self) -> str:
1678
+ """Class body emitted after compose(): state tables, handlers, actions."""
1679
+ stateful = bool(self._carousels or self._steppers
1680
+ or self._calendars or self._date_pickers)
1681
+ if not stateful and not self._toasts:
1682
+ return ' pass'
1683
+
1684
+ py = PyBuilder()
1685
+ py._indent = 1
1686
+
1687
+ # --- static configuration tables ---
1688
+ if self._carousels:
1689
+ py.line(f'Q_CAROUSELS = {self._carousels!r}')
1690
+ py.blank()
1691
+ if self._steppers:
1692
+ py.line(f'Q_STEPPERS = {self._steppers!r}')
1693
+ py.blank()
1694
+ if self._calendars:
1695
+ py.line(f'Q_CALENDARS = {self._calendars!r}')
1696
+ py.blank()
1697
+ if self._date_pickers:
1698
+ py.line(f'Q_DATE_PICKERS = {self._date_pickers!r}')
1699
+ py.blank()
1700
+
1701
+ # --- on_mount: build runtime state, then initialise each component ---
1702
+ py.line('def on_mount(self) -> None:')
1703
+ py.indent()
1704
+ if stateful:
1705
+ py.line('self._q_state = {}')
1706
+ if self._carousels:
1707
+ py.line('self._q_carousels = {cid: dict(spec, slides=list(spec["slides"]))')
1708
+ py.line(' for cid, spec in self.Q_CAROUSELS.items()}')
1709
+ if self._steppers:
1710
+ py.line('self._q_steppers = {sid: dict(spec, steps=list(spec["steps"]))')
1711
+ py.line(' for sid, spec in self.Q_STEPPERS.items()}')
1712
+ if self._calendars:
1713
+ py.line('self._q_calendars = {}')
1714
+ py.line('for cid, spec in self.Q_CALENDARS.items():')
1715
+ py.indent()
1716
+ py.line('self._q_calendars[cid] = dict(')
1717
+ py.line(' spec,')
1718
+ py.line(' min=self._q_date(spec["min"]),')
1719
+ py.line(' max=self._q_date(spec["max"]),')
1720
+ py.line(' selected=[self._q_date(day) for day in spec["selected"]],')
1721
+ py.line(' disabled_dates=set(spec["disabled_dates"]),')
1722
+ py.line(' disabled_days=set(spec["disabled_days"]),')
1723
+ py.line(' cells=[],')
1724
+ py.line(')')
1725
+ py.dedent()
1726
+ if self._date_pickers:
1727
+ py.line('self._q_date_pickers = {}')
1728
+ py.line('for pid, spec in self.Q_DATE_PICKERS.items():')
1729
+ py.indent()
1730
+ py.line('self._q_date_pickers[pid] = dict(')
1731
+ py.line(' spec,')
1732
+ py.line(' min=self._q_date(spec["min"]),')
1733
+ py.line(' max=self._q_date(spec["max"]),')
1734
+ py.line(')')
1735
+ py.dedent()
1736
+ if self._carousels:
1737
+ py.line('self._q_carousel_init()')
1738
+ if self._steppers:
1739
+ py.line('self._q_stepper_init()')
1740
+ if self._calendars:
1741
+ py.line('self._q_calendar_init()')
1742
+ if self._date_pickers:
1743
+ py.line('self._q_date_picker_init()')
1744
+ if self._toasts:
1745
+ py.line('self._q_show_toasts()')
1746
+ py.dedent()
1747
+ py.blank()
1748
+
1749
+ # --- shared date helper ---
1750
+ if self._calendars or self._date_pickers:
1751
+ py.line('def _q_date(self, value):')
1752
+ py.indent()
1753
+ py.line('"""Parse an ISO date attribute, ignoring unusable values."""')
1754
+ py.line('try:')
1755
+ py.line(' return date.fromisoformat(value) if value else None')
1756
+ py.line('except (TypeError, ValueError):')
1757
+ py.line(' return None')
1758
+ py.dedent()
1759
+ py.blank()
1760
+
1761
+ # --- button dispatch shared by carousel / stepper / calendar / picker ---
1762
+ if self._carousels or self._steppers or self._calendars or self._date_pickers:
1763
+ py.line('def on_button_pressed(self, event: Button.Pressed) -> None:')
1764
+ py.indent()
1765
+ py.line('owner, _, action = (event.button.id or "").rpartition("__")')
1766
+ py.line('if not owner:')
1767
+ py.line(' return')
1768
+ if self._carousels:
1769
+ py.line('if owner in self._q_carousels:')
1770
+ py.line(' if action in ("prev", "next"):')
1771
+ py.line(' self._q_carousel_go(owner, 1 if action == "next" else -1)')
1772
+ py.line(' return')
1773
+ if self._steppers:
1774
+ py.line('if owner in self._q_steppers:')
1775
+ py.line(' index = self._q_steppers[owner]["index"]')
1776
+ py.line(' if action == "next":')
1777
+ py.line(' self._q_stepper_goto(owner, index + 1)')
1778
+ py.line(' elif action == "prev":')
1779
+ py.line(' self._q_stepper_goto(owner, index - 1)')
1780
+ py.line(' elif action.startswith("ind_"):')
1781
+ py.line(' self._q_stepper_goto(owner, int(action[4:]))')
1782
+ py.line(' return')
1783
+ if self._calendars:
1784
+ py.line('if owner in self._q_calendars:')
1785
+ py.line(' if action in ("prev", "next"):')
1786
+ py.line(' self._q_calendar_move(owner, 1 if action == "next" else -1)')
1787
+ py.line(' return')
1788
+ if self._date_pickers:
1789
+ py.line('if owner in self._q_date_pickers and action == "clear":')
1790
+ py.line(' for field in self.query("#" + owner + "__input"):')
1791
+ py.line(' field.value = ""')
1792
+ py.dedent()
1793
+ py.blank()
1794
+
1795
+ # --- per-component runtime ---
1796
+ for used, block in ((self._carousels, _CAROUSEL_METHODS),
1797
+ (self._steppers, _STEPPER_METHODS),
1798
+ (self._calendars, _CALENDAR_METHODS),
1799
+ (self._date_pickers, _DATE_PICKER_METHODS)):
1800
+ if used:
1801
+ self._emit_block(py, block)
1802
+ py.blank()
1803
+
1804
+ # --- toasts ---
1805
+ if self._toasts:
1806
+ shown = self._toasts if self._max_toasts is None else self._toasts[:self._max_toasts]
1807
+ py.line('def _q_show_toasts(self) -> None:')
1808
+ py.indent()
1809
+ py.line('"""Raise every declared ui:toast once the app is running."""')
1810
+ if not shown:
1811
+ py.line('return')
1812
+ # `show=` nomeia uma variavel de estado, e o binding virava um
1813
+ # COMENTARIO: `self.q_toast_1() # show="hasError"`. O toast
1814
+ # disparava incondicionalmente — um <ui:toast show="hasError">
1815
+ # aparecia em todo lancamento do app, tivesse erro ou nao. O
1816
+ # adapter html trata isso como "escondido ate a variavel ficar
1817
+ # verdadeira"; aqui vale o mesmo contrato.
1818
+ condicionais = []
1819
+ for toast in shown:
1820
+ if toast['show']:
1821
+ condicionais.append(toast)
1822
+ py.line(f'if self._q_truthy(self._q_state.get({py_string(toast["show"])})):')
1823
+ py.line(f' self.{toast["method"]}()')
1824
+ else:
1825
+ py.line(f'self.{toast["method"]}()')
1826
+ py.dedent()
1827
+ py.blank()
1828
+
1829
+ # E quando a variavel muda DEPOIS do mount, o toast aparece —
1830
+ # senao "escondido ate ficar verdadeiro" viraria "nunca".
1831
+ py.line('def _q_toast_on_state(self, name: str) -> None:')
1832
+ py.indent()
1833
+ py.line('"""Dispara os toasts cujo show= acabou de ficar verdadeiro."""')
1834
+ if not condicionais:
1835
+ py.line('return')
1836
+ else:
1837
+ py.line('if not self._q_truthy(self._q_state.get(name)):')
1838
+ py.line(' return')
1839
+ py.line('ja = getattr(self, "_q_toasts_shown", None)')
1840
+ py.line('if ja is None:')
1841
+ py.line(' ja = self._q_toasts_shown = set()')
1842
+ for toast in condicionais:
1843
+ nome = py_string(toast['show'])
1844
+ metodo = py_string(toast['method'])
1845
+ py.line(f'if name == {nome} and {metodo} not in ja:')
1846
+ py.line(f' ja.add({metodo})')
1847
+ py.line(f' self.{toast["method"]}()')
1848
+ py.dedent()
1849
+ py.blank()
1850
+ for toast in self._toasts:
1851
+ py.line(f'def {toast["method"]}(self) -> None:')
1852
+ py.indent()
1853
+ py.line(f'"""ui:toast variant={toast["variant"]}."""')
1854
+ args = [py_string(toast['message'])]
1855
+ if toast['title']:
1856
+ args.append(f'title={py_string(toast["title"])}')
1857
+ args.append(f'severity={py_string(toast["severity"])}')
1858
+ args.append(f'timeout={toast["timeout"]!r}')
1859
+ py.line(f'self.notify({", ".join(args)})')
1860
+ py.dedent()
1861
+ py.blank()
1862
+
1863
+ if stateful:
1864
+ self._emit_block(py, _STATE_ACCESSOR)
1865
+
1866
+ return py.build().rstrip()