From b9a10653f1fe3464f8170e8fae92189e625dd53f Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Fri, 14 Jun 2019 15:31:03 -0400 Subject: [PATCH] Image Editor - Multi-line text. * Two pass rendering for text on top while editing. --- .../securesms/imageeditor/HiddenEditText.java | 54 ++- .../imageeditor/ImageEditorView.java | 21 +- .../imageeditor/model/EditorFlags.java | 9 + .../imageeditor/model/EditorModel.java | 47 ++- .../renderers/MultiLineTextRenderer.java | 395 ++++++++++++++++++ .../imageeditor/renderers/TextRenderer.java | 238 ----------- .../scribbles/ImageEditorFragment.java | 14 +- 7 files changed, 502 insertions(+), 276 deletions(-) create mode 100644 src/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java delete mode 100644 src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java diff --git a/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java b/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java index 7a25d0cf5b..ab1f2d1e94 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java +++ b/src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java @@ -4,7 +4,6 @@ import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Color; import android.graphics.Rect; -import androidx.annotation.Nullable; import android.text.InputType; import android.util.TypedValue; import android.view.Gravity; @@ -12,7 +11,11 @@ import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.FrameLayout; -import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.model.EditorElement; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; /** * Invisible {@link android.widget.EditText} that is used during in-image text editing. @@ -23,11 +26,17 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; @Nullable - private TextRenderer currentTextEntity; + private EditorElement currentTextEditorElement; + + @Nullable + private MultiLineTextRenderer currentTextEntity; @Nullable private Runnable onEndEdit; + @Nullable + private OnEditOrSelectionChange onEditOrSelectionChange; + public HiddenEditText(Context context) { super(context); setAlpha(0); @@ -37,8 +46,7 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { setFocusableInTouchMode(true); setBackgroundColor(Color.TRANSPARENT); setTextSize(TypedValue.COMPLEX_UNIT_SP, 1); - setInputType(InputType.TYPE_CLASS_TEXT); - setImeOptions(EditorInfo.IME_ACTION_DONE); + setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); clearFocus(); } @@ -47,6 +55,7 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { super.onTextChanged(text, start, lengthBefore, lengthAfter); if (currentTextEntity != null) { currentTextEntity.setText(text.toString()); + postEditOrSelectionChange(); } } @@ -76,11 +85,33 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { } } - @Nullable TextRenderer getCurrentTextEntity() { + private void postEditOrSelectionChange() { + if (currentTextEditorElement != null && currentTextEntity != null && onEditOrSelectionChange != null) { + onEditOrSelectionChange.onChange(currentTextEditorElement, currentTextEntity); + } + } + + @Nullable MultiLineTextRenderer getCurrentTextEntity() { return currentTextEntity; } - void setCurrentTextEntity(@Nullable TextRenderer currentTextEntity) { + @Nullable EditorElement getCurrentTextEditorElement() { + return currentTextEditorElement; + } + + public void setCurrentTextEditorElement(@Nullable EditorElement currentTextEditorElement) { + if (currentTextEditorElement != null && currentTextEditorElement.getRenderer() instanceof MultiLineTextRenderer) { + this.currentTextEditorElement = currentTextEditorElement; + setCurrentTextEntity((MultiLineTextRenderer) currentTextEditorElement.getRenderer()); + } else { + this.currentTextEditorElement = null; + setCurrentTextEntity(null); + } + + postEditOrSelectionChange(); + } + + private void setCurrentTextEntity(@Nullable MultiLineTextRenderer currentTextEntity) { if (this.currentTextEntity != currentTextEntity) { if (this.currentTextEntity != null) { this.currentTextEntity.setFocused(false); @@ -101,6 +132,7 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { super.onSelectionChanged(selStart, selEnd); if (currentTextEntity != null) { currentTextEntity.setSelection(selStart, selEnd); + postEditOrSelectionChange(); } } @@ -133,4 +165,12 @@ final class HiddenEditText extends androidx.appcompat.widget.AppCompatEditText { public void setOnEndEdit(@Nullable Runnable onEndEdit) { this.onEndEdit = onEndEdit; } + + public void setOnEditOrSelectionChange(@Nullable OnEditOrSelectionChange onEditOrSelectionChange) { + this.onEditOrSelectionChange = onEditOrSelectionChange; + } + + public interface OnEditOrSelectionChange { + void onChange(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer); + } } diff --git a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index 3ab56a89a5..c66908fc04 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorElement; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; -import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; /** * ImageEditorView @@ -106,25 +106,25 @@ public final class ImageEditorView extends FrameLayout { addView(editText); editText.clearFocus(); editText.setOnEndEdit(this::doneTextEditing); + editText.setOnEditOrSelectionChange(this::zoomToFitText); return editText; } public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) { - Renderer renderer = editorElement.getRenderer(); - if (renderer instanceof TextRenderer) { - TextRenderer textRenderer = (TextRenderer) renderer; - + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled); - editText.setCurrentTextEntity(textRenderer); + editText.setCurrentTextEditorElement(editorElement); if (selectAll) { editText.selectAll(); } editText.requestFocus(); - - getModel().zoomTo(editorElement, Bounds.TOP / 2, true); } } + private void zoomToFitText(@NonNull EditorElement editorElement, @NonNull MultiLineTextRenderer textRenderer) { + getModel().zoomToTextElement(editorElement, textRenderer); + } + public boolean isTextEditing() { return editText.getCurrentTextEntity() != null; } @@ -132,7 +132,7 @@ public final class ImageEditorView extends FrameLayout { public void doneTextEditing() { getModel().zoomOut(); if (editText.getCurrentTextEntity() != null) { - editText.setCurrentTextEntity(null); + editText.setCurrentTextEditorElement(null); editText.hideKeyboard(); if (tapListener != null) { tapListener.onEntityDown(null); @@ -148,7 +148,8 @@ public final class ImageEditorView extends FrameLayout { rendererContext.save(); try { rendererContext.canvasMatrix.initial(viewMatrix); - model.draw(rendererContext); + + model.draw(rendererContext, editText.getCurrentTextEditorElement()); } finally { rendererContext.restore(); } diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java index 6d5020ce87..61802bd96b 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorFlags.java @@ -19,6 +19,7 @@ public final class EditorFlags { private static final int EDITABLE = 32; private int flags; + private int markedFlags; private int persistedFlags; EditorFlags() { @@ -116,6 +117,14 @@ public final class EditorFlags { this.flags = flags; } + void mark() { + markedFlags = flags; + } + + void restore() { + flags = markedFlags; + } + public void set(@NonNull EditorFlags from) { this.persistedFlags = from.persistedFlags; this.flags = from.flags; diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index 6ee09d63e1..8cbd9d0a16 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; import org.thoughtcrime.securesms.imageeditor.Renderer; import org.thoughtcrime.securesms.imageeditor.RendererContext; import org.thoughtcrime.securesms.imageeditor.UndoRedoStackListener; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; import java.util.HashMap; import java.util.LinkedHashSet; @@ -88,9 +89,30 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { * Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix * * @param rendererContext Canvas to draw on to. + * @param renderOnTop This element will appear on top of the overlay. */ - public void draw(@NonNull RendererContext rendererContext) { - editorElementHierarchy.getRoot().draw(rendererContext); + public void draw(@NonNull RendererContext rendererContext, @Nullable EditorElement renderOnTop) { + EditorElement root = editorElementHierarchy.getRoot(); + if (renderOnTop != null) { + root.forAllInTree(element -> element.getFlags().mark()); + + renderOnTop.getFlags().setVisible(false); + } + + // pass 1 + root.draw(rendererContext); + + if (renderOnTop != null) { + // hide all + try { + root.forAllInTree(element -> element.getFlags().setVisible(renderOnTop == element)); + + // pass 2 + root.draw(rendererContext); + } finally { + root.forAllInTree(element -> element.getFlags().restore()); + } + } } public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) { @@ -676,25 +698,22 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } /** - * Changes the temporary view so that the element is centered in it. + * Changes the temporary view so that the text element is centered in it. * - * @param entity Entity to center on. - * @param y An optional extra value to translate the view by to leave space for the keyboard for example. - * @param doNotZoomOut Iff true, undoes any zoom out + * @param entity Entity to center on. + * @param textRenderer The text renderer, which can make additional adjustments to the zoom matrix + * to leave space for the keyboard for example. */ - public void zoomTo(@NonNull EditorElement entity, float y, boolean doNotZoomOut) { + public void zoomToTextElement(@NonNull EditorElement entity, @NonNull MultiLineTextRenderer textRenderer) { Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix()); if (elementInverseMatrix != null) { - elementInverseMatrix.preConcat(editorElementHierarchy.getRoot().getEditorMatrix()); + EditorElement root = editorElementHierarchy.getRoot(); - float xScale = EditorElementHierarchy.xScale(elementInverseMatrix); - if (doNotZoomOut && xScale < 1) { - elementInverseMatrix.postScale(1 / xScale, 1 / xScale); - } + elementInverseMatrix.preConcat(root.getEditorMatrix()); - elementInverseMatrix.postTranslate(0, y); + textRenderer.applyRecommendedEditorMatrix(elementInverseMatrix); - editorElementHierarchy.getRoot().animateEditorTo(elementInverseMatrix, invalidate); + root.animateEditorTo(elementInverseMatrix, invalidate); } } diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java new file mode 100644 index 0000000000..91d878bfda --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/renderers/MultiLineTextRenderer.java @@ -0,0 +1,395 @@ +package org.thoughtcrime.securesms.imageeditor.renderers; + +import android.animation.ValueAnimator; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.Parcel; +import android.view.animation.Interpolator; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.imageeditor.Bounds; +import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; +import org.thoughtcrime.securesms.imageeditor.RendererContext; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.emptyList; + +/** + * Renders multiple lines of {@link #text} in ths specified {@link #color}. + *

+ * Scales down the text size of long lines to fit inside the {@link Bounds} width. + */ +public final class MultiLineTextRenderer extends InvalidateableRenderer implements ColorableRenderer { + + @NonNull + private String text = ""; + + @ColorInt + private int color; + + private final Paint paint = new Paint(); + private final Paint selectionPaint = new Paint(); + + private final float textScale; + + private int selStart; + private int selEnd; + private boolean hasFocus; + + private List lines = emptyList(); + + private ValueAnimator cursorAnimator; + private float cursorAnimatedValue; + + private final Matrix recommendedEditorMatrix = new Matrix(); + + public MultiLineTextRenderer(@Nullable String text, @ColorInt int color) { + setColor(color); + float regularTextSize = paint.getTextSize(); + paint.setAntiAlias(true); + paint.setTextSize(100); + paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); + textScale = paint.getTextSize() / regularTextSize; + selectionPaint.setAntiAlias(true); + setText(text != null ? text : ""); + createLinesForText(); + } + + @Override + public void render(@NonNull RendererContext rendererContext) { + super.render(rendererContext); + + for (Line line : lines) { + line.render(rendererContext); + } + } + + @NonNull + public String getText() { + return text; + } + + public void setText(@NonNull String text) { + if (!this.text.equals(text)) { + this.text = text; + createLinesForText(); + } + } + + /** + * Post concats an additional matrix to the supplied matrix that scales and positions the editor + * so that all the text is visible. + * + * @param matrix editor matrix, already zoomed and positioned to fit the regular bounds. + */ + public void applyRecommendedEditorMatrix(@NonNull Matrix matrix) { + recommendedEditorMatrix.reset(); + + float scale = 1f; + for (Line line : lines) { + if (line.scale < scale) { + scale = line.scale; + } + } + + float yOff = 0; + for (Line line : lines) { + if (line.containsSelectionEnd()) { + break; + } else { + yOff -= line.heightInBounds; + } + } + + recommendedEditorMatrix.postTranslate(0, Bounds.TOP / 1.5f + yOff); + + recommendedEditorMatrix.postScale(scale, scale); + + matrix.postConcat(recommendedEditorMatrix); + } + + private void createLinesForText() { + String[] split = text.split("\n", -1); + + if (split.length == lines.size()) { + for (int i = 0; i < split.length; i++) { + lines.get(i).setText(split[i]); + } + } else { + lines = new ArrayList<>(split.length); + for (String s : split) { + lines.add(new Line(s)); + } + } + setSelection(selStart, selEnd); + } + + private class Line { + private final Matrix accentMatrix = new Matrix(); + private final Matrix decentMatrix = new Matrix(); + private final Matrix projectionMatrix = new Matrix(); + private final Matrix inverseProjectionMatrix = new Matrix(); + private final RectF selectionBounds = new RectF(); + private final RectF textBounds = new RectF(); + + private String text; + private int selStart; + private int selEnd; + private float ascentInBounds; + private float descentInBounds; + private float scale = 1f; + private float heightInBounds; + + Line(String text) { + this.text = text; + recalculate(); + } + + private void recalculate() { + RectF maxTextBounds = new RectF(); + Rect temp = new Rect(); + + getTextBoundsWithoutTrim(text, 0, text.length(), temp); + textBounds.set(temp); + + maxTextBounds.set(textBounds); + float widthLimit = 150 * textScale; + + scale = 1f / Math.max(1, maxTextBounds.right / widthLimit); + + maxTextBounds.right = widthLimit; + + if (showSelectionOrCursor()) { + Rect startTemp = new Rect(); + int startInString = Math.min(text.length(), Math.max(0, selStart)); + int endInString = Math.min(text.length(), Math.max(0, selEnd)); + String startText = this.text.substring(0, startInString); + + getTextBoundsWithoutTrim(startText, 0, startInString, startTemp); + + if (selStart != selEnd) { + // selection + getTextBoundsWithoutTrim(text, startInString, endInString, temp); + } else { + // cursor + paint.getTextBounds("|", 0, 1, temp); + int width = temp.width(); + + temp.left -= width; + temp.right -= width; + } + + temp.left += startTemp.right; + temp.right += startTemp.right; + selectionBounds.set(temp); + } + + projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); + removeTranslate(projectionMatrix); + + float[] pts = { 0, paint.ascent(), 0, paint.descent() }; + projectionMatrix.mapPoints(pts); + ascentInBounds = pts[1]; + descentInBounds = pts[3]; + heightInBounds = descentInBounds - ascentInBounds; + + projectionMatrix.preTranslate(-textBounds.centerX(), 0); + projectionMatrix.invert(inverseProjectionMatrix); + + accentMatrix.setTranslate(0, -ascentInBounds); + decentMatrix.setTranslate(0, descentInBounds); + + invalidate(); + } + + private void removeTranslate(Matrix matrix) { + float[] values = new float[9]; + + matrix.getValues(values); + values[2] = 0; + values[5] = 0; + matrix.setValues(values); + } + + private boolean showSelectionOrCursor() { + return (selStart >= 0 || selEnd >= 0) && + (selStart <= text.length() || selEnd <= text.length()); + } + + private boolean containsSelectionEnd() { + return (selEnd >= 0) && + (selEnd <= text.length()); + } + + private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) { + Rect extra = new Rect(); + Rect xBounds = new Rect(); + + String cannotBeTrimmed = "x" + text.substring(Math.max(0, start), Math.min(text.length(), end)) + "x"; + + paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra); + paint.getTextBounds("x", 0, 1, xBounds); + result.set(extra); + result.right -= 2 * xBounds.width(); + + int temp = result.left; + result.left -= temp; + result.right -= temp; + } + + public boolean contains(float x, float y) { + float[] dst = new float[2]; + + inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y }); + + return textBounds.contains(dst[0], dst[1]); + } + + void setText(String text) { + if (!this.text.equals(text)) { + this.text = text; + recalculate(); + } + } + + public void render(@NonNull RendererContext rendererContext) { + // add our ascent for ourselves and the next lines + rendererContext.canvasMatrix.concat(accentMatrix); + + rendererContext.save(); + + rendererContext.canvasMatrix.concat(projectionMatrix); + + if (hasFocus && showSelectionOrCursor()) { + if (selStart == selEnd) { + selectionPaint.setAlpha((int) (cursorAnimatedValue * 128)); + } else { + selectionPaint.setAlpha(128); + } + rendererContext.canvas.drawRect(selectionBounds, selectionPaint); + } + + int alpha = paint.getAlpha(); + paint.setAlpha(rendererContext.getAlpha(alpha)); + + rendererContext.canvas.drawText(text, 0, 0, paint); + + paint.setAlpha(alpha); + + rendererContext.restore(); + + // add our descent for the next lines + rendererContext.canvasMatrix.concat(decentMatrix); + } + + void setSelection(int selStart, int selEnd) { + if (selStart != this.selStart || selEnd != this.selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + recalculate(); + } + } + } + + @Override + public int getColor() { + return color; + } + + @Override + public void setColor(@ColorInt int color) { + if (this.color != color) { + this.color = color; + paint.setColor(color); + selectionPaint.setColor(color); + invalidate(); + } + } + + @Override + public boolean hitTest(float x, float y) { + for (Line line : lines) { + y += line.ascentInBounds; + if (line.contains(x, y)) return true; + y -= line.descentInBounds; + } + return false; + } + + public void setSelection(int selStart, int selEnd) { + this.selStart = selStart; + this.selEnd = selEnd; + for (Line line : lines) { + line.setSelection(selStart, selEnd); + + int length = line.text.length() + 1; // one for new line + + selStart -= length; + selEnd -= length; + } + } + + public void setFocused(boolean hasFocus) { + if (this.hasFocus != hasFocus) { + this.hasFocus = hasFocus; + if (cursorAnimator != null) { + cursorAnimator.cancel(); + cursorAnimator = null; + } + if (hasFocus) { + cursorAnimator = ValueAnimator.ofFloat(0, 1); + cursorAnimator.setInterpolator(pulseInterpolator()); + cursorAnimator.setRepeatCount(ValueAnimator.INFINITE); + cursorAnimator.setDuration(1000); + cursorAnimator.addUpdateListener(animation -> { + cursorAnimatedValue = (float) animation.getAnimatedValue(); + invalidate(); + }); + cursorAnimator.start(); + } else { + invalidate(); + } + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public MultiLineTextRenderer createFromParcel(Parcel in) { + return new MultiLineTextRenderer(in.readString(), in.readInt()); + } + + @Override + public MultiLineTextRenderer[] newArray(int size) { + return new MultiLineTextRenderer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeInt(color); + } + + private static Interpolator pulseInterpolator() { + return input -> { + input *= 5; + if (input > 1) { + input = 4 - input; + } + return Math.max(0, Math.min(1, input)); + }; + } +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java b/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java deleted file mode 100644 index b32823f9a0..0000000000 --- a/src/org/thoughtcrime/securesms/imageeditor/renderers/TextRenderer.java +++ /dev/null @@ -1,238 +0,0 @@ -package org.thoughtcrime.securesms.imageeditor.renderers; - -import android.animation.ValueAnimator; -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.Typeface; -import android.os.Parcel; -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import android.view.animation.Interpolator; - -import org.thoughtcrime.securesms.imageeditor.Bounds; -import org.thoughtcrime.securesms.imageeditor.ColorableRenderer; -import org.thoughtcrime.securesms.imageeditor.RendererContext; - -/** - * Renders a single line of {@link #text} in ths specified {@link #color}. - *

- * Scales down the text size to fit inside the {@link Bounds} width. - */ -public final class TextRenderer extends InvalidateableRenderer implements ColorableRenderer { - - @NonNull - private String text = ""; - - @ColorInt - private int color; - - private final Paint paint = new Paint(); - private final Paint selectionPaint = new Paint(); - private final RectF textBounds = new RectF(); - private final RectF selectionBounds = new RectF(); - private final RectF maxTextBounds = new RectF(); - private final Matrix projectionMatrix = new Matrix(); - private final Matrix inverseProjectionMatrix = new Matrix(); - - private final float textScale; - - private float xForCentre; - private int selStart; - private int selEnd; - private boolean hasFocus; - - private ValueAnimator cursorAnimator; - private float cursorAnimatedValue; - - public TextRenderer(@Nullable String text, @ColorInt int color) { - setColor(color); - float regularTextSize = paint.getTextSize(); - paint.setAntiAlias(true); - paint.setTextSize(100); - paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)); - textScale = paint.getTextSize() / regularTextSize; - selectionPaint.setAntiAlias(true); - setText(text != null ? text : ""); - } - - private TextRenderer(Parcel in) { - this(in.readString(), in.readInt()); - } - - public static final Creator CREATOR = new Creator() { - @Override - public TextRenderer createFromParcel(Parcel in) { - return new TextRenderer(in); - } - - @Override - public TextRenderer[] newArray(int size) { - return new TextRenderer[size]; - } - }; - - @Override - public void render(@NonNull RendererContext rendererContext) { - super.render(rendererContext); - rendererContext.save(); - Canvas canvas = rendererContext.canvas; - - rendererContext.canvasMatrix.concat(projectionMatrix); - - if (hasFocus) { - if (selStart == selEnd) { - selectionPaint.setAlpha((int) (cursorAnimatedValue * 128)); - } else { - selectionPaint.setAlpha(128); - } - canvas.drawRect(selectionBounds, selectionPaint); - } - - int alpha = paint.getAlpha(); - paint.setAlpha(rendererContext.getAlpha(alpha)); - - canvas.drawText(text, xForCentre, 0, paint); - - paint.setAlpha(alpha); - - rendererContext.restore(); - } - - @NonNull - public String getText() { - return text; - } - - public void setText(@NonNull String text) { - if (!this.text.equals(text)) { - this.text = text; - recalculate(); - } - } - - private void recalculate() { - Rect temp = new Rect(); - - getTextBoundsWithoutTrim(text, 0, text.length(), temp); - textBounds.set(temp); - - maxTextBounds.set(textBounds); - maxTextBounds.right = Math.max(150 * textScale, maxTextBounds.right); - - xForCentre = maxTextBounds.centerX() - textBounds.centerX(); - - textBounds.left += xForCentre; - textBounds.right += xForCentre; - - if (selStart != selEnd) { - getTextBoundsWithoutTrim(text, Math.min(text.length(), selStart), Math.min(text.length(), selEnd), temp); - } else { - Rect startTemp = new Rect(); - int start = Math.min(text.length(), selStart); - String text = this.text.substring(0, start); - - getTextBoundsWithoutTrim(text, 0, start, startTemp); - paint.getTextBounds("|", 0, 1, temp); - - int width = temp.width(); - - temp.left -= width; - temp.right -= width; - temp.left += startTemp.right; - temp.right += startTemp.right; - } - selectionBounds.set(temp); - selectionBounds.left += xForCentre; - selectionBounds.right += xForCentre; - - projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER); - projectionMatrix.invert(inverseProjectionMatrix); - invalidate(); - } - - private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) { - Rect extra = new Rect(); - Rect xBounds = new Rect(); - String cannotBeTrimmed = "x" + text.substring(start, end) + "x"; - paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra); - paint.getTextBounds("x", 0, 1, xBounds); - result.set(extra); - result.right -= 2 * xBounds.width(); - } - - @Override - public int getColor() { - return color; - } - - @Override - public void setColor(@ColorInt int color) { - if (this.color != color) { - this.color = color; - paint.setColor(color); - selectionPaint.setColor(color); - invalidate(); - } - } - - @Override - public boolean hitTest(float x, float y) { - float[] dst = new float[2]; - inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y }); - return textBounds.contains(dst[0], dst[1]); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(text); - dest.writeInt(color); - } - - public void setSelection(int selStart, int selEnd) { - this.selStart = selStart; - this.selEnd = selEnd; - recalculate(); - } - - public void setFocused(boolean hasFocus) { - if (this.hasFocus != hasFocus) { - this.hasFocus = hasFocus; - if (cursorAnimator != null) { - cursorAnimator.cancel(); - cursorAnimator = null; - } - if (hasFocus) { - cursorAnimator = ValueAnimator.ofFloat(0, 1); - cursorAnimator.setInterpolator(pulseInterpolator()); - cursorAnimator.setRepeatCount(ValueAnimator.INFINITE); - cursorAnimator.setDuration(1000); - cursorAnimator.addUpdateListener(animation -> { - cursorAnimatedValue = (float) animation.getAnimatedValue(); - invalidate(); - }); - cursorAnimator.start(); - } else { - invalidate(); - } - } - } - - private static Interpolator pulseInterpolator() { - return input -> { - input *= 5; - if (input > 1) { - input = 4 - input; - } - return Math.max(0, Math.min(1, input)); - }; - } -} diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 85085182ed..a20f0ef3f7 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.imageeditor.ImageEditorView; import org.thoughtcrime.securesms.imageeditor.Renderer; import org.thoughtcrime.securesms.imageeditor.model.EditorElement; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; -import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment; import org.thoughtcrime.securesms.mms.MediaConstraints; @@ -213,10 +213,10 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } protected void addText() { - String initialText = ""; - int color = imageEditorHud.getActiveColor(); - TextRenderer renderer = new TextRenderer(initialText, color); - EditorElement element = new EditorElement(renderer); + String initialText = ""; + int color = imageEditorHud.getActiveColor(); + MultiLineTextRenderer renderer = new MultiLineTextRenderer(initialText, color); + EditorElement element = new EditorElement(renderer); imageEditorView.getModel().addElementCentered(element, 1); imageEditorView.invalidate(); @@ -346,7 +346,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu public void onEntitySingleTap(@Nullable EditorElement editorElement) { currentSelection = editorElement; if (currentSelection != null) { - if (editorElement.getRenderer() instanceof TextRenderer) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing()); } else { imageEditorHud.enterMode(ImageEditorHud.Mode.MOVE_DELETE); @@ -357,7 +357,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu @Override public void onEntityDoubleTap(@NonNull EditorElement editorElement) { currentSelection = editorElement; - if (editorElement.getRenderer() instanceof TextRenderer) { + if (editorElement.getRenderer() instanceof MultiLineTextRenderer) { setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true); } }