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,392 @@
1
+ """`ui:*` inside a served page (UI-1, PLANO_V1.md U1).
2
+
3
+ The UI Engine used to be a separate world: `<q:application type="ui">`
4
+ compiled to a file, where each target translated the page's logic on its own.
5
+ Here the page's runtime does the logic — queries, actions, the SPEC's rules —
6
+ and this class only draws: it reuses the HTML adapter's drawing of each
7
+ `ui:*` element and hands everything else (q:loop, q:if, HTML, components)
8
+ back to the page renderer.
9
+
10
+ What changes from the standalone adapter:
11
+
12
+ - `{expressions}` are resolved by the page's runtime, and every resolved value
13
+ is HTML-escaped (the adapter's builder escapes nothing).
14
+ - Events are actions: `<ui:button on-click="save">` posts to the page's
15
+ `q:action name="save"`; `with="id={row.id}"` adds fields to that post.
16
+ `<ui:form on-submit="create">` is a form that posts its fields to `create`.
17
+ An event that names no q:action of the page is an error.
18
+ - The adapter's CSS for the elements comes once per page, without its global
19
+ reset (the page keeps its own styles).
20
+ """
21
+
22
+ import copy
23
+ import html
24
+ from typing import Any, Iterator, List, Optional
25
+
26
+ from quantum.core.features.ui_engine.src import ast_nodes as ui_nodes
27
+ from quantum.runtime.ui_html_adapter import UIHtmlAdapter
28
+ from quantum.runtime.ui_form_rules import FORM_CSS, form_context, generated_fields, prepare_field
29
+ from quantum.runtime.ui_html_templates import CSS_THEME, RESPONSIVE_CSS, HtmlBuilder
30
+ from quantum.runtime.ui_pager import PAGER_CSS, pager_items
31
+ from quantum.runtime.ui_table_edit import CELL_CSS, cell_field, edit_plan, sort_link
32
+ from quantum.core.xml_lines import tag_error
33
+
34
+ # Attributes that name an action or a field, never text to resolve. `source`
35
+ # is data (a list), resolved by source_rows().
36
+ _NOT_TEXT = {'on_click', 'on_submit', 'on_change', 'on_select', 'bind', 'children', 'send', 'source',
37
+ 'values'}
38
+
39
+
40
+ is_ui_node = ui_nodes.is_ui_node
41
+
42
+
43
+ def source_rows(page, node, where: str, default_var: str) -> Iterator[Any]:
44
+ """UI-5: once per row of a `source="{...}"`, with the row variable set as
45
+ q:loop sets it (`row`, and `row.field` for dict rows). A source that is not
46
+ a list is an error — never an empty table."""
47
+ fonte = (node.source or '').strip()
48
+ linhas = page._apply_databinding(fonte) if fonte.startswith('{') and fonte.endswith('}') else None
49
+ if not isinstance(linhas, list):
50
+ tipo = 'no {expression}' if linhas is None and not fonte.startswith('{') else type(linhas).__name__
51
+ raise ValueError(
52
+ f'<{where} source="{fonte}"> needs a list — a q:query or an array, as '
53
+ f'source="{{rows}}" — and got {tipo} (UI-5).')
54
+ var = node.as_var or default_var
55
+ ctx = page.context
56
+ originais = ctx.local_vars.copy()
57
+ try:
58
+ for linha in linhas:
59
+ ctx.set_variable(var, linha, scope='local')
60
+ if isinstance(linha, dict):
61
+ for campo, valor in linha.items():
62
+ ctx.set_variable(f'{var}.{campo}', valor, scope='local')
63
+ yield linha
64
+ finally:
65
+ ctx.local_vars = originais
66
+
67
+
68
+ def _single_name(expr) -> Optional[str]:
69
+ import re as _re
70
+ m = _re.fullmatch(r'\s*\{\s*(\w+)\s*\}\s*', expr or '')
71
+ return m.group(1) if m else None
72
+
73
+
74
+ def cell_value(linha: Any, coluna) -> str:
75
+ """UI-5: the text of a `<ui:column key>` cell; a key the row lacks is an error."""
76
+ if not isinstance(linha, dict):
77
+ return '' if linha is None else str(linha)
78
+ if coluna.key not in linha:
79
+ raise ValueError(
80
+ f'<ui:column key="{coluna.key}"> (UI-5): the row has no field "{coluna.key}" '
81
+ f'(fields: {", ".join(map(str, linha)) or "none"}).')
82
+ valor = linha[coluna.key]
83
+ return '' if valor is None else str(valor)
84
+
85
+
86
+ class UIPageRenderer(UIHtmlAdapter):
87
+
88
+ def __init__(self, page, action_names: List[str]):
89
+ super().__init__()
90
+ self._page = page
91
+ self._actions = set(action_names)
92
+
93
+ # -- entry -------------------------------------------------------------
94
+
95
+ def render(self, node) -> str:
96
+ b = HtmlBuilder()
97
+ self._render_node(node, b)
98
+ return b.build()
99
+
100
+ @staticmethod
101
+ def css() -> str:
102
+ return CSS_THEME + '\n' + RESPONSIVE_CSS + '\n' + FORM_CSS + '\n' + PAGER_CSS + '\n' + CELL_CSS
103
+
104
+ # -- dispatch ----------------------------------------------------------
105
+
106
+ def _render_node_ui(self, node, b: HtmlBuilder):
107
+ try:
108
+ if is_ui_node(node):
109
+ super()._render_node(self._resolved(node), b)
110
+ else:
111
+ b.raw(self._page.render(node))
112
+ except Exception as exc:
113
+ # DEV-2: the innermost ui:* element that failed names its line.
114
+ tag_error(exc, node)
115
+ raise
116
+
117
+ def _render_children(self, children: list, b: HtmlBuilder):
118
+ for child in children:
119
+ self._render_node(child, b)
120
+
121
+ def _resolved(self, node):
122
+ """A copy of the node with every `{expression}` resolved and escaped."""
123
+ copia = copy.copy(node)
124
+ for nome, valor in vars(node).items():
125
+ if nome in _NOT_TEXT or not isinstance(valor, str) or '{' not in valor:
126
+ continue
127
+ resolvido = self._page._apply_databinding(valor)
128
+ setattr(copia, nome, html.escape('' if resolvido is None else str(resolvido), quote=True))
129
+ return copia
130
+
131
+ # -- events are actions ------------------------------------------------
132
+
133
+ def _action(self, nome: str, onde: str) -> str:
134
+ nome = (nome or '').strip()
135
+ if nome.endswith('()'):
136
+ nome = nome[:-2]
137
+ if nome not in self._actions:
138
+ disponiveis = ', '.join(sorted(self._actions)) or 'none'
139
+ raise ValueError(
140
+ f'{onde}="{nome}" names no q:action of this page (UI-1; actions: {disponiveis}). '
141
+ f'Inside a page, a ui:* event is a q:action.')
142
+ return nome
143
+
144
+ def _hidden_fields(self, b: HtmlBuilder, acao: str, envio: str = None):
145
+ b.open_tag('input', {'type': 'hidden', 'name': 'action', 'value': acao}, self_closing=True)
146
+ if envio:
147
+ resolvido = str(self._page._apply_databinding(envio))
148
+ for par in resolvido.split(','):
149
+ if '=' not in par:
150
+ continue
151
+ campo, valor = (p.strip() for p in par.split('=', 1))
152
+ b.open_tag('input', {'type': 'hidden', 'name': html.escape(campo, quote=True),
153
+ 'value': html.escape(valor, quote=True)}, self_closing=True)
154
+
155
+ def _render_button(self, node, b: HtmlBuilder):
156
+ if not node.on_click:
157
+ return super()._render_button(node, b)
158
+ acao = self._action(node.on_click, '<ui:button on-click')
159
+ b.open_tag('form', {'method': 'post', 'class': 'q-action', 'style': 'display: inline'})
160
+ b.indent()
161
+ self._hidden_fields(b, acao, getattr(node, 'send', None))
162
+ semeventos = copy.copy(node)
163
+ semeventos.on_click = None
164
+ super()._render_button(semeventos, b)
165
+ b.dedent()
166
+ b.close_tag('form')
167
+
168
+ def _render_form(self, node, b: HtmlBuilder):
169
+ attrs = {'class': 'q-form', 'method': 'post'}
170
+ b.open_tag('form', self._merge_attrs(attrs, self._layout_attrs(node)))
171
+ b.indent()
172
+ acao = None
173
+ if node.on_submit:
174
+ acao = self._action(node.on_submit, '<ui:form on-submit')
175
+ self._hidden_fields(b, acao, getattr(node, 'send', None))
176
+ anterior = getattr(self, '_form_ctx', None)
177
+ self._form_ctx = form_context(self._page, node, acao) # UI-9
178
+ try:
179
+ self._render_children(generated_fields(node, self._form_ctx), b) # UI-10
180
+ finally:
181
+ self._form_ctx = anterior
182
+ b.dedent()
183
+ b.close_tag('form')
184
+
185
+ # -- fields take the action's rules, the values sent back, their error (UI-9)
186
+
187
+ def _field(self, desenhar, node, b: HtmlBuilder):
188
+ copia, erro = prepare_field(node, getattr(self, '_form_ctx', None), escape=True)
189
+ desenhar(copia, b)
190
+ if erro:
191
+ b.open_tag('span', {'class': 'q-field-error', 'role': 'alert'})
192
+ b.text(html.escape(erro))
193
+ b.close_tag('span')
194
+
195
+ def _render_input(self, node, b: HtmlBuilder):
196
+ if getattr(node, 'search', None):
197
+ return self._render_search(node, b)
198
+ self._field(super()._render_input, node, b)
199
+
200
+ # -- search as you type (UI-12) -------------------------------------------
201
+
202
+ def _render_search(self, node, b: HtmlBuilder):
203
+ """A GET form around the field: Enter searches without JavaScript; with
204
+ htmx, each pause in typing asks for the same page and swaps only the
205
+ target region. The other URL parameters are kept; `page` is not (a new
206
+ search starts on page 1)."""
207
+ if getattr(self, '_form_ctx', None) is not None:
208
+ raise ValueError(f'<ui:input bind="{node.bind}" search=...> cannot be inside a ui:form: '
209
+ f'it is a form of its own (a GET search) (UI-12)')
210
+ alvo = node.search
211
+ self._page._search_targets = getattr(self._page, '_search_targets', set()) | {alvo}
212
+ contexto = self._page.context
213
+ caminho = (getattr(contexto, 'request_vars', None) or {}).get('path') or ''
214
+ try:
215
+ atuais = contexto.get_variable('query')
216
+ except Exception:
217
+ atuais = None
218
+ atuais = atuais if isinstance(atuais, dict) else {}
219
+ campo = copy.copy(node)
220
+ if campo.value in (None, ''):
221
+ valor = atuais.get(node.bind)
222
+ campo.value = html.escape(str(valor), quote=True) if valor not in (None, '') else None
223
+ campo.input_type = 'search' if (campo.input_type or 'text') == 'text' else campo.input_type
224
+ b.open_tag('form', {'method': 'get', 'role': 'search', 'class': 'q-search',
225
+ 'action': html.escape(caminho, quote=True)})
226
+ b.indent()
227
+ for nome, valor in atuais.items():
228
+ if nome in (node.bind, 'page'):
229
+ continue
230
+ b.open_tag('input', {'type': 'hidden', 'name': html.escape(str(nome), quote=True),
231
+ 'value': html.escape(str(valor), quote=True)}, self_closing=True)
232
+ interno = HtmlBuilder()
233
+ super()._render_input(campo, interno)
234
+ hx = (f'hx-get="{html.escape(caminho, quote=True)}" '
235
+ f'hx-trigger="input changed delay:{int(node.delay)}ms, search" '
236
+ f'hx-target="#{html.escape(alvo, quote=True)}" hx-select="#{html.escape(alvo, quote=True)}" '
237
+ f'hx-swap="outerHTML" hx-push-url="true" hx-include="closest form" ')
238
+ b.raw(interno.build().replace('<input ', f'<input {hx}', 1))
239
+ b.dedent()
240
+ b.close_tag('form')
241
+
242
+ def _render_select(self, node, b: HtmlBuilder):
243
+ self._field(super()._render_select, node, b)
244
+
245
+ def _render_radio(self, node, b: HtmlBuilder):
246
+ self._field(super()._render_radio, node, b)
247
+
248
+ def _render_checkbox(self, node, b: HtmlBuilder):
249
+ self._field(super()._render_checkbox, node, b)
250
+
251
+ def _render_switch(self, node, b: HtmlBuilder):
252
+ self._field(super()._render_switch, node, b)
253
+
254
+ # -- <ui:pager> (UI-11) ----------------------------------------------------
255
+
256
+ def _render_node(self, node, b: HtmlBuilder):
257
+ if isinstance(node, ui_nodes.UIPagerNode):
258
+ try:
259
+ return self._render_pager(node, b)
260
+ except Exception as exc:
261
+ tag_error(exc, node)
262
+ raise
263
+ return self._render_node_ui(node, b)
264
+
265
+ def _render_pager(self, node, b: HtmlBuilder):
266
+ itens = pager_items(self._page, node)
267
+ if not itens:
268
+ return
269
+ b.open_tag('nav', self._merge_attrs({'class': 'q-pager', 'aria-label': 'Pages'}, self._layout_attrs(node)))
270
+ for item in itens:
271
+ if item['href']:
272
+ b.open_tag('a', {'href': html.escape(item['href'], quote=True),
273
+ 'aria-label': item['aria'], 'class': f"q-page-{item['kind']}"})
274
+ b.text(html.escape(item['label']))
275
+ b.close_tag('a')
276
+ else:
277
+ classe = ('q-page-current' if item['current'] else
278
+ 'q-page-gap' if item['kind'] == 'gap' else 'q-page-disabled')
279
+ atributos = {'class': classe}
280
+ if item['current']:
281
+ atributos['aria-current'] = 'page'
282
+ b.open_tag('span', atributos)
283
+ b.text(html.escape(item['label']))
284
+ b.close_tag('span')
285
+ b.close_tag('nav')
286
+
287
+ # -- a section is a titled group, open (UI-7) ------------------------------
288
+
289
+ def _render_section(self, node, b: HtmlBuilder):
290
+ # The standalone build draws a collapsed <details>: in a page its
291
+ # content was hidden in the browser and shown in the console.
292
+ b.open_tag('section', self._merge_attrs({'class': 'q-section-group'}, self._layout_attrs(node)))
293
+ b.indent()
294
+ if node.title:
295
+ b.open_tag('div', {'class': 'q-section-title'})
296
+ b.text(node.title)
297
+ b.close_tag('div')
298
+ self._render_children(node.children, b)
299
+ b.dedent()
300
+ b.close_tag('section')
301
+
302
+ # -- data: source="{rows}" -----------------------------------------------
303
+
304
+ def _render_table(self, node, b: HtmlBuilder):
305
+ if not node.source:
306
+ return super()._render_table(node, b)
307
+ colunas = [c for c in node.children if isinstance(c, ui_nodes.UIColumnNode)]
308
+ if getattr(node, 'sort', False):
309
+ self._check_sortable(node)
310
+ plano = edit_plan(self._page, node) # UI-13
311
+ estado = getattr(self._page, 'form_state', None)
312
+ b.open_tag('table', self._merge_attrs({'class': 'q-table'}, self._layout_attrs(node)))
313
+ b.indent()
314
+ b.open_tag('thead')
315
+ b.open_tag('tr')
316
+ for coluna in colunas:
317
+ estilo = f'text-align: {coluna.align}' if coluna.align else None
318
+ b.open_tag('th', {'style': estilo} if estilo else None)
319
+ rotulo = html.escape(coluna.label or coluna.key or '')
320
+ if getattr(node, 'sort', False) and coluna.key and not coluna.children:
321
+ href, marca = sort_link(self._page, coluna.key)
322
+ b.open_tag('a', {'href': html.escape(href, quote=True), 'class': 'q-sort'})
323
+ b.text(rotulo + (f' {marca}' if marca else ''))
324
+ b.close_tag('a')
325
+ else:
326
+ b.text(rotulo)
327
+ b.close_tag('th')
328
+ b.close_tag('tr')
329
+ b.close_tag('thead')
330
+ b.open_tag('tbody')
331
+ b.indent()
332
+ for linha in source_rows(self._page, node, 'ui:table', 'row'):
333
+ b.open_tag('tr')
334
+ for coluna in colunas:
335
+ estilo = f'text-align: {coluna.align}' if coluna.align else None
336
+ b.open_tag('td', {'style': estilo} if estilo else None)
337
+ if coluna.children:
338
+ self._render_children(coluna.children, b)
339
+ elif plano and coluna.key in plano['columns']:
340
+ self._render_cell_edit(plano, plano['columns'][coluna.key], linha, estado, b)
341
+ else:
342
+ b.text(html.escape(cell_value(linha, coluna)))
343
+ b.close_tag('td')
344
+ b.close_tag('tr')
345
+ b.dedent()
346
+ b.close_tag('tbody')
347
+ b.dedent()
348
+ b.close_tag('table')
349
+
350
+ def _check_sortable(self, node):
351
+ nome = _single_name(node.source)
352
+ consulta = (getattr(self._page, '_query_nodes', None) or {}).get(nome)
353
+ if consulta is not None and not getattr(consulta, 'sortable', False):
354
+ raise ValueError(f'<ui:table sort="true" source="{node.source}">: the query "{nome}" needs '
355
+ f'sortable="true" to order by a column in SQL (UI-13)')
356
+
357
+ def _render_cell_edit(self, plano, coluna_schema, linha, estado, b: HtmlBuilder):
358
+ """UI-13: a cell is a small form that posts to the generated __edit action."""
359
+ campo, ocultos, erro = cell_field(plano, coluna_schema, linha, estado, escape=True)
360
+ b.open_tag('form', {'method': 'post', 'class': 'q-cell'})
361
+ for nome, valor in ocultos.items():
362
+ b.open_tag('input', {'type': 'hidden', 'name': nome, 'value': html.escape(valor, quote=True)},
363
+ self_closing=True)
364
+ anterior = getattr(self, '_form_ctx', None)
365
+ self._form_ctx = None # the field is prepared already
366
+ try:
367
+ super()._render_node(campo, b)
368
+ finally:
369
+ self._form_ctx = anterior
370
+ b.open_tag('button', {'class': 'q-cell-save', 'aria-label': f'Save {coluna_schema.name}'})
371
+ b.text('✓')
372
+ b.close_tag('button')
373
+ b.close_tag('form')
374
+ if erro:
375
+ b.open_tag('span', {'class': 'q-field-error', 'role': 'alert'})
376
+ b.text(html.escape(erro))
377
+ b.close_tag('span')
378
+
379
+ def _render_list(self, node, b: HtmlBuilder):
380
+ if not node.source:
381
+ return super()._render_list(node, b)
382
+ b.open_tag('ul', self._merge_attrs({'class': 'q-list'}, self._layout_attrs(node)))
383
+ b.indent()
384
+ for linha in source_rows(self._page, node, 'ui:list', 'item'):
385
+ if node.children:
386
+ self._render_children(node.children, b)
387
+ else:
388
+ b.open_tag('li')
389
+ b.text(html.escape('' if linha is None else str(linha)))
390
+ b.close_tag('li')
391
+ b.dedent()
392
+ b.close_tag('ul')
@@ -0,0 +1,73 @@
1
+ """<ui:pager for="query"> — pages over a paginated query (M14, UI-11).
2
+
3
+ The query is `paginate="true"` (DB-2); its page is the URL's `page` parameter
4
+ (DB-9). The pager draws previous, the page numbers around the current one —
5
+ first and last always, a gap where pages are skipped — and next, as links to
6
+ the same page with the other URL parameters kept. The browser and the console
7
+ draw the same items.
8
+ """
9
+
10
+ from typing import Dict, List, Optional
11
+ from urllib.parse import urlencode
12
+
13
+
14
+ def _pagination(renderer, node) -> dict:
15
+ nome = node.for_query
16
+ try:
17
+ resultado = renderer.context.get_variable(f'{nome}_result')
18
+ except Exception:
19
+ resultado = None
20
+ if isinstance(resultado, dict) and isinstance(resultado.get('pagination'), dict):
21
+ return resultado['pagination']
22
+ raise ValueError(
23
+ f'<ui:pager for="{nome}">: there is no paginated q:query named "{nome}" on this page '
24
+ f'(it needs paginate="true" and page_size) (UI-11)')
25
+
26
+
27
+ def _href(renderer, param: str, pagina: int) -> str:
28
+ contexto = renderer.context
29
+ caminho = (getattr(contexto, 'request_vars', None) or {}).get('path') or ''
30
+ try:
31
+ atuais = contexto.get_variable('query')
32
+ except Exception:
33
+ atuais = None
34
+ parametros = {k: v for k, v in (atuais or {}).items() if k != param} if isinstance(atuais, dict) else {}
35
+ parametros[param] = str(pagina)
36
+ return f'{caminho}?{urlencode(parametros)}'
37
+
38
+
39
+ def pager_items(renderer, node) -> List[Dict]:
40
+ """The pager's items: {'kind': prev|page|gap|next, 'label', 'href' (None if not a link), 'current'}."""
41
+ pag = _pagination(renderer, node)
42
+ total = int(pag.get('totalPages') or 0)
43
+ atual = int(pag.get('currentPage') or 1)
44
+ if total <= 1:
45
+ return []
46
+ janela = max(0, int(node.window))
47
+ paginas = sorted({1, total} | set(range(max(1, atual - janela), min(total, atual + janela) + 1)))
48
+ itens: List[Dict] = [{'kind': 'prev', 'label': '‹', 'aria': 'Previous page',
49
+ 'href': _href(renderer, node.param, atual - 1) if atual > 1 else None,
50
+ 'current': False}]
51
+ anterior: Optional[int] = None
52
+ for n in paginas:
53
+ if anterior is not None and n > anterior + 1:
54
+ itens.append({'kind': 'gap', 'label': '…', 'href': None, 'current': False, 'aria': ''})
55
+ itens.append({'kind': 'page', 'label': str(n), 'aria': f'Page {n}', 'current': n == atual,
56
+ 'href': None if n == atual else _href(renderer, node.param, n)})
57
+ anterior = n
58
+ itens.append({'kind': 'next', 'label': '›', 'aria': 'Next page',
59
+ 'href': _href(renderer, node.param, atual + 1) if atual < total else None,
60
+ 'current': False})
61
+ return itens
62
+
63
+
64
+ PAGER_CSS = """
65
+ /* UI-11: <ui:pager> */
66
+ .q-pager { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin: 8px 0; }
67
+ .q-pager a, .q-pager span { min-width: 32px; padding: 4px 8px; text-align: center; border-radius: 6px;
68
+ border: 1px solid var(--q-border, #d0d7de); text-decoration: none; color: inherit; }
69
+ .q-pager a:hover { background: var(--q-surface-hover, #f3f4f6); }
70
+ .q-pager .q-page-current { background: var(--q-primary, #2563eb); border-color: var(--q-primary, #2563eb); color: #fff; }
71
+ .q-pager .q-page-disabled { opacity: .45; }
72
+ .q-pager .q-page-gap { border: none; }
73
+ """
@@ -0,0 +1,139 @@
1
+ """A table that sorts and edits itself (M16, UI-13).
2
+
3
+ `<ui:table source="{tarefas}" sort="true">` — each header is a link that
4
+ orders the query in SQL (`q:query sortable="true"`, by the URL's ?sort= and
5
+ ?dir=), so it works with pagination.
6
+
7
+ `<ui:table source="{tarefas}" edit="tarefas" datasource="db">` — each shown
8
+ column of that table (not the key, not a column with `edit="false"`) is a
9
+ small form in its cell. It posts to an action the page does not write:
10
+ `__edit`, handled by the server (web_server._edit_cell), which accepts only a
11
+ table and column this page declares editable, validates the value with the
12
+ column's rules from the schema (UI-10) and updates one row by its key. What it
13
+ does shows in /_dev like any action.
14
+ """
15
+
16
+ import copy
17
+ import html
18
+ from typing import Dict, List, Optional
19
+ from urllib.parse import urlencode
20
+
21
+ EDIT_ACTION = '__edit'
22
+
23
+
24
+ def _query_params(renderer) -> dict:
25
+ try:
26
+ atuais = renderer.context.get_variable('query')
27
+ except Exception:
28
+ atuais = None
29
+ return atuais if isinstance(atuais, dict) else {}
30
+
31
+
32
+ def _path(renderer) -> str:
33
+ return (getattr(renderer.context, 'request_vars', None) or {}).get('path') or ''
34
+
35
+
36
+ def sort_link(renderer, chave: str):
37
+ """(href, marker) of a header: the next order for this column, and ▲/▼ if it is the current one."""
38
+ atuais = _query_params(renderer)
39
+ atual, direcao = atuais.get('sort'), str(atuais.get('dir') or 'asc').lower()
40
+ proxima = 'desc' if atual == chave and direcao == 'asc' else 'asc'
41
+ parametros = {k: v for k, v in atuais.items() if k not in ('sort', 'dir', 'page')}
42
+ parametros.update({'sort': chave, 'dir': proxima})
43
+ marca = ('▲' if direcao == 'asc' else '▼') if atual == chave else ''
44
+ return f'{_path(renderer)}?{urlencode(parametros)}', marca
45
+
46
+
47
+ def edit_plan(renderer, table_node) -> Optional[dict]:
48
+ """For an editable table: the key column and, per editable column key, its schema column."""
49
+ if not getattr(table_node, 'edit', None):
50
+ return None
51
+ from quantum.core.features.ui_engine.src.ast_nodes import UIColumnNode
52
+ from quantum.runtime.table_params import TableParamsError, columns_of
53
+ config = getattr(renderer, 'config', None) or {}
54
+ try:
55
+ colunas = {c.name: c for c in columns_of(config, table_node.edit_datasource, table_node.edit)}
56
+ except TableParamsError as exc:
57
+ raise ValueError(f'<ui:table edit="{table_node.edit}">: {exc} (UI-13)') from exc
58
+ chaves = [c for c in colunas.values() if c.primary_key]
59
+ if len(chaves) != 1:
60
+ raise ValueError(f'<ui:table edit="{table_node.edit}">: the table needs one primary key column '
61
+ f'to know which row a cell is (UI-13)')
62
+ editaveis = {}
63
+ for coluna in table_node.children:
64
+ if isinstance(coluna, UIColumnNode) and coluna.key and coluna.edit and not coluna.children:
65
+ if coluna.key in colunas and not colunas[coluna.key].primary_key:
66
+ editaveis[coluna.key] = colunas[coluna.key]
67
+ return {'table': table_node.edit, 'key': chaves[0].name, 'columns': editaveis}
68
+
69
+
70
+ def cell_field(plano: dict, coluna_schema, linha: dict, estado: Optional[dict], escape: bool):
71
+ """The field of one cell (with the column's rules), its hidden fields, and its error."""
72
+ from quantum.core.features.ui_engine.src.ast_nodes import UICheckboxNode, UIInputNode, UISelectNode
73
+ from quantum.runtime.table_params import param_from_column
74
+ from quantum.runtime.ui_form_rules import FormContext, prepare_field
75
+ chave = plano['key']
76
+ if chave not in linha:
77
+ raise ValueError(f'<ui:table edit=...>: the rows need the key column "{chave}" '
78
+ f'(add it to the query\'s SELECT) (UI-13)')
79
+ param = param_from_column(coluna_schema)
80
+ param.name = 'value'
81
+ if str(param.type) == 'boolean':
82
+ campo = UICheckboxNode()
83
+ elif param.enum:
84
+ campo = UISelectNode()
85
+ else:
86
+ campo = UIInputNode()
87
+ campo.bind = 'value'
88
+ campo.ui_id = f'cell-{coluna_schema.name}-{linha.get(plano["key"])}' # one id per cell
89
+ atual = linha.get(coluna_schema.name)
90
+ identidade = f'{coluna_schema.name}@{linha[chave]}'
91
+ erros, enviados = {}, None
92
+ if estado and estado.get('action') == EDIT_ACTION and estado.get('cell') == identidade:
93
+ erros, enviados = estado.get('errors') or {}, estado.get('values') or {}
94
+ ctx = FormContext([param], {'errors': erros, 'values': enviados} if enviados is not None else None,
95
+ True, {'value': atual})
96
+ campo, erro = prepare_field(campo, ctx, escape)
97
+ ocultos = {'action': EDIT_ACTION, '__table': plano['table'], '__key': str(linha[chave]),
98
+ '__column': coluna_schema.name}
99
+ return campo, ocultos, erro
100
+
101
+
102
+ CELL_CSS = """
103
+ /* UI-13: a cell edited in place */
104
+ .q-cell { display: flex; gap: 4px; align-items: center; margin: 0; }
105
+ .q-cell .q-input, .q-cell .q-select { min-width: 0; padding: 2px 6px; }
106
+ .q-cell-save { padding: 2px 8px; border: 1px solid var(--q-border, #d0d7de); border-radius: 6px;
107
+ background: transparent; cursor: pointer; }
108
+ .q-sort { color: inherit; text-decoration: none; }
109
+ .q-sort:hover { text-decoration: underline; }
110
+ """
111
+
112
+
113
+ def _walk(nodes):
114
+ for node in nodes or []:
115
+ yield node
116
+ for campo in ('statements', 'children', 'body', 'if_body', 'else_body'):
117
+ yield from _walk(getattr(node, campo, None))
118
+ for bloco in getattr(node, 'elseif_blocks', None) or []:
119
+ yield from _walk(bloco.get('body') if isinstance(bloco, dict) else getattr(bloco, 'body', None))
120
+
121
+
122
+ def find_editable_table(ast, tabela: str, coluna: str):
123
+ """The <ui:table edit="tabela"> of this page that shows `coluna` as editable, or None.
124
+
125
+ The server edits only what the page declares: a post naming another table
126
+ or column — a forged form — finds nothing and is refused."""
127
+ from quantum.core.features.ui_engine.src.ast_nodes import UIColumnNode, UITableNode
128
+ for node in _walk(getattr(ast, 'statements', None)):
129
+ if isinstance(node, UITableNode) and node.edit == tabela:
130
+ for filho in node.children:
131
+ if isinstance(filho, UIColumnNode) and filho.key == coluna and filho.edit and not filho.children:
132
+ return node
133
+ return None
134
+
135
+
136
+ def query_nodes(ast) -> Dict[str, object]:
137
+ """The page's q:query nodes by name (a sortable table checks its query)."""
138
+ from quantum.core.ast_nodes import QueryNode
139
+ return {n.name: n for n in _walk(getattr(ast, 'statements', None)) if isinstance(n, QueryNode)}