├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
└── index.html
├── screenshots
└── calculator_screenshot.jpg
└── src
├── Hello.js
├── calculator.js
├── index.js
├── machine.js
└── styles.css
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Simple calculator built using statecharts
2 |
3 | Implementation of calculator using statechart as described in Ian Horrock's book - 'Constructing the User Interface'
4 |
5 | 
6 |
7 | The demo can be found on codesandbox - https://codesandbox.io/s/github/mukeshsoni/statechart-calculator/tree/master/
8 |
9 | To test it locally, clone the repository and run the following commands on your terminal
10 |
11 | ```
12 | $ git clone https://github.com/mukeshsoni/statechart-calculator.git
13 | $ cd statechart-calculator
14 | $ npm install
15 | $ npm run start
16 | ```
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "new",
3 | "version": "1.0.0",
4 | "description": "",
5 | "keywords": [],
6 | "main": "src/index.js",
7 | "dependencies": {
8 | "react": "16.3.2",
9 | "react-dom": "16.3.2",
10 | "react-scripts": "1.1.4",
11 | "xstate": "3.3.1"
12 | },
13 | "devDependencies": {},
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test --env=jsdom",
18 | "eject": "react-scripts eject"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | React App
24 |
25 |
26 |
27 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/screenshots/calculator_screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mukeshsoni/statechart-calculator/721a0a39973d11c9caba1309293546d058aa892a/screenshots/calculator_screenshot.jpg
--------------------------------------------------------------------------------
/src/Hello.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default ({ name }) => Hello {name}!
;
4 |
--------------------------------------------------------------------------------
/src/calculator.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import calcMachine from "./machine.js";
3 | import "./styles.css";
4 |
5 | function isOperator(text) {
6 | return text === "+" || text === "-" || text === "x" || text === "/";
7 | }
8 |
9 | function doMath(operand1, operand2, operator) {
10 | switch (operator) {
11 | case "+":
12 | return +operand1 + +operand2;
13 | case "-":
14 | return +operand1 - +operand2;
15 | case "/":
16 | return +operand1 / +operand2;
17 | case "x":
18 | return +operand1 * +operand2;
19 | default:
20 | return Infinity;
21 | }
22 | }
23 |
24 | export default class Calculator extends React.Component {
25 | state = {
26 | display: "0.",
27 | operand1: null,
28 | operand2: null,
29 | operator: null,
30 | calcState: calcMachine.initial
31 | };
32 |
33 | defaultReadout() {
34 | this.setState({ display: "0." });
35 | }
36 |
37 | defaultNegativeReadout() {
38 | this.setState({ display: "-0." });
39 | }
40 |
41 | appendNumBeforeDecimal = ({ key: num }) => {
42 | this.setState({ display: this.state.display.slice(0, -1) + num + "." });
43 | };
44 |
45 | appendNumAfterDecimal = ({ key: num }) => {
46 | this.setState({ display: this.state.display + num });
47 | };
48 |
49 | setReadoutNum = ({ key: num }) => {
50 | this.setState({ display: num + "." });
51 | };
52 |
53 | setNegativeReadoutNum = ({ key: num }) => {
54 | this.setState({ display: "-" + num + "." });
55 | };
56 |
57 | startNegativeNumber = num => {
58 | console.log("here");
59 | this.setState({ display: "-" });
60 | };
61 |
62 | recordOperator = ({ operator }) => {
63 | this.setState({ operand1: this.state.display, operator });
64 | };
65 |
66 | setOperator = ({ operator }) => {
67 | this.setState({ operator });
68 | };
69 |
70 | computePercentage = () => {
71 | this.setState({ display: this.state.display / 100 });
72 | };
73 |
74 | compute = () => {
75 | const operand2 = this.state.display;
76 | const { operand1, operator } = this.state;
77 | this.setState({ display: doMath(operand1, operand2, operator) });
78 | };
79 |
80 | computeAndStoreResultAsOperand1 = () => {
81 | const operand2 = this.state.display;
82 | const { operand1, operator } = this.state;
83 | console.log("new operand1", doMath(operand1, operand2, operator));
84 | this.setState({ operand1: doMath(operand1, operand2, operator) });
85 | };
86 |
87 | storeResultAsOperand1() {
88 | this.setState({ operand1: this.state.display });
89 | }
90 |
91 | divideByZeroAlert() {
92 | // have to put the alert in setTimeout because action is executed on event, before the transition to next state happens
93 | // this alert is supposed to happend on transition
94 | // setTimeout allows time for other state transition (to 'alert' state) to happen before showing the alert
95 | // probably a better way to do it. like entry or exit actions
96 | setTimeout(() => {
97 | alert("Cannot divide by zero!");
98 | this.transition("OK");
99 | }, 0);
100 | }
101 |
102 | reset() {
103 | this.setState({ display: "0." });
104 | }
105 |
106 | runActions = (calcState, evtObj) => {
107 | calcState.actions.forEach(action => this[action](evtObj));
108 | };
109 |
110 | /**
111 | * does three things
112 | * 1. calls the transition function of machine creted using xstate
113 | * 2. invokes/runs all the actions
114 | * 3. Sets the new state as the current state
115 | */
116 | transition = (eventName, evtObj = {}) => {
117 | console.log("current state", this.state.calcState, eventName);
118 | const nextState = calcMachine.transition(
119 | this.state.calcState,
120 | {
121 | type: eventName,
122 | ...evtObj
123 | },
124 | {
125 | operator: this.state.operator,
126 | operand2: this.state.operator ? this.state.display : null
127 | }
128 | );
129 | console.log("actions", nextState.actions);
130 | console.log("next state", nextState.value);
131 | this.runActions(nextState, evtObj);
132 | this.setState({ calcState: nextState.value });
133 | };
134 |
135 | handleButtonClick = item => {
136 | if (Number.isInteger(+item)) {
137 | this.transition("NUMBER", { key: +item });
138 | } else if (isOperator(item)) {
139 | this.transition("OPERATOR", { operator: item });
140 | } else if (item === "C") {
141 | this.transition("CANCEL");
142 | } else if (item === ".") {
143 | this.transition("DECIMAL_POINT");
144 | } else if (item === "%") {
145 | this.transition("PERCENTAGE");
146 | } else if (item === "CE") {
147 | this.transition("CE");
148 | } else {
149 | this.transition("EQUALS");
150 | console.log("equals clicked");
151 | }
152 | };
153 |
154 | calcButtons() {
155 | const buttons = [
156 | "C",
157 | "CE",
158 | "/",
159 | "7",
160 | "8",
161 | "9",
162 | "x",
163 | "4",
164 | "5",
165 | "6",
166 | "-",
167 | "1",
168 | "2",
169 | "3",
170 | "+",
171 | "0",
172 | ".",
173 | "=",
174 | "%"
175 | ];
176 |
177 | return buttons.map((item, index) => {
178 | let classNames = "calc-button";
179 |
180 | if (item === "C") {
181 | classNames += " two-span";
182 | }
183 |
184 | return (
185 |
192 | );
193 | });
194 | }
195 |
196 | render() {
197 | return (
198 |
199 |
200 |
{this.calcButtons()}
201 |
202 | );
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "react-dom";
3 | import Hello from "./Hello";
4 | import Calculator from "./calculator.js";
5 |
6 | const styles = {
7 | fontFamily: "sans-serif",
8 | textAlign: "center",
9 | marginTop: 20
10 | };
11 |
12 | const App = () => (
13 |
14 |
Statechart driven calculator
15 |
16 |
17 |
18 | );
19 |
20 | render(, document.getElementById("root"));
21 |
--------------------------------------------------------------------------------
/src/machine.js:
--------------------------------------------------------------------------------
1 | import { Machine } from "xstate";
2 |
3 | const not = fn => (...args) => !fn.apply(null, args);
4 | const isZero = (extState, evtObj) => evtObj.key === 0;
5 | const isNotZero = not(isZero);
6 | const isMinus = (extState, evtObj) => evtObj.operator === "-";
7 | const isNotMinus = not(isMinus);
8 | const divideByZero = (extState, evtObj) =>
9 | extState.operand2 === "0." && extState.operator === "/";
10 | const notDivideByZero = not(divideByZero);
11 |
12 | const calcMachine = Machine(
13 | {
14 | initial: "calc.start",
15 | states: {
16 | calc: {
17 | on: {
18 | CANCEL: {
19 | "calc.start": { actions: ["reset"] }
20 | }
21 | },
22 | states: {
23 | start: {
24 | on: {
25 | NUMBER: {
26 | "operand1.zero": {
27 | actions: ["defaultReadout"],
28 | cond: "isZero"
29 | },
30 | "operand1.before_decimal_point": {
31 | cond: "isNotZero",
32 | actions: ["setReadoutNum"]
33 | }
34 | },
35 | OPERATOR: {
36 | negative_number: {
37 | cond: "isMinus",
38 | actions: ["startNegativeNumber"]
39 | }
40 | },
41 | DECIMAL_POINT: {
42 | "operand1.after_decimal_point": {
43 | actions: ["defaultReadout"]
44 | }
45 | }
46 | }
47 | },
48 | result: {
49 | on: {
50 | NUMBER: {
51 | operand1: {
52 | actions: ["reset"],
53 | cond: "isZero"
54 | }
55 | },
56 | PERCENTAGE: {
57 | result: {
58 | actions: ["computePercentage"]
59 | }
60 | },
61 | OPERATOR: {
62 | operator_entered: {
63 | actions: ["storeResultAsOperand1", "recordOperator"]
64 | }
65 | }
66 | }
67 | },
68 | operand1: {
69 | on: {
70 | OPERATOR: {
71 | operator_entered: {
72 | actions: ["recordOperator"]
73 | }
74 | },
75 | PERCENTAGE: {
76 | result: {
77 | actions: ["computePercentage"]
78 | }
79 | },
80 | CE: {
81 | start: {
82 | actions: ["reset"]
83 | }
84 | }
85 | },
86 | states: {
87 | zero: {
88 | on: {
89 | NUMBER: {
90 | before_decimal_point: {
91 | actions: ["setReadoutNum"]
92 | }
93 | },
94 | DECIMAL_POINT: "after_decimal_point"
95 | }
96 | },
97 | before_decimal_point: {
98 | on: {
99 | NUMBER: {
100 | before_decimal_point: {
101 | actions: ["appendNumBeforeDecimal"]
102 | }
103 | },
104 | DECIMAL_POINT: "after_decimal_point"
105 | }
106 | },
107 | after_decimal_point: {
108 | on: {
109 | NUMBER: {
110 | after_decimal_point: {
111 | actions: ["appendNumAfterDecimal"]
112 | }
113 | }
114 | }
115 | }
116 | }
117 | },
118 | alert: {
119 | on: {
120 | OK: "operand2.hist"
121 | }
122 | },
123 | operand2: {
124 | on: {
125 | OPERATOR: {
126 | operator_entered: {
127 | actions: ["computeAndStoreResultAsOperand1", "setOperator"]
128 | }
129 | },
130 | EQUALS: {
131 | result: {
132 | actions: ["compute"],
133 | cond: "notDivideByZero"
134 | },
135 | alert: {
136 | actions: ["divideByZeroAlert"]
137 | }
138 | }
139 | },
140 | states: {
141 | initial: "zero",
142 | hist: {
143 | history: true,
144 | target: "zero"
145 | },
146 | zero: {
147 | on: {
148 | NUMBER: {
149 | before_decimal_point: {
150 | actions: ["setReadoutNum"]
151 | }
152 | },
153 | DECIMAL_POINT: "after_decimal_point"
154 | }
155 | },
156 | before_decimal_point: {
157 | on: {
158 | NUMBER: {
159 | before_decimal_point: {
160 | actions: ["appendNumBeforeDecimal"]
161 | }
162 | },
163 | DECIMAL_POINT: "after_decimal_point"
164 | }
165 | },
166 | after_decimal_point: {
167 | on: {
168 | NUMBER: {
169 | after_decimal_point: {
170 | actions: ["appendNumAfterDecimal"]
171 | }
172 | }
173 | }
174 | }
175 | }
176 | },
177 | operator_entered: {
178 | on: {
179 | OPERATOR: {
180 | operator_entered: {
181 | cond: "isNotMinus",
182 | actions: ["setOperator"]
183 | },
184 | negative_number_2: {
185 | cond: "isMinus",
186 | actions: ["startNegativeNumber"]
187 | }
188 | },
189 | NUMBER: {
190 | "operand2.zero": {
191 | actions: ["defaultReadout"],
192 | cond: "isZero"
193 | },
194 | "operand2.before_decimal_point": {
195 | cond: "isNotZero",
196 | actions: ["setReadoutNum"]
197 | }
198 | },
199 | DECIMAL_POINT: {
200 | "operand2.after_decimal_point": {
201 | actions: ["defaultReadout"]
202 | }
203 | }
204 | }
205 | },
206 | negative_number: {
207 | on: {
208 | NUMBER: {
209 | "operand1.zero": {
210 | actions: ["defaultNegativeReadout"],
211 | cond: "isZero"
212 | },
213 | "operand1.before_decimal_point": {
214 | cond: "isNotZero",
215 | actions: ["setNegativeReadoutNum"]
216 | }
217 | },
218 | DECIMAL_POINT: {
219 | "operand1.after_decimal_point": {
220 | actions: ["defaultNegativeReadout"]
221 | }
222 | },
223 | CE: {
224 | start: {
225 | actions: ["reset"]
226 | }
227 | }
228 | }
229 | },
230 | negative_number_2: {
231 | on: {
232 | NUMBER: {
233 | "operand2.zero": {
234 | actions: ["defaultNegativeReadout"],
235 | cond: "isZero"
236 | },
237 | "operand2.before_decimal_point": {
238 | cond: "isNotZero",
239 | actions: ["setNegativeReadoutNum"]
240 | }
241 | },
242 | DECIMAL_POINT: {
243 | "operand2.after_decimal_point": {
244 | actions: ["defaultNegativeReadout"]
245 | }
246 | },
247 | CE: {
248 | operator_entered: {
249 | actions: ["defaultReadout"]
250 | }
251 | }
252 | }
253 | }
254 | }
255 | }
256 | }
257 | },
258 | {
259 | guards: {
260 | isMinus,
261 | isNotMinus,
262 | isZero,
263 | isNotZero,
264 | notDivideByZero
265 | }
266 | }
267 | );
268 |
269 | export default calcMachine;
270 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | body * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | }
6 |
7 | .container {
8 | max-width: 300px;
9 | margin: 0 auto;
10 | border: 2px solid gray;
11 | border-radius: 4px;
12 | box-sizing: border-box;
13 | }
14 |
15 | .readout {
16 | font-size: 32px;
17 | color: #333;
18 | text-align: right;
19 | padding: 5px 13px;
20 | width: 100%;
21 | border: none;
22 | border-bottom: 1px solid gray;
23 | box-sizing: border-box;
24 | }
25 |
26 | .button-grid {
27 | display: grid;
28 | padding: 20px;
29 | grid-template-columns: repeat(4, 1fr);
30 | grid-gap: 15px;
31 | }
32 |
33 | .calc-button {
34 | padding: 10px;
35 | font-size: 22px;
36 | color: #eee;
37 | background: rgba(0, 0, 0, 0.5);
38 | cursor: pointer;
39 | border-radius: 2px;
40 | border: 0;
41 | outline: none;
42 | opacity: 0.8;
43 | transition: opacity 0.2s ease-in-out;
44 | }
45 |
46 | .calc-button:hover {
47 | opacity: 1;
48 | }
49 |
50 | .calc-button:active {
51 | background: #999;
52 | box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.6);
53 | }
54 |
55 | .two-span {
56 | grid-column: span 2;
57 | background-color: #3572db;
58 | }
59 |
--------------------------------------------------------------------------------