// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/// @docImport 'package:flutter/material.dart';
library;

import 'dart:collection';
import 'dart:math' as math show pi;
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart' show Brightness, clampDouble;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

import 'colors.dart';
import 'text_selection_toolbar_button.dart';
import 'theme.dart';

// The radius of the toolbar RRect shape.
// Value extracted from https://developer.apple.com/design/resources/.
const Radius _kToolbarBorderRadius = Radius.circular(8.0);

// Vertical distance between the tip of the arrow and the line of text the arrow
// is pointing to. The value used here is eyeballed.
const double _kToolbarContentDistance = 8.0;

// The size of the arrow pointing to the anchor. Eyeballed value.
const Size _kToolbarArrowSize = Size(14.0, 7.0);

// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
// screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0;

// The size and thickness of the chevron icon used for navigating between toolbar pages.
// Eyeballed values.
const double _kToolbarChevronSize = 10.0;
const double _kToolbarChevronThickness = 2.0;

// Color was measured from a screenshot of iOS 16.0.2
// TODO(LongCatIsLooong): https://github.com/flutter/flutter/issues/41507.
const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
  color: Color(0xFFF6F6F6),
  darkColor: Color(0xFF222222),
);

// Color was measured from a screenshot of iOS 16.0.2.
const CupertinoDynamicColor _kToolbarDividerColor = CupertinoDynamicColor.withBrightness(
  color: Color(0xFFD6D6D6),
  darkColor: Color(0xFF424242),
);

const CupertinoDynamicColor _kToolbarTextColor = CupertinoDynamicColor.withBrightness(
  color: CupertinoColors.black,
  darkColor: CupertinoColors.white,
);

const Duration _kToolbarTransitionDuration = Duration(milliseconds: 125);

/// The type for a Function that builds a toolbar's container with the given
/// child.
///
/// The anchor is provided in global coordinates.
///
/// See also:
///
///   * [CupertinoTextSelectionToolbar.toolbarBuilder], which is of this type.
///   * [TextSelectionToolbar.toolbarBuilder], which is similar, but for an
///     Material-style toolbar.
typedef CupertinoToolbarBuilder =
    Widget Function(BuildContext context, Offset anchorAbove, Offset anchorBelow, Widget child);

/// An iOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
/// text.
///
/// Tries to position itself above [anchorAbove], but if it doesn't fit, then
/// it positions itself below [anchorBelow].
///
/// If any children don't fit in the menu, an overflow menu will automatically
/// be created.
///
/// See also:
///
///  * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
///    platform.
///  * [TextSelectionToolbar], which is similar, but builds an Android-style
///    toolbar.
class CupertinoTextSelectionToolbar extends StatelessWidget {
  /// Creates an instance of CupertinoTextSelectionToolbar.
  const CupertinoTextSelectionToolbar({
    super.key,
    required this.anchorAbove,
    required this.anchorBelow,
    required this.children,
    this.toolbarBuilder = _defaultToolbarBuilder,
  }) : assert(children.length > 0);

  /// {@macro flutter.material.TextSelectionToolbar.anchorAbove}
  final Offset anchorAbove;

  /// {@macro flutter.material.TextSelectionToolbar.anchorBelow}
  final Offset anchorBelow;

  /// {@macro flutter.material.TextSelectionToolbar.children}
  ///
  /// See also:
  ///   * [CupertinoTextSelectionToolbarButton], which builds a default
  ///     Cupertino-style text selection toolbar text button.
  final List<Widget> children;

  /// {@macro flutter.material.TextSelectionToolbar.toolbarBuilder}
  ///
  /// The given anchor and isAbove can be used to position an arrow, as in the
  /// default Cupertino toolbar.
  final CupertinoToolbarBuilder toolbarBuilder;

  /// Minimal padding from all edges of the selection toolbar to all edges of the
  /// viewport.
  ///
  /// See also:
  ///
  ///  * [SpellCheckSuggestionsToolbar], which uses this same value for its
  ///    padding from the edges of the viewport.
  ///  * [TextSelectionToolbar], which uses this same value as well.
  static const double kToolbarScreenPadding = 8.0;

  // Builds a toolbar just like the default iOS toolbar, with the right color
  // background and a rounded cutout with an arrow.
  static Widget _defaultToolbarBuilder(
    BuildContext context,
    Offset anchorAbove,
    Offset anchorBelow,
    Widget child,
  ) {
    return _CupertinoTextSelectionToolbarShape(
      anchorAbove: anchorAbove,
      anchorBelow: anchorBelow,
      shadowColor: CupertinoTheme.brightnessOf(context) == Brightness.light
          ? CupertinoColors.black.withOpacity(0.2)
          : null,
      child: ColoredBox(color: _kToolbarBackgroundColor.resolveFrom(context), child: child),
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMediaQuery(context));
    final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context);

    final double paddingAbove = mediaQueryPadding.top + kToolbarScreenPadding;

    // The arrow, which points to the anchor, has some margin so it can't get
    // too close to the horizontal edges of the screen.
    final double leftMargin = _kArrowScreenPadding + mediaQueryPadding.left;
    final double rightMargin =
        MediaQuery.widthOf(context) - mediaQueryPadding.right - _kArrowScreenPadding;

