d76461d774
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++ only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before); home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout. The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md). tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the three files as one; docs/PROFILES.md says how the files fit together and how a board is added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
158 lines
7.5 KiB
Python
158 lines
7.5 KiB
Python
"""Write the tile icon set into both board profiles and build the editor's fonts.
|
|
|
|
The list lives in screen_manager/app/tile_icons.py. Every Material Design Icons
|
|
font of a board profile gets exactly those glyphs (the first font carries the
|
|
list as a YAML anchor, the smaller sizes reuse it); the big font of the full-page
|
|
card (id ending in _big) gets tile_icons.BIG_GLYPHS as its own anchor, and the editor gets a subset
|
|
of the same TTF so its mockup shows the glyphs the screen draws. The top bar
|
|
mockup also gets Roboto 400/500 with the glyphs of header_bar.GLYPHS, so text
|
|
widths in the editor match the screen. The fonts live in packages/core.yaml, shared by every board;
|
|
--check changes nothing and fails when anything is out of date.
|
|
"""
|
|
import argparse
|
|
import io
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from fontTools import subset
|
|
from fontTools.ttLib import TTFont
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
|
import header_bar # noqa: E402
|
|
import tile_icons # noqa: E402
|
|
|
|
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf'
|
|
PROFILES = [ROOT / 'packages/core.yaml'] # the fonts every board shares (app 0.2.84+); the sizes are the boards' substitutions
|
|
WEB_FONT = ROOT / 'web/src/assets/tile-icons.woff'
|
|
BAR_FONTS = {weight: ROOT / f'web/src/assets/bar-roboto-{weight}.woff' for weight in (400, 500)}
|
|
NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h'
|
|
# Four bits per pixel: the default of one draws a thin ring like mdi:power as a lumpy circle on the CYD.
|
|
FONT_BLOCK = re.compile(r"( - file: \"\$\{FONT_DIR\}/materialdesignicons-webfont\.ttf\"\n id: \w+\n size: [^\n]+\n bpp: 4\n) glyphs:.*\n(?: .*\n)*")
|
|
|
|
def verify():
|
|
"""Names and codepoints must match the TTF the firmware is built from."""
|
|
codepoints = {}
|
|
for code, name in TTFont(TTF).getBestCmap().items():
|
|
codepoints.setdefault(name, code)
|
|
codepoints[name] = min(codepoints[name], code)
|
|
seen = set()
|
|
for name, code in tile_icons.GLYPHS.items():
|
|
if codepoints.get(name) != int(code, 16):
|
|
raise SystemExit(f'{name}: codepoint {code} does not match {TTF.name}')
|
|
if code in seen:
|
|
raise SystemExit(f'Duplicate glyph {code}')
|
|
seen.add(code)
|
|
if len(tile_icons.ICONS) != sum(len(icons) for _, icons in tile_icons.GROUPS):
|
|
raise SystemExit('An icon appears in more than one group')
|
|
|
|
def glyph_list(anchor='tile_icons', names=None):
|
|
lines = [f' glyphs: &{anchor} # GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py']
|
|
chosen = tile_icons.GLYPHS if names is None else {name: tile_icons.GLYPHS[name] for name in names}
|
|
lines += [f' - "\\U000{code}" # {name}' for name, code in chosen.items()]
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
def profile(text):
|
|
blocks = list(FONT_BLOCK.finditer(text))
|
|
if len(blocks) != 4:
|
|
raise SystemExit(f'Expected four icon fonts, found {len(blocks)}')
|
|
out, last = [], 0
|
|
for index, block in enumerate(blocks):
|
|
big = re.search(r'id: \w+_big\n', block[1])
|
|
glyphs = glyph_list('tile_icons_big', tile_icons.BIG_GLYPHS) if big else glyph_list() if index == 0 else ' glyphs: *tile_icons\n'
|
|
out += [text[last:block.start()], block[1], glyphs]
|
|
last = block.end()
|
|
return ''.join(out) + text[last:]
|
|
|
|
def name_table():
|
|
"""Name -> codepoint for the firmware, so a Home Assistant action can say icon: doorbell."""
|
|
lines = ['#pragma once',
|
|
'// GENERATED by tools/generate_icons.py from screen_manager/app/tile_icons.py; do not edit.',
|
|
'#include <cstdint>', '#include <string>', '', 'namespace tile_icon {',
|
|
'struct Named { const char *name; uint32_t codepoint; };',
|
|
'// Every glyph the three icon fonts of both boards carry, by Material Design Icons name.',
|
|
'inline constexpr Named NAMES[] = {']
|
|
lines += [f' {{"{name}", 0x{code}}},' for name, code in tile_icons.GLYPHS.items()]
|
|
lines += ['};',
|
|
'inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]);',
|
|
'// Codepoint of a name the fonts carry; 0 for any other name.',
|
|
'inline uint32_t named(const std::string &name) {',
|
|
' for (const auto &entry : NAMES) if (name == entry.name) return entry.codepoint;',
|
|
' return 0;',
|
|
'}',
|
|
'// True when the fonts carry this codepoint, so a label can draw it.',
|
|
'inline bool carried(uint32_t codepoint) {',
|
|
' for (const auto &entry : NAMES) if (entry.codepoint == codepoint) return true;',
|
|
' return false;',
|
|
'}',
|
|
'} // namespace tile_icon']
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
def web_font():
|
|
font = TTFont(TTF)
|
|
options = subset.Options()
|
|
options.flavor = 'woff'
|
|
options.layout_features = []
|
|
options.name_IDs = []
|
|
options.notdef_outline = False
|
|
subsetter = subset.Subsetter(options)
|
|
subsetter.populate(unicodes=[int(code, 16) for code in tile_icons.GLYPHS.values()])
|
|
subsetter.subset(font)
|
|
# The source stores xMin 0 with left bearing 0; saving recalculates xMin, and browsers
|
|
# then shift each glyph left by the difference. Keep the bearing equal to the real xMin.
|
|
glyf, hmtx = font['glyf'], font['hmtx']
|
|
for name in font.getGlyphOrder():
|
|
glyph = glyf[name]
|
|
glyph.recalcBounds(glyf)
|
|
hmtx[name] = (hmtx[name][0], getattr(glyph, 'xMin', 0))
|
|
buffer = io.BytesIO()
|
|
font.flavor = 'woff'
|
|
font.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
def bar_font(weight):
|
|
"""Roboto subset for the editor's top bar mockup: the glyphs and metrics the screens draw."""
|
|
font = TTFont(ROOT / f'fonts/Roboto-{weight}.ttf')
|
|
options = subset.Options()
|
|
options.flavor = 'woff'
|
|
options.layout_features = ['kern']
|
|
options.name_IDs = [0, 1, 2]
|
|
subsetter = subset.Subsetter(options)
|
|
subsetter.populate(unicodes=[ord(char) for char in header_bar.GLYPHS])
|
|
subsetter.subset(font)
|
|
buffer = io.BytesIO()
|
|
font.flavor = 'woff'
|
|
font.save(buffer)
|
|
return buffer.getvalue()
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--check', action='store_true')
|
|
args = parser.parse_args()
|
|
verify()
|
|
for path in PROFILES:
|
|
expected = profile(path.read_text())
|
|
if args.check:
|
|
if path.read_text() != expected: raise SystemExit(f'Outdated icon fonts: {path.name}')
|
|
else:
|
|
path.write_text(expected)
|
|
if args.check:
|
|
if not NAME_TABLE.exists() or NAME_TABLE.read_text() != name_table(): raise SystemExit(f'Outdated: {NAME_TABLE.name}')
|
|
# Compare glyph coverage, not bytes: font output differs between fontTools versions.
|
|
wanted = {int(code, 16) for code in tile_icons.GLYPHS.values()}
|
|
if not WEB_FONT.exists() or set(TTFont(WEB_FONT).getBestCmap()) != wanted:
|
|
raise SystemExit(f'Outdated: {WEB_FONT.name}')
|
|
for weight, path in BAR_FONTS.items():
|
|
if not path.exists() or set(TTFont(path).getBestCmap()) != {ord(char) for char in header_bar.GLYPHS}:
|
|
raise SystemExit(f'Outdated: {path.name}')
|
|
else:
|
|
NAME_TABLE.write_text(name_table())
|
|
WEB_FONT.write_bytes(web_font())
|
|
for weight, path in BAR_FONTS.items():
|
|
path.write_bytes(bar_font(weight))
|
|
print(f'{len(tile_icons.ICONS)} pickable icons, {len(tile_icons.GLYPHS)} glyphs: ' + ('verified' if args.check else 'generated'))
|
|
|
|
if __name__ == '__main__':
|
|
main()
|