"""Checks that UI texts fit their slots on a 240x320 ldpi screen, in every language and UI size. Uses Droid Sans (Android 2.x's system font) metrics. A slot's width is computed from the screen width in dp, which shrinks as the UI scale grows (240 px / (0.75 * scale)). Texts that are expected to ellipsize (album titles etc.) aren't checked; fixed UI words are. Run: python3 tools/check_fit.py [font_dir] """ import os import sys from PIL import ImageFont sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from strings import S # noqa: E402 FONT_DIR = sys.argv[1] if len(sys.argv) > 1 else "/home/claude/tools/fonts/x/connect-fonts-droidsans/package/fonts/latin" REG = ImageFont.truetype(os.path.join(FONT_DIR, "droidsans-regular.ttf"), 200) BOLD = ImageFont.truetype(os.path.join(FONT_DIR, "droidsans-bold.ttf"), 200) SCALES = {"Normal": 1.0, "Grande": 1.15, "Moi grande": 1.3} # keep in sync with App.UI_SCALES SCREEN_PX = 240 LDPI = 0.75 SAFETY = 1.04 # hinting / rounding headroom def width_dp(text, sp, bold): f = BOLD if bold else REG return f.getlength(text) * sp / 200.0 * SAFETY def lines_needed(text, sp, bold, avail): words = text.replace("\n", " \n ").split(" ") lines, cur = 1, "" for w in words: if w == "\n": lines += 1 cur = "" continue trial = (cur + " " + w).strip() if width_dp(trial, sp, bold) <= avail: cur = trial else: if cur: lines += 1 cur = w if width_dp(w, sp, bold) > avail: return 99 # a single word doesn't fit return lines def texts(keys, fmt_args=()): out = [] for k in keys: en, gl = S[k] for lang, t in (("en", en), ("gl", gl)): if fmt_args: try: t = t.replace("%1$s", fmt_args[0]).replace("%1$d", fmt_args[0]).replace("%2$d", fmt_args[1] if len(fmt_args) > 1 else "") except Exception: pass out.append((k, lang, t)) return out # slot: (name, keys, sp, bold, width_fn(screen_dp), max_lines, fmt_args) def slots(D): sw = lambda W: W # noqa: E731 return [ ("tab", ["tab_home", "tab_artists", "tab_albums", "tab_playlists"], D["text_tab"], True, lambda W: W / 4 - 4, 1, ()), ("detail button", ["play", "shuffle", "play_all", "artist_radio"], D["text_button"], True, lambda W: (W - 2 * 10 - 10) / 2 - 2 * 4, 1, ()), ("home tile title", ["library_radio", "loved_radio", "time_travel", "random_album"], D["text_tile"], True, lambda W: W - 2 * 8 - 12 - 10 - D["tile_icon"] - 12, 1, ()), ("home tile sub", ["library_radio_sub", "loved_radio_sub", "time_travel_sub", "random_album_sub"], D["text_small"], False, lambda W: W - 2 * 8 - 12 - 10 - D["tile_icon"] - 12, 1, ()), ("shelf header + see all", ["recently_added", "recently_played"], D["text_small"], True, lambda W: W - 10 - width_dp(S["see_all"][1], D["text_small"], True) - 20, 1, ()), ("now playing: playing from", ["playing_from"], D["text_tiny"], True, lambda W: W - 2 * D["touch"] - 8, 1, ()), ("now playing: sleep", ["sleep", "end_of_track"], D["text_np_small"], False, lambda W: W - 16 - 16 - D["np_small_icon"] - 6 - width_dp(S["album_link"][1], D["text_np_small"], False) - 16, 1, ()), ("now playing: album link", ["album_link"], D["text_np_small"], False, lambda W: (W - 24) / 2 - 16, 1, ()), ("setup button", ["sign_in", "connect_address", "pin_check_now", "cancel", "connect", "back"], D["text_button"], True, lambda W: W - 2 * 20 - 2 * D["button_pad"], 1, ()), ("settings: size options", ["size_normal", "size_large", "size_xlarge"], 18, False, lambda W: W - 80, 1, ()), ("setup tagline", ["tagline"], D["text_sub"], False, lambda W: W - 40, 3, ()), ("pin status", ["pin_not_yet"], D["text_sub"], False, lambda W: W - 40 - 24, 3, ("12", "11")), ("pin instructions", ["pin_open"], D["text_sub"], False, lambda W: W - 40, 2, ()), ("queue title", ["up_next"], D["text_title"], True, lambda W: W - 3 * D["touch"] - 12, 1, ()), ("search hint", ["search_hint"], D["text_body"], False, lambda W: W - D["touch"] - 12 - 20, 1, ()), ("status message", ["queue_empty", "no_playlists", "pick_something", "nothing_playing"], D["text_status"], False, lambda W: W - 48, 2, ()), ] def run(D): bad = 0 for scale_name, s in SCALES.items(): W = SCREEN_PX / (LDPI * s) for name, keys, sp, bold, wfn, max_lines, args in slots(D): avail = wfn(W) for k, lang, t in texts(keys, args): n = lines_needed(t, sp, bold, avail) if n > max_lines: bad += 1 print("DOESN'T FIT %-11s %-26s %s %-24r needs %s line(s) in %.0fdp (max %d)" % (scale_name, name, lang, t, "word too long:" if n == 99 else n, avail, max_lines)) return bad if __name__ == "__main__": import re dimens = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "res", "values", "dimens.xml")).read() D = {m.group(1): float(m.group(2)) for m in re.finditer(r'([\d.]+)(?:sp|dp)', dimens)} n = run(D) print("all texts fit" if n == 0 else "%d text(s) don't fit" % n) sys.exit(1 if n else 0)