From 0989e1af91726688d84a144de517a97c48cba4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereda?= Date: Thu, 24 Feb 2022 10:21:58 +0100 Subject: [PATCH] Add redo/undoStackSize properties to track stack sizes (#53) * Add redo/undoStackSize properties to track stack sizes * Use boolean and move stackEmpty properties to viewModel only * Fix tests Co-authored-by: jose.pereda --- src/main/java/com/gluonhq/Main.java | 9 +- .../com/gluonhq/richtext/RichTextArea.java | 45 +++--- .../gluonhq/richtext/RichTextAreaSkin.java | 4 +- .../gluonhq/richtext/model/PieceTable.java | 12 +- .../gluonhq/richtext/model/TextBuffer.java | 1 + .../gluonhq/richtext/undo/CommandManager.java | 26 +++- .../viewmodel/RichTextAreaViewModel.java | 27 +++- .../richtext/undo/CmdManagerTests.java | 130 ++++++++++++++++-- 8 files changed, 209 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/gluonhq/Main.java b/src/main/java/com/gluonhq/Main.java index 082d08e1..6ca43b58 100644 --- a/src/main/java/com/gluonhq/Main.java +++ b/src/main/java/com/gluonhq/Main.java @@ -91,14 +91,19 @@ public Double fromString(String s) { CheckBox editableProp = new CheckBox("Editable"); editableProp.selectedProperty().bindBidirectional(editor.editableProperty()); + Button undoButton = actionButton(LineAwesomeSolid.UNDO, editor.getActionFactory().undo()); + undoButton.disableProperty().bind(editor.undoStackEmptyProperty()); + Button redoButton = actionButton(LineAwesomeSolid.REDO, editor.getActionFactory().redo()); + redoButton.disableProperty().bind(editor.redoStackEmptyProperty()); + ToolBar toolbar = new ToolBar(); toolbar.getItems().setAll( actionButton(LineAwesomeSolid.CUT, editor.getActionFactory().cut()), actionButton(LineAwesomeSolid.COPY, editor.getActionFactory().copy()), actionButton(LineAwesomeSolid.PASTE, editor.getActionFactory().paste()), new Separator(Orientation.VERTICAL), - actionButton(LineAwesomeSolid.UNDO, editor.getActionFactory().undo()), - actionButton(LineAwesomeSolid.REDO, editor.getActionFactory().redo()), + undoButton, + redoButton, new Separator(Orientation.VERTICAL), fontFamilies, fontSize, diff --git a/src/main/java/com/gluonhq/richtext/RichTextArea.java b/src/main/java/com/gluonhq/richtext/RichTextArea.java index eff2a26b..b2efc023 100644 --- a/src/main/java/com/gluonhq/richtext/RichTextArea.java +++ b/src/main/java/com/gluonhq/richtext/RichTextArea.java @@ -2,11 +2,16 @@ import com.gluonhq.richtext.viewmodel.ActionFactory; -import javafx.beans.property.*; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.ReadOnlyIntegerWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleBooleanProperty; import javafx.scene.control.Control; import javafx.scene.control.SkinBase; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; import java.util.Objects; @@ -61,22 +66,23 @@ public final int getTextLength() { return textLengthProperty.get(); } -// // codecProperty -// private final ObjectProperty codecProperty = new SimpleObjectProperty<>(this, "codec"); -// public final ObjectProperty codecProperty() { -// return codecProperty; -// } -// public final Codec getCodec() { -// return codecProperty.get(); -// } -// public final void setCodec(Codec value) { -// codecProperty.set(value); -// } -// -// public interface Codec { -// OutputStream decode(List nodes); -// List encode(InputStream stream); -// } + // undoStackSizeProperty + final ReadOnlyBooleanWrapper undoStackEmptyProperty = new ReadOnlyBooleanWrapper(this, "undoStackEmpty"); + public ReadOnlyBooleanProperty undoStackEmptyProperty() { + return undoStackEmptyProperty.getReadOnlyProperty(); + } + public boolean isUndoStackEmpty() { + return undoStackEmptyProperty.get(); + } + + // redoStackSizeProperty + final ReadOnlyBooleanWrapper redoStackEmptyProperty = new ReadOnlyBooleanWrapper(this, "redoStackEmpty"); + public ReadOnlyBooleanProperty redoStackEmptyProperty() { + return redoStackEmptyProperty.getReadOnlyProperty(); + } + public boolean isRedoStackEmpty() { + return redoStackEmptyProperty.get(); + } public void execute( Action action ) { if ( getSkin() instanceof RichTextAreaSkin ) { @@ -90,6 +96,5 @@ public ActionFactory getActionFactory() { return RichTextAreaSkin.getActionFactory(); } - } diff --git a/src/main/java/com/gluonhq/richtext/RichTextAreaSkin.java b/src/main/java/com/gluonhq/richtext/RichTextAreaSkin.java index 7e1e460f..2b1719dc 100644 --- a/src/main/java/com/gluonhq/richtext/RichTextAreaSkin.java +++ b/src/main/java/com/gluonhq/richtext/RichTextAreaSkin.java @@ -53,7 +53,7 @@ interface ActionBuilder extends Function{} entry( new KeyCodeCombination(X, SHORTCUT_DOWN), e -> ACTION_FACTORY.cut()), entry( new KeyCodeCombination(V, SHORTCUT_DOWN), e -> ACTION_FACTORY.paste()), entry( new KeyCodeCombination(Z, SHORTCUT_DOWN), e -> ACTION_FACTORY.undo()), - entry( new KeyCodeCombination(Z, SHORTCUT_DOWN, SHIFT_DOWN), e -> ACTION_FACTORY.paste()), + entry( new KeyCodeCombination(Z, SHORTCUT_DOWN, SHIFT_DOWN), e -> ACTION_FACTORY.redo()), entry( new KeyCodeCombination(ENTER, SHIFT_ANY), e -> ACTION_FACTORY.insertText("\n")), entry( new KeyCodeCombination(BACK_SPACE, SHIFT_ANY), e -> ACTION_FACTORY.removeText(-1)), entry( new KeyCodeCombination(DELETE), e -> ACTION_FACTORY.removeText(0)), @@ -114,6 +114,8 @@ protected RichTextAreaSkin(final RichTextArea control) { control.editableProperty().addListener(this::editableChangeListener); editableChangeListener(null); // sets up all related listeners control.textLengthProperty.bind(viewModel.textLengthProperty()); + control.undoStackEmptyProperty.bind(viewModel.undoStackEmptyProperty()); + control.redoStackEmptyProperty.bind(viewModel.redoStackEmptyProperty()); //TODO remove listener on viewModel change viewModel.caretPositionProperty().addListener( (o,ocp, p) -> { diff --git a/src/main/java/com/gluonhq/richtext/model/PieceTable.java b/src/main/java/com/gluonhq/richtext/model/PieceTable.java index 8884156a..1435d290 100644 --- a/src/main/java/com/gluonhq/richtext/model/PieceTable.java +++ b/src/main/java/com/gluonhq/richtext/model/PieceTable.java @@ -109,8 +109,8 @@ Piece appendTextInternal(String text, TextDecoration decoration) { * @param text new text */ @Override - public void append( String text ) { - commander.execute( new AppendCmd(text)); + public void append(String text) { + commander.execute(new AppendCmd(text)); } @Override @@ -135,8 +135,8 @@ public void walkFragments(BiConsumer onFragment) { * @throws IllegalArgumentException if insertPosition is not valid */ @Override - public void insert( final String text, final int insertPosition ) { - commander.execute( new InsertCmd(text, insertPosition)); + public void insert(final String text, final int insertPosition) { + commander.execute(new InsertCmd(text, insertPosition)); } /** @@ -146,8 +146,8 @@ public void insert( final String text, final int insertPosition ) { * @throws IllegalArgumentException if deletePosition is not valid */ @Override - public void delete( final int deletePosition, int length ) { - commander.execute( new DeleteCmd(deletePosition, length)); + public void delete(final int deletePosition, int length) { + commander.execute(new DeleteCmd(deletePosition, length)); } /** diff --git a/src/main/java/com/gluonhq/richtext/model/TextBuffer.java b/src/main/java/com/gluonhq/richtext/model/TextBuffer.java index f47e9657..bf446862 100644 --- a/src/main/java/com/gluonhq/richtext/model/TextBuffer.java +++ b/src/main/java/com/gluonhq/richtext/model/TextBuffer.java @@ -1,5 +1,6 @@ package com.gluonhq.richtext.model; +import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyIntegerProperty; import java.text.CharacterIterator; diff --git a/src/main/java/com/gluonhq/richtext/undo/CommandManager.java b/src/main/java/com/gluonhq/richtext/undo/CommandManager.java index 9100dc8e..9ea9e4f7 100644 --- a/src/main/java/com/gluonhq/richtext/undo/CommandManager.java +++ b/src/main/java/com/gluonhq/richtext/undo/CommandManager.java @@ -9,15 +9,22 @@ public class CommandManager { final Deque> undoStack = new ArrayDeque<>(); final Deque> redoStack = new ArrayDeque<>(); final T context; + private final Runnable runnable; - public CommandManager(T context ) { + public CommandManager(T context) { + this(context, null); + } + + public CommandManager(T context, Runnable runnable) { this.context = context; + this.runnable = runnable; } - public void execute( AbstractCommand cmd ) { + public void execute(AbstractCommand cmd) { Objects.requireNonNull(cmd).redo(context); undoStack.push(cmd); redoStack.clear(); + end(); } public void undo() { @@ -25,7 +32,7 @@ public void undo() { var cmd = undoStack.pop(); cmd.undo(context); redoStack.push(cmd); - + end(); } } @@ -34,8 +41,21 @@ public void redo() { var cmd = redoStack.pop(); cmd.redo(context); undoStack.push(cmd); + end(); } } + public boolean isUndoStackEmpty() { + return undoStack.isEmpty(); + } + + public boolean isRedoStackEmpty() { + return redoStack.isEmpty(); + } + private void end() { + if (runnable != null) { + runnable.run(); + } + } } diff --git a/src/main/java/com/gluonhq/richtext/viewmodel/RichTextAreaViewModel.java b/src/main/java/com/gluonhq/richtext/viewmodel/RichTextAreaViewModel.java index 5597e76e..9bf39a6c 100644 --- a/src/main/java/com/gluonhq/richtext/viewmodel/RichTextAreaViewModel.java +++ b/src/main/java/com/gluonhq/richtext/viewmodel/RichTextAreaViewModel.java @@ -7,6 +7,8 @@ import com.gluonhq.richtext.undo.CommandManager; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.ReadOnlyIntegerProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; @@ -29,7 +31,7 @@ public class RichTextAreaViewModel { public enum Direction { FORWARD, BACK, UP, DOWN } private final TextBuffer textBuffer; - private final CommandManager commandManager = new CommandManager<>(this); + private final CommandManager commandManager = new CommandManager<>(this, this::updateProperties); private BreakIterator wordIterator; @@ -80,6 +82,24 @@ public final int getTextLength() { return textBuffer.getTextLength(); } + // undoStackSizeProperty + final ReadOnlyBooleanWrapper undoStackEmptyProperty = new ReadOnlyBooleanWrapper(this, "undoStackEmpty", true); + public ReadOnlyBooleanProperty undoStackEmptyProperty() { + return undoStackEmptyProperty.getReadOnlyProperty(); + } + public boolean isUndoStackEmpty() { + return undoStackEmptyProperty.get(); + } + + // redoStackSizeProperty + final ReadOnlyBooleanWrapper redoStackEmptyProperty = new ReadOnlyBooleanWrapper(this, "redoStackEmpty", true); + public ReadOnlyBooleanProperty redoStackEmptyProperty() { + return redoStackEmptyProperty.getReadOnlyProperty(); + } + public boolean isRedoStackEmpty() { + return redoStackEmptyProperty.get(); + } + public RichTextAreaViewModel(TextBuffer textBuffer, BiFunction getNextRowPosition) { this.textBuffer = Objects.requireNonNull(textBuffer); // TODO convert to property this.getNextRowPosition = Objects.requireNonNull(getNextRowPosition); @@ -313,4 +333,9 @@ private void lineEnd() { int pos = getNextRowPosition.apply(Double.MAX_VALUE, false); setCaretPosition(pos); } + + private void updateProperties() { + undoStackEmptyProperty.set(commandManager.isUndoStackEmpty()); + redoStackEmptyProperty.set(commandManager.isRedoStackEmpty()); + } } diff --git a/src/test/java/com/gluonhq/richtext/undo/CmdManagerTests.java b/src/test/java/com/gluonhq/richtext/undo/CmdManagerTests.java index 02f969aa..fa498b9a 100644 --- a/src/test/java/com/gluonhq/richtext/undo/CmdManagerTests.java +++ b/src/test/java/com/gluonhq/richtext/undo/CmdManagerTests.java @@ -4,21 +4,24 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + public class CmdManagerTests { - @Test - @DisplayName("Passes command execution rules") - public void executionRules() { + @Test + @DisplayName("Passes command execution rules") + public void executionRules() { - StringBuilder text = new StringBuilder("Text"); - CommandManager commander = new CommandManager<>(text); - commander.execute( new TestCommand()); + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute( new TestCommand()); - Assertions.assertEquals( 1, commander.undoStack.size()); - Assertions.assertEquals( 0, commander.redoStack.size()); - Assertions.assertEquals( "Text-redo", commander.context.toString()); + Assertions.assertEquals( 1, commander.undoStack.size()); + Assertions.assertEquals( 0, commander.redoStack.size()); + Assertions.assertEquals( "Text-redo", commander.context.toString()); - } + } @Test @DisplayName("Passes undo rules") @@ -43,7 +46,7 @@ public void redoRules() { StringBuilder text = new StringBuilder("Text"); CommandManager commander = new CommandManager<>(text); - commander.execute( new TestCommand()); + commander.execute(new TestCommand()); commander.undo(); commander.redo(); Assertions.assertEquals( 1, commander.undoStack.size()); @@ -52,6 +55,109 @@ public void redoRules() { } + @Test + @DisplayName("undo and redo stack must be empty initially") + public void undoAndRedoStackEmpty() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + Assertions.assertAll( + () -> Assertions.assertTrue(commander.isUndoStackEmpty()), + () -> Assertions.assertTrue(commander.isRedoStackEmpty()) + ); + } + + @Test + @DisplayName("undo stack must not be empty after a command execution") + public void undoStackNotEmptyAfterCommandExecution() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + Assertions.assertFalse(commander.isUndoStackEmpty()); + } + + @Test + @DisplayName("redo stack must be empty after a command execution") + public void redoStackEmptyAfterCommandExecution() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + Assertions.assertTrue(commander.isRedoStackEmpty()); + } + + @Test + @DisplayName("undo stack must be empty after a undo operation") + public void undoStackEmptyAfterUndoExecution() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + commander.undo(); + Assertions.assertTrue(commander.isUndoStackEmpty()); + } + + @Test + @DisplayName("redo stack must not be empty after undo operation") + public void redoStackNotEmptyAfterUndoOperation() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + commander.undo(); + Assertions.assertFalse(commander.isRedoStackEmpty()); + } + + @Test + @DisplayName("redo stack must be empty after redo operation") + public void redoStackEmptyAfterRedoOperation() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + commander.undo(); + commander.redo(); + Assertions.assertTrue(commander.isRedoStackEmpty()); + } + + @Test + @DisplayName("undo stack must be not empty after redo operation") + public void undoStackNotEmptyAfterRedoOperation() { + StringBuilder text = new StringBuilder("Text"); + CommandManager commander = new CommandManager<>(text); + commander.execute(new TestCommand()); + commander.undo(); + commander.redo(); + Assertions.assertFalse(commander.isUndoStackEmpty()); + } + + @Test + @DisplayName("runnable called when command executed") + public void runnableIsCalledWhenCommandIsExecuted() { + StringBuilder text = new StringBuilder("Text"); + AtomicInteger aInteger = new AtomicInteger(); + CommandManager commander = new CommandManager<>(text, aInteger::incrementAndGet); + commander.execute(new TestCommand()); + Assertions.assertEquals(1, aInteger.get()); + } + + @Test + @DisplayName("runnable called when undo is executed") + public void runnableIsCalledWhenUndoIsExecuted() { + StringBuilder text = new StringBuilder("Text"); + AtomicInteger aInteger = new AtomicInteger(); + CommandManager commander = new CommandManager<>(text, aInteger::incrementAndGet); + commander.execute(new TestCommand()); + commander.undo(); + Assertions.assertEquals(2, aInteger.get()); + } + + @Test + @DisplayName("runnable called when redo is executed") + public void runnableIsCalledWhenRedoIsExecuted() { + StringBuilder text = new StringBuilder("Text"); + AtomicInteger aInteger = new AtomicInteger(); + CommandManager commander = new CommandManager<>(text, aInteger::incrementAndGet); + commander.execute(new TestCommand()); + commander.undo(); + commander.redo(); + Assertions.assertEquals(3, aInteger.get()); + } } class TestCommand extends AbstractCommand { @@ -61,7 +167,7 @@ class TestCommand extends AbstractCommand { @Override protected void doUndo(StringBuilder context) { - context.delete( pos, pos+length); + context.delete(pos, pos+length); } @Override