From 4ab0c871c820df68a7c0fb7fea03351dd8b5c945 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 10 Jul 2026 12:08:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(layout):=20add=20fit=5Ftext=5Fproportional?= =?UTF-8?q?=20=E2=80=94=20proportional=20sizing=20vs.=20always-maximize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fit_text always picks the largest ladder rung that fits its box. That's right when an element owns dedicated space, but wrong when several independently-fitted elements need to stay visually harmonious as the panel grows: a score's box might have generous room while a neighboring logo scales by a fixed geometry factor via px() — fit_text lets the score balloon out of proportion (even overlapping the logo) even though its individual pick is technically correct. fit_text_proportional(text, box, base_size_px, ladder) instead targets base_size_px * self.scale (the same scale factor px() already uses), picking the nearest ladder rung at or below that target, still capped to what fits the box, floored at the smallest rung when the target is below every rung. Refactored the shared largest-that-fits/ellipsize walk into _walk_ladder() so fit_text and fit_text_proportional don't duplicate it. Co-Authored-By: Claude Fable 5 --- docs/ADAPTIVE_LAYOUT.md | 10 ++++++++ src/adaptive_layout.py | 46 ++++++++++++++++++++++++++++++++++-- test/test_adaptive_layout.py | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/ADAPTIVE_LAYOUT.md b/docs/ADAPTIVE_LAYOUT.md index c589d21b..8bb7e7d2 100644 --- a/docs/ADAPTIVE_LAYOUT.md +++ b/docs/ADAPTIVE_LAYOUT.md @@ -87,6 +87,16 @@ 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)` — + 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 + panel grows — `fit_text` maximizes *each one* within its own region, + which can make one element (e.g. a score with a generous box) balloon + 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. - `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 7eccaffe..7637d3f2 100644 --- a/src/adaptive_layout.py +++ b/src/adaptive_layout.py @@ -376,7 +376,51 @@ class LayoutContext: cached = self._fit_cache.get(key) if cached is not None: return cached + result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis) + self._fit_cache[key] = result + return result + 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`` + that still fits the box — proportional sizing instead of ``fit_text``'s + "always maximize" behavior. + + Use this when several independently-fitted elements need to stay + visually harmonious as the panel grows (e.g. a scoreboard's score, + status, and detail text) — ``fit_text`` maximizes each one within + its own region, which can make one element balloon out of + proportion to its neighbors (a huge score overlapping logos it fit + fine at the design size) even though every individual pick is + 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()``. + + Falls back to the smallest rung when even that exceeds the target + (a tiny scale factor), and to fit_text's ordinary smaller-rung + fallback when the closest-to-target rung doesn't actually fit the + box. + """ + box_w, box_h = _box_dims(box) + key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis) + cached = self._fit_cache.get(key) + if cached is not None: + return cached + target = base_size_px * self.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) + self._fit_cache[key] = result + return result + + def _walk_ladder(self, text: str, ladder: Sequence[FontStep], + box_w: int, box_h: int, ellipsis: bool) -> FitResult: + """Shared by fit_text/fit_text_proportional: first ladder entry (in + the order given) whose rendered text fits, ellipsizing the last one + tried if none do.""" result = None for step in ladder: font = self.font_manager.get_font(step.family, step.size_px) @@ -395,8 +439,6 @@ class LayoutContext: short, width, height, baseline, y_offset, fits=(width <= box_w and height <= box_h), line_height=result.line_height) - - self._fit_cache[key] = result return result def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]], diff --git a/test/test_adaptive_layout.py b/test/test_adaptive_layout.py index 08435f31..9d6f7792 100644 --- a/test/test_adaptive_layout.py +++ b/test/test_adaptive_layout.py @@ -233,6 +233,47 @@ class TestFontFitting: ctx.clear_cache() assert ctx.fit_text("CACHED", ctx.bounds) is not first + def test_fit_text_proportional_tracks_design_scale(self, font_manager): + # design size 128x32, base_size_px=10 (a typical classic score size): + # at 2x scale the target is 20px -> nearest LADDER_ARCADE rung <= 20 + # is 16px, not the largest that merely fits the box (32). + ctx = LayoutContext(256, 64, font_manager) # scale = min(2,2) = 2 + fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + assert fit.size_px == 16 + + def test_fit_text_proportional_does_not_exceed_max_fit(self, ctx): + # at scale=1 (128x32, the design size itself) the target equals + # base_size_px, so proportional should never pick something LARGER + # than plain fit_text would for the same box. + prop = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + maxed = ctx.fit_text("17-21", ctx.bounds, ladder=LADDER_ARCADE) + assert prop.size_px <= maxed.size_px + + def test_fit_text_proportional_floors_at_smallest_rung(self, font_manager): + # scale so small the target is below every rung -> use the smallest + # rung as a floor rather than refusing to render anything. + ctx = LayoutContext(32, 8, font_manager) # scale = min(32/128, 8/32) = 0.25 + fit = ctx.fit_text_proportional("HI", ctx.bounds, base_size_px=10, + ladder=LADDER_ARCADE) + assert fit.size_px == min(s.size_px for s in LADDER_ARCADE) + + def test_fit_text_proportional_falls_through_when_target_rung_overflows(self, font_manager): + # a long string at the target rung might not fit a narrow box even + # though the target size is "correct" -- must fall through to a + # smaller rung exactly like fit_text does, not just refuse to fit. + ctx = LayoutContext(256, 64, font_manager) + narrow_box = Region(0, 0, 40, 64) + fit = ctx.fit_text_proportional("A REALLY LONG STRING HERE", narrow_box, + base_size_px=10, ladder=LADDER_ARCADE) + assert fit.fits or fit.text.endswith("…") + + def test_fit_text_proportional_cached(self, ctx): + first = 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 + def test_fit_lines_stacks_within_height(self, ctx): box = ctx.bounds lines = ["LINE ONE", "LINE TWO", "LINE THREE"]