    final anchorAboveAdjusted = Offset(
      clampDouble(anchorAbove.dx, leftMargin, rightMargin),
      anchorAbove.dy - _kToolbarContentDistance - paddingAbove,
    );
    final anchorBelowAdjusted = Offset(
      clampDouble(anchorBelow.dx, leftMargin, rightMargin),
      anchorBelow.dy + _kToolbarContentDistance - paddingAbove,
    );

    return Padding(
      padding: EdgeInsets.fromLTRB(
        kToolbarScreenPadding,
        paddingAbove,
        kToolbarScreenPadding,
        kToolbarScreenPadding,
      ),
      child: CustomSingleChildLayout(
        delegate: TextSelectionToolbarLayoutDelegate(
          anchorAbove: anchorAboveAdjusted,
          anchorBelow: anchorBelowAdjusted,
        ),
        child: _CupertinoTextSelectionToolbarContent(
          anchorAbove: anchorAboveAdjusted,
          anchorBelow: anchorBelowAdjusted,
          toolbarBuilder: toolbarBuilder,
          children: children,
        ),
      ),
    );
  }
}

// Clips the child so that it has the shape of the default iOS text selection
// toolbar, with rounded corners and an arrow pointing at the anchor.
//
// The anchor should be in global coordinates.
class _CupertinoTextSelectionToolbarShape extends SingleChildRenderObjectWidget {
  const _CupertinoTextSelectionToolbarShape({
    required Offset anchorAbove,
    required Offset anchorBelow,
    Color? shadowColor,
    super.child,
  }) : _anchorAbove = anchorAbove,
       _anchorBelow = anchorBelow,
       _shadowColor = shadowColor;

  final Offset _anchorAbove;
  final Offset _anchorBelow;
  final Color? _shadowColor;

  @override
  _RenderCupertinoTextSelectionToolbarShape createRenderObject(BuildContext context) =>
      _RenderCupertinoTextSelectionToolbarShape(_anchorAbove, _anchorBelow, _shadowColor, null);

  @override
  void updateRenderObject(
    BuildContext context,
    _RenderCupertinoTextSelectionToolbarShape renderObject,
  ) {
    renderObject
      ..anchorAbove = _anchorAbove
      ..anchorBelow = _anchorBelow
      ..shadowColor = _shadowColor;
  }
}

// Clips the child into the shape of the default iOS text selection toolbar.
//
// The shape is a rounded rectangle with a protruding arrow pointing at the
// given anchor in the direction indicated by isAbove.
//
// In order to allow the child to render itself independent of isAbove, its
// height is clipped on both the top and the bottom, leaving the arrow remaining
// on the necessary side.
class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox {
  _RenderCupertinoTextSelectionToolbarShape(
    this._anchorAbove,
    this._anchorBelow,
    this._shadowColor,
    super.child,
  );

  @override
  bool get isRepaintBoundary => true;

  Offset get anchorAbove => _anchorAbove;
  Offset _anchorAbove;
  set anchorAbove(Offset value) {
    if (value == _anchorAbove) {
      return;
    }
    _anchorAbove = value;
    markNeedsLayout();
  }

  Offset get anchorBelow => _anchorBelow;
  Offset _anchorBelow;
  set anchorBelow(Offset value) {
    if (value == _anchorBelow) {
      return;
    }
    _anchorBelow = value;
    markNeedsLayout();
  }

  Color? get shadowColor => _shadowColor;
  Color? _shadowColor;
  set shadowColor(Color? value) {
    if (value == _shadowColor) {
      return;
    }
    _shadowColor = value;
    markNeedsPaint();
  }

  bool _isAbove(double childHeight) => anchorAbove.dy >= childHeight - _kToolbarArrowSize.height;

