Ниже — разбор алгоритма, который рисует аккуратную "плашку" под выделенным текстом, даже если текст переносится на несколько строк.
Пример кода в проекте сделан на Flutter, но сама идея не привязана к Dart.
Весь код и текст этой статьи можно найти тут на GitHub.

Какая цель?
Выделение должно идти по контуру текста, а не простым прямоугольником.
На стыках строк не должно быть ломаных "ступенек".
Углы должны быть скруглены, чтобы форма выглядела естественно.

1) Получаем геометрию выделяемого фрагмента
Сначала превращаем массив сегментов в единый TextSpan, отрисовываем его через TextPainter, и вычисляем диапазон символов для нужного сегмента.

final textPainter = TextPainter( text: TextSpan(children: inlineSpans), textDirection: textDirection, )..layout(maxWidth: maxWidth); int selectionStart = 0; for (int i = 0; i < segmentIndex; i++) { selectionStart += textSegments[i].length; } final int selectionEnd = selectionStart + textSegments[segmentIndex].length;
Для индексов работает простая формула:
- — i-й текстовый сегмент,
- — начало сегмента в общей строке,
- — конец сегмента.
Дальше используем getBoxesForSelection:
final selectionBoxes = textPainter.getBoxesForSelection( TextSelection(baseOffset: selectionStart, extentOffset: selectionEnd), );
Если боксы есть — конвертируем их в HighlightBounds.
Если нет (редкий крайний случай) — берем caret-позиции начала/конца и строим fallback-контур.
2) Нормализуем особый кейс с "уехавшим" первым боксом
Иногда первый бокс может оказаться правее, чем ожидается относительно следующей строки. В проекте это правится простым эвристическим правилом: разбиваем группу на две.

if (boundsGroup.length > 1 && boundsGroup[0].startX > (boundsGroup[1].endX - 10)) { normalizedBoundsGroups.add([boundsGroup[0]]); boundsGroup.removeAt(0); normalizedBoundsGroups.add(boundsGroup); }
Практически это убирает артефакты в переходах между строками.
10px - это простая эвристика. Добавляет визуально красоты
Рассматриваем мы тут только первую строчку, потому что только она может попасть в такую ситуацию. Ведь все последующие строки будут расположены так, что выровнены по левому краю. То есть нам нужно рассмотреть только вариант с первой и второй строкой.
3) Строим контур через матрицу точек
Идея: собрать все узловые точки боксов в "таблицу" координат и пройти ее по периметру по часовой стрелке. Это позволит а) идти по границе и б) замечать, когда происходят смещения "вправо" и "влево" и учитывать это

3.1) Уникальные оси X и Y
Берем все x и y из прямоугольников, оставляем уникальные и сортируем:
final uniqueXList = uniqueX.toList()..sort(); final uniqueYList = uniqueY.toList()..sort(); final List<List<Offset?>> matrix = List.generate( uniqueYList.length, (index) => List.generate(uniqueXList.length, (index) => null), );
3.2) Заполнение матрицы
Для каждой точки каждого бокса ищем индекс по x и y, после чего кладем ее в matrix[yIndex][xIndex].
Обход по часовой стрелке
Контур собирается так:
Верхняя грань: слева направо.
Правая грань: сверху вниз.
Нижняя грань: справа налево.
Левая грань: снизу вверх.
Формально:
где (T, R, B, L) — списки точек соответствующих сторон, а || — конкатенация.
Выравниваем переходы на боковых гранях
Когда соседние точки на правой/левой стороне имеют разный dx, вертикальный переход может получиться "косым".
Поэтому dy усредняется попарно:
Для правой стороны:
Для левой — зеркально:
Чистим лишние точки
Удаляем дубликаты.
Удаляем точки, лежащие на одной прямой:
если
, точка не нужна;
если
, точка не нужна.
После этого остаются в основном угловые вершины контура.
4) Скругляем углы через векторы

Для каждой вершины берем соседние точки
и
, считаем два единичных вектора:
Радиус ограничиваем сверху базовым значением и снизу геометрией отрезка:
Строим две точки рядом с углом:
В коде это выглядит так:
final prevVector = (prevPoint - point).normalized(); final nextVector = (nextPoint - point).normalized(); final radius = min(6.0, (nextPoint - point).length / 2); final pointCloseToNext = (nextVector * radius) + point; final pointCloseToPrev = (prevVector * radius) + point;
5) Определяем направление дуги через векторное произведение
Нужно понять, как рисовать arcToPoint: по или против часовой.
2D-векторное произведение (z-компонента):
Если знак положительный — поворот считаем "clockwise" (в терминах внутренней геометрии контура), иначе — обратный.
final vectorToCurrent = point - pointCloseToPrev; final vectorToNext = pointCloseToNext - pointCloseToPrev; final crossProduct = vectorToNext.cross(vectorToCurrent); final isClockwise = crossProduct > 0;
Важно: в экранных координатах Flutter ось \(Y\) направлена вниз, поэтому при передаче флага в
arcToPointв коде используется инверсия (clockwise: ... != true), чтобы визуально дуга закручивалась правильно.
6) Рисуем итоговый путь
После скругления получаем пары точек:
(точка_входа_в_угол, флаг_направления) и (точка_выхода_из_угла, null).
path.moveTo(roundedContourPoints.first.$1.dx, roundedContourPoints.first.$1.dy); drawArc(0); for (int i = 2; i < roundedContourPoints.length; i = i + 2) { path.lineTo(roundedContourPoints[i].$1.dx, roundedContourPoints[i].$1.dy); drawArc(i); } path.close(); canvas.drawPath(path, Paint()..color = highlightColor);
7) Текст рисуем поверх контура
Контур и текст складываются в Stack: сначала CustomPaint, затем RichText.
Stack( children: [ CustomPaint(...), IgnorePointer( ignoring: true, child: RichText(text: ...), ), ], )
Так мы получаем аккуратную цветную подложку и тот же текст сверху.
Короткий итог алгоритма
Из текста получаем
TextBox-прямоугольники выделяемого сегмента.Нормализуем особые случаи многострочных переходов.
По уникальным
x/yстроим матрицу и обходим ее по периметру по часовой стрелке.Чистим дубликаты и коллинеарные точки.
Скругляем углы через единичные векторы и ограниченный радиус.
Направление дуги определяем знаком векторного произведения.
Рисуем путь и накладываем текст сверху.
Что можно улучшить дальше
Разделить стили обычного и выделенного текста без расхождения метрик.
Кэшировать рассчитанный контур, чтобы не пересчитывать его на каждый
build.Улучшить объединение "особых" групп, чтобы сохранять больше семантики цельного блока.
По-другому обходить таблицу (возможно, совсем без таблицы)
Спасибо вам за прочтение статьи! Надеюсь, вы найдете ее полезной. А если будут какие-то замечания или улучшения, обязательно напишите. Если это будет кому-то полезно, можно будет сделать простой пакет в pub.dev
