# FB-09 — HUD default sizing follows the title, not a fixed uniform cell **Depends on** FB-08 (same files, sequence after it merges) · **Size** L · **Status** Done ## Goal A HUD widget's title must always fit at a fixed, readable size. A HUD widget's value must grow or shrink to use whatever space is left, without ever overflowing. ## Context Direct user feedback (`docs/FEEDBACK.md`): > Also the default sizing is fucked. The height and width should be limited by the > title size, and then the value will grow or shrink to ensure there is always > padding and not overflowing. ### Today's default sizing — one fixed size for every metric `lib/src/hud/hud_widget_layout.dart`, `defaultFor` (full factory): ```dart factory HudWidgetLayout.defaultFor(HudMetric metric) { const columns = 4; const rowSpan = 2; final index = HudMetric.values.indexOf(metric); final row = (index ~/ columns) * rowSpan; final col = index % columns; return HudWidgetLayout( metric: metric, col: col, row: row, colSpan: 1, rowSpan: rowSpan, visible: index < columns, ); } ``` Every metric gets `colSpan: 1`, no matter how long its label is. `HudMetric.label` (`lib/src/hud/hud_metric.dart`) ranges from 5 characters ("Speed") to 16 characters ("Points captured"): | Metric | Label | Length | |---|---|---| | speed | Speed | 5 | | distance | Distance | 8 | | maxSpeed | Max speed | 9 | | movingTime | Moving time | 11 | | elapsedTime | Elapsed time | 12 | | avgSpeed | Average speed | 14 | | elevationGain | Elevation gain | 15 | | pointsCaptured | Points captured | 16 | A single `colSpan: 1` cell is not wide enough to comfortably show "Average speed" or "Points captured" at a readable size next to "Speed" at the same width — this is the "default sizing is fucked" complaint. ### Today's text sizing — one `FittedBox` shrinks both lines together `lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 328-360): ```dart return FittedBox( fit: BoxFit.scaleDown, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( metric.label.toUpperCase(), style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant), ), const SizedBox(height: 4), Text( _value, style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)), ), ], ), ); ``` One `FittedBox` wraps both the title and the value together. `BoxFit.scaleDown` shrinks both lines by the same factor to fit the available space. This means the title shrinks along with the value whenever the widget is small — the feedback wants the opposite: the title stays at a fixed, always-readable size, and only the value adapts. ### The grid has no per-metric minimum size today `hudMinSpan = 1` (`hud_widget_layout.dart`) is the same floor for every metric, regardless of label length. Nothing stops a rider from resizing "Average speed" down to a `1`-column-wide widget today, which is exactly the "overflowing" half of the complaint (currently masked only by the shared `FittedBox` shrinking the title along with everything else, rather than the title having its own guaranteed minimum room). ## Design `defaultFor` is a static factory with no access to real screen pixel dimensions (no `BuildContext`, no `areaSize`) — an exact per-pixel text measurement is not available at the point this factory runs. This ticket uses label **character count** as a deliberately simple, easily-adjusted proxy for "how many grid columns this title needs" instead of an exact measurement, since the grid's own column count (`4`) is already a coarse unit and an approximate mapping is sufficient to fix the reported problem. - Add a helper to `hud_widget_layout.dart`: ```dart /// A label under 10 characters fits one column. A label under 15 characters needs /// two. Anything longer needs three. This is a character-count proxy for "how much /// horizontal room this title needs," not an exact pixel measurement -- `defaultFor` /// runs with no `BuildContext` and cannot measure real text width. Adjust these /// thresholds directly if a future label reads too cramped or too loose in practice. int minColSpanForLabel(String label) { if (label.length < 10) return 1; if (label.length < 15) return 2; return 3; } ``` Applying this to the table above: Speed (1), Distance (1), Max speed (1), Moving time (2), Elapsed time (2), Average speed (2), Elevation gain (3), Points captured (3). - **Rework `defaultFor` into a left-to-right row-packing layout.** A fixed "always 4 per row" assumption no longer holds once metrics have different default widths. Replace the index-based `row`/`col` math with a running cursor that wraps to a new row when the current one would overflow: ```dart factory HudWidgetLayout.defaultFor(HudMetric metric) { const rowSpan = 2; var col = 0; var row = 0; for (final m in HudMetric.values) { final span = minColSpanForLabel(m.label); if (col + span > hudGridColumns) { col = 0; row += rowSpan; } if (m == metric) { return HudWidgetLayout( metric: metric, col: col, row: row, colSpan: span, rowSpan: rowSpan, visible: HudMetric.values.indexOf(metric) < 4, ); } col += span; } // Unreachable: metric is always one of HudMetric.values. throw StateError('Unknown metric: $metric'); } ``` The `visible: index < 4` rule is unchanged — the first four enum values (Speed, Avg Speed, Distance, Elapsed Time) still start visible, matching the Map HUD mockup's own fixed row. Their exact column positions may no longer form four perfectly even columns once their individual widths differ — this is the correct, intended result of sizing by title, not a bug. Say so in the Outcome section so nobody "fixes" it back to even columns later. - **Enforce the same minimum as a resize floor.** In `lib/src/ui/components/draggable_resizable_hud_widget.dart`, after computing `snappedColSpan` in the resize handle's `onPanEnd`, clamp it up to `minColSpanForLabel(widget.layout.metric.label)` before calling `widget.onResized`: ```dart final snappedColSpan = (((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth) .round()) .clamp(minColSpanForLabel(layout.metric.label), hudMaxColSpan); ``` A rider can still make a widget larger than its title needs (for a bigger value reading), but never smaller than the title's own minimum. - **Split the `FittedBox`: title fixed, value flexible.** In `_HudMetricValue.build()`, keep the title as a plain, unwrapped `Text` at its fixed reference size (with `maxLines: 1`/`TextOverflow.ellipsis` restored as a safety net, in case a future label is ever added that this ticket's thresholds under-estimate). Wrap only the value in its own `FittedBox`: ```dart return Column( mainAxisSize: MainAxisSize.min, children: [ Text( metric.label.toUpperCase(), style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( _value, style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)), ), ), ), ], ); ``` `Flexible` around the value's `FittedBox` lets it claim whatever vertical space is left after the fixed-size title, rather than both competing for space inside one shared `Column`/`FittedBox` as today. ## Implementation 1. Add `minColSpanForLabel` to `hud_widget_layout.dart`. 2. Rework `HudWidgetLayout.defaultFor` into the row-packing algorithm above. 3. Add the `minColSpanForLabel` floor to the resize clamp in `draggable_resizable_hud_widget.dart`. 4. Change `_HudMetricValue.build()` to a fixed-size title `Text` plus a `Flexible(child: FittedBox(...))`-wrapped value `Text`. 5. Run `flutter analyze` and `flutter test`. ## Acceptance criteria - [ ] Every metric's default widget is wide enough to show its full title without truncation at the fixed title font size. - [ ] The title never shrinks below its fixed reference size, at any widget size. - [ ] The value text grows or shrinks to fill the space left after the title, and never overflows the widget's bounds. - [ ] A rider cannot resize a widget's `colSpan` below the minimum its own title needs, via the resize handle. - [ ] `flutter analyze` clean, `flutter test` green, test count only goes up. ## Tests In `test/hud_widget_layout_test.dart`: - `minColSpanForLabel` returns `1` for a label under 10 characters, `2` for one under 15, `3` for 15 or more — test with `HudMetric.speed.label`, `HudMetric.elapsedTime.label`, and `HudMetric.pointsCaptured.label` directly, not synthetic strings, so the test breaks if a real label's length crosses a threshold. - `defaultFor` gives every metric a `colSpan` at least equal to `minColSpanForLabel(metric.label)`. - `defaultFor` never produces a row whose members' `colSpan` sum exceeds `hudGridColumns` (the packing algorithm's own invariant). - `defaultFor`'s first four enum values are still the ones marked `visible: true` (unchanged rule, worth re-asserting given the factory was rewritten). Widget tests (extend `test/hud_edit_overlay_test.dart` or add to `test/widget_test.dart` alongside existing HUD tests): - Resizing a widget below its title's minimum `colSpan` via the resize handle clamps to that minimum instead of the generic `hudMinSpan`. - Pump `_HudMetricValue` for `HudMetric.pointsCaptured` (the longest label) inside a widget sized to exactly `minColSpanForLabel`'s column count — assert `tester.takeException()` is `null` (no overflow) and the title's full text is present (`find.text('POINTS CAPTURED')`, not an ellipsized fragment). - Pump the same widget very tall and very wide — assert the title's rendered font size is unchanged from the reference size (it does not grow), while the value's rendered size may differ (it is the flexible part). ## Risks - The default HUD row of four no longer looks like four perfectly even columns once title-driven widths differ (e.g. "Average speed" is now wider than "Speed"). This is the intended, correct result of this ticket, not a regression — call it out plainly in the Outcome section with a screenshot so it reads as a deliberate change, not an overlooked one. - The character-count thresholds in `minColSpanForLabel` are an approximation. If a real device screenshot shows a title still cramped or a widget clearly over-generously sized, adjust the threshold numbers directly — they are three plain integers in one function, not a structural decision. ## Out of scope FB-08's row-reflow-on-hide behavior — implement this ticket assuming FB-08 has already merged, and re-run FB-08's own tests to confirm the two features compose correctly (a reflowed row must still respect each remaining member's own title-driven minimum `colSpan` — if `_reflowRow`'s even split would push a widget below its own minimum, that is a real interaction between the two tickets worth checking explicitly and documenting in this ticket's Outcome section). ## Outcome Implemented exactly as designed, with no changes to the Design section's approach: - `minColSpanForLabel(String label)` added to `hud_widget_layout.dart`, verbatim. - `HudWidgetLayout.defaultFor` reworked into the left-to-right row-packing factory from the Design section, verbatim. - `draggable_resizable_hud_widget.dart`'s resize handle `onPanEnd` now clamps `snappedColSpan` up to `minColSpanForLabel(layout.metric.label)` (floor) and down to `hudMaxColSpan` (ceiling), instead of the generic `hudMinSpan` floor the grid-level clamp (`clampedToGrid`, still called downstream in `HudLayoutController`) applies to every other span. - `_HudMetricValue.build()` in `record_screen.dart` now renders the title as a plain, unwrapped `Text` (with `maxLines: 1`/`TextOverflow.ellipsis` restored as a safety net) at its fixed reference size, and wraps only the value `Text` in `Flexible(child: FittedBox(fit: BoxFit.scaleDown, ...))`. **Risk #1 (four even columns no longer even) — confirmed, as predicted, not a regression.** With real label lengths, the default row-0 packing is now: Speed (span 1), Average speed (span 2), Distance (span 1) filling all 4 columns — Elapsed time (a fourth default-visible metric, still marked `visible: true` by the unchanged `index < 4` rule) wraps to row 2 alongside Max speed, rather than sharing row 0 with the other three. This is the ticket's own predicted, intended outcome of sizing by title rather than by a fixed 4-per-row assumption, not a bug. (Note: the worked example table in the Design section above lists slightly different character counts for "Average speed"/"Elevation gain"/"Points captured" than their actual `String` lengths in `hud_metric.dart` — the real lengths are 13/14/15, one less than the table's 14/15/16 — which changes "Elevation gain" from the table's predicted span-3 to an actual span-2. This doesn't affect correctness of the implemented function, which matches the Design section's code verbatim; it only means the packing result differs slightly from the table's worked example. No grid-bounds issue results: the last default row (`pointsCaptured` alone) lands at `row: 6`, `rowSpan: 2`, exactly at `hudGridRows`'s (8) boundary.) **Risk #2 / FB-08 interaction (named in Out of scope) — confirmed real, left unfixed per the ticket's own scope.** Checked directly: `_reflowRow`'s even split divides `hudGridColumns` (4) across however many visible members now share a row, with no awareness of any member's `minColSpanForLabel`. Simulating every membership count `_reflowRow` ever handles (2, 3, or 4 -- it no-ops below 2) against every real `HudMetric` label shows the even split pushes a member below its own title-driven minimum in *every* case where that member's label needs more than 1 column: - 2 members -> both get span 2 -- too small for "Points captured" (needs 3). - 3 members -> spans of 2/1/1 -- too small for any member needing 2 (e.g. "Average speed", "Elapsed time", "Moving time", "Elevation gain") unless it happens to be the one member that lands on span 2, and always too small for "Points captured". - 4 members -> all get span 1 -- too small for any label longer than "Speed", "Distance", or "Max speed". This is a real, reproducible gap between FB-08's `_reflowRow` and this ticket's `minColSpanForLabel`, but per this ticket's own "Out of scope" section it is deliberately left unfixed here — `_reflowRow` is FB-08's function, and changing its even-split algorithm to respect a per-label minimum (e.g. giving longer-labeled members first claim on any spare columns, falling back to letting a row simply not fill exactly 4 columns when it can't be split evenly and legibly) is a follow-up ticket's work, not this one's. Test suite: `flutter analyze` stays at the same 4 pre-existing, unrelated info-level issues as the pre-FB-09 baseline (none in files this ticket touches). `flutter test` went from 426 to **434** tests, all green (8 net new: 5 in `hud_widget_layout_test.dart` for `minColSpanForLabel`/`defaultFor`'s new invariants, 3 in `hud_edit_overlay_test.dart` for the resize-floor clamp, the longest-label minimum-width no-overflow case, and the fixed-title/flexible-value split). Two pre-existing `hud_layout_controller_test.dart` tests (FB-08's own reflow tests) had to be updated, not because `_reflowRow` broke, but because they asserted on *which specific row* certain metrics defaulted into -- an assumption that no longer holds now that `defaultFor` packs by title width instead of a fixed 4-per-row index. Both were rewritten to explicitly position their metrics into a shared row via `updatePosition`/`updateSize` before exercising `_reflowRow`, so they test the reflow behavior itself rather than an incidental default-layout coincidence.