Complete text formatting.

This commit is contained in:
Cody Henthorne
2023-05-17 13:44:14 -04:00
committed by Greyson Parrelli
parent 534c5c3c64
commit a64bffd83a
20 changed files with 211 additions and 271 deletions

View File

@@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
if (FeatureFlags.textFormattingSpoilerSend()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
}
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
addTextChangedListener(new ComposeTextStyleWatcher());
@@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
if (FeatureFlags.textFormattingSpoilerSend()) {
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
}
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
return true;
}

View File

@@ -5,7 +5,6 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.Spannable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setMessageType(messageType);
bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}

View File

@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Path;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Annotation;
@@ -70,9 +72,8 @@ public class EmojiTextView extends AppCompatTextView {
private boolean forceJumboEmoji;
private boolean isInOnDraw;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
public EmojiTextView(Context context) {
this(context, null);
@@ -113,11 +114,6 @@ public class EmojiTextView extends AppCompatTextView {
setText(getText());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
setSpannableFactory(spoilerFilteringSpannableFactory);
}
@Override
protected void onDraw(Canvas canvas) {
isInOnDraw = true;
@@ -126,7 +122,10 @@ public class EmojiTextView extends AppCompatTextView {
boolean hasLayout = getLayout() != null;
if (hasSpannedText && hasLayout) {
drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
Path textClipPath = drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
canvas.clipPath(textClipPath, Region.Op.DIFFERENCE);
canvas.translate(-getTotalPaddingLeft(), -getTotalPaddingTop());
}
super.onDraw(canvas);
@@ -138,14 +137,14 @@ public class EmojiTextView extends AppCompatTextView {
isInOnDraw = false;
}
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
private Path drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
if (mentionDelegate != null) {
mentionDelegate.draw(canvas, (Spanned) getText(), getLayout());
}
spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
return spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
@@ -187,9 +186,6 @@ public class EmojiTextView extends AppCompatTextView {
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
if (spoilerFilteringSpannableFactory != null) {
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
@@ -333,11 +329,7 @@ public class EmojiTextView extends AppCompatTextView {
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
}
if (spoilerFilteringSpannableFactory != null) {
spoilerFilteringSpannableFactory.wrap(newTextToSet);
}
super.setText(newContent, BufferType.SPANNABLE);
super.setText(newTextToSet, BufferType.SPANNABLE);
}
}

View File

@@ -2,7 +2,8 @@ package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spannable
import android.graphics.Path
import android.graphics.Region
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
@@ -21,8 +22,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
private var isInOnDraw: Boolean = false
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
@@ -30,20 +29,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
override fun onDraw(canvas: Canvas) {
isInOnDraw = true
var textClipPath: Path? = null
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
try {
spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
textClipPath = spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
} finally {
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
isInOnDraw = false
if (textClipPath != null) {
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
canvas.clipPath(textClipPath, Region.Op.DIFFERENCE)
canvas.translate(-totalPaddingLeft.toFloat(), -totalPaddingTop.toFloat())
}
super.onDraw(canvas)
}
override fun setText(text: CharSequence?, type: BufferType?) {
@@ -69,10 +72,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
}
bufferType = BufferType.SPANNABLE
super.setText(newContent, type)
}
@@ -86,9 +85,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
}
}
fun enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
setSpannableFactory(spoilerFilteringSpannableFactory!!)
}
}

View File

@@ -1,21 +0,0 @@
package org.thoughtcrime.securesms.components.emoji
import android.text.Spannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
/**
* Spannable factory used to help ensure spans are copied/maintained properly through the
* Android text handling system.
*
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
*/
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
override fun newSpannable(source: CharSequence): Spannable {
return wrap(super.newSpannable(source))
}
fun wrap(source: Spannable): SpoilerFilteringSpannable {
return SpoilerFilteringSpannable(source, inOnDraw)
}
}

View File

@@ -1,10 +1,8 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Color
import android.text.Annotation
import android.text.Selection
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
@@ -63,21 +61,15 @@ object SpoilerAnnotation {
override fun onClick(widget: View) {
revealedSpoilers.add(spoiler.value)
if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) {
val text: Spannable = if (widget.text is Spannable) {
widget.text as Spannable
} else {
SpannableString(widget.text)
if (widget is TextView) {
val text = widget.text
if (text is Spannable) {
Selection.removeSelection(text)
}
Selection.removeSelection(text)
widget.text = text
}
}
override fun updateDrawState(ds: TextPaint) {
if (!spoilerRevealed) {
ds.color = Color.TRANSPARENT
}
}
override fun updateDrawState(ds: TextPaint) = Unit
}
}

View File

