diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 82c2b844..34759aa3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1440,15 +1440,10 @@ underneath them (OKLab dE 6.2 for `new` against the no-coverage red, 7.6 for sat on. The constant body is dE 25.7 from the nearest coverage colour. - **Single repeater**: a `#22303A` body in every state and every colour-vision - palette, an 8 px state-coloured bar down the left clipped to the rounded - rect, a 1.5 px state line just outside the body and a 1 px `#0d1114` - hairline outside that. The body is inset 2.5 px (1.5 + 1) so the edge is - added INWARD and the footprint is unchanged. Width follows the id length - (`<= 2` chars: 24, else `10 + len * 7`, plus 8 for the bar), and the label is - centred in the space RIGHT of the bar, not in the whole box. A newly - discovered repeater is taller (28 vs 24), with a larger label and a wider - glow. Corner radius still encodes hop-byte width (4 / 6 / 8), which is this - app's own signal and has no web counterpart. + palette, a 3 px status-coloured border around the whole pill and a 1 px + `#0d1114` hairline outside that. Both borders are drawn inward, so the + footprint remains 28 px tall. Width follows the id length (`26 + len * 8`), + the label is centred, and all statuses use the same capsule geometry. - **Group marker**: a `#22303A` disc of radius 19 with the count across it, a 2.5 px ring at radius 17.75 in the DOMINANT state's colour, a 1 px hairline at 19.5, and one uniform 2.6 px dot per state PRESENT, pitch 4.2, centred at diff --git a/lib/utils/repeater_marker_painter.dart b/lib/utils/repeater_marker_painter.dart index 65c550d6..55b6f5c4 100644 --- a/lib/utils/repeater_marker_painter.dart +++ b/lib/utils/repeater_marker_painter.dart @@ -10,89 +10,52 @@ import 'repeater_marker_style.dart'; /// only puts paint on a canvas. MapLibre plumbing (encoding these to PNG and /// registering them by name) stays in the map widget. -/// Margin left around every baked chip so the state-coloured glow has room. -/// Uniform across states, so a chip's body stays centred in its bitmap however -/// wide its glow is and the symbol's centre anchor lands on the body's centre. -const double repeaterChipGlowMargin = RepeaterMarkerStyle.chipGlowNew + 2; +/// Transparent margin around every baked chip for antialiased outer corners. +const double repeaterChipCanvasMargin = 2; /// Paints one repeater chip into [outer] and returns the body rect, so a /// caller that also draws a label knows where the label may go. /// -/// The body is neutral in every state. The state appears only in the left bar, -/// the line just outside the body and the glow behind it, which is what keeps -/// the marker from competing with the coverage carpet drawn underneath. See -/// [RepeaterMarkerStyle] for the measurements behind that. +/// The body is neutral in every state. The state appears only in the border, +/// which keeps the marker from competing with the coverage carpet drawn +/// underneath. See [RepeaterMarkerStyle] for the measurements behind that. Rect paintRepeaterChip( Canvas canvas, Rect outer, Color accent, - double bodyRadius, { + double cornerRadius, { required bool isNew, }) { - final body = outer.deflate(RepeaterMarkerStyle.bodyInset); - final bodyRRect = - RRect.fromRectAndRadius(body, Radius.circular(bodyRadius)); - - // Glow in the state colour, behind everything. A new repeater's is wide - // enough to read as emphasis on its own. + // Draw nested fills rather than strokes so both borders stay entirely + // inside [outer] and remain crisp at every device scale. canvas.drawRRect( - bodyRRect, - Paint() - ..color = accent.withValues(alpha: isNew ? 0.85 : 0.55) - ..maskFilter = MaskFilter.blur( - BlurStyle.normal, - isNew ? RepeaterMarkerStyle.chipGlowNew : RepeaterMarkerStyle.chipGlow, - ), - ); - - // The neutral body. - canvas.drawRRect(bodyRRect, Paint()..color = RepeaterMarkerStyle.bodyColor); - - // State bar down the left edge, clipped to the body so it picks up the - // rounded corners instead of squaring them off. - canvas.save(); - canvas.clipRRect(bodyRRect); - canvas.drawRect( - Rect.fromLTWH( - body.left, body.top, RepeaterMarkerStyle.barWidth, body.height), - Paint()..color = accent, + RRect.fromRectAndRadius(outer, Radius.circular(cornerRadius)), + Paint()..color = RepeaterMarkerStyle.hairlineColor, ); - canvas.restore(); - // State line laid just outside the body... - const halfLine = RepeaterMarkerStyle.stateLineWidth / 2; + final accentRect = outer.deflate(RepeaterMarkerStyle.hairlineWidth); canvas.drawRRect( RRect.fromRectAndRadius( - body.inflate(halfLine), - Radius.circular(bodyRadius + halfLine), + accentRect, + Radius.circular(cornerRadius - RepeaterMarkerStyle.hairlineWidth), ), - Paint() - ..color = accent - ..style = PaintingStyle.stroke - ..strokeWidth = RepeaterMarkerStyle.stateLineWidth, + Paint()..color = accent, ); - // ...and the near-black hairline just outside that, whose outer edge lands - // exactly on [outer]. Two tones because no single border colour survives - // both a dark and a pale basemap. - const hairOffset = RepeaterMarkerStyle.stateLineWidth + - RepeaterMarkerStyle.hairlineWidth / 2; + final body = outer.deflate(RepeaterMarkerStyle.bodyInset); canvas.drawRRect( RRect.fromRectAndRadius( - body.inflate(hairOffset), - Radius.circular(bodyRadius + hairOffset), + body, + Radius.circular(cornerRadius - RepeaterMarkerStyle.bodyInset), ), - Paint() - ..color = RepeaterMarkerStyle.hairlineColor - ..style = PaintingStyle.stroke - ..strokeWidth = RepeaterMarkerStyle.hairlineWidth, + Paint()..color = RepeaterMarkerStyle.bodyColor, ); return body; } -/// Lays out a chip's hex label. Bold, sized by state, and inked from the body -/// rather than hardcoded white. See [RepeaterMarkerStyle.labelInkFor]. +/// Lays out a chip's bold hex label, inked from the body rather than hardcoded +/// white. See [RepeaterMarkerStyle.labelInkFor]. TextPainter repeaterChipLabelPainter(String hex, {required bool isNew}) => TextPainter( text: TextSpan( @@ -108,18 +71,16 @@ TextPainter repeaterChipLabelPainter(String hex, {required bool isNew}) => textDirection: TextDirection.ltr, )..layout(); -/// Paints a chip's hex label centred in the space right of the state bar. +/// Paints a chip's hex label centred in the neutral body. void paintRepeaterChipLabel( Canvas canvas, Rect body, TextPainter textPainter, ) { - final labelLeft = body.left + RepeaterMarkerStyle.barWidth; - final labelWidth = body.width - RepeaterMarkerStyle.barWidth; textPainter.paint( canvas, Offset( - labelLeft + (labelWidth - textPainter.width) / 2, + body.left + (body.width - textPainter.width) / 2, body.top + (body.height - textPainter.height) / 2, ), ); diff --git a/lib/utils/repeater_marker_style.dart b/lib/utils/repeater_marker_style.dart index 31e80239..b71de202 100644 --- a/lib/utils/repeater_marker_style.dart +++ b/lib/utils/repeater_marker_style.dart @@ -11,7 +11,7 @@ import 'ping_colors.dart'; /// **The one rule underneath the design: the marker's large area is neutral /// and the state rides its edge.** The body is a constant [bodyColor] in every /// state and under every colour-vision palette; the state only ever appears in -/// the left bar, the state line and the group ring. That is not a stylistic +/// the pill border and the group ring. That is not a stylistic /// preference. The old marker fills were darker siblings of the coverage /// colours painted underneath them (measured on the web at OKLab dE 6.2 for /// `new` against the no-coverage red, 7.6 for `stale` against the dead-zone @@ -50,37 +50,28 @@ class RepeaterMarkerStyle { // ── Chip (single repeater) geometry, logical px ─────────────────────────── - /// Width of the state-coloured bar down the chip's left edge, clipped to the - /// body's rounded rect. - static const double barWidth = 8; + /// The state-coloured border around the neutral body. + static const double accentBorderWidth = 3; - /// The state line laid just outside the body. - static const double stateLineWidth = 1.5; - - /// The near-black hairline laid just outside the state line. + /// The near-black hairline laid just outside the accent border. static const double hairlineWidth = 1; - /// How far the body is inset from the marker's outer footprint, so the state - /// line and hairline are added *inward* and the footprint is unchanged. - static const double bodyInset = stateLineWidth + hairlineWidth; - - /// Chip height, and the taller variant used for a newly discovered repeater. - static const double chipHeight = 24; - static const double chipHeightNew = 28; + /// How far the body is inset from the marker's outer footprint, so both + /// borders are added *inward* and the footprint is unchanged. + static const double bodyInset = accentBorderWidth + hairlineWidth; - /// Uniform body corner radius for every repeater label length. - static const double chipCornerRadius = 8; + /// Every repeater pill has the same height, regardless of status. + static const double chipHeight = 28; + static const double chipHeightNew = chipHeight; - /// Label size, and the larger variant for a newly discovered repeater. - static const double chipFontSize = 12; - static const double chipFontSizeNew = 13; + /// Capsule corner radius, uniform across label lengths and states. + static const double chipCornerRadius = chipHeight / 2; - /// Glow blur behind the chip, in the state colour. A new repeater gets a - /// noticeably wider one: that is the whole emphasis treatment. - static const double chipGlow = 2; - static const double chipGlowNew = 6; + /// Label size. Status changes the border colour, not the pill geometry. + static const double chipFontSize = 13; + static const double chipFontSizeNew = chipFontSize; - /// Padding either side of the label, inside the space right of the bar. + /// Padding either side of the centred label. static const double chipHorizontalPad = 8; // ── Badge (cluster) geometry, logical px ────────────────────────────────── @@ -137,10 +128,10 @@ class RepeaterMarkerStyle { /// Pro Max a 6-character chip measured 84 pt wide against the old 56, and /// the cluster badge 56 pt across against the old 40. /// - /// At 1.0 the badge is 40 pt across, the size it has always been, and a chip - /// is 24 pt tall against the old 28 while growing with the id instead of - /// being padded to a fixed width. Raising this again means re-measuring - /// against those numbers, not guessing. + /// At 1.0 the badge is 40 pt across, the size it has always been, and a pill + /// is 28 pt tall while growing with the id instead of being padded to a fixed + /// width. Raising this again means re-measuring against those numbers, not + /// guessing. /// /// `addImage` honours the screen scale on both platforms (iOS reads /// `UIScreen.main.scale`, Android the decoded bitmap's density), so a 2x @@ -151,33 +142,27 @@ class RepeaterMarkerStyle { /// Outer footprint of a chip carrying a [labelLength]-character hex. /// - /// The width rule is the spec's: a one- or two-character id gets a fixed 24, - /// anything longer grows with the label, and the state bar's 8 px is added - /// on top of either. [measuredLabelWidth] only ever widens the result, for a + /// The width grows with the label while preserving the capsule proportions + /// in the reference. [measuredLabelWidth] only ever widens the result, for a /// font that renders wider than the rule assumed. /// - /// This is the OUTER box. The body is [bodyInset] inside it, so the state - /// line and hairline are added inward and the footprint is unchanged. + /// This is the OUTER box. The body is [bodyInset] inside it, so the accent + /// border and hairline are added inward and the footprint is unchanged. static Size chipSize( int labelLength, { required bool isNew, double measuredLabelWidth = 0, }) { - final ruleWidth = - (labelLength <= 2 ? 24.0 : 10.0 + labelLength * 7.0) + barWidth; - final needed = - measuredLabelWidth + chipHorizontalPad * 2 + barWidth + bodyInset * 2; + final ruleWidth = 26.0 + labelLength * 8.0; + final needed = measuredLabelWidth + chipHorizontalPad * 2 + bodyInset * 2; return Size( math.max(ruleWidth, needed), isNew ? chipHeightNew : chipHeight, ); } - /// Width available for the label: the body, less the state bar. The label is - /// centred in THIS, not in the whole chip, or a short one drifts left and - /// sits on the bar. - static double chipLabelWidth(double outerWidth) => - outerWidth - bodyInset * 2 - barWidth; + /// Width available for the centred label inside the neutral body. + static double chipLabelWidth(double outerWidth) => outerWidth - bodyInset * 2; // ── Status registry ─────────────────────────────────────────────────────── @@ -398,7 +383,7 @@ enum RepeaterMarkerStatus { /// Online and adverting. active('active'), - /// Discovered recently. Drawn taller, with a larger label and glow. + /// Discovered recently. Uses the new-repeater accent on the same pill. fresh('new'), /// No advert inside the region's window. diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 297b27e9..71078ecd 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -70,7 +70,7 @@ class _MapImages { // Simplified-mode repeater CHIP bodies: status x hop_bytes. The chip body is // baked here and MapLibre places the hex on top as a shared-glyph text // label. Width follows the hop's hex length, so the label always has room in - // the space right of the state bar. + // the neutral body. // Names: rep_active_1, rep_dead_2, rep_dup_3, etc. static String repeater(String status, int hopBytes) => 'rep_${status}_$hopBytes'; @@ -235,20 +235,19 @@ Future _encodePicture( const double _repeaterCardMaxHeightFraction = 0.32; /// Height of the repeater chip's whole BOX in the detail sheet's header, -/// glow margin included, against the 44 the old solid pill occupied. +/// antialiasing margin included, against the 44 the old solid pill occupied. /// -/// The map bakes its chips at their final size, 24 logical px tall (28 for a -/// newly discovered one). That is right on a map and small beside a +/// The map bakes its chips at their final size, 28 logical px tall. That is +/// right on a map and small beside a /// `titleLarge` name, so the sheet draws the SAME chip scaled up rather than a -/// lookalike built from a `Container`. Scaling the canvas takes the bar, the -/// state line, the hairline, the corner radius and the label with it, so the -/// proportions the design rests on survive exactly. +/// lookalike built from a `Container`. Scaling the canvas takes both borders, +/// the corner radius and the label with it, so the proportions survive. /// -/// It measures the BOX, not the body, because [repeaterChipGlowMargin] is -/// transparent padding the chip needs but the header cannot spend. Sizing the -/// body to 40 instead put the box at 67 tall and up to 127 wide for a -/// six-character id, against the old badge's 44 by roughly 70, and the name -/// beside it had nowhere left to go. +/// It measures the BOX, not the body, because [repeaterChipCanvasMargin] is +/// transparent antialiasing padding the chip needs but the header cannot +/// spend. Sizing the body to 40 instead put the box at 67 tall and up to 127 +/// wide for a six-character id, against the old badge's 44 by roughly 70, and +/// the name beside it had nowhere left to go. /// /// 44 is the old badge's own footprint, so swapping the pill for this chip /// cannot reflow the header, and it lands the label at about 13 px, which is @@ -257,12 +256,9 @@ const double _repeaterCardMaxHeightFraction = 0.32; /// change, and everything else scales with it. const double _sheetChipBoxHeight = 44; -/// The scale every header chip is drawn at. Derived from the ORDINARY chip, so -/// it is one number for all of them and a new repeater stays proportionally -/// taller here exactly as it is on the map, rather than being squashed back to -/// a common height and losing the signal. +/// The scale every header chip is drawn at. All statuses share this geometry. const double _sheetChipScale = _sheetChipBoxHeight / - (RepeaterMarkerStyle.chipHeight + repeaterChipGlowMargin * 2); + (RepeaterMarkerStyle.chipHeight + repeaterChipCanvasMargin * 2); /// Draws one repeater chip in the map's marker style, at [_sheetChipScale]. /// @@ -289,11 +285,10 @@ class _RepeaterChip extends StatelessWidget { final label = repeaterChipLabelPainter(hex, isNew: isNew); final chip = RepeaterMarkerStyle.chipSize(hex.length, isNew: isNew, measuredLabelWidth: label.width); - // The glow is drawn outside the chip's own box, so the widget reserves the - // same margin the baked bitmaps do or it would be clipped at the edges. + // Reserve the same antialiasing margin as the baked map bitmap. final logical = Size( - chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2, + chip.width + repeaterChipCanvasMargin * 2, + chip.height + repeaterChipCanvasMargin * 2, ); const scale = _sheetChipScale; return SizedBox( @@ -335,8 +330,12 @@ class _RepeaterChipPainter extends CustomPainter { canvas.scale(scale); final body = paintRepeaterChip( canvas, - Rect.fromLTWH(repeaterChipGlowMargin, repeaterChipGlowMargin, chip.width, - chip.height), + Rect.fromLTWH( + repeaterChipCanvasMargin, + repeaterChipCanvasMargin, + chip.width, + chip.height, + ), accent, bodyRadius, isNew: isNew, @@ -353,7 +352,7 @@ class _RepeaterChipPainter extends CustomPainter { old.label.text?.toPlainText() != label.text?.toPlainText(); } -/// Bakes a complete repeater chip (body, state bar, edge and the hex label) +/// Bakes a complete repeater chip (body, accent border and the hex label) /// into a single PNG, so the label is part of the icon and can never detach /// onto a neighbouring chip's box (the MapLibre symbol two-pass "all icons, /// then all glyphs" overlap bug). Used ONLY in Detailed grid mode, where @@ -370,8 +369,8 @@ Future _renderRepeaterChipPng( final chip = RepeaterMarkerStyle.chipSize(hex.length, isNew: isNew, measuredLabelWidth: textPainter.width); final logical = Size( - chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2, + chip.width + repeaterChipCanvasMargin * 2, + chip.height + repeaterChipCanvasMargin * 2, ); final recorder = ui.PictureRecorder(); @@ -380,8 +379,12 @@ Future _renderRepeaterChipPng( final body = paintRepeaterChip( canvas, - Rect.fromLTWH(repeaterChipGlowMargin, repeaterChipGlowMargin, chip.width, - chip.height), + Rect.fromLTWH( + repeaterChipCanvasMargin, + repeaterChipCanvasMargin, + chip.width, + chip.height, + ), accent, borderRadius, isNew: isNew, @@ -4192,8 +4195,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { await _addCoverageOverlay(appState); } - /// The ACCENT colour for a repeater status keyword: the left bar, the state - /// line, the cluster ring. Never a fill. See [RepeaterMarkerStyle]. + /// The ACCENT colour for a repeater status keyword: the pill border and the + /// cluster ring. Never a fill. See [RepeaterMarkerStyle]. /// /// One registry, one lookup. A keyword with no entry draws in the active /// colour and logs a single warning, because a silent wrong colour is worse @@ -4227,7 +4230,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Generates: /// - 15 repeater chip bodies (5 states × 3 hop_byte widths). Width follows /// the hop's hex length so the shared-glyph label always has room right - /// of the state bar; a `new` chip is taller than the rest. + /// inside the pill. /// - 5 cluster badge discs, one per dominant state. /// - 8 coverage marker bitmaps for the user's currently-selected style. /// - 6 GPS marker bitmaps (one per style). @@ -4260,8 +4263,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { stage = 'rendering ${_MapImages.repeater(status.wireKey, hopBytes)}'; final bytes = await _renderPainterToPng( painter, - Size(chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2), + Size(chip.width + repeaterChipCanvasMargin * 2, + chip.height + repeaterChipCanvasMargin * 2), devicePixelRatio: RepeaterMarkerStyle.bakeDevicePixelRatio, ); stage = 'adding ${_MapImages.repeater(status.wireKey, hopBytes)}'; @@ -4622,13 +4625,9 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // so there is nothing left to tint. Both modes render at the same // iconScale, so a Grid Mode switch no longer changes marker size. // - // The Simplified label is nudged right by half the state bar, because it is - // centred in the space RIGHT of the bar rather than in the whole chip. - // text-offset is in ems, so that is barWidth / 2 over the font size. The - // halo is the body colour, not black: it only exists to keep a glyph - // legible if it overhangs the body, and a black one would smear the chip. - const labelNudgeEm = - RepeaterMarkerStyle.barWidth / 2 / RepeaterMarkerStyle.chipFontSize; + // The label is centred in the pill. The halo is the body colour, not + // black: it only exists to keep a glyph legible if it overhangs the body, + // and a black one would smear the chip. final labelInk = _colorToHex( RepeaterMarkerStyle.labelInkFor(RepeaterMarkerStyle.bodyColor)); final SymbolLayerProperties repeaterSymbolProps = clustered @@ -4641,8 +4640,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { textColor: labelInk, textHaloColor: _colorToHex(RepeaterMarkerStyle.bodyColor), textHaloWidth: 1, - // A newly discovered repeater gets the taller chip and the larger - // label. Data-driven, so one layer still covers every state. + // Kept data-driven so status-specific geometry can be restored + // without splitting this shared layer. textSize: [ 'case', [ @@ -4653,7 +4652,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { RepeaterMarkerStyle.chipFontSizeNew, RepeaterMarkerStyle.chipFontSize, ], - textOffset: const [labelNudgeEm, 0], + textOffset: const [0, 0], textAllowOverlap: true, textIgnorePlacement: true, textFont: _defaultFontStack, @@ -7766,14 +7765,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Widget _repeaterChipSwatch(Color accent) => Container( width: 30, height: 18, - alignment: Alignment.centerLeft, - clipBehavior: Clip.antiAlias, + padding: const EdgeInsets.all(1), decoration: BoxDecoration( - color: RepeaterMarkerStyle.bodyColor, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: accent, width: 1.5), + color: RepeaterMarkerStyle.hairlineColor, + borderRadius: BorderRadius.circular(9), + ), + child: Container( + decoration: BoxDecoration( + color: RepeaterMarkerStyle.bodyColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent, width: 2), + ), ), - child: Container(width: 5, color: accent), ); /// One repeater-state row in the legend. @@ -11802,10 +11805,10 @@ class _RepeaterShapePainter extends CustomPainter { paintRepeaterChip( canvas, Rect.fromLTWH( - repeaterChipGlowMargin, - repeaterChipGlowMargin, - size.width - repeaterChipGlowMargin * 2, - size.height - repeaterChipGlowMargin * 2, + repeaterChipCanvasMargin, + repeaterChipCanvasMargin, + size.width - repeaterChipCanvasMargin * 2, + size.height - repeaterChipCanvasMargin * 2, ), accent, borderRadius, diff --git a/test/utils/repeater_marker_painter_test.dart b/test/utils/repeater_marker_painter_test.dart index d317199b..59629ac2 100644 --- a/test/utils/repeater_marker_painter_test.dart +++ b/test/utils/repeater_marker_painter_test.dart @@ -49,8 +49,8 @@ Future<_Raster> _render( return _Raster(data!, size.width.round(), size.height.round()); } -/// Channel-wise closeness, so antialiasing and the glow bleeding underneath -/// don't make a correct drawing fail. +/// Channel-wise closeness, so edge antialiasing doesn't make a correct drawing +/// fail. Matcher _isColor(Color expected, {int tolerance = 6}) => predicate((actual) { return (actual.r * 255 - expected.r * 255).abs() <= tolerance && @@ -78,8 +78,8 @@ void main() { const body = RepeaterMarkerStyle.bodyColor; const hairline = RepeaterMarkerStyle.hairlineColor; - group('the chip keeps the state on its edge', () { - const radius = 4.0; + group('the repeater pill', () { + const radius = RepeaterMarkerStyle.chipCornerRadius; late _Raster raster; late Rect outer; late Rect bodyRect; @@ -87,119 +87,64 @@ void main() { setUp(() async { final chip = RepeaterMarkerStyle.chipSize(4, isNew: false); final canvasSize = Size( - chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2, + chip.width + repeaterChipCanvasMargin * 2, + chip.height + repeaterChipCanvasMargin * 2, ); - outer = Rect.fromLTWH( - repeaterChipGlowMargin, repeaterChipGlowMargin, chip.width, chip.height); + outer = Rect.fromLTWH(repeaterChipCanvasMargin, + repeaterChipCanvasMargin, chip.width, chip.height); raster = await _render(canvasSize, (canvas) { - bodyRect = paintRepeaterChip(canvas, outer, accent, radius, - isNew: false); + bodyRect = + paintRepeaterChip(canvas, outer, accent, radius, isNew: false); }); }); test('the large area is the neutral body, NOT the state colour', () { - // Well right of the bar and well inside the edge: this is the area that - // sits over the coverage carpet, and it must never carry the state. - final x = (bodyRect.left + RepeaterMarkerStyle.barWidth + 6).round(); + final x = bodyRect.center.dx.round(); final y = bodyRect.center.dy.round(); expect(raster.at(x, y), _isColor(body)); expect(raster.at(bodyRect.right.round() - 3, y), _isColor(body)); }); - test('the left bar carries the state colour', () { - final x = (bodyRect.left + RepeaterMarkerStyle.barWidth / 2).round(); - expect(raster.at(x, bodyRect.center.dy.round()), _isColor(accent)); - }); - - test('the bar is exactly the specified width, not a wash over the body', - () { + test('the accent forms one continuous border around the body', () { + final x = bodyRect.center.dx.round(); final y = bodyRect.center.dy.round(); - // Walk right from the body's left edge and find where the state colour - // gives way to the body. That boundary is the bar's width. - var lastAccentX = bodyRect.left.floor(); - for (var x = bodyRect.left.floor(); x < bodyRect.right.floor(); x++) { - if (_nearer(raster.at(x, y), accent, body)) lastAccentX = x; - } - expect((lastAccentX + 1) - bodyRect.left, - closeTo(RepeaterMarkerStyle.barWidth, 1.0), - reason: 'the bar ran to x=$lastAccentX'); + expect(raster.at(bodyRect.left.floor() - 2, y), _isColor(accent)); + expect(raster.at(bodyRect.right.ceil() + 1, y), _isColor(accent)); + expect(raster.at(x, bodyRect.top.floor() - 2), _isColor(accent)); + expect(raster.at(x, bodyRect.bottom.ceil() + 1), _isColor(accent)); }); - test('the edge is two-tone: a state line inside a near-black hairline', () { + test('a near-black hairline sits outside the accent', () { final y = bodyRect.center.dy.round(); - // Left to right across the edge: hairline, then state line, then the - // bar. The state line and the bar are the same colour and meet on this - // side, so only the hairline can be probed for a distinct value. - final hairlineX = bodyRect.left.floor() - 2; - expect(raster.at(hairlineX, y), _isColor(hairline, tolerance: 12), - reason: 'expected the hairline at x=$hairlineX'); - - final stateLineX = bodyRect.left.floor() - 1; - expect(_nearer(raster.at(stateLineX, y), accent, hairline), isTrue, - reason: 'expected the state line at x=$stateLineX, ' - 'found ${raster.at(stateLineX, y)}'); - - // On the TOP edge the state line stands alone, with the body inside it - // and the hairline outside, which is the two-tone claim in full. - final x = bodyRect.center.dx.round(); - expect(raster.at(x, bodyRect.top.floor() - 2), - _isColor(hairline, tolerance: 12)); - expect(_nearer(raster.at(x, bodyRect.top.floor() - 1), accent, body), - isTrue); - expect(raster.at(x, bodyRect.top.floor() + 2), _isColor(body)); + expect( + raster.at(outer.left.floor(), y), _isColor(hairline, tolerance: 20)); + expect(raster.at(outer.right.ceil() - 1, y), + _isColor(hairline, tolerance: 20)); }); test('the edge is added inward, so the footprint is the outer box', () { final y = bodyRect.center.dy.round(); expect(bodyRect, outer.deflate(RepeaterMarkerStyle.bodyInset)); - // Just outside the footprint there is only glow, never opaque edge. - expect(raster.alphaAt((outer.left - 1).round(), y), lessThan(250)); + expect(raster.alphaAt((outer.left - 1).round(), y), 0); }); - test('a new repeater is taller and glows further', () async { + test('status does not change the pill geometry', () { final normal = RepeaterMarkerStyle.chipSize(4, isNew: false); final fresh = RepeaterMarkerStyle.chipSize(4, isNew: true); - expect(fresh.height, greaterThan(normal.height)); - - // Same probe point outside both bodies: the wider glow puts more of the - // state colour there. - Future glowAlpha({required bool isNew}) async { - final chip = RepeaterMarkerStyle.chipSize(4, isNew: isNew); - final size = Size(chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2); - final r = await _render(size, (canvas) { - paintRepeaterChip( - canvas, - Rect.fromLTWH(repeaterChipGlowMargin, repeaterChipGlowMargin, - chip.width, chip.height), - accent, - radius, - isNew: isNew, - ); - }); - return r.alphaAt(2, (size.height / 2).round()); - } - - final freshGlow = await glowAlpha(isNew: true); - final normalGlow = await glowAlpha(isNew: false); - expect(freshGlow, greaterThan(normalGlow), - reason: 'new glow $freshGlow vs normal $normalGlow'); - expect(freshGlow, greaterThan(0), reason: 'the new chip has no glow'); + expect(fresh, normal); }); - test('the label is centred right of the bar, not in the whole chip', - () async { + test('the label is centred in the pill', () async { final painter = repeaterChipLabelPainter('4E9A', isNew: false); final chip = RepeaterMarkerStyle.chipSize(4, isNew: false, measuredLabelWidth: painter.width); - final size = Size(chip.width + repeaterChipGlowMargin * 2, - chip.height + repeaterChipGlowMargin * 2); + final size = Size(chip.width + repeaterChipCanvasMargin * 2, + chip.height + repeaterChipCanvasMargin * 2); late Rect drawnBody; final r = await _render(size, (canvas) { drawnBody = paintRepeaterChip( canvas, - Rect.fromLTWH(repeaterChipGlowMargin, repeaterChipGlowMargin, + Rect.fromLTWH(repeaterChipCanvasMargin, repeaterChipCanvasMargin, chip.width, chip.height), accent, radius, @@ -208,13 +153,7 @@ void main() { paintRepeaterChipLabel(canvas, drawnBody, painter); }); - // Ink appears on both sides of the label area's centre line, and the - // label area's centre is right of the whole chip's centre by half a bar. - final labelCenter = - drawnBody.left + RepeaterMarkerStyle.barWidth + - (drawnBody.width - RepeaterMarkerStyle.barWidth) / 2; - expect(labelCenter - drawnBody.center.dx, - closeTo(RepeaterMarkerStyle.barWidth / 2, 0.001)); + final labelCenter = drawnBody.center.dx; int inkColumns(int fromX, int toX) { var count = 0; @@ -232,15 +171,12 @@ void main() { return count; } - final leftHalf = inkColumns( - (drawnBody.left + RepeaterMarkerStyle.barWidth).round(), - labelCenter.round()); + final leftHalf = inkColumns(drawnBody.left.round(), labelCenter.round()); final rightHalf = inkColumns(labelCenter.round(), drawnBody.right.round()); expect(leftHalf, greaterThan(0)); expect(rightHalf, greaterThan(0)); - // Roughly balanced around the label area's centre, which is what - // "centred in the space right of the bar" means. + // Roughly balanced around the pill centre. expect((leftHalf - rightHalf).abs(), lessThanOrEqualTo(3), reason: 'label ink is lopsided: $leftHalf left, $rightHalf right'); }); diff --git a/test/utils/repeater_marker_style_test.dart b/test/utils/repeater_marker_style_test.dart index cfaa99a3..8edee097 100644 --- a/test/utils/repeater_marker_style_test.dart +++ b/test/utils/repeater_marker_style_test.dart @@ -161,25 +161,18 @@ void main() { }); group('chip geometry', () { - test('a short id gets the fixed minimum, plus the bar', () { - expect(RepeaterMarkerStyle.chipSize(2, isNew: false).width, - 24 + RepeaterMarkerStyle.barWidth); - expect(RepeaterMarkerStyle.chipSize(1, isNew: false).width, - 24 + RepeaterMarkerStyle.barWidth); + test('width grows consistently with the id', () { + expect(RepeaterMarkerStyle.chipSize(1, isNew: false).width, 34); + expect(RepeaterMarkerStyle.chipSize(2, isNew: false).width, 42); + expect(RepeaterMarkerStyle.chipSize(4, isNew: false).width, 58); + expect(RepeaterMarkerStyle.chipSize(6, isNew: false).width, 74); }); - test('a longer id grows with the label, plus the bar', () { - expect(RepeaterMarkerStyle.chipSize(4, isNew: false).width, - 10 + 4 * 7 + RepeaterMarkerStyle.barWidth); - expect(RepeaterMarkerStyle.chipSize(6, isNew: false).width, - 10 + 6 * 7 + RepeaterMarkerStyle.barWidth); - }); - - test('a new repeater gets the taller chip', () { + test('status does not change the pill height', () { expect(RepeaterMarkerStyle.chipSize(4, isNew: false).height, RepeaterMarkerStyle.chipHeight); expect(RepeaterMarkerStyle.chipSize(4, isNew: true).height, - RepeaterMarkerStyle.chipHeightNew); + RepeaterMarkerStyle.chipHeight); }); test('a measured label only ever widens the chip, never narrows it', () { @@ -194,7 +187,7 @@ void main() { greaterThan(rule)); }); - test('the label always has room right of the bar', () { + test('the label always has room inside the body', () { for (final length in [2, 4, 6]) { final width = RepeaterMarkerStyle.chipSize(length, isNew: false).width; // Roughly 7 px a character is what the width rule assumes, and the @@ -205,22 +198,18 @@ void main() { } }); - test('rendered sizes stay in the range the old markers occupied', () { - // Measured on an iPhone 16 Pro Max after the redesign first shipped: a - // 6-character chip came out 84 pt wide and the cluster badge 56 pt - // across, against 56 pt and 40 pt before it. The scale was the culprit. - // These bounds are what "too big" looked like, so they stay pinned. + test('rendered sizes stay compact beside the coverage grid', () { double rendered(double logical) => logical * RepeaterMarkerStyle.iconScale; final widest = rendered(RepeaterMarkerStyle.chipSize(6, isNew: false).width); - expect(widest, lessThanOrEqualTo(64.0), - reason: 'a 6-character chip is $widest pt wide; the old one was 56'); + expect(widest, lessThanOrEqualTo(78.0), + reason: 'a 6-character pill is $widest pt wide'); final tallest = rendered(RepeaterMarkerStyle.chipSize(6, isNew: true).height); expect(tallest, lessThanOrEqualTo(30.0), - reason: 'a new-repeater chip is $tallest pt tall; the old one was 28'); + reason: 'a repeater pill is $tallest pt tall'); final badgeAcross = rendered( (RepeaterMarkerStyle.badgeHairlineRadius + @@ -231,10 +220,12 @@ void main() { }); test('the edge is added inward, so the footprint is the spec width', () { - // bodyInset is exactly the state line plus the hairline: the two-tone + // bodyInset is exactly the accent border plus the hairline: the two-tone // edge costs nothing outside the box it is drawn in. - expect(RepeaterMarkerStyle.bodyInset, - RepeaterMarkerStyle.stateLineWidth + RepeaterMarkerStyle.hairlineWidth); + expect( + RepeaterMarkerStyle.bodyInset, + RepeaterMarkerStyle.accentBorderWidth + + RepeaterMarkerStyle.hairlineWidth); }); });