diff --git a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index ed98f296e..2d4f189a6 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -60,6 +60,9 @@ public final class ImageEditorView extends FrameLayout { @Nullable private DrawingChangedListener drawingChangedListener; + @Nullable + private UndoRedoStackListener undoRedoStackListener; + private final Matrix viewMatrix = new Matrix(); private final RectF viewPort = Bounds.newFullBounds(); private final RectF visibleViewPort = Bounds.newFullBounds(); @@ -200,9 +203,11 @@ public final class ImageEditorView extends FrameLayout { if (this.model != model) { if (this.model != null) { this.model.setInvalidate(null); + this.model.setUndoRedoStackListener(null); } this.model = model; this.model.setInvalidate(this::invalidate); + this.model.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); this.model.setVisibleViewPort(visibleViewPort); invalidate(); } @@ -386,6 +391,10 @@ public final class ImageEditorView extends FrameLayout { this.drawingChangedListener = drawingChangedListener; } + public void setUndoRedoStackListener(@Nullable UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + } + public void setTapListener(TapListener tapListener) { this.tapListener = tapListener; } @@ -398,6 +407,12 @@ public final class ImageEditorView extends FrameLayout { } } + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + if (undoRedoStackListener != null) { + undoRedoStackListener.onAvailabilityChanged(undoAvailable, redoAvailable); + } + } + private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener { @Override diff --git a/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java b/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java new file mode 100644 index 000000000..6f7b5f11c --- /dev/null +++ b/src/org/thoughtcrime/securesms/imageeditor/UndoRedoStackListener.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.imageeditor; + +public interface UndoRedoStackListener { + + void onAvailabilityChanged(boolean undoAvailable, boolean redoAvailable); +} diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java index f8b7f3de2..2f2312c9f 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorElement.java @@ -44,7 +44,7 @@ public final class EditorElement implements Parcelable { private final Matrix tempMatrix = new Matrix(); - private final List children = new LinkedList<>(); + private final List children = new LinkedList<>(); private final List deletedChildren = new LinkedList<>(); @NonNull diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index fde68048c..c85dd8943 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.imageeditor.Bounds; 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 java.util.HashMap; import java.util.LinkedHashSet; @@ -42,6 +43,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { @NonNull private Runnable invalidate = NULL_RUNNABLE; + private UndoRedoStackListener undoRedoStackListener; + private final UndoRedoStacks undoRedoStacks; private final UndoRedoStacks cropUndoRedoStacks; private final InBoundsMemory inBoundsMemory = new InBoundsMemory(); @@ -70,6 +73,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE; } + public void setUndoRedoStackListener(UndoRedoStackListener undoRedoStackListener) { + this.undoRedoStackListener = undoRedoStackListener; + } + /** * Renders tree with the following matrix: *

@@ -117,9 +124,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { UndoRedoStacks stacks = cropping ? cropUndoRedoStacks : undoRedoStacks; - if (stacks.getUndoStack().tryPush(editorElementHierarchy.getRoot())) { - stacks.getRedoStack().clear(); - } + stacks.pushState(editorElementHierarchy.getRoot()); } public void undo() { @@ -127,6 +132,8 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { UndoRedoStacks stacks = cropping ? cropUndoRedoStacks : undoRedoStacks; undoRedo(stacks.getUndoStack(), stacks.getRedoStack(), cropping); + + updateUndoRedoAvailableState(stacks); } public void redo() { @@ -134,12 +141,15 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { UndoRedoStacks stacks = cropping ? cropUndoRedoStacks : undoRedoStacks; undoRedo(stacks.getRedoStack(), stacks.getUndoStack(), cropping); + + updateUndoRedoAvailableState(stacks); } private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack, boolean keepEditorState) { - final EditorElement popped = fromStack.pop(); + final EditorElement oldRootElement = editorElementHierarchy.getRoot(); + final EditorElement popped = fromStack.pop(oldRootElement); + if (popped != null) { - EditorElement oldRootElement = editorElementHierarchy.getRoot(); editorElementHierarchy = EditorElementHierarchy.create(popped); toStack.tryPush(oldRootElement); @@ -187,6 +197,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } } + private void updateUndoRedoAvailableState(UndoRedoStacks currentStack) { + if (undoRedoStackListener == null) return; + + EditorElement root = editorElementHierarchy.getRoot(); + + undoRedoStackListener.onAvailabilityChanged(currentStack.canUndo(root), currentStack.canRedo(root)); + } + private static Map getElementMap(@NonNull EditorElement element) { final Map result = new HashMap<>(); element.buildMap(result); @@ -195,14 +213,15 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { public void startCrop() { pushUndoPoint(); - cropUndoRedoStacks.getUndoStack().clear(); - cropUndoRedoStacks.getUndoStack().clear(); + cropUndoRedoStacks.clear(editorElementHierarchy.getRoot()); editorElementHierarchy.startCrop(invalidate); inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + updateUndoRedoAvailableState(cropUndoRedoStacks); } public void doneCrop() { editorElementHierarchy.doneCrop(visibleViewPort, invalidate); + updateUndoRedoAvailableState(undoRedoStacks); } public void setCropAspectLock(boolean locked) { @@ -223,6 +242,9 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { if (isCropping()) { ensureFitsBounds(allowScaleToRepairCrop); } + + UndoRedoStacks stacks = isCropping() ? cropUndoRedoStacks : undoRedoStacks; + updateUndoRedoAvailableState(stacks); } private void ensureFitsBounds(boolean allowScaleToRepairCrop) { @@ -467,13 +489,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { parent.addElement(element); if (parent != mainImage) { - undoRedoStacks.getUndoStack().clear(); + undoRedoStacks.clear(editorElementHierarchy.getRoot()); } + + updateUndoRedoAvailableState(undoRedoStacks); } public boolean isChanged() { - ElementStack undoStack = undoRedoStacks.getUndoStack(); - return !undoStack.isEmpty() || undoStack.isOverflowed(); + return undoRedoStacks.isChanged(editorElementHierarchy.getRoot()); } public RectF findCropRelativeToRoot() { @@ -578,4 +601,5 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { public boolean isCropping() { return editorElementHierarchy.getCropEditorElement().getFlags().isVisible(); } + } diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java index 3b0a1b588..15e051614 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/ElementStack.java @@ -13,14 +13,12 @@ import java.util.Stack; *

* Elements are mutable, so this stack serializes the element and keeps a stack of serialized data. *

- * The stack has a {@link #limit} and if it exceeds that limit the {@link #overflowed} flag is set. - * So that when used as an undo stack, {@link #isEmpty()} and {@link #isOverflowed()} tell you if the image has ever changed. + * The stack has a {@link #limit} and if it exceeds that limit during a push the earliest item is removed. */ final class ElementStack implements Parcelable { private final int limit; private final Stack stack = new Stack<>(); - private boolean overflowed; ElementStack(int limit) { this.limit = limit; @@ -28,7 +26,6 @@ final class ElementStack implements Parcelable { private ElementStack(@NonNull Parcel in) { this(in.readInt()); - overflowed = in.readInt() != 0; final int count = in.readInt(); for (int i = 0; i < count; i++) { stack.add(i, in.createByteArray()); @@ -43,32 +40,52 @@ final class ElementStack implements Parcelable { * @return true iff the pushed item was different to the top item. */ boolean tryPush(@NonNull EditorElement element) { - Parcel parcel = Parcel.obtain(); - byte[] bytes; - try { - parcel.writeParcelable(element, 0); - bytes = parcel.marshall(); - } finally { - parcel.recycle(); - } - boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek()); + byte[] bytes = getBytes(element); + boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek()); + if (push) { stack.push(bytes); if (stack.size() > limit) { stack.remove(0); - overflowed = true; } } return push; } - @Nullable EditorElement pop() { + static byte[] getBytes(@NonNull Parcelable parcelable) { + Parcel parcel = Parcel.obtain(); + byte[] bytes; + try { + parcel.writeParcelable(parcelable, 0); + bytes = parcel.marshall(); + } finally { + parcel.recycle(); + } + return bytes; + } + + /** + * Pops the first different state from the supplied element. + */ + @Nullable EditorElement pop(@NonNull EditorElement element) { if (stack.empty()) return null; - byte[] data = stack.pop(); + byte[] elementBytes = getBytes(element); + byte[] stackData = null; + + while (!stack.empty() && stackData == null) { + byte[] topData = stack.pop(); + + if (!Arrays.equals(topData, elementBytes)) { + stackData = topData; + } + } + + if (stackData == null) return null; + Parcel parcel = Parcel.obtain(); try { - parcel.unmarshall(data, 0, data.length); + parcel.unmarshall(stackData, 0, stackData.length); parcel.setDataPosition(0); return parcel.readParcelable(EditorElement.class.getClassLoader()); } finally { @@ -100,7 +117,6 @@ final class ElementStack implements Parcelable { @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(limit); - dest.writeInt(overflowed ? 1 : 0); final int count = stack.size(); dest.writeInt(count); for (int i = 0; i < count; i++) { @@ -108,11 +124,17 @@ final class ElementStack implements Parcelable { } } - boolean isEmpty() { - return stack.isEmpty(); - } + boolean stackContainsStateDifferentFrom(@NonNull EditorElement element) { + if (stack.isEmpty()) return false; - boolean isOverflowed() { - return overflowed; + byte[] currentStateBytes = getBytes(element); + + for (byte[] item : stack) { + if (!Arrays.equals(item, currentStateBytes)) { + return true; + } + } + + return false; } } diff --git a/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java b/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java index c0820e0d6..d7a1481ee 100644 --- a/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java +++ b/src/org/thoughtcrime/securesms/imageeditor/model/UndoRedoStacks.java @@ -2,19 +2,27 @@ package org.thoughtcrime.securesms.imageeditor.model; import android.os.Parcel; import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import java.util.Arrays; final class UndoRedoStacks implements Parcelable { private final ElementStack undoStack; private final ElementStack redoStack; - public UndoRedoStacks(int limit) { - this(new ElementStack(limit), new ElementStack(limit)); + @NonNull + private byte[] unchangedState; + + UndoRedoStacks(int limit) { + this(new ElementStack(limit), new ElementStack(limit), null); } - private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack) { + private UndoRedoStacks(ElementStack undoStack, ElementStack redoStack, @Nullable byte[] unchangedState) { this.undoStack = undoStack; this.redoStack = redoStack; + this.unchangedState = unchangedState != null ? unchangedState : new byte[0]; } public static final Creator CREATOR = new Creator() { @@ -22,7 +30,8 @@ final class UndoRedoStacks implements Parcelable { public UndoRedoStacks createFromParcel(Parcel in) { return new UndoRedoStacks( in.readParcelable(ElementStack.class.getClassLoader()), - in.readParcelable(ElementStack.class.getClassLoader()) + in.readParcelable(ElementStack.class.getClassLoader()), + in.createByteArray() ); } @@ -36,6 +45,7 @@ final class UndoRedoStacks implements Parcelable { public void writeToParcel(Parcel dest, int flags) { dest.writeParcelable(undoStack, flags); dest.writeParcelable(redoStack, flags); + dest.writeByteArray(unchangedState); } @Override @@ -50,4 +60,34 @@ final class UndoRedoStacks implements Parcelable { ElementStack getRedoStack() { return redoStack; } + + void pushState(@NonNull EditorElement element) { + if (undoStack.tryPush(element)) { + redoStack.clear(); + } + } + + void clear(@NonNull EditorElement element) { + undoStack.clear(); + redoStack.clear(); + unchangedState = ElementStack.getBytes(element); + } + + boolean isChanged(@NonNull EditorElement element) { + return !Arrays.equals(ElementStack.getBytes(element), unchangedState); + } + + /** + * As long as there is something different in the stack somewhere, then we can undo. + */ + boolean canUndo(@NonNull EditorElement currentState) { + return undoStack.stackContainsStateDifferentFrom(currentState); + } + + /** + * As long as there is something different in the stack somewhere, then we can redo. + */ + boolean canRedo(@NonNull EditorElement currentState) { + return redoStack.stackContainsStateDifferentFrom(currentState); + } } diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 2b00d2f51..5862d5948 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -118,13 +118,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - imageEditorHud = view.findViewById(R.id.scribble_hud); - imageEditorView = view.findViewById(R.id.image_editor_view); + imageEditorHud = view.findViewById(R.id.scribble_hud); + imageEditorView = view.findViewById(R.id.image_editor_view); imageEditorHud.setEventListener(this); imageEditorView.setTapListener(selectionListener); imageEditorView.setDrawingChangedListener(this::refreshUniqueColors); + imageEditorView.setUndoRedoStackListener(this::onUndoRedoAvailabilityChanged); EditorModel editorModel = null; @@ -321,6 +322,10 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha()); } + private void onUndoRedoAvailabilityChanged(boolean undoAvailable, boolean redoAvailable) { + imageEditorHud.setUndoAvailability(undoAvailable); + } + private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() { @Override diff --git a/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java index c00a753e3..50299db49 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java +++ b/src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java @@ -47,7 +47,10 @@ public final class ImageEditorHud extends LinearLayout { private ColorPaletteAdapter colorPaletteAdapter; private final Map> visibilityModeMap = new HashMap<>(); - private final Set allViews = new HashSet<>(); + private final Set allViews = new HashSet<>(); + + private Mode currentMode; + private boolean undoAvailable; public ImageEditorHud(@NonNull Context context) { super(context); @@ -171,9 +174,10 @@ public final class ImageEditorHud extends LinearLayout { } private void setMode(@NonNull Mode mode, boolean notify) { + this.currentMode = mode; Set visibleButtons = visibilityModeMap.get(mode); for (View button : allViews) { - button.setVisibility(visibleButtons != null && visibleButtons.contains(button) ? VISIBLE : GONE); + button.setVisibility(buttonIsVisible(visibleButtons, button) ? VISIBLE : GONE); } switch (mode) { @@ -189,6 +193,12 @@ public final class ImageEditorHud extends LinearLayout { eventListener.onRequestFullScreen(mode != Mode.NONE); } + private boolean buttonIsVisible(@Nullable Set visibleButtons, @NonNull View button) { + return visibleButtons != null && + visibleButtons.contains(button) && + (button != undoButton || undoAvailable); + } + private void presentModeCrop() { updateCropAspectLockImage(eventListener.isCropAspectLocked()); } @@ -216,6 +226,12 @@ public final class ImageEditorHud extends LinearLayout { return color & ~0xff000000 | 0x80000000; } + public void setUndoAvailability(boolean undoAvailable) { + this.undoAvailable = undoAvailable; + + undoButton.setVisibility(buttonIsVisible(visibilityModeMap.get(currentMode), undoButton) ? VISIBLE : GONE); + } + public enum Mode { NONE, DRAW, HIGHLIGHT, TEXT, MOVE_DELETE, CROP }