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