  BoxConstraints _constraintsForChild(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: _kToolbarArrowSize.width + _kToolbarBorderRadius.x * 2,
    ).enforce(constraints.loosen());
  }

  Offset _computeChildOffset(Size childSize) {
    return Offset(0.0, _isAbove(childSize.height) ? -_kToolbarArrowSize.height : 0.0);
  }

  @override
  double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) {
    final RenderBox? child = this.child;
    if (child == null) {
      return null;
    }
    final BoxConstraints enforcedConstraint = _constraintsForChild(constraints);
    final double? result = child.getDryBaseline(enforcedConstraint, baseline);
    return result == null
        ? null
        : result + _computeChildOffset(child.getDryLayout(enforcedConstraint)).dy;
  }

  @override
  void performLayout() {
    final RenderBox? child = this.child;
    if (child == null) {
      return;
    }

    child.layout(_constraintsForChild(constraints), parentUsesSize: true);
    // The buttons are padded on both top and bottom sufficiently to have
    // the arrow clipped out of it on either side. By
    // using this approach, the buttons don't need any special padding that
    // depends on isAbove.
    // The height of one arrow will be clipped off of the child, so adjust the
    // size and position to remove that piece from the layout.
    final childParentData = child.parentData! as BoxParentData;
    childParentData.offset = _computeChildOffset(child.size);
    size = Size(child.size.width, child.size.height - _kToolbarArrowSize.height);
  }

  // Returns the RRect inside which the child is painted.
  RRect _shapeRRect(RenderBox child) {
    final Rect rect =
        Offset(0.0, _kToolbarArrowSize.height) &
        Size(child.size.width, child.size.height - _kToolbarArrowSize.height * 2);
    return RRect.fromRectAndRadius(rect, _kToolbarBorderRadius).scaleRadii();
  }

  // Adds the given `rrect` to the current `path`, starting from the last point
  // in `path` and ends after the last corner of the rrect (closest corner to
  // `startAngle` in the counterclockwise direction), without closing the path.
  //
  // The `startAngle` argument must be a multiple of pi / 2, with 0 being the
  // positive half of the x-axis, and pi / 2 being the negative half of the
  // y-axis.
  //
  // For instance, if `startAngle` equals pi/2 then this method draws a line
  // segment to the bottom-left corner of `rrect` from the last point in `path`,
  // and follows the `rrect` path clockwise until the bottom-right corner is
  // added, then this method returns the mutated path without closing it.
  static Path _addRRectToPath(Path path, RRect rrect, {required double startAngle}) {
    const double halfPI = math.pi / 2;
    assert(startAngle % halfPI == 0.0);
    final Rect rect = rrect.outerRect;

    final rrectCorners = <(Offset, Radius)>[
      (rect.bottomRight, -rrect.brRadius),
      (rect.bottomLeft, Radius.elliptical(rrect.blRadiusX, -rrect.blRadiusY)),
      (rect.topLeft, rrect.tlRadius),
      (rect.topRight, Radius.elliptical(-rrect.trRadiusX, rrect.trRadiusY)),
    ];

    // Add the 4 corners to the path clockwise. Convert radians to quadrants
    // to avoid fp arithmetics. The order is br -> bl -> tl -> tr if the starting
    // angle is 0.
    final int startQuadrantIndex = startAngle ~/ halfPI;
    for (var i = startQuadrantIndex; i < rrectCorners.length + startQuadrantIndex; i += 1) {
      final (Offset vertex, Radius rectCenterOffset) = rrectCorners[i % rrectCorners.length];
      final otherVertex = Offset(
        vertex.dx + 2 * rectCenterOffset.x,
        vertex.dy + 2 * rectCenterOffset.y,
      );
      final rect = Rect.fromPoints(vertex, otherVertex);
      path.arcTo(rect, halfPI * i, halfPI, false);
    }
    return path;
  }

  // The path is described in the toolbar child's coordinate system.
  Path _clipPath(RenderBox child, RRect rrect) {
    final path = Path();
    // If there isn't enough width for the arrow + radii, ignore the arrow.
    // Because of the constraints we gave children in performLayout, this should
    // only happen if the parent isn't wide enough which should be very rare, and
    // when that happens the arrow won't be too useful anyways.
    if (_kToolbarBorderRadius.x * 2 + _kToolbarArrowSize.width > size.width) {
      return path..addRRect(rrect);
    }

    final bool isAbove = _isAbove(child.size.height);
    final Offset localAnchor = globalToLocal(isAbove ? _anchorAbove : _anchorBelow);
    final double arrowTipX = clampDouble(
      localAnchor.dx,
      _kToolbarBorderRadius.x + _kToolbarArrowSize.width / 2,
      size.width - _kToolbarArrowSize.width / 2 - _kToolbarBorderRadius.x,
    );

    // Draw the path clockwise, starting from the beginning side of the arrow.
    if (isAbove) {
      final double arrowBaseY = child.size.height - _kToolbarArrowSize.height;
      final double arrowTipY = child.size.height;
      path
        ..moveTo(
          arrowTipX + _kToolbarArrowSize.width / 2,
          arrowBaseY,
        ) // right side of the arrow triangle
        ..lineTo(arrowTipX, arrowTipY) // The tip of the arrow
        ..lineTo(
          arrowTipX - _kToolbarArrowSize.width / 2,
          arrowBaseY,
        ); // left side of the arrow triangle
    } else {
      final double arrowBaseY = _kToolbarArrowSize.height;
      const arrowTipY = 0.0;
      path
        ..moveTo(
          arrowTipX - _kToolbarArrowSize.width / 2,
          arrowBaseY,
        ) // right side of the arrow triangle
        ..lineTo(arrowTipX, arrowTipY) // The tip of the arrow
        ..lineTo(
          arrowTipX + _kToolbarArrowSize.width / 2,
          arrowBaseY,
        ); // left side of the arrow triangle
    }
    final double startAngle = isAbove ? math.pi / 2 : -math.pi / 2;
    return _addRRectToPath(path, rrect, startAngle: startAngle)..close();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final RenderBox? child = this.child;
    if (child == null) {
      return;
    }

    final childParentData = child.parentData! as BoxParentData;

    final RRect rrect = _shapeRRect(child);
    final Path clipPath = _clipPath(child, rrect);

    // If configured, paint the shadow beneath the shape.
    if (_shadowColor != null) {
      final boxShadow = BoxShadow(color: _shadowColor!, blurRadius: 15.0);
      final RRect shadowRRect = RRect.fromLTRBR(
        rrect.left,
        rrect.top,
        rrect.right,
        rrect.bottom + _kToolbarArrowSize.height,
        _kToolbarBorderRadius,
      ).shift(offset + childParentData.offset + boxShadow.offset);
      context.canvas.drawRRect(shadowRRect, boxShadow.toPaint());
    }

    _clipPathLayer.layer = context.pushClipPath(
      needsCompositing,
      offset + childParentData.offset,
      Offset.zero & child.size,
      clipPath,
      (PaintingContext innerContext, Offset innerOffset) =>
          innerContext.paintChild(child, innerOffset),
      oldLayer: _clipPathLayer.layer,
    );
  }

  final LayerHandle<ClipPathLayer> _clipPathLayer = LayerHandle<ClipPathLayer>();
  Paint? _debugPaint;

  @override
  void dispose() {
    _clipPathLayer.layer = null;
    super.dispose();
  }

  @override
  void debugPaintSize(PaintingContext context, Offset offset) {
    assert(() {
      final RenderBox? child = this.child;
      if (child == null) {
        return true;
      }

      final ui.Paint debugPaint = _debugPaint ??= Paint()
        ..shader = ui.Gradient.linear(
          Offset.zero,
          const Offset(10.0, 10.0),
          const <Color>[
            CupertinoColors.transparent,
            Color(0xFFFF00FF),
            Color(0xFFFF00FF),
            CupertinoColors.transparent,
          ],
          const <double>[0.25, 0.25, 0.75, 0.75],
          TileMode.repeated,
        )
        ..strokeWidth = 2.0
        ..style = PaintingStyle.stroke;

      final childParentData = child.parentData! as BoxParentData;
      final Path clipPath = _clipPath(child, _shapeRRect(child));
      context.canvas.drawPath(clipPath.shift(offset + childParentData.offset), debugPaint);
      return true;
    }());
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    final RenderBox? child = this.child;
    if (child == null) {
      return false;
    }

    // Positions outside of the clipped area of the child are not counted as
    // hits.
    final childParentData = child.parentData! as BoxParentData;
    final hitBox = Rect.fromLTWH(
      childParentData.offset.dx,
      childParentData.offset.dy + _kToolbarArrowSize.height,
      child.size.width,
      child.size.height - _kToolbarArrowSize.height * 2,
    );
    if (!hitBox.contains(position)) {
      return false;
    }

    return super.hitTestChildren(result, position: position);
  }
}

