@@ -8,6 +8,18 @@ import androidx.compose.ui.geometry.Size
88import androidx.compose.ui.graphics.Color
99import androidx.compose.ui.graphics.drawscope.DrawScope
1010import androidx.compose.ui.text.AnnotatedString
11+ import androidx.compose.ui.text.ParagraphStyle
12+ import androidx.compose.ui.text.font.FontFamily
13+ import androidx.compose.ui.text.font.FontStyle
14+ import androidx.compose.ui.text.font.FontWeight
15+ import androidx.compose.ui.text.style.TextDecoration
16+ import androidx.compose.ui.text.style.TextIndent
17+ import androidx.compose.ui.unit.TextUnit
18+ import androidx.compose.ui.unit.isSpecified
19+ import androidx.compose.ui.unit.isUnspecified
20+ import com.flipcash.libs.textformat.FormatStyle
21+ import com.flipcash.libs.textformat.RangeKind
22+ import com.flipcash.libs.textformat.StyledSpan
1123import androidx.compose.ui.text.LinkAnnotation
1224import androidx.compose.ui.text.Placeholder
1325import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -40,45 +52,83 @@ internal object MentionPill {
4052}
4153
4254/* *
43- * A text bubble's body with its links and mentions in place, and where each handle's letters sit
44- * in it.
55+ * A text bubble's body with its formatting, links and mentions in place, and where each handle's
56+ * letters sit in it.
4557 *
46- * The body is not [ text] verbatim: each mention is bracketed by a placeholder [MentionPill.spacing]
47- * wide, which is what makes room for the pill without drawing over the words beside it. iOS kerns
48- * the neighbouring characters for the same room; Compose has no per-character kern, and a
49- * placeholder of known width is the nearest thing. Its stand-in character is a no-break space, so
50- * the line can still break before the leading one but never between it and the `@`, nor between
51- * the handle and the trailing one.
58+ * The body is not the message text verbatim. The parser's consumed markers are gone, and each
59+ * mention is bracketed by a placeholder [MentionPill.spacing] wide, which is what makes room for
60+ * the pill without drawing over the words beside it. iOS kerns the neighbouring characters for the
61+ * same room; Compose has no per-character kern, and a placeholder of known width is the nearest
62+ * thing. Its stand-in character is a no-break space, so the line can still break before the
63+ * leading one but never between it and the `@`, nor between the handle and the trailing one.
5264 */
5365internal class MentionedText (
5466 val text : AnnotatedString ,
5567 /* * Each handle's letters, `@` included, in [text]'s offsets. The pills are drawn from these. */
5668 val handles : List <TextRange >,
69+ /* *
70+ * One range per run of consecutive quoted lines, in [text]'s offsets. The bar is drawn down
71+ * each, in the gutter [FormatStyles.quoteIndent] leaves.
72+ */
73+ val quoteBars : List <TextRange > = emptyList(),
74+ )
75+
76+ /* * How the parser's styles are drawn. Everything else the bubble owns: weight, colour, size. */
77+ internal class FormatStyles (
78+ val code : SpanStyle = SpanStyle (fontFamily = FontFamily .Monospace , background = Color .White .copy(alpha = 0.12f)),
79+ val codeBlock : SpanStyle = SpanStyle (fontFamily = FontFamily .Monospace , background = Color .White .copy(alpha = 0.12f)),
80+ /* * The gutter a quoted line is indented by, which holds the bar. */
81+ val quoteIndent : TextUnit = 12 .sp,
82+ /* * Where the wrapped lines of a bulleted item start, past the `- `. */
83+ val bulletIndent : TextUnit = 12 .sp,
84+ /* * The same for a numbered item, past `1. `. */
85+ val numberedIndent : TextUnit = 20 .sp,
5786)
5887
5988internal const val MENTION_SPACING_SLOT = " mentionSpacing"
6089
61- private const val MENTION_SPACING_STAND_IN = " "
90+ private const val MENTION_SPACING_STAND_IN = " "
6291
6392internal fun buildMentionedText (
6493 text : String ,
6594 mentions : List <DetectedMention >,
6695 linkStyle : SpanStyle ,
6796 mentionStyle : SpanStyle ,
6897 onMentionClick : ((String ) -> Unit )? ,
98+ formatStyles : FormatStyles = FormatStyles (),
6999): MentionedText {
70- val ordered = mentions.sortedBy { it.start }
100+ // Out of order or overlapping only if the caller handed over ranges from other text.
101+ val ordered = mentions.sortedBy { it.start }.fold(emptyList<DetectedMention >()) { kept, mention ->
102+ if (mention.start < (kept.lastOrNull()?.end ? : 0 ) || mention.end > text.length) kept else kept + mention
103+ }
104+ val parsed = parseChatText(text, detectUrls(text), ordered)
105+ val display = parsed.display
106+
107+ // The parser hands back every mention it was given, moved to display offsets and in order.
108+ val mentionRanges = parsed.displayRanges.filter { it.kind == RangeKind .Mention }
109+ val placed = if (mentionRanges.size == ordered.size) mentionRanges.zip(ordered) else emptyList()
110+
111+ // Each placed mention adds a placeholder before and after its handle, so a display offset moves
112+ // by two for every mention wholly before it and by one inside a handle. A boundary on a
113+ // mention's own edge stays outside the leading placeholder and past the trailing one, which
114+ // lets a style wrap a whole pill (`*@jeff*`).
115+ fun laidOut (offset : Int ): Int = offset + placed.sumOf { (range, _) ->
116+ when {
117+ range.end <= offset -> 2
118+ range.start < offset -> 1
119+ else -> 0
120+ }.toInt()
121+ }
122+
71123 val handles = mutableListOf<TextRange >()
72124 val body = buildAnnotatedString {
73125 var cursor = 0
74- ordered.forEach { mention ->
75- // Out of order or overlapping only if the caller handed over ranges from other text.
76- if (mention.start < cursor || mention.end > text.length) return @forEach
77- append(text, cursor, mention.start)
126+ placed.forEach { (range, mention) ->
127+ append(display, cursor, range.start)
78128 val tapStart = length
79129 appendInlineContent(MENTION_SPACING_SLOT , MENTION_SPACING_STAND_IN )
80130 val handleStart = length
81- append(text, mention .start, mention .end)
131+ append(display, range .start, range .end)
82132 handles + = TextRange (handleStart, length)
83133 appendInlineContent(MENTION_SPACING_SLOT , MENTION_SPACING_STAND_IN )
84134 // Over the spacing as well as the letters, so the whole pill takes the tap.
@@ -94,24 +144,90 @@ internal fun buildMentionedText(
94144 end = length,
95145 )
96146 }
97- cursor = mention .end
147+ cursor = range .end
98148 }
99- append(text , cursor, text .length)
149+ append(display , cursor, display .length)
100150
101- // A link never overlaps a mention -- detection drops the mention -- so each link moves by
102- // the two placeholders of every mention before it.
103- detectUrls(text).forEach { link ->
104- val shift = 2 * ordered.count { it.start < link.start }
151+ parsed.spans.forEach { span ->
152+ val style = when (span.style) {
153+ FormatStyle .Bold -> SpanStyle (fontWeight = FontWeight .Bold )
154+ FormatStyle .Italic -> SpanStyle (fontStyle = FontStyle .Italic )
155+ FormatStyle .Strike -> SpanStyle (textDecoration = TextDecoration .LineThrough )
156+ FormatStyle .Code -> formatStyles.code
157+ FormatStyle .CodeBlock -> formatStyles.codeBlock
158+ // Whole lines, drawn below.
159+ FormatStyle .Quote , FormatStyle .Bullet , FormatStyle .Numbered -> null
160+ } ? : return @forEach
161+ addStyle(style, laidOut(span.start), laidOut(span.end))
162+ }
163+
164+ parsed.displayRanges.filter { it.kind == RangeKind .Link }.forEach { link ->
165+ val url = link.target?.let { target -> detectUrls(target).singleOrNull()?.url ? : target }
166+ ? : display.substring(link.start, link.end).let { shown ->
167+ detectUrls(shown).firstOrNull()?.url ? : shown
168+ }
105169 addLink(
106- LinkAnnotation .Url (url = link. url, styles = TextLinkStyles (style = linkStyle)),
107- start = link.start + shift ,
108- end = link.end + shift ,
170+ LinkAnnotation .Url (url = url, styles = TextLinkStyles (style = linkStyle)),
171+ start = laidOut( link.start) ,
172+ end = laidOut( link.end) ,
109173 )
110174 }
111175 }
112- return MentionedText (body, handles)
176+
177+ val lines = display.lineRanges()
178+ val quoted = lines.map { line -> parsed.spans.any { it.style == FormatStyle .Quote && it.covers(line) } }
179+ val paragraphs = AnnotatedString .Builder (body)
180+ lines.forEachIndexed { index, line ->
181+ val listIndent = when {
182+ parsed.spans.any { it.style == FormatStyle .Bullet && it.covers(line) } -> formatStyles.bulletIndent
183+ parsed.spans.any { it.style == FormatStyle .Numbered && it.covers(line) } -> formatStyles.numberedIndent
184+ else -> TextUnit .Unspecified
185+ }
186+ val quoteIndent = if (quoted[index]) formatStyles.quoteIndent else TextUnit .Unspecified
187+ if (listIndent.isUnspecified && quoteIndent.isUnspecified) return @forEachIndexed
188+ val lead = if (quoteIndent.isSpecified) quoteIndent else 0 .sp
189+ val rest = (lead.value + if (listIndent.isSpecified) listIndent.value else 0f ).sp
190+ // Through the newline, so the paragraphs tile the text and no stray one is left holding it.
191+ val end = if (line.last < display.length) line.last + 1 else line.last
192+ paragraphs.addStyle(
193+ ParagraphStyle (textIndent = TextIndent (firstLine = lead, restLine = rest)),
194+ laidOut(line.first),
195+ laidOut(end),
196+ )
197+ }
198+
199+ val bars = mutableListOf<TextRange >()
200+ var run: IntRange ? = null
201+ lines.forEachIndexed { index, line ->
202+ if (quoted[index]) {
203+ run = run?.let { it.first.. line.last } ? : line
204+ } else {
205+ run?.let { bars + = TextRange (laidOut(it.first), laidOut(it.last)) }
206+ run = null
207+ }
208+ }
209+ run?.let { bars + = TextRange (laidOut(it.first), laidOut(it.last)) }
210+
211+ return MentionedText (paragraphs.toAnnotatedString(), handles, bars)
212+ }
213+
214+ /* * Each line's span, end exclusive of the newline; `last` is the offset of the line break. */
215+ private fun String.lineRanges (): List <IntRange > {
216+ val out = mutableListOf<IntRange >()
217+ var start = 0
218+ while (true ) {
219+ val newline = indexOf(' \n ' , start)
220+ if (newline < 0 ) {
221+ out + = start.. length
222+ return out
223+ }
224+ out + = start.. newline
225+ start = newline + 1
226+ }
113227}
114228
229+ private fun StyledSpan.covers (line : IntRange ): Boolean = start < line.last && end > line.first
230+
115231/* * The placeholder either side of a handle, for the body's `inlineContent`. */
116232internal fun mentionSpacingContent (density : Density ): Pair <String , InlineTextContent > =
117233 MENTION_SPACING_SLOT to InlineTextContent (
@@ -150,3 +266,17 @@ internal fun DrawScope.drawMentionPills(layout: TextLayoutResult, handles: List<
150266 }
151267 }
152268}
269+
270+ /* * The bar down the leading edge of each run of quoted lines, from its first line's top to its last line's bottom. */
271+ internal fun DrawScope.drawQuoteBars (layout : TextLayoutResult , bars : List <TextRange >, color : Color ) {
272+ val width = QUOTE_BAR_WIDTH .toPx()
273+ val radius = CornerRadius (width / 2 )
274+ bars.forEach { bar ->
275+ if (bar.end > layout.layoutInput.text.length) return @forEach
276+ val top = layout.getLineTop(layout.getLineForOffset(bar.start))
277+ val bottom = layout.getLineBottom(layout.getLineForOffset(maxOf(bar.start, bar.end - 1 )))
278+ drawRoundRect(color, Offset (0f , top), Size (width, bottom - top), radius)
279+ }
280+ }
281+
282+ private val QUOTE_BAR_WIDTH = 3 .dp
0 commit comments