Files
rippr/docs/feedback/FB-09-hud-title-driven-sizing.md

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:
    /// 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:
    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:
    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:
    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.

On-device verification, later pass (emulator available)

Confirmed live during a recording: long titles ("AVERAGE SPEED", "ELAPSED TIME") both render at full size, uncut and unellipsized, with "Average speed" correctly claiming a wider default column span than "Speed" or "Distance" per minColSpanForLabel. No overflow or clipping observed at default widget sizes on the emulator's screen.