// A toolbar containing the given children. If they overflow the width
// available, then the menu will be paginated with the overflowing children
// displayed on subsequent pages.
//
// The anchor should be in global coordinates.
class _CupertinoTextSelectionToolbarContent extends StatefulWidget {
  const _CupertinoTextSelectionToolbarContent({
    required this.anchorAbove,
    required this.anchorBelow,
    required this.toolbarBuilder,
    required this.children,
  }) : assert(children.length > 0);

  final Offset anchorAbove;
  final Offset anchorBelow;
  final List<Widget> children;
  final CupertinoToolbarBuilder toolbarBuilder;

  @override
  _CupertinoTextSelectionToolbarContentState createState() =>
      _CupertinoTextSelectionToolbarContentState();
}

class _CupertinoTextSelectionToolbarContentState
    extends State<_CupertinoTextSelectionToolbarContent>
    with TickerProviderStateMixin {
  // Controls the fading of the buttons within the menu during page transitions.
  late AnimationController _controller;
  int? _nextPage;
  int _page = 0;

  final GlobalKey _toolbarItemsKey = GlobalKey();

  void _onHorizontalDragEnd(DragEndDetails details) {
    final double? velocity = details.primaryVelocity;

    if (velocity != null && velocity != 0) {
      if (velocity > 0) {
        _handlePreviousPage();
      } else {
        _handleNextPage();
      }
    }
  }

  void _handleNextPage() {
    final renderToolbar = _toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems && renderToolbar.hasNextPage) {
      _controller.reverse();
      _controller.addStatusListener(_statusListener);
      _nextPage = _page + 1;
    }
  }

  void _handlePreviousPage() {
    final renderToolbar = _toolbarItemsKey.currentContext?.findRenderObject() as RenderBox?;

    if (renderToolbar is _RenderCupertinoTextSelectionToolbarItems &&
        renderToolbar.hasPreviousPage) {
      _controller.reverse();
      _controller.addStatusListener(_statusListener);
      _nextPage = _page - 1;
    }
  }

  void _statusListener(AnimationStatus status) {
    if (!status.isDismissed) {
      return;
    }

    setState(() {
      _page = _nextPage!;
      _nextPage = null;
    });
    _controller.forward();
    _controller.removeStatusListener(_statusListener);
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      value: 1.0,
      vsync: this,
      // This was eyeballed on a physical iOS device running iOS 13.
      duration: _kToolbarTransitionDuration,
    );
  }

  @override
  void didUpdateWidget(_CupertinoTextSelectionToolbarContent oldWidget) {
    super.didUpdateWidget(oldWidget);
    // If the children are changing, the current page should be reset.
    if (widget.children != oldWidget.children) {
      _page = 0;
      _nextPage = null;
      _controller.forward();
      _controller.removeStatusListener(_statusListener);
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final Color chevronColor = _kToolbarTextColor.resolveFrom(context);

    // Wrap the children and the chevron painters in Center with widthFactor
    // and heightFactor of 1.0 so _CupertinoTextSelectionToolbarItems can get
    // the natural size of the buttons and then expand vertically as needed.
    final Widget backButton = Center(
      widthFactor: 1.0,
      heightFactor: 1.0,
      child: CupertinoTextSelectionToolbarButton(
        onPressed: _handlePreviousPage,
        child: IgnorePointer(
          child: CustomPaint(
            painter: _LeftCupertinoChevronPainter(color: chevronColor),
            size: const Size.square(_kToolbarChevronSize),
          ),
        ),
      ),
    );
    final Widget nextButton = Center(
      widthFactor: 1.0,
      heightFactor: 1.0,
      child: CupertinoTextSelectionToolbarButton(
        onPressed: _handleNextPage,
        child: IgnorePointer(
          child: CustomPaint(
            painter: _RightCupertinoChevronPainter(color: chevronColor),
            size: const Size.square(_kToolbarChevronSize),
          ),
        ),
      ),
    );
    final List<Widget> children = widget.children.map((Widget child) {
      return Center(widthFactor: 1.0, heightFactor: 1.0, child: child);
    }).toList();

    return widget.toolbarBuilder(
      context,
      widget.anchorAbove,
      widget.anchorBelow,
      FadeTransition(
        opacity: _controller,
        child: AnimatedSize(
          duration: _kToolbarTransitionDuration,
          curve: Curves.decelerate,
          child: GestureDetector(
            onHorizontalDragEnd: _onHorizontalDragEnd,
            child: _CupertinoTextSelectionToolbarItems(
              key: _toolbarItemsKey,
              page: _page,
              backButton: backButton,
              dividerColor: _kToolbarDividerColor.resolveFrom(context),
              dividerWidth: 1.0 / MediaQuery.devicePixelRatioOf(context),
              nextButton: nextButton,
              children: children,
            ),
          ),
        ),
      ),
    );
  }
}

// These classes help to test the chevrons. As _CupertinoChevronPainter must be
// private, it's possible to check the runtimeType of each chevron to know if
// they should be pointing left or right.
class _LeftCupertinoChevronPainter extends _CupertinoChevronPainter {
  _LeftCupertinoChevronPainter({required super.color}) : super(isLeft: true);
}

class _RightCupertinoChevronPainter extends _CupertinoChevronPainter {
  _RightCupertinoChevronPainter({required super.color}) : super(isLeft: false);
}

abstract class _CupertinoChevronPainter extends CustomPainter {
  _CupertinoChevronPainter({required this.color, required this.isLeft});

  final Color color;

  /// If this is true the chevron will point left, else it will point right.
  final bool isLeft;

  @override
  void paint(Canvas canvas, Size size) {
    assert(size.height == size.width, 'size must have the same height and width: $size');

    final double iconSize = size.height;

    // The chevron is half of a square rotated 45˚, so it needs a margin of 1/4
    // its size on each side to be centered horizontally.
    //
    // If pointing left, it means the left half of a square is being used and
    // the offset is positive. If pointing right, the right half is being used
    // and the offset is negative.
    final centerOffset = Offset(iconSize / 4 * (isLeft ? 1 : -1), 0);

    final Offset firstPoint = Offset(iconSize / 2, 0) + centerOffset;
    final Offset middlePoint = Offset(isLeft ? 0 : iconSize, iconSize / 2) + centerOffset;
    final Offset lowerPoint = Offset(iconSize / 2, iconSize) + centerOffset;

    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = _kToolbarChevronThickness
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round;

    // `drawLine` is used here because it's testable. When using `drawPath`,
    // there's no way to test that the chevron points to the correct side.
    canvas.drawLine(firstPoint, middlePoint, paint);
    canvas.drawLine(middlePoint, lowerPoint, paint);
  }

  @override
  bool shouldRepaint(_CupertinoChevronPainter oldDelegate) =>
      oldDelegate.color != color || oldDelegate.isLeft != isLeft;
}

// The custom RenderObjectWidget that, together with
// _RenderCupertinoTextSelectionToolbarItems and
// _CupertinoTextSelectionToolbarItemsElement, paginates the menu items.
class _CupertinoTextSelectionToolbarItems extends RenderObjectWidget {
  _CupertinoTextSelectionToolbarItems({
    super.key,
    required this.page,
    required this.children,
    required this.backButton,
    required this.dividerColor,
    required this.dividerWidth,
    required this.nextButton,
  }) : assert(children.isNotEmpty);

  final Widget backButton;
  final List<Widget> children;
  final Color dividerColor;
  final double dividerWidth;
  final Widget nextButton;
  final int page;

  @override
  _RenderCupertinoTextSelectionToolbarItems createRenderObject(BuildContext context) {
    return _RenderCupertinoTextSelectionToolbarItems(
      dividerColor: dividerColor,
      dividerWidth: dividerWidth,
      page: page,
    );
  }

  @override
  void updateRenderObject(
    BuildContext context,
    _RenderCupertinoTextSelectionToolbarItems renderObject,
  ) {
    renderObject
      ..page = page
      ..dividerColor = dividerColor
      ..dividerWidth = dividerWidth;
  }

  @override
  _CupertinoTextSelectionToolbarItemsElement createElement() =>
      _CupertinoTextSelectionToolbarItemsElement(this);
}

// The custom RenderObjectElement that helps paginate the menu items.
class _CupertinoTextSelectionToolbarItemsElement extends RenderObjectElement {
  _CupertinoTextSelectionToolbarItemsElement(_CupertinoTextSelectionToolbarItems super.widget);

