package org.signal.imageeditor.core.renderers; import android.animation.ValueAnimator; import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Rect; import android.graphics.RectF; import android.os.Build; import android.os.Parcel; import android.view.animation.Interpolator; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.DimensionUnit; import org.signal.imageeditor.core.Bounds; import org.signal.imageeditor.core.ColorableRenderer; import org.signal.imageeditor.core.RendererContext; import org.signal.imageeditor.core.SelectableRenderer; 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, SelectableRenderer { private static final float HIT_PADDING = DimensionUnit.DP.toPixels(30); private static final float HIGHLIGHT_HORIZONTAL_PADDING = DimensionUnit.DP.toPixels(8); private static final float HIGHLIGHT_TOP_PADDING = DimensionUnit.DP.toPixels(10); private static final float HIGHLIGHT_BOTTOM_PADDING = DimensionUnit.DP.toPixels(6); private static final float HIGHLIGHT_CORNER_RADIUS = DimensionUnit.DP.toPixels(4); @NonNull private String text = ""; private static final int PADDING = 10; @ColorInt private int color; private final Paint paint = new Paint(); private final Paint selectionPaint = new Paint(); private final Paint modePaint = new Paint(); private final float textScale; private int selStart; private int selEnd; private boolean hasFocus; private Mode mode; private List lines = emptyList(); private ValueAnimator cursorAnimator; private float cursorAnimatedValue; private final Matrix recommendedEditorMatrix = new Matrix(); private final RectF textBounds = new RectF(); public MultiLineTextRenderer(@Nullable String text, @ColorInt int color, @NonNull Mode mode) { this.mode = mode; modePaint.setAntiAlias(true); modePaint.setTextSize(100); setColorInternal(color); float regularTextSize = paint.getTextSize(); paint.setAntiAlias(true); paint.setTextSize(100); textScale = paint.getTextSize() / regularTextSize; selectionPaint.setAntiAlias(true); setText(text != null ? text : ""); createLinesForText(); } @Override public void render(@NonNull RendererContext rendererContext) { super.render(rendererContext); paint.setTypeface(rendererContext.typefaceProvider.getSelectedTypeface(rendererContext.context, this, rendererContext.invalidate)); modePaint.setTypeface(rendererContext.typefaceProvider.getSelectedTypeface(rendererContext.context, this, rendererContext.invalidate)); float height = 0; float width = 0; for (Line line : lines) { line.render(rendererContext); height += line.heightInBounds - line.ascentInBounds + line.descentInBounds; width = Math.max(line.textBounds.width(), width); } textBounds.set(-width - PADDING, -PADDING, width + PADDING, height / 2f + PADDING); } @NonNull public String getText() { return text; } public void setText(@NonNull String text) { if (!this.text.equals(text)) { this.text = text; createLinesForText(); } } public void nextMode() { setMode(Mode.fromCode(mode.code + 1)); } public @NonNull Mode getMode() { return mode; } /** * 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 ascentMatrix = new Matrix(); private final Matrix descentMatrix = 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 final RectF hitBounds = new RectF(); private final RectF modeBounds = new RectF(); private final Path outlinerPath = new Path(); 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); hitBounds.set(textBounds); hitBounds.left -= HIT_PADDING; hitBounds.right += HIT_PADDING; hitBounds.top -= HIT_PADDING; hitBounds.bottom += HIT_PADDING; 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); ascentMatrix.setTranslate(0, -ascentInBounds); descentMatrix.setTranslate(0, descentInBounds + HIGHLIGHT_TOP_PADDING + HIGHLIGHT_BOTTOM_PADDING); 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 hitBounds.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(ascentMatrix); rendererContext.save(); rendererContext.canvasMatrix.concat(projectionMatrix); if (mode == Mode.HIGHLIGHT) { if(text.isEmpty()){ modeBounds.setEmpty(); }else{ modeBounds.set(textBounds.left - HIGHLIGHT_HORIZONTAL_PADDING, selectionBounds.top - HIGHLIGHT_TOP_PADDING, textBounds.right + HIGHLIGHT_HORIZONTAL_PADDING, selectionBounds.bottom + HIGHLIGHT_BOTTOM_PADDING); } int alpha = modePaint.getAlpha(); modePaint.setAlpha(rendererContext.getAlpha(alpha)); rendererContext.canvas.drawRoundRect(modeBounds, HIGHLIGHT_CORNER_RADIUS, HIGHLIGHT_CORNER_RADIUS, modePaint); modePaint.setAlpha(alpha); } else if (mode == Mode.UNDERLINE) { modeBounds.set(textBounds.left, selectionBounds.top, textBounds.right, selectionBounds.bottom); modeBounds.inset(-DimensionUnit.DP.toPixels(2), -DimensionUnit.DP.toPixels(2)); modeBounds.set(modeBounds.left, Math.max(modeBounds.top, modeBounds.bottom - DimensionUnit.DP.toPixels(6)), modeBounds.right, modeBounds.bottom - DimensionUnit.DP.toPixels(2)); int alpha = modePaint.getAlpha(); modePaint.setAlpha(rendererContext.getAlpha(alpha)); rendererContext.canvas.drawRect(modeBounds, modePaint); modePaint.setAlpha(alpha); } 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); if (mode == Mode.OUTLINE) { int modeAlpha = modePaint.getAlpha(); modePaint.setAlpha(rendererContext.getAlpha(alpha)); if (Build.VERSION.SDK_INT >= 31) { outlinerPath.reset(); modePaint.getTextPath(text, 0, text.length(), 0, 0, outlinerPath); outlinerPath.op(outlinerPath, Path.Op.INTERSECT); rendererContext.canvas.drawPath(outlinerPath, modePaint); } else { rendererContext.canvas.drawText(text, 0, 0, modePaint); } modePaint.setAlpha(modeAlpha); } rendererContext.restore(); // add our descent for the next lines rendererContext.canvasMatrix.concat(descentMatrix); } 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) { setColorInternal(color); } } @Override public void onSelected(boolean selected) { } @Override public void getSelectionBounds(@NonNull RectF bounds) { bounds.set(textBounds); } @Override public boolean hitTest(float x, float y) { return textBounds.contains(x, y); } 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(); } } } private void setMode(@NonNull Mode mode) { if (this.mode != mode) { this.mode = mode; setColorInternal(color); } } private void setColorInternal(@ColorInt int color) { this.color = color; if (mode == Mode.REGULAR) { paint.setColor(color); selectionPaint.setColor(color); } else { paint.setColor(Color.WHITE); selectionPaint.setColor(Color.WHITE); } if (mode == Mode.OUTLINE) { modePaint.setStrokeWidth(DimensionUnit.DP.toPixels(15) / 10f); modePaint.setStyle(Paint.Style.STROKE); } else { modePaint.setStyle(Paint.Style.FILL); } modePaint.setColor(color); invalidate(); } public static final Creator CREATOR = new Creator() { @Override public MultiLineTextRenderer createFromParcel(Parcel in) { return new MultiLineTextRenderer(in.readString(), in.readInt(), Mode.fromCode(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); dest.writeInt(mode.code); } private static Interpolator pulseInterpolator() { return input -> { input *= 5; if (input > 1) { input = 4 - input; } return Math.max(0, Math.min(1, input)); }; } public enum Mode { REGULAR(0), HIGHLIGHT(1), UNDERLINE(2), OUTLINE(3); private final int code; Mode(int code) { this.code = code; } private static Mode fromCode(int code) { for (final Mode value : Mode.values()) { if (value.code == code) { return value; } } return REGULAR; } } }