Files
rippr/docs/feedback/FB-09-hud-title-driven-sizing.md
uhryniuk d9aa412e15 Write FB-06..FB-09 feedback tickets (round two)
Turns the newly-added issues in docs/FEEDBACK.md into four more
self-contained tickets: always-interactive map with a recenter
button, Route Planner's Null Island centering bug, HUD reflow-on-hide,
and title-driven HUD sizing. Sequenced FB-08 before FB-09 since both
rework the same HUD layout files.
2026-08-24 23:26:49 -05:00

11 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 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):

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).