Adds minColSpanForLabel as a character-count proxy for how many grid columns a metric's title needs, reworks HudWidgetLayout.defaultFor into a left-to-right row-packing layout using it, enforces the same minimum as a floor on manual resize, and splits _HudMetricValue's FittedBox so the title renders at a fixed reference size while only the value shrinks/grows to fill the remaining space. Confirms a real, documented interaction with FB-08's _reflowRow: its even column split has no awareness of a member's title-driven minimum and can push one below it. Left unfixed per this ticket's own Out-of-scope section -- _reflowRow's algorithm is FB-08's, not this ticket's, to change. Test count 426 -> 434, all green. flutter analyze unchanged (same 4 pre-existing, unrelated info-level issues).
16 KiB
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):
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):
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: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)./// 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; } - Rework
defaultForinto 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-basedrow/colmath with a running cursor that wraps to a new row when the current one would overflow:Thefactory 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'); }visible: index < 4rule 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 computingsnappedColSpanin the resize handle'sonPanEnd, clamp it up tominColSpanForLabel(widget.layout.metric.label)before callingwidget.onResized: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.final snappedColSpan = (((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth) .round()) .clamp(minColSpanForLabel(layout.metric.label), hudMaxColSpan); - Split the
FittedBox: title fixed, value flexible. In_HudMetricValue.build(), keep the title as a plain, unwrappedTextat its fixed reference size (withmaxLines: 1/TextOverflow.ellipsisrestored 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 ownFittedBox: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)), ), ), ), ], );Flexiblearound the value'sFittedBoxlets it claim whatever vertical space is left after the fixed-size title, rather than both competing for space inside one sharedColumn/FittedBoxas today.
Implementation
- Add
minColSpanForLabeltohud_widget_layout.dart. - Rework
HudWidgetLayout.defaultForinto the row-packing algorithm above. - Add the
minColSpanForLabelfloor to the resize clamp indraggable_resizable_hud_widget.dart. - Change
_HudMetricValue.build()to a fixed-size titleTextplus aFlexible(child: FittedBox(...))-wrapped valueText. - Run
flutter analyzeandflutter 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
colSpanbelow the minimum its own title needs, via the resize handle. flutter analyzeclean,flutter testgreen, test count only goes up.
Tests
In test/hud_widget_layout_test.dart:
minColSpanForLabelreturns1for a label under 10 characters,2for one under 15,3for 15 or more — test withHudMetric.speed.label,HudMetric.elapsedTime.label, andHudMetric.pointsCaptured.labeldirectly, not synthetic strings, so the test breaks if a real label's length crosses a threshold.defaultForgives every metric acolSpanat least equal tominColSpanForLabel(metric.label).defaultFornever produces a row whose members'colSpansum exceedshudGridColumns(the packing algorithm's own invariant).defaultFor's first four enum values are still the ones markedvisible: 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
colSpanvia the resize handle clamps to that minimum instead of the generichudMinSpan. - Pump
_HudMetricValueforHudMetric.pointsCaptured(the longest label) inside a widget sized to exactlyminColSpanForLabel's column count — asserttester.takeException()isnull(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
minColSpanForLabelare 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 tohud_widget_layout.dart, verbatim.HudWidgetLayout.defaultForreworked into the left-to-right row-packing factory from the Design section, verbatim.draggable_resizable_hud_widget.dart's resize handleonPanEndnow clampssnappedColSpanup tominColSpanForLabel(layout.metric.label)(floor) and down tohudMaxColSpan(ceiling), instead of the generichudMinSpanfloor the grid-level clamp (clampedToGrid, still called downstream inHudLayoutController) applies to every other span._HudMetricValue.build()inrecord_screen.dartnow renders the title as a plain, unwrappedText(withmaxLines: 1/TextOverflow.ellipsisrestored as a safety net) at its fixed reference size, and wraps only the valueTextinFlexible(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
_reflowRowand this ticket'sminColSpanForLabel, but per this ticket's own "Out of scope" section it is deliberately left unfixed here —_reflowRowis 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.