diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 8a4f2635a1..cf8c9cb416 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.htmlrenderer.api.ui.HtmlMessageContent import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.limitMentionPillWidth import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.compose.EditorStyledText @@ -93,7 +94,11 @@ fun TimelineItemTextView( LocalTextStyle provides textStyle ) { val text = getTextWithResolvedMentions(content) - Box(modifier.semantics { contentDescription = content.plainText }) { + Box( + modifier + .semantics { contentDescription = content.plainText } + .limitMentionPillWidth(text) + ) { EditorStyledText( text = text, onLinkClickedListener = onLinkClick, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 937561e2a5..6d4c7ea710 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -15,12 +15,14 @@ import android.graphics.Typeface import android.text.TextPaint import android.text.TextUtils import android.text.style.ReplacementSpan +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import androidx.core.text.getSpans import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.wysiwyg.view.spans.CustomMentionSpan -import kotlin.math.roundToInt +import kotlin.math.ceil /** * A span that represents a mention (user, room, etc.) in text. @@ -38,8 +40,12 @@ class MentionSpan( private var endPadding: Int = 0 private var typeface: Typeface = Typeface.DEFAULT - private var measuredTextWidth = 0 - private var measuredTrailingWidth = 0 + /** + * The widest the pill is allowed to be, in pixels. A pill wider than this is truncated, so that + * [getSize] never reports a width the line can't accommodate: the layout would then be unable to + * move the pill to the next line and would leave it overflowing the end of the current one. + */ + private var maxWidth: Int = NO_MAX_WIDTH // The formatted display text, will be set by the formatter var displayText: CharSequence = "" @@ -88,6 +94,50 @@ class MentionSpan( this.trailingText = trailingText } + /** + * Sets the widest this pill may be. Pass [NO_MAX_WIDTH] to leave it unbounded. + */ + fun updateMaxWidth(maxWidthPx: Int) { + maxWidth = maxWidthPx + } + + /** + * Measures the pill with [paint], truncating the display text so the result never exceeds + * [maxWidth]. + * + * Both [getSize] and [draw] go through here: the layout reserves exactly what is painted. They + * can't share a cached measurement instead, because the platform skips [getSize] before [draw] + * for the last span of a line, and the same [MentionSpan] instance is reused across ranges and + * across layouts. + */ + private fun measure(paint: Paint): Measurements { + textPaint.set(paint) + textPaint.typeface = typeface + val trailing = trailingText?.let { " $it" } + val trailingWidth = trailing?.let { textPaint.measureText(it) } ?: 0f + val availableForText = if (maxWidth == NO_MAX_WIDTH) { + Float.MAX_VALUE + } else { + (maxWidth - startPadding - endPadding - trailingWidth).coerceAtLeast(0f) + } + val fullTextWidth = textPaint.measureText(displayText, 0, displayText.length) + return if (fullTextWidth <= availableForText) { + Measurements(displayText, fullTextWidth, trailing, trailingWidth) + } else { + val truncated = TextUtils.ellipsize(displayText, textPaint, availableForText, TextUtils.TruncateAt.END) + Measurements(truncated, textPaint.measureText(truncated, 0, truncated.length), trailing, trailingWidth) + } + } + + private inner class Measurements( + val text: CharSequence, + val textWidth: Float, + val trailing: String?, + val trailingWidth: Float, + ) { + val width: Float get() = textWidth + trailingWidth + startPadding + endPadding + } + override fun getSize( paint: Paint, text: CharSequence?, @@ -95,12 +145,7 @@ class MentionSpan( end: Int, fm: Paint.FontMetricsInt? ): Int { - textPaint.set(paint) - textPaint.typeface = typeface - // Measure the full text width without truncation - measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt() - measuredTrailingWidth = trailingText?.let { textPaint.measureText(" $it").roundToInt() } ?: 0 - return measuredTextWidth + measuredTrailingWidth + startPadding + endPadding + return ceil(measure(paint).width).toInt() } override fun draw( @@ -114,13 +159,9 @@ class MentionSpan( bottom: Int, paint: Paint ) { - val availableWidth = (canvas.width - x).coerceAtLeast(0f) - - val measuredWidth = measuredTextWidth + measuredTrailingWidth + startPadding + endPadding - val pillWidth = minOf(availableWidth, measuredWidth.toFloat()) - + val measurements = measure(paint) backgroundPaint.color = backgroundColor - val rect = RectF(x, top.toFloat(), x + pillWidth, bottom.toFloat()) + val rect = RectF(x, top.toFloat(), x + measurements.width, bottom.toFloat()) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) @@ -128,18 +169,14 @@ class MentionSpan( textPaint.color = textColor textPaint.typeface = typeface - val availableWidthForText = availableWidth - startPadding - endPadding - measuredTrailingWidth - val textToDraw = if (measuredTextWidth > availableWidthForText) { - TextUtils.ellipsize(displayText, textPaint, availableWidthForText, TextUtils.TruncateAt.END) - } else { - displayText + canvas.drawText(measurements.text, 0, measurements.text.length, x + startPadding, y.toFloat(), textPaint) + measurements.trailing?.let { trailing -> + canvas.drawText(trailing, x + startPadding + measurements.textWidth, y.toFloat(), textPaint) } - canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint) + } - trailingText?.let { - val drawnTextWidth = minOf(availableWidthForText, measuredTextWidth.toFloat()) - canvas.drawText(" $it", x + startPadding + drawnTextWidth, y.toFloat(), textPaint) - } + companion object { + const val NO_MAX_WIDTH = Int.MAX_VALUE } } @@ -170,3 +207,17 @@ fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List + val maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else MentionSpan.NO_MAX_WIDTH + text.getMentionSpans().forEach { it.updateMaxWidth(maxWidth) } + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } +}