FB-09: size default HUD widgets by title, not a fixed uniform cell

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).
This commit is contained in:
2026-08-25 00:28:40 -05:00
parent aff59bfcf7
commit 7b452b7b81
7 changed files with 382 additions and 65 deletions

View File

@@ -20,6 +20,17 @@ const int hudMaxRowSpan = 3;
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
/// merely touch do not count as overlapping.
/// 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;
}
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
@@ -113,26 +124,41 @@ class HudWidgetLayout {
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
/// before the rider customises anything, and a metric toggled on for the first time
/// (with no saved position) lands somewhere sane rather than stacked on another
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
/// many metrics fit in the first row, with the rest continuing below it. Purely a
/// function of the metric's own index -- these 8 fixed slots never overlap each
/// other by construction, so no runtime state is needed here.
/// widget. FB-09: rather than a fixed "always 4 per row, colSpan 1" assumption, this
/// packs metrics left-to-right with each one's own title-driven width
/// ([minColSpanForLabel]), wrapping to a new row when the current one would
/// overflow. Purely a function of the metric's own index/label -- these slots never
/// overlap each other by construction, so no runtime state is needed here.
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,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible.
visible: index < columns,
);
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,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the
// first four enum values are ordered to match, so only those start visible.
// Their exact column positions may no longer form four perfectly even
// columns once their individual widths differ -- that is the correct,
// intended result of sizing by title, not a bug (see FB-09's Outcome
// section).
visible: HudMetric.values.indexOf(metric) < 4,
);
}
col += span;
}
// Unreachable: metric is always one of HudMetric.values.
throw StateError('Unknown metric: $metric');
}
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't

View File

@@ -146,9 +146,18 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
});
},
onPanEnd: (_) {
// FB-09: 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 -- clamped here rather than left to the
// generic hudMinSpan floor in HudLayoutController.
final snappedColSpan =
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
.round();
(((layout.colSpan * _cellWidth + _resizeDelta.width) /
_cellWidth)
.round())
.clamp(
minColSpanForLabel(layout.metric.label),
hudMaxColSpan,
);
final snappedRowSpan =
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
_cellHeight)

View File

@@ -328,38 +328,41 @@ class _HudMetricValue extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
// scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
// FittedBox scales down from at small widget sizes; the ratio between label and
// value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
// enlarges past that reference size, so a widget resized to the grid's maximum
// span still shows the same reference text, just with more empty space around it
// -- not larger text filling the space.
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,
// FB-09: the title is a plain, unwrapped Text at its fixed reference size -- it
// must always fit at a readable size, never shrinking along with the value. Only
// the value is wrapped in its own FittedBox, inside a Flexible so it claims
// whatever vertical space is left after the fixed-size title rather than both
// competing for space inside one shared FittedBox as before. maxLines/overflow are
// restored on the title as a safety net in case a future label is ever added that
// minColSpanForLabel's thresholds under-estimate.
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),
),
),
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(
fontSize: 18 * scale,
fontWeight: FontWeight.bold,
color: _valueColor(colors),
),
),
],
),
),
],
);
}
}