📜 ⬆️ ⬇️

JUndo - undo library for Java


Introduction


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 .


Opportunities



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.



How it works - a simple example

How it works - a simple example


The subject is the fictional class NonTrivialClass , with a list of two types of objects - RECT & CIRCLE.


0. Design


  • There is no binding to local context objects, we do not reflect them on the stack.
  • An instance of NonTrivialClass does not depend on the address context, so it can be a stack subject
  • For the same reason, we can keep references to the NonTrivialClass instance in commands

1. Create an instance, stack, and event watcher


 NonTrivialClass ntc = new NonTrivialClass(); UndoStack stack = new UndoStack(ntc, null); //  NonTrivialClass    stack.setWatcher(new SimpleUndoWatcher()); //  .    `UndoPacket...store()` 

2. Work on the subject


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(); 

3. Save history


 String store = UndoPacket //       . //      . .make(stack, "some.NonTrivialClass", 1) //    gzip .zipped(true) // NonTrivialClass  Serializable,    . //.onStore(...) .store(); 

SimpleUndoWatcher automatically.


4. Restoring somewhere in a different address context, use further


 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.


How it works - a complicated example.

How it works - a complicated example.


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.


0. Design ...


... teams

We manage the properties of an instance of the javafx.scene.shape.Circle class.


  • the class does not implement 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.
  • commands have the Caption property, which may depend on the context, because it is possible that recovery will occur in an application with a different location, and with a different version of string resources. Therefore, we will store resource identifiers, and query the rows themselves dynamically through local stack contexts.
  • 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); } 

... stack

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.


1. Create a stack and an observer of events


 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); 

2. Work on the subject


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()); } 

3. Save history


 /** *        . *         . *      ? *    ,              . *                ,        . */ 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()); } } 

4. Restoring somewhere in a different address context, use further


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.


About library design


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.


Classes


The library consists of the following main classes and interfaces:



Additional classes and interfaces:



Concepts




rules


One subject - one team stack


This rule is described above and is quite obvious from the elementary logic of the uniqueness of the chain of changes for the subject.


Any change in subject property — only through commands


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.


All that is not Serializable - via OnStore / OnRestore events


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.


All that is bound to addresses in memory is through local contexts.


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.


Small HowTo for additional options

Howto


Create macro


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 chain of commands without using macros


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