Files
z-bar-qt/scripts/symbols-preview.py
T
zach 11e7aac195
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s
feat: better notification storage and image caching + blobs
2026-11-02 19:00:43 +01:00

599 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate a searchable preview page for installed Material Symbols fonts.
Usage:
symbols-preview.py [out.html] [--styles rounded,outlined,sharp] [--source url|local|embed]
--source local (the default) uses local() only: nothing extra on disk, and all four
axes apply, because ~/.config/fontconfig/conf.d/90-material-symbols-variable.conf
re-scans the installed fonts with "variable: True" so Firefox stops treating the
local() match as static. Without that fontconfig edit, Firefox maps only `wght`.
local() matches the full font name (name ID 4) and PostScript name (ID 6), NOT the
family name, which is why local("Material Symbols Rounded") silently fails.
--source url writes one subsetted variable WOFF2 per style next to the HTML and
loads them with url(). Webfonts apply all four axes regardless of the fontconfig
flag, so this is the fallback if the advertising is ever removed.
--source embed puts the subset in the page as a data URL: self-contained, ~2 MB/style.
"""
import argparse
import base64
import json
import os
import subprocess
import tempfile
from fontTools import ttLib
STYLES = ("rounded", "outlined", "sharp")
FAMILY = {"rounded": "Material Symbols Rounded", "outlined": "Material Symbols Outlined", "sharp": "Material Symbols Sharp"}
FEATURES = "rlig,rclt"
def installed():
"""Return {style: ttf path} for every installed Material Symbols style."""
out = subprocess.run(["fc-list", "--format", "%{file}|%{family}\n"], capture_output=True, text=True).stdout
found = {}
for line in out.splitlines():
path, _, families = line.partition("|")
for style, family in FAMILY.items():
if style in found:
continue
if family in families and path.strip().endswith(".ttf"):
found[style] = path.strip()
return found
def names_of(font):
"""Icon name -> codepoint, read from the font's rlig/rclt ligature lookups."""
cmap = {}
for table in font["cmap"].tables:
if table.isUnicode():
cmap.update(table.cmap)
rev = {g: u for u, g in cmap.items()}
names = {}
for lookup in font["GSUB"].table.LookupList.Lookup:
for sub in lookup.SubTable:
root = sub.ExtSubTable if lookup.LookupType == 7 else sub
ligatures = getattr(root, "ligatures", None)
if not ligatures:
continue
for first, entries in ligatures.items():
for lig in entries:
comps = (first,) + tuple(lig.Component)
if any(c not in rev for c in comps):
continue
code = rev.get(lig.LigGlyph)
if code is not None and code >= 0xE000:
names["".join(chr(rev[c]) for c in comps)] = code
return names, cmap, rev
def local_names(font):
"""local() matches full font name (id 4) and PostScript name (id 6), not family."""
name = font["name"]
def pick(name_id):
for record in name.names:
if record.nameID == name_id and record.platformID in (1, 3):
return record.toUnicode()
return None
return [n for n in (pick(4), pick(6), pick(1)) if n]
def axes_of(font):
if "fvar" not in font:
return {}
return {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in font["fvar"].axes}
# display order; any axis not listed still gets a slider, appended after these
AXIS_ORDER = ("FILL", "wght", "GRAD", "opsz")
def axis_sliders(axes):
"""Return (slider markup, JS axis specs) driven by the font's own fvar table."""
order = [t for t in AXIS_ORDER if t in axes] + [t for t in axes if t not in AXIS_ORDER]
html, js = [], {}
for tag in order:
lo, def_, hi = axes[tag]
integral = lo.is_integer() and hi.is_integer() and def_.is_integer()
# narrow-range axes (e.g. FILL 0..1) need fractional steps to be useful;
# wide axes stay at whole-number steps
step = 0.01 if hi - lo <= 2 else (1 if integral else 0.01)
sid = tag.lower()
html.append(
f' <div class="slider"><span>{tag}</span>'
f'<input id="{sid}" type="range"><output id="{sid}v"></output></div>'
)
js[sid] = {"tag": tag, "min": lo if integral else round(lo, 2),
"max": hi if integral else round(hi, 2),
"def": def_ if integral else round(def_, 2),
"step": step}
return "\n".join(html), js
def subset(font_path, glyphs, out_path):
tmp = tempfile.mkdtemp()
glyphs_file = os.path.join(tmp, "glyphs.txt")
open(glyphs_file, "w").write(",".join(glyphs))
subprocess.run(
["pyftsubset", font_path, f"--glyphs-file={glyphs_file}",
f"--layout-features={FEATURES}", "--flavor=woff2", f"--output-file={out_path}"],
check=True,
)
return os.path.getsize(out_path)
def all_glyphs(font, rev):
keep = {g for g, u in rev.items() if chr(u).isalnum() or chr(u) == "_"}
for lookup in font["GSUB"].table.LookupList.Lookup:
for sub in lookup.SubTable:
root = sub.ExtSubTable if lookup.LookupType == 7 else sub
ligatures = getattr(root, "ligatures", None)
if not ligatures:
continue
for first, entries in ligatures.items():
keep.add(first)
for lig in entries:
keep.add(lig.LigGlyph)
keep.update(lig.Component)
return sorted(keep)
TEMPLATE = """<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Material Symbols — {count} icons</title>
<style>
{font_faces}
:root {{
--pad-max: 28px;
--cell-min: 112px;
--gap: 8px;
--cols-min: 4;
--grid-min: calc(var(--cols-min) * (var(--cell-min) + var(--gap)) - var(--gap));
--sidebar: 260px;
/* padding shrinks with the available width and reaches 0 exactly where reflow starts.
100% resolves against .grid/.top's containing block (main), which already excludes
the sidebar, so the sidebar is not subtracted here. */
--pad: clamp(0px, calc((100% - var(--grid-min)) / 2), var(--pad-max));
--sym: "Sym-Rounded";
--bg: #121214; --panel: #1a1a1e; --line: #2b2b31; --fg: #ececec; --dim: #a2a2ac;
--accent: #8ab4f8; --hover: #222228;
}}
html[data-theme="light"] {{
--bg: #fafafa; --panel: #ffffff; --line: #e2e2e6; --fg: #1c1c1f; --dim: #6a6a72;
--accent: #1a73e8; --hover: #f0f0f3;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; }}
/* sidebar */
aside {{
position: fixed; inset: 0 auto 0 0; width: 260px; background: var(--panel);
border-right: 1px solid var(--line); padding: 18px 16px 24px; overflow-y: auto; z-index: 20;
transition: transform .22s ease;
}}
aside h1 {{ font-size: 15px; margin: 0 0 2px; }}
aside .sub {{ font-size: 12px; color: var(--dim); margin: 0 0 18px; }}
.group {{ margin: 0 0 20px; }}
.group > label {{ display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
color: var(--dim); margin: 0 0 8px; }}
.seg {{ display: flex; gap: 4px; background: var(--bg); border: 1px solid var(--line);
border-radius: 10px; padding: 4px; }}
.seg button {{ flex: 1; padding: 7px 0; border: 0; border-radius: 7px; background: transparent;
color: var(--dim); font: inherit; font-size: 13px; cursor: pointer; }}
.seg button[aria-pressed="true"] {{ background: var(--accent); color: #fff; }}
html[data-theme="light"] .seg button[aria-pressed="true"] {{ color: #fff; }}
.slider {{ display: grid; grid-template-columns: 46px 1fr 42px; align-items: center; gap: 10px;
margin-bottom: 12px; font-size: 12px; color: var(--dim); }}
.slider output {{ text-align: right; color: var(--fg); font-variant-numeric: tabular-nums; }}
input[type=range] {{ accent-color: var(--accent); width: 100%; }}
.row {{ display: flex; align-items: center; justify-content: space-between; gap: 10px;
font-size: 12px; color: var(--dim); margin-bottom: 10px; }}
.ghost {{ padding: 7px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 13px; cursor: pointer; }}
.ghost:hover {{ background: var(--hover); }}
/* hide sidebar on narrow viewports, reveal via toggle */
#menu {{ display: none; }}
@media (max-width: 900px) {{
:root {{ --sidebar: 0px; }}
aside {{ transform: translateX(-100%); box-shadow: 0 0 40px rgba(0,0,0,.35); }}
body.nav-open aside {{ transform: none; }}
#menu {{ display: inline-block; }}
}}
/* content */
main {{ margin-left: var(--sidebar); }}
.top {{ position: sticky; top: 0; z-index: 10; background: var(--bg);
padding: 14px var(--pad);
display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }}
.top input {{ flex: 1; min-width: 0; padding: 9px 12px; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; font: inherit; }}
.count {{ font-size: 12px; color: var(--dim); white-space: nowrap; }}
.pager {{ display: flex; align-items: center; gap: 6px; }}
.pager.bottom {{ justify-content: center; padding: 6px var(--pad) 30px; }}
.page {{ font-size: 12px; color: var(--dim); min-width: 64px; text-align: center;
font-variant-numeric: tabular-nums; }}
.ghost:disabled {{ opacity: .35; cursor: default; }}
/* padding shrinks with the viewport until it hits 0; only then does the grid reflow */
.grid {{ display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(var(--cell-min), 1fr));
padding: 16px var(--pad) 28px; }}
.cell {{ display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 4px;
border: 1px solid var(--line); border-radius: 12px; background: var(--panel); cursor: pointer;
transition: border-color .15s, background .15s; }}
.cell:hover {{ border-color: var(--accent); background: var(--hover); }}
.icon {{ font-family: var(--sym); font-feature-settings: "rlig" 1, "rclt" 1; line-height: 1; }}
.name {{ font-size: 11px; color: var(--dim); text-align: center; word-break: break-all; }}
.cp {{ font-size: 10px; color: var(--accent); opacity: .8; display: none; }}
html[data-cp="1"] .cp {{ display: block; }}
.cell.miss {{ opacity: .3; cursor: default; }}
#toast {{ position: fixed; right: 16px; bottom: 16px; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; padding: 8px 12px; font-size: 12px;
opacity: 0; transition: opacity .2s; pointer-events: none; }}
#toast.show {{ opacity: 1; }}
</style>
</head>
<body>
<aside>
<h1>Material Symbols</h1>
<p class="sub">{count} icons · styles: {style_list}</p>
<div class="group">
<label>Style</label>
<div class="seg" id="styles">{style_buttons}</div>
</div>
<div class="group">
<label>Variable axes</label>
{axis_sliders}
</div>
<div class="group">
<label>Layout</label>
<div class="slider"><span>size</span><input id="size" type="range"><output id="sizev"></output></div>
<div class="slider"><span>cell</span><input id="cell" type="range"><output id="cellv"></output></div>
<div class="slider"><span>per pg</span><input id="per" type="range"><output id="perv"></output></div>
</div>
<div class="group">
<label>Options</label>
<div class="row"><span>Show codepoints</span><button class="ghost" id="cp">off</button></div>
<div class="row"><span>Theme</span><button class="ghost" id="theme">dark</button></div>
</div>
<button class="ghost" id="reset">Reset</button>
</aside>
<main>
<div class="top">
<button class="ghost" id="menu">Controls</button>
<input id="q" type="search" placeholder="Search icon names…" autocomplete="off">
<span class="count" id="count"></span>
<div class="pager">
<button class="ghost" id="prev">‹</button>
<span class="page" id="page"></span>
<button class="ghost" id="next">›</button>
</div>
</div>
<div class="grid" id="grid"></div>
<div class="pager bottom">
<button class="ghost" id="prev2">‹ Prev</button>
<span class="page" id="page2"></span>
<button class="ghost" id="next2">Next ›</button>
</div>
</main>
<div id="toast"></div>
<script>
const ICONS = {icons};
const STYLES = {styles};
const AXES = {axes};
const el = id => document.getElementById(id);
const val = id => el(id).value;
const grid = el("grid");
const toast = el("toast");
const SLIDERS = Object.assign({{}}, AXES, {{
size: {{ min: 16, max: 64, step: 1, def: 24 }},
cell: {{ min: 70, max: 200, step: 1, def: 112 }},
per: {{ min: 24, max: 600, step: 24, def: 120 }},
}});
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
// remember options across reloads: sliders, style, theme, codepoint toggle
const STATE_KEY = "symbols-preview-v1";
let saved = {{}};
try {{ saved = JSON.parse(localStorage.getItem(STATE_KEY)) || {{}}; }} catch (e) {{}}
function save() {{
const st = {{ style: styleKey, theme: document.documentElement.dataset.theme,
cp: document.documentElement.dataset.cp }};
for (const id of Object.keys(SLIDERS)) st[id] = val(id);
try {{ localStorage.setItem(STATE_KEY, JSON.stringify(st)); }} catch (e) {{}}
}}
for (const [id, s] of Object.entries(SLIDERS)) {{
const e = el(id);
e.min = s.min; e.max = s.max; e.step = s.step;
e.value = clamp(Number(saved[id] ?? s.def), s.min, s.max);
}}
// cell objects exist for every icon, but only the current page is ever in the DOM
const cells = ICONS.map(([name, cp]) => {{
const cell = document.createElement("div");
cell.className = "cell";
const icon = document.createElement("div");
icon.className = "icon";
icon.textContent = name;
const label = document.createElement("div");
label.className = "name";
label.textContent = name;
const hex = document.createElement("div");
hex.className = "cp";
hex.textContent = cp;
cell.append(icon, label, hex);
cell.title = name + " " + cp;
cell.onclick = async () => {{
if (cell.classList.contains("miss")) return;
await navigator.clipboard.writeText(name);
toast.textContent = "copied " + name;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 1400);
}};
return cell;
}});
let styleKey = "{default_style}";
let haveSet = null;
let filtered = [];
let page = 0;
let rendered = [];
function settings() {{
// Axis tags MUST be quoted: Firefox drops unquoted forms like `FILL 1` as
// invalid (computed = "normal"); only `"FILL" 1` is accepted.
return Object.keys(AXES)
.map(id => `"${{AXES[id].tag}}" ${{val(id)}}`).join(", ");
}}
function paint() {{
// only the rendered page needs variation settings — applying to all 4k+ cells
// is what made slider drags stutter
const vs = settings();
const px = val("size");
for (const cell of rendered) {{
const icon = cell.firstElementChild;
icon.style.fontVariationSettings = vs;
icon.style.fontSize = px + "px";
}}
}}
function apply() {{
paint();
for (const id of Object.keys(SLIDERS)) el(id + "v").textContent = val(id);
document.documentElement.style.setProperty("--cell-min", val("cell") + "px");
}}
// Every token must appear somewhere in the name, so "edit notif" matches
// edit_notifications. Hyphens are treated as underscores. Results are ranked:
// name prefix > word-boundary prefix > substring, then shorter names first.
function score(name, tokens) {{
let total = 0;
for (const t of tokens) {{
let s = 0;
if (name.startsWith(t)) s = 8;
else if (name.split("_").some(w => w.startsWith(t))) s = 5;
else if (name.includes(t)) s = 2;
else return -1;
total += s - name.length / 1000;
}}
return total;
}}
function filter() {{
const q = val("q").trim().toLowerCase().replace(/-/g, "_");
const tokens = q ? q.split(/[_\\s]+/).filter(Boolean) : [];
const hits = [];
for (let i = 0; i < ICONS.length; i++) {{
const s = tokens.length ? score(ICONS[i][0], tokens) : 0;
if (s >= 0) hits.push([i, s]);
}}
if (tokens.length)
hits.sort((a, b) => b[1] - a[1] || ICONS[a[0]][0].length - ICONS[b[0]][0].length);
filtered = hits.map(h => h[0]);
}}
function pages() {{ return Math.max(1, Math.ceil(filtered.length / Number(val("per")))); }}
function render() {{
page = clamp(page, 0, pages() - 1);
const n = Number(val("per"));
const start = page * n;
const frag = document.createDocumentFragment();
rendered = [];
for (const i of filtered.slice(start, start + n)) {{
const cell = cells[i];
cell.classList.toggle("miss", haveSet !== null && !haveSet.has(ICONS[i][0]));
frag.append(cell);
rendered.push(cell);
}}
grid.replaceChildren(frag);
paint();
const m = filtered.length;
el("count").textContent = m ? (start + 1) + "-" + Math.min(start + n, m) + " of " + m
: "0 of " + ICONS.length;
const label = (page + 1) + " / " + pages();
el("page").textContent = label;
el("page2").textContent = label;
el("prev").disabled = el("prev2").disabled = page <= 0;
el("next").disabled = el("next2").disabled = page >= pages() - 1;
}}
function go(delta) {{ page += delta; render(); }}
function setStyle(key, persist = true) {{
styleKey = key;
document.documentElement.style.setProperty("--sym", STYLES[key].family);
haveSet = STYLES[key].names ? new Set(STYLES[key].names) : null;
document.querySelectorAll("#styles button").forEach(b =>
b.setAttribute("aria-pressed", String(b.dataset.style === key)));
render();
if (persist) save();
}}
for (const id of Object.keys(SLIDERS)) el(id).addEventListener("input", () => {{
if (id === "per") page = 0;
apply(); render(); save();
}});
let timer = null;
el("q").addEventListener("input", () => {{
clearTimeout(timer);
timer = setTimeout(() => {{ filter(); page = 0; render(); }}, 120);
}});
el("styles").addEventListener("click", e => {{
if (e.target.dataset.style) setStyle(e.target.dataset.style);
}});
el("cp").onclick = e => {{
const on = document.documentElement.dataset.cp === "1";
document.documentElement.dataset.cp = on ? "0" : "1";
e.target.textContent = on ? "off" : "on";
save();
}};
el("theme").onclick = e => {{
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
e.target.textContent = next;
save();
}};
el("menu").onclick = () => document.body.classList.toggle("nav-open");
for (const id of ["prev", "prev2"]) el(id).onclick = () => go(-1);
for (const id of ["next", "next2"]) el(id).onclick = () => go(1);
el("reset").onclick = () => {{
try {{ localStorage.removeItem(STATE_KEY); }} catch (e) {{}}
for (const [id, s] of Object.entries(SLIDERS)) el(id).value = s.def;
document.documentElement.dataset.theme = "dark";
el("theme").textContent = "dark";
document.documentElement.dataset.cp = "0";
el("cp").textContent = "off";
page = 0;
apply(); filter(); setStyle("{default_style}", false);
}};
document.addEventListener("keydown", e => {{
if (e.key === "/" && document.activeElement !== el("q")) {{
e.preventDefault(); el("q").focus(); return;
}}
if (document.activeElement && document.activeElement.tagName === "INPUT") return;
if (e.key === "ArrowLeft") {{ e.preventDefault(); go(-1); }}
if (e.key === "ArrowRight") {{ e.preventDefault(); go(1); }}
}});
// init from saved state
document.documentElement.dataset.theme = saved.theme === "light" ? "light" : "dark";
el("theme").textContent = document.documentElement.dataset.theme;
document.documentElement.dataset.cp = saved.cp === "1" ? "1" : "0";
el("cp").textContent = document.documentElement.dataset.cp === "1" ? "on" : "off";
apply(); filter();
setStyle(saved.style && STYLES[saved.style] ? saved.style : "{default_style}", false);
</script>
</body>
</html>
"""
def build(styles, source="local", out_path="/tmp/opencode/symbols-preview.html"):
"""source: local | url | embed
local() is the default: with the fontconfig advertisement in place
(conf.d/90-material-symbols-variable.conf), Firefox treats the local() match
as variable and applies all four axes. url()/embed remain as fallbacks for
machines without that config.
"""
fonts = {}
for key in styles:
fonts[key] = ttLib.TTFont(installed()[key])
# name -> codepoint, from the first style; other styles checked for coverage
primary_key = styles[0]
primary_names = names_of(fonts[primary_key])[0]
per_style = {key: names_of(fonts[key])[0] for key in styles}
icons = sorted(primary_names.items())
outdir = os.path.dirname(os.path.abspath(out_path)) or "."
faces, style_js, written = [], {}, []
for key in styles:
font = fonts[key]
locs = ", ".join(f'local("{n}")' for n in local_names(font))
parts = []
if source in ("url", "embed"):
cmap_data = names_of(font)
if source == "url":
fname = f"symbols-{key}.woff2"
dest = os.path.join(outdir, fname)
written.append((fname, subset(installed()[key], all_glyphs(font, cmap_data[2]), dest)))
# url() first: applies all axes without needing the fontconfig advertisement
parts.append(f'url("{fname}") format("woff2")')
parts.append(locs)
else:
tmp = os.path.join(tempfile.mkdtemp(), f"{key}.woff2")
size = subset(installed()[key], all_glyphs(font, cmap_data[2]), tmp)
data = base64.b64encode(open(tmp, "rb").read()).decode()
written.append((f"(embedded {key})", size))
parts.append(f'url(data:font/woff2;base64,{data}) format("woff2")')
else:
parts.append(locs)
faces.append(f' @font-face {{ font-family: "Sym-{key.title()}"; src: {", ".join(parts)}; }}')
same = set(per_style[key]) == set(primary_names)
style_js[key] = {"family": f"Sym-{key.title()}", "names": None if same else sorted(per_style[key])}
axes = axes_of(fonts[primary_key])
slider_html, axis_js = axis_sliders(axes)
buttons = "".join(
f'<button data-style="{key}" aria-pressed="false">{FAMILY[key].replace("Material Symbols ", "")}</button>'
for key in styles
)
html = TEMPLATE.format(
font_faces="\n".join(faces),
count=len(icons),
style_list=", ".join(FAMILY[k] for k in styles),
style_buttons=buttons,
axis_sliders=slider_html,
default_style=primary_key,
icons=json.dumps([[n, f"{hex(c)}"] for n, c in icons]),
styles=json.dumps(style_js),
axes=json.dumps(axis_js),
)
open(out_path, "w").write(html)
counts = {k: (len(icons) if v["names"] is None else len(v["names"])) for k, v in style_js.items()}
return out_path, len(html), counts, written
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("out", nargs="?", default="/tmp/opencode/symbols-preview.html")
ap.add_argument("--styles", default=",".join(STYLES))
ap.add_argument("--source", choices=("local", "url", "embed"), default="local",
help="local = system font via local() (all axes work with the fontconfig "
"advertisement); url = sibling woff2 subsets (all axes work anywhere); "
"embed = self-contained but multi-MB page")
args = ap.parse_args()
want = [s for s in args.styles.split(",") if s in STYLES]
have = installed()
styles = [s for s in want if s in have]
if not styles:
raise SystemExit("no installed Material Symbols style found")
for s in want:
if s not in have:
print(f" skipping {s}: not installed")
path, size, counts, written = build(styles, source=args.source, out_path=args.out)
print(f"{path}: {size} bytes, source={args.source}")
print(" styles:", ", ".join(f"{k} ({v} icons)" for k, v in counts.items()))
for name, bytes_ in written:
print(f" wrote {name}: {bytes_} bytes")
for s in styles:
print(f" {FAMILY[s]}: {installed()[s]}")
if __name__ == "__main__":
main()