Ниже — разбор алгоритма, который рисует аккуратную "плашку" под выделенным текстом, даже если текст переносится на несколько строк.
Пример кода в проекте сделан на 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
Комментарии (4)

aamonster
15.02.2026 23:18Сделайте ширину чуть больше (начинайте выделение чуть левее, а заканчивайте чуть правее – примерно на радиус закругления), а то плохо смотрится.

RodionGork
15.02.2026 23:18С одной стороны - круто, плюсую :)
с другой стороны такое ощущение немножко, знаете, машина голдберга для стрельбы из пушки по воробьям. (ну или для собственного развлечения программиста, что неплохо)
читатель пробегая текст "по диагонали" не будет особо разбираться как именно закругляли уголки. а уж если текст будет разбирать LLM как сейчас модно, чтобы представить "выжимку" то тем более :)
savostin
Немного SVG магии:
koptehe Автор
Да, согласен, что такие фильтры есть. И я даже думал сделать это через эффект gooey, или через шейдеры (где тоже gooey). Но для flutter нельзя напрямую использовать SVG-фильтры, а с шейдерами чуть запарно.
А еще есть с gooey есть проблема, что при малом радиусе скругления уголки могу "неправильно" отрисовываться. "Неправильно" - это как раз не перетекать, а создавать острые углы в месте стыка (каждый gooey "обнимает" свой текст) . Но тут я могу ошибаться