Signal-Android/image-editor/lib/src/main/java/org/signal/imageeditor/core/renderers/MultiLineTextRenderer.java

544 wiersze
16 KiB
Java

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}.
* <p>
* 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<Line> 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<MultiLineTextRenderer> CREATOR = new Creator<MultiLineTextRenderer>() {
@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;
}
}
}