Every D-pad step on the TV home ends with a spotlight swap, and on a low-end box that frame ran over budget: 12 % of UI-thread CPU during navigation was paragraph layout, most of it shaping strings a second time to measure them before the render tree shaped them for real. - FittingTitleText no longer lays the title out to learn whether it fits: Text wraps and ellipsizes at maxLines, so the box can only overflow vertically, and one cached one-glyph line height settles that. Only a box shorter than maxLines lines still searches, now bracketed by the proportional shrink of the base layout and stopped at a 0.25 px tolerance instead of a fixed 12 bisections. - FittedMetadataLine and inlineRatingBadgeWidth measure through a small bounded cache keyed by text, style, scaler and direction, so separators, type labels, certifications, runtimes and years measure once per session. - MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their regex tokenisation per key; every card build and step re-asked them. Cold navigation on the Android 14 box (30 steps over three hubs): build 312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90 7.5-7.9 ms -> 6.8-7.2 ms.
71 lines
2.5 KiB
Dart
71 lines
2.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/widgets/fitting_title_text.dart';
|
|
|
|
const _style = TextStyle(fontSize: 40, letterSpacing: 0, wordSpacing: 0);
|
|
|
|
Future<double> _fittedSize(WidgetTester tester, String text, {required double width, required double height}) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Center(
|
|
child: SizedBox(
|
|
width: width,
|
|
height: height,
|
|
child: FittingTitleText(text, style: _style),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return tester.widget<Text>(find.text(text)).style!.fontSize!;
|
|
}
|
|
|
|
/// Whether [text] at [fontSize], wrapped to two lines like the widget does,
|
|
/// fits [height] — the widget's own definition of fitting.
|
|
bool _fits(String text, double fontSize, {required double width, required double height}) {
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: text,
|
|
style: _style.copyWith(fontSize: fontSize),
|
|
),
|
|
maxLines: 2,
|
|
ellipsis: '\u2026',
|
|
textDirection: TextDirection.ltr,
|
|
)..layout(maxWidth: width);
|
|
final fits = painter.height <= height + 0.1;
|
|
painter.dispose();
|
|
return fits;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('keeps the base size when two lines fit the box', (tester) async {
|
|
expect(await _fittedSize(tester, 'ABCDE', width: 400, height: 100), 40);
|
|
// Width never shrinks the title: a too-long line is ellipsized instead.
|
|
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 50, height: 100), 40);
|
|
});
|
|
|
|
testWidgets('a box shorter than two lines gets the largest size that still fits', (tester) async {
|
|
for (final (text, width, height) in [
|
|
('ABCDE FGHIJ', 50.0, 8.0), // one line beats two: 11 chars on one line at ~4.5 > two lines at 4
|
|
('ABCDE FGHIJ', 50.0, 20.0), // two lines at ~10, bounded by the line width
|
|
('The Lord of the Rings: The Return of the King', 300.0, 60.0),
|
|
]) {
|
|
final size = await _fittedSize(tester, text, width: width, height: height);
|
|
expect(size, lessThan(40), reason: '$text in ${width}x$height');
|
|
expect(
|
|
_fits(text, size, width: width, height: height),
|
|
isTrue,
|
|
reason: '$text at $size must fit',
|
|
);
|
|
expect(
|
|
_fits(text, size + 0.5, width: width, height: height),
|
|
isFalse,
|
|
reason: '$text at $size is not maximal',
|
|
);
|
|
}
|
|
});
|
|
|
|
testWidgets('never goes below the minimum size', (tester) async {
|
|
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 5, height: 0.5), 1);
|
|
});
|
|
}
|