ui-design-workbench-cli 0.6.7__tar.gz → 0.6.9__tar.gz
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.
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/PKG-INFO +2 -2
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/README.md +1 -1
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/pyproject.toml +9 -1
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/fidelity.md +12 -0
- ui_design_workbench_cli-0.6.9/scripts/compose_instances.py +165 -0
- ui_design_workbench_cli-0.6.9/scripts/compose_resources.py +242 -0
- ui_design_workbench_cli-0.6.9/scripts/compose_syntax.py +36 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_adapter_api.py +11 -1
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_adapters.py +225 -45
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_platform_adapters.py +323 -59
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/layout_model.py +11 -2
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/platform_component_catalog.py +23 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/render_preview.py +7 -5
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/scan_ui.py +1 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/smoke_preview.js +38 -12
- ui_design_workbench_cli-0.6.9/scripts/source_syntax.py +118 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/PKG-INFO +2 -2
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/SOURCES.txt +4 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/top_level.txt +4 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/uidw.py +6 -2
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/SKILL.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/adapter-api.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/agent-integrations.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/cache-protocol.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-catalog.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-catalog.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-inventory.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/design-modes.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ir-schema.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/native-rendering.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platform-profiles.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platform-standards.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platforms.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/quality-automation.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/review-workflow.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-guidance-mode.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-policy-schema.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-reviewer.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/validation.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/workbench-ui.md +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/native-render-state.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-agent-job.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-graph.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-ir.patch.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-ir.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/uidw-config.schema.json +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/android_resource_resolver.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/android_xml_support.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/apple_resource_resolver.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/coverage_report.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_core.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/generate_interaction_matrix.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ir_contracts.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/merge_review_state.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_android.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_apple.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_contracts.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_registry.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/quality_common.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/scoped_context.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/dependency_links.txt +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/entry_points.txt +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/requires.txt +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/uidw_mcp.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/validate_platform_profiles.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/visual_regression.py +0 -0
- {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ui-design-workbench-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.9
|
|
4
4
|
Summary: Repository UI indexing, graph, preview, and deterministic review CLI
|
|
5
5
|
Author: Elgreed
|
|
6
6
|
Project-URL: Homepage, https://github.com/Elgreed/ui-design-workbench
|
|
@@ -131,4 +131,4 @@ A Windows `.exe` is not published yet. PyPI + `pipx` is the primary cross-platfo
|
|
|
131
131
|
- [IR schema](references/ir-schema.md)
|
|
132
132
|
- [Review workflow](references/review-workflow.md)
|
|
133
133
|
|
|
134
|
-
Current CLI version: `0.6.
|
|
134
|
+
Current CLI version: `0.6.9`.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ui-design-workbench-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.9"
|
|
8
8
|
description = "Repository UI indexing, graph, preview, and deterministic review CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -43,6 +43,10 @@ py-modules = [
|
|
|
43
43
|
"fidelity_core",
|
|
44
44
|
"fidelity_adapter_api",
|
|
45
45
|
"fidelity_adapters",
|
|
46
|
+
"source_syntax",
|
|
47
|
+
"compose_syntax",
|
|
48
|
+
"compose_resources",
|
|
49
|
+
"compose_instances",
|
|
46
50
|
"fidelity_platform_adapters",
|
|
47
51
|
"android_resource_resolver",
|
|
48
52
|
"apple_resource_resolver",
|
|
@@ -68,6 +72,10 @@ py-modules = [
|
|
|
68
72
|
"scripts/coverage_report.py",
|
|
69
73
|
"scripts/fidelity_adapter_api.py",
|
|
70
74
|
"scripts/fidelity_adapters.py",
|
|
75
|
+
"scripts/source_syntax.py",
|
|
76
|
+
"scripts/compose_syntax.py",
|
|
77
|
+
"scripts/compose_resources.py",
|
|
78
|
+
"scripts/compose_instances.py",
|
|
71
79
|
"scripts/fidelity_core.py",
|
|
72
80
|
"scripts/fidelity_platform_adapters.py",
|
|
73
81
|
"scripts/generate_interaction_matrix.py",
|
|
@@ -10,6 +10,18 @@ Use `uidw fidelity capabilities` for the installed support matrix, `uidw fidelit
|
|
|
10
10
|
|
|
11
11
|
The HTML projection resolves recognized controls through the official-doc-first [platform component catalog](component-catalog.md). Catalog geometry is a render-only fallback: explicit source layout, theme, token, and style evidence always wins, and the source IR is not mutated.
|
|
12
12
|
|
|
13
|
+
Compose translation follows unique local composable declarations, literal parameter defaults and constructor preview fixtures. Simple imported enum `entries.forEach` loops and literal resource getters can supply repeated content. Expansion is limited to six nested components and 2,000 calls per translated source; ambiguity, recursion and exhausted budgets remain explicit gaps. Runtime Kotlin, arbitrary collection transformations, slot forwarding, custom drawing and arbitrary theme providers are not evaluated.
|
|
14
|
+
|
|
15
|
+
Literal project theme values and local font assets are resolved from source. The box model marks automatic text measurement as partial and lets the browser wrap and measure the loaded font, rather than treating average character widths as solved geometry. Browser line boxes and shadows can still differ from native rendering. A partial context does not establish native visual parity.
|
|
16
|
+
|
|
17
|
+
For a user-requested native comparison, use the same authored preview state, locale, font scale and logical viewport. Capture metadata can provide `viewport.safeAreaInsets` in logical pixels for Compose system-bar padding. `smoke_preview.js --capture-device --capture-screen ID --capture-theme light --screenshot screen.png --geometry-output geometry.json` captures the device content after fonts load, excluding workbench chrome. Keep the native screenshot, device density, source revision and comparison results with the diagnostic artifact; system-bar icons themselves are outside the source projection.
|
|
18
|
+
|
|
19
|
+
Flutter and SwiftUI use the same balanced source delimiter and literal masking utilities as Compose. Visual parameters are scoped to the current call, excluding child widgets and event handlers. SwiftUI expands unique local View declarations with literal member arguments and represents order-dependent frame/padding/background operations as separate source-backed nodes. Flutter resolves literal constructor defaults, distinguishes TextStyle height factors from widget heights, and propagates SizedBox tight constraints to its child. Expansion is capped at six component levels and 2,000 widget calls. Arbitrary control flow, unavailable SF Symbols and recursive expansion remain gaps.
|
|
20
|
+
|
|
21
|
+
XAML translates four-value Thickness in left/top/right/bottom order, alpha-first colors, explicit grid tracks/cell spans and literal states. WPF Border and TextBlock defaults are distinct from generic card recipes. Resource bindings, control templates, nonliteral grid tracks and more complex platform layout behavior still require separate handling. Web handles the supported simple selectors in CSS cascade order, including importance and linked stylesheet order; unsupported at-rules are reported rather than applied unconditionally.
|
|
22
|
+
|
|
23
|
+
The source fixtures in `fixtures/golden/platform-fidelity` exercise these shared failure patterns. A Flutter engine golden, a WPF RenderTargetBitmap and an original Web page can provide independent runtime evidence for their respective fixture. WPF evidence does not verify WinUI, and Flutter test-engine evidence does not verify every device configuration. SwiftUI iOS/macOS fixture assertions are source-only unless matching Apple runtime captures are supplied. Successful source tests or a small native comparison must never promote unrelated screens or platforms to verified visual parity.
|
|
24
|
+
|
|
13
25
|
Use this contract before creating reviewable IR. The goal is not a visually pleasant approximation; it is a traceable projection of the authored UI.
|
|
14
26
|
|
|
15
27
|
## Evidence bundle per screen
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Static argument substitution for bounded local Compose component instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from compose_syntax import arguments, closing, mask_literals, named_argument
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def substitute(expression: str, bindings: dict[str, str]) -> str:
|
|
12
|
+
masked = mask_literals(expression)
|
|
13
|
+
edits = []
|
|
14
|
+
for match in re.finditer(r"\b[A-Za-z_]\w*\b", masked):
|
|
15
|
+
if match.start() > 0 and masked[match.start() - 1] == ".":
|
|
16
|
+
continue
|
|
17
|
+
# Named argument keys and lambda parameter declarations are not values.
|
|
18
|
+
tail = masked[match.end():]
|
|
19
|
+
if re.match(r"\s*=(?!=)", tail) or re.match(r"\s*->", tail):
|
|
20
|
+
continue
|
|
21
|
+
value = bindings.get(match.group())
|
|
22
|
+
if value is not None and value != match.group():
|
|
23
|
+
edits.append((match.start(), match.end(), value))
|
|
24
|
+
for start, end, value in reversed(edits):
|
|
25
|
+
expression = expression[:start] + value + expression[end:]
|
|
26
|
+
return expression
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def static_value(expression: str, bindings: dict[str, str]) -> str:
|
|
30
|
+
expression = substitute(expression, bindings).strip()
|
|
31
|
+
# Read authored constructor fixtures such as ScreenState(selected = Choice.ONE).selected.
|
|
32
|
+
for match in reversed(list(re.finditer(r"\b[A-Z]\w*\s*\(", mask_literals(expression)))):
|
|
33
|
+
start = expression.find("(", match.start())
|
|
34
|
+
end = closing(expression, start)
|
|
35
|
+
member = re.match(r"\.(\w+)", expression[end + 1:]) if end is not None else None
|
|
36
|
+
if member:
|
|
37
|
+
value = named_argument(expression[start + 1:end], member.group(1))
|
|
38
|
+
if value is not None:
|
|
39
|
+
expression = expression[:match.start()] + value + expression[end + 1 + member.end():]
|
|
40
|
+
equality = re.fullmatch(r"([A-Z]\w*\.\w+|true|false|null)\s*==\s*([A-Z]\w*\.\w+|true|false|null)", expression)
|
|
41
|
+
if equality:
|
|
42
|
+
return "true" if equality.group(1) == equality.group(2) else "false"
|
|
43
|
+
condition = re.match(r"if\s*\(\s*([^()]+)\s*\)\s*", expression)
|
|
44
|
+
if condition:
|
|
45
|
+
test = condition.group(1).strip()
|
|
46
|
+
equality = re.fullmatch(r"([A-Z]\w*\.\w+|true|false)\s*==\s*([A-Z]\w*\.\w+|true|false)", test)
|
|
47
|
+
selected = test == "true" if test in {"true", "false"} else equality.group(1) == equality.group(2) if equality else None
|
|
48
|
+
if selected is not None:
|
|
49
|
+
branch_start = condition.end()
|
|
50
|
+
if expression[branch_start:branch_start + 1] == "{":
|
|
51
|
+
branch_end = closing(expression, branch_start)
|
|
52
|
+
if branch_end is not None:
|
|
53
|
+
alternative = re.match(r"\s*else\s*", expression[branch_end + 1:])
|
|
54
|
+
if alternative:
|
|
55
|
+
second = expression[branch_end + 1 + alternative.end():].strip()
|
|
56
|
+
return (expression[branch_start + 1:branch_end] if selected else second.removeprefix("{").removesuffix("}")).strip()
|
|
57
|
+
inline = re.fullmatch(r"([^\n]+?)\s+else\s+([^\n]+)", expression[branch_start:])
|
|
58
|
+
if inline:
|
|
59
|
+
return inline.group(1 if selected else 2).strip()
|
|
60
|
+
choice = re.match(r"when\s*\(\s*([A-Z]\w*\.\w+)\s*\)\s*\{", expression)
|
|
61
|
+
if choice:
|
|
62
|
+
branches = list(re.finditer(r"(?:^|\n)\s*([A-Z]\w*\.\w+|else)\s*->\s*", expression))
|
|
63
|
+
for index, branch in enumerate(branches):
|
|
64
|
+
if branch.group(1) in {choice.group(1), "else"}:
|
|
65
|
+
end = branches[index + 1].start() if index + 1 < len(branches) else expression.rfind("}")
|
|
66
|
+
return expression[branch.end():end].strip()
|
|
67
|
+
return expression
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bind_parameters(parameters: str, args: str) -> dict[str, str]:
|
|
71
|
+
result = {}
|
|
72
|
+
names = []
|
|
73
|
+
for parameter in arguments(parameters):
|
|
74
|
+
match = re.match(r"(\w+)\s*:", parameter.strip())
|
|
75
|
+
if not match:
|
|
76
|
+
continue
|
|
77
|
+
name = match.group(1)
|
|
78
|
+
names.append(name)
|
|
79
|
+
default = re.search(r"(?<![=!])=(?!=)", parameter)
|
|
80
|
+
if default:
|
|
81
|
+
result[name] = parameter[default.end():].strip()
|
|
82
|
+
position = 0
|
|
83
|
+
for argument in arguments(args):
|
|
84
|
+
named = re.match(r"(\w+)\s*=\s*([\s\S]+)", argument)
|
|
85
|
+
if named:
|
|
86
|
+
if named.group(1) in names:
|
|
87
|
+
result[named.group(1)] = named.group(2)
|
|
88
|
+
elif position < len(names):
|
|
89
|
+
result[names[position]] = argument
|
|
90
|
+
position += 1
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StaticImports:
|
|
95
|
+
def __init__(self):
|
|
96
|
+
self.cache = {}
|
|
97
|
+
self.paths = {}
|
|
98
|
+
|
|
99
|
+
def source_paths(self, root):
|
|
100
|
+
if root not in self.paths:
|
|
101
|
+
paths = []
|
|
102
|
+
for directory, subdirs, files in os.walk(root):
|
|
103
|
+
subdirs[:] = [name for name in subdirs if name not in {
|
|
104
|
+
"build", "node_modules", ".git", ".gradle", ".idea", ".codegraph", ".ui-design-workbench"}]
|
|
105
|
+
for name in files:
|
|
106
|
+
if name.endswith(".kt"):
|
|
107
|
+
path = root / os.path.relpath(os.path.join(directory, name), root)
|
|
108
|
+
if "/src/" in path.as_posix():
|
|
109
|
+
paths.append(path)
|
|
110
|
+
self.paths[root] = paths
|
|
111
|
+
return self.paths[root]
|
|
112
|
+
|
|
113
|
+
def sources(self, name, context):
|
|
114
|
+
key = (str(context.root), context.source, name)
|
|
115
|
+
if key not in self.cache:
|
|
116
|
+
imports = re.findall(rf"^import\s+([\w.]+\.{re.escape(name)})\s*$", context.text, re.M)
|
|
117
|
+
paths = []
|
|
118
|
+
for imported in imports:
|
|
119
|
+
package = imported.rsplit(".", 1)[0].replace(".", "/")
|
|
120
|
+
paths.extend(path for path in self.source_paths(context.root)
|
|
121
|
+
if any(path.parent.as_posix().endswith(f"/{language}/{package}") for language in ("java", "kotlin")))
|
|
122
|
+
self.cache[key] = [(path, path.read_text(encoding="utf-8")) for path in sorted(set(paths)) if path.is_file()]
|
|
123
|
+
return self.cache[key]
|
|
124
|
+
|
|
125
|
+
def enum_entries(self, name, context):
|
|
126
|
+
matches = []
|
|
127
|
+
for path, text in self.sources(name, context):
|
|
128
|
+
match = re.search(rf"\benum\s+class\s+{re.escape(name)}\s*\{{([^{{}};]*)}}", mask_literals(text))
|
|
129
|
+
if match:
|
|
130
|
+
entries = [item.strip() for item in match.group(1).split(",") if item.strip()]
|
|
131
|
+
if all(re.fullmatch(r"[A-Z]\w*", item) for item in entries) and len(entries) <= 32:
|
|
132
|
+
matches.append([f"{name}.{item}" for item in entries])
|
|
133
|
+
return matches[0] if len(matches) == 1 else []
|
|
134
|
+
|
|
135
|
+
def resolve_properties(self, expression, context):
|
|
136
|
+
for match in reversed(list(re.finditer(r"\b([A-Z]\w*\.\w+)\.(\w+)\b", expression))):
|
|
137
|
+
values = []
|
|
138
|
+
for path, text in self.sources(match.group(2), context):
|
|
139
|
+
owner = match.group(1).split(".")[0]
|
|
140
|
+
declaration = re.search(rf"\bval\s+{owner}\.{match.group(2)}\s*:\s*\w+\s+get\(\)\s*=\s*when\s*\(this\)\s*\{{", text)
|
|
141
|
+
if declaration:
|
|
142
|
+
end = closing(text, text.find("{", declaration.start()))
|
|
143
|
+
body = text[declaration.end():end] if end is not None else ""
|
|
144
|
+
branch = re.search(rf"\b{re.escape(match.group(1))}\s*->\s*(R\.string\.\w+)\b", body)
|
|
145
|
+
if branch:
|
|
146
|
+
values.append(branch.group(1))
|
|
147
|
+
if len(values) == 1:
|
|
148
|
+
expression = expression[:match.start()] + values[0] + expression[match.end():]
|
|
149
|
+
return expression
|
|
150
|
+
|
|
151
|
+
def expand_calls(self, calls, body, context):
|
|
152
|
+
result = calls
|
|
153
|
+
for loop in re.finditer(r"\b(\w+)\.entries\.forEach\s*\{\s*(\w+)\s*->", mask_literals(body)):
|
|
154
|
+
end = closing(body, body.find("{", loop.start()))
|
|
155
|
+
entries = self.enum_entries(loop.group(1), context)
|
|
156
|
+
if end is None or not entries:
|
|
157
|
+
continue
|
|
158
|
+
enclosed = [call for call in result if loop.end() <= call["start"] < end]
|
|
159
|
+
if enclosed:
|
|
160
|
+
position = result.index(enclosed[0])
|
|
161
|
+
expanded = [{**call, "loopBindings": {**call.get("loopBindings", {}), loop.group(2): entry}, "loopInstance": entry}
|
|
162
|
+
for entry in entries for call in enclosed]
|
|
163
|
+
result = [call for call in result if call not in enclosed]
|
|
164
|
+
result[position:position] = expanded
|
|
165
|
+
return result
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Resolve literal Compose theme values and local font assets without executing Kotlin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from compose_syntax import arguments, closing, mask_literals, named_argument
|
|
9
|
+
from fidelity_core import property_evidence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
WEIGHTS = {"Thin": 100, "ExtraLight": 200, "Light": 300, "Normal": 400, "Medium": 500,
|
|
13
|
+
"SemiBold": 600, "Bold": 700, "ExtraBold": 800, "Black": 900}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expression_at(text: str, start: int) -> str:
|
|
17
|
+
masked = mask_literals(text)
|
|
18
|
+
depth = 0
|
|
19
|
+
for index in range(start, len(text)):
|
|
20
|
+
char = masked[index]
|
|
21
|
+
if char in "({[":
|
|
22
|
+
depth += 1
|
|
23
|
+
elif char in ")}]":
|
|
24
|
+
if depth == 0:
|
|
25
|
+
return text[start:index].strip()
|
|
26
|
+
depth -= 1
|
|
27
|
+
elif char in "\n;," and depth == 0:
|
|
28
|
+
return text[start:index].strip()
|
|
29
|
+
return text[start:].strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ComposeResources:
|
|
33
|
+
def __init__(self, contexts):
|
|
34
|
+
self.definitions = defaultdict(list)
|
|
35
|
+
self.aliases = {}
|
|
36
|
+
self.fonts = []
|
|
37
|
+
self.bindings = defaultdict(set)
|
|
38
|
+
self._resolved = {}
|
|
39
|
+
self.contexts = [context for context in contexts if context.path.suffix == ".kt" and
|
|
40
|
+
(context.role == "theme" or "/theme/" in context.source)]
|
|
41
|
+
for context in self.contexts:
|
|
42
|
+
text = context.text
|
|
43
|
+
for match in re.finditer(r"\bval\s+(\w+)\s*(?::\s*[\w<>?.]+)?\s*=\s*", mask_literals(text)):
|
|
44
|
+
self.definitions[match.group(1)].append((expression_at(text, match.end()), context, match.start()))
|
|
45
|
+
for function in re.finditer(r"\bfun\s+(\w+)\s*\(\s*\)\s*:\s*Brush\s*\{", mask_literals(text)):
|
|
46
|
+
end = closing(text, text.find("{", function.start()))
|
|
47
|
+
body = text[function.end():end] if end is not None else ""
|
|
48
|
+
gradients = list(re.finditer(r"Brush\.verticalGradient\s*\(", mask_literals(body)))
|
|
49
|
+
if len(gradients) == 1:
|
|
50
|
+
start = gradients[0].start()
|
|
51
|
+
gradient_end = closing(body, body.find("(", start))
|
|
52
|
+
if gradient_end is not None:
|
|
53
|
+
from compose_instances import substitute
|
|
54
|
+
bindings = {match.group(1): expression_at(body, match.end()) for match in
|
|
55
|
+
re.finditer(r"\bval\s+(\w+)\s*=\s*", mask_literals(body[:start]))}
|
|
56
|
+
gradient = substitute(body[start:gradient_end + 1], bindings)
|
|
57
|
+
self.definitions[function.group(1) + "()"].append((gradient, context, function.start()))
|
|
58
|
+
for match in re.finditer(r"\bval\s+Typography\.(\w+)\s*:\s*TextStyle\s+get\(\)\s*=\s*(\w+)", text):
|
|
59
|
+
self.aliases[f"MaterialTheme.typography.{match.group(1)}"] = match.group(2)
|
|
60
|
+
for match in re.finditer(r"\bobject\s+(\w+)\s*\{", mask_literals(text)):
|
|
61
|
+
end = closing(text, text.find("{", match.start()))
|
|
62
|
+
body = text[match.end():end] if end is not None else ""
|
|
63
|
+
for prop in re.finditer(r"\bval\s+(\w+)\s*:\s*\w+[\s\S]*?get\(\)\s*=\s*(\w+)\.current", body):
|
|
64
|
+
self.aliases[f"{match.group(1)}.{prop.group(1)}"] = prop.group(2)
|
|
65
|
+
for match in re.finditer(r"\bMaterialTheme\s*\(", mask_literals(text)):
|
|
66
|
+
start = text.find("(", match.start())
|
|
67
|
+
end = closing(text, start)
|
|
68
|
+
if end is None:
|
|
69
|
+
continue
|
|
70
|
+
for name in ("typography", "colorScheme", "shapes"):
|
|
71
|
+
value = named_argument(text[start + 1:end], name)
|
|
72
|
+
if value:
|
|
73
|
+
self.bindings[name].add(value)
|
|
74
|
+
for name, values in self.bindings.items():
|
|
75
|
+
if len(values) == 1:
|
|
76
|
+
self.aliases[f"MaterialTheme.{name}"] = next(iter(values))
|
|
77
|
+
for name, definitions in self.definitions.items():
|
|
78
|
+
if len(definitions) != 1 or not definitions[0][0].startswith("FontFamily("):
|
|
79
|
+
continue
|
|
80
|
+
expression, context, offset = definitions[0]
|
|
81
|
+
for match in re.finditer(r"\bFont\s*\(", expression):
|
|
82
|
+
start = expression.find("(", match.start())
|
|
83
|
+
end = closing(expression, start)
|
|
84
|
+
if end is None:
|
|
85
|
+
continue
|
|
86
|
+
args = expression[start + 1:end]
|
|
87
|
+
resource = re.search(r"R\.font\.(\w+)", args)
|
|
88
|
+
if resource is None:
|
|
89
|
+
continue
|
|
90
|
+
assets = [path for path in context.root.glob(f"**/src/main/res/font/{resource.group(1)}.*")
|
|
91
|
+
if path.suffix.lower() in {".ttf", ".otf", ".woff", ".woff2"}]
|
|
92
|
+
if len(assets) != 1:
|
|
93
|
+
continue
|
|
94
|
+
weight = (named_argument(args, "weight") or "FontWeight.Normal").split(".")[-1]
|
|
95
|
+
self.fonts.append({"family": name, "asset": assets[0].relative_to(context.root).as_posix(),
|
|
96
|
+
"weight": WEIGHTS.get(weight, 400), "style": "normal"})
|
|
97
|
+
|
|
98
|
+
def resolve(self, expression, dark=False, seen=None):
|
|
99
|
+
if not isinstance(expression, str) or seen is not None:
|
|
100
|
+
return self._resolve(expression, dark, seen)
|
|
101
|
+
key = (expression.strip(), dark)
|
|
102
|
+
if key not in self._resolved:
|
|
103
|
+
self._resolved[key] = self._resolve(expression, dark, set())
|
|
104
|
+
return self._resolved[key]
|
|
105
|
+
|
|
106
|
+
def _resolve(self, expression, dark=False, seen=None):
|
|
107
|
+
if not isinstance(expression, str):
|
|
108
|
+
return expression
|
|
109
|
+
expression = expression.strip()
|
|
110
|
+
from compose_instances import static_value
|
|
111
|
+
selected = static_value(expression, {})
|
|
112
|
+
if selected != expression:
|
|
113
|
+
return self.resolve(selected, dark, seen)
|
|
114
|
+
seen = set(seen or ())
|
|
115
|
+
if expression in seen or len(seen) > 24:
|
|
116
|
+
return None
|
|
117
|
+
seen.add(expression)
|
|
118
|
+
dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)\.(?:dp|sp)", expression)
|
|
119
|
+
if dimension:
|
|
120
|
+
return f"{dimension.group(1)}px"
|
|
121
|
+
if expression.startswith("FontWeight."):
|
|
122
|
+
return WEIGHTS.get(expression.split(".")[-1])
|
|
123
|
+
color = re.fullmatch(r"Color\(0x([\da-fA-F]{8})\)", expression)
|
|
124
|
+
if color:
|
|
125
|
+
value = color.group(1).lower()
|
|
126
|
+
return "#" + value[2:] + (value[:2] if value[:2] != "ff" else "")
|
|
127
|
+
if expression in {"Color.White", "Color.Black", "Color.Transparent"}:
|
|
128
|
+
return {"Color.White": "#ffffff", "Color.Black": "#000000", "Color.Transparent": "transparent"}[expression]
|
|
129
|
+
conditional = re.fullmatch(r"if\s*\(\s*darkTheme\s*\)\s*(\w+)\s+else\s+(\w+)", expression)
|
|
130
|
+
if conditional:
|
|
131
|
+
return self.resolve(conditional.group(1 if dark else 2), dark, seen)
|
|
132
|
+
for alias in sorted(self.aliases, key=len, reverse=True):
|
|
133
|
+
if expression == alias or expression.startswith(alias + "."):
|
|
134
|
+
value = self.resolve(self.aliases[alias], dark, seen)
|
|
135
|
+
for part in expression[len(alias):].strip(".").split(".") if expression != alias else []:
|
|
136
|
+
value = value.get(part) if isinstance(value, dict) else None
|
|
137
|
+
return value
|
|
138
|
+
if expression in self.definitions:
|
|
139
|
+
definitions = self.definitions[expression]
|
|
140
|
+
if len(definitions) != 1:
|
|
141
|
+
return None
|
|
142
|
+
value = definitions[0][0]
|
|
143
|
+
if value.startswith("FontFamily("):
|
|
144
|
+
return expression if any(font["family"] == expression for font in self.fonts) else None
|
|
145
|
+
local = re.fullmatch(r"staticCompositionLocalOf\s*\{\s*(\w+)\s*}", value, re.S)
|
|
146
|
+
return self.resolve(local.group(1) if local else value, dark, seen)
|
|
147
|
+
if re.fullmatch(r"\w+(?:\.\w+)+", expression):
|
|
148
|
+
parts = expression.split(".")
|
|
149
|
+
value = self.resolve(parts[0], dark, seen)
|
|
150
|
+
for part in parts[1:]:
|
|
151
|
+
value = value.get(part) if isinstance(value, dict) else None
|
|
152
|
+
return value
|
|
153
|
+
call = re.match(r"\w+\s*\(", expression)
|
|
154
|
+
if expression.startswith("RoundedCornerShape("):
|
|
155
|
+
values = arguments(expression[len("RoundedCornerShape("):-1])
|
|
156
|
+
if len(values) == 1 and "=" not in values[0]:
|
|
157
|
+
return self.resolve(values[0], dark, seen)
|
|
158
|
+
if expression.startswith("Brush.verticalGradient("):
|
|
159
|
+
args = expression[len("Brush.verticalGradient("):-1]
|
|
160
|
+
colors = named_argument(args, "colors")
|
|
161
|
+
if colors and colors.startswith("listOf("):
|
|
162
|
+
values = [self.resolve(value, dark, seen) for value in arguments(colors[7:-1])]
|
|
163
|
+
if len(values) >= 2 and all(isinstance(value, str) and value.startswith("#") for value in values):
|
|
164
|
+
return "linear-gradient(to bottom, " + ", ".join(values) + ")"
|
|
165
|
+
if call and closing(expression, expression.find("(")) == len(expression) - 1:
|
|
166
|
+
values = {}
|
|
167
|
+
for argument in arguments(expression[expression.find("(") + 1:-1]):
|
|
168
|
+
field = re.match(r"(\w+)\s*=\s*([\s\S]+)", argument)
|
|
169
|
+
if field:
|
|
170
|
+
value = self.resolve(field.group(2), dark, seen)
|
|
171
|
+
if value is not None:
|
|
172
|
+
values[field.group(1)] = value
|
|
173
|
+
return values or None
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def resolve_local(self, expression, context, dark=False):
|
|
177
|
+
value = self.resolve(expression, dark)
|
|
178
|
+
if value is not None or not re.fullmatch(r"\w+", expression.strip()):
|
|
179
|
+
return value
|
|
180
|
+
definitions = list(re.finditer(rf"\bval\s+{re.escape(expression.strip())}\s*=\s*", mask_literals(context.text)))
|
|
181
|
+
if len(definitions) == 1:
|
|
182
|
+
return self.resolve(expression_at(context.text, definitions[0].end()), dark)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def apply(self, result, context):
|
|
186
|
+
from fidelity_adapters import _compose_named_value
|
|
187
|
+
for node in result.nodes.values():
|
|
188
|
+
for group in ("layout", "style"):
|
|
189
|
+
for key, expression in list(node.get(group, {}).items()):
|
|
190
|
+
resolved = self.resolve(expression)
|
|
191
|
+
if resolved is not None and not isinstance(resolved, dict):
|
|
192
|
+
node[group][key] = resolved
|
|
193
|
+
# Node call arguments are kept local to this translation pass.
|
|
194
|
+
args = node.pop("_composeArguments", "")
|
|
195
|
+
style_expression = _compose_named_value(args, "style")
|
|
196
|
+
typography = self.resolve(style_expression)
|
|
197
|
+
if isinstance(typography, dict):
|
|
198
|
+
for key in ("fontSize", "fontFamily", "fontWeight", "lineHeight", "letterSpacing"):
|
|
199
|
+
if key in typography and key not in node["style"]:
|
|
200
|
+
node["style"][key] = typography[key]
|
|
201
|
+
node["provenance"][f"style.{key}"] = property_evidence(context.source, node["source"]["line"], style_expression, "compose", "high")
|
|
202
|
+
color_expression = _compose_named_value(args, "color")
|
|
203
|
+
color = self.resolve(color_expression)
|
|
204
|
+
if isinstance(color, str) and color.startswith("#"):
|
|
205
|
+
key = "backgroundColor" if node.get("component") == "Surface" else "color"
|
|
206
|
+
node["style"][key] = color
|
|
207
|
+
node["provenance"][f"style.{key}"] = property_evidence(context.source, node["source"]["line"], color_expression, "compose", "high")
|
|
208
|
+
dark_color = self.resolve(color_expression, dark=True)
|
|
209
|
+
if isinstance(dark_color, str) and dark_color != color:
|
|
210
|
+
node.setdefault("_composeDarkStyle", {})[key] = dark_color
|
|
211
|
+
def assign(group, key, value, expression):
|
|
212
|
+
if value is not None:
|
|
213
|
+
node[group][key] = value
|
|
214
|
+
node["provenance"][f"{group}.{key}"] = property_evidence(context.source, node["source"]["line"], expression, "compose", "high")
|
|
215
|
+
dark_value = self.resolve(expression, dark=True)
|
|
216
|
+
if isinstance(dark_value, dict):
|
|
217
|
+
dark_value = dark_value.get({"borderColor": "color", "borderWidth": "width"}.get(key, key))
|
|
218
|
+
if group == "style" and isinstance(dark_value, (str, int, float)) and dark_value != value:
|
|
219
|
+
node.setdefault("_composeDarkStyle", {})[key] = dark_value
|
|
220
|
+
shape = _compose_named_value(args, "shape")
|
|
221
|
+
radius = self.resolve(shape)
|
|
222
|
+
if isinstance(radius, str):
|
|
223
|
+
assign("style", "radius", radius, shape)
|
|
224
|
+
border_expression = _compose_named_value(args, "border")
|
|
225
|
+
border = self.resolve(border_expression)
|
|
226
|
+
if isinstance(border, dict):
|
|
227
|
+
assign("style", "borderWidth", border.get("width"), border_expression)
|
|
228
|
+
assign("style", "borderColor", border.get("color"), border_expression)
|
|
229
|
+
assign("style", "borderStyle", "solid", border_expression)
|
|
230
|
+
padding_expression = _compose_named_value(args, "contentPadding")
|
|
231
|
+
padding = self.resolve(padding_expression)
|
|
232
|
+
if isinstance(padding, dict):
|
|
233
|
+
for key in ("horizontal", "vertical", "top", "bottom", "start", "end"):
|
|
234
|
+
target = {"start": "Left", "end": "Right"}.get(key, key.title())
|
|
235
|
+
assign("layout", "padding" + target, padding.get(key), padding_expression)
|
|
236
|
+
if node.get("component") == "Button":
|
|
237
|
+
for key, role in (("backgroundColor", "primary"), ("color", "onPrimary")):
|
|
238
|
+
if key not in node["style"]:
|
|
239
|
+
assign("style", key, self.resolve("MaterialTheme.colorScheme." + role), "MaterialTheme.colorScheme." + role)
|
|
240
|
+
if node.get("component") == "RadioButton":
|
|
241
|
+
role = "primary" if node.get("checked") else "onSurfaceVariant"
|
|
242
|
+
assign("style", "color", self.resolve("MaterialTheme.colorScheme." + role), "MaterialTheme.colorScheme." + role)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Bounded Kotlin syntax helpers; offsets always refer to the original source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from source_syntax import arguments, closing, mask_literals, named_argument
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def composable_functions(text: str) -> Iterator[dict]:
|
|
13
|
+
masked = mask_literals(text)
|
|
14
|
+
# A declaration may have visibility modifiers, annotations and function-typed parameters.
|
|
15
|
+
pattern = re.compile(r"@Composable\b(?:(?!\bfun\b)[\s\S])*?\bfun\s+([A-Z]\w*)\s*\(")
|
|
16
|
+
cursor = 0
|
|
17
|
+
while match := pattern.search(masked, cursor):
|
|
18
|
+
open_paren = masked.find("(", match.start(1))
|
|
19
|
+
end_args = closing(text, open_paren)
|
|
20
|
+
if end_args is None:
|
|
21
|
+
break
|
|
22
|
+
body_start = end_args + 1
|
|
23
|
+
while body_start < len(text) and masked[body_start].isspace():
|
|
24
|
+
body_start += 1
|
|
25
|
+
if masked[body_start:body_start + 1] == ":":
|
|
26
|
+
return_type = re.match(r":\s*Unit\s*", masked[body_start:])
|
|
27
|
+
body_start += len(return_type.group()) if return_type else 0
|
|
28
|
+
if masked[body_start:body_start + 1] != "{":
|
|
29
|
+
cursor = end_args + 1
|
|
30
|
+
continue
|
|
31
|
+
body_end = closing(text, body_start)
|
|
32
|
+
if body_end is None:
|
|
33
|
+
break
|
|
34
|
+
yield {"name": match.group(1), "start": match.start(), "bodyStart": body_start + 1,
|
|
35
|
+
"bodyEnd": body_end, "parameters": text[open_paren + 1:end_args]}
|
|
36
|
+
cursor = body_end + 1
|
{ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_adapter_api.py
RENAMED
|
@@ -27,6 +27,7 @@ class AdapterResult:
|
|
|
27
27
|
themes: list[dict[str, Any]] = field(default_factory=list)
|
|
28
28
|
components: list[dict[str, Any]] = field(default_factory=list)
|
|
29
29
|
unsupported: list[dict[str, Any]] = field(default_factory=list)
|
|
30
|
+
fonts: list[dict[str, Any]] = field(default_factory=list)
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class UiSourceAdapter(Protocol):
|
|
@@ -83,7 +84,16 @@ def translate_sources(contexts: list[SourceContext]) -> AdapterResult:
|
|
|
83
84
|
combined.nodes.update(result.nodes)
|
|
84
85
|
for group, values in result.tokens.items():
|
|
85
86
|
combined.tokens.setdefault(group, {}).update(values if isinstance(values, dict) else {})
|
|
86
|
-
|
|
87
|
+
for theme in result.themes:
|
|
88
|
+
existing = next((item for item in combined.themes if item.get("id") == theme.get("id")), None)
|
|
89
|
+
if existing is None:
|
|
90
|
+
combined.themes.append(theme)
|
|
91
|
+
else:
|
|
92
|
+
for field in ("nodeOverrides", "tokenOverrides"):
|
|
93
|
+
existing.setdefault(field, {}).update(theme.get(field, {}))
|
|
87
94
|
combined.components.extend(result.components)
|
|
88
95
|
combined.unsupported.extend(result.unsupported)
|
|
96
|
+
for font in result.fonts:
|
|
97
|
+
if font not in combined.fonts:
|
|
98
|
+
combined.fonts.append(font)
|
|
89
99
|
return combined
|