At the end of last year, I needed an undo / redo tool for a Java project that, in addition to the standard tasks for this concept, would be able to keep the history of commands and correctly handle the binding to a changing address context (this is with a view to my upcoming project for Android and its regular re-creation of views). I looked, I did not find it, I took it.
The result was the JUndo library .
The term "subject" in the context of the library is an element of the application, all without exception of which changes are made through commands. Failure to comply with this condition will unambiguously lead to unpredictable behavior of the undo / redo methods and, quite possibly, to the crash of the application.>
To be honest, it wasn’t too easy to do, mainly due to the maintenance of the concept of transferring commands to another address context.
The saving itself is based on the standard Java serialization mechanism, so all automatically saved library classes implement Serialize. Those that do not implement, you need to adjust the pens when saving / restoring. But, although it sounds cumbersome, in fact, is not so difficult.
Important points
The general rule is this: widgets, views, resources, and everything that is a dependent part of the local address space should be connected to local stack contexts and used dynamically. If you save them along with a stack of commands, then when you re-create in a new address space, trouble will surely happen.
The subject is the fictional class NonTrivialClass
, with a list of two types of objects - RECT & CIRCLE.
NonTrivialClass
does not depend on the address context, so it can be a stack subjectNonTrivialClass
instance in commands NonTrivialClass ntc = new NonTrivialClass(); UndoStack stack = new UndoStack(ntc, null); // NonTrivialClass stack.setWatcher(new SimpleUndoWatcher()); // . `UndoPacket...store()`
All changes over the subject - only through the team. More behaviors are required - add commands.
stack.push(new NonTrivialClass.AddCommand(stack, CIRCLE, ntc, null)); stack.push(new NonTrivialClass.AddCommand(stack, RECT, ntc, null)); stack.undo(); stack.redo(); stack.push(new NonTrivialClass.MovedCommand(stack, item, oldPos, null)); stack.undo(); stack.redo(); stack.push(new NonTrivialClass.DeleteCommand(stack, ntc, null)); stack.undo(); stack.undo(); stack.redo();
String store = UndoPacket // . // . .make(stack, "some.NonTrivialClass", 1) // gzip .zipped(true) // NonTrivialClass Serializable, . //.onStore(...) .store();
SimpleUndoWatcher
automatically.
UndoStack stackBack = UndoPacket // , .peek(store, null) .restore(null) .stack(null); // . stack.setWatcher(new SimpleUndoWatcher()); // , . stack.undo(); stack.redo(); stack.push(new NonTrivialClass.MovedCommand(stack, item, oldPos, null)); stack.undo(); stack.redo();
Nothing complicated, but nothing particularly interesting. The juice is how address contexts are handled during the transfer of history, how migration support is implemented, and other goodies.
This is part of the JavaFx code example .
The example further illustrates the subject's migration to another version and the use of local contexts, including string resources.
We manage the properties of an instance of the javafx.scene.shape.Circle
class.
Serializable
and is not even serialized to JSON without a tambourine, so we will not use it in the command fields. Instead, we will save the properties that we specifically control: ColorUndo
will store color
, RadiusUndo
- radius
and so on.javafx.scene.control.Slider
controls that change the x
, y
and radius
properties have the feature of generating events when the change is even a pixel. We have absolutely no need for 100 teams when transferring a subject by 100 pixels, only a command for the final position is needed. Therefore, we use the property of gluing commands using UndoCommand#id
// resId - Caption. public ColorUndo(@NotNull UndoStack owner, UndoCommand parent, int resId, Color oldV, Color newV) { super(owner, parent, resId, // Color Serializable, . FxGson.createWithExtras().toJson(oldV), FxGson.createWithExtras().toJson(newV)); } @Override protected void doRedo() { // . // , , . ColorPicker cp = (ColorPicker) owner.getLocalContexts().get(IDS_COLOR_PICKER); Color cl = FxGson.createWithExtras().fromJson(newV, Color.class); cp.setValue(cl); } @Override protected void doUndo() { // . // , , . ColorPicker cp = (ColorPicker) owner.getLocalContexts().get(IDS_COLOR_PICKER); Color cl = FxGson.createWithExtras().fromJson(oldV, Color.class); cp.setValue(cl); } @Override public int id() { return 1001; // XUndo (return 1002) YUndo (return 1003) } /** * , * , , {@link ColorUndo} , . * <p> , redo undo . */ @Override public boolean mergeWith(@NotNull UndoCommand cmd) { if(cmd instanceof RadiusUndo) { RadiusUndo ruCmd = (RadiusUndo)cmd; newV = ruCmd.newV; return true; } return false; } @Override public String getCaption() { // . // , , . Resources res = (Resources) owner.getLocalContexts().get(IDS_RES); return res.getString(resId); }
The properties of the subject are controlled through the scene widgets, and these are local contexts, since in a different address space all saved links will be invalid.
stack = new UndoStack(tab.shape, null); // stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RES, new Resources_V1()); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_COLOR_PICKER, tab.colorPicker); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RADIUS_SLIDER, tab.radius); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_X_SLIDER, tab.centerX); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_Y_SLIDER, tab.centerY); // UndoStack stack.setWatcher(this);
Work is performed automatically by linking to widget events and stack events.
// tab.shape.fillProperty().addListener( (observable, oldValue, newValue) -> stack.push(new BaseTab.UndoBulk.ColorUndo( stack, null, 0, (Color)oldValue, (Color)newValue) )); // undo/redo tab.undoBtn.setOnAction(event -> stack.undo()); tab.redoBtn.setOnAction(event -> stack.redo()); tab.saveBtn.setOnAction(event -> stack.setClean()); /** * {@link UndoWatcher} * @param idx */ @Override public void indexChanged(int idx) { tab.undoBtn.setDisable(!stack.canUndo()); tab.redoBtn.setDisable(!stack.canRedo()); tab.saveBtn.setDisable(stack.isClean()); tab.undoBtn.setText("undo: " + stack.undoCaption()); tab.redoBtn.setText("redo: " + stack.redoCaption()); }
/** * . * . * ? * , . * , . */ private void serialize() throws IOException { try { String store = UndoPacket .make(stack, IDS_STACK, 1) .onStore(new UndoPacket.OnStore() { @Override public Serializable handle(Object subj) { Map<String, Object> props = new HashMap<>(); Gson fxGson = FxGson.createWithExtras(); props.put("color", FxGson.createWithExtras().toJson(tab.shape.getFill())); props.put("radius", FxGson.createWithExtras().toJson(tab.shape.getRadius())); props.put("x", FxGson.createWithExtras().toJson(tab.shape.getCenterX())); props.put("y", FxGson.createWithExtras().toJson(tab.shape.getCenterY())); return fxGson.toJson(props); } }) .zipped(true) .store(); // . Files.write(Paths.get("./undo.txt"), store.getBytes()); } catch (Exception e) { System.err.println(e.getLocalizedMessage()); } }
In our situation, we restore the history for a new instance of the subject, on another tab and moreover - the new subject has a new type of Circle_V2
.
Here is how it is handled.
// String store = new String(Files.readAllBytes(Paths.get("./undo.txt"))); stack = UndoPacket // , , . .peek(store, subjInfo -> IDS_STACK.equals(subjInfo.id)) // -, ( ) // -, .restore((processedSubj, subjInfo) -> { // 1. Type type = new TypeToken<HashMap<String, Object>>(){}.getType(); HashMap<String, Object> map = new Gson().fromJson((String) processedSubj, type); if(subjInfo.version == 1) { // 2. Gson fxGson = FxGson.createWithExtras(); Color c = fxGson.fromJson(map.get("color").toString(), Color.class); tab.colorPicker.setValue(c); Double r = fxGson.fromJson(map.get("radius").toString(), Double.class); tab.radius.setValue(r); Double x = fxGson.fromJson(map.get("x").toString(), Double.class); tab.centerX.setValue(x); Double y = fxGson.fromJson(map.get("y").toString(), Double.class); tab.centerY.setValue(y); } return map; }).stack((stack, subjInfo) -> { // stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RES, new Resources_V2()); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_COLOR_PICKER, tab.colorPicker); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_RADIUS_SLIDER, tab.radius); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_X_SLIDER, tab.centerX); stack.getLocalContexts().put(BaseTab.UndoBulk.IDS_Y_SLIDER, tab.centerY); }); if(null == stack) stack = new UndoStack(tab.shape, null); // . stack.setWatcher(this);
The remaining actions for connecting the stack events and for the stack are similar to those in the "2.Work over the subject" step described earlier.
As can be seen, with proper design of a specific project, working with the library is quite logical and simple.
JUndo is a pattern implementation. A command for creating Undo / Redo chains in an application.
The command pattern is based on the assumption that all changes of the subjects are made through the creation of the objects of the corresponding commands. Command objects save the state of the subject, make the necessary changes, and are consistently stored on the command stack. Accordingly, each team knows how to return the subject to a previous state. As long as the “change through commands” model is not violated, there is always the option to roll back at any time, simply by sequentially executing Undo, command by command on the stack, and returning back, executing Redo in the forward direction.
The library consists of the following main classes and interfaces:
UndoCommand
: base class for commands stored on the stack. Apply the redo / undo command to an atomic change in a subject.UndoStack
: command stack. Contains a list of consecutively added command objects executed on a specific subject and can "roll back" the state of the subject to any point forward and backward.UndoGroup
: stack group. Grouping stacks is convenient when the application has more than one subject (for example, open documents), and it is necessary for each to store an individual undo / redo state. UndoGroup has an active stack property that allows you to seamlessly switch between subjects and perform undo / redo on each of them.UndoPacket
: class management of the preservation and restoration of the stack. Additional information that can be saved while saving allows you to learn how to correctly interpret the subjects on the recovery side, in particular, the version of the subject (which is useful when migrating data)UndoWatcher
: set of events for subscribers needing information about command stack events. All methods are defaulted, so the subscriber is not required to implement what he does not needAdditional classes and interfaces:
RefCmd<V>
: A convenient template command that allows you to implement simple data changes without creating additional classesGetter<V>
: Helper interface for the getter property of the RefCmd<V>
commandSetter<V>
: Helper interface for the setter property of the RefCmd<V>
commandThis rule is described above and is quite obvious from the elementary logic of the uniqueness of the chain of changes for the subject.
If commands do not control the total change of property from beginning to end, they do not control it at all.
Here we must understand that you can choose to control only certain properties, for example - color or size, the main thing is to follow this rule.
It will certainly look strange at work - but at least consistent.
Under the hood of the library - work with the ObjectOutputStream methods, and not only when saving / restoring. When the stack works with macros, serialization is also used.
Therefore, when designing commands, you should not save non-serializable types in the fields, since there is no way to process them - you should use dynamic references to them in the doUndo / doRedo methods and so on through calls to local stack contexts (UndoCommand # owner).
Non-serializable subjects should be manually converted to strings or another Serializable type in the onStore () / restore () methods.
When restoring a stack in a different address space, most likely all saved links will become invalid. Therefore, you should get rid of their preservation as much as possible, replacing them with work with local contexts.
A macro is a given sequence of commands for automating a chain of actions. A characteristic feature of the macro is the possibility of its use at any time, as an independent atomic macro with a write to the stack. For example, the macro
stack.beginMacro("new line"); AddLineCmd(stack, "add line", null) AddSymbolCmd(stack, "add char", ":", null) AddSymbolCmd(stack, "add char", "~", null) AddSymbolCmd(stack, "add char", "$", null) AddSymbolCmd(stack, "add char", " ", null) stack.endMacro();
called at any time should add a new line and characters in it
// :~$ String 1 :~$ String 2 :~$ String 3| // stack.push(some_macro); // :~$ String 1 :~$ String 2 :~$ String 3 :~$ |
UndoStackTest
contains a testRealMacros()
test that describes how it works:
... // stack.beginMacro("macro 1"); stack.push(new TextSampleCommands.AddString(stack, "new string", "Hello", null)); stack.push(new TextSampleCommands.AddString(stack, "new string", ", ", null)); stack.push(new TextSampleCommands.AddString(stack, "new string", "world!", null)); // , "Hello, world!" stack.endMacro(); ... // - UndoCommand macro = stack.clone(stack.getMacros().get(0)); stack.push(macro); // "Hello, world!" stack.undo(); // "Hello, world!" stack.redo(); // "Hello, world!" ... }
Naturally, macros are also saved and restored along with the stack.
Create a basic command of the UndoCommand type, and for all subsequent ones, specify it as a parent. Add only the first command to the stack. Thus, all subsequent commands will not be in the stack, but in the list of subcommands of the first command, and will automatically be executed when UndoStack.undo/redo
called:
UndoCommand parent = new UndoCommand("Add robot"); new AddShapeCommand(doc, ShapeRectangle, parent); new AddShapeCommand(doc, ShapeRectangle, parent); new AddShapeCommand(doc, ShapeRectangle, parent); new AddShapeCommand(doc, ShapeRectangle, parent); doc.undoStack().push(parent);
Source: https://habr.com/ru/post/347128/
All Articles