├── .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 | ![Calculator screenshot](screenshots/calculator_screenshot.jpg) 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 | --------------------------------------------------------------------------------