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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""The console renderer: a Quantum page as a terminal app (UI-3).
|
|
2
|
+
|
|
3
|
+
`quantum console` starts the application's server in this process and opens a
|
|
4
|
+
Textual app on it. The app asks each page for its view tree
|
|
5
|
+
(quantum/runtime/ui_view.py) instead of HTML, draws it with Textual widgets,
|
|
6
|
+
and sends the same q:action posts a browser sends — with a cookie jar, so
|
|
7
|
+
login, validation, flash and redirects are exactly the web's. Nothing of the
|
|
8
|
+
page's logic is translated: the runtime that serves the browser serves this.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import urljoin
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual.app import App, ComposeResult
|
|
18
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
19
|
+
from textual.widgets import (Button, Checkbox, Input, ProgressBar, RadioButton, RadioSet,
|
|
20
|
+
Select, Static, Switch, TabbedContent, TabPane)
|
|
21
|
+
|
|
22
|
+
VIEW = 'application/vnd.quantum.view+json'
|
|
23
|
+
|
|
24
|
+
# UI-2 in a terminal: the breakpoints in columns (a column is ~8 px).
|
|
25
|
+
BREAKPOINT_COLUMNS = {'sm': 80, 'md': 96, 'lg': 128}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _marcado(valor) -> bool:
|
|
29
|
+
"""UI-6: checked="true", or an expression that resolved to a true value."""
|
|
30
|
+
return valor is not None and str(valor).strip().lower() in ('true', '1', 'on', 'yes', 'checked')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _numero(valor, padrao: float) -> float:
|
|
34
|
+
try:
|
|
35
|
+
return float(valor)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return padrao
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConsoleUI(App):
|
|
41
|
+
"""Draws a page's view tree; buttons and forms post the page's actions."""
|
|
42
|
+
|
|
43
|
+
CSS = """
|
|
44
|
+
Screen { background: $surface; }
|
|
45
|
+
#tela { padding: 1 2; }
|
|
46
|
+
.window { height: auto; }
|
|
47
|
+
.hbox { height: auto; }
|
|
48
|
+
.vbox { height: auto; }
|
|
49
|
+
.hbox > Static { width: auto; margin-right: 1; }
|
|
50
|
+
.hbox > Button, .hbox > Input, .hbox > Select { margin-right: 1; }
|
|
51
|
+
.grow { width: 1fr; }
|
|
52
|
+
.panel { border: round $primary; padding: 0 1; height: auto; margin-bottom: 1; }
|
|
53
|
+
.flash { background: $success 30%; padding: 0 1; margin-bottom: 1; }
|
|
54
|
+
.flash-error { background: $error 30%; }
|
|
55
|
+
.badge { background: $secondary; color: $text; padding: 0 1; }
|
|
56
|
+
.link { min-width: 0; height: 1; border: none; background: $surface; text-style: underline; }
|
|
57
|
+
Button { min-width: 8; }
|
|
58
|
+
.grid { height: auto; grid-rows: auto; grid-gutter: 0 1; }
|
|
59
|
+
.table { height: auto; margin-bottom: 1; }
|
|
60
|
+
.row { height: auto; }
|
|
61
|
+
.row-head { border-bottom: solid $primary; }
|
|
62
|
+
.cell { height: auto; padding: 0 1; }
|
|
63
|
+
.label { color: $text-muted; }
|
|
64
|
+
.unsupported { color: $warning; }
|
|
65
|
+
.field-error { color: $error; }
|
|
66
|
+
.invalid { border: tall $error; }
|
|
67
|
+
TabbedContent { height: auto; }
|
|
68
|
+
TabPane { height: auto; }
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, base_url: str, path: str = '/'):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.base_url = base_url
|
|
74
|
+
self.path = path
|
|
75
|
+
self.http = requests.Session()
|
|
76
|
+
self._eventos: Dict[int, Dict[str, Any]] = {}
|
|
77
|
+
self.titulo_da_pagina = ''
|
|
78
|
+
self._trava = asyncio.Lock()
|
|
79
|
+
self.carregando = False
|
|
80
|
+
|
|
81
|
+
def compose(self) -> ComposeResult:
|
|
82
|
+
yield VerticalScroll(id='tela')
|
|
83
|
+
|
|
84
|
+
async def on_mount(self) -> None:
|
|
85
|
+
await self.carregar(self.path)
|
|
86
|
+
|
|
87
|
+
# -- talking to the page --------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _pedir(self, metodo: str, caminho: str, dados: Optional[dict] = None) -> dict:
|
|
90
|
+
url = urljoin(self.base_url, caminho)
|
|
91
|
+
resposta = self.http.request(metodo, url, data=dados, allow_redirects=True,
|
|
92
|
+
headers={'Accept': VIEW, 'Referer': urljoin(self.base_url, self.path)},
|
|
93
|
+
timeout=30)
|
|
94
|
+
if resposta.headers.get('Content-Type', '').startswith(VIEW):
|
|
95
|
+
self.path = resposta.url[len(self.base_url.rstrip('/')):] or '/'
|
|
96
|
+
return resposta.json()
|
|
97
|
+
# An error page, a 400 from an unknown action: show it as it is.
|
|
98
|
+
return {'title': str(resposta.status_code), 'flash': f'{resposta.status_code} {resposta.reason}',
|
|
99
|
+
'flashType': 'error', 'view': []}
|
|
100
|
+
|
|
101
|
+
async def carregar(self, caminho: str, metodo: str = 'GET', dados: Optional[dict] = None) -> None:
|
|
102
|
+
# One page at a time: a second click while a page is still being
|
|
103
|
+
# mounted removed half-mounted widgets (Textual's Select then fails).
|
|
104
|
+
async with self._trava:
|
|
105
|
+
self.carregando = True
|
|
106
|
+
try:
|
|
107
|
+
await self._carregar(caminho, metodo, dados)
|
|
108
|
+
# Let the new widgets finish their own mounting (a Select
|
|
109
|
+
# composes its label after mount) before anything replaces them.
|
|
110
|
+
await asyncio.sleep(0)
|
|
111
|
+
finally:
|
|
112
|
+
self.carregando = False
|
|
113
|
+
|
|
114
|
+
async def _carregar(self, caminho: str, metodo: str, dados: Optional[dict]) -> None:
|
|
115
|
+
pagina = self._pedir(metodo, caminho, dados)
|
|
116
|
+
self.titulo_da_pagina = pagina.get('title', '')
|
|
117
|
+
self.title = self.titulo_da_pagina
|
|
118
|
+
tela = self.query_one('#tela', VerticalScroll)
|
|
119
|
+
await tela.remove_children()
|
|
120
|
+
self._eventos.clear()
|
|
121
|
+
widgets: List = []
|
|
122
|
+
if pagina.get('flash'):
|
|
123
|
+
classes = 'flash flash-error' if pagina.get('flashType') == 'error' else 'flash'
|
|
124
|
+
widgets.append(Static(Text(str(pagina['flash'])), classes=classes, id='flash'))
|
|
125
|
+
widgets.extend(self._montar(pagina.get('view', [])))
|
|
126
|
+
await tela.mount_all(widgets)
|
|
127
|
+
|
|
128
|
+
# -- drawing --------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _montar(self, nos: List[dict], formulario: Optional[dict] = None) -> List:
|
|
131
|
+
widgets = [w for no in nos for w in [self._widget(no, formulario)] if w is not None]
|
|
132
|
+
for w in widgets:
|
|
133
|
+
if formulario is not None and not hasattr(w, '_quantum_formulario'):
|
|
134
|
+
w._quantum_formulario = formulario
|
|
135
|
+
return widgets
|
|
136
|
+
|
|
137
|
+
def _classes_css(self, props: dict, base: str) -> str:
|
|
138
|
+
classes = [base]
|
|
139
|
+
if str(props.get('grow', '')).lower() == 'true':
|
|
140
|
+
classes.append('grow')
|
|
141
|
+
return ' '.join(classes)
|
|
142
|
+
|
|
143
|
+
def _widget(self, no: dict, formulario: Optional[dict]):
|
|
144
|
+
widget = self._widget_sem_largura(no, formulario)
|
|
145
|
+
props = no.get('props') or {}
|
|
146
|
+
largura = str(props.get('width') or '')
|
|
147
|
+
if widget is not None and largura.isdigit():
|
|
148
|
+
# A width in px becomes columns (~8 px each), as the breakpoints do.
|
|
149
|
+
widget.styles.width = max(8, int(largura) // 8)
|
|
150
|
+
if widget is not None and props.get('error'):
|
|
151
|
+
# UI-9: the server refused this field; its message under it.
|
|
152
|
+
widget.add_class('invalid')
|
|
153
|
+
return Vertical(widget, Static(Text(str(props['error'])), classes='field-error'), classes='vbox')
|
|
154
|
+
return widget
|
|
155
|
+
|
|
156
|
+
def _widget_sem_largura(self, no: dict, formulario: Optional[dict]):
|
|
157
|
+
tipo, props = no.get('type'), no.get('props', {})
|
|
158
|
+
filhos = no.get('children', [])
|
|
159
|
+
ident = props.get('ui_id')
|
|
160
|
+
texto = str(props.get('text') or '')
|
|
161
|
+
|
|
162
|
+
if tipo in ('window', 'vbox', 'scrollbox', 'cardbody', 'cardfooter', 'tab'):
|
|
163
|
+
return Vertical(*self._montar(filhos, formulario), classes=self._classes_css(props, 'vbox'), id=ident)
|
|
164
|
+
if tipo == 'section':
|
|
165
|
+
titulo = [Static(Text(str(props['title']), style='bold'), classes='text')] if props.get('title') else []
|
|
166
|
+
return Vertical(*titulo, *self._montar(filhos, formulario), classes=self._classes_css(props, 'vbox'),
|
|
167
|
+
id=ident)
|
|
168
|
+
if tipo == 'card':
|
|
169
|
+
cartao = Vertical(*self._montar(filhos, formulario), classes='panel', id=ident)
|
|
170
|
+
cartao.border_title = props.get('title', '')
|
|
171
|
+
return cartao
|
|
172
|
+
if tipo == 'hbox':
|
|
173
|
+
empilha = props.get('stack_below')
|
|
174
|
+
estreito = empilha and self.size.width < BREAKPOINT_COLUMNS.get(empilha, 0)
|
|
175
|
+
tipo_caixa = Vertical if estreito else Horizontal
|
|
176
|
+
filhos_w = self._montar(filhos, formulario)
|
|
177
|
+
if estreito:
|
|
178
|
+
# Stacked: fixed widths stop applying, as in the web's CSS (UI-2).
|
|
179
|
+
for w in filhos_w:
|
|
180
|
+
w.styles.width = '100%'
|
|
181
|
+
return tipo_caixa(*filhos_w,
|
|
182
|
+
classes=self._classes_css(props, 'vbox' if estreito else 'hbox'), id=ident)
|
|
183
|
+
if tipo == 'grid':
|
|
184
|
+
grade = Grid(*self._montar(filhos, formulario), classes='grid', id=ident)
|
|
185
|
+
grade.styles.grid_size_columns = self._colunas_da_grade(str(props.get('columns') or '1'))
|
|
186
|
+
return grade
|
|
187
|
+
if tipo == 'panel':
|
|
188
|
+
painel = Vertical(*self._montar(filhos, formulario), classes='panel', id=ident)
|
|
189
|
+
painel.border_title = props.get('title', '')
|
|
190
|
+
return painel
|
|
191
|
+
if tipo == 'tabpanel':
|
|
192
|
+
abas = TabbedContent(id=ident)
|
|
193
|
+
for n, f in enumerate(f for f in filhos if f.get('type') == 'tab'):
|
|
194
|
+
abas.compose_add_child(TabPane(str(f.get('props', {}).get('title') or n + 1),
|
|
195
|
+
*self._montar(f.get('children', []), formulario)))
|
|
196
|
+
return abas
|
|
197
|
+
if tipo == 'form':
|
|
198
|
+
# The form's marker exists before its children, so each widget
|
|
199
|
+
# inside can point to it; the marker keeps the box and the action.
|
|
200
|
+
marcador = {'event': no.get('event')}
|
|
201
|
+
caixa = Vertical(*self._montar(filhos, marcador), classes='vbox', id=ident)
|
|
202
|
+
marcador['caixa'] = caixa
|
|
203
|
+
return caixa
|
|
204
|
+
if tipo == 'formitem':
|
|
205
|
+
rotulo = [Static(Text(str(props['label'])), classes='label')] if props.get('label') else []
|
|
206
|
+
return Vertical(*rotulo, *self._montar(filhos, formulario), classes='vbox', id=ident)
|
|
207
|
+
if tipo == 'button':
|
|
208
|
+
botao = Button(texto or props.get('label') or '', id=ident,
|
|
209
|
+
variant='error' if props.get('variant') == 'danger' else
|
|
210
|
+
('primary' if props.get('variant') == 'primary' else 'default'))
|
|
211
|
+
if no.get('event'):
|
|
212
|
+
self._eventos[id(botao)] = no['event']
|
|
213
|
+
return botao
|
|
214
|
+
if tipo == 'link':
|
|
215
|
+
link = Button(texto or props.get('to', ''), classes='link', id=ident)
|
|
216
|
+
self._eventos[id(link)] = {'navigate': props.get('to', '/')}
|
|
217
|
+
return link
|
|
218
|
+
if tipo == 'input':
|
|
219
|
+
campo = Input(value=str(props.get('value') or ''), placeholder=props.get('placeholder', ''),
|
|
220
|
+
id=ident, classes=self._classes_css(props, 'input'),
|
|
221
|
+
password=props.get('input_type') == 'password')
|
|
222
|
+
campo._quantum_nome = props.get('bind')
|
|
223
|
+
if props.get('search'):
|
|
224
|
+
# UI-12: typing asks for the page again with ?bind=value.
|
|
225
|
+
campo._quantum_busca = int(props.get('delay') or 300)
|
|
226
|
+
campo._quantum_inicial = campo.value
|
|
227
|
+
return campo
|
|
228
|
+
if tipo == 'select':
|
|
229
|
+
opcoes = [(o.strip(), o.strip()) for o in str(props.get('options') or '').split(',') if o.strip()]
|
|
230
|
+
opcoes += [(str(f['props'].get('label') or f['props'].get('value') or ''),
|
|
231
|
+
str(f['props'].get('value') or ''))
|
|
232
|
+
for f in filhos if f.get('type') == 'option']
|
|
233
|
+
valores = [v for _, v in opcoes]
|
|
234
|
+
escolhido = str(props.get('value') or '')
|
|
235
|
+
valor = escolhido if escolhido in valores else (valores[0] if valores else Select.BLANK)
|
|
236
|
+
escolha = Select(opcoes, value=valor, allow_blank=not opcoes, id=ident)
|
|
237
|
+
escolha._quantum_nome = props.get('bind')
|
|
238
|
+
return escolha
|
|
239
|
+
if tipo == 'checkbox':
|
|
240
|
+
caixa = Checkbox(str(props.get('label') or ''), value=_marcado(props.get('checked')), id=ident)
|
|
241
|
+
caixa._quantum_nome = props.get('bind')
|
|
242
|
+
return caixa
|
|
243
|
+
if tipo == 'switch':
|
|
244
|
+
chave = Switch(value=_marcado(props.get('checked')))
|
|
245
|
+
chave._quantum_nome = props.get('bind')
|
|
246
|
+
return Horizontal(chave, Static(Text(str(props.get('label') or '')), classes='text'),
|
|
247
|
+
classes='hbox', id=ident)
|
|
248
|
+
if tipo == 'radio':
|
|
249
|
+
escolhido = str(props.get('value') or '')
|
|
250
|
+
opcoes = [o.strip() for o in str(props.get('options') or '').split(',') if o.strip()]
|
|
251
|
+
grupo = RadioSet(*[RadioButton(o, value=o == escolhido) for o in opcoes], id=ident)
|
|
252
|
+
grupo._quantum_nome = props.get('bind')
|
|
253
|
+
return grupo
|
|
254
|
+
if tipo == 'table':
|
|
255
|
+
return self._tabela(no, formulario, ident)
|
|
256
|
+
if tipo == 'list':
|
|
257
|
+
return Vertical(*self._montar(filhos, formulario), classes='vbox', id=ident)
|
|
258
|
+
if tipo == 'item':
|
|
259
|
+
return Horizontal(Static('•', classes='text'), *self._montar(filhos, formulario),
|
|
260
|
+
classes='hbox', id=ident)
|
|
261
|
+
if tipo == 'progress':
|
|
262
|
+
barra = ProgressBar(total=_numero(props.get('max'), 100), show_eta=False, id=ident)
|
|
263
|
+
barra.progress = _numero(props.get('value'), 0)
|
|
264
|
+
return barra
|
|
265
|
+
if tipo == 'image':
|
|
266
|
+
return Static(Text(f"[{props.get('alt') or props.get('src') or 'image'}]"), classes='text', id=ident)
|
|
267
|
+
if tipo in ('header', 'cardheader'):
|
|
268
|
+
titulo = str(props.get('title') or texto)
|
|
269
|
+
return Vertical(Static(Text(titulo, style='bold'), classes='text'),
|
|
270
|
+
*self._montar(filhos, formulario), classes='vbox', id=ident)
|
|
271
|
+
if tipo == 'alert':
|
|
272
|
+
classes = 'flash flash-error' if props.get('variant') in ('danger', 'error') else 'flash'
|
|
273
|
+
titulo = f"{props['title']}: " if props.get('title') else ''
|
|
274
|
+
return Static(Text(titulo + texto), classes=classes, id=ident)
|
|
275
|
+
if tipo == 'badge':
|
|
276
|
+
return Static(Text(texto), classes='badge', id=ident)
|
|
277
|
+
if tipo == 'text':
|
|
278
|
+
return Static(Text(texto, style='bold' if props.get('weight') == 'bold' else ''),
|
|
279
|
+
classes='text', id=ident)
|
|
280
|
+
if tipo == 'footer':
|
|
281
|
+
return Static(Text(texto, style='dim'), classes='text', id=ident)
|
|
282
|
+
if tipo == 'rule':
|
|
283
|
+
return Static('─' * 40, classes='text')
|
|
284
|
+
if tipo == 'spacer':
|
|
285
|
+
return Static('', classes='text')
|
|
286
|
+
# UI-7: outside the core set, say so — never draw something else in its place.
|
|
287
|
+
return Static(Text(f'[ui:{tipo} is not drawn in the console]', style='italic'),
|
|
288
|
+
classes='unsupported', id=ident)
|
|
289
|
+
|
|
290
|
+
def _colunas_da_grade(self, spec: str) -> int:
|
|
291
|
+
"""UI-2 `columns="1 sm:2 lg:3"` counted in terminal columns; "1fr 2fr" is 2."""
|
|
292
|
+
partes = spec.split()
|
|
293
|
+
if len(partes) > 1 and all(p.endswith('fr') for p in partes):
|
|
294
|
+
return len(partes)
|
|
295
|
+
colunas = 1
|
|
296
|
+
for parte in partes:
|
|
297
|
+
faixa, _, valor = parte.rpartition(':')
|
|
298
|
+
if valor.isdigit() and (not faixa or self.size.width >= BREAKPOINT_COLUMNS.get(faixa, 10 ** 6)):
|
|
299
|
+
colunas = int(valor)
|
|
300
|
+
return max(1, colunas)
|
|
301
|
+
|
|
302
|
+
def _tabela(self, no: dict, formulario: Optional[dict], ident):
|
|
303
|
+
"""UI-5: a header row and one row per record; a cell holds text or widgets."""
|
|
304
|
+
colunas = no.get('columns', [])
|
|
305
|
+
|
|
306
|
+
def celula(widgets, coluna):
|
|
307
|
+
caixa = Vertical(*widgets, classes='cell')
|
|
308
|
+
largura = str(coluna.get('width') or '')
|
|
309
|
+
caixa.styles.width = max(4, int(largura) // 8) if largura.isdigit() else '1fr'
|
|
310
|
+
return caixa
|
|
311
|
+
|
|
312
|
+
def titulo(c):
|
|
313
|
+
if c.get('to'):
|
|
314
|
+
# UI-13: a sortable header is a link, as in the browser.
|
|
315
|
+
link = Button(c['label'], classes='link')
|
|
316
|
+
self._eventos[id(link)] = {'navigate': c['to']}
|
|
317
|
+
return link
|
|
318
|
+
return Static(Text(c['label'], style='bold'))
|
|
319
|
+
|
|
320
|
+
cabecalho = Horizontal(*[celula([titulo(c)], c) for c in colunas], classes='row row-head')
|
|
321
|
+
linhas = [Horizontal(*[celula(self._montar(cel, formulario), c) for cel, c in zip(linha, colunas)],
|
|
322
|
+
classes='row')
|
|
323
|
+
for linha in no.get('rows', [])]
|
|
324
|
+
return Vertical(cabecalho, *linhas, classes='table', id=ident)
|
|
325
|
+
|
|
326
|
+
# -- events are the page's actions ---------------------------------------
|
|
327
|
+
|
|
328
|
+
def _campos_do_formulario(self, formulario: dict) -> Dict[str, str]:
|
|
329
|
+
"""The fields a browser would post: a checked box posts 'on', an unchecked one nothing."""
|
|
330
|
+
campos = {}
|
|
331
|
+
for w in formulario['caixa'].query('Input, Select, Checkbox, Switch, RadioSet'):
|
|
332
|
+
nome = getattr(w, '_quantum_nome', None)
|
|
333
|
+
if not nome:
|
|
334
|
+
continue
|
|
335
|
+
if isinstance(w, (Checkbox, Switch)):
|
|
336
|
+
if w.value:
|
|
337
|
+
campos[nome] = 'on'
|
|
338
|
+
continue
|
|
339
|
+
if isinstance(w, RadioSet):
|
|
340
|
+
if w.pressed_button is not None:
|
|
341
|
+
campos[nome] = str(w.pressed_button.label)
|
|
342
|
+
continue
|
|
343
|
+
valor = w.value
|
|
344
|
+
campos[nome] = '' if valor is None or valor is Select.BLANK else str(valor)
|
|
345
|
+
return campos
|
|
346
|
+
|
|
347
|
+
async def on_button_pressed(self, evento: Button.Pressed) -> None:
|
|
348
|
+
botao = evento.button
|
|
349
|
+
acao = self._eventos.get(id(botao))
|
|
350
|
+
if acao and 'navigate' in acao:
|
|
351
|
+
await self.carregar(acao['navigate'])
|
|
352
|
+
return
|
|
353
|
+
formulario = getattr(botao, '_quantum_formulario', None)
|
|
354
|
+
if acao is None and formulario is not None:
|
|
355
|
+
acao = formulario.get('event')
|
|
356
|
+
if acao:
|
|
357
|
+
dados = {'action': acao['action'], **acao.get('fields', {}),
|
|
358
|
+
**self._campos_do_formulario(formulario)}
|
|
359
|
+
await self.carregar(self.path, 'POST', dados)
|
|
360
|
+
return
|
|
361
|
+
if acao:
|
|
362
|
+
await self.carregar(self.path, 'POST', {'action': acao['action'], **acao.get('fields', {})})
|
|
363
|
+
|
|
364
|
+
def on_input_changed(self, evento: Input.Changed) -> None:
|
|
365
|
+
"""UI-12: search as you type — after a pause, the page with ?bind=value."""
|
|
366
|
+
campo = evento.input
|
|
367
|
+
espera = getattr(campo, '_quantum_busca', None)
|
|
368
|
+
if espera is None or campo.value == getattr(campo, '_quantum_inicial', None):
|
|
369
|
+
return
|
|
370
|
+
anterior = getattr(self, '_busca_timer', None)
|
|
371
|
+
if anterior is not None:
|
|
372
|
+
anterior.stop()
|
|
373
|
+
nome, valor = campo._quantum_nome, campo.value
|
|
374
|
+
self._busca_timer = self.set_timer(
|
|
375
|
+
espera / 1000, lambda: self.run_worker(self._buscar(nome, valor), exclusive=True, group='busca'))
|
|
376
|
+
|
|
377
|
+
async def _buscar(self, nome: str, valor: str) -> None:
|
|
378
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
379
|
+
partes = urlsplit(self.path)
|
|
380
|
+
parametros = {k: v for k, v in parse_qsl(partes.query) if k not in (nome, 'page')}
|
|
381
|
+
if valor:
|
|
382
|
+
parametros[nome] = valor
|
|
383
|
+
await self.carregar(partes.path + ('?' + urlencode(parametros) if parametros else ''))
|
|
384
|
+
# The page was drawn again: the field keeps the focus and the cursor at the end.
|
|
385
|
+
for campo in self.query(Input):
|
|
386
|
+
if getattr(campo, '_quantum_nome', None) == nome and getattr(campo, '_quantum_busca', None):
|
|
387
|
+
campo.focus()
|
|
388
|
+
campo.cursor_position = len(campo.value)
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
async def on_input_submitted(self, evento: Input.Submitted) -> None:
|
|
392
|
+
formulario = getattr(evento.input, '_quantum_formulario', None)
|
|
393
|
+
acao = formulario.get('event') if formulario is not None else None
|
|
394
|
+
if acao:
|
|
395
|
+
dados = {'action': acao['action'], **acao.get('fields', {}), **self._campos_do_formulario(formulario)}
|
|
396
|
+
await self.carregar(self.path, 'POST', dados)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def run_console(config_path: str = 'quantum.config.yaml', path: str = '/') -> int:
|
|
400
|
+
"""`quantum console`: the application's server in a thread, the page in the terminal."""
|
|
401
|
+
from quantum.runtime.local_server import local_server
|
|
402
|
+
|
|
403
|
+
with local_server(config_path) as base_url:
|
|
404
|
+
ConsoleUI(base_url, path).run()
|
|
405
|
+
return 0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The desktop renderer: a Quantum application in its own window (UI-4).
|
|
2
|
+
|
|
3
|
+
`quantum desktop` starts the application's server on a free local port and
|
|
4
|
+
opens a native window (pywebview) on it. The window is a browser without the
|
|
5
|
+
browser's chrome: pages, q:action, sessions and UI-2 layout are exactly the
|
|
6
|
+
web's, because it is the web's server that answers. Nothing of a page's logic
|
|
7
|
+
is translated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from urllib.parse import urljoin
|
|
12
|
+
|
|
13
|
+
INSTALL_HINT = 'pip install "quantum-framework[desktop]"'
|
|
14
|
+
|
|
15
|
+
DEFAULT_WIDTH = 1024
|
|
16
|
+
DEFAULT_HEIGHT = 720
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _webview():
|
|
20
|
+
try:
|
|
21
|
+
import webview
|
|
22
|
+
except ImportError:
|
|
23
|
+
return None
|
|
24
|
+
return webview
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def open_window(webview, url: str, title: str = 'Quantum',
|
|
28
|
+
width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT):
|
|
29
|
+
"""Create the window on `url`; its title follows each page's <title>."""
|
|
30
|
+
janela = webview.create_window(title, url, width=width, height=height, min_size=(360, 480))
|
|
31
|
+
|
|
32
|
+
def ao_carregar():
|
|
33
|
+
titulo = janela.evaluate_js('document.title')
|
|
34
|
+
if titulo:
|
|
35
|
+
janela.set_title(str(titulo))
|
|
36
|
+
|
|
37
|
+
janela.events.loaded += ao_carregar
|
|
38
|
+
return janela
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_desktop(config_path: str = 'quantum.config.yaml', path: str = '/',
|
|
42
|
+
width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT,
|
|
43
|
+
webview=None) -> int:
|
|
44
|
+
"""`quantum desktop`: the application's server in a thread, the pages in a window."""
|
|
45
|
+
webview = webview or _webview()
|
|
46
|
+
if webview is None:
|
|
47
|
+
print(f'[ERROR] quantum desktop needs pywebview: {INSTALL_HINT}', file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
from quantum.runtime.local_server import local_server
|
|
50
|
+
|
|
51
|
+
with local_server(config_path) as base_url:
|
|
52
|
+
open_window(webview, urljoin(base_url, path.lstrip('/')), width=width, height=height)
|
|
53
|
+
# Blocks until the window closes; the server stops with it.
|
|
54
|
+
webview.start(private_mode=True)
|
|
55
|
+
return 0
|
|
56
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Forms that know their action's rules (M1, UI-9).
|
|
2
|
+
|
|
3
|
+
A `<ui:form on-submit="criar">` posts to `<q:action name="criar">`, whose
|
|
4
|
+
`q:param`s already say what each field must be. The form takes those rules
|
|
5
|
+
as HTML attributes — `required`, `minlength`, `min`, `type="number"`… — so the
|
|
6
|
+
browser checks them before posting; the server still validates (ACT-2). When
|
|
7
|
+
the server's validation fails, the next render of that form shows the values
|
|
8
|
+
that were sent (never a password) and each error next to its field.
|
|
9
|
+
|
|
10
|
+
What a field gets is visible in the HTML it draws; `<ui:form rules="off">`
|
|
11
|
+
turns it off; an attribute written on the field always wins.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import html
|
|
16
|
+
from typing import Dict, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from quantum.core.features.ui_engine.src.ast_nodes import (
|
|
19
|
+
UICheckboxNode, UIInputNode, UIRadioNode, UISelectNode, UISwitchNode)
|
|
20
|
+
|
|
21
|
+
FIELDS = (UIInputNode, UISelectNode, UIRadioNode, UICheckboxNode, UISwitchNode)
|
|
22
|
+
|
|
23
|
+
# q:param type -> the input type the browser checks
|
|
24
|
+
_HTML_TYPE = {'integer': 'number', 'number': 'number', 'decimal': 'number', 'float': 'number',
|
|
25
|
+
'email': 'email', 'url': 'url', 'date': 'date'}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FormContext:
|
|
29
|
+
"""What a form knows while its fields are drawn."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, params=None, state: Optional[dict] = None, rules: bool = True,
|
|
32
|
+
initial: Optional[dict] = None, action=None, config=None):
|
|
33
|
+
self.all_params = list(params or [])
|
|
34
|
+
self.params = {p.name: p for p in self.all_params} if rules else {}
|
|
35
|
+
self.errors: Dict[str, str] = (state or {}).get('errors') or {}
|
|
36
|
+
self.values: Optional[Dict[str, str]] = (state or {}).get('values') if state else None
|
|
37
|
+
self.initial: Dict[str, object] = initial or {} # UI-10: values="{row}"
|
|
38
|
+
self.action = action
|
|
39
|
+
self.config = config or {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def form_context(renderer, form_node, action_name: Optional[str]) -> Optional[FormContext]:
|
|
43
|
+
if not action_name:
|
|
44
|
+
return None
|
|
45
|
+
acao = (getattr(renderer, '_ui_action_nodes', None) or {}).get(action_name)
|
|
46
|
+
estado = getattr(renderer, 'form_state', None)
|
|
47
|
+
if not estado or estado.get('action') != action_name:
|
|
48
|
+
estado = None
|
|
49
|
+
config = getattr(renderer, 'config', None) or {}
|
|
50
|
+
params = []
|
|
51
|
+
if acao is not None:
|
|
52
|
+
from quantum.runtime.table_params import TableParamsError, params_of
|
|
53
|
+
try:
|
|
54
|
+
params = params_of(acao, config)
|
|
55
|
+
except TableParamsError as exc:
|
|
56
|
+
raise ValueError(f'<ui:form on-submit="{action_name}">: {exc}') from exc
|
|
57
|
+
inicial = {}
|
|
58
|
+
expressao = getattr(form_node, 'values', None)
|
|
59
|
+
if expressao:
|
|
60
|
+
linha = renderer._apply_databinding(expressao)
|
|
61
|
+
if isinstance(linha, list) and len(linha) == 1:
|
|
62
|
+
linha = linha[0] # a q:query of one row
|
|
63
|
+
if linha not in (None, ''):
|
|
64
|
+
if not isinstance(linha, dict):
|
|
65
|
+
raise ValueError(f'<ui:form values="{expressao}"> needs a row (a record), '
|
|
66
|
+
f'got {type(linha).__name__} (UI-10)')
|
|
67
|
+
inicial = linha
|
|
68
|
+
return FormContext(params, estado, getattr(form_node, 'rules', None) != 'off', inicial, acao, config)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prepare_field(node, ctx: Optional[FormContext], escape: bool) -> Tuple[object, Optional[str]]:
|
|
72
|
+
"""A copy of the field with the action's rules and the values sent, and its error.
|
|
73
|
+
|
|
74
|
+
`escape`: the HTML renderer writes attribute values as they are, so a
|
|
75
|
+
value sent back to the page is escaped here; the view tree is not (UI-3).
|
|
76
|
+
"""
|
|
77
|
+
nome = getattr(node, 'bind', None)
|
|
78
|
+
if ctx is None or not nome:
|
|
79
|
+
return node, None
|
|
80
|
+
copia = copy.copy(node)
|
|
81
|
+
param = ctx.params.get(nome)
|
|
82
|
+
if param is not None:
|
|
83
|
+
if isinstance(copia, UIInputNode):
|
|
84
|
+
if param.required and not copia.required:
|
|
85
|
+
copia.required = True
|
|
86
|
+
for campo in ('minlength', 'maxlength'):
|
|
87
|
+
valor = getattr(param, campo, None)
|
|
88
|
+
if getattr(copia, campo) is None and valor is not None:
|
|
89
|
+
setattr(copia, campo, int(valor))
|
|
90
|
+
for campo in ('min', 'max'):
|
|
91
|
+
valor = getattr(param, campo, None)
|
|
92
|
+
if getattr(copia, campo) is None and valor is not None:
|
|
93
|
+
setattr(copia, campo, str(valor))
|
|
94
|
+
padrao = getattr(param, 'pattern', None) or ''
|
|
95
|
+
# The server searches (re.search); HTML's pattern must match the
|
|
96
|
+
# whole value. Only an anchored ^…$ pattern means the same in both.
|
|
97
|
+
if not copia.pattern and padrao.startswith('^') and padrao.endswith('$'):
|
|
98
|
+
copia.pattern = padrao[1:-1]
|
|
99
|
+
tipo = _HTML_TYPE.get(str(getattr(param, 'type', '') or '').lower())
|
|
100
|
+
if tipo and (copia.input_type or 'text') == 'text':
|
|
101
|
+
copia.input_type = tipo
|
|
102
|
+
elif isinstance(copia, (UISelectNode, UIRadioNode)):
|
|
103
|
+
if not copia.options and not getattr(copia, 'children', None) and getattr(param, 'enum', None):
|
|
104
|
+
copia.options = param.enum
|
|
105
|
+
if ctx.values is None and nome in ctx.initial:
|
|
106
|
+
# UI-10: an edit form opens with the row's value (unless the field sets its own).
|
|
107
|
+
inicial = ctx.initial[nome]
|
|
108
|
+
if isinstance(copia, (UICheckboxNode, UISwitchNode)):
|
|
109
|
+
if getattr(copia, 'checked', None) is None:
|
|
110
|
+
copia.checked = 'true' if inicial not in (None, '', 0, '0', False, 'false') else 'false'
|
|
111
|
+
elif getattr(copia, 'value', None) in (None, '') and inicial is not None and not (isinstance(copia, UIInputNode) and copia.input_type == 'password'):
|
|
112
|
+
copia.value = html.escape(str(inicial), quote=True) if escape else str(inicial)
|
|
113
|
+
if ctx.values is not None:
|
|
114
|
+
if isinstance(copia, (UICheckboxNode, UISwitchNode)):
|
|
115
|
+
# A browser sends a checked box and omits an unchecked one.
|
|
116
|
+
copia.checked = 'true' if nome in ctx.values else 'false'
|
|
117
|
+
elif nome in ctx.values and not (isinstance(copia, UIInputNode) and copia.input_type == 'password'):
|
|
118
|
+
valor = str(ctx.values[nome])
|
|
119
|
+
copia.value = html.escape(valor, quote=True) if escape else valor
|
|
120
|
+
erro = ctx.errors.get(nome)
|
|
121
|
+
if erro:
|
|
122
|
+
copia.ui_class = ((getattr(copia, 'ui_class', None) or '') + ' q-invalid').strip()
|
|
123
|
+
return copia, erro
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
FORM_CSS = """
|
|
127
|
+
/* UI-9: a field the server's validation refused, and its message */
|
|
128
|
+
.q-invalid, .q-invalid input { border-color: var(--q-danger, #dc2626) !important; }
|
|
129
|
+
.q-field-error { display: block; color: var(--q-danger, #dc2626); font-size: 13px; margin-top: 4px; }
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def has_fields(nodes) -> bool:
|
|
134
|
+
"""Whether a form's markup already has fields of its own."""
|
|
135
|
+
for no in nodes or []:
|
|
136
|
+
if isinstance(no, FIELDS) or has_fields(getattr(no, 'children', None)):
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def has_button(nodes) -> bool:
|
|
142
|
+
from quantum.core.features.ui_engine.src.ast_nodes import UIButtonNode
|
|
143
|
+
for no in nodes or []:
|
|
144
|
+
if isinstance(no, UIButtonNode) or has_button(getattr(no, 'children', None)):
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def generated_fields(form_node, ctx: Optional[FormContext]) -> list:
|
|
150
|
+
"""UI-10: a form with no fields draws one per param of its action, and a button."""
|
|
151
|
+
from quantum.core.features.ui_engine.src.ast_nodes import (
|
|
152
|
+
UIButtonNode, UIFormItemNode, UIOptionNode)
|
|
153
|
+
from quantum.runtime.table_params import humanize, reference_options
|
|
154
|
+
filhos = list(getattr(form_node, 'children', None) or [])
|
|
155
|
+
if ctx is None or not ctx.params or has_fields(filhos):
|
|
156
|
+
return filhos
|
|
157
|
+
gerados = []
|
|
158
|
+
for param in ctx.all_params:
|
|
159
|
+
rotulo = humanize(param.name)
|
|
160
|
+
if str(param.type).lower() == 'boolean':
|
|
161
|
+
campo = UICheckboxNode()
|
|
162
|
+
campo.bind, campo.label = param.name, rotulo
|
|
163
|
+
gerados.append(campo)
|
|
164
|
+
continue
|
|
165
|
+
referencia = getattr(param, 'references', None)
|
|
166
|
+
if referencia and ctx.action is not None:
|
|
167
|
+
campo = UISelectNode()
|
|
168
|
+
campo.bind = param.name
|
|
169
|
+
if not param.required:
|
|
170
|
+
vazio = UIOptionNode()
|
|
171
|
+
vazio.value, vazio.label = '', '—'
|
|
172
|
+
campo.children.append(vazio)
|
|
173
|
+
for valor, texto in reference_options(ctx.config, ctx.action.table_datasource, referencia):
|
|
174
|
+
opcao = UIOptionNode()
|
|
175
|
+
opcao.value, opcao.label = valor, texto
|
|
176
|
+
campo.children.append(opcao)
|
|
177
|
+
elif param.enum:
|
|
178
|
+
campo = UISelectNode()
|
|
179
|
+
campo.bind = param.name
|
|
180
|
+
else:
|
|
181
|
+
campo = UIInputNode()
|
|
182
|
+
campo.bind = param.name
|
|
183
|
+
item = UIFormItemNode()
|
|
184
|
+
item.label = rotulo
|
|
185
|
+
item.children.append(campo)
|
|
186
|
+
gerados.append(item)
|
|
187
|
+
if not has_button(filhos):
|
|
188
|
+
botao = UIButtonNode()
|
|
189
|
+
botao.content = getattr(form_node, 'submit', None) or 'Save'
|
|
190
|
+
botao.variant = 'primary'
|
|
191
|
+
gerados.append(botao)
|
|
192
|
+
return filhos + gerados
|
|
193
|
+
|