Minamp 1.6: adjustable UI size, larger text and controls

- New setting "Text and button size" (Normal / Large / Extra large): scales
  text, icons and buttons on top of the phone's density (App.UI_SCALES)
- Larger default text, rows, tiles, touch targets and player icons; all sizes
  now live in res/values/dimens.xml
- Seek bar knob drawn in code, since Android 2.x caches XML shapes at the
  size they were first loaded
- Full-width buttons wrap to two lines instead of cutting words off
- Shorter labels so they fit 240x320 screens in both languages
- tools/check_fit.py checks that UI texts fit their slots at every size and
  language using Droid Sans metrics; build.sh runs it and setup_toolchain.sh
  fetches the font

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xockoandClaude Opus 5.5 committed 2026-10-15 08:02:49 +02:00
1 parent fa67e95fde
commit bbdab09f83
32 files changed
+457 -190

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
"""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'<dimen name="(\w+)">([\d.]+)(?:sp|dp)</dimen>', 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)