Files
rippr/lib/src/hud/hud_widget_layout.dart
uhryniuk 2d118b46a2 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.
2026-08-24 15:53:00 -05:00

168 lines
5.9 KiB
Dart

/// FB-03: one telemetry widget's position, size, and visibility on the customizable
/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
/// for why: free-form fractional positioning had no concept of a sibling widget, so
/// toggling widgets on/off could stack one directly on top of another).
library;
import 'hud_metric.dart';
/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
/// assumption and gives ample vertical room for the 8-metric list.
const int hudGridColumns = 4;
const int hudGridRows = 8;
/// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to
/// the point its own number is unreadable, or grow to the point it swallows the whole
/// HUD.
const int hudMinSpan = 1;
const int hudMaxColSpan = 4;
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.
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
final bColEnd = b.col + b.colSpan;
final bRowEnd = b.row + b.rowSpan;
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
}
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.col,
required this.row,
required this.colSpan,
required this.rowSpan,
required this.visible,
});
final HudMetric metric;
/// Top-left cell, in grid coordinates (0-based).
final int col;
final int row;
/// How many cells wide/tall this widget spans.
final int colSpan;
final int rowSpan;
final bool visible;
HudWidgetLayout copyWith({
int? col,
int? row,
int? colSpan,
int? rowSpan,
bool? visible,
}) => HudWidgetLayout(
metric: metric,
col: col ?? this.col,
row: row ?? this.row,
colSpan: colSpan ?? this.colSpan,
rowSpan: rowSpan ?? this.rowSpan,
visible: visible ?? this.visible,
);
/// A *self-contained* bounds check with no awareness of siblings -- corrects a
/// drag/resize result that ended outside the grid back to the nearest valid
/// position/size, span first (so a resize that would push a widget off-grid shrinks
/// it back on-grid rather than silently repositioning it), then position.
HudWidgetLayout clampedToGrid() {
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
return HudWidgetLayout(
metric: metric,
col: clampedCol,
row: clampedRow,
colSpan: clampedColSpan,
rowSpan: clampedRowSpan,
visible: visible,
);
}
Map<String, dynamic> toJson() => {
'col': col,
'row': row,
'colSpan': colSpan,
'rowSpan': rowSpan,
'visible': visible,
};
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
/// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with
/// fields this version doesn't recognise should degrade to a sane default, not crash
/// Settings on launch.
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
try {
return HudWidgetLayout(
metric: metric,
col: (json['col'] as num).toInt(),
row: (json['row'] as num).toInt(),
colSpan: (json['colSpan'] as num).toInt(),
rowSpan: (json['rowSpan'] as num).toInt(),
visible: json['visible'] as bool,
).clampedToGrid();
} catch (_) {
return HudWidgetLayout.defaultFor(metric);
}
}
/// 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.
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,
);
}
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if
/// the grid is fully packed -- a stacked default is better than a crash or an
/// exception the rider can't do anything about.
static HudWidgetLayout nextFreeSlot(
HudMetric metric, {
required Map<HudMetric, HudWidgetLayout> occupied,
int colSpan = 1,
int rowSpan = 2,
}) {
HudWidgetLayout candidate(int col, int row) => HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: colSpan,
rowSpan: rowSpan,
visible: true,
);
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
final c = candidate(col, row);
final overlapsAny = occupied.values
.where((l) => l.visible && l.metric != metric)
.any((other) => hudRectsOverlap(c, other));
if (!overlapsAny) return c;
}
}
return candidate(0, 0);
}
}