Commit ec81b433 authored by Yuanle Song's avatar Yuanle Song
Browse files

make undo work.

- implemented undo using snapshots.
  I kept recent 1000 snapshots in a bounded stack.
  note: snapshot must clone the underlying array instead of shadow copying.
- bugfix: when input invalid number like "2.3.", the error msg was not
  shown on #error-msg.
- use monospace font on #number-display
parent 64b2e803
Loading
Loading
Loading
Loading
+65 −0
Original line number Diff line number Diff line
@@ -64,6 +64,7 @@
	  border: 1px skyblue solid;
      }
      #number-display {
	  font-family: monospace;
	  border: 1px grey solid;
	  width: 100%;
	  max-width: 150px;	/*just set to something small and it works in chrome.*/
@@ -393,6 +394,67 @@
	      console.assert(m.numberStack[0] === -1);
	      console.assert(m.lastError === null);
	  };
	  const rpnTestUndo = function () {
	      const m = new fsm.RPNCalculator();
	      console.assert(m.numberStack.length === 0);
	      m.sendKey("num1");
	      m.sendKey("return");
	      console.assert(m.snapshots.data.length === 1);
	      console.assert(m.snapshots.data[0].numberStack.length === 0);
	      m.sendKey("num2");
	      m.sendKey("return");
	      console.assert(m.snapshots.data.length === 2);
	      console.assert(m.snapshots.data[0].numberStack.length === 0);
	      console.assert(m.snapshots.data[1].numberStack.length === 1);
	      console.assert(m.snapshots.data[1].numberStack[0] === 1);

	      console.assert(m.numberStack.length === 2);
	      console.assert(m.numberStack[0] === 1);
	      console.assert(m.numberStack[1] === 2);

	      m.sendKey("undo");
	      console.assert(m.numberStack.length === 1);
	      console.assert(m.numberStack[0] === 1);
	      m.sendKey("num3");
	      m.sendKey("return");
	      m.sendKey("plus");
	      m.sendKey("undo");
	      m.sendKey("times");
	      console.assert(m.numberStack.length === 1);
	      console.assert(m.numberStack[0] === 3);
	      m.sendKey("undo");
	      m.sendKey("minus");
	      console.assert(m.numberStack.length === 1);
	      console.assert(m.numberStack[0] === -2);
	  };
	  const rpnTestUndoWithErrors = function () {
	      var m = new fsm.RPNCalculator();
	      console.assert(m.numberStack.length === 0);
	      m.sendKey("num1");
	      m.sendKey("return");
	      m.sendKey("plus");
	      console.assert(m.numberStack.length === 1);
	      m.sendKey("undo");
	      console.assert(m.numberStack.length === 0);

	      m = new fsm.RPNCalculator();
	      console.assert(m.numberStack.length === 0);
	      m.sendKey("num8");
	      m.sendKey("return");
	      m.sendKey("num1");
	      m.sendKey("return");
	      m.sendKey("num0");
	      m.sendKey("divide");
	      m.sendKey("undo");
	      console.assert(m.numberStack.length === 3);
	      console.assert(m.numberStack[0] === 8);
	      console.assert(m.numberStack[1] === 1);
	      console.assert(m.numberStack[2] === 0);
	      m.sendKey("undo");
	      console.assert(m.numberStack.length === 2);
	      console.assert(m.numberStack[0] === 8);
	      console.assert(m.numberStack[1] === 1);
	  };

	  /**
	   * run all tests.
@@ -417,6 +479,9 @@
	      rpnTestNumberErrorHandling();
	      rpnTestNotEnoughElementOnStack();
	      rpnBackspaceOperator();

	      rpnTestUndo();
	      rpnTestUndoWithErrors();
	  };

	  /**
+63 −0
Original line number Diff line number Diff line
@@ -17,6 +17,11 @@ var fifo = function () {
	this.clear = function () {
	    this.data = [];
	};
	this.clone = function () {
	    const r = new Queue();
	    r.data = this.data.slice(0);
	    return r;
	};
    };

    /**
@@ -46,6 +51,46 @@ var fifo = function () {
	this.clear = function () {
	    this.data = [];
	};
	this.clone = function () {
	    const r = new BoundedQueue(this.capacity);
	    r.data = this.data.slice(0);
	    return r;
	};
    };

    /**
     * a bounded stack. Use push and pop to add or remove elements.
     * when stack is full, it discard early entries.
     * you can also read the raw elements in this.data.
     */
    const BoundedStack = function (capacity) {
	this.capacity = capacity;
	this.data = [];
	/**
	 * push new element to the BoundedStack. if there is no room for it,
	 * remove the oldest element from the stack.
	 */
	this.push = function (e) {
	    console.assert(this.data.length <= this.capacity, "bounded stack data size is invalid");
	    if (this.data.length === this.capacity) {
		this.data.shift();
	    }
	    this.data.push(e);
	};
	/**
	 * note: pop() will return undefined when there is not enough element.
	 */
	this.pop = function () {
	    return this.data.pop();
	};
	this.clear = function () {
	    this.data = [];
	};
	this.clone = function () {
	    const r = new BoundedStack(this.capacity);
	    r.data = this.data.slice(0);
	    return r;
	};
    };

    const testFIFO = function () {
@@ -73,6 +118,23 @@ var fifo = function () {
	console.assert(q.pop() === 2);
	console.assert(q.pop() === 3);
	console.assert(q.pop() === 4);

	q = new BoundedStack(3);
	q.push(1);
	q.push(2);
	q.push(3);
	console.assert(q.pop() === 3);
	console.assert(q.pop() === 2);
	console.assert(q.pop() === 1);

	q = new BoundedStack(3);
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);
	console.assert(q.pop() === 4);
	console.assert(q.pop() === 3);
	console.assert(q.pop() === 2);
    };
    const testClearFunction = function () {
	q = new BoundedQueue(3);
@@ -93,5 +155,6 @@ var fifo = function () {
    return {
	Queue: Queue,
	BoundedQueue: BoundedQueue,
	BoundedStack: BoundedStack,
    };
}();
+62 −9
Original line number Diff line number Diff line
@@ -125,13 +125,15 @@ var fsm = function () {
	    "change-sign": "chs",
	};
	const msgTooFewElementsOnStack = "Too few elements on stack";
	const trailHistorySize = 50;
	const trailHistorySize = 100;
	const undoHistorySize = 100;

    	this.currentState = stateIdle;
    	this.numberStack = [];
    	this.currentNumber = "";
	this.lastError = null;
	this.trail = new fifo.BoundedQueue(trailHistorySize);
	this.snapshots = new fifo.BoundedStack(undoHistorySize);

	/**
	 * save current state of this FSM. You can use load to restore the FSM state.
@@ -139,9 +141,9 @@ var fsm = function () {
	this.save = function () {
	    return {
		"currentState": this.currentState,
		"numberStack": this.numberStack,
		"numberStack": this.numberStack.slice(0),
		"currentNumber": this.currentNumber,
		"trail": this.trail,
		"trail": this.trail.clone(),
	    };
	};
	/**
@@ -149,9 +151,9 @@ var fsm = function () {
	 */
	this.load = function (data) {
	    this.currentState = data.currentState;
	    this.numberStack = data.numberStack;
	    this.numberStack = data.numberStack.slice(0);
	    this.currentNumber = data.currentNumber;
	    this.trail = data.trail;
	    this.trail = data.trail.clone();
	};
	/**
	 * set error msg. only the last error msg can be fetched via this.lastError.
@@ -177,15 +179,19 @@ var fsm = function () {
	 * be set when false is returned.
	 */
	this.doBinaryOperator = function (keyName, func) {
	    this.snapshots.push(this.save());

	    const num2 = this.numberStack.pop();
	    if (num2 === undefined) {
		this.setErrorMsg(msgTooFewElementsOnStack);
		this.snapshots.pop();
		return false;
	    }
	    const num1 = this.numberStack.pop();
	    if (num1 === undefined) {
		this.setErrorMsg(msgTooFewElementsOnStack);
		this.numberStack.push(num2);
		this.snapshots.pop();
		return false;
	    }
	    const result = func(num1, num2);
@@ -216,7 +222,7 @@ var fsm = function () {
	const operatorKeyList = [
	    keyNames.change_sign,
	    keyNames.plus, keyNames.minus, keyNames.times, keyNames.divide,
	    keyNames.swap,
	    keyNames.swap, keyNames.undo,
	];
	const isOperator = function (keyName) {
	    return operatorKeyList.indexOf(keyName) !== -1;
@@ -289,6 +295,21 @@ var fsm = function () {
	const shortKeyName = function (keyName) {
	    return shortKeyNames[keyName];
	};
	const printSnapshots = function (snapshots) {
	    debug("printSnapshots()");
	    const data = snapshots.data;
	    const l = data.length;
	    debug("len=" + l);
	    var i;
	    for (i = 0; i < l; ++i) {
		debug("snapshot[" + i + "]: " + data[i].currentState + ", " + data[i].numberStack);
	    }
	};
	const printSnapshot = function (snapshot) {
	    const msg = "snapshot: " + snapshot.currentState + ", " + snapshot.numberStack;
	    debug(msg);
	    return msg;
	};
    	this.sendKey = function (keyName) {
	    var num, num1, num2;
	    var numString, errMsg;
@@ -302,17 +323,22 @@ var fsm = function () {
		    errMsg = r[1];
		    if (errMsg === null) {
			this.currentNumber = numString;
			// change state
			this.currentState = stateWaitingForNumberOrAction;
		    } else {
			// idle state always succeed on number key input.
			console.assert(false, "this should not happen");
			this.setErrorMsg(errMsg);
			return;
		    }
		    // change state
		    this.currentState = stateWaitingForNumberOrAction;
		} else {
		    switch (keyName) {
		    case keyNames.change_sign:
			this.snapshots.push(this.save());
			num = this.numberStack.pop();
			if (num === undefined) {
			    this.setErrorMsg(msgTooFewElementsOnStack);
			    this.snapshots.pop();
			    return;
			}
		    	this.numberStack.push(-num);
@@ -346,15 +372,18 @@ var fsm = function () {
			}
		    	break;
		    case keyNames.swap:
			this.snapshots.push(this.save());
		    	num2 = this.numberStack.pop();
			if (num2 === undefined) {
			    this.setErrorMsg(msgTooFewElementsOnStack);
			    this.snapshots.pop();
			    return;
			}
		    	num1 = this.numberStack.pop();
			if (num1 === undefined) {
			    this.setErrorMsg(msgTooFewElementsOnStack);
			    this.numberStack.push(num2);
			    this.snapshots.pop();
			    return;
			}
		    	this.numberStack.push(num2);
@@ -363,9 +392,11 @@ var fsm = function () {
		    	break;
		    case keyNames['return']:
			// duplicate number
			this.snapshots.push(this.save());
			num = this.numberStack.pop();
			if (num === undefined) {
			    this.setErrorMsg(msgTooFewElementsOnStack);
			    this.snapshots.pop();
			    return;
			}
			this.numberStack.push(num);
@@ -374,12 +405,28 @@ var fsm = function () {
		    	break;
		    case keyNames.backspace:
			// drop one number on stack
			this.snapshots.push(this.save());
			num = this.numberStack.pop();
			if (num === undefined) {
			    this.setErrorMsg(msgTooFewElementsOnStack);
			    this.snapshots.pop();
			    return;
			}
		    	break;
		    case keyNames.undo:
			printSnapshots(this.snapshots);

			r = this.snapshots.pop();
			if (r === undefined) {
			    this.setErrorMsg("No more undo history!");
			    return;
			} else {
			    console.log("undo: load saved state: " + printSnapshot(r));
			    this.load(r);
			    this.setErrorMsg("Undo!");
			    // this is a success msg, no need to do early return.
			}
			break;
		    default:
		    	console.assert(false, "action not implemented, key is " + keyName);
		    }
@@ -394,15 +441,21 @@ var fsm = function () {
			this.currentNumber = numString;
		    } else {
			this.setErrorMsg(errMsg);
			return;
		    }
		    // keep current state
		} else if (keyName === keyNames['return']) {
		    // commit number to stack
		    num = parseFloat(this.currentNumber);
		    this.numberStack.push(num);
		    this.currentNumber = "";
		    this.currentState = stateIdle;

		    r = this.save();
		    // console.log("save a snapshot: " + r.currentState
		    // 		+ ", " + r.numberStack + ", " + r.currentNumber);
		    this.snapshots.push(r);

		    this.numberStack.push(num);
		    this.trail.push([null, num]);
		} else if (keyName === keyNames.backspace) {
		    if (this.currentNumber.length > 0) {
+104 −1
Original line number Diff line number Diff line
@@ -24,6 +24,8 @@ Time-stamp: <2017-01-27>
  | waiting for number or action | press < key               | modify number, then waiting for number or action |
  | idle                         | press return key          | dup number, then idle                            |
  | idle                         | press < key               | drop number on stack, then idle                  |
  | idle                         | press undo                | restore to previous snapshot, then idle          |
  | waiting for number or action | press undo                | commit number, then do undo, then idle           |

  There is no ending state, the machine can always accept new keyboard events.

@@ -56,6 +58,25 @@ the parent DOM.

* current                                                             :entry:
** 
** 2017-01-27 add a button: sqrt				 :featurereq:
** 2017-01-27 add a button: sum-all, it will sum all numbers on stack. :featurereq:
** 2017-01-27 accept keyboard event as well.		     :low:featurereq:
- num0 to num9
- dot
- n for change-sign
- enter for return
- backspace
- +, -, *, /
  not sure about
  shift+= => +
  shift+8 => *

- tab for swap

- This is only useful on desktop. Not very useful on mobile.
  Can I skip a JS code block when executing on mobile browser that doesn't
  have physical keyboard?

** 2017-01-27 add some visual feedback when clicking a button. like in material design.
** 2017-01-27 UI problem: when input a very large number, #number-display and keyboard will grow.
should set a max-width on #number-display.
@@ -64,8 +85,90 @@ overflow: auto
doesn't work with <td> in firefox.

** 2017-01-26 make it work offline, add sw.js
** 2017-01-26 make undo work
* done                                                                :entry:
** 2017-01-26 make undo work
- action based or snapshot based?
- how does "undo" fit in the FSM states?
  it's an op, like swap/return.

  what can undo do?

  undo commit a number. (this just undo a return misc command)
  undo an op. (restore stack)

  it will not undo backspace when in "waiting for number or op" state.

- how to implement it?

  init FSM:
  this.snapshots = new fifo.BoundedQueue(100);

  on FSM op handler:
  this.snapshots.push(this.save());
  //do op

  on FSM "undo" op handler:
  this.load(this.snapshots.pop());

  when user undo, show "Undo!" in error msg.
  if this.snapshots.pop() return undefined, undo fail with error msg "No more undo history!".

  It can undo the two "clear" and "reset" commands as well. Just take snapshot
  before running those.

- undo does not write undo op to trail. it unwind the whole FSM.

  in emacs, undo doesn't change trail at all.
  should I keep trail data in this.save()?
  I will just restore trail to previous saved snapshot.

- can I undo a undo? no.

  s0, e1 => s1
  s1, e2 => s2
  s2, undo => s1
  s1, e3 => s3
  s3, undo => s2
  s2, undo => s1
  s1, undo => s0

  DONE add unit test for this.

- problems
  - when to take snapshot?

    num1
    return    {[]} [1]
    num2
    return    {[1]} [1, 2]
    undo

    my initial design has no problem. do it before each op.
  - save snapshot doesn't work.

    when commit number to stack,

    r = this.save()
    r is undefined.
    r["numberStack"] is a mess. not an array at all.

    is it about the waiting state?
    when doing the push, the current state is still waiting state.
    do it before modifying stack instead. I don't want to take a snapshot on waiting state.

  - it's a shadow copy problem. save() returns a reference to the same
    array. that will create lots of problems. I need a copy there.

    see example in ./test-shadow-copy.html

    how to clone array in javascript?
    use .slice(0) on the array.

  - I see the problem.
    undo should be FILO stack, not FIFI queue!

    I need a bounded stack.

** 2017-01-26 make basic things work.
- DONE draw the keyboard using HTML and CSS
- DONE make basic number input and arithmetic work

test-shadow-copy.html

0 → 100644
+29 −0
Original line number Diff line number Diff line
<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <title>test shadow copy</title>
  </head>

  <body>
    <script type="text/javascript">
      (function () {
	  const Machine = function () {
	      this.data1 = [];
	      this.save = function () {
		  return {data1: this.data1};
	      };
	  };
	  const m = new Machine();
	  m.data1.push(1);
	  const r = m.save();
	  console.log(r.data1);
	  m.data1.push(2);
	  console.log(r.data1);    // r.data1 is changed because it's a shadow copy.
      }());
    </script>
  </body>

</html>