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"]