diff --git a/examples/app/calculator/data/state.json b/examples/app/calculator/data/state.json new file mode 100644 index 00000000..dcac4b8c --- /dev/null +++ b/examples/app/calculator/data/state.json @@ -0,0 +1,30 @@ +{ + "textdirection": "ltr", + "language": "en", + "title": "Calculator", + "mode": "standard", + "displayValue": "0", + "expression": "", + "columns": "4", + "buttons": [ + { "label": "AC", "value": "clear", "type": "action", "span": "1" }, + { "label": "±", "value": "negate", "type": "action", "span": "1" }, + { "label": "%", "value": "percent", "type": "action", "span": "1" }, + { "label": "÷", "value": "/", "type": "operator", "span": "1" }, + { "label": "7", "value": "7", "type": "number", "span": "1" }, + { "label": "8", "value": "8", "type": "number", "span": "1" }, + { "label": "9", "value": "9", "type": "number", "span": "1" }, + { "label": "×", "value": "*", "type": "operator", "span": "1" }, + { "label": "4", "value": "4", "type": "number", "span": "1" }, + { "label": "5", "value": "5", "type": "number", "span": "1" }, + { "label": "6", "value": "6", "type": "number", "span": "1" }, + { "label": "−", "value": "-", "type": "operator", "span": "1" }, + { "label": "1", "value": "1", "type": "number", "span": "1" }, + { "label": "2", "value": "2", "type": "number", "span": "1" }, + { "label": "3", "value": "3", "type": "number", "span": "1" }, + { "label": "+", "value": "+", "type": "operator", "span": "1" }, + { "label": "0", "value": "0", "type": "number", "span": "2" }, + { "label": ".", "value": ".", "type": "number", "span": "1" }, + { "label": "=", "value": "=", "type": "equal", "span": "1" } + ] +} diff --git a/examples/app/calculator/package.json b/examples/app/calculator/package.json new file mode 100644 index 00000000..18c183d7 --- /dev/null +++ b/examples/app/calculator/package.json @@ -0,0 +1,18 @@ +{ + "name": "calculator-example", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch", + "start:server": "cargo run -p webui-cli -- start ./src --state ./data/state.json --plugin=fast --servedir ./dist --port 3001 --watch", + "start": "cargo xtask dev calculator" + }, + "devDependencies": { + "@microsoft/fast-element": "catalog:", + "@microsoft/fast-html": "catalog:", + "esbuild": "catalog:", + "tslib": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/app/calculator/src/calc-app/calc-app.css b/examples/app/calculator/src/calc-app/calc-app.css new file mode 100644 index 00000000..45990755 --- /dev/null +++ b/examples/app/calculator/src/calc-app/calc-app.css @@ -0,0 +1,66 @@ +:host { + display: block; + width: 100%; + max-width: 380px; +} + +.calculator { + background: var(--color-surface); + border-radius: var(--border-radius-xl); + padding: var(--spacing-l); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: var(--spacing-m); +} + +/* Mode tabs */ +.mode-tabs { + display: flex; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + background: rgba(0, 0, 0, 0.15); + border-radius: var(--border-radius-s); +} + +.mode-tab { + flex: 1; + padding: var(--spacing-s) var(--spacing-m); + border: none; + border-radius: var(--border-radius-s); + background: var(--color-tab-bg); + color: var(--color-tab-text); + font-family: var(--font-family-base); + font-size: var(--font-size-s); + font-weight: 500; + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); + text-transform: capitalize; +} + +.mode-tab:hover { + color: var(--color-display-text); + background: rgba(255, 255, 255, 0.05); +} + +.mode-tab[data-active] { + background: var(--color-tab-active-bg); + color: var(--color-tab-active-text); +} + +/* Button grid */ +.button-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-s); +} + +:host([columns="5"]) .button-grid { + grid-template-columns: repeat(5, 1fr); +} + +:host([columns="6"]) .button-grid { + grid-template-columns: repeat(6, 1fr); +} diff --git a/examples/app/calculator/src/calc-app/calc-app.html b/examples/app/calculator/src/calc-app/calc-app.html new file mode 100644 index 00000000..b09da4b0 --- /dev/null +++ b/examples/app/calculator/src/calc-app/calc-app.html @@ -0,0 +1,26 @@ + + + + Standard + Scientific + + + + + + + + + + + diff --git a/examples/app/calculator/src/calc-app/calc-app.ts b/examples/app/calculator/src/calc-app/calc-app.ts new file mode 100644 index 00000000..f83c2542 --- /dev/null +++ b/examples/app/calculator/src/calc-app/calc-app.ts @@ -0,0 +1,142 @@ +import { FASTElement, attr, observable } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; +import type { CalcState, ButtonDef } from '../modes/engine.js'; +import { createInitialState, getMode } from '../modes/engine.js'; +import '../modes/standard.js'; +import '../modes/scientific.js'; + +interface ButtonData { + label: string; + value: string; + type: string; + span: string; +} + +export class CalcApp extends RenderableFASTElement(FASTElement) { + @attr mode!: string; + @attr({ attribute: 'display-value' }) displayValue!: string; + @attr expression!: string; + @attr({ attribute: 'columns' }) gridColumns!: string; + + @observable buttons!: ButtonData[]; + + private state!: CalcState; + + async prepare(): Promise { + this.mode = this.getAttribute('mode') || 'standard'; + this.displayValue = this.getAttribute('display-value') || '0'; + this.expression = this.getAttribute('expression') || ''; + this.gridColumns = this.getAttribute('columns') || '4'; + this.state = createInitialState(); + + // Read button data from pre-rendered DOM (mirrors todo-fast pattern) + const buttons: ButtonData[] = []; + if (this.shadowRoot) { + for (const el of this.shadowRoot.querySelectorAll('calc-button')) { + buttons.push({ + label: el.getAttribute('label') || '', + value: el.getAttribute('value') || '', + type: el.getAttribute('btn-type') || '', + span: el.getAttribute('btn-span') || '1', + }); + } + } + + if (buttons.length > 0) { + this.buttons = buttons; + } else { + this.loadButtonsFromEngine(); + } + } + + private boundKeydown = this.onKeydown.bind(this); + + connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('keydown', this.boundKeydown); + this.updateActiveModeTab(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('keydown', this.boundKeydown); + } + + onButtonPress(e: CustomEvent<{ value: string }>): void { + this.handleInput(e.detail.value); + } + + onModeSelect(e: MouseEvent): void { + const target = e.composedPath()[0] as HTMLElement; + const btn = target.closest('[data-mode]') as HTMLElement | null; + if (!btn) return; + + const newMode = btn.getAttribute('data-mode'); + if (newMode && newMode !== this.mode) { + this.mode = newMode; + this.state = { + ...this.state, + expression: '', + resetOnNext: true, + error: null, + }; + this.expression = ''; + this.loadButtonsFromEngine(); + this.updateActiveModeTab(); + } + } + + private onKeydown(e: KeyboardEvent): void { + const keyMap: Record = { + '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', + '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', + '.': '.', '+': '+', '-': '-', '*': '*', '/': '/', + Enter: '=', '=': '=', Escape: 'clear', Backspace: 'clear', + '%': 'percent', + }; + + const input = keyMap[e.key]; + if (input) { + e.preventDefault(); + this.handleInput(input); + } + } + + private handleInput(input: string): void { + const engine = getMode(this.mode); + if (!engine) return; + + this.state = engine.processInput(input, this.state); + this.displayValue = this.state.display; + this.expression = this.state.expression; + } + + private loadButtonsFromEngine(): void { + const engine = getMode(this.mode); + if (!engine) return; + + this.gridColumns = String(engine.columns); + this.buttons = engine.buttons.map((b: ButtonDef) => ({ + label: b.label, + value: b.value, + type: b.type, + span: String(b.span ?? 1), + })); + } + + private updateActiveModeTab(): void { + if (!this.shadowRoot) return; + for (const tab of this.shadowRoot.querySelectorAll('[data-mode]')) { + if (tab.getAttribute('data-mode') === this.mode) { + tab.setAttribute('data-active', ''); + } else { + tab.removeAttribute('data-active'); + } + } + } +} + +CalcApp.defineAsync({ + name: 'calc-app', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/calculator/src/calc-button/calc-button.css b/examples/app/calculator/src/calc-button/calc-button.css new file mode 100644 index 00000000..3991a6ec --- /dev/null +++ b/examples/app/calculator/src/calc-button/calc-button.css @@ -0,0 +1,116 @@ +:host { + display: block; +} + +:host([btn-span="2"]) { + grid-column: span 2; +} + +:host([btn-span="3"]) { + grid-column: span 3; +} + +:host([btn-span="4"]) { + grid-column: span 4; +} + +:host([btn-span="5"]) { + grid-column: span 5; +} + +.btn { + width: 100%; + height: 100%; + min-height: 56px; + border: none; + border-radius: var(--border-radius-m); + font-family: var(--font-family-base); + font-size: var(--font-size-l); + font-weight: 500; + cursor: pointer; + transition: + background var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); + box-shadow: var(--shadow-button); + user-select: none; + -webkit-user-select: none; +} + +.btn:active { + transform: scale(0.94); + box-shadow: var(--shadow-button-active); +} + +/* Number buttons */ +:host([btn-type="number"]) .btn { + background: var(--color-btn-number); + color: var(--color-btn-number-text); +} + +:host([btn-type="number"]) .btn:hover { + background: var(--color-btn-number-hover); +} + +:host([btn-type="number"]) .btn:active { + background: var(--color-btn-number-active); +} + +/* Operator buttons */ +:host([btn-type="operator"]) .btn { + background: var(--color-btn-operator); + color: var(--color-btn-operator-text); +} + +:host([btn-type="operator"]) .btn:hover { + background: var(--color-btn-operator-hover); +} + +:host([btn-type="operator"]) .btn:active { + background: var(--color-btn-operator-active); +} + +/* Function buttons (scientific) */ +:host([btn-type="function"]) .btn { + background: var(--color-btn-function); + color: var(--color-btn-function-text); + font-size: var(--font-size-s); +} + +:host([btn-type="function"]) .btn:hover { + background: var(--color-btn-function-hover); +} + +:host([btn-type="function"]) .btn:active { + background: var(--color-btn-function-active); +} + +/* Action buttons (AC, ±, %) */ +:host([btn-type="action"]) .btn { + background: var(--color-btn-action); + color: var(--color-btn-action-text); +} + +:host([btn-type="action"]) .btn:hover { + background: var(--color-btn-action-hover); +} + +:host([btn-type="action"]) .btn:active { + background: var(--color-btn-action-active); +} + +/* Equal button */ +:host([btn-type="equal"]) .btn { + background: var(--color-btn-equal); + color: var(--color-btn-equal-text); + font-size: var(--font-size-xl); + font-weight: 600; +} + +:host([btn-type="equal"]) .btn:hover { + background: var(--color-btn-equal-hover); +} + +:host([btn-type="equal"]) .btn:active { + background: var(--color-btn-equal-active); +} diff --git a/examples/app/calculator/src/calc-button/calc-button.html b/examples/app/calculator/src/calc-button/calc-button.html new file mode 100644 index 00000000..8e57d91f --- /dev/null +++ b/examples/app/calculator/src/calc-button/calc-button.html @@ -0,0 +1,5 @@ + + {{label}} + diff --git a/examples/app/calculator/src/calc-button/calc-button.ts b/examples/app/calculator/src/calc-button/calc-button.ts new file mode 100644 index 00000000..4712520e --- /dev/null +++ b/examples/app/calculator/src/calc-button/calc-button.ts @@ -0,0 +1,24 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CalcButton extends RenderableFASTElement(FASTElement) { + @attr label = ''; + @attr value = ''; + @attr({ attribute: 'btn-type' }) btnType = ''; + @attr({ attribute: 'btn-span' }) btnSpan = ''; + + onClick(): void { + this.dispatchEvent( + new CustomEvent('button-press', { + bubbles: true, + composed: true, + detail: { value: this.value }, + }) + ); + } +} + +CalcButton.defineAsync({ + name: 'calc-button', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/calculator/src/calc-display/calc-display.css b/examples/app/calculator/src/calc-display/calc-display.css new file mode 100644 index 00000000..60ddc60e --- /dev/null +++ b/examples/app/calculator/src/calc-display/calc-display.css @@ -0,0 +1,46 @@ +:host { + display: block; +} + +.display { + background: var(--color-display-bg); + border: 1px solid var(--color-display-border); + border-radius: var(--border-radius-l); + padding: var(--spacing-xl) var(--spacing-xxl); + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + overflow: hidden; + backdrop-filter: blur(12px); + box-shadow: var(--shadow-display); +} + +.expression { + font-family: var(--font-family-mono); + font-size: var(--font-size-expression); + color: var(--color-display-expression); + min-height: 1.4em; + text-align: right; + word-break: break-all; + width: 100%; + direction: rtl; + unicode-bidi: plaintext; +} + +.value { + font-family: var(--font-family-mono); + font-size: var(--font-size-display); + font-weight: 300; + color: var(--color-display-text); + line-height: 1.2; + text-align: right; + word-break: break-all; + width: 100%; + transition: font-size var(--transition-normal); +} + +:host([error]) .value { + color: var(--color-btn-operator); +} diff --git a/examples/app/calculator/src/calc-display/calc-display.html b/examples/app/calculator/src/calc-display/calc-display.html new file mode 100644 index 00000000..807085b8 --- /dev/null +++ b/examples/app/calculator/src/calc-display/calc-display.html @@ -0,0 +1,4 @@ + + {{expression}} + {{value}} + diff --git a/examples/app/calculator/src/calc-display/calc-display.ts b/examples/app/calculator/src/calc-display/calc-display.ts new file mode 100644 index 00000000..2b2fdb8f --- /dev/null +++ b/examples/app/calculator/src/calc-display/calc-display.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CalcDisplay extends RenderableFASTElement(FASTElement) { + @attr expression = ''; + @attr value = ''; + @attr error = ''; +} + +CalcDisplay.defineAsync({ + name: 'calc-display', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/calculator/src/index.html b/examples/app/calculator/src/index.html new file mode 100644 index 00000000..aa7ba0af --- /dev/null +++ b/examples/app/calculator/src/index.html @@ -0,0 +1,125 @@ + + + + + + {{title}} + + + + + + + + diff --git a/examples/app/calculator/src/index.ts b/examples/app/calculator/src/index.ts new file mode 100644 index 00000000..eb5267fb --- /dev/null +++ b/examples/app/calculator/src/index.ts @@ -0,0 +1,30 @@ +/** + * Calculator hydration entry point. + * + * The server pre-renders HTML with hydration markers via `webui build --plugin=fast`. + * This script registers custom elements, configures FAST-HTML observation maps, + * and defines to trigger hydration. + */ + +performance.mark('calc-hydration-started'); + +import { TemplateElement } from '@microsoft/fast-html'; + +// Side-effect imports register custom elements +import './calc-app/calc-app.js'; +import './calc-display/calc-display.js'; +import './calc-button/calc-button.js'; + +// Configure hydration +TemplateElement.options({ + 'calc-app': { observerMap: 'all' }, + 'calc-display': { observerMap: 'all' }, + 'calc-button': { observerMap: 'all' }, +}).config({ + hydrationComplete() { + performance.measure('calc-hydration-completed', 'calc-hydration-started'); + console.log('Calculator hydration complete!'); + }, +}).define({ + name: 'f-template', +}); diff --git a/examples/app/calculator/src/modes/engine.ts b/examples/app/calculator/src/modes/engine.ts new file mode 100644 index 00000000..c699b0ce --- /dev/null +++ b/examples/app/calculator/src/modes/engine.ts @@ -0,0 +1,74 @@ +/** + * Mode engine interface and registry. + * + * Each calculator mode (Standard, Scientific, etc.) implements ModeEngine. + * To add a new mode: create a file, implement the interface, register it. + */ + +/** Definition for a single calculator button. */ +export interface ButtonDef { + /** Display label (e.g. "sin", "7", "+") */ + label: string; + /** Action identifier sent on press */ + value: string; + /** Visual/behavioral category */ + type: 'number' | 'operator' | 'function' | 'action' | 'equal'; + /** Column span (default 1) */ + span?: number; +} + +/** Calculator state passed through engine processing. */ +export interface CalcState { + /** Current display value */ + display: string; + /** Full expression shown above display */ + expression: string; + /** Memory register */ + memory: number; + /** Whether the display should reset on next number input */ + resetOnNext: boolean; + /** Error message, if any */ + error: string | null; +} + +/** A calculator mode engine. Pure logic, no UI. */ +export interface ModeEngine { + /** Display name for the mode tab */ + readonly name: string; + /** Number of grid columns for button layout */ + readonly columns: number; + /** Ordered list of buttons to render */ + readonly buttons: ButtonDef[]; + /** Process a button press and return the new state. */ + processInput(input: string, state: CalcState): CalcState; +} + +/** Create a fresh initial calculator state. */ +export function createInitialState(): CalcState { + return { + display: '0', + expression: '', + memory: 0, + resetOnNext: false, + error: null, + }; +} + +// --- Mode Registry --- + +const registry = new Map(); + +/** Register a mode engine by key. */ +export function registerMode(key: string, engine: ModeEngine): void { + registry.set(key, engine); +} + +/** Get a registered mode engine by key. Returns undefined if not found. */ +export function getMode(key: string): ModeEngine | undefined { + return registry.get(key); +} + +/** Get all registered mode keys in insertion order. */ +export function getModeKeys(): string[] { + return Array.from(registry.keys()); +} diff --git a/examples/app/calculator/src/modes/scientific.ts b/examples/app/calculator/src/modes/scientific.ts new file mode 100644 index 00000000..b36a4611 --- /dev/null +++ b/examples/app/calculator/src/modes/scientific.ts @@ -0,0 +1,416 @@ +import type { ModeEngine, ButtonDef, CalcState } from './engine.js'; +import { registerMode } from './engine.js'; + +const buttons: ButtonDef[] = [ + // Row 1 — Scientific functions + { label: 'sin', value: 'sin', type: 'function' }, + { label: 'cos', value: 'cos', type: 'function' }, + { label: 'tan', value: 'tan', type: 'function' }, + { label: '(', value: '(', type: 'function' }, + { label: ')', value: ')', type: 'function' }, + + // Row 2 — More functions + { label: 'ln', value: 'ln', type: 'function' }, + { label: 'log', value: 'log', type: 'function' }, + { label: '√', value: 'sqrt', type: 'function' }, + { label: 'x²', value: 'square', type: 'function' }, + { label: 'xʸ', value: 'power', type: 'function' }, + + // Row 3 — Constants & extras + { label: 'π', value: 'pi', type: 'function' }, + { label: 'e', value: 'euler', type: 'function' }, + { label: 'x!', value: 'factorial', type: 'function' }, + { label: '±', value: 'negate', type: 'action' }, + { label: '%', value: 'percent', type: 'action' }, + + // Row 4 — Standard row + { label: 'AC', value: 'clear', type: 'action' }, + { label: '7', value: '7', type: 'number' }, + { label: '8', value: '8', type: 'number' }, + { label: '9', value: '9', type: 'number' }, + { label: '÷', value: '/', type: 'operator' }, + + // Row 5 + { label: 'MC', value: 'mc', type: 'action' }, + { label: '4', value: '4', type: 'number' }, + { label: '5', value: '5', type: 'number' }, + { label: '6', value: '6', type: 'number' }, + { label: '×', value: '*', type: 'operator' }, + + // Row 6 + { label: 'MR', value: 'mr', type: 'action' }, + { label: '1', value: '1', type: 'number' }, + { label: '2', value: '2', type: 'number' }, + { label: '3', value: '3', type: 'number' }, + { label: '−', value: '-', type: 'operator' }, + + // Row 7 + { label: 'M+', value: 'm+', type: 'action' }, + { label: '0', value: '0', type: 'number', span: 2 }, + { label: '.', value: '.', type: 'number' }, + { label: '+', value: '+', type: 'operator' }, + + // Row 8 — Equals + { label: '=', value: '=', type: 'equal', span: 5 }, +]; + +function factorial(n: number): number { + if (n < 0 || !Number.isInteger(n)) return NaN; + if (n > 170) return Infinity; + let result = 1; + for (let i = 2; i <= n; i++) { + result *= i; + } + return result; +} + +function evaluate(expression: string): number { + // Tokenize into numbers, operators, and parentheses + const tokens: (number | string)[] = []; + let current = ''; + + for (let i = 0; i < expression.length; i++) { + const ch = expression[i]; + if (ch === ' ') continue; + + if (ch === '(' || ch === ')') { + if (current !== '') { + tokens.push(parseFloat(current)); + current = ''; + } + tokens.push(ch); + } else if (ch === '+' || ch === '×' || ch === '÷') { + if (current !== '') { + tokens.push(parseFloat(current)); + current = ''; + } + tokens.push(ch); + } else if (ch === '−') { + if (current === '' && (tokens.length === 0 || typeof tokens[tokens.length - 1] === 'string' && tokens[tokens.length - 1] !== ')')) { + current += '-'; + } else { + if (current !== '') { + tokens.push(parseFloat(current)); + current = ''; + } + tokens.push(ch); + } + } else { + current += ch; + } + } + if (current !== '') { + tokens.push(parseFloat(current)); + } + + return evalTokens(tokens); +} + +function evalTokens(tokens: (number | string)[]): number { + // Handle parentheses iteratively using a stack + const stack: (number | string)[][] = [[]]; + + for (const token of tokens) { + if (token === '(') { + stack.push([]); + } else if (token === ')') { + const inner = stack.pop(); + if (!inner || stack.length === 0) return NaN; + const val = evalFlat(inner); + stack[stack.length - 1].push(val); + } else { + stack[stack.length - 1].push(token); + } + } + + return evalFlat(stack[0]); +} + +function evalFlat(tokens: (number | string)[]): number { + if (tokens.length === 0) return 0; + + // Multiply and divide first + const addSub: (number | string)[] = []; + let i = 0; + while (i < tokens.length) { + if (typeof tokens[i] === 'string' && (tokens[i] === '×' || tokens[i] === '÷')) { + const left = addSub.pop() as number; + const right = tokens[i + 1] as number; + if (tokens[i] === '×') { + addSub.push(left * right); + } else { + addSub.push(right === 0 ? NaN : left / right); + } + i += 2; + } else { + addSub.push(tokens[i]); + i++; + } + } + + // Add and subtract + let result = addSub[0] as number; + for (let j = 1; j < addSub.length; j += 2) { + const op = addSub[j] as string; + const val = addSub[j + 1] as number; + if (op === '+') { + result += val; + } else if (op === '−') { + result -= val; + } + } + + return result; +} + +function formatNumber(n: number): string { + if (!isFinite(n)) return 'Error'; + if (Number.isNaN(n)) return 'Error'; + + const str = String(n); + if (str.length > 12) { + const precise = n.toPrecision(10); + if (precise.includes('.')) { + return precise.replace(/\.?0+$/, ''); + } + return precise; + } + return str; +} + +const OP_DISPLAY: Record = { + '/': ' ÷ ', + '*': ' × ', + '-': ' − ', + '+': ' + ', +}; + +class ScientificEngine implements ModeEngine { + readonly name = 'Scientific'; + readonly columns = 5; + readonly buttons = buttons; + + private pendingOp: string | null = null; + private leftOperand: number | null = null; + private expressionParts: string[] = []; + private parenDepth = 0; + + processInput(input: string, state: CalcState): CalcState { + const next = { ...state, error: null }; + + // Number input + if (/^[0-9]$/.test(input)) { + if (next.resetOnNext || next.display === '0') { + next.display = input; + next.resetOnNext = false; + } else if (next.display.replace(/[^0-9]/g, '').length < 15) { + next.display += input; + } + return next; + } + + // Decimal + if (input === '.') { + if (next.resetOnNext) { + next.display = '0.'; + next.resetOnNext = false; + } else if (!next.display.includes('.')) { + next.display += '.'; + } + return next; + } + + // Clear + if (input === 'clear') { + next.display = '0'; + next.expression = ''; + next.resetOnNext = false; + next.error = null; + this.pendingOp = null; + this.leftOperand = null; + this.expressionParts = []; + this.parenDepth = 0; + return next; + } + + // Negate + if (input === 'negate') { + if (next.display !== '0' && next.display !== 'Error') { + next.display = next.display.startsWith('-') + ? next.display.slice(1) + : '-' + next.display; + } + return next; + } + + // Percent + if (input === 'percent') { + const val = parseFloat(next.display); + if (!isNaN(val)) { + next.display = formatNumber(val / 100); + } + return next; + } + + // Constants + if (input === 'pi') { + next.display = formatNumber(Math.PI); + next.resetOnNext = false; + return next; + } + if (input === 'euler') { + next.display = formatNumber(Math.E); + next.resetOnNext = false; + return next; + } + + // Unary functions + const unaryFns: Record number> = { + sin: (x) => Math.sin(x * Math.PI / 180), + cos: (x) => Math.cos(x * Math.PI / 180), + tan: (x) => Math.tan(x * Math.PI / 180), + ln: (x) => Math.log(x), + log: (x) => Math.log10(x), + sqrt: (x) => Math.sqrt(x), + square: (x) => x * x, + factorial: (x) => factorial(x), + }; + + if (input in unaryFns) { + const val = parseFloat(next.display); + if (!isNaN(val)) { + const result = unaryFns[input](val); + const formatted = formatNumber(result); + const fnLabel = input === 'square' ? '²' : input === 'factorial' ? '!' : input; + + if (input === 'square') { + next.expression = `(${next.display})² =`; + } else if (input === 'factorial') { + next.expression = `${next.display}! =`; + } else { + next.expression = `${fnLabel}(${next.display}) =`; + } + + next.display = formatted; + if (formatted === 'Error') { + next.error = 'Invalid operation'; + } + next.resetOnNext = true; + } + return next; + } + + // Power (xʸ) — acts like an operator + if (input === 'power') { + const currentVal = parseFloat(next.display); + this.leftOperand = currentVal; + this.pendingOp = 'power'; + this.expressionParts = [next.display, ' ^ ']; + next.expression = this.expressionParts.join(''); + next.resetOnNext = true; + return next; + } + + // Parentheses + if (input === '(') { + this.parenDepth++; + this.expressionParts.push('('); + next.expression = this.expressionParts.join(''); + next.resetOnNext = true; + return next; + } + if (input === ')') { + if (this.parenDepth > 0) { + this.parenDepth--; + this.expressionParts.push(next.display); + this.expressionParts.push(')'); + next.expression = this.expressionParts.join(''); + next.resetOnNext = true; + } + return next; + } + + // Memory operations + if (input === 'mc') { + next.memory = 0; + return next; + } + if (input === 'mr') { + next.display = formatNumber(next.memory); + next.resetOnNext = true; + return next; + } + if (input === 'm+') { + const val = parseFloat(next.display); + if (!isNaN(val)) { + next.memory += val; + } + return next; + } + + // Operators + if (['+', '-', '*', '/'].includes(input)) { + const currentVal = parseFloat(next.display); + + if (this.pendingOp === 'power' && this.leftOperand !== null && !next.resetOnNext) { + const result = Math.pow(this.leftOperand, currentVal); + next.display = formatNumber(result); + this.expressionParts = [formatNumber(result)]; + this.leftOperand = result; + } else if (this.pendingOp !== null && this.pendingOp !== 'power' && this.leftOperand !== null && !next.resetOnNext) { + const exprStr = this.expressionParts.join('') + next.display; + const result = evaluate(exprStr); + next.display = formatNumber(result); + this.expressionParts = [formatNumber(result)]; + this.leftOperand = result; + } else { + this.leftOperand = currentVal; + if (this.expressionParts.length === 0 || next.resetOnNext) { + this.expressionParts = [next.display]; + } + } + + this.pendingOp = input; + this.expressionParts.push(OP_DISPLAY[input]); + next.expression = this.expressionParts.join(''); + next.resetOnNext = true; + return next; + } + + // Equals + if (input === '=') { + if (this.pendingOp !== null && this.leftOperand !== null) { + let result: number; + if (this.pendingOp === 'power') { + result = Math.pow(this.leftOperand, parseFloat(next.display)); + this.expressionParts.push(next.display); + } else { + this.expressionParts.push(next.display); + const exprStr = this.expressionParts.join(''); + result = evaluate(exprStr); + } + + const displayResult = formatNumber(result); + next.expression = this.expressionParts.join('') + ' ='; + next.display = displayResult; + + if (displayResult === 'Error') { + next.error = 'Invalid operation'; + } + + this.pendingOp = null; + this.leftOperand = null; + this.expressionParts = []; + next.resetOnNext = true; + } + return next; + } + + return next; + } +} + +const engine = new ScientificEngine(); +registerMode('scientific', engine); + +export { engine as scientificEngine }; diff --git a/examples/app/calculator/src/modes/standard.ts b/examples/app/calculator/src/modes/standard.ts new file mode 100644 index 00000000..7ad4c1ce --- /dev/null +++ b/examples/app/calculator/src/modes/standard.ts @@ -0,0 +1,245 @@ +import type { ModeEngine, ButtonDef, CalcState } from './engine.js'; +import { registerMode } from './engine.js'; + +const buttons: ButtonDef[] = [ + { label: 'AC', value: 'clear', type: 'action' }, + { label: '±', value: 'negate', type: 'action' }, + { label: '%', value: 'percent', type: 'action' }, + { label: '÷', value: '/', type: 'operator' }, + + { label: '7', value: '7', type: 'number' }, + { label: '8', value: '8', type: 'number' }, + { label: '9', value: '9', type: 'number' }, + { label: '×', value: '*', type: 'operator' }, + + { label: '4', value: '4', type: 'number' }, + { label: '5', value: '5', type: 'number' }, + { label: '6', value: '6', type: 'number' }, + { label: '−', value: '-', type: 'operator' }, + + { label: '1', value: '1', type: 'number' }, + { label: '2', value: '2', type: 'number' }, + { label: '3', value: '3', type: 'number' }, + { label: '+', value: '+', type: 'operator' }, + + { label: '0', value: '0', type: 'number', span: 2 }, + { label: '.', value: '.', type: 'number' }, + { label: '=', value: '=', type: 'equal' }, +]; + +function evaluate(expression: string): number { + // Tokenize the expression into numbers and operators + const tokens: (number | string)[] = []; + let current = ''; + + for (let i = 0; i < expression.length; i++) { + const ch = expression[i]; + if (ch === ' ') continue; + + if (ch === '+' || ch === '×' || ch === '÷') { + if (current !== '') { + tokens.push(parseFloat(current)); + current = ''; + } + tokens.push(ch); + } else if (ch === '−') { + // Distinguish negative sign from subtraction + if (current === '' && (tokens.length === 0 || typeof tokens[tokens.length - 1] === 'string')) { + current += '-'; + } else { + if (current !== '') { + tokens.push(parseFloat(current)); + current = ''; + } + tokens.push(ch); + } + } else { + current += ch; + } + } + if (current !== '') { + tokens.push(parseFloat(current)); + } + + if (tokens.length === 0) return 0; + + // First pass: multiply and divide + const addSub: (number | string)[] = []; + let i = 0; + while (i < tokens.length) { + if (typeof tokens[i] === 'string' && (tokens[i] === '×' || tokens[i] === '÷')) { + const left = addSub.pop() as number; + const right = tokens[i + 1] as number; + if (tokens[i] === '×') { + addSub.push(left * right); + } else { + addSub.push(right === 0 ? NaN : left / right); + } + i += 2; + } else { + addSub.push(tokens[i]); + i++; + } + } + + // Second pass: add and subtract + let result = addSub[0] as number; + for (let j = 1; j < addSub.length; j += 2) { + const op = addSub[j] as string; + const val = addSub[j + 1] as number; + if (op === '+') { + result += val; + } else if (op === '−') { + result -= val; + } + } + + return result; +} + +function formatNumber(n: number): string { + if (!isFinite(n)) return 'Error'; + if (Number.isNaN(n)) return 'Error'; + + // Avoid floating point display issues + const str = String(n); + if (str.length > 12) { + // Use toPrecision for very large/small numbers + const precise = n.toPrecision(10); + // Remove trailing zeros after decimal + if (precise.includes('.')) { + return precise.replace(/\.?0+$/, ''); + } + return precise; + } + return str; +} + +const OP_DISPLAY: Record = { + '/': ' ÷ ', + '*': ' × ', + '-': ' − ', + '+': ' + ', +}; + +class StandardEngine implements ModeEngine { + readonly name = 'Standard'; + readonly columns = 4; + readonly buttons = buttons; + + private pendingOp: string | null = null; + private leftOperand: number | null = null; + private expressionParts: string[] = []; + + processInput(input: string, state: CalcState): CalcState { + const next = { ...state, error: null }; + + // Number input + if (/^[0-9]$/.test(input)) { + if (next.resetOnNext || next.display === '0') { + next.display = input; + next.resetOnNext = false; + } else if (next.display.replace(/[^0-9]/g, '').length < 12) { + next.display += input; + } + return next; + } + + // Decimal point + if (input === '.') { + if (next.resetOnNext) { + next.display = '0.'; + next.resetOnNext = false; + } else if (!next.display.includes('.')) { + next.display += '.'; + } + return next; + } + + // Clear + if (input === 'clear') { + next.display = '0'; + next.expression = ''; + next.resetOnNext = false; + next.error = null; + this.pendingOp = null; + this.leftOperand = null; + this.expressionParts = []; + return next; + } + + // Negate + if (input === 'negate') { + if (next.display !== '0' && next.display !== 'Error') { + next.display = next.display.startsWith('-') + ? next.display.slice(1) + : '-' + next.display; + } + return next; + } + + // Percent + if (input === 'percent') { + const val = parseFloat(next.display); + if (!isNaN(val)) { + next.display = formatNumber(val / 100); + } + return next; + } + + // Operators + if (['+', '-', '*', '/'].includes(input)) { + const currentVal = parseFloat(next.display); + + if (this.pendingOp !== null && this.leftOperand !== null && !next.resetOnNext) { + // Chain: evaluate pending operation first + const exprStr = this.expressionParts.join('') + next.display; + const result = evaluate(exprStr); + next.display = formatNumber(result); + this.expressionParts = [formatNumber(result)]; + this.leftOperand = result; + } else { + this.leftOperand = currentVal; + if (this.expressionParts.length === 0 || next.resetOnNext) { + this.expressionParts = [next.display]; + } + } + + this.pendingOp = input; + this.expressionParts.push(OP_DISPLAY[input]); + next.expression = this.expressionParts.join(''); + next.resetOnNext = true; + return next; + } + + // Equals + if (input === '=') { + if (this.pendingOp !== null && this.leftOperand !== null) { + this.expressionParts.push(next.display); + const exprStr = this.expressionParts.join(''); + const result = evaluate(exprStr); + const displayResult = formatNumber(result); + + next.expression = exprStr + ' ='; + next.display = displayResult; + + if (displayResult === 'Error') { + next.error = 'Invalid operation'; + } + + this.pendingOp = null; + this.leftOperand = null; + this.expressionParts = []; + next.resetOnNext = true; + } + return next; + } + + return next; + } +} + +const engine = new StandardEngine(); +registerMode('standard', engine); + +export { engine as standardEngine }; diff --git a/examples/app/calculator/tsconfig.json b/examples/app/calculator/tsconfig.json new file mode 100644 index 00000000..3d32cf55 --- /dev/null +++ b/examples/app/calculator/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext", + "strict": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "incremental": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c729683..8abfb43e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,24 @@ importers: specifier: 'catalog:' version: 3.5.29(typescript@5.9.3) + examples/app/calculator: + devDependencies: + '@microsoft/fast-element': + specifier: 'catalog:' + version: 2.10.1 + '@microsoft/fast-html': + specifier: 'catalog:' + version: 1.0.0-alpha.38(@microsoft/fast-element@2.10.1) + esbuild: + specifier: 'catalog:' + version: 0.27.3 + tslib: + specifier: 'catalog:' + version: 2.8.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + examples/app/contact-book-manager: devDependencies: '@microsoft/fast-element':