# 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** Not started ## 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).