FB-03: grid-snapped HUD widgets with auto-fit, centered text

Replace free-form fractional HUD widget positioning with a 4x8 int grid
(col/row/colSpan/rowSpan), reject drag/resize moves that would overlap
another visible widget, and re-place a re-enabled metric into the next
free grid slot instead of stacking it on an occupier. Drag/resize now
track a local pixel offset during the gesture and only snap/commit once
at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it
never overflows and stays centered at any grid size.
This commit is contained in:
2026-08-24 15:53:00 -05:00
parent 04127d7237
commit 2d118b46a2
10 changed files with 1306 additions and 198 deletions

View File

@@ -2,53 +2,69 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/hud/hud_metric.dart';
import 'package:rippr/src/hud/hud_widget_layout.dart';
/// UI-04: the JSON round-trip and clamp math are exercised directly with fixed inputs
/// -- no real gestures needed to test either, per the ticket's own Tests section.
/// FB-03: the JSON round-trip, grid-clamp math, overlap check, and free-slot search
/// are all exercised directly with fixed inputs -- no real gestures needed to test
/// any of these, per the ticket's own Tests section.
void main() {
group('JSON round trip', () {
test('encodes and decodes exactly', () {
const original = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.15,
col: 1,
row: 2,
colSpan: 3,
rowSpan: 2,
visible: true,
);
final decoded = HudWidgetLayout.fromJson(HudMetric.speed, original.toJson());
expect(decoded.x, original.x);
expect(decoded.y, original.y);
expect(decoded.width, original.width);
expect(decoded.height, original.height);
expect(decoded.col, original.col);
expect(decoded.row, original.row);
expect(decoded.colSpan, original.colSpan);
expect(decoded.rowSpan, original.rowSpan);
expect(decoded.visible, original.visible);
});
test('a malformed entry falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'x': 'not a number'});
final decoded = HudWidgetLayout.fromJson(HudMetric.distance, {'col': 'not a number'});
final default_ = HudWidgetLayout.defaultFor(HudMetric.distance);
expect(decoded.x, default_.x);
expect(decoded.width, default_.width);
expect(decoded.col, default_.col);
expect(decoded.colSpan, default_.colSpan);
});
test('a missing field falls back to the default rather than crashing', () {
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'x': 0.1, 'y': 0.1});
final decoded = HudWidgetLayout.fromJson(HudMetric.maxSpeed, {'col': 1, 'row': 1});
final default_ = HudWidgetLayout.defaultFor(HudMetric.maxSpeed);
expect(decoded.x, default_.x);
expect(decoded.col, default_.col);
});
test('an old-shaped (pre-grid) saved layout falls back to the default rather '
'than crashing', () {
final decoded = HudWidgetLayout.fromJson(
HudMetric.speed,
{'x': 0.1, 'y': 0.2, 'width': 0.3, 'height': 0.15, 'visible': true},
);
final default_ = HudWidgetLayout.defaultFor(HudMetric.speed);
expect(decoded.col, default_.col);
expect(decoded.row, default_.row);
expect(decoded.colSpan, default_.colSpan);
expect(decoded.rowSpan, default_.rowSpan);
});
});
group('defaultFor', () {
test('every metric gets a distinct default position', () {
final positions = HudMetric.values
.map(HudWidgetLayout.defaultFor)
.map((l) => '${l.x},${l.y}')
.toSet();
expect(positions.length, HudMetric.values.length,
reason: 'no two metrics should default to overlapping positions');
test('every metric gets a distinct, non-overlapping default rect', () {
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
for (var i = 0; i < layouts.length; i++) {
for (var j = i + 1; j < layouts.length; j++) {
expect(hudRectsOverlap(layouts[i], layouts[j]), isFalse,
reason: 'no two metrics should default to overlapping rects');
}
}
});
test('the first four metrics (the Map HUD mockup\'s fixed row: Speed/Avg Speed/'
@@ -64,65 +80,146 @@ void main() {
expect(HudWidgetLayout.defaultFor(HudMetric.elevationGain).visible, isFalse);
});
test('every default is already within the valid clamp bounds', () {
test('every default is already within the valid grid bounds', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(layout.clamped().x, layout.x);
expect(layout.clamped().y, layout.y);
expect(layout.clamped().width, layout.width);
expect(layout.clamped().height, layout.height);
expect(layout.clampedToGrid().col, layout.col);
expect(layout.clampedToGrid().row, layout.row);
expect(layout.clampedToGrid().colSpan, layout.colSpan);
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
}
});
});
group('clamped', () {
group('clampedToGrid', () {
const base = HudWidgetLayout(
metric: HudMetric.speed,
x: 0.5,
y: 0.5,
width: 0.3,
height: 0.15,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
visible: true,
);
test('a position dragged past the right/bottom edge is pulled back on-screen', () {
final result = base.copyWith(x: 1.5, y: 1.5).clamped();
expect(result.x, 1.0 - base.width);
expect(result.y, 1.0 - base.height);
test('a position dragged past the right/bottom edge is pulled back onto the '
'grid', () {
final result = base.copyWith(col: 20, row: 20).clampedToGrid();
expect(result.col, hudGridColumns - base.colSpan);
expect(result.row, hudGridRows - base.rowSpan);
});
test('a position dragged past the left/top edge is pulled back on-screen', () {
final result = base.copyWith(x: -0.5, y: -0.5).clamped();
expect(result.x, 0.0);
expect(result.y, 0.0);
test('a position dragged past the left/top edge is pulled back onto the grid', () {
final result = base.copyWith(col: -5, row: -5).clampedToGrid();
expect(result.col, 0);
expect(result.row, 0);
});
test('a resize below the legibility floor is corrected up to the minimum', () {
final result = base.copyWith(width: 0.01, height: 0.01).clamped();
expect(result.width, hudMinWidthFraction);
expect(result.height, hudMinHeightFraction);
final result = base.copyWith(colSpan: 0, rowSpan: 0).clampedToGrid();
expect(result.colSpan, hudMinSpan);
expect(result.rowSpan, hudMinSpan);
});
test('a resize above the sane ceiling is corrected down to the maximum', () {
final result = base.copyWith(width: 5.0, height: 5.0).clamped();
expect(result.width, hudMaxWidthFraction);
expect(result.height, hudMaxHeightFraction);
final result = base.copyWith(colSpan: 99, rowSpan: 99).clampedToGrid();
expect(result.colSpan, hudMaxColSpan);
expect(result.rowSpan, hudMaxRowSpan);
});
test('shrinking to fit happens before repositioning, so a widget resized past '
'the edge shrinks rather than silently relocates', () {
final result = base.copyWith(x: 0.9, width: 0.7).clamped();
expect(result.width, hudMaxWidthFraction);
// x itself is still clamped against the (now-bounded) width so the widget
// never sits partially off-screen either.
expect(result.x + result.width, lessThanOrEqualTo(1.0));
final result = base.copyWith(col: 3, colSpan: 4).clampedToGrid();
expect(result.colSpan, hudMaxColSpan);
// col itself is still clamped against the (now-bounded) span so the widget
// never sits even partially off the grid either.
expect(result.col + result.colSpan, lessThanOrEqualTo(hudGridColumns));
});
test('an already-valid layout is unchanged', () {
expect(base.clamped().x, base.x);
expect(base.clamped().y, base.y);
expect(base.clamped().width, base.width);
expect(base.clamped().height, base.height);
expect(base.clampedToGrid().col, base.col);
expect(base.clampedToGrid().row, base.row);
expect(base.clampedToGrid().colSpan, base.colSpan);
expect(base.clampedToGrid().rowSpan, base.rowSpan);
});
});
group('hudRectsOverlap', () {
const a = HudWidgetLayout(
metric: HudMetric.speed,
col: 1,
row: 1,
colSpan: 2,
rowSpan: 2,
visible: true,
);
test('two identical rects overlap', () {
expect(hudRectsOverlap(a, a), isTrue);
});
test('two rects sharing only an edge (touching, not overlapping) do not overlap', () {
final rightNeighbor = a.copyWith(col: a.col + a.colSpan);
expect(hudRectsOverlap(a, rightNeighbor), isFalse);
final belowNeighbor = a.copyWith(row: a.row + a.rowSpan);
expect(hudRectsOverlap(a, belowNeighbor), isFalse);
});
test('two disjoint rects do not overlap', () {
final far = a.copyWith(col: a.col + a.colSpan + 3, row: a.row + a.rowSpan + 3);
expect(hudRectsOverlap(a, far), isFalse);
});
test('a rect fully containing another overlaps', () {
const big = HudWidgetLayout(
metric: HudMetric.distance,
col: 0,
row: 0,
colSpan: 4,
rowSpan: 4,
visible: true,
);
expect(hudRectsOverlap(big, a), isTrue);
});
test('two partially-overlapping rects overlap', () {
final overlapping = a.copyWith(col: a.col + 1, row: a.row + 1);
expect(hudRectsOverlap(a, overlapping), isTrue);
});
});
group('nextFreeSlot', () {
test('returns a rect that does not collide with any occupied visible layout, '
'when the metric\'s own default position is taken', () {
final occupier = HudWidgetLayout.defaultFor(HudMetric.speed);
final occupied = {HudMetric.speed: occupier};
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: occupied,
colSpan: occupier.colSpan,
rowSpan: occupier.rowSpan,
);
expect(hudRectsOverlap(result, occupier), isFalse);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is empty', () {
final result = HudWidgetLayout.nextFreeSlot(HudMetric.speed, occupied: {});
expect(result.col, 0);
expect(result.row, 0);
});
test('returns the same rect defaultFor would have (or another valid one) when '
'occupied is all-hidden', () {
final hidden = HudWidgetLayout.defaultFor(HudMetric.speed).copyWith(visible: false);
final result = HudWidgetLayout.nextFreeSlot(
HudMetric.avgSpeed,
occupied: {HudMetric.speed: hidden},
);
expect(result.col, 0);
expect(result.row, 0);
});
});
}