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
fidelity_adapters.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Provider-neutral source adapter API with built-in Web and Compose adapters."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html.parser
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from android_resource_resolver import AndroidResourceCatalog, material_icon_asset
|
|
11
|
+
from fidelity_adapter_api import AdapterResult, SourceContext, UiSourceAdapter, adapter_capabilities, register_adapter, registered_adapters, translate_sources
|
|
12
|
+
from fidelity_core import property_evidence, stable_id
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _slug(value: str, fallback: str = "node") -> str:
|
|
16
|
+
separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
|
|
17
|
+
return re.sub(r"[^a-z0-9]+", "-", separated.lower()).strip("-") or fallback
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _css_value(value: str, group: str, tokens: dict[str, Any]) -> Any:
|
|
21
|
+
match = re.fullmatch(r"var\(\s*--([\w-]+)\s*(?:,[^)]+)?\)", value.strip())
|
|
22
|
+
if not match:
|
|
23
|
+
return value.strip()
|
|
24
|
+
name = _slug(match.group(1)).replace("-", "_")
|
|
25
|
+
if name in tokens.get(group, {}):
|
|
26
|
+
return f"${group}.{name}"
|
|
27
|
+
for candidate_group, entries in tokens.items():
|
|
28
|
+
if name in entries:
|
|
29
|
+
return f"${candidate_group}.{name}"
|
|
30
|
+
return value.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CSS_PROPERTY_MAP = {
|
|
34
|
+
"display": ("layout", "display"), "flex-direction": ("layout", "direction"), "gap": ("layout", "gap"),
|
|
35
|
+
"width": ("layout", "width"), "height": ("layout", "height"), "min-width": ("layout", "minWidth"),
|
|
36
|
+
"min-height": ("layout", "minHeight"), "max-width": ("layout", "maxWidth"), "padding": ("layout", "padding"),
|
|
37
|
+
"margin": ("layout", "margin"), "align-items": ("layout", "align"), "justify-content": ("layout", "justify"),
|
|
38
|
+
"background": ("style", "background"), "background-color": ("style", "backgroundColor"), "color": ("style", "color"),
|
|
39
|
+
"border": ("style", "border"), "border-color": ("style", "borderColor"), "border-width": ("style", "borderWidth"),
|
|
40
|
+
"border-radius": ("style", "radius"), "box-shadow": ("style", "boxShadow"), "font-family": ("style", "fontFamily"),
|
|
41
|
+
"font-size": ("style", "fontSize"), "font-weight": ("style", "fontWeight"), "line-height": ("style", "lineHeight"),
|
|
42
|
+
"text-align": ("style", "textAlign"), "opacity": ("style", "opacity"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _token_group(name: str, value: str) -> str:
|
|
47
|
+
lower = name.lower()
|
|
48
|
+
if value.strip().startswith(("#", "rgb", "hsl")) or any(part in lower for part in ("color", "bg", "surface", "text", "accent")):
|
|
49
|
+
return "colors"
|
|
50
|
+
if "radius" in lower:
|
|
51
|
+
return "radii"
|
|
52
|
+
if any(part in lower for part in ("font", "line-height", "type")):
|
|
53
|
+
return "typography"
|
|
54
|
+
return "spacing"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _HtmlTreeParser(html.parser.HTMLParser):
|
|
58
|
+
def __init__(self, source: str):
|
|
59
|
+
super().__init__(convert_charrefs=True)
|
|
60
|
+
self.source = source
|
|
61
|
+
self.counter = 0
|
|
62
|
+
self.roots: list[dict[str, Any]] = []
|
|
63
|
+
self.stack: list[dict[str, Any]] = []
|
|
64
|
+
|
|
65
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
66
|
+
self.counter += 1
|
|
67
|
+
node = {"tag": tag.lower(), "attrs": dict(attrs), "children": [], "text": "", "line": self.getpos()[0], "index": self.counter}
|
|
68
|
+
(self.stack[-1]["children"] if self.stack else self.roots).append(node)
|
|
69
|
+
if tag.lower() not in {"img", "input", "br", "hr", "meta", "link"}:
|
|
70
|
+
self.stack.append(node)
|
|
71
|
+
|
|
72
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
73
|
+
self.handle_starttag(tag, attrs)
|
|
74
|
+
if self.stack and self.stack[-1]["tag"] == tag.lower():
|
|
75
|
+
self.stack.pop()
|
|
76
|
+
|
|
77
|
+
def handle_endtag(self, tag: str) -> None:
|
|
78
|
+
for index in range(len(self.stack) - 1, -1, -1):
|
|
79
|
+
if self.stack[index]["tag"] == tag.lower():
|
|
80
|
+
del self.stack[index:]
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
def handle_data(self, data: str) -> None:
|
|
84
|
+
if self.stack and data.strip():
|
|
85
|
+
self.stack[-1]["text"] += (" " if self.stack[-1]["text"] else "") + " ".join(data.split())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WebAdapter:
|
|
89
|
+
id = "web"
|
|
90
|
+
platforms = ("web",)
|
|
91
|
+
extensions = (".html", ".htm", ".css")
|
|
92
|
+
maturity = "golden"
|
|
93
|
+
structural_tier = "translated"
|
|
94
|
+
visual_tier = "projection"
|
|
95
|
+
native_evidence_required = False
|
|
96
|
+
limitations = ("simple CSS selectors only", "runtime-generated DOM is unsupported")
|
|
97
|
+
|
|
98
|
+
def supports(self, context: SourceContext) -> bool:
|
|
99
|
+
return context.path.suffix.lower() in {".html", ".htm"}
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _css(context: SourceContext) -> tuple[dict[str, dict[str, tuple[str, int, str]]], dict[str, Any], list[dict[str, Any]]]:
|
|
103
|
+
rules: dict[str, dict[str, tuple[str, int, str]]] = {}
|
|
104
|
+
tokens: dict[str, Any] = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
|
|
105
|
+
unsupported: list[dict[str, Any]] = []
|
|
106
|
+
texts: list[tuple[str, str]] = [(context.source, context.text)]
|
|
107
|
+
for css_path in sorted(context.path.parent.glob("*.css")):
|
|
108
|
+
try:
|
|
109
|
+
texts.append((css_path.relative_to(context.root).as_posix(), css_path.read_text(encoding="utf-8", errors="ignore")))
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
for source, text in texts:
|
|
113
|
+
for token in re.finditer(r"--([\w-]+)\s*:\s*([^;{}]+);", text):
|
|
114
|
+
name, value = token.group(1), token.group(2).strip()
|
|
115
|
+
group = _token_group(name, value)
|
|
116
|
+
tokens[group][_slug(name).replace("-", "_")] = {"value": value, "source": {"file": source, "line": text.count("\n", 0, token.start()) + 1}, "adapter": "web"}
|
|
117
|
+
for block in re.finditer(r"([^{}]+)\{([^{}]*)\}", text, re.S):
|
|
118
|
+
selectors, body = block.group(1).strip(), block.group(2)
|
|
119
|
+
line = text.count("\n", 0, block.start()) + 1
|
|
120
|
+
for selector in (part.strip() for part in selectors.split(",")):
|
|
121
|
+
if not re.fullmatch(r"(?:[a-zA-Z][\w-]*|\.[\w-]+|#[\w-]+|:root)", selector):
|
|
122
|
+
unsupported.append({"adapter": "web", "file": source, "line": line, "expression": selector, "reason": "unsupported-css-selector"})
|
|
123
|
+
continue
|
|
124
|
+
bucket = rules.setdefault(selector, {})
|
|
125
|
+
for declaration in re.finditer(r"([\w-]+)\s*:\s*([^;]+)", body):
|
|
126
|
+
prop, value = declaration.group(1).lower(), declaration.group(2).strip()
|
|
127
|
+
if prop.startswith("--"):
|
|
128
|
+
continue
|
|
129
|
+
if prop not in CSS_PROPERTY_MAP:
|
|
130
|
+
unsupported.append({"adapter": "web", "file": source, "line": line, "expression": f"{prop}: {value}", "reason": "unsupported-css-property"})
|
|
131
|
+
continue
|
|
132
|
+
bucket[prop] = (value, line, source)
|
|
133
|
+
return rules, tokens, unsupported
|
|
134
|
+
|
|
135
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
136
|
+
result = AdapterResult(adapter=self.id)
|
|
137
|
+
rules, result.tokens, result.unsupported = self._css(context)
|
|
138
|
+
parser = _HtmlTreeParser(context.source)
|
|
139
|
+
parser.feed(context.text)
|
|
140
|
+
body = next((item for item in parser.roots if item["tag"] == "html"), None)
|
|
141
|
+
roots = body["children"] if body else parser.roots
|
|
142
|
+
body_node = next((item for item in roots if item["tag"] == "body"), None)
|
|
143
|
+
roots = body_node["children"] if body_node else roots
|
|
144
|
+
screen_id = _slug(context.path.stem, "web-screen")
|
|
145
|
+
root_id = f"{screen_id}-root"
|
|
146
|
+
used: set[str] = set()
|
|
147
|
+
|
|
148
|
+
def convert(item: dict[str, Any]) -> str | None:
|
|
149
|
+
tag = item["tag"]
|
|
150
|
+
if tag in {"script", "style", "head", "title", "meta", "link"}:
|
|
151
|
+
return None
|
|
152
|
+
attrs = item["attrs"]
|
|
153
|
+
base = _slug(attrs.get("id") or f"{tag}-{item['index']}")
|
|
154
|
+
node_id = f"{screen_id}-{base}"
|
|
155
|
+
while node_id in used:
|
|
156
|
+
node_id += "-2"
|
|
157
|
+
used.add(node_id)
|
|
158
|
+
node_type = "container"
|
|
159
|
+
if tag in {"ul", "ol"}: node_type = "list"
|
|
160
|
+
elif tag == "li": node_type = "card"
|
|
161
|
+
elif tag in {"h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "label", "small", "strong"}: node_type = "text"
|
|
162
|
+
elif tag in {"button", "a"}: node_type = "button"
|
|
163
|
+
elif tag in {"input", "textarea", "select"}: node_type = "input"
|
|
164
|
+
elif tag == "img": node_type = "image"
|
|
165
|
+
elif tag == "hr": node_type = "divider"
|
|
166
|
+
node: dict[str, Any] = {"type": node_type, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": item["line"]}, "confidence": "high", "standardRef": f"html.{tag}", "provenance": {}}
|
|
167
|
+
if item["text"]:
|
|
168
|
+
node["text"] = item["text"]
|
|
169
|
+
node["provenance"]["text"] = property_evidence(context.source, item["line"], item["text"], self.id, "exact")
|
|
170
|
+
if node_type == "image" and attrs.get("src"):
|
|
171
|
+
node["asset"] = attrs["src"]
|
|
172
|
+
node["provenance"]["asset"] = property_evidence(context.source, item["line"], attrs["src"], self.id, "exact")
|
|
173
|
+
if tag == "a" and attrs.get("href"):
|
|
174
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": item["line"], "expression": attrs["href"], "reason": "route-resolution-required"})
|
|
175
|
+
selectors = [tag]
|
|
176
|
+
if attrs.get("id"): selectors.append(f"#{attrs['id']}")
|
|
177
|
+
selectors.extend(f".{name}" for name in str(attrs.get("class") or "").split())
|
|
178
|
+
declarations: dict[str, tuple[str, int, str]] = {}
|
|
179
|
+
for selector in selectors:
|
|
180
|
+
declarations.update(rules.get(selector, {}))
|
|
181
|
+
inline = attrs.get("style") or ""
|
|
182
|
+
for declaration in re.finditer(r"([\w-]+)\s*:\s*([^;]+)", inline):
|
|
183
|
+
declarations[declaration.group(1).lower()] = (declaration.group(2).strip(), item["line"], context.source)
|
|
184
|
+
for prop, (value, line, source) in declarations.items():
|
|
185
|
+
if prop not in CSS_PROPERTY_MAP:
|
|
186
|
+
continue
|
|
187
|
+
group, key = CSS_PROPERTY_MAP[prop]
|
|
188
|
+
if prop == "flex-direction": value = "column" if value == "column" else "row"
|
|
189
|
+
node[group][key] = _css_value(value, "colors" if group == "style" else "spacing", result.tokens)
|
|
190
|
+
node["provenance"][f"{group}.{key}"] = property_evidence(source, line, f"{prop}: {value}", self.id, "exact")
|
|
191
|
+
child_ids = [child_id for child in item["children"] if (child_id := convert(child))]
|
|
192
|
+
node["children"] = child_ids
|
|
193
|
+
result.nodes[node_id] = node
|
|
194
|
+
return node_id
|
|
195
|
+
|
|
196
|
+
child_ids = [node_id for item in roots if (node_id := convert(item))]
|
|
197
|
+
result.nodes[root_id] = {
|
|
198
|
+
"type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": child_ids,
|
|
199
|
+
"source": {"file": context.source, "line": 1}, "confidence": "high", "standardRef": "html.document",
|
|
200
|
+
"provenance": {
|
|
201
|
+
"layout.direction": property_evidence(context.source, 1, "document flow", self.id, "high"),
|
|
202
|
+
"layout.width": property_evidence(context.source, 1, "viewport width", self.id, "high"),
|
|
203
|
+
"layout.height": property_evidence(context.source, 1, "viewport height", self.id, "high"),
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
result.screens.append({"id": screen_id, "name": context.path.stem, "root": root_id, "route": "", "platform": "web", "source": {"file": context.source, "line": 1, "symbol": context.path.stem}, "confidence": "high"})
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
COMPOSE_TYPES = {"Column": "container", "Row": "container", "Box": "container", "LazyColumn": "list", "LazyRow": "list", "Scaffold": "container", "Card": "card", "Surface": "card", "Text": "text", "Button": "button", "TextField": "input", "OutlinedTextField": "input", "Image": "image", "Icon": "icon", "Spacer": "spacer"}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _balanced_close(text: str, start: int, opening: str, closing: str) -> int | None:
|
|
214
|
+
depth, quote, escaped = 0, None, False
|
|
215
|
+
for index in range(start, len(text)):
|
|
216
|
+
char = text[index]
|
|
217
|
+
if quote:
|
|
218
|
+
if escaped:
|
|
219
|
+
escaped = False
|
|
220
|
+
elif char == "\\":
|
|
221
|
+
escaped = True
|
|
222
|
+
elif char == quote:
|
|
223
|
+
quote = None
|
|
224
|
+
continue
|
|
225
|
+
if char in {'"', "'"}:
|
|
226
|
+
quote = char
|
|
227
|
+
elif char == opening:
|
|
228
|
+
depth += 1
|
|
229
|
+
elif char == closing:
|
|
230
|
+
depth -= 1
|
|
231
|
+
if depth == 0:
|
|
232
|
+
return index
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _compose_calls(body: str) -> list[dict[str, Any]]:
|
|
237
|
+
calls: list[dict[str, Any]] = []
|
|
238
|
+
pattern = re.compile(r"\b(" + "|".join(COMPOSE_TYPES) + r")\s*\(")
|
|
239
|
+
for match in pattern.finditer(body):
|
|
240
|
+
open_paren = body.find("(", match.start())
|
|
241
|
+
close_paren = _balanced_close(body, open_paren, "(", ")")
|
|
242
|
+
if close_paren is None:
|
|
243
|
+
continue
|
|
244
|
+
cursor = close_paren + 1
|
|
245
|
+
while cursor < len(body) and body[cursor].isspace():
|
|
246
|
+
cursor += 1
|
|
247
|
+
block_end = _balanced_close(body, cursor, "{", "}") if cursor < len(body) and body[cursor] == "{" else None
|
|
248
|
+
calls.append({"widget": match.group(1), "start": match.start(), "args": body[open_paren + 1:close_paren], "blockStart": cursor if block_end is not None else None, "blockEnd": block_end})
|
|
249
|
+
return calls
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _compose_modifier_calls(value: str) -> list[tuple[str, str]]:
|
|
253
|
+
calls: list[tuple[str, str]] = []
|
|
254
|
+
for match in re.finditer(r"\.(\w+)\s*\(", value):
|
|
255
|
+
open_paren = value.find("(", match.start())
|
|
256
|
+
close_paren = _balanced_close(value, open_paren, "(", ")")
|
|
257
|
+
if close_paren is not None:
|
|
258
|
+
calls.append((match.group(1), value[open_paren + 1:close_paren]))
|
|
259
|
+
return calls
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _compose_length(value: str, tokens: dict[str, Any], group: str = "spacing") -> Any:
|
|
263
|
+
clean = value.strip()
|
|
264
|
+
dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)\.(?:dp|sp)", clean)
|
|
265
|
+
if dimension:
|
|
266
|
+
return f"{dimension.group(1)}px"
|
|
267
|
+
number = re.fullmatch(r"-?\d+(?:\.\d+)?", clean)
|
|
268
|
+
if number:
|
|
269
|
+
return float(clean) if "." in clean else int(clean)
|
|
270
|
+
token_name = _slug(clean).replace("-", "_")
|
|
271
|
+
if token_name in tokens.get(group, {}):
|
|
272
|
+
return f"${group}.{token_name}"
|
|
273
|
+
for candidate, entries in tokens.items():
|
|
274
|
+
if token_name in entries:
|
|
275
|
+
return f"${candidate}.{token_name}"
|
|
276
|
+
return clean
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _compose_color(value: str) -> str | None:
|
|
280
|
+
clean = value.strip()
|
|
281
|
+
argb = re.fullmatch(r"Color\(0x([0-9a-fA-F]{8})\)", clean)
|
|
282
|
+
if argb:
|
|
283
|
+
alpha, red, green, blue = (argb.group(1)[index:index + 2] for index in (0, 2, 4, 6))
|
|
284
|
+
return f"#{red}{green}{blue}" if alpha.lower() == "ff" else f"#{red}{green}{blue}{alpha}"
|
|
285
|
+
rgb = re.fullmatch(r"Color\(0x([0-9a-fA-F]{6})\)", clean)
|
|
286
|
+
if rgb:
|
|
287
|
+
return f"#{rgb.group(1)}"
|
|
288
|
+
named = {"Black": "#000000", "White": "#ffffff", "Red": "#f44336", "Green": "#4caf50", "Blue": "#2196f3", "Transparent": "transparent"}
|
|
289
|
+
match = re.fullmatch(r"Color\.(\w+)", clean)
|
|
290
|
+
return named.get(match.group(1)) if match else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _compose_named_value(args: str, name: str) -> str | None:
|
|
294
|
+
match = re.search(rf"\b{re.escape(name)}\s*=\s*([^,\n]+)", args)
|
|
295
|
+
return match.group(1).strip() if match else None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _set_compose(node: dict[str, Any], path: str, value: Any, context: SourceContext, line: int, expression: str, adapter: str, confidence: str = "exact") -> None:
|
|
299
|
+
group, key = path.split(".", 1)
|
|
300
|
+
node.setdefault(group, {})[key] = value
|
|
301
|
+
node.setdefault("provenance", {})[path] = property_evidence(context.source, line, expression, adapter, confidence)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ComposeAdapter:
|
|
305
|
+
id = "compose"
|
|
306
|
+
platforms = ("android",)
|
|
307
|
+
extensions = (".kt", ".kts")
|
|
308
|
+
maturity = "structural"
|
|
309
|
+
structural_tier = "translated"
|
|
310
|
+
visual_tier = "none"
|
|
311
|
+
native_evidence_required = True
|
|
312
|
+
native_providers = ("android-compose-screenshot", "paparazzi", "roborazzi", "android-emulator")
|
|
313
|
+
resource_resolution = ("R.string", "R.drawable", "R.mipmap", "common Material Icons")
|
|
314
|
+
layout_features = ("size/fill/weight/aspectRatio", "directional padding", "Row/Column arrangement", "Box alignment", "basic typography")
|
|
315
|
+
limitations = ("supported Compose primitives and modifiers only", "custom composables remain explicit unsupported entries")
|
|
316
|
+
|
|
317
|
+
def __init__(self) -> None:
|
|
318
|
+
self._catalogs: dict[str, AndroidResourceCatalog] = {}
|
|
319
|
+
|
|
320
|
+
def prepare(self, contexts: list[SourceContext]) -> None:
|
|
321
|
+
roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
|
|
322
|
+
self._catalogs = {key: AndroidResourceCatalog.discover(root) for key, root in roots.items()}
|
|
323
|
+
|
|
324
|
+
def _catalog(self, context: SourceContext) -> AndroidResourceCatalog:
|
|
325
|
+
key = str(context.root.resolve())
|
|
326
|
+
if key not in self._catalogs:
|
|
327
|
+
self._catalogs[key] = AndroidResourceCatalog.discover(context.root)
|
|
328
|
+
return self._catalogs[key]
|
|
329
|
+
|
|
330
|
+
def supports(self, context: SourceContext) -> bool:
|
|
331
|
+
return context.path.suffix.lower() in {".kt", ".kts"} and ("@Composable" in context.text or "androidx.compose" in context.text)
|
|
332
|
+
|
|
333
|
+
def translate(self, context: SourceContext) -> AdapterResult:
|
|
334
|
+
result = AdapterResult(adapter=self.id)
|
|
335
|
+
catalog = self._catalog(context)
|
|
336
|
+
platform = "android"
|
|
337
|
+
for match in re.finditer(r"(?:val|const\s+val)\s+(\w+)\s*=\s*(Color\([^\n]+?\)|[^\s]+\.(?:dp|sp))", context.text):
|
|
338
|
+
name, value = match.group(1), match.group(2).strip()
|
|
339
|
+
group = "colors" if value.startswith("Color") else "typography" if value.endswith(".sp") else "spacing"
|
|
340
|
+
normalized = re.sub(r"(\d+(?:\.\d+)?)\.(?:dp|sp)", r"\1px", value)
|
|
341
|
+
result.tokens[group][_slug(name).replace("-", "_")] = {"value": normalized, "source": {"file": context.source, "line": context.text.count("\n", 0, match.start()) + 1}, "adapter": self.id}
|
|
342
|
+
functions = list(re.finditer(r"@Composable[\s\S]*?fun\s+([A-Z]\w*)\s*\([^)]*\)\s*\{", context.text))
|
|
343
|
+
for function_index, function in enumerate(functions):
|
|
344
|
+
name = function.group(1)
|
|
345
|
+
start = function.end()
|
|
346
|
+
depth, end = 1, start
|
|
347
|
+
while end < len(context.text) and depth:
|
|
348
|
+
depth += (context.text[end] == "{") - (context.text[end] == "}")
|
|
349
|
+
end += 1
|
|
350
|
+
body = context.text[start:max(start, end - 1)]
|
|
351
|
+
screen_id = _slug(name)
|
|
352
|
+
root_id = f"{screen_id}-root"
|
|
353
|
+
result.nodes[root_id] = {"type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": [], "source": {"file": context.source, "line": context.text.count("\n", 0, function.start()) + 1, "symbol": name}, "confidence": "high", "standardRef": f"project.{platform}.compose.screen", "provenance": {
|
|
354
|
+
"layout.direction": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
|
|
355
|
+
"layout.width": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
|
|
356
|
+
"layout.height": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
|
|
357
|
+
}}
|
|
358
|
+
calls = _compose_calls(body)
|
|
359
|
+
call_nodes: list[tuple[dict[str, Any], str]] = []
|
|
360
|
+
for count, call in enumerate(calls, start=1):
|
|
361
|
+
widget, args = call["widget"], call["args"]
|
|
362
|
+
node_id = f"{screen_id}-{_slug(widget)}-{count}"
|
|
363
|
+
line = context.text.count("\n", 0, start + call["start"]) + 1
|
|
364
|
+
native_prefix = "material3"
|
|
365
|
+
standard = f"{native_prefix}.{widget.lower()}" if widget in {"Scaffold", "Card", "Surface", "Text", "Button", "TextField", "OutlinedTextField", "Icon"} else f"project.{platform}.compose.{widget.lower()}"
|
|
366
|
+
node: dict[str, Any] = {"type": COMPOSE_TYPES[widget], "component": widget, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line, "symbol": name}, "confidence": "high", "standardRef": standard, "provenance": {"component": property_evidence(context.source, line, widget, self.id, "exact")}}
|
|
367
|
+
if standard.startswith("material3."):
|
|
368
|
+
node["inheritsAppearance"] = True
|
|
369
|
+
literal = re.search(r"(?:text\s*=\s*)?\"([^\"]*)\"", args)
|
|
370
|
+
if literal and node["type"] in {"text", "button", "input"}:
|
|
371
|
+
node["text"] = literal.group(1)
|
|
372
|
+
node["provenance"]["text"] = property_evidence(context.source, line, literal.group(0), self.id, "exact")
|
|
373
|
+
string_resource = re.search(r"stringResource\s*\(\s*R\.string\.(\w+)\s*\)", args)
|
|
374
|
+
if string_resource and node["type"] in {"text", "button", "input"}:
|
|
375
|
+
resolved = catalog.resolve(f"@string/{string_resource.group(1)}", "string")
|
|
376
|
+
if resolved.resolved:
|
|
377
|
+
node["text"] = resolved.value
|
|
378
|
+
node["provenance"]["text"] = property_evidence(resolved.source, resolved.line, string_resource.group(0), self.id, resolved.confidence)
|
|
379
|
+
else:
|
|
380
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": string_resource.group(0), "reason": "unresolved-android-string"})
|
|
381
|
+
if widget in {"Column", "LazyColumn"}:
|
|
382
|
+
node["layout"]["direction"] = "column"
|
|
383
|
+
node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
|
|
384
|
+
if widget in {"Row", "LazyRow"}:
|
|
385
|
+
node["layout"]["direction"] = "row"
|
|
386
|
+
node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
|
|
387
|
+
if widget == "Box":
|
|
388
|
+
node["layout"]["direction"] = "overlay"
|
|
389
|
+
node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
|
|
390
|
+
alignments = {
|
|
391
|
+
"Alignment.Start": "start", "Alignment.CenterHorizontally": "center", "Alignment.End": "end",
|
|
392
|
+
"Alignment.Top": "start", "Alignment.CenterVertically": "center", "Alignment.Bottom": "end",
|
|
393
|
+
}
|
|
394
|
+
arrangements = {
|
|
395
|
+
"Arrangement.Start": "start", "Arrangement.Top": "start", "Arrangement.End": "end", "Arrangement.Bottom": "end",
|
|
396
|
+
"Arrangement.Center": "center", "Arrangement.SpaceBetween": "between", "Arrangement.SpaceAround": "around", "Arrangement.SpaceEvenly": "evenly",
|
|
397
|
+
}
|
|
398
|
+
if widget in {"Column", "LazyColumn"}:
|
|
399
|
+
alignment = _compose_named_value(args, "horizontalAlignment")
|
|
400
|
+
arrangement = _compose_named_value(args, "verticalArrangement")
|
|
401
|
+
if alignment in alignments: _set_compose(node, "layout.align", alignments[alignment], context, line, f"horizontalAlignment = {alignment}", self.id)
|
|
402
|
+
if arrangement in arrangements: _set_compose(node, "layout.justify", arrangements[arrangement], context, line, f"verticalArrangement = {arrangement}", self.id)
|
|
403
|
+
if widget in {"Row", "LazyRow"}:
|
|
404
|
+
alignment = _compose_named_value(args, "verticalAlignment")
|
|
405
|
+
arrangement = _compose_named_value(args, "horizontalArrangement")
|
|
406
|
+
if alignment in alignments: _set_compose(node, "layout.align", alignments[alignment], context, line, f"verticalAlignment = {alignment}", self.id)
|
|
407
|
+
if arrangement in arrangements: _set_compose(node, "layout.justify", arrangements[arrangement], context, line, f"horizontalArrangement = {arrangement}", self.id)
|
|
408
|
+
if widget == "Box":
|
|
409
|
+
content_alignment = _compose_named_value(args, "contentAlignment")
|
|
410
|
+
if content_alignment and "Center" in content_alignment:
|
|
411
|
+
_set_compose(node, "layout.align", "center", context, line, f"contentAlignment = {content_alignment}", self.id)
|
|
412
|
+
_set_compose(node, "layout.justify", "center", context, line, f"contentAlignment = {content_alignment}", self.id)
|
|
413
|
+
for method, value in _compose_modifier_calls(args):
|
|
414
|
+
expression = f".{method}({value})"
|
|
415
|
+
if method in {"size", "width", "height", "requiredSize", "requiredWidth", "requiredHeight"}:
|
|
416
|
+
normalized = _compose_length(value.split(",", 1)[0], result.tokens)
|
|
417
|
+
keys = ("width", "height") if method in {"size", "requiredSize"} else ("width",) if "Width" in method or method == "width" else ("height",)
|
|
418
|
+
for key in keys: _set_compose(node, f"layout.{key}", normalized, context, line, expression, self.id)
|
|
419
|
+
elif method == "padding":
|
|
420
|
+
named = {name: _compose_named_value(value, name) for name in ("horizontal", "vertical", "start", "end", "top", "bottom")}
|
|
421
|
+
if any(named.values()):
|
|
422
|
+
key_map = {"horizontal": "paddingHorizontal", "vertical": "paddingVertical", "start": "paddingLeft", "end": "paddingRight", "top": "paddingTop", "bottom": "paddingBottom"}
|
|
423
|
+
for side_name, raw in named.items():
|
|
424
|
+
if raw: _set_compose(node, f"layout.{key_map[side_name]}", _compose_length(raw, result.tokens), context, line, expression, self.id)
|
|
425
|
+
else:
|
|
426
|
+
_set_compose(node, "layout.padding", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
|
|
427
|
+
elif method in {"fillMaxWidth", "fillMaxHeight", "fillMaxSize"}:
|
|
428
|
+
keys = ("width", "height") if method == "fillMaxSize" else ("width",) if method == "fillMaxWidth" else ("height",)
|
|
429
|
+
for key in keys: _set_compose(node, f"layout.{key}", "fill", context, line, expression, self.id)
|
|
430
|
+
elif method == "weight":
|
|
431
|
+
_set_compose(node, "layout.grow", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
|
|
432
|
+
elif method == "aspectRatio":
|
|
433
|
+
_set_compose(node, "layout.aspectRatio", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
|
|
434
|
+
elif method in {"verticalScroll", "horizontalScroll"}:
|
|
435
|
+
_set_compose(node, "layout.overflowY" if method == "verticalScroll" else "layout.overflowX", "auto", context, line, expression, self.id)
|
|
436
|
+
elif method == "align":
|
|
437
|
+
target = next((mapped for source, mapped in alignments.items() if source in value), None)
|
|
438
|
+
if target: _set_compose(node, "layout.alignSelf", target, context, line, expression, self.id)
|
|
439
|
+
elif method == "background":
|
|
440
|
+
color = _compose_color(value.split(",", 1)[0])
|
|
441
|
+
if color: _set_compose(node, "style.backgroundColor", color, context, line, expression, self.id)
|
|
442
|
+
elif method == "clip":
|
|
443
|
+
radius = re.search(r"RoundedCornerShape\s*\(\s*([^)]+)\)", value)
|
|
444
|
+
if radius: _set_compose(node, "style.radius", _compose_length(radius.group(1), result.tokens), context, line, expression, self.id)
|
|
445
|
+
font_size = _compose_named_value(args, "fontSize")
|
|
446
|
+
if font_size: _set_compose(node, "style.fontSize", _compose_length(font_size, result.tokens, "typography"), context, line, f"fontSize = {font_size}", self.id)
|
|
447
|
+
line_height = _compose_named_value(args, "lineHeight")
|
|
448
|
+
if line_height: _set_compose(node, "style.lineHeight", _compose_length(line_height, result.tokens, "typography"), context, line, f"lineHeight = {line_height}", self.id)
|
|
449
|
+
letter_spacing = _compose_named_value(args, "letterSpacing")
|
|
450
|
+
if letter_spacing: _set_compose(node, "style.letterSpacing", _compose_length(letter_spacing, result.tokens, "typography"), context, line, f"letterSpacing = {letter_spacing}", self.id)
|
|
451
|
+
font_weight = _compose_named_value(args, "fontWeight")
|
|
452
|
+
weight_map = {"FontWeight.Normal": 400, "FontWeight.Medium": 500, "FontWeight.SemiBold": 600, "FontWeight.Bold": 700, "FontWeight.ExtraBold": 800}
|
|
453
|
+
if font_weight in weight_map: _set_compose(node, "style.fontWeight", weight_map[font_weight], context, line, f"fontWeight = {font_weight}", self.id)
|
|
454
|
+
color_value = _compose_named_value(args, "color")
|
|
455
|
+
color = _compose_color(color_value) if color_value else None
|
|
456
|
+
if color: _set_compose(node, "style.color", color, context, line, f"color = {color_value}", self.id)
|
|
457
|
+
text_align = _compose_named_value(args, "textAlign")
|
|
458
|
+
if text_align:
|
|
459
|
+
_set_compose(node, "style.textAlign", text_align.rsplit(".", 1)[-1].lower(), context, line, f"textAlign = {text_align}", self.id)
|
|
460
|
+
if widget == "Icon":
|
|
461
|
+
icon = re.search(r"Icons\.(?:Default|Filled|Outlined|Rounded|Sharp|TwoTone)\.(\w+)", args)
|
|
462
|
+
if icon:
|
|
463
|
+
node["iconName"] = icon.group(1)
|
|
464
|
+
asset = material_icon_asset(icon.group(1))
|
|
465
|
+
if asset:
|
|
466
|
+
node["asset"] = asset
|
|
467
|
+
node["provenance"]["asset"] = property_evidence(context.source, line, icon.group(0), self.id, "high")
|
|
468
|
+
else:
|
|
469
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": icon.group(0), "reason": "unsupported-material-icon"})
|
|
470
|
+
painter = re.search(r"painterResource\s*\(\s*(?:id\s*=\s*)?R\.(drawable|mipmap)\.(\w+)\s*\)", args)
|
|
471
|
+
if painter:
|
|
472
|
+
drawable = catalog.drawable(f"@{painter.group(1)}/{painter.group(2)}")
|
|
473
|
+
if drawable.asset:
|
|
474
|
+
node["asset"] = drawable.asset
|
|
475
|
+
node["provenance"]["asset"] = property_evidence(drawable.source, drawable.line, painter.group(0), self.id, drawable.confidence)
|
|
476
|
+
else:
|
|
477
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": painter.group(0), "reason": "unsupported-android-drawable"})
|
|
478
|
+
description = _compose_named_value(args, "contentDescription")
|
|
479
|
+
if description and description != "null":
|
|
480
|
+
node["alt"] = description.strip('"')
|
|
481
|
+
node["semantics"] = {"label": node["alt"], "role": "image" if node["type"] in {"image", "icon"} else "button"}
|
|
482
|
+
result.nodes[node_id] = node
|
|
483
|
+
call_nodes.append((call, node_id))
|
|
484
|
+
for call, node_id in call_nodes:
|
|
485
|
+
parents = [
|
|
486
|
+
(candidate, candidate_id) for candidate, candidate_id in call_nodes
|
|
487
|
+
if candidate["blockStart"] is not None and candidate["blockEnd"] is not None
|
|
488
|
+
and candidate["blockStart"] < call["start"] < candidate["blockEnd"]
|
|
489
|
+
]
|
|
490
|
+
parent_id = max(parents, key=lambda item: item[0]["blockStart"])[1] if parents else root_id
|
|
491
|
+
result.nodes[parent_id]["children"].append(node_id)
|
|
492
|
+
if result.nodes[parent_id].get("layout", {}).get("direction") == "overlay":
|
|
493
|
+
result.nodes[node_id].setdefault("layout", {})["gridArea"] = "1 / 1"
|
|
494
|
+
supported_names = set(COMPOSE_TYPES) | {"Modifier", "Color", "RoundedCornerShape"}
|
|
495
|
+
for unknown in re.finditer(r"\b([A-Z]\w*)\s*\(", body):
|
|
496
|
+
if unknown.group(1) in supported_names:
|
|
497
|
+
continue
|
|
498
|
+
result.unsupported.append({"adapter": self.id, "file": context.source, "line": context.text.count("\n", 0, start + unknown.start()) + 1, "expression": unknown.group(1), "reason": "unsupported-compose-call"})
|
|
499
|
+
result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": context.text.count("\n", 0, function.start()) + 1, "symbol": name}, "confidence": "high"})
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
register_adapter(WebAdapter())
|
|
504
|
+
register_adapter(ComposeAdapter())
|
|
505
|
+
from fidelity_platform_adapters import builtin_platform_adapters
|
|
506
|
+
|
|
507
|
+
for _adapter in builtin_platform_adapters(WebAdapter()):
|
|
508
|
+
register_adapter(_adapter)
|