fxcss 0.6.1__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.
fxcss/compare.py ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """Turn two sets of theme screenshots into before/after/diff images.
3
+
4
+ Reads a base/ and a head/ directory of PNGs produced by render.py, compares
5
+ each matching view, and writes one stacked comparison image per view that
6
+ changed, plus a summary.json describing what changed and by how much.
7
+
8
+ Views that render identically are reported but not drawn -- the point is to put
9
+ the reviewer's eye straight on the difference.
10
+
11
+ Usage:
12
+ python3 compose.py --base base/ --head head/ --out out/ --platform macos
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from PIL import Image, ImageChops, ImageDraw, ImageFont
21
+
22
+ # Width each panel is normalised to. GitHub renders comment images at roughly
23
+ # 800px, so this stays sharp without making the comment enormous.
24
+ PANEL_WIDTH = 960
25
+ LABEL_HEIGHT = 34
26
+ GAP = 10
27
+ # Per-channel difference below this is treated as noise (antialiasing, subpixel
28
+ # text rendering) rather than a real change.
29
+ NOISE_THRESHOLD = 24
30
+ HIGHLIGHT = (255, 0, 128)
31
+
32
+ FRIENDLY_NAMES = {
33
+ "01-window": "Browser window",
34
+ "02-urlbar": "Address bar focused",
35
+ "03-findbar": "Find bar",
36
+ }
37
+
38
+
39
+ def load_font(size):
40
+ try:
41
+ return ImageFont.load_default(size=size)
42
+ except TypeError: # older Pillow: default font has no size argument
43
+ return ImageFont.load_default()
44
+
45
+
46
+ def normalise(im, width=PANEL_WIDTH):
47
+ im = im.convert("RGB")
48
+ if im.width == width:
49
+ return im
50
+ height = round(im.height * width / im.width)
51
+ return im.resize((width, height), Image.LANCZOS)
52
+
53
+
54
+ def diff_stats(base, head):
55
+ """Return (changed_pixel_count, total, mask) ignoring sub-threshold noise."""
56
+ delta = ImageChops.difference(base, head).convert("L")
57
+ mask = delta.point(lambda v: 255 if v >= NOISE_THRESHOLD else 0)
58
+ changed = mask.histogram()[255]
59
+ return changed, base.width * base.height, mask
60
+
61
+
62
+ def render_diff_panel(base, mask):
63
+ """Faded base image with changed regions painted in a hot highlight."""
64
+ faded = Image.blend(base, Image.new("RGB", base.size, (255, 255, 255)), 0.72)
65
+ overlay = Image.new("RGB", base.size, HIGHLIGHT)
66
+ # Grow the mask slightly so single-pixel changes stay visible once scaled.
67
+ from PIL import ImageFilter
68
+ grown = mask.filter(ImageFilter.MaxFilter(5))
69
+ return Image.composite(overlay, faded, grown)
70
+
71
+
72
+ def label_bar(width, text, bg, fg=(255, 255, 255)):
73
+ bar = Image.new("RGB", (width, LABEL_HEIGHT), bg)
74
+ draw = ImageDraw.Draw(bar)
75
+ draw.text((12, LABEL_HEIGHT // 2), text, fill=fg, font=load_font(20), anchor="lm")
76
+ return bar
77
+
78
+
79
+ def stack(panels, width):
80
+ """Vertically stack (label, image) pairs onto one canvas."""
81
+ height = sum(LABEL_HEIGHT + im.height + GAP for _, im in panels) + GAP
82
+ canvas = Image.new("RGB", (width, height), (240, 241, 243))
83
+ y = GAP
84
+ for (text, bg), im in panels:
85
+ canvas.paste(label_bar(width, text, bg), (0, y))
86
+ y += LABEL_HEIGHT
87
+ canvas.paste(im, (0, y))
88
+ y += im.height + GAP
89
+ return canvas
90
+
91
+
92
+ def compare_view(name, base_path, head_path, outdir):
93
+ base = normalise(Image.open(base_path))
94
+ head = normalise(Image.open(head_path))
95
+
96
+ if base.size != head.size:
97
+ # Different window heights between runs: pad the shorter one so the
98
+ # comparison is still meaningful rather than failing outright.
99
+ height = max(base.height, head.height)
100
+
101
+ def pad(im):
102
+ if im.height == height:
103
+ return im
104
+ padded = Image.new("RGB", (im.width, height), (255, 255, 255))
105
+ padded.paste(im, (0, 0))
106
+ return padded
107
+
108
+ base, head = pad(base), pad(head)
109
+
110
+ changed, total, mask = diff_stats(base, head)
111
+ pct = 100.0 * changed / total if total else 0.0
112
+
113
+ result = {
114
+ "view": name,
115
+ "title": FRIENDLY_NAMES.get(name.split("-", 1)[1], name),
116
+ "mode": name.split("-", 1)[0],
117
+ "changed_pixels": changed,
118
+ "total_pixels": total,
119
+ "percent": round(pct, 4),
120
+ "image": None,
121
+ }
122
+ if changed == 0:
123
+ return result
124
+
125
+ comparison = stack(
126
+ [
127
+ (("BEFORE · base branch", (90, 96, 104)), base),
128
+ (("AFTER · this PR", (32, 113, 62)), head),
129
+ ((f"CHANGED · {pct:.2f}% of pixels", (176, 21, 92)), render_diff_panel(base, mask)),
130
+ ],
131
+ base.width,
132
+ )
133
+ out_path = outdir / f"{name}.png"
134
+ comparison.save(out_path, optimize=True)
135
+ result["image"] = out_path.name
136
+ return result
137
+
138
+
139
+ def run(base_dir: Path, head_dir: Path, out: Path, platform: str):
140
+ out.mkdir(parents=True, exist_ok=True)
141
+
142
+ base_shots = {p.stem: p for p in sorted(base_dir.glob("*.png"))}
143
+ head_shots = {p.stem: p for p in sorted(head_dir.glob("*.png"))}
144
+ shared = sorted(set(base_shots) & set(head_shots))
145
+
146
+ summary = {
147
+ "platform": platform,
148
+ "views": [],
149
+ "only_in_base": sorted(set(base_shots) - set(head_shots)),
150
+ "only_in_head": sorted(set(head_shots) - set(base_shots)),
151
+ }
152
+
153
+ for name in shared:
154
+ result = compare_view(name, base_shots[name], head_shots[name], out)
155
+ summary["views"].append(result)
156
+ state = "changed" if result["changed_pixels"] else "identical"
157
+ print(f" {name}: {state} ({result['percent']:.3f}%)", flush=True)
158
+
159
+ # Always ship the head screenshots too, so a reviewer can see the whole UI
160
+ # even when a change is subtle or when nothing differs at all.
161
+ full_dir = out / "full"
162
+ full_dir.mkdir(exist_ok=True)
163
+ for name, path in head_shots.items():
164
+ normalise(Image.open(path)).save(full_dir / f"{name}.png", optimize=True)
165
+
166
+ summary["changed_views"] = [v for v in summary["views"] if v["changed_pixels"]]
167
+ summary["any_change"] = bool(summary["changed_views"])
168
+
169
+ src = head_dir / "render-info.json"
170
+ if src.exists():
171
+ summary["render_info"] = json.loads(src.read_text(encoding="utf-8"))
172
+
173
+ (out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
174
+ print(f"\n{len(summary['changed_views'])} of {len(shared)} views changed on {platform}")
175
+ return 0
176
+
177
+
178
+ def main():
179
+ ap = argparse.ArgumentParser(description=__doc__)
180
+ ap.add_argument("--base", required=True, type=Path)
181
+ ap.add_argument("--head", required=True, type=Path)
182
+ ap.add_argument("--out", required=True, type=Path)
183
+ ap.add_argument("--platform", required=True)
184
+ args = ap.parse_args()
185
+ return run(args.base, args.head, args.out, args.platform)
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())