@@ -1,109 +1,123 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.text.Layout
import androidx.annotation.ColorInt
import androidx.annotation.Px
import org.thoughtcrime.securesms.util.LayoutUtil
/**
* Handles drawing the spoiler sparkles for a TextView.
*/
abstract class SpoilerRenderer {
class SpoilerRenderer(
private val spoilerDrawable: SpoilerDrawable,
private val renderForComposing: Boolean,
@Px private val padding: Int,
@ColorInt composeBackgroundColor: Int
) {
abstract fun draw(
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
private val paint = Paint().apply { color = composeBackgroundColor }
fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
)
protected fun getLineTop(layout: Layout, line: Int): Int {
return LayoutUtil.getLineTopWithoutPadding(layout, line)
}
protected fun getLineBottom(layout: Layout, line: Int): Int {
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
}
protected inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
return getOrPut(line * 31 + layout.hashCode() * 31, default)
}
class SingleLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
endOffset: Int,
textClipPath: Path
) {
if (startLine == endLine) {
val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
val left = startOffset.coerceAtMost(endOffset)
val right = startOffset.coerceAtLeast(endOffset)
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
}
class MultiLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
val paragraphDirection = layout.getParagraphDirection(startLine)
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
for (line in startLine + 1 until endLine) {
val left: Int = layout.getLineLeft(line).toInt()
val right: Int = layout.getLineRight(line).toInt()
lineTop = getLineTop(layout, line)
lineBottom = getLineBottom(layout, line)
if (renderForComposing) {
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
} else {
textClipPath.addRect(left, lineTop, right, lineBottom)
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
return
}
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
if (start > end) {
spoilerDrawable.setBounds(end, top, start, bottom)
val paragraphDirection = layout.getParagraphDirection(startLine)
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
drawPartialLine(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom, textClipPath)
for (line in startLine + 1 until endLine) {
val left: Int = layout.getLineLeft(line).toInt()
val right: Int = layout.getLineRight(line).toInt()
lineTop = getLineTop(layout, line)
lineBottom = getLineBottom(layout, line)
if (renderForComposing) {
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
} else {
spoilerDrawable.setBounds(start, top, end, bottom)
textClipPath.addRect(left, lineTop, right, lineBottom)
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
spoilerDrawable.draw(canvas)
}
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
if (start > end) {
spoilerDrawable.setBounds(end, top, start, bottom)
} else {
spoilerDrawable.setBounds(start, top, end, bottom)
}
spoilerDrawable.draw(canvas)
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
drawPartialLine(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom, textClipPath)
}
private fun drawPartialLine(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, textClipPath: Path) {
if (renderForComposing) {
canvas.drawComposeBackground(start, top, end, bottom)
return
}
if (start > end) {
textClipPath.addRect(end, top, start, bottom)
spoilerDrawable.setBounds(end, top, start, bottom)
} else {
textClipPath.addRect(start, top, end, bottom)
spoilerDrawable.setBounds(start, top, end, bottom)
}
spoilerDrawable.draw(canvas)
}
private fun getLineTop(layout: Layout, line: Int): Int {
return LayoutUtil.getLineTopWithoutPadding(layout, line)
}
private fun getLineBottom(layout: Layout, line: Int): Int {
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
}
private inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
return getOrPut(line * 31 + layout.hashCode() * 31, default)
}
private fun Canvas.drawComposeBackground(start: Int, top: Int, end: Int, bottom: Int) {
drawRoundRect(
start.toFloat() - padding,
top.toFloat() - padding,
end.toFloat() + padding,
bottom.toFloat(),
padding.toFloat(),
padding.toFloat(),
paint
)
}
private fun Path.addRect(left: Int, top: Int, end: Int, bottom: Int) {
addRect(left.toFloat() - padding, top.toFloat() - padding, end.toFloat() + padding, bottom.toFloat() + padding, Path.Direction.CCW)
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.spoiler
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Path
import android.text.Annotation
import android.text.Layout
import android.text.Spanned
@@ -9,18 +10,17 @@ import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.animation.LinearInterpolator
import android.widget.TextView
import androidx.core.content.ContextCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
/**
* Performs initial calculation on how to render spoilers and then delegates to the single line or
* multi-line version of actually drawing the spoiler sparkles.
* Performs initial calculation on how to render spoilers and then delegates to actually drawing the spoiler sparkles.
*/
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
private val single: SpoilerRenderer
private val multi: SpoilerRenderer
private val renderer: SpoilerRenderer
private val spoilerDrawable: SpoilerDrawable
private var animatorRunning = false
private var textColor: Int
@@ -39,11 +39,17 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
repeatMode = ValueAnimator.REVERSE
}
private val textClipPath: Path = Path()
init {
textColor = view.textColors.defaultColor
spoilerDrawable = SpoilerDrawable(textColor)
single = SingleLineSpoilerRenderer(spoilerDrawable)
multi = MultiLineSpoilerRenderer(spoilerDrawable)
renderer = SpoilerRenderer(
spoilerDrawable = spoilerDrawable,
renderForComposing = renderForComposing,
padding = 2.dp,
composeBackgroundColor = ContextCompat.getColor(view.context, R.color.signal_colorOnSurfaceVariant1)
)
view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(v: View) = stopAnimating()
@@ -59,10 +65,11 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
}
}
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
fun draw(canvas: Canvas, text: Spanned, layout: Layout): Path {
var hasSpoilersToRender = false
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
textClipPath.reset()
for ((annotation, clickSpan) in annotations.entries) {
if (clickSpan?.spoilerRevealed == true) {
continue
@@ -85,9 +92,7 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
)
}
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset)
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, textClipPath)
hasSpoilersToRender = true
}
@@ -99,6 +104,8 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
} else {
stopAnimating()
}
return textClipPath
}
private fun stopAnimating() {