  late List<Element> _children;
  final Map<_CupertinoTextSelectionToolbarItemsSlot, Element> slotToChild =
      <_CupertinoTextSelectionToolbarItemsSlot, Element>{};

  // We keep a set of forgotten children to avoid O(n^2) work walking _children
  // repeatedly to remove children.
  final Set<Element> _forgottenChildren = HashSet<Element>();

  @override
  _RenderCupertinoTextSelectionToolbarItems get renderObject =>
      super.renderObject as _RenderCupertinoTextSelectionToolbarItems;

  void _updateRenderObject(RenderBox? child, _CupertinoTextSelectionToolbarItemsSlot slot) {
    switch (slot) {
      case _CupertinoTextSelectionToolbarItemsSlot.backButton:
        renderObject.backButton = child;
      case _CupertinoTextSelectionToolbarItemsSlot.nextButton:
        renderObject.nextButton = child;
    }
  }

  @override
  void insertRenderObjectChild(RenderObject child, Object? slot) {
    if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
      assert(child is RenderBox);
      _updateRenderObject(child as RenderBox, slot);
      assert(renderObject.slottedChildren.containsKey(slot));
      return;
    }
    if (slot is IndexedSlot) {
      assert(renderObject.debugValidateChild(child));
      renderObject.insert(child as RenderBox, after: slot.value?.renderObject as RenderBox?);
      return;
    }
    assert(false, 'slot must be _CupertinoTextSelectionToolbarItemsSlot or IndexedSlot');
  }

  // This is not reachable for children that don't have an IndexedSlot.
  @override
  void moveRenderObjectChild(
    RenderObject child,
    IndexedSlot<Element> oldSlot,
    IndexedSlot<Element> newSlot,
  ) {
    assert(child.parent == renderObject);
    renderObject.move(child as RenderBox, after: newSlot.value.renderObject as RenderBox?);
  }

  static bool _shouldPaint(Element child) {
    return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint;
  }

  @override
  void removeRenderObjectChild(RenderObject child, Object? slot) {
    // Check if the child is in a slot.
    if (slot is _CupertinoTextSelectionToolbarItemsSlot) {
      assert(child is RenderBox);
      assert(renderObject.slottedChildren.containsKey(slot));
      _updateRenderObject(null, slot);
      assert(!renderObject.slottedChildren.containsKey(slot));
      return;
    }

    // Otherwise look for it in the list of children.
    assert(slot is IndexedSlot);
    assert(child.parent == renderObject);
    renderObject.remove(child as RenderBox);
  }

  @override
  void visitChildren(ElementVisitor visitor) {
    slotToChild.values.forEach(visitor);
    for (final Element child in _children) {
      if (!_forgottenChildren.contains(child)) {
        visitor(child);
      }
    }
  }

  @override
  void forgetChild(Element child) {
    assert(slotToChild.containsValue(child) || _children.contains(child));
    assert(!_forgottenChildren.contains(child));
    // Handle forgetting a child in children or in a slot.
    if (slotToChild.containsKey(child.slot)) {
      final slot = child.slot! as _CupertinoTextSelectionToolbarItemsSlot;
      slotToChild.remove(slot);
    } else {
      _forgottenChildren.add(child);
    }
    super.forgetChild(child);
  }

  // Mount or update slotted child.
  void _mountChild(Widget widget, _CupertinoTextSelectionToolbarItemsSlot slot) {
    final Element? oldChild = slotToChild[slot];
    final Element? newChild = updateChild(oldChild, widget, slot);
    if (oldChild != null) {
      slotToChild.remove(slot);
    }
    if (newChild != null) {
      slotToChild[slot] = newChild;
    }
  }

  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    // Mount slotted children.
    final toolbarItems = widget as _CupertinoTextSelectionToolbarItems;
    _mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
    _mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);

    // Mount list children.
    Element? previousChild;
    _children = List<Element>.generate(toolbarItems.children.length, (int i) {
      final Element result = inflateWidget(
        toolbarItems.children[i],
        IndexedSlot<Element?>(i, previousChild),
      );
      previousChild = result;
      return result;
    }, growable: false);
  }

  @override
  void debugVisitOnstageChildren(ElementVisitor visitor) {
    // Visit slot children.
    for (final Element child in slotToChild.values) {
      if (_shouldPaint(child) && !_forgottenChildren.contains(child)) {
        visitor(child);
      }
    }
    // Visit list children.
    _children
        .where((Element child) => !_forgottenChildren.contains(child) && _shouldPaint(child))
        .forEach(visitor);
  }

  @override
  void update(_CupertinoTextSelectionToolbarItems newWidget) {
    super.update(newWidget);
    assert(widget == newWidget);

    // Update slotted children.
    final toolbarItems = widget as _CupertinoTextSelectionToolbarItems;
    _mountChild(toolbarItems.backButton, _CupertinoTextSelectionToolbarItemsSlot.backButton);
    _mountChild(toolbarItems.nextButton, _CupertinoTextSelectionToolbarItemsSlot.nextButton);

    // Update list children.
    _children = updateChildren(
      _children,
      toolbarItems.children,
      forgottenChildren: _forgottenChildren,
    );
    _forgottenChildren.clear();
  }
}

// The custom RenderBox that helps paginate the menu items.
class _RenderCupertinoTextSelectionToolbarItems extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, ToolbarItemsParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, ToolbarItemsParentData> {
  _RenderCupertinoTextSelectionToolbarItems({
    required Color dividerColor,
    required double dividerWidth,
    required int page,
  }) : _dividerColor = dividerColor,
       _dividerWidth = dividerWidth,
       _page = page,
       super();

  final Map<_CupertinoTextSelectionToolbarItemsSlot, RenderBox> slottedChildren =
      <_CupertinoTextSelectionToolbarItemsSlot, RenderBox>{};

  late bool hasNextPage;
  late bool hasPreviousPage;

  RenderBox? _updateChild(
    RenderBox? oldChild,
    RenderBox? newChild,
    _CupertinoTextSelectionToolbarItemsSlot slot,
  ) {
    if (oldChild != null) {
      dropChild(oldChild);
      slottedChildren.remove(slot);
    }
    if (newChild != null) {
      slottedChildren[slot] = newChild;
      adoptChild(newChild);
    }
    return newChild;
  }

  int _page;
  int get page => _page;
  set page(int value) {
    if (value == _page) {
      return;
    }
    _page = value;
    markNeedsLayout();
  }

  Color _dividerColor;
  Color get dividerColor => _dividerColor;
  set dividerColor(Color value) {
    if (value == _dividerColor) {
      return;
    }
    _dividerColor = value;
    markNeedsLayout();
  }

  double _dividerWidth;
  double get dividerWidth => _dividerWidth;
  set dividerWidth(double value) {
    if (value == _dividerWidth) {
      return;
    }
    _dividerWidth = value;
    markNeedsLayout();
  }

  RenderBox? _backButton;
  RenderBox? get backButton => _backButton;
  set backButton(RenderBox? value) {
    _backButton = _updateChild(
      _backButton,
      value,
      _CupertinoTextSelectionToolbarItemsSlot.backButton,
    );
  }

  RenderBox? _nextButton;
  RenderBox? get nextButton => _nextButton;
  set nextButton(RenderBox? value) {
    _nextButton = _updateChild(
      _nextButton,
      value,
      _CupertinoTextSelectionToolbarItemsSlot.nextButton,
    );
  }

