ui-design-workbench-cli 0.5.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.
- android_resource_resolver.py +370 -0
- android_xml_support.py +230 -0
- apple_resource_resolver.py +141 -0
- coverage_report.py +134 -0
- fidelity_adapter_api.py +89 -0
- fidelity_adapters.py +508 -0
- fidelity_core.py +241 -0
- fidelity_platform_adapters.py +902 -0
- ir_contracts.py +76 -0
- native_render_android.py +160 -0
- native_render_apple.py +165 -0
- native_render_contracts.py +148 -0
- native_render_registry.py +86 -0
- quality_common.py +217 -0
- render_preview.py +1797 -0
- scan_ui.py +1485 -0
- scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
- ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
- ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
- ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
- ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
- ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
- uidw.py +2821 -0
- uidw_mcp.py +282 -0
- validate_platform_profiles.py +93 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Built-in platform adapters beyond static Web and Jetpack Compose."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from android_resource_resolver import AndroidResourceCatalog
|
|
12
|
+
from android_xml_support import android_layout_metadata
|
|
13
|
+
from apple_resource_resolver import AppleResourceCatalog, sf_symbol_asset
|
|
14
|
+
from fidelity_adapter_api import AdapterResult, SourceContext
|
|
15
|
+
from fidelity_core import property_evidence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _slug(value: str, fallback: str = "node") -> str:
|
|
19
|
+
separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
|
|
20
|
+
return re.sub(r"[^a-z0-9]+", "-", separated.lower()).strip("-") or fallback
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _local(value: str) -> str:
|
|
24
|
+
return value.rsplit("}", 1)[-1].rsplit(":", 1)[-1]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _attrs(element: ET.Element) -> dict[str, str]:
|
|
28
|
+
return {_local(key): str(value) for key, value in element.attrib.items()}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _line(text: str, expression: str, start: int = 0) -> int:
|
|
32
|
+
offset = text.find(expression, start)
|
|
33
|
+
return text.count("\n", 0, max(0, offset)) + 1 if offset >= 0 else 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _balanced_close(text: str, start: int, opening: str, closing: str) -> int | None:
|
|
37
|
+
depth, quote, escaped = 0, None, False
|
|
38
|
+
for index in range(start, len(text)):
|
|
39
|
+
char = text[index]
|
|
40
|
+
if quote:
|
|
41
|
+
if escaped:
|
|
42
|
+
escaped = False
|
|
43
|
+
elif char == "\\":
|
|
44
|
+
escaped = True
|
|
45
|
+
elif char == quote:
|
|
46
|
+
quote = None
|
|
47
|
+
continue
|
|
48
|
+
if char in {'"', "'"}:
|
|
49
|
+
quote = char
|
|
50
|
+
elif char == opening:
|
|
51
|
+
depth += 1
|
|
52
|
+
elif char == closing:
|
|
53
|
+
depth -= 1
|
|
54
|
+
if depth == 0:
|
|
55
|
+
return index
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_ref(value: str, preferred_group: str = "spacing") -> Any:
|
|
60
|
+
value = value.strip()
|
|
61
|
+
if value in {"match_parent", "-1"}: return "fill"
|
|
62
|
+
if value in {"wrap_content", "Auto"}: return "hug"
|
|
63
|
+
numeric = re.fullmatch(r"-?\d+(?:\.\d+)?", value)
|
|
64
|
+
if numeric: return float(value) if "." in value else int(value)
|
|
65
|
+
dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)(?:dp|sp|pt)", value)
|
|
66
|
+
if dimension: return f"{dimension.group(1)}px"
|
|
67
|
+
match = re.fullmatch(r"@(color|dimen|string)/([\w.-]+)", value)
|
|
68
|
+
if match:
|
|
69
|
+
resource_type, resource_name = match.groups()
|
|
70
|
+
group = "colors" if resource_type == "color" else "strings" if resource_type == "string" else preferred_group
|
|
71
|
+
return f"${group}.{_slug(resource_name).replace('-', '_')}"
|
|
72
|
+
match = re.fullmatch(r"\{(?:StaticResource|ThemeResource)\s+([\w.-]+)\}", value)
|
|
73
|
+
if match:
|
|
74
|
+
return f"${preferred_group}.{_slug(match.group(1)).replace('-', '_')}"
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _set(node: dict[str, Any], path: str, value: Any, context: SourceContext, line: int, expression: str, adapter: str, confidence: str = "exact") -> None:
|
|
79
|
+
group, key = path.split(".", 1)
|
|
80
|
+
node.setdefault(group, {})[key] = value
|
|
81
|
+
node.setdefault("provenance", {})[path] = property_evidence(context.source, line, expression, adapter, confidence)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _root_node(context: SourceContext, adapter: str, standard: str, line: int = 1) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": [],
|
|
87
|
+
"source": {"file": context.source, "line": line}, "confidence": "high", "standardRef": standard,
|
|
88
|
+
"provenance": {
|
|
89
|
+
"layout.direction": property_evidence(context.source, line, "screen root", adapter, "high"),
|
|
90
|
+
"layout.width": property_evidence(context.source, line, "screen bounds", adapter, "high"),
|
|
91
|
+
"layout.height": property_evidence(context.source, line, "screen bounds", adapter, "high"),
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AndroidXmlAdapter:
|
|
97
|
+
id = "android-xml"
|
|
98
|
+
platforms = ("android",)
|
|
99
|
+
extensions = (".xml",)
|
|
100
|
+
maturity = "structural"
|
|
101
|
+
structural_tier = "translated"
|
|
102
|
+
visual_tier = "none"
|
|
103
|
+
native_evidence_required = True
|
|
104
|
+
native_providers = ("paparazzi", "roborazzi", "android-emulator")
|
|
105
|
+
resource_resolution = ("values strings/colors/dimens", "styles", "bitmap/vector/shape drawables", "static layout includes")
|
|
106
|
+
layout_features = ("LinearLayout gravity/weight", "Frame/Constraint overlay", "parent-edge centering", "directional padding/margins", "image scaleType")
|
|
107
|
+
limitations = ("custom views are not expanded", "complex ConstraintLayout equations and runtime resources remain explicit gaps")
|
|
108
|
+
TYPES = {
|
|
109
|
+
"LinearLayout": "container", "ConstraintLayout": "container", "FrameLayout": "container", "RelativeLayout": "container",
|
|
110
|
+
"CoordinatorLayout": "container", "AppBarLayout": "container", "Toolbar": "container", "MaterialToolbar": "container",
|
|
111
|
+
"ScrollView": "container", "NestedScrollView": "container", "HorizontalScrollView": "container", "ViewPager": "container",
|
|
112
|
+
"ViewPager2": "container", "SwipeRefreshLayout": "container", "DrawerLayout": "container", "NavigationView": "container",
|
|
113
|
+
"RecyclerView": "list", "ListView": "list", "GridView": "list", "TabLayout": "container",
|
|
114
|
+
"TextView": "text", "AppCompatTextView": "text", "MaterialTextView": "text", "Button": "button",
|
|
115
|
+
"AppCompatButton": "button", "MaterialButton": "button", "FloatingActionButton": "button", "ExtendedFloatingActionButton": "button",
|
|
116
|
+
"EditText": "input", "AppCompatEditText": "input", "TextInputEditText": "input", "TextInputLayout": "container",
|
|
117
|
+
"CheckBox": "button", "RadioButton": "button", "Switch": "button", "SwitchMaterial": "button", "Chip": "button",
|
|
118
|
+
"MaterialCheckBox": "button", "MaterialSwitch": "button", "CheckedTextView": "button", "RadioGroup": "container",
|
|
119
|
+
"MaterialButtonToggleGroup": "container", "GridLayout": "container", "ShimmerFrameLayout": "container", "SearchView": "input",
|
|
120
|
+
"ImageView": "image", "AppCompatImageView": "image", "ShapeableImageView": "image", "ImageButton": "button",
|
|
121
|
+
"AppCompatImageButton": "button", "CardView": "card", "MaterialCardView": "card", "View": "divider", "Space": "spacer",
|
|
122
|
+
"ProgressBar": "custom", "Guideline": "spacer", "Barrier": "spacer", "Group": "container",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self._catalogs: dict[str, AndroidResourceCatalog] = {}
|
|
127
|
+
|
|
128
|
+
def prepare(self, contexts: list[SourceContext]) -> None:
|
|
129
|
+
roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
|
|
130
|
+
self._catalogs = {key: AndroidResourceCatalog.discover(root) for key, root in roots.items()}
|
|
131
|
+
|
|
132
|
+
def _catalog(self, context: SourceContext) -> AndroidResourceCatalog:
|
|
133
|
+
key = str(context.root.resolve())
|
|
134
|
+
if key not in self._catalogs:
|
|
135
|
+
self._catalogs[key] = AndroidResourceCatalog.discover(context.root)
|
|
136
|
+
return self._catalogs[key]
|
|
137
|
+
|
|
138
|
+
def supports(self, context: SourceContext) -> bool:
|
|
139
|
+
return context.path.suffix.lower() == ".xml" and any(value.startswith("android-") for value in context.platforms)
|
|
140
|
+
|
|
141
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
142
|
+
result = AdapterResult(adapter=self.id)
|
|
143
|
+
catalog = self._catalog(context)
|
|
144
|
+
try:
|
|
145
|
+
root = ET.fromstring(context.text)
|
|
146
|
+
except ET.ParseError as exc:
|
|
147
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-xml"})
|
|
148
|
+
return result
|
|
149
|
+
normalized_source = "/" + context.source.lower().replace("\\", "/")
|
|
150
|
+
if "/res/color" in normalized_source:
|
|
151
|
+
items = [child for child in root if _local(child.tag) == "item"] if _local(root.tag) == "selector" else []
|
|
152
|
+
default_item = next((child for child in reversed(items) if not any(key.startswith("state_") for key in _attrs(child))), None)
|
|
153
|
+
selected = default_item if default_item is not None else items[-1] if items else root
|
|
154
|
+
value = _attrs(selected).get("color") or (selected.text or "").strip()
|
|
155
|
+
if value:
|
|
156
|
+
result.tokens["colors"][_slug(context.path.stem).replace("-", "_")] = {
|
|
157
|
+
"value": _normalize_ref(value, "colors"),
|
|
158
|
+
"source": {"file": context.source, "line": _line(context.text, value)},
|
|
159
|
+
"adapter": self.id,
|
|
160
|
+
}
|
|
161
|
+
if len(items) > 1:
|
|
162
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": 1, "expression": context.path.name, "reason": "stateful-color-selector-uses-default-state"})
|
|
163
|
+
return result
|
|
164
|
+
if _local(root.tag) == "resources":
|
|
165
|
+
for child in root:
|
|
166
|
+
tag, attrs, value = _local(child.tag), _attrs(child), (child.text or "").strip()
|
|
167
|
+
name = attrs.get("name") or attrs.get("Key")
|
|
168
|
+
if not name:
|
|
169
|
+
continue
|
|
170
|
+
line = _line(context.text, name)
|
|
171
|
+
if tag == "style":
|
|
172
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": name, "reason": "unresolved-android-style"})
|
|
173
|
+
continue
|
|
174
|
+
if not value:
|
|
175
|
+
continue
|
|
176
|
+
if tag in {"color", "Color", "SolidColorBrush"}:
|
|
177
|
+
groups = ("colors",)
|
|
178
|
+
elif tag == "dimen":
|
|
179
|
+
groups = ("spacing", "typography") if value.endswith("sp") else ("spacing",)
|
|
180
|
+
elif tag in {"string", "plurals", "string-array"}:
|
|
181
|
+
groups = ("strings",)
|
|
182
|
+
elif tag in {"bool", "integer", "fraction"}:
|
|
183
|
+
groups = ("values",)
|
|
184
|
+
else:
|
|
185
|
+
continue
|
|
186
|
+
for group in groups:
|
|
187
|
+
result.tokens.setdefault(group, {})[_slug(name).replace("-", "_")] = {
|
|
188
|
+
"value": _normalize_ref(value, group),
|
|
189
|
+
"source": {"file": context.source, "line": line},
|
|
190
|
+
"adapter": self.id,
|
|
191
|
+
}
|
|
192
|
+
if "/values-night" in "/" + context.source.lower().replace("\\", "/"):
|
|
193
|
+
result.themes.append({"id": "dark", "label": "Dark", "kind": "dark", "sourceRefs": [{"file": context.source, "line": 1, "reason": "values-night"}], "tokenOverrides": result.tokens, "nodeOverrides": {}})
|
|
194
|
+
result.tokens = {group: {} for group in result.tokens}
|
|
195
|
+
return result
|
|
196
|
+
if context.role == "navigation":
|
|
197
|
+
return result
|
|
198
|
+
content_root = root
|
|
199
|
+
if _local(root.tag) == "layout":
|
|
200
|
+
content_root = next((child for child in root if isinstance(child.tag, str) and _local(child.tag) != "data"), None)
|
|
201
|
+
if content_root is None:
|
|
202
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": 1, "expression": "<layout>", "reason": "data-binding-layout-without-view-root"})
|
|
203
|
+
return result
|
|
204
|
+
platform = "android"
|
|
205
|
+
screen_id = _slug(context.path.stem, "android-layout")
|
|
206
|
+
root_id = f"{screen_id}-root"
|
|
207
|
+
result.nodes[root_id] = _root_node(context, self.id, "project.android.layout")
|
|
208
|
+
count = 0
|
|
209
|
+
|
|
210
|
+
def convert(element: ET.Element, active_context: SourceContext, active_text: str, include_stack: tuple[str, ...] = ()) -> str:
|
|
211
|
+
nonlocal count
|
|
212
|
+
count += 1
|
|
213
|
+
raw_tag, explicit_attrs = _local(element.tag), _attrs(element)
|
|
214
|
+
if raw_tag == "include":
|
|
215
|
+
reference = explicit_attrs.get("layout", "")
|
|
216
|
+
line = _line(active_text, reference or "<include")
|
|
217
|
+
node_id = f"{screen_id}-include-{count}"
|
|
218
|
+
node = {
|
|
219
|
+
"type": "container", "component": "include", "layout": {"width": "fill"}, "style": {}, "children": [],
|
|
220
|
+
"source": {"file": active_context.source, "line": line}, "confidence": "high", "standardRef": "project.android.include",
|
|
221
|
+
"provenance": {
|
|
222
|
+
"component": property_evidence(active_context.source, line, reference or "include", self.id, "exact"),
|
|
223
|
+
"layout.width": property_evidence(active_context.source, line, reference or "include", self.id, "high"),
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
target = catalog.layout(reference)
|
|
227
|
+
if not target or reference in include_stack:
|
|
228
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": reference, "reason": "unresolved-or-cyclic-layout-include"})
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
target_text = target.read_text(encoding="utf-8", errors="replace")
|
|
232
|
+
target_root = ET.fromstring(target_text)
|
|
233
|
+
target_context = SourceContext(active_context.root, target, target.relative_to(active_context.root).as_posix(), target_text, active_context.platforms, "component")
|
|
234
|
+
candidates = list(target_root) if _local(target_root.tag) == "merge" else [target_root]
|
|
235
|
+
node["children"] = [convert(child, target_context, target_text, (*include_stack, reference)) for child in candidates]
|
|
236
|
+
except (OSError, ET.ParseError, ValueError) as exc:
|
|
237
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": reference, "reason": f"invalid-layout-include:{type(exc).__name__}"})
|
|
238
|
+
result.nodes[node_id] = node
|
|
239
|
+
return node_id
|
|
240
|
+
style_values, style_entry = catalog.style(explicit_attrs.get("style"))
|
|
241
|
+
normalized_style = {key.rsplit(":", 1)[-1]: value for key, value in style_values.items()}
|
|
242
|
+
attrs = {**normalized_style, **explicit_attrs}
|
|
243
|
+
short = raw_tag.rsplit(".", 1)[-1]
|
|
244
|
+
node_id = f"{screen_id}-{_slug(attrs.get('id', '').split('/')[-1] or short)}-{count}"
|
|
245
|
+
node_type = self.TYPES.get(short, "custom")
|
|
246
|
+
line = _line(active_text, f"<{raw_tag}")
|
|
247
|
+
standard_prefix = "material3" if short.startswith("Material") or "TextInput" in short else "project"
|
|
248
|
+
node: dict[str, Any] = {
|
|
249
|
+
"type": node_type, "component": raw_tag, "layout": {}, "style": {}, "children": [], "source": {"file": active_context.source, "line": line},
|
|
250
|
+
"confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"{standard_prefix}.android-xml.{short.lower()}", "provenance": {},
|
|
251
|
+
}
|
|
252
|
+
if node_type != "custom":
|
|
253
|
+
node["inheritsAppearance"] = True
|
|
254
|
+
node["provenance"]["component"] = property_evidence(active_context.source, line, raw_tag, self.id, "exact")
|
|
255
|
+
else:
|
|
256
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": raw_tag, "reason": "unsupported-android-view"})
|
|
257
|
+
if short in {"FrameLayout", "ConstraintLayout", "CoordinatorLayout"}:
|
|
258
|
+
_set(node, "layout.direction", "overlay", active_context, line, short, self.id)
|
|
259
|
+
mapping = {
|
|
260
|
+
"layout_width": ("layout.width", "spacing"), "layout_height": ("layout.height", "spacing"), "padding": ("layout.padding", "spacing"),
|
|
261
|
+
"paddingHorizontal": ("layout.paddingHorizontal", "spacing"), "paddingVertical": ("layout.paddingVertical", "spacing"),
|
|
262
|
+
"paddingStart": ("layout.paddingLeft", "spacing"), "paddingEnd": ("layout.paddingRight", "spacing"), "paddingTop": ("layout.paddingTop", "spacing"), "paddingBottom": ("layout.paddingBottom", "spacing"),
|
|
263
|
+
"layout_margin": ("layout.margin", "spacing"), "layout_marginHorizontal": ("layout.marginHorizontal", "spacing"), "layout_marginVertical": ("layout.marginVertical", "spacing"),
|
|
264
|
+
"layout_marginStart": ("layout.marginLeft", "spacing"), "layout_marginEnd": ("layout.marginRight", "spacing"), "layout_marginTop": ("layout.marginTop", "spacing"), "layout_marginBottom": ("layout.marginBottom", "spacing"),
|
|
265
|
+
"minWidth": ("layout.minWidth", "spacing"), "minHeight": ("layout.minHeight", "spacing"), "maxWidth": ("layout.maxWidth", "spacing"), "maxHeight": ("layout.maxHeight", "spacing"),
|
|
266
|
+
"textColor": ("style.color", "color"), "textSize": ("style.fontSize", "dimen"), "alpha": ("style.opacity", "spacing"), "fontFamily": ("style.fontFamily", "font"),
|
|
267
|
+
"tint": ("style.color", "color"),
|
|
268
|
+
}
|
|
269
|
+
for attr, (path, group) in mapping.items():
|
|
270
|
+
if attr in attrs:
|
|
271
|
+
resolved = catalog.resolve(attrs[attr], group, normalized_style)
|
|
272
|
+
value = resolved.value if resolved.resolved else _normalize_ref(attrs[attr], "colors" if group == "color" else group)
|
|
273
|
+
source = resolved.source or (style_entry.source if attr not in explicit_attrs and style_entry else active_context.source)
|
|
274
|
+
source_line = resolved.line if resolved.source else style_entry.line if attr not in explicit_attrs and style_entry else line
|
|
275
|
+
group_name, key = path.split(".", 1)
|
|
276
|
+
node.setdefault(group_name, {})[key] = value
|
|
277
|
+
node["provenance"][path] = property_evidence(source, source_line, f'{attr}="{attrs[attr]}"', self.id, resolved.confidence if resolved.source else "exact")
|
|
278
|
+
if attrs.get("orientation") in {"vertical", "horizontal"}:
|
|
279
|
+
_set(node, "layout.direction", "column" if attrs["orientation"] == "vertical" else "row", active_context, line, f'orientation="{attrs["orientation"]}"', self.id)
|
|
280
|
+
direction = node.get("layout", {}).get("direction", "column")
|
|
281
|
+
gravity = attrs.get("gravity", "")
|
|
282
|
+
if gravity:
|
|
283
|
+
if "center" == gravity or "center_horizontal" in gravity:
|
|
284
|
+
_set(node, "layout.align" if direction == "column" else "layout.justify", "center", active_context, line, f'gravity="{gravity}"', self.id)
|
|
285
|
+
if "center" == gravity or "center_vertical" in gravity:
|
|
286
|
+
_set(node, "layout.justify" if direction == "column" else "layout.align", "center", active_context, line, f'gravity="{gravity}"', self.id)
|
|
287
|
+
if "end" in gravity or "right" in gravity:
|
|
288
|
+
_set(node, "layout.align" if direction == "column" else "layout.justify", "end", active_context, line, f'gravity="{gravity}"', self.id)
|
|
289
|
+
layout_gravity = attrs.get("layout_gravity", "")
|
|
290
|
+
if "center" in layout_gravity:
|
|
291
|
+
_set(node, "layout.alignSelf", "center", active_context, line, f'layout_gravity="{layout_gravity}"', self.id)
|
|
292
|
+
_set(node, "layout.justifySelf", "center", active_context, line, f'layout_gravity="{layout_gravity}"', self.id)
|
|
293
|
+
if attrs.get("layout_weight"):
|
|
294
|
+
_set(node, "layout.grow", _normalize_ref(attrs["layout_weight"]), active_context, line, f'layout_weight="{attrs["layout_weight"]}"', self.id)
|
|
295
|
+
text_style = attrs.get("textStyle", "")
|
|
296
|
+
if "bold" in text_style:
|
|
297
|
+
_set(node, "style.fontWeight", 700, active_context, line, f'textStyle="{text_style}"', self.id)
|
|
298
|
+
if "italic" in text_style:
|
|
299
|
+
_set(node, "style.fontStyle", "italic", active_context, line, f'textStyle="{text_style}"', self.id)
|
|
300
|
+
text_alignment = {"center": "center", "viewStart": "start", "textStart": "start", "viewEnd": "end", "textEnd": "end"}.get(attrs.get("textAlignment", ""))
|
|
301
|
+
if text_alignment:
|
|
302
|
+
_set(node, "style.textAlign", text_alignment, active_context, line, f'textAlignment="{attrs["textAlignment"]}"', self.id)
|
|
303
|
+
constraints = {key: value for key, value in attrs.items() if key.startswith("layout_constraint")}
|
|
304
|
+
if constraints:
|
|
305
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": ", ".join(sorted(constraints)), "reason": "unresolved-constraint-equations"})
|
|
306
|
+
horizontal_center = constraints.get("layout_constraintStart_toStartOf") == "parent" and constraints.get("layout_constraintEnd_toEndOf") == "parent"
|
|
307
|
+
vertical_center = constraints.get("layout_constraintTop_toTopOf") == "parent" and constraints.get("layout_constraintBottom_toBottomOf") == "parent"
|
|
308
|
+
if horizontal_center:
|
|
309
|
+
_set(node, "layout.justifySelf", "stretch" if attrs.get("layout_width") == "0dp" else "center", active_context, line, "horizontal parent constraints", self.id, "high")
|
|
310
|
+
if attrs.get("layout_width") == "0dp": node["layout"]["width"] = "fill"
|
|
311
|
+
if vertical_center:
|
|
312
|
+
_set(node, "layout.alignSelf", "stretch" if attrs.get("layout_height") == "0dp" else "center", active_context, line, "vertical parent constraints", self.id, "high")
|
|
313
|
+
if attrs.get("layout_height") == "0dp": node["layout"]["height"] = "fill"
|
|
314
|
+
unsupported_constraints = [key for key in constraints if key not in {"layout_constraintStart_toStartOf", "layout_constraintEnd_toEndOf", "layout_constraintTop_toTopOf", "layout_constraintBottom_toBottomOf"}]
|
|
315
|
+
if unsupported_constraints:
|
|
316
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": ", ".join(sorted(unsupported_constraints)), "reason": "unsupported-constraint-equation"})
|
|
317
|
+
if attrs.get("text"):
|
|
318
|
+
resolved = catalog.resolve(attrs["text"], "string", normalized_style)
|
|
319
|
+
node["text"] = resolved.value if resolved.resolved else _normalize_ref(attrs["text"], "strings")
|
|
320
|
+
node["provenance"]["text"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'text="{attrs["text"]}"', self.id, resolved.confidence if resolved.source else "exact")
|
|
321
|
+
if attrs.get("hint"):
|
|
322
|
+
resolved = catalog.resolve(attrs["hint"], "string", normalized_style)
|
|
323
|
+
node["placeholder"] = resolved.value if resolved.resolved else attrs["hint"]
|
|
324
|
+
node["provenance"]["placeholder"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'hint="{attrs["hint"]}"', self.id, resolved.confidence if resolved.source else "exact")
|
|
325
|
+
if attrs.get("contentDescription"):
|
|
326
|
+
resolved = catalog.resolve(attrs["contentDescription"], "string", normalized_style)
|
|
327
|
+
node["alt"] = resolved.value if resolved.resolved else attrs["contentDescription"]
|
|
328
|
+
node["semantics"] = {"role": "button" if node_type == "button" else "image", "label": node["alt"]}
|
|
329
|
+
node["provenance"]["alt"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'contentDescription="{attrs["contentDescription"]}"', self.id, resolved.confidence if resolved.source else "exact")
|
|
330
|
+
if attrs.get("background", "").startswith("@drawable/"):
|
|
331
|
+
drawable = catalog.drawable(attrs["background"], normalized_style)
|
|
332
|
+
node["style"].update(drawable.style)
|
|
333
|
+
for key in drawable.style:
|
|
334
|
+
node["provenance"][f"style.{key}"] = property_evidence(drawable.source, drawable.line, attrs["background"], self.id, drawable.confidence)
|
|
335
|
+
if not drawable.style:
|
|
336
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": attrs["background"], "reason": "unsupported-background-drawable"})
|
|
337
|
+
elif attrs.get("background"):
|
|
338
|
+
resolved = catalog.resolve(attrs["background"], "color", normalized_style)
|
|
339
|
+
node["style"]["backgroundColor"] = resolved.value if resolved.resolved else attrs["background"]
|
|
340
|
+
node["provenance"]["style.backgroundColor"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, attrs["background"], self.id, resolved.confidence if resolved.source else "exact")
|
|
341
|
+
if attrs.get("src") or attrs.get("srcCompat"):
|
|
342
|
+
asset = attrs.get("srcCompat") or attrs.get("src")
|
|
343
|
+
drawable = catalog.drawable(str(asset), normalized_style)
|
|
344
|
+
node["asset"] = drawable.asset or asset
|
|
345
|
+
node["style"].update(drawable.style)
|
|
346
|
+
node["provenance"]["asset"] = property_evidence(drawable.source or active_context.source, drawable.line if drawable.source else line, str(asset), self.id, drawable.confidence)
|
|
347
|
+
if not drawable.asset:
|
|
348
|
+
result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": asset, "reason": "unsupported-android-drawable"})
|
|
349
|
+
scale_types = {"centerCrop": "cover", "fitCenter": "contain", "centerInside": "contain", "fitXY": "fill", "center": "none"}
|
|
350
|
+
if attrs.get("scaleType") in scale_types:
|
|
351
|
+
_set(node, "style.objectFit", scale_types[attrs["scaleType"]], active_context, line, f'scaleType="{attrs["scaleType"]}"', self.id)
|
|
352
|
+
if attrs.get("focusable") == "true":
|
|
353
|
+
node["states"] = {"default": {}, "focused": {"style": {"outline": "source-focus"}}}
|
|
354
|
+
node["children"] = [convert(child, active_context, active_text, include_stack) for child in element if isinstance(child.tag, str)]
|
|
355
|
+
if short == "ImageButton" and node.get("asset"):
|
|
356
|
+
icon_id = f"{node_id}-icon"
|
|
357
|
+
result.nodes[icon_id] = {
|
|
358
|
+
"type": "icon", "component": "ImageButton.drawable", "asset": node["asset"], "alt": node.get("alt", ""),
|
|
359
|
+
"layout": {"width": "fill", "height": "fill"}, "style": {"objectFit": node.get("style", {}).get("objectFit", "contain")}, "children": [],
|
|
360
|
+
"source": {"file": active_context.source, "line": line}, "confidence": node.get("confidence", "high"), "standardRef": node.get("standardRef"),
|
|
361
|
+
"provenance": {"asset": node["provenance"].get("asset", property_evidence(active_context.source, line, str(node["asset"]), self.id, "exact"))},
|
|
362
|
+
}
|
|
363
|
+
node["children"].insert(0, icon_id)
|
|
364
|
+
if node.get("layout", {}).get("direction") == "overlay":
|
|
365
|
+
for child_id in node["children"]:
|
|
366
|
+
child_node = result.nodes[child_id]
|
|
367
|
+
child_node.setdefault("layout", {})["gridArea"] = "1 / 1"
|
|
368
|
+
child_source = child_node.get("source", {})
|
|
369
|
+
child_node.setdefault("provenance", {})["layout.gridArea"] = property_evidence(
|
|
370
|
+
str(child_source.get("file") or active_context.source),
|
|
371
|
+
int(child_source.get("line") or line),
|
|
372
|
+
"overlay parent",
|
|
373
|
+
self.id,
|
|
374
|
+
"high",
|
|
375
|
+
)
|
|
376
|
+
result.nodes[node_id] = node
|
|
377
|
+
return node_id
|
|
378
|
+
|
|
379
|
+
candidates = list(content_root) if _local(content_root.tag) == "merge" else [content_root]
|
|
380
|
+
result.nodes[root_id]["children"] = [convert(child, context, context.text) for child in candidates]
|
|
381
|
+
if context.role == "screen":
|
|
382
|
+
metadata = android_layout_metadata(context.path) or {}
|
|
383
|
+
result.screens.append({
|
|
384
|
+
"id": screen_id,
|
|
385
|
+
"name": context.path.stem,
|
|
386
|
+
"root": root_id,
|
|
387
|
+
"route": "",
|
|
388
|
+
"platform": platform,
|
|
389
|
+
"source": {"file": context.source, "line": 1, "symbol": context.path.stem},
|
|
390
|
+
"confidence": "high",
|
|
391
|
+
"androidLayout": str(metadata.get("resource") or context.path.stem),
|
|
392
|
+
"resourceQualifier": str(metadata.get("qualifier") or ""),
|
|
393
|
+
})
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class XamlAdapter:
|
|
398
|
+
id = "xaml"
|
|
399
|
+
platforms = ("windows",)
|
|
400
|
+
extensions = (".xaml",)
|
|
401
|
+
maturity = "structural"
|
|
402
|
+
structural_tier = "translated"
|
|
403
|
+
visual_tier = "none"
|
|
404
|
+
native_evidence_required = True
|
|
405
|
+
limitations = ("templates and bindings are not evaluated", "custom controls remain unsupported")
|
|
406
|
+
TYPES = {"Page": "container", "Window": "container", "UserControl": "container", "Grid": "container", "StackPanel": "container", "Canvas": "container", "Border": "card", "NavigationView": "container", "Frame": "container", "ScrollViewer": "container", "ListView": "list", "GridView": "list", "ItemsControl": "list", "TextBlock": "text", "Label": "text", "Button": "button", "HyperlinkButton": "button", "TextBox": "input", "PasswordBox": "input", "Image": "image", "SymbolIcon": "icon", "FontIcon": "icon", "Rectangle": "divider"}
|
|
407
|
+
|
|
408
|
+
def supports(self, context: SourceContext) -> bool:
|
|
409
|
+
return context.path.suffix.lower() == ".xaml" and any(value.startswith("windows-") for value in context.platforms)
|
|
410
|
+
|
|
411
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
412
|
+
result = AdapterResult(adapter=self.id)
|
|
413
|
+
try:
|
|
414
|
+
root = ET.fromstring(context.text)
|
|
415
|
+
except ET.ParseError as exc:
|
|
416
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-xaml"})
|
|
417
|
+
return result
|
|
418
|
+
root_tag = _local(root.tag)
|
|
419
|
+
if root_tag == "ResourceDictionary":
|
|
420
|
+
for child in root:
|
|
421
|
+
attrs, value = _attrs(child), (child.text or attrs.get("Color") or "").strip()
|
|
422
|
+
name = attrs.get("Key")
|
|
423
|
+
if not name or not value:
|
|
424
|
+
continue
|
|
425
|
+
group = "colors" if _local(child.tag) in {"Color", "SolidColorBrush"} else "spacing"
|
|
426
|
+
result.tokens[group][_slug(name).replace("-", "_")] = {"value": _normalize_ref(value, group), "source": {"file": context.source, "line": _line(context.text, name)}, "adapter": self.id}
|
|
427
|
+
return result
|
|
428
|
+
root_attrs = _attrs(root)
|
|
429
|
+
name = root_attrs.get("Class", context.path.stem).split(".")[-1]
|
|
430
|
+
screen_id, root_id = _slug(name, "windows-view"), f"{_slug(name, 'windows-view')}-root"
|
|
431
|
+
result.nodes[root_id] = _root_node(context, self.id, "project.windows.xaml")
|
|
432
|
+
count = 0
|
|
433
|
+
|
|
434
|
+
def convert(element: ET.Element) -> str:
|
|
435
|
+
nonlocal count
|
|
436
|
+
count += 1
|
|
437
|
+
tag, attrs = _local(element.tag), _attrs(element)
|
|
438
|
+
node_id = f"{screen_id}-{_slug(attrs.get('Name') or tag)}-{count}"
|
|
439
|
+
node_type, line = self.TYPES.get(tag, "custom"), _line(context.text, f"<{tag}")
|
|
440
|
+
node: dict[str, Any] = {"type": node_type, "component": tag, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line}, "confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"project.windows.{tag.lower()}", "provenance": {}}
|
|
441
|
+
if node_type != "custom":
|
|
442
|
+
node["inheritsAppearance"] = True
|
|
443
|
+
node["provenance"]["component"] = property_evidence(context.source, line, tag, self.id, "exact")
|
|
444
|
+
else:
|
|
445
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": tag, "reason": "unsupported-xaml-control"})
|
|
446
|
+
mapping = {"Width": ("layout.width", "spacing"), "Height": ("layout.height", "spacing"), "MinWidth": ("layout.minWidth", "spacing"), "MinHeight": ("layout.minHeight", "spacing"), "Margin": ("layout.margin", "spacing"), "Padding": ("layout.padding", "spacing"), "Background": ("style.background", "colors"), "Foreground": ("style.color", "colors"), "FontSize": ("style.fontSize", "typography"), "CornerRadius": ("style.radius", "radii"), "Opacity": ("style.opacity", "spacing")}
|
|
447
|
+
for attr, (path, group) in mapping.items():
|
|
448
|
+
if attr in attrs:
|
|
449
|
+
_set(node, path, _normalize_ref(attrs[attr], group), context, line, f'{attr}="{attrs[attr]}"', self.id)
|
|
450
|
+
if attrs.get("Orientation") in {"Vertical", "Horizontal"}:
|
|
451
|
+
_set(node, "layout.direction", "column" if attrs["Orientation"] == "Vertical" else "row", context, line, f'Orientation="{attrs["Orientation"]}"', self.id)
|
|
452
|
+
text = attrs.get("Text") or attrs.get("Content") or (element.text or "").strip()
|
|
453
|
+
if text and "{" not in text:
|
|
454
|
+
node["text"] = text
|
|
455
|
+
node["provenance"]["text"] = property_evidence(context.source, line, text, self.id, "exact")
|
|
456
|
+
if attrs.get("Source"):
|
|
457
|
+
node["asset"] = attrs["Source"]
|
|
458
|
+
node["provenance"]["asset"] = property_evidence(context.source, line, attrs["Source"], self.id, "exact")
|
|
459
|
+
node["children"] = [convert(child) for child in element if isinstance(child.tag, str) and "." not in _local(child.tag)]
|
|
460
|
+
result.nodes[node_id] = node
|
|
461
|
+
return node_id
|
|
462
|
+
|
|
463
|
+
result.nodes[root_id]["children"] = [convert(root)]
|
|
464
|
+
result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": "windows", "source": {"file": context.source, "line": 1, "symbol": name}, "confidence": "high"})
|
|
465
|
+
return result
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
DECLARATIVE_TYPES = {
|
|
469
|
+
"VStack": "container", "HStack": "container", "ZStack": "container", "NavigationStack": "container", "ScrollView": "container", "Form": "container", "Section": "container", "List": "list", "Text": "text", "Button": "button", "TextField": "input", "SecureField": "input", "Image": "image", "AsyncImage": "image", "Label": "text", "Spacer": "spacer", "Divider": "divider", "Toggle": "button",
|
|
470
|
+
"Column": "container", "Row": "container", "Stack": "container", "Scaffold": "container", "Container": "container", "Padding": "container", "Center": "container", "Expanded": "container", "ListView": "list", "GridView": "list", "Card": "card", "ElevatedButton": "button", "TextButton": "button", "IconButton": "button", "TextFormField": "input", "SizedBox": "spacer", "Icon": "icon",
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _declarative_calls(body: str, types: set[str]) -> list[dict[str, Any]]:
|
|
475
|
+
calls: list[dict[str, Any]] = []
|
|
476
|
+
pattern = re.compile(r"\b(" + "|".join(sorted(types, key=len, reverse=True)) + r")\b")
|
|
477
|
+
for match in pattern.finditer(body):
|
|
478
|
+
cursor = match.end()
|
|
479
|
+
while cursor < len(body) and body[cursor].isspace(): cursor += 1
|
|
480
|
+
args, close = "", match.end()
|
|
481
|
+
if cursor < len(body) and body[cursor] == "(":
|
|
482
|
+
end = _balanced_close(body, cursor, "(", ")")
|
|
483
|
+
if end is None: continue
|
|
484
|
+
args, close, cursor = body[cursor + 1:end], end, end + 1
|
|
485
|
+
while cursor < len(body) and body[cursor].isspace(): cursor += 1
|
|
486
|
+
block_end = _balanced_close(body, cursor, "{", "}") if cursor < len(body) and body[cursor] == "{" else None
|
|
487
|
+
contain_end = block_end if block_end is not None else close
|
|
488
|
+
calls.append({"widget": match.group(1), "start": match.start(), "args": args, "close": close, "blockStart": cursor if block_end is not None else None, "containEnd": contain_end})
|
|
489
|
+
return calls
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _modifier_chain(body: str, start: int) -> str:
|
|
493
|
+
cursor, end = start, start
|
|
494
|
+
while cursor < len(body):
|
|
495
|
+
while cursor < len(body) and body[cursor].isspace():
|
|
496
|
+
cursor += 1
|
|
497
|
+
if cursor >= len(body) or body[cursor] != ".":
|
|
498
|
+
break
|
|
499
|
+
match = re.match(r"\.\w+", body[cursor:])
|
|
500
|
+
if not match:
|
|
501
|
+
break
|
|
502
|
+
cursor += match.end()
|
|
503
|
+
while cursor < len(body) and body[cursor].isspace():
|
|
504
|
+
cursor += 1
|
|
505
|
+
if cursor < len(body) and body[cursor] == "(":
|
|
506
|
+
close = _balanced_close(body, cursor, "(", ")")
|
|
507
|
+
if close is None:
|
|
508
|
+
break
|
|
509
|
+
cursor = close + 1
|
|
510
|
+
end = cursor
|
|
511
|
+
return body[start:end]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _apple_color(expression: str, catalog: AppleResourceCatalog | None) -> tuple[str | None, str]:
|
|
515
|
+
asset = re.search(r'Color\(\s*"([^"]+)"', expression)
|
|
516
|
+
if asset and catalog and (resolved := catalog.color(asset.group(1))):
|
|
517
|
+
return resolved.value, resolved.source
|
|
518
|
+
named = re.search(r"(?:Color\.)?\.(black|white|red|green|blue|gray|orange|yellow|pink|purple|primary|secondary)\b", expression)
|
|
519
|
+
if not named:
|
|
520
|
+
named = re.search(r"\bColor\.(black|white|red|green|blue|gray|orange|yellow|pink|purple|primary|secondary)\b", expression)
|
|
521
|
+
colors = {
|
|
522
|
+
"black": "#000000", "white": "#ffffff", "red": "#ff3b30", "green": "#34c759", "blue": "#007aff",
|
|
523
|
+
"gray": "#8e8e93", "orange": "#ff9500", "yellow": "#ffcc00", "pink": "#ff2d55", "purple": "#af52de",
|
|
524
|
+
"primary": "#000000", "secondary": "#6d6d72",
|
|
525
|
+
}
|
|
526
|
+
return (colors.get(named.group(1)), "apple-semantic-color") if named else (None, "")
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _translate_declarative(context: SourceContext, adapter: str, screen_name: str, body: str, body_offset: int, platform: str, standard_prefix: str, tokens: dict[str, Any], apple_catalog: AppleResourceCatalog | None = None) -> AdapterResult:
|
|
530
|
+
result = AdapterResult(adapter=adapter, tokens=tokens)
|
|
531
|
+
screen_id, root_id = _slug(screen_name), f"{_slug(screen_name)}-root"
|
|
532
|
+
result.nodes[root_id] = _root_node(context, adapter, f"project.{standard_prefix}.screen", _line(context.text, screen_name))
|
|
533
|
+
calls = _declarative_calls(body, set(DECLARATIVE_TYPES))
|
|
534
|
+
records: list[tuple[dict[str, Any], str]] = []
|
|
535
|
+
for index, call in enumerate(calls, start=1):
|
|
536
|
+
widget, args = call["widget"], call["args"]
|
|
537
|
+
node_id, node_type = f"{screen_id}-{_slug(widget)}-{index}", DECLARATIVE_TYPES[widget]
|
|
538
|
+
line = context.text.count("\n", 0, body_offset + call["start"]) + 1
|
|
539
|
+
native_prefix = "apple" if platform in {"ios", "macos"} else "material3" if platform == "android" else "project"
|
|
540
|
+
node: dict[str, Any] = {"type": node_type, "component": widget, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line, "symbol": screen_name}, "confidence": "high", "standardRef": f"{native_prefix}.{standard_prefix}.{widget.lower()}", "inheritsAppearance": True, "provenance": {"component": property_evidence(context.source, line, widget, adapter, "exact")}}
|
|
541
|
+
literal = re.search(r"(?:text\s*:\s*|label\s*:\s*)?[\"']([^\"']+)[\"']", args)
|
|
542
|
+
if literal and node_type in {"text", "button", "input"}:
|
|
543
|
+
node["text"] = literal.group(1)
|
|
544
|
+
node["provenance"]["text"] = property_evidence(context.source, line, literal.group(0), adapter, "exact")
|
|
545
|
+
localized = re.search(r'String\(\s*localized\s*:\s*"([^"]+)"', args)
|
|
546
|
+
if localized and apple_catalog and (resolved := apple_catalog.localized(localized.group(1))):
|
|
547
|
+
node["text"] = resolved.value
|
|
548
|
+
node["provenance"]["text"] = property_evidence(resolved.source, 1, localized.group(0), adapter, resolved.confidence)
|
|
549
|
+
if widget in {"VStack", "Column", "List", "ListView"}:
|
|
550
|
+
_set(node, "layout.direction", "column", context, line, widget, adapter)
|
|
551
|
+
if widget in {"HStack", "Row"}:
|
|
552
|
+
_set(node, "layout.direction", "row", context, line, widget, adapter)
|
|
553
|
+
if widget in {"ZStack", "Stack"}:
|
|
554
|
+
_set(node, "layout.direction", "overlay", context, line, widget, adapter)
|
|
555
|
+
alignment = re.search(r"\balignment\s*:\s*\.?(leading|trailing|center|top|bottom)", args)
|
|
556
|
+
if alignment and widget in {"VStack", "HStack", "ZStack"}:
|
|
557
|
+
mapped = {"leading": "start", "trailing": "end", "top": "start", "bottom": "end", "center": "center"}[alignment.group(1)]
|
|
558
|
+
_set(node, "layout.align", mapped, context, line, alignment.group(0), adapter)
|
|
559
|
+
spacing = re.search(r"\bspacing\s*:\s*(-?\d+(?:\.\d+)?)", args)
|
|
560
|
+
if spacing:
|
|
561
|
+
_set(node, "layout.gap", _normalize_ref(spacing.group(1)), context, line, spacing.group(0), adapter)
|
|
562
|
+
modifier_start = int(call.get("containEnd") or call["close"]) + 1
|
|
563
|
+
expression = args + _modifier_chain(body, modifier_start)
|
|
564
|
+
padding = re.search(r"\.padding\s*\(\s*(?!\.)([\w.]+)", expression)
|
|
565
|
+
flutter_padding = re.search(r"padding\s*:\s*(?:const\s+)?EdgeInsets\.all\s*\(\s*([\w.]+)", expression)
|
|
566
|
+
padding_value = padding.group(1) if padding else flutter_padding.group(1) if flutter_padding else None
|
|
567
|
+
if padding_value: _set(node, "layout.padding", _normalize_ref(padding_value), context, line, (padding or flutter_padding).group(0), adapter)
|
|
568
|
+
directional_padding = re.search(r"\.padding\s*\(\s*\.(horizontal|vertical|top|bottom|leading|trailing)\s*,\s*([\w.]+)", expression)
|
|
569
|
+
if directional_padding:
|
|
570
|
+
key = {"horizontal": "paddingHorizontal", "vertical": "paddingVertical", "top": "paddingTop", "bottom": "paddingBottom", "leading": "paddingLeft", "trailing": "paddingRight"}[directional_padding.group(1)]
|
|
571
|
+
_set(node, f"layout.{key}", _normalize_ref(directional_padding.group(2)), context, line, directional_padding.group(0), adapter)
|
|
572
|
+
width = re.search(r"(?:width\s*:\s*|\.frame\s*\(\s*width\s*:\s*)([\w.]+)", expression)
|
|
573
|
+
height = re.search(r"(?:height\s*:\s*|\.frame\s*\([^)]*height\s*:\s*)([\w.]+)", expression)
|
|
574
|
+
if width: _set(node, "layout.width", _normalize_ref(width.group(1)), context, line, width.group(0), adapter)
|
|
575
|
+
if height: _set(node, "layout.height", _normalize_ref(height.group(1)), context, line, height.group(0), adapter)
|
|
576
|
+
if re.search(r"\.frame\s*\([^)]*maxWidth\s*:\s*\.infinity", expression):
|
|
577
|
+
_set(node, "layout.width", "fill", context, line, "frame maxWidth: .infinity", adapter)
|
|
578
|
+
if re.search(r"\.frame\s*\([^)]*maxHeight\s*:\s*\.infinity", expression):
|
|
579
|
+
_set(node, "layout.height", "fill", context, line, "frame maxHeight: .infinity", adapter)
|
|
580
|
+
if platform in {"ios", "macos"}:
|
|
581
|
+
system_image = re.search(r'systemName\s*:\s*"([^"]+)"', args)
|
|
582
|
+
named_image = re.match(r'\s*"([^"]+)"', args)
|
|
583
|
+
if widget == "Image" and system_image:
|
|
584
|
+
node["type"] = "icon"
|
|
585
|
+
node["iconName"] = system_image.group(1)
|
|
586
|
+
if asset := sf_symbol_asset(system_image.group(1)):
|
|
587
|
+
node["asset"] = asset
|
|
588
|
+
node["provenance"]["asset"] = property_evidence("apple-sf-symbol-fallback", 1, system_image.group(0), adapter, "approximate")
|
|
589
|
+
elif widget in {"Image", "AsyncImage"} and named_image and apple_catalog:
|
|
590
|
+
resolved = apple_catalog.image(named_image.group(1))
|
|
591
|
+
if resolved:
|
|
592
|
+
node["asset"] = resolved.value
|
|
593
|
+
node["provenance"]["asset"] = property_evidence(resolved.source, 1, named_image.group(0), adapter, resolved.confidence)
|
|
594
|
+
foreground = re.search(r"\.(?:foregroundStyle|foregroundColor)\s*\(([^)]*(?:\([^)]*\)[^)]*)?)\)", expression)
|
|
595
|
+
if foreground:
|
|
596
|
+
color, source = _apple_color(foreground.group(1), apple_catalog)
|
|
597
|
+
if color:
|
|
598
|
+
node["style"]["color"] = color
|
|
599
|
+
node["provenance"]["style.color"] = property_evidence(source or context.source, 1 if source else line, foreground.group(0), adapter, "high")
|
|
600
|
+
background = re.search(r"\.background\s*\(([^)]*(?:\([^)]*\)[^)]*)?)\)", expression)
|
|
601
|
+
if background:
|
|
602
|
+
color, source = _apple_color(background.group(1), apple_catalog)
|
|
603
|
+
if color:
|
|
604
|
+
node["style"]["backgroundColor"] = color
|
|
605
|
+
node["provenance"]["style.backgroundColor"] = property_evidence(source or context.source, 1 if source else line, background.group(0), adapter, "high")
|
|
606
|
+
radius = re.search(r"\.cornerRadius\s*\(\s*([\d.]+)", expression) or re.search(r"RoundedRectangle\s*\(\s*cornerRadius\s*:\s*([\d.]+)", expression)
|
|
607
|
+
if radius:
|
|
608
|
+
_set(node, "style.radius", _normalize_ref(radius.group(1)), context, line, radius.group(0), adapter)
|
|
609
|
+
font_size = re.search(r"\.font\s*\(\s*\.system\s*\([^)]*size\s*:\s*([\d.]+)", expression)
|
|
610
|
+
if font_size:
|
|
611
|
+
_set(node, "style.fontSize", _normalize_ref(font_size.group(1)), context, line, font_size.group(0), adapter)
|
|
612
|
+
font_weight = re.search(r"(?:weight\s*:\s*|\.fontWeight\s*\()\.?(bold|semibold|medium|regular)", expression)
|
|
613
|
+
if font_weight:
|
|
614
|
+
_set(node, "style.fontWeight", {"bold": 700, "semibold": 600, "medium": 500, "regular": 400}[font_weight.group(1)], context, line, font_weight.group(0), adapter)
|
|
615
|
+
if ".bold()" in expression:
|
|
616
|
+
_set(node, "style.fontWeight", 700, context, line, ".bold()", adapter)
|
|
617
|
+
text_align = re.search(r"\.multilineTextAlignment\s*\(\s*\.(leading|center|trailing)", expression)
|
|
618
|
+
if text_align:
|
|
619
|
+
_set(node, "style.textAlign", {"leading": "start", "center": "center", "trailing": "end"}[text_align.group(1)], context, line, text_align.group(0), adapter)
|
|
620
|
+
if ".scaledToFill()" in expression:
|
|
621
|
+
_set(node, "style.objectFit", "cover", context, line, ".scaledToFill()", adapter)
|
|
622
|
+
elif ".scaledToFit()" in expression:
|
|
623
|
+
_set(node, "style.objectFit", "contain", context, line, ".scaledToFit()", adapter)
|
|
624
|
+
result.nodes[node_id] = node
|
|
625
|
+
records.append((call, node_id))
|
|
626
|
+
for call, node_id in records:
|
|
627
|
+
parents = [(candidate, candidate_id) for candidate, candidate_id in records if candidate["start"] < call["start"] < candidate["containEnd"]]
|
|
628
|
+
parent_id = max(parents, key=lambda item: item[0]["start"])[1] if parents else root_id
|
|
629
|
+
result.nodes[parent_id]["children"].append(node_id)
|
|
630
|
+
known = set(DECLARATIVE_TYPES) | {"Color", "Font", "String", "EdgeInsets", "RoundedRectangle", "MaterialApp", "ThemeData"}
|
|
631
|
+
for unknown in re.finditer(r"\b([A-Z]\w*)\s*(?:\(|\{)", body):
|
|
632
|
+
if unknown.group(1) not in known:
|
|
633
|
+
result.unsupported.append({"adapter": adapter, "file": context.source, "line": context.text.count("\n", 0, body_offset + unknown.start()) + 1, "expression": unknown.group(1), "reason": "unsupported-declarative-component"})
|
|
634
|
+
result.screens.append({"id": screen_id, "name": screen_name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": _line(context.text, screen_name), "symbol": screen_name}, "confidence": "high"})
|
|
635
|
+
return result
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class SwiftUIAdapter:
|
|
639
|
+
id = "swiftui"
|
|
640
|
+
platforms = ("ios", "macos")
|
|
641
|
+
extensions = (".swift",)
|
|
642
|
+
maturity = "structural"
|
|
643
|
+
structural_tier = "translated"
|
|
644
|
+
visual_tier = "none"
|
|
645
|
+
native_evidence_required = True
|
|
646
|
+
native_providers = ("xcode-preview", "swift-snapshot-testing", "apple-simulator")
|
|
647
|
+
resource_resolution = ("Asset Catalog images/colors", "Localizable.strings", "common SF Symbols")
|
|
648
|
+
layout_features = ("stack direction/alignment/spacing", "frame/fill/padding", "overlay stacks", "basic color/type/image modifiers")
|
|
649
|
+
limitations = ("supported SwiftUI primitives and modifiers only", "conditional runtime branches are not executed", "SF Symbol SVGs are structural fallbacks")
|
|
650
|
+
|
|
651
|
+
def __init__(self) -> None:
|
|
652
|
+
self._catalogs: dict[str, AppleResourceCatalog] = {}
|
|
653
|
+
|
|
654
|
+
def prepare(self, contexts: list[SourceContext]) -> None:
|
|
655
|
+
roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
|
|
656
|
+
self._catalogs = {key: AppleResourceCatalog.discover(root) for key, root in roots.items()}
|
|
657
|
+
|
|
658
|
+
def _catalog(self, context: SourceContext) -> AppleResourceCatalog:
|
|
659
|
+
key = str(context.root.resolve())
|
|
660
|
+
if key not in self._catalogs:
|
|
661
|
+
self._catalogs[key] = AppleResourceCatalog.discover(context.root)
|
|
662
|
+
return self._catalogs[key]
|
|
663
|
+
|
|
664
|
+
def supports(self, context: SourceContext) -> bool:
|
|
665
|
+
return context.path.suffix.lower() == ".swift" and any(value.startswith("swiftui") for value in context.platforms)
|
|
666
|
+
|
|
667
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
668
|
+
combined = AdapterResult(adapter=self.id)
|
|
669
|
+
catalog = self._catalog(context)
|
|
670
|
+
tokens = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
|
|
671
|
+
for match in re.finditer(r"(?:static\s+)?let\s+(\w+)\s*(?::\s*\w+)?\s*=\s*(Color\([^\n]+\)|\d+(?:\.\d+)?)", context.text):
|
|
672
|
+
group = "colors" if match.group(2).startswith("Color") else "spacing"
|
|
673
|
+
tokens[group][_slug(match.group(1)).replace("-", "_")] = {"value": match.group(2), "source": {"file": context.source, "line": _line(context.text, match.group(0))}, "adapter": self.id}
|
|
674
|
+
platform = "macos" if any(value in {"swiftui-macos", "appkit"} for value in context.platforms) else "ios"
|
|
675
|
+
for struct in re.finditer(r"\bstruct\s+([A-Z]\w*)\s*:\s*View\s*\{", context.text):
|
|
676
|
+
end = _balanced_close(context.text, context.text.find("{", struct.start()), "{", "}")
|
|
677
|
+
if end is None: continue
|
|
678
|
+
segment = context.text[struct.end():end]
|
|
679
|
+
body_match = re.search(r"\bvar\s+body\s*:\s*some\s+View\s*\{", segment)
|
|
680
|
+
if not body_match: continue
|
|
681
|
+
body_start = struct.end() + body_match.end()
|
|
682
|
+
body_end = _balanced_close(context.text, body_start - 1, "{", "}")
|
|
683
|
+
if body_end is None: continue
|
|
684
|
+
current = _translate_declarative(context, self.id, struct.group(1), context.text[body_start:body_end], body_start, platform, "swiftui", tokens, catalog)
|
|
685
|
+
combined.screens.extend(current.screens); combined.nodes.update(current.nodes); combined.unsupported.extend(current.unsupported)
|
|
686
|
+
combined.tokens = tokens
|
|
687
|
+
return combined
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class FlutterAdapter:
|
|
691
|
+
id = "flutter"
|
|
692
|
+
platforms = ("flutter",)
|
|
693
|
+
extensions = (".dart",)
|
|
694
|
+
maturity = "structural"
|
|
695
|
+
structural_tier = "translated"
|
|
696
|
+
visual_tier = "none"
|
|
697
|
+
native_evidence_required = True
|
|
698
|
+
native_providers = ("flutter-golden", "flutter-device")
|
|
699
|
+
limitations = ("custom widgets remain unsupported", "platform-adaptive runtime branches are not executed")
|
|
700
|
+
|
|
701
|
+
def supports(self, context: SourceContext) -> bool:
|
|
702
|
+
return context.path.suffix.lower() == ".dart" and "flutter" in context.platforms
|
|
703
|
+
|
|
704
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
705
|
+
combined = AdapterResult(adapter=self.id)
|
|
706
|
+
tokens = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
|
|
707
|
+
for match in re.finditer(r"(?:static\s+)?const\s+(?:Color|double)\s+(\w+)\s*=\s*([^;]+);", context.text):
|
|
708
|
+
group = "colors" if "Color" in match.group(0).split("=")[0] else "spacing"
|
|
709
|
+
tokens[group][_slug(match.group(1)).replace("-", "_")] = {"value": match.group(2).strip(), "source": {"file": context.source, "line": _line(context.text, match.group(0))}, "adapter": self.id}
|
|
710
|
+
for klass in re.finditer(r"\bclass\s+([A-Z]\w*)\s+extends\s+(?:StatelessWidget|StatefulWidget)\s*\{", context.text):
|
|
711
|
+
class_end = _balanced_close(context.text, context.text.find("{", klass.start()), "{", "}")
|
|
712
|
+
if class_end is None: continue
|
|
713
|
+
segment = context.text[klass.end():class_end]
|
|
714
|
+
build = re.search(r"\bWidget\s+build\s*\([^)]*\)\s*\{", segment)
|
|
715
|
+
if not build: continue
|
|
716
|
+
build_start = klass.end() + build.end()
|
|
717
|
+
build_end = _balanced_close(context.text, build_start - 1, "{", "}")
|
|
718
|
+
if build_end is None: continue
|
|
719
|
+
current = _translate_declarative(context, self.id, klass.group(1), context.text[build_start:build_end], build_start, "flutter", "flutter", tokens)
|
|
720
|
+
combined.screens.extend(current.screens); combined.nodes.update(current.nodes); combined.unsupported.extend(current.unsupported)
|
|
721
|
+
combined.tokens = tokens
|
|
722
|
+
return combined
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class AppleInterfaceXmlAdapter:
|
|
726
|
+
id = "apple-interface-xml"
|
|
727
|
+
platforms = ("ios", "macos")
|
|
728
|
+
extensions = (".storyboard", ".xib")
|
|
729
|
+
maturity = "structural"
|
|
730
|
+
structural_tier = "translated"
|
|
731
|
+
visual_tier = "none"
|
|
732
|
+
native_evidence_required = True
|
|
733
|
+
native_providers = ("swift-snapshot-testing", "apple-simulator")
|
|
734
|
+
resource_resolution = ("Asset Catalog images/colors", "authored interface strings")
|
|
735
|
+
layout_features = ("authored frames", "stack axis/spacing", "basic width/height/center Auto Layout constraints")
|
|
736
|
+
limitations = ("complex Auto Layout equations are not solved", "runtime UIKit/AppKit mutations are unsupported")
|
|
737
|
+
TYPES = {"view": "container", "stackView": "container", "scrollView": "container", "tableView": "list", "collectionView": "list", "label": "text", "button": "button", "textField": "input", "textView": "input", "imageView": "image", "separator": "divider"}
|
|
738
|
+
|
|
739
|
+
def __init__(self) -> None:
|
|
740
|
+
self._catalogs: dict[str, AppleResourceCatalog] = {}
|
|
741
|
+
|
|
742
|
+
def prepare(self, contexts: list[SourceContext]) -> None:
|
|
743
|
+
roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
|
|
744
|
+
self._catalogs = {key: AppleResourceCatalog.discover(root) for key, root in roots.items()}
|
|
745
|
+
|
|
746
|
+
def _catalog(self, context: SourceContext) -> AppleResourceCatalog:
|
|
747
|
+
key = str(context.root.resolve())
|
|
748
|
+
if key not in self._catalogs:
|
|
749
|
+
self._catalogs[key] = AppleResourceCatalog.discover(context.root)
|
|
750
|
+
return self._catalogs[key]
|
|
751
|
+
|
|
752
|
+
def supports(self, context: SourceContext) -> bool:
|
|
753
|
+
return context.path.suffix.lower() in {".storyboard", ".xib"}
|
|
754
|
+
|
|
755
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
756
|
+
result = AdapterResult(adapter=self.id)
|
|
757
|
+
catalog = self._catalog(context)
|
|
758
|
+
try: document = ET.fromstring(context.text)
|
|
759
|
+
except ET.ParseError as exc:
|
|
760
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-interface-xml"}); return result
|
|
761
|
+
controllers = [item for item in document.iter() if _local(item.tag) in {"viewController", "windowController"}]
|
|
762
|
+
if not controllers and context.path.suffix.lower() == ".xib": controllers = [document]
|
|
763
|
+
platform = "macos" if "MacOSX" in context.text or any(value == "appkit" for value in context.platforms) else "ios"
|
|
764
|
+
for screen_index, controller in enumerate(controllers, start=1):
|
|
765
|
+
attrs = _attrs(controller); name = attrs.get("customClass") or attrs.get("id") or f"{context.path.stem}-{screen_index}"
|
|
766
|
+
screen_id, root_id = _slug(name), f"{_slug(name)}-root"
|
|
767
|
+
result.nodes[root_id] = _root_node(context, self.id, f"project.{platform}.interface")
|
|
768
|
+
count = 0
|
|
769
|
+
source_ids: dict[str, str] = {}
|
|
770
|
+
def convert(element: ET.Element, parent_tag: str = "") -> str | None:
|
|
771
|
+
nonlocal count
|
|
772
|
+
tag = _local(element.tag)
|
|
773
|
+
if tag in {"connections", "constraints", "resources", "dependencies", "outlet", "segue", "constraint", "color"}: return None
|
|
774
|
+
count += 1; attrs = _attrs(element); node_type = self.TYPES.get(tag, "custom"); line = _line(context.text, f"<{tag}")
|
|
775
|
+
node_id = f"{screen_id}-{_slug(attrs.get('id') or tag)}-{count}"
|
|
776
|
+
if attrs.get("id"):
|
|
777
|
+
source_ids[attrs["id"]] = node_id
|
|
778
|
+
node: dict[str, Any] = {"type": node_type, "component": tag, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line}, "confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"project.{platform}.{tag.lower()}", "provenance": {}}
|
|
779
|
+
if node_type != "custom": node["inheritsAppearance"] = True; node["provenance"]["component"] = property_evidence(context.source, line, tag, self.id, "exact")
|
|
780
|
+
else: result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": tag, "reason": "unsupported-interface-node"})
|
|
781
|
+
text = attrs.get("text") or attrs.get("title") or attrs.get("placeholder")
|
|
782
|
+
if text: node["text"] = text; node["provenance"]["text"] = property_evidence(context.source, line, text, self.id, "exact")
|
|
783
|
+
if tag == "view":
|
|
784
|
+
_set(node, "layout.direction", "overlay", context, line, tag, self.id)
|
|
785
|
+
if tag == "stackView":
|
|
786
|
+
_set(node, "layout.direction", "column" if attrs.get("axis") == "vertical" else "row", context, line, f'axis={attrs.get("axis", "horizontal")}', self.id)
|
|
787
|
+
if attrs.get("spacing"):
|
|
788
|
+
_set(node, "layout.gap", _normalize_ref(attrs["spacing"]), context, line, f'spacing={attrs["spacing"]}', self.id)
|
|
789
|
+
if attrs.get("image"):
|
|
790
|
+
resolved = catalog.image(attrs["image"])
|
|
791
|
+
node["asset"] = resolved.value if resolved else attrs["image"]
|
|
792
|
+
node["provenance"]["asset"] = property_evidence(resolved.source if resolved else context.source, 1 if resolved else line, attrs["image"], self.id, resolved.confidence if resolved else "approximate")
|
|
793
|
+
if not resolved:
|
|
794
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": attrs["image"], "reason": "unresolved-apple-image"})
|
|
795
|
+
named_color = next((_attrs(child).get("name") for child in element if _local(child.tag) == "color" and _attrs(child).get("name")), None)
|
|
796
|
+
if named_color and (resolved_color := catalog.color(named_color)):
|
|
797
|
+
node["style"]["color" if node_type in {"text", "button", "input", "icon"} else "backgroundColor"] = resolved_color.value
|
|
798
|
+
node["provenance"]["style.color" if node_type in {"text", "button", "input", "icon"} else "style.backgroundColor"] = property_evidence(resolved_color.source, 1, named_color, self.id, resolved_color.confidence)
|
|
799
|
+
frame = next((child for child in element if _local(child.tag) == "rect" and _attrs(child).get("key") == "frame"), None)
|
|
800
|
+
if frame is not None:
|
|
801
|
+
values = _attrs(frame)
|
|
802
|
+
for attr, key in (("x", "left"), ("y", "top"), ("width", "width"), ("height", "height")):
|
|
803
|
+
if attr in values: _set(node, f"layout.{key}", _normalize_ref(values[attr]), context, line, f"frame.{attr}={values[attr]}", self.id)
|
|
804
|
+
if parent_tag and parent_tag != "stackView":
|
|
805
|
+
_set(node, "layout.position", "absolute", context, line, "authored frame", self.id, "high")
|
|
806
|
+
child_ids: list[str] = []
|
|
807
|
+
for child in element:
|
|
808
|
+
if not isinstance(child.tag, str) or _local(child.tag) == "rect": continue
|
|
809
|
+
candidates = list(child) if _local(child.tag) in {"subviews", "objects", "scenes"} else [child]
|
|
810
|
+
for candidate in candidates:
|
|
811
|
+
if isinstance(candidate.tag, str) and (child_id := convert(candidate, tag)): child_ids.append(child_id)
|
|
812
|
+
node["children"] = child_ids
|
|
813
|
+
result.nodes[node_id] = node; return node_id
|
|
814
|
+
roots = [child_id for child in controller if isinstance(child.tag, str) and (child_id := convert(child))]
|
|
815
|
+
result.nodes[root_id]["children"] = roots
|
|
816
|
+
for constraint in controller.iter():
|
|
817
|
+
if _local(constraint.tag) != "constraint":
|
|
818
|
+
continue
|
|
819
|
+
values = _attrs(constraint)
|
|
820
|
+
first_id = source_ids.get(values.get("firstItem", ""))
|
|
821
|
+
if not first_id:
|
|
822
|
+
continue
|
|
823
|
+
node = result.nodes[first_id]
|
|
824
|
+
attribute = values.get("firstAttribute", "")
|
|
825
|
+
second_attribute = values.get("secondAttribute", "")
|
|
826
|
+
second_item = values.get("secondItem")
|
|
827
|
+
constant = values.get("constant")
|
|
828
|
+
if attribute in {"width", "height"} and not second_item and constant:
|
|
829
|
+
_set(node, f"layout.{attribute}", _normalize_ref(constant), context, _line(context.text, values.get("id", "constraint")), f"constraint {attribute}={constant}", self.id, "high")
|
|
830
|
+
elif attribute == "centerX" and second_attribute == "centerX":
|
|
831
|
+
_set(node, "layout.justifySelf", "center", context, _line(context.text, values.get("id", "constraint")), "centerX constraint", self.id, "high")
|
|
832
|
+
elif attribute == "centerY" and second_attribute == "centerY":
|
|
833
|
+
_set(node, "layout.alignSelf", "center", context, _line(context.text, values.get("id", "constraint")), "centerY constraint", self.id, "high")
|
|
834
|
+
else:
|
|
835
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": _line(context.text, values.get("id", "constraint")), "expression": values.get("id", "constraint"), "reason": "unsupported-auto-layout-equation"})
|
|
836
|
+
result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": _line(context.text, name), "symbol": name}, "confidence": "high"})
|
|
837
|
+
return result
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class ProjectedMarkupAdapter:
|
|
841
|
+
def __init__(self, web_adapter: Any, kind: str):
|
|
842
|
+
self.web_adapter, self.id = web_adapter, kind
|
|
843
|
+
self.platforms = ("web",)
|
|
844
|
+
self.extensions = (".jsx", ".tsx", ".js", ".ts") if kind.startswith("react") else (f".{kind}",)
|
|
845
|
+
self.maturity = "golden"
|
|
846
|
+
self.structural_tier = "translated"
|
|
847
|
+
self.visual_tier = "projection"
|
|
848
|
+
self.native_evidence_required = False
|
|
849
|
+
self.native_providers = ()
|
|
850
|
+
self.limitations = ("dynamic expressions are not executed", "project components remain unsupported until mapped")
|
|
851
|
+
|
|
852
|
+
def supports(self, context: SourceContext) -> bool:
|
|
853
|
+
suffix = context.path.suffix.lower()
|
|
854
|
+
if self.id == "react-jsx": return suffix in {".jsx", ".tsx", ".js", ".ts"} and "react-web" in context.platforms
|
|
855
|
+
if self.id == "vue": return suffix == ".vue"
|
|
856
|
+
return suffix == ".svelte"
|
|
857
|
+
|
|
858
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
859
|
+
text, start = context.text, 0
|
|
860
|
+
if self.id == "vue":
|
|
861
|
+
match = re.search(r"<template\b[^>]*>([\s\S]*?)</template>", text, re.I)
|
|
862
|
+
if not match: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": 1, "expression": "template", "reason": "missing-template"}])
|
|
863
|
+
markup, start = match.group(1), match.start(1)
|
|
864
|
+
elif self.id == "svelte":
|
|
865
|
+
markup = re.sub(r"<(script|style)\b[^>]*>[\s\S]*?</\1>", "", text, flags=re.I)
|
|
866
|
+
else:
|
|
867
|
+
match = re.search(r"\breturn\s*\(", text)
|
|
868
|
+
if not match: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": 1, "expression": "return", "reason": "missing-static-jsx-return"}])
|
|
869
|
+
open_index = text.find("(", match.start()); end = _balanced_close(text, open_index, "(", ")")
|
|
870
|
+
if end is None: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": _line(text, match.group(0)), "expression": "return", "reason": "unbalanced-jsx"}])
|
|
871
|
+
markup, start = text[open_index + 1:end], open_index + 1
|
|
872
|
+
unsupported: list[dict[str, Any]] = []
|
|
873
|
+
for expression in re.finditer(r"\{[^{}]*\}", markup):
|
|
874
|
+
unsupported.append({"adapter": self.id, "file": context.source, "line": text.count("\n", 0, start + expression.start()) + 1, "expression": expression.group(0)[:200], "reason": "dynamic-markup-expression"})
|
|
875
|
+
projected = re.sub(r"\{[^{}]*\}", "", markup)
|
|
876
|
+
projected = projected.replace("className=", "class=").replace("htmlFor=", "for=")
|
|
877
|
+
embedded_css = "\n".join(re.findall(r"<style\b[^>]*>([\s\S]*?)</style>", text, re.I)) if self.id in {"vue", "svelte"} else ""
|
|
878
|
+
padded = "\n" * text.count("\n", 0, start) + projected + ("\n" + embedded_css if embedded_css else "")
|
|
879
|
+
projected_context = SourceContext(context.root, context.path, context.source, padded, context.platforms, context.role)
|
|
880
|
+
result = self.web_adapter.translate(projected_context)
|
|
881
|
+
result.adapter = self.id; result.unsupported.extend(unsupported)
|
|
882
|
+
custom_tags = {value.lower(): value for value in re.findall(r"<([A-Z][A-Za-z0-9_.]*)\b", markup)}
|
|
883
|
+
for node in result.nodes.values():
|
|
884
|
+
for evidence in node.get("provenance", {}).values():
|
|
885
|
+
if isinstance(evidence, dict): evidence["adapter"] = self.id; evidence["confidence"] = "high"
|
|
886
|
+
node["confidence"] = "high"
|
|
887
|
+
ref = str(node.get("standardRef") or "")
|
|
888
|
+
tag = ref.removeprefix("html.")
|
|
889
|
+
if tag in custom_tags:
|
|
890
|
+
node.update({"type": "custom", "component": custom_tags[tag], "confidence": "unsupported", "standardRef": f"project.{self.id}.{tag}"})
|
|
891
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": node.get("source", {}).get("line", 1), "expression": custom_tags[tag], "reason": "untranslated-project-component"})
|
|
892
|
+
target_platform = "web"
|
|
893
|
+
for screen in result.screens: screen["platform"] = target_platform; screen["name"] = context.path.stem
|
|
894
|
+
return result
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def builtin_platform_adapters(web_adapter: Any) -> list[Any]:
|
|
898
|
+
return [
|
|
899
|
+
AndroidXmlAdapter(), SwiftUIAdapter(), XamlAdapter(), FlutterAdapter(), AppleInterfaceXmlAdapter(),
|
|
900
|
+
ProjectedMarkupAdapter(web_adapter, "react-jsx"),
|
|
901
|
+
ProjectedMarkupAdapter(web_adapter, "vue"), ProjectedMarkupAdapter(web_adapter, "svelte"),
|
|
902
|
+
]
|