feat(layout): fit_text_proportional gains an axis-specific scale override

self.scale (min(width_ratio, height_ratio)) is the right conservative
default for anything whose aspect ratio matters, but a caller whose
surrounding composition already scales along a single axis — e.g.
football-scoreboard's logo_slot = min(height, width // 2), which tracks
height alone — needs text sized the same way, or it reads as
under-scaled next to logos that grew on a panel that only got taller
(128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but
logos still double).

fit_text_proportional(..., scale=None) now accepts an explicit override;
None keeps the existing self.scale default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-11 09:00:37 -04:00
co-authored by Claude Fable 5
parent 4ab0c871c8
commit 87ff97d006
3 changed files with 37 additions and 8 deletions
+7 -2
View File
@@ -87,7 +87,7 @@ Built per (width, height); exposes facts and fit queries:
at-or-below the panel's tier. at-or-below the panel's tier.
- `fit_text(text, box, ladder, ellipsis=True)``FitResult` — largest rung - `fit_text(text, box, ladder, ellipsis=True)``FitResult` — largest rung
that fits; ellipsizes as a last resort. Cached per (text, box, ladder). that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True)` - `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)`
rung closest to (not exceeding) `base_size_px * scale`, still capped to rung closest to (not exceeding) `base_size_px * scale`, still capped to
what fits the box. Use this instead of `fit_text` when several what fits the box. Use this instead of `fit_text` when several
independently-fitted elements need to stay visually harmonious as the independently-fitted elements need to stay visually harmonious as the
@@ -96,7 +96,12 @@ Built per (width, height); exposes facts and fit queries:
out of proportion to a neighbor that scales by geometry (e.g. logos out of proportion to a neighbor that scales by geometry (e.g. logos
sized via `px()`), even though each individual pick is "correct" in sized via `px()`), even though each individual pick is "correct" in
isolation. `base_size_px` is normally the element's existing classic/ isolation. `base_size_px` is normally the element's existing classic/
fixed font size, so it scales in step with everything else. fixed font size. `scale` defaults to `self.scale` (the conservative
min-of-both-axes factor `px()` uses); pass an axis-specific value when
the surrounding composition already scales that way — e.g. a scoreboard
whose logo slots track height alone (`min(height, width // 2)`) should
size its text by `height / design_height` too, or the text reads as
under-scaled next to bigger logos on a panel that only grew taller.
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and - `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
the stack fits the height (measures the actual strings). the stack fits the height (measures the actual strings).
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height - `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
+16 -6
View File
@@ -382,8 +382,9 @@ class LayoutContext:
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]], def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
base_size_px: int, ladder: FontLadder = LADDER_DEFAULT, base_size_px: int, ladder: FontLadder = LADDER_DEFAULT,
ellipsis: bool = True) -> FitResult: ellipsis: bool = True,
"""Ladder rung closest to (but not exceeding) ``base_size_px * self.scale`` scale: Optional[float] = None) -> FitResult:
"""Ladder rung closest to (but not exceeding) ``base_size_px * scale``
that still fits the box — proportional sizing instead of ``fit_text``'s that still fits the box — proportional sizing instead of ``fit_text``'s
"always maximize" behavior. "always maximize" behavior.
@@ -396,8 +397,16 @@ class LayoutContext:
independently "correct". ``base_size_px`` is the size that element independently "correct". ``base_size_px`` is the size that element
renders at on the design size (``design_size``, typically 128x32) renders at on the design size (``design_size``, typically 128x32)
— commonly a plugin's existing classic/fixed font size for that — commonly a plugin's existing classic/fixed font size for that
element, so proportional sizing tracks the same geometry scale as element.
everything else in ``px()``.
``scale`` defaults to ``self.scale`` (the same conservative
min(width_ratio, height_ratio) factor ``px()`` uses — safe for
content whose aspect ratio matters). Pass an explicit axis-specific
value when the surrounding composition already scales that way —
e.g. a scoreboard whose logos scale with height alone
(``logo_slot = min(height, width // 2)``) should size its score
text by ``height / design_height`` too, or its text will look
under-scaled next to bigger logos on a panel that only grew taller.
Falls back to the smallest rung when even that exceeds the target Falls back to the smallest rung when even that exceeds the target
(a tiny scale factor), and to fit_text's ordinary smaller-rung (a tiny scale factor), and to fit_text's ordinary smaller-rung
@@ -405,11 +414,12 @@ class LayoutContext:
box. box.
""" """
box_w, box_h = _box_dims(box) box_w, box_h = _box_dims(box)
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis) effective_scale = self.scale if scale is None else scale
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
cached = self._fit_cache.get(key) cached = self._fit_cache.get(key)
if cached is not None: if cached is not None:
return cached return cached
target = base_size_px * self.scale target = base_size_px * effective_scale
eligible = [step for step in ladder if step.size_px <= target] eligible = [step for step in ladder if step.size_px <= target]
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),) candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis) result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
+14
View File
@@ -274,6 +274,20 @@ class TestFontFitting:
second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10) second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
assert first is second assert first is second
def test_fit_text_proportional_scale_override(self, font_manager):
# 128x64 vs design 128x32: self.scale (min of both axes) is 1.0
# since width didn't grow, but a caller whose composition scales by
# HEIGHT alone (e.g. logo_slot = min(h, w//2)) should be able to
# override the reference scale so text grows with it too.
ctx = LayoutContext(128, 64, font_manager)
assert ctx.scale == 1.0
default_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
ladder=LADDER_ARCADE)
height_scale = 64 / 32 # matches design height
scaled_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
ladder=LADDER_ARCADE, scale=height_scale)
assert scaled_fit.size_px > default_fit.size_px
def test_fit_lines_stacks_within_height(self, ctx): def test_fit_lines_stacks_within_height(self, ctx):
box = ctx.bounds box = ctx.bounds
lines = ["LINE ONE", "LINE TWO", "LINE THREE"] lines = ["LINE ONE", "LINE TWO", "LINE THREE"]