  @override
  void performLayout() {
    if (firstChild == null) {
      size = constraints.smallest;
      return;
    }

    // First pass: determine the height of the tallest child.
    var greatestHeight = 0.0;
    visitChildren((RenderObject renderObjectChild) {
      final child = renderObjectChild as RenderBox;
      final double childHeight = child.getMaxIntrinsicHeight(constraints.maxWidth);
      if (childHeight > greatestHeight) {
        greatestHeight = childHeight;
      }
    });

    // Layout slotted children.
    final slottedConstraints = BoxConstraints(
      maxWidth: constraints.maxWidth,
      minHeight: greatestHeight,
      maxHeight: greatestHeight,
    );
    _backButton!.layout(slottedConstraints, parentUsesSize: true);
    _nextButton!.layout(slottedConstraints, parentUsesSize: true);

    final double subsequentPageButtonsWidth = _backButton!.size.width + _nextButton!.size.width;
    var currentButtonPosition = 0.0;
    late double toolbarWidth; // The width of the whole widget.
    var currentPage = 0;
    var i = -1;
    visitChildren((RenderObject renderObjectChild) {
      i++;
      final child = renderObjectChild as RenderBox;
      final childParentData = child.parentData! as ToolbarItemsParentData;
      childParentData.shouldPaint = false;

      // Skip slotted children and children on pages after the visible page.
      if (child == _backButton || child == _nextButton || currentPage > _page) {
        return;
      }

      // If this is the last child on the first page, it's ok to fit without a forward button.
      // Note childCount doesn't include slotted children which come before the list ones.
      double paginationButtonsWidth = currentPage == 0
          ? i == childCount + 1
                ? 0.0
                : _nextButton!.size.width
          : subsequentPageButtonsWidth;

      // The width of the menu is set by the first page.
      child.layout(
        BoxConstraints(
          maxWidth: constraints.maxWidth - paginationButtonsWidth,
          minHeight: greatestHeight,
          maxHeight: greatestHeight,
        ),
        parentUsesSize: true,
      );

      // If this child causes the current page to overflow, move to the next
      // page and relayout the child.
      final double currentWidth = currentButtonPosition + paginationButtonsWidth + child.size.width;
      if (currentWidth > constraints.maxWidth) {
        currentPage++;
        currentButtonPosition = _backButton!.size.width + dividerWidth;
        paginationButtonsWidth = _backButton!.size.width + _nextButton!.size.width;
        child.layout(
          BoxConstraints(
            maxWidth: constraints.maxWidth - paginationButtonsWidth,
            minHeight: greatestHeight,
            maxHeight: greatestHeight,
          ),
          parentUsesSize: true,
        );
      }
      childParentData.offset = Offset(currentButtonPosition, 0.0);
      currentButtonPosition += child.size.width + dividerWidth;
      childParentData.shouldPaint = currentPage == page;

      if (currentPage == page) {
        toolbarWidth = currentButtonPosition;
      }
    });

    // It shouldn't be possible to navigate beyond the last page.
    assert(page <= currentPage);

    // Position page nav buttons.
    if (currentPage > 0) {
      final nextButtonParentData = _nextButton!.parentData! as ToolbarItemsParentData;
      final backButtonParentData = _backButton!.parentData! as ToolbarItemsParentData;
      // The forward button only shows when there's a page after this one.
      if (page != currentPage) {
        nextButtonParentData.offset = Offset(toolbarWidth, 0.0);
        nextButtonParentData.shouldPaint = true;
        toolbarWidth += nextButton!.size.width;
      }
      if (page > 0) {
        backButtonParentData.offset = Offset.zero;
        backButtonParentData.shouldPaint = true;
        // No need to add the width of the back button to toolbarWidth here. It's
        // already been taken care of when laying out the children to
        // accommodate the back button.
      }
    } else {
      // No divider for the next button when there's only one page.
      toolbarWidth -= dividerWidth;
    }

    // Update previous/next page values so that we can check in the horizontal
    // drag gesture callback if it's possible to navigate.
    hasNextPage = page != currentPage;
    hasPreviousPage = page > 0;

    size = constraints.constrain(Size(toolbarWidth, greatestHeight));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    visitChildren((RenderObject renderObjectChild) {
      final child = renderObjectChild as RenderBox;
      final childParentData = child.parentData! as ToolbarItemsParentData;

      if (childParentData.shouldPaint) {
        final Offset childOffset = childParentData.offset + offset;
        context.paintChild(child, childOffset);

        // backButton is a slotted child and is not in the children list, so its
        // childParentData.nextSibling is null. So either when there's a
        // nextSibling or when child is the backButton, draw a divider to the
        // child's right.
        if (childParentData.nextSibling != null || child == backButton) {
          context.canvas.drawLine(
            Offset(child.size.width, 0) + childOffset,
            Offset(child.size.width, child.size.height) + childOffset,
            Paint()..color = dividerColor,
          );
        }
      }
    });
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! ToolbarItemsParentData) {
      child.parentData = ToolbarItemsParentData();
    }
  }

  // Returns true if the single child is hit by the given position.
  static bool hitTestChild(RenderBox? child, BoxHitTestResult result, {required Offset position}) {
    if (child == null) {
      return false;
    }
    final childParentData = child.parentData! as ToolbarItemsParentData;
    if (!childParentData.shouldPaint) {
      return false;
    }
    return result.addWithPaintOffset(
      offset: childParentData.offset,
      position: position,
      hitTest: (BoxHitTestResult result, Offset transformed) {
        assert(transformed == position - childParentData.offset);
        return child.hitTest(result, position: transformed);
      },
    );
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    // Hit test list children.
    RenderBox? child = lastChild;
    while (child != null) {
      final childParentData = child.parentData! as ToolbarItemsParentData;

      // Don't hit test children that aren't shown.
      if (!childParentData.shouldPaint) {
        child = childParentData.previousSibling;
        continue;
      }

      if (hitTestChild(child, result, position: position)) {
        return true;
      }
      child = childParentData.previousSibling;
    }

    // Hit test slot children.
    if (hitTestChild(backButton, result, position: position)) {
      return true;
    }
    if (hitTestChild(nextButton, result, position: position)) {
      return true;
    }

    return false;
  }

  @override
  void attach(PipelineOwner owner) {
    // Attach list children.
    super.attach(owner);

    // Attach slot children.
    for (final RenderBox child in slottedChildren.values) {
      child.attach(owner);
    }
  }

  @override
  void detach() {
    // Detach list children.
    super.detach();

    // Detach slot children.
    for (final RenderBox child in slottedChildren.values) {
      child.detach();
    }
  }

  @override
  void redepthChildren() {
    visitChildren((RenderObject renderObjectChild) {
      final child = renderObjectChild as RenderBox;
      redepthChild(child);
    });
  }

  @override
  void visitChildren(RenderObjectVisitor visitor) {
    // Visit the slotted children.
    if (_backButton != null) {
      visitor(_backButton!);
    }
    if (_nextButton != null) {
      visitor(_nextButton!);
    }
    // Visit the list children.
    super.visitChildren(visitor);
  }

  // Visit only the children that should be painted.
  @override
  void visitChildrenForSemantics(RenderObjectVisitor visitor) {
    visitChildren((RenderObject renderObjectChild) {
      final child = renderObjectChild as RenderBox;
      final childParentData = child.parentData! as ToolbarItemsParentData;
      if (childParentData.shouldPaint) {
        visitor(renderObjectChild);
      }
    });
  }

  @override
  List<DiagnosticsNode> debugDescribeChildren() {
    final value = <DiagnosticsNode>[];
    visitChildren((RenderObject renderObjectChild) {
      final child = renderObjectChild as RenderBox;
      if (child == backButton) {
        value.add(child.toDiagnosticsNode(name: 'back button'));
      } else if (child == nextButton) {
        value.add(child.toDiagnosticsNode(name: 'next button'));

        // List children.
      } else {
        value.add(child.toDiagnosticsNode(name: 'menu item'));
      }
    });
    return value;
  }
}

// The slots that can be occupied by widgets in
// _CupertinoTextSelectionToolbarItems, excluding the list of children.
enum _CupertinoTextSelectionToolbarItemsSlot { backButton, nextButton }
