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

@@ -1,6 +1,9 @@
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true.
/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
/// resizable only while [editing] is true. Drag/resize snap to whole grid cells on
/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields);
/// during the gesture itself the widget tracks the finger/handle continuously in
/// pixels for a smooth feel.
///
/// Deliberately does not attach any drag gesture at all when [editing] is false --
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
@@ -36,11 +39,13 @@ class DraggableResizableHudWidget extends StatefulWidget {
final bool editing;
final Widget child;
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
/// place that logic lives.
final void Function(double x, double y) onMoved;
final void Function(double width, double height) onResized;
/// Final snapped grid coordinates -- called exactly once, at gesture end, not on
/// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
/// see [_DraggableResizableHudWidgetState]). The caller
/// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
/// exactly one place that logic lives.
final void Function(int col, int row) onMoved;
final void Function(int colSpan, int rowSpan) onResized;
@override
State<DraggableResizableHudWidget> createState() =>
@@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget {
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false;
/// Pixels, uncommitted, live only during a move gesture -- painted on top of the
/// last-committed grid position so the drag tracks the finger smoothly, then reset
/// to zero the instant the gesture ends (whether or not the parent accepted the
/// resulting snapped position), which is what makes the widget visually "snap back"
/// on a rejected move with no separate accept/reject callback needed.
Offset _dragOffset = Offset.zero;
/// Pixels, uncommitted, live only during a resize gesture -- same idea as
/// [_dragOffset].
Size _resizeDelta = Size.zero;
double get _cellWidth => widget.areaSize.width / hudGridColumns;
double get _cellHeight => widget.areaSize.height / hudGridRows;
@override
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.x * widget.areaSize.width;
final top = layout.y * widget.areaSize.height;
final width = layout.width * widget.areaSize.width;
final height = layout.height * widget.areaSize.height;
final left = layout.col * _cellWidth + _dragOffset.dx;
final top = layout.row * _cellHeight + _dragOffset.dy;
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
Widget card = AnimatedScale(
scale: _grabbed ? 1.05 : 1.0,
@@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
setState(() => _grabbed = true);
},
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
setState(() => _dragOffset = details.offsetFromOrigin);
},
onLongPressEnd: (_) => setState(() => _grabbed = false),
onLongPressCancel: () => setState(() => _grabbed = false),
onLongPressEnd: (_) {
final snappedCol =
((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
final snappedRow =
((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
widget.onMoved(snappedCol, snappedRow);
setState(() {
_grabbed = false;
_dragOffset = Offset.zero;
});
},
onLongPressCancel: () => setState(() {
_grabbed = false;
_dragOffset = Offset.zero;
}),
child: card,
);
} else {
@@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
bottom: -8,
child: GestureDetector(
onPanUpdate: (details) {
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
setState(() {
_resizeDelta = Size(
_resizeDelta.width + details.delta.dx,
_resizeDelta.height + details.delta.dy,
);
});
},
onPanEnd: (_) {
final snappedColSpan =
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
.round();
final snappedRowSpan =
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
_cellHeight)
.round();
widget.onResized(snappedColSpan, snappedRowSpan);
setState(() => _resizeDelta = Size.zero);
},
child: Container(
key: const Key('hud-resize-handle'),

View File

@@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
layout: entry.value,
areaSize: areaSize,
editing: _editing,
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
onMoved: (col, row) => notifier.updatePosition(entry.key, col, row),
onResized: (colSpan, rowSpan) =>
notifier.updateSize(entry.key, colSpan, rowSpan),
child: widget.metricBuilder(context, entry.key),
),
if (_editing)