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/catalogue.py ADDED
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env python3
2
+ """Build a browsable directory of the UI parts this theme can style.
3
+
4
+ For each landmark it resolves the real element in a live Firefox, crops its
5
+ region out of a screenshot, records the styles actually in effect, and greps
6
+ the theme for the rules that target it. The result is an HTML page answering
7
+ "what is this bit called, what does it look like, and which file do I edit?".
8
+
9
+ Everything here is measured from a running browser rather than assumed, so it
10
+ stays honest as Firefox changes: an element that no longer exists is reported
11
+ as missing instead of silently documented.
12
+ """
13
+
14
+ import html
15
+ import json
16
+ import re
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from PIL import Image, ImageDraw, ImageFont
21
+
22
+ # Curated because an exhaustive dump of every node in browser.xhtml would be
23
+ # noise. These are the parts a theme actually targets.
24
+ LANDMARKS = [
25
+ # area, slug, selector, name, description
26
+ ("Toolbars", "nav-bar", "#nav-bar", "Navigation toolbar",
27
+ "The main toolbar holding back/forward, the address bar and the right-hand buttons."),
28
+ ("Toolbars", "tabs-toolbar", "#TabsToolbar", "Tab strip toolbar",
29
+ "Container for the row of tabs. Themes often move or restyle this whole strip."),
30
+ ("Toolbars", "personal-toolbar", "#PersonalToolbar", "Bookmarks toolbar",
31
+ "The bookmarks bar, shown here with three sample bookmarks."),
32
+ ("Toolbars", "toolbar-menubar", "#toolbar-menubar", "Menu bar",
33
+ "Hidden by default on macOS; on Windows this is the File/Edit/View strip."),
34
+
35
+ ("Address bar", "urlbar", "#urlbar", "Address bar container",
36
+ "The whole address bar, including its background, border and rounding."),
37
+ ("Address bar", "urlbar-input", "#urlbar-input", "Address bar text field",
38
+ "The editable text itself. Colour problems with typed URLs live here."),
39
+ ("Address bar", "identity-box", "#identity-box", "Site identity block",
40
+ "Padlock / permissions area at the left of the address bar."),
41
+ ("Address bar", "star-button", "#star-button-box", "Bookmark star",
42
+ "The bookmark-this-page control at the right of the address bar."),
43
+ ("Address bar", "urlbar-results", "#urlbar-results", "Address bar dropdown",
44
+ "Suggestion list shown while typing. Rendered inside the window, so it is themeable."),
45
+
46
+ ("Tabs", "tabbrowser-tabs", "#tabbrowser-tabs", "Tab container",
47
+ "Wraps every tab plus the new-tab button."),
48
+ ("Tabs", "selected-tab", ".tabbrowser-tab[selected]", "Selected tab",
49
+ "The active tab. Most tab colour work targets this."),
50
+ ("Tabs", "pinned-container", "#pinned-tabs-container", "Pinned tabs container",
51
+ "Holds pinned tabs; spacing here affects the join with normal tabs."),
52
+ ("Tabs", "tab-close", ".tab-close-button", "Tab close button",
53
+ "Per-tab close control. The install script can move it to the left."),
54
+ ("Tabs", "newtab-button", "#tabs-newtab-button", "New tab button",
55
+ "The + at the end of the tab strip."),
56
+
57
+ ("Toolbar buttons", "back-button", "#back-button", "Back button", "Navigation back."),
58
+ ("Toolbar buttons", "reload-button", "#reload-button", "Reload button", "Reload / stop."),
59
+ ("Toolbar buttons", "firefox-view", "#firefox-view-button", "Firefox View button",
60
+ "The leftmost control on the tab strip."),
61
+ ("Toolbar buttons", "appmenu-button", "#PanelUI-menu-button", "App menu button",
62
+ "The hamburger. Its panel is a native OS window and cannot be screenshotted."),
63
+ ("Toolbar buttons", "extensions-button", "#unified-extensions-button", "Extensions button",
64
+ "Unified extensions control."),
65
+ ("Toolbar buttons", "account-button", "#fxa-toolbar-menu-button", "Account button",
66
+ "Firefox Account menu."),
67
+
68
+ ("Find bar", "findbar", "findbar", "Find bar",
69
+ "Docked at the bottom of the content area when you press Ctrl/Cmd+F."),
70
+
71
+ ("Content", "browser-stack", "#browser", "Content area",
72
+ "Where the page renders. Styled by userContent.css rather than userChrome.css."),
73
+ ]
74
+
75
+ COMPUTED_PROPS = [
76
+ "background-color", "color", "border-radius", "border-top-width",
77
+ "border-top-color", "box-shadow", "font-size", "padding-top", "margin-inline-end",
78
+ "height", "opacity",
79
+ ]
80
+
81
+ RESOLVE = """
82
+ const [landmarks, props] = arguments;
83
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
84
+ const doc = win.document;
85
+ const out = [];
86
+ for (const [slug, selector] of landmarks) {
87
+ let el = null;
88
+ try { el = doc.querySelector(selector); } catch (e) {}
89
+ if (!el) { out.push({slug, found: false}); continue; }
90
+ const r = el.getBoundingClientRect();
91
+ const cs = win.getComputedStyle(el);
92
+ const styles = {};
93
+ for (const p of props) { styles[p] = cs.getPropertyValue(p); }
94
+ out.push({
95
+ slug, found: true,
96
+ tag: el.localName,
97
+ id: el.id || null,
98
+ classes: (typeof el.className === "string" ? el.className : "") || null,
99
+ rect: {x: r.x, y: r.y, w: r.width, h: r.height},
100
+ visible: r.width > 0 && r.height > 0,
101
+ styles,
102
+ });
103
+ }
104
+ return out;
105
+ """
106
+
107
+
108
+ def css_references(repo: Path, selector: str):
109
+ """Find where the theme styles this selector.
110
+
111
+ Matches on the id or class token rather than the literal selector string,
112
+ since a rule is far more likely to read `#urlbar[focused]` or
113
+ `#nav-bar > .foo` than to repeat the selector verbatim.
114
+ """
115
+ token = selector.lstrip(".#[").split("[")[0].split(">")[0].strip()
116
+ if not token:
117
+ return []
118
+ pattern = re.compile(r"(?<![\w-])[.#]?" + re.escape(token) + r"(?![\w-])")
119
+ hits = []
120
+ for path in sorted((repo / "chrome").rglob("*.css")):
121
+ try:
122
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
123
+ except OSError:
124
+ continue
125
+ for n, line in enumerate(lines, 1):
126
+ stripped = line.strip()
127
+ if not stripped or stripped.startswith("/*"):
128
+ continue
129
+ if pattern.search(line):
130
+ hits.append({
131
+ "file": str(path.relative_to(repo)),
132
+ "line": n,
133
+ "text": stripped[:160],
134
+ })
135
+ return hits
136
+
137
+
138
+ def annotate(overview: Image.Image, items, dpr):
139
+ """Draw numbered boxes over the overview screenshot."""
140
+ img = overview.convert("RGB")
141
+ draw = ImageDraw.Draw(img, "RGBA")
142
+ try:
143
+ font = ImageFont.load_default(size=int(15 * dpr))
144
+ except TypeError:
145
+ font = ImageFont.load_default()
146
+
147
+ for idx, item in enumerate(items, 1):
148
+ if not item.get("visible"):
149
+ continue
150
+ r = item["rect"]
151
+ box = [r["x"] * dpr, r["y"] * dpr, (r["x"] + r["w"]) * dpr, (r["y"] + r["h"]) * dpr]
152
+ if box[2] - box[0] < 2 or box[3] - box[1] < 2:
153
+ continue
154
+ draw.rectangle(box, outline=(255, 0, 128, 255), width=max(2, int(dpr)))
155
+ draw.rectangle([box[0], box[1], box[0] + 26 * dpr, box[1] + 18 * dpr],
156
+ fill=(255, 0, 128, 235))
157
+ draw.text((box[0] + 5 * dpr, box[1] + 2 * dpr), str(idx), fill=(255, 255, 255), font=font)
158
+ return img
159
+
160
+
161
+ def crop(overview: Image.Image, rect, dpr, pad=8):
162
+ x0 = max(0, int((rect["x"] - pad) * dpr))
163
+ y0 = max(0, int((rect["y"] - pad) * dpr))
164
+ x1 = min(overview.width, int((rect["x"] + rect["w"] + pad) * dpr))
165
+ y1 = min(overview.height, int((rect["y"] + rect["h"] + pad) * dpr))
166
+ if x1 - x0 < 4 or y1 - y0 < 4:
167
+ return None
168
+ piece = overview.crop((x0, y0, x1, y1))
169
+ if piece.width > 760:
170
+ piece = piece.resize((760, round(piece.height * 760 / piece.width)), Image.LANCZOS)
171
+ return piece
172
+
173
+
174
+ def build(session, repo: Path, outdir: Path, self_contained=False):
175
+ from . import core
176
+
177
+ outdir.mkdir(parents=True, exist_ok=True)
178
+ session.setup_window()
179
+ # The find bar is created lazily, so it does not exist as an element until
180
+ # opened. Open it before measuring or it would be reported as missing.
181
+ session.m.script(core.OPEN_FINDBAR)
182
+ time.sleep(1.2)
183
+ session.m.script(core.RESET_FINDBAR)
184
+ time.sleep(0.6)
185
+
186
+ info = session.info()
187
+ dpr = info["dpr"]
188
+
189
+ entries = {}
190
+ for mode in ("light", "dark"):
191
+ session.set_dark(mode == "dark")
192
+ time.sleep(2.0)
193
+ mode_dir = outdir / mode
194
+ mode_dir.mkdir(exist_ok=True)
195
+
196
+ import io
197
+ overview = Image.open(io.BytesIO(session.m.screenshot()))
198
+ resolved = session.m.script(
199
+ RESOLVE, [[[l[1], l[2]] for l in LANDMARKS], COMPUTED_PROPS])
200
+ by_slug = {r["slug"]: r for r in resolved}
201
+
202
+ ordered = [by_slug[l[1]] for l in LANDMARKS if l[1] in by_slug]
203
+ annotate(overview, ordered, dpr).save(mode_dir / "overview.png", optimize=True)
204
+
205
+ for area, slug, selector, name, desc in LANDMARKS:
206
+ item = by_slug.get(slug, {"slug": slug, "found": False})
207
+ entry = entries.setdefault(slug, {
208
+ "area": area, "slug": slug, "selector": selector,
209
+ "name": name, "description": desc,
210
+ "references": css_references(repo, selector),
211
+ "modes": {},
212
+ })
213
+ shot = None
214
+ if item.get("visible"):
215
+ piece = crop(overview, item["rect"], dpr)
216
+ if piece:
217
+ shot = f"{mode}/{slug}.png"
218
+ piece.save(mode_dir / f"{slug}.png", optimize=True)
219
+ entry["modes"][mode] = {
220
+ "found": item.get("found", False),
221
+ "visible": item.get("visible", False),
222
+ "styles": item.get("styles", {}),
223
+ "shot": shot,
224
+ "tag": item.get("tag"),
225
+ }
226
+ print(f" {mode}: {sum(1 for r in resolved if r.get('visible'))}"
227
+ f"/{len(LANDMARKS)} landmarks visible", flush=True)
228
+
229
+ catalogue = {"info": info, "entries": list(entries.values())}
230
+ (outdir / "catalogue.json").write_text(json.dumps(catalogue, indent=2), encoding="utf-8")
231
+ page = render_html(catalogue)
232
+ (outdir / "index.html").write_text(page, encoding="utf-8")
233
+ if self_contained:
234
+ (outdir / "catalogue.html").write_text(inline_images(page, outdir), encoding="utf-8")
235
+ return catalogue
236
+
237
+
238
+ def inline_images(page: str, base: Path):
239
+ """Rewrite image references as data URIs so the page is one shareable file."""
240
+ import base64
241
+
242
+ def repl(match):
243
+ rel = match.group(1)
244
+ path = base / rel
245
+ if not path.exists():
246
+ return match.group(0)
247
+ data = base64.b64encode(path.read_bytes()).decode("ascii")
248
+ return f"src='data:image/png;base64,{data}'"
249
+
250
+ return re.sub(r"src='([^']+\.png)'", repl, page)
251
+
252
+
253
+ PAGE_CSS = """
254
+ :root { color-scheme: light dark; --bg:#fff; --fg:#1c1c1e; --muted:#6b6b70;
255
+ --line:#e3e3e6; --card:#fafafa; --accent:#315bef; }
256
+ @media (prefers-color-scheme: dark) {
257
+ :root { --bg:#1c1c1e; --fg:#f2f2f7; --muted:#9a9aa0; --line:#38383c;
258
+ --card:#232326; --accent:#6f8dff; }
259
+ }
260
+ * { box-sizing: border-box; }
261
+ body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 -apple-system,
262
+ BlinkMacSystemFont,'Segoe UI',sans-serif; }
263
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 80px; }
264
+ h1 { font-size: 28px; margin: 0 0 6px; letter-spacing: -0.02em; }
265
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
266
+ color: var(--muted); margin: 40px 0 12px; }
267
+ .lede { color: var(--muted); margin: 0 0 28px; }
268
+ img { max-width: 100%; border-radius: 8px; border: 1px solid var(--line); display:block; }
269
+ .overview { margin-bottom: 8px; }
270
+ .tabs { display:flex; gap:8px; margin: 0 0 12px; }
271
+ .tabs button { font: inherit; padding: 6px 14px; border-radius: 999px; cursor: pointer;
272
+ border: 1px solid var(--line); background: var(--card); color: var(--fg); }
273
+ .tabs button[aria-selected=true] { background: var(--accent); color:#fff; border-color:transparent; }
274
+ .card { border:1px solid var(--line); border-radius:12px; padding:16px 18px;
275
+ margin-bottom:14px; background:var(--card); }
276
+ .card h3 { margin:0 0 2px; font-size:17px; }
277
+ .card .sel { font: 13px ui-monospace,SFMono-Regular,Menlo,monospace; color: var(--accent); }
278
+ .card p { margin:8px 0 12px; color: var(--muted); }
279
+ .styles { display:flex; flex-wrap:wrap; gap:6px; margin:10px 0 0; }
280
+ .chip { font: 12px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg);
281
+ border:1px solid var(--line); border-radius:6px; padding:3px 8px; }
282
+ .sw { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:5px;
283
+ border:1px solid rgba(128,128,128,.5); vertical-align:-1px; }
284
+ details { margin-top:10px; }
285
+ summary { cursor:pointer; color:var(--muted); font-size:13px; }
286
+ table { border-collapse:collapse; width:100%; margin-top:8px; font-size:13px; }
287
+ td { border-top:1px solid var(--line); padding:5px 8px; vertical-align:top; }
288
+ td.loc { white-space:nowrap; font: 12px ui-monospace,Menlo,monospace; color:var(--accent); }
289
+ td.src { font: 12px ui-monospace,Menlo,monospace; color:var(--muted); }
290
+ .missing { opacity:.55; }
291
+ .badge { font-size:11px; border:1px solid var(--line); border-radius:5px;
292
+ padding:1px 6px; color:var(--muted); margin-left:8px; }
293
+ """
294
+
295
+ PAGE_JS = """
296
+ function showMode(m) {
297
+ document.querySelectorAll('[data-mode]').forEach(function (el) {
298
+ el.hidden = el.dataset.mode !== m;
299
+ });
300
+ document.querySelectorAll('.tabs button').forEach(function (b) {
301
+ b.setAttribute('aria-selected', String(b.dataset.set === m));
302
+ });
303
+ }
304
+ document.addEventListener('DOMContentLoaded', function () { showMode('light'); });
305
+ """
306
+
307
+
308
+ def _swatch(value):
309
+ v = (value or "").strip()
310
+ if v.startswith(("rgb", "#")) and "0, 0, 0, 0" not in v:
311
+ return f'<span class="sw" style="background:{html.escape(v)}"></span>'
312
+ return ""
313
+
314
+
315
+ def render_html(catalogue):
316
+ info = catalogue["info"]
317
+ parts = [
318
+ "<!doctype html><meta charset=utf-8>",
319
+ "<meta name=viewport content='width=device-width,initial-scale=1'>",
320
+ "<title>Themeable UI directory</title>",
321
+ f"<style>{PAGE_CSS}</style><div class=wrap>",
322
+ "<h1>Themeable UI directory</h1>",
323
+ f"<p class=lede>Measured live in Firefox {html.escape(str(info.get('version')))} "
324
+ f"on {html.escape(str(info.get('os')))}. Each entry shows what the part looks "
325
+ "like, the styles in effect, and which files in this repo style it.</p>",
326
+ "<div class=tabs>"
327
+ "<button data-set=light onclick=\"showMode('light')\">Light</button>"
328
+ "<button data-set=dark onclick=\"showMode('dark')\">Dark</button></div>",
329
+ ]
330
+ for mode in ("light", "dark"):
331
+ parts.append(f"<div data-mode={mode} hidden>"
332
+ f"<img class=overview src='{mode}/overview.png' "
333
+ f"alt='Annotated {mode} browser window'></div>")
334
+
335
+ by_area = {}
336
+ for e in catalogue["entries"]:
337
+ by_area.setdefault(e["area"], []).append(e)
338
+
339
+ for area, items in by_area.items():
340
+ parts.append(f"<h2>{html.escape(area)}</h2>")
341
+ for e in items:
342
+ any_found = any(m["found"] for m in e["modes"].values())
343
+ cls = "card" if any_found else "card missing"
344
+ parts.append(f"<div class='{cls}'>")
345
+ badge = "" if any_found else "<span class=badge>not present in this Firefox</span>"
346
+ parts.append(f"<h3>{html.escape(e['name'])}{badge}</h3>")
347
+ parts.append(f"<div class=sel>{html.escape(e['selector'])}</div>")
348
+ parts.append(f"<p>{html.escape(e['description'])}</p>")
349
+
350
+ for mode in ("light", "dark"):
351
+ m = e["modes"].get(mode, {})
352
+ parts.append(f"<div data-mode={mode} hidden>")
353
+ if m.get("shot"):
354
+ parts.append(f"<img src='{m['shot']}' alt='{html.escape(e['name'])}'>")
355
+ elif any_found:
356
+ parts.append("<p class=sel>present but not visible in this layout</p>")
357
+ chips = []
358
+ for prop, val in (m.get("styles") or {}).items():
359
+ if not val or val in ("none", "0px", "auto", "normal", "rgba(0, 0, 0, 0)"):
360
+ continue
361
+ chips.append(f"<span class=chip>{_swatch(val)}"
362
+ f"{html.escape(prop)}: {html.escape(val)}</span>")
363
+ if chips:
364
+ parts.append("<div class=styles>" + "".join(chips) + "</div>")
365
+ parts.append("</div>")
366
+
367
+ refs = e["references"]
368
+ if refs:
369
+ parts.append(f"<details><summary>{len(refs)} rule"
370
+ f"{'s' if len(refs) != 1 else ''} in this repo</summary><table>")
371
+ for r in refs[:60]:
372
+ parts.append(
373
+ f"<tr><td class=loc>{html.escape(r['file'])}:{r['line']}</td>"
374
+ f"<td class=src>{html.escape(r['text'])}</td></tr>")
375
+ if len(refs) > 60:
376
+ parts.append(f"<tr><td class=loc>…</td><td class=src>"
377
+ f"and {len(refs) - 60} more</td></tr>")
378
+ parts.append("</table></details>")
379
+ else:
380
+ parts.append("<details><summary>no rules in this repo target it"
381
+ "</summary><p>Styled by Firefox defaults only.</p></details>")
382
+ parts.append("</div>")
383
+
384
+ parts.append(f"</div><script>{PAGE_JS}</script>")
385
+ return "\n".join(parts)