From 87ff97d006ebfb05d6c1e82d0f9d86b757f13656 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Jul 2026 12:14:52 -0400 Subject: [PATCH] feat(layout): fit_text_proportional gains an axis-specific scale override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ADAPTIVE_LAYOUT.md | 9 +++++++-- src/adaptive_layout.py | 22 ++++++++++++++++------ test/test_adaptive_layout.py | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/ADAPTIVE_LAYOUT.md b/docs/ADAPTIVE_LAYOUT.md index 8bb7e7d2..43b43473 100644 --- a/docs/ADAPTIVE_LAYOUT.md +++ b/docs/ADAPTIVE_LAYOUT.md @@ -87,7 +87,7 @@ Built per (width, height); exposes facts and fit queries: at-or-below the panel's tier. - `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung 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 what fits the box. Use this instead of `fit_text` when several 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 sized via `px()`), even though each individual pick is "correct" in 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 the stack fits the height (measures the actual strings). - `font_for_rows(rows, box_h, ladder)` — largest rung whose line height diff --git a/src/adaptive_layout.py b/src/adaptive_layout.py index 7637d3f2..e91038ad 100644 --- a/src/adaptive_layout.py +++ b/src/adaptive_layout.py @@ -382,8 +382,9 @@ class LayoutContext: def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]], base_size_px: int, ladder: FontLadder = LADDER_DEFAULT, - ellipsis: bool = True) -> FitResult: - """Ladder rung closest to (but not exceeding) ``base_size_px * self.scale`` + ellipsis: bool = True, + 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 "always maximize" behavior. @@ -396,8 +397,16 @@ class LayoutContext: independently "correct". ``base_size_px`` is the size that element renders at on the design size (``design_size``, typically 128x32) — commonly a plugin's existing classic/fixed font size for that - element, so proportional sizing tracks the same geometry scale as - everything else in ``px()``. + element. + + ``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 (a tiny scale factor), and to fit_text's ordinary smaller-rung @@ -405,11 +414,12 @@ class LayoutContext: 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) if cached is not None: 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] candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),) result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis) diff --git a/test/test_adaptive_layout.py b/test/test_adaptive_layout.py index 9d6f7792..8f8b0174 100644 --- a/test/test_adaptive_layout.py +++ b/test/test_adaptive_layout.py @@ -274,6 +274,20 @@ class TestFontFitting: second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10) 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): box = ctx.bounds lines = ["LINE ONE", "LINE TWO", "LINE THREE"]