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/__init__.py +3 -0
- fxcss/__main__.py +6 -0
- fxcss/audit.py +595 -0
- fxcss/catalogue.py +385 -0
- fxcss/cli.py +558 -0
- fxcss/compare.py +189 -0
- fxcss/core.py +999 -0
- fxcss/fetch.py +255 -0
- fxcss/probe.py +220 -0
- fxcss-0.6.1.dist-info/METADATA +572 -0
- fxcss-0.6.1.dist-info/RECORD +15 -0
- fxcss-0.6.1.dist-info/WHEEL +5 -0
- fxcss-0.6.1.dist-info/entry_points.txt +2 -0
- fxcss-0.6.1.dist-info/licenses/LICENSE +21 -0
- fxcss-0.6.1.dist-info/top_level.txt +1 -0
fxcss/fetch.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch a theme from GitHub so it can be test-driven.
|
|
3
|
+
|
|
4
|
+
`fxcss try owner/repo` downloads a theme, installs it into a throwaway profile
|
|
5
|
+
and opens it, so someone can see what a theme actually looks like before putting
|
|
6
|
+
it anywhere near their real Firefox profile.
|
|
7
|
+
|
|
8
|
+
**Nothing from the downloaded repository is ever executed.** A theme's install
|
|
9
|
+
script is, in practice, `cp -r chrome/ <profile>/` plus flipping a pref -- which
|
|
10
|
+
is precisely what fxcss already does itself. So this locates and *reports* the
|
|
11
|
+
install script and any options it documents, and then does the installation by
|
|
12
|
+
copying files. Running a shell script fetched from a URL to preview a stylesheet
|
|
13
|
+
would be a poor trade.
|
|
14
|
+
|
|
15
|
+
That leaves the theme's own contents, which are CSS, SVG and occasionally a .js
|
|
16
|
+
file. Firefox does not execute a .js file sitting in a profile's chrome folder --
|
|
17
|
+
that needs an autoconfig hook in the *application* directory, which fxcss does
|
|
18
|
+
not create.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import tarfile
|
|
27
|
+
import tempfile
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
API = "https://api.github.com"
|
|
33
|
+
USER_AGENT = "fxcss (+https://github.com/AdamXweb/fxcss)"
|
|
34
|
+
|
|
35
|
+
# A theme is small. Anything wildly bigger than this is not one, and unpacking it
|
|
36
|
+
# blindly would be careless.
|
|
37
|
+
MAX_ARCHIVE_BYTES = 80 * 1024 * 1024
|
|
38
|
+
MAX_UNPACKED_BYTES = 200 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
REPO_SPEC = re.compile(
|
|
41
|
+
r"^(?:https?://)?(?:www\.)?(?:github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?/?$")
|
|
42
|
+
|
|
43
|
+
INSTALL_SCRIPTS = [
|
|
44
|
+
"install.sh", "install.bat", "install.ps1", "install.py",
|
|
45
|
+
"setup.sh", "Makefile", "makefile", "justfile",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Themes commonly keep optional tweaks in one of these.
|
|
49
|
+
VARIANT_DIRS = ["custom", "optional", "options", "extras", "variants"]
|
|
50
|
+
|
|
51
|
+
# "- `-c` Left hand side tab close button" and similar list items.
|
|
52
|
+
FLAG_LINE = re.compile(r"^\s*[-*+]\s*`?(-{1,2}[\w-]+)`?\s*[—:-]?\s+(.{4,120}?)\s*$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_repo(spec: str):
|
|
56
|
+
match = REPO_SPEC.match(spec.strip())
|
|
57
|
+
if not match:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"could not read {spec!r} as a GitHub repo. "
|
|
60
|
+
"Use owner/name or a github.com URL.")
|
|
61
|
+
return match.group(1), match.group(2)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _request(url, accept="application/vnd.github+json"):
|
|
65
|
+
headers = {"User-Agent": USER_AGENT, "Accept": accept}
|
|
66
|
+
# Only raises the anonymous rate limit; never required.
|
|
67
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
68
|
+
if token:
|
|
69
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
70
|
+
return urllib.request.Request(url, headers=headers)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _api(path):
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(_request(API + path), timeout=45) as response:
|
|
76
|
+
return json.loads(response.read().decode("utf-8"))
|
|
77
|
+
except urllib.error.HTTPError as exc:
|
|
78
|
+
if exc.code == 404:
|
|
79
|
+
return None
|
|
80
|
+
if exc.code == 403:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"GitHub refused the request (rate limit). Set GITHUB_TOKEN to "
|
|
83
|
+
"raise it, or pass --ref to skip the lookups.") from exc
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve(owner, name):
|
|
88
|
+
"""Report what is available: the default branch tip and the latest release."""
|
|
89
|
+
repo = _api(f"/repos/{owner}/{name}")
|
|
90
|
+
if repo is None:
|
|
91
|
+
raise RuntimeError(f"no such repository: {owner}/{name}")
|
|
92
|
+
|
|
93
|
+
branch = repo.get("default_branch", "main")
|
|
94
|
+
commit = _api(f"/repos/{owner}/{name}/commits/{branch}") or {}
|
|
95
|
+
release = _api(f"/repos/{owner}/{name}/releases/latest")
|
|
96
|
+
|
|
97
|
+
info = {
|
|
98
|
+
"owner": owner, "name": name,
|
|
99
|
+
"description": repo.get("description") or "",
|
|
100
|
+
"stars": repo.get("stargazers_count", 0),
|
|
101
|
+
"licence": (repo.get("license") or {}).get("spdx_id"),
|
|
102
|
+
"default_branch": branch,
|
|
103
|
+
"commit": {
|
|
104
|
+
"sha": (commit.get("sha") or "")[:7],
|
|
105
|
+
"date": (commit.get("commit", {}).get("author", {}) or {}).get("date", ""),
|
|
106
|
+
"message": (commit.get("commit", {}).get("message") or "").splitlines()[0][:72],
|
|
107
|
+
} if commit else None,
|
|
108
|
+
"release": {
|
|
109
|
+
"tag": release.get("tag_name"),
|
|
110
|
+
"name": release.get("name") or release.get("tag_name"),
|
|
111
|
+
"date": release.get("published_at", ""),
|
|
112
|
+
} if release else None,
|
|
113
|
+
}
|
|
114
|
+
return info
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def choose_ref(info, prefer):
|
|
118
|
+
"""Pick what to download. Releases are what a theme's author blessed."""
|
|
119
|
+
if prefer == "commit":
|
|
120
|
+
return info["default_branch"], "latest commit"
|
|
121
|
+
if info["release"]:
|
|
122
|
+
return info["release"]["tag"], f"release {info['release']['tag']}"
|
|
123
|
+
return info["default_branch"], f"latest commit on {info['default_branch']}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _safe_members(archive, destination: Path):
|
|
127
|
+
"""Yield members that stay inside the destination, refusing anything else."""
|
|
128
|
+
total = 0
|
|
129
|
+
root = destination.resolve()
|
|
130
|
+
for member in archive:
|
|
131
|
+
if member.issym() or member.islnk():
|
|
132
|
+
continue # a link could point anywhere; a theme does not need them
|
|
133
|
+
if not (member.isfile() or member.isdir()):
|
|
134
|
+
continue
|
|
135
|
+
target = (root / member.name).resolve()
|
|
136
|
+
if not str(target).startswith(str(root) + os.sep):
|
|
137
|
+
raise RuntimeError(f"archive tried to write outside the target: {member.name}")
|
|
138
|
+
total += max(member.size, 0)
|
|
139
|
+
if total > MAX_UNPACKED_BYTES:
|
|
140
|
+
raise RuntimeError("archive unpacks to more than 200 MB; refusing")
|
|
141
|
+
yield member
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def download(owner, name, ref, into: Path):
|
|
145
|
+
"""Download and unpack a repository at a ref. Returns the unpacked root."""
|
|
146
|
+
url = f"https://codeload.github.com/{owner}/{name}/tar.gz/{ref}"
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(_request(url, accept="*/*"), timeout=180) as response:
|
|
149
|
+
payload = response.read(MAX_ARCHIVE_BYTES + 1)
|
|
150
|
+
except urllib.error.HTTPError as exc:
|
|
151
|
+
raise RuntimeError(f"could not download {owner}/{name}@{ref}: {exc}") from exc
|
|
152
|
+
if len(payload) > MAX_ARCHIVE_BYTES:
|
|
153
|
+
raise RuntimeError("archive is larger than 80 MB; refusing")
|
|
154
|
+
|
|
155
|
+
into.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
|
157
|
+
archive.extractall(into, members=_safe_members(archive, into))
|
|
158
|
+
|
|
159
|
+
entries = [p for p in into.iterdir() if p.is_dir()]
|
|
160
|
+
if len(entries) == 1:
|
|
161
|
+
return entries[0] # GitHub wraps everything in one directory
|
|
162
|
+
return into
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def find_theme_root(unpacked: Path):
|
|
166
|
+
"""Locate the folder that holds chrome/userChrome.css.
|
|
167
|
+
|
|
168
|
+
Not always the repository root: plenty of themes keep sources under src/ or
|
|
169
|
+
ship several variants side by side.
|
|
170
|
+
"""
|
|
171
|
+
candidates = sorted(unpacked.rglob("userChrome.css"),
|
|
172
|
+
key=lambda p: len(p.relative_to(unpacked).parts))
|
|
173
|
+
for sheet in candidates:
|
|
174
|
+
if sheet.parent.name == "chrome":
|
|
175
|
+
return sheet.parent.parent
|
|
176
|
+
if candidates:
|
|
177
|
+
# userChrome.css not inside a folder called chrome; treat its parent as
|
|
178
|
+
# the chrome folder anyway.
|
|
179
|
+
return candidates[0].parent.parent
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def describe(repo_root: Path, theme_root: Path):
|
|
184
|
+
"""What this theme offers, and how its author says to install it."""
|
|
185
|
+
scripts = [name for name in INSTALL_SCRIPTS if (repo_root / name).is_file()]
|
|
186
|
+
|
|
187
|
+
variants = []
|
|
188
|
+
for folder in VARIANT_DIRS:
|
|
189
|
+
directory = repo_root / folder
|
|
190
|
+
if directory.is_dir():
|
|
191
|
+
for sheet in sorted(directory.glob("*.css")):
|
|
192
|
+
variants.append(sheet)
|
|
193
|
+
|
|
194
|
+
flags = []
|
|
195
|
+
for readme in ("README.md", "readme.md", "README", "README.rst"):
|
|
196
|
+
path = repo_root / readme
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
200
|
+
match = FLAG_LINE.match(line)
|
|
201
|
+
if match:
|
|
202
|
+
flags.append({"flag": match.group(1), "text": match.group(2)})
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
sheets = list(theme_root.rglob("*.css")) if theme_root else []
|
|
206
|
+
return {
|
|
207
|
+
"scripts": scripts,
|
|
208
|
+
"variants": variants,
|
|
209
|
+
"flags": flags[:14],
|
|
210
|
+
"stylesheets": len(sheets),
|
|
211
|
+
"bytes": sum(p.stat().st_size for p in sheets),
|
|
212
|
+
"has_configuration": (theme_root / "configuration").is_dir() if theme_root else False,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def apply_variants(profile: Path, chosen):
|
|
217
|
+
"""Layer optional stylesheets on top, by import rather than by copying.
|
|
218
|
+
|
|
219
|
+
Themes disagree about where an optional sheet belongs -- some expect it
|
|
220
|
+
inside their own folder, some rely on their installer editing an import
|
|
221
|
+
list. Appending an absolute @import to the profile's userChrome.css works
|
|
222
|
+
for all of them, and relative url()s inside the sheet still resolve because
|
|
223
|
+
the file stays where it was unpacked.
|
|
224
|
+
"""
|
|
225
|
+
user_chrome = profile / "chrome" / "userChrome.css"
|
|
226
|
+
if not user_chrome.exists() or not chosen:
|
|
227
|
+
return []
|
|
228
|
+
lines = ["", "/* optional sheets layered on by fxcss try --with */"]
|
|
229
|
+
for sheet in chosen:
|
|
230
|
+
lines.append(f'@import url("{sheet.resolve().as_uri()}");')
|
|
231
|
+
with user_chrome.open("a", encoding="utf-8") as handle:
|
|
232
|
+
handle.write("\n".join(lines) + "\n")
|
|
233
|
+
return chosen
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def humanise(info):
|
|
237
|
+
"""Lines describing what was found upstream."""
|
|
238
|
+
out = []
|
|
239
|
+
header = f"{info['owner']}/{info['name']}"
|
|
240
|
+
if info["stars"]:
|
|
241
|
+
header += f" ★{info['stars']}"
|
|
242
|
+
if info["licence"] and info["licence"] != "NOASSERTION":
|
|
243
|
+
header += f" {info['licence']}"
|
|
244
|
+
out.append(header)
|
|
245
|
+
if info["description"]:
|
|
246
|
+
out.append(f" {info['description'][:96]}")
|
|
247
|
+
if info["release"]:
|
|
248
|
+
out.append(f" latest release {info['release']['tag']}"
|
|
249
|
+
f" ({info['release']['date'][:10]})")
|
|
250
|
+
else:
|
|
251
|
+
out.append(" latest release none published")
|
|
252
|
+
if info["commit"]:
|
|
253
|
+
out.append(f" latest commit {info['commit']['sha']}"
|
|
254
|
+
f" ({info['commit']['date'][:10]}) {info['commit']['message']}")
|
|
255
|
+
return out
|
fxcss/probe.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Identify browser UI elements and the rules that style them.
|
|
3
|
+
|
|
4
|
+
Two ways in:
|
|
5
|
+
|
|
6
|
+
* `pick` overlays a highlighter on the running browser -- hover any part of the
|
|
7
|
+
UI and click to get a selector you can paste into your theme.
|
|
8
|
+
* `inspect` takes a selector you already have and reports what it matches.
|
|
9
|
+
|
|
10
|
+
Both finish by grepping the theme for rules that target the element, so the
|
|
11
|
+
answer is not just "what is this" but "where do I edit it".
|
|
12
|
+
|
|
13
|
+
Named probe.py rather than inspect.py so it cannot shadow the standard
|
|
14
|
+
library's inspect module for the sibling imports.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
# Builds a selector from an element. Prefers an id, because userChrome rules
|
|
20
|
+
# overwhelmingly key off ids, then falls back to tag + classes, then to a short
|
|
21
|
+
# ancestor path so a nameless element is still addressable.
|
|
22
|
+
SELECTOR_HELPERS = """
|
|
23
|
+
const esc = (s) => (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(s) : s;
|
|
24
|
+
function classListOf(el) {
|
|
25
|
+
const c = el.getAttribute && el.getAttribute("class");
|
|
26
|
+
return c ? c.trim().split(/\\s+/).filter(Boolean) : [];
|
|
27
|
+
}
|
|
28
|
+
function selOf(el) {
|
|
29
|
+
if (!el || el.nodeType !== 1) return "";
|
|
30
|
+
if (el.id) return "#" + esc(el.id);
|
|
31
|
+
let s = el.localName;
|
|
32
|
+
const cls = classListOf(el);
|
|
33
|
+
if (cls.length) s += "." + cls.map(esc).join(".");
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
function pathOf(el) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
let e = el;
|
|
39
|
+
while (e && e.nodeType === 1 && parts.length < 6) {
|
|
40
|
+
parts.unshift(selOf(e));
|
|
41
|
+
if (e.id) break;
|
|
42
|
+
e = e.parentElement;
|
|
43
|
+
}
|
|
44
|
+
return parts.join(" ");
|
|
45
|
+
}
|
|
46
|
+
function describe(el) {
|
|
47
|
+
const r = el.getBoundingClientRect();
|
|
48
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
49
|
+
const cs = win.getComputedStyle(el);
|
|
50
|
+
const styles = {};
|
|
51
|
+
for (const p of ["background-color","color","border-radius","box-shadow",
|
|
52
|
+
"list-style-image","font-size","padding","margin","opacity",
|
|
53
|
+
"height","width","display"]) {
|
|
54
|
+
const v = cs.getPropertyValue(p);
|
|
55
|
+
if (v && !["none","normal","auto","0px","rgba(0, 0, 0, 0)","1"].includes(v)) {
|
|
56
|
+
styles[p] = v;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
selector: selOf(el), path: pathOf(el),
|
|
61
|
+
tag: el.localName, id: el.id || null,
|
|
62
|
+
classes: classListOf(el),
|
|
63
|
+
attrs: [...el.attributes].map(a => a.name).filter(n => n !== "class" && n !== "id"),
|
|
64
|
+
rect: {x: Math.round(r.x), y: Math.round(r.y),
|
|
65
|
+
w: Math.round(r.width), h: Math.round(r.height)},
|
|
66
|
+
styles,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
START_PICKER = SELECTOR_HELPERS + """
|
|
72
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
73
|
+
const doc = win.document;
|
|
74
|
+
if (win._fxcssStopPicker) { win._fxcssStopPicker(); }
|
|
75
|
+
win._fxcssPicked = null;
|
|
76
|
+
|
|
77
|
+
const HTML = "http://www.w3.org/1999/xhtml";
|
|
78
|
+
const box = doc.createElementNS(HTML, "div");
|
|
79
|
+
box.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;" +
|
|
80
|
+
"border:2px solid #ff0080;background:rgba(255,0,128,.14);box-sizing:border-box;" +
|
|
81
|
+
"border-radius:3px;display:none;";
|
|
82
|
+
const tag = doc.createElementNS(HTML, "div");
|
|
83
|
+
tag.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;" +
|
|
84
|
+
"background:#ff0080;color:#fff;padding:2px 6px;border-radius:3px;display:none;" +
|
|
85
|
+
"font:11px ui-monospace,Menlo,monospace;white-space:nowrap;";
|
|
86
|
+
doc.documentElement.appendChild(box);
|
|
87
|
+
doc.documentElement.appendChild(tag);
|
|
88
|
+
|
|
89
|
+
let current = null;
|
|
90
|
+
function onMove(ev) {
|
|
91
|
+
const el = ev.originalTarget || ev.target;
|
|
92
|
+
if (!el || el.nodeType !== 1 || el === box || el === tag) return;
|
|
93
|
+
current = el;
|
|
94
|
+
const r = el.getBoundingClientRect();
|
|
95
|
+
box.style.display = "block";
|
|
96
|
+
box.style.left = r.left + "px"; box.style.top = r.top + "px";
|
|
97
|
+
box.style.width = r.width + "px"; box.style.height = r.height + "px";
|
|
98
|
+
tag.style.display = "block";
|
|
99
|
+
tag.textContent = selOf(el);
|
|
100
|
+
tag.style.left = Math.max(0, r.left) + "px";
|
|
101
|
+
tag.style.top = Math.max(0, r.top - 19) + "px";
|
|
102
|
+
}
|
|
103
|
+
function onPick(ev) {
|
|
104
|
+
if (!current) return;
|
|
105
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
106
|
+
win._fxcssPicked = describe(current);
|
|
107
|
+
}
|
|
108
|
+
function onKey(ev) {
|
|
109
|
+
if (ev.key === "Escape") { ev.preventDefault(); win._fxcssStopPicker(); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Capture phase so a click lands on the highlighter rather than activating the
|
|
113
|
+
// button underneath it.
|
|
114
|
+
doc.addEventListener("mousemove", onMove, true);
|
|
115
|
+
doc.addEventListener("mousedown", onPick, true);
|
|
116
|
+
doc.addEventListener("click", onPick, true);
|
|
117
|
+
doc.addEventListener("keydown", onKey, true);
|
|
118
|
+
|
|
119
|
+
win._fxcssStopPicker = function () {
|
|
120
|
+
doc.removeEventListener("mousemove", onMove, true);
|
|
121
|
+
doc.removeEventListener("mousedown", onPick, true);
|
|
122
|
+
doc.removeEventListener("click", onPick, true);
|
|
123
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
124
|
+
box.remove(); tag.remove();
|
|
125
|
+
win._fxcssStopPicker = null;
|
|
126
|
+
win._fxcssPickerStopped = true;
|
|
127
|
+
};
|
|
128
|
+
win._fxcssPickerStopped = false;
|
|
129
|
+
return true;
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
READ_PICK = """
|
|
133
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
134
|
+
const p = win._fxcssPicked;
|
|
135
|
+
win._fxcssPicked = null;
|
|
136
|
+
return {picked: p, stopped: !!win._fxcssPickerStopped};
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
STOP_PICKER = """
|
|
140
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
141
|
+
if (win._fxcssStopPicker) { win._fxcssStopPicker(); }
|
|
142
|
+
return true;
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
QUERY = SELECTOR_HELPERS + """
|
|
146
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
147
|
+
const doc = win.document;
|
|
148
|
+
let nodes = [];
|
|
149
|
+
try { nodes = [...doc.querySelectorAll(arguments[0])]; }
|
|
150
|
+
catch (e) { return {error: String(e)}; }
|
|
151
|
+
return {count: nodes.length, matches: nodes.slice(0, 8).map(describe)};
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _print_element(info, repo, references):
|
|
156
|
+
print(f"\n {info['tag']} → {info['selector']}")
|
|
157
|
+
if info["path"] and info["path"] != info["selector"]:
|
|
158
|
+
print(f" path {info['path']}")
|
|
159
|
+
if info["classes"]:
|
|
160
|
+
print(f" classes {' '.join(info['classes'])}")
|
|
161
|
+
if info["attrs"]:
|
|
162
|
+
print(f" attrs {' '.join(info['attrs'][:10])}")
|
|
163
|
+
r = info["rect"]
|
|
164
|
+
print(f" box {r['w']}×{r['h']} at ({r['x']}, {r['y']})"
|
|
165
|
+
+ (" (not visible in this layout)" if not r["w"] or not r["h"] else ""))
|
|
166
|
+
if info["styles"]:
|
|
167
|
+
print(" styles")
|
|
168
|
+
for k, v in info["styles"].items():
|
|
169
|
+
print(f" {k}: {v}")
|
|
170
|
+
|
|
171
|
+
hits = references(repo, info["selector"])
|
|
172
|
+
if hits:
|
|
173
|
+
print(f" styled by {len(hits)} rule{'s' if len(hits) != 1 else ''} in this theme")
|
|
174
|
+
for h in hits[:8]:
|
|
175
|
+
print(f" {h['file']}:{h['line']} {h['text'][:88]}")
|
|
176
|
+
if len(hits) > 8:
|
|
177
|
+
print(f" … and {len(hits) - 8} more")
|
|
178
|
+
else:
|
|
179
|
+
print(" styled by no rules in this theme (Firefox defaults only)")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def inspect_selector(session, selector, repo, references):
|
|
183
|
+
result = session.m.script(QUERY, [selector])
|
|
184
|
+
if isinstance(result, dict) and result.get("error"):
|
|
185
|
+
print(f"invalid selector: {result['error']}")
|
|
186
|
+
return 2
|
|
187
|
+
count = result["count"]
|
|
188
|
+
if not count:
|
|
189
|
+
print(f"no elements match {selector!r} in this Firefox")
|
|
190
|
+
return 1
|
|
191
|
+
print(f"{count} element{'s' if count != 1 else ''} match {selector!r}"
|
|
192
|
+
+ (" (showing first 8)" if count > 8 else ""))
|
|
193
|
+
for info in result["matches"]:
|
|
194
|
+
_print_element(info, repo, references)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pick(session, repo, references):
|
|
199
|
+
session.m.script(START_PICKER)
|
|
200
|
+
print("\n Move the mouse over the browser window; the element under the")
|
|
201
|
+
print(" cursor is outlined. Click to capture it. Esc in the browser, or")
|
|
202
|
+
print(" Ctrl-C here, to stop.\n")
|
|
203
|
+
picked = 0
|
|
204
|
+
try:
|
|
205
|
+
while True:
|
|
206
|
+
time.sleep(0.35)
|
|
207
|
+
state = session.m.script(READ_PICK)
|
|
208
|
+
if state.get("picked"):
|
|
209
|
+
picked += 1
|
|
210
|
+
_print_element(state["picked"], repo, references)
|
|
211
|
+
if state.get("stopped"):
|
|
212
|
+
print("\n picker stopped.")
|
|
213
|
+
break
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
print("\n stopping.")
|
|
216
|
+
try:
|
|
217
|
+
session.m.script(STOP_PICKER)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
return 0 if picked else 0
|