This commit is contained in:
Joel Jeremy Marquez
2024-11-04 14:36:49 -08:00
parent 1fcba8571a
commit 2d23a09f03
3 changed files with 115 additions and 20 deletions

View File

@@ -15,8 +15,14 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import {
evalArithmetic,
hasArithmeticOperator,
lastIndexOfArithmeticOperator,
} from 'loot-core/shared/arithmetic';
import {
amountToCurrency,
amountToInteger,
appendDecimals,
currencyToAmount,
reapplyThousandSeparators,
@@ -76,6 +82,10 @@ const AmountInput = memo(function AmountInput({
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
keyboardRef.current?.setInput(text);
}, [text]);
const onKeyUp: HTMLProps<HTMLInputElement>['onKeyUp'] = e => {
if (e.key === 'Backspace' && text === '') {
setEditing(true);
@@ -88,7 +98,10 @@ const AmountInput = memo(function AmountInput({
};
const applyText = () => {
const parsed = currencyToAmount(text) || 0;
const parsed = hasArithmeticOperator(text)
? evalArithmetic(text)
: currencyToAmount(text) || 0;
const newValue = editing ? parsed : value;
setValue(Math.abs(newValue));
@@ -119,12 +132,46 @@ const AmountInput = memo(function AmountInput({
};
const onChangeText = (text: string) => {
console.log('text', text);
text = reapplyThousandSeparators(text);
text = appendDecimals(text, String(hideFraction) === 'true');
const lastOperatorIndex = lastIndexOfArithmeticOperator(text);
if (lastOperatorIndex > 0) {
// This will evaluate the expression whenever an operator is added
// so only one operation will be displayed at a given time
const isOperatorAtEnd = lastOperatorIndex === text.length - 1;
if (isOperatorAtEnd) {
const lastOperator = text[lastOperatorIndex];
const charIndexPriorToLastOperator = lastOperatorIndex - 1;
const charPriorToLastOperator =
text.length > 0 ? text[charIndexPriorToLastOperator] : '';
if (
charPriorToLastOperator &&
hasArithmeticOperator(charPriorToLastOperator)
) {
// Clicked on another operator while there is still an operator
// Replace previous operator with the new one
// TODO: Fix why clicking the same operator duplicates it
text = `${text.slice(0, charIndexPriorToLastOperator)}${lastOperator}`;
} else {
// Evaluate the left side of the expression whenever an operator is added
const left = text.slice(0, lastOperatorIndex);
const leftEvaluated = evalArithmetic(left);
const leftEvaluatedWithDecimal = appendDecimals(
String(amountToInteger(leftEvaluated)),
String(hideFraction) === 'true',
);
text = `${leftEvaluatedWithDecimal}${lastOperator}`;
}
}
} else {
text = appendDecimals(text, String(hideFraction) === 'true');
}
setEditing(true);
setText(text);
props.onChangeValue?.(text);
keyboardRef.current?.setInput(text);
};
const input = (
@@ -177,6 +224,7 @@ const AmountInput = memo(function AmountInput({
keyboardRef={(r: AmountKeyboardRef) => (keyboardRef.current = r)}
onChange={onChangeText}
onBlur={onBlur}
onEnter={onUpdate}
/>
)}
</View>

View File

@@ -17,6 +17,7 @@ export type AmountKeyboardRef = SimpleKeyboard;
type AmountKeyboardProps = ComponentPropsWithoutRef<typeof Keyboard> & {
onBlur?: FocusEventHandler<HTMLDivElement>;
onEnter?: (text: string) => void;
};
export function AmountKeyboard(props: AmountKeyboardProps) {
@@ -50,10 +51,18 @@ export function AmountKeyboard(props: AmountKeyboardProps) {
},
},
// eslint-disable-next-line rulesdir/typography
'& [data-skbtn="+"], & [data-skbtn="-"], & [data-skbtn="×"], & [data-skbtn="÷"], & [data-skbtn="{bksp}"]':
'& [data-skbtn="+"], & [data-skbtn="-"], & [data-skbtn="×"], & [data-skbtn="÷"]':
{
backgroundColor: theme.keyboardButtonSecondaryBackground,
},
// eslint-disable-next-line rulesdir/typography
'& [data-skbtn="{bksp}"], & [data-skbtn="{clear}"]': {
backgroundColor: theme.keyboardButtonSecondaryBackground,
},
// eslint-disable-next-line rulesdir/typography
'& [data-skbtn="{enter}"]': {
backgroundColor: theme.buttonPrimaryBackground,
},
}),
props.theme,
]);
@@ -81,25 +90,38 @@ export function AmountKeyboard(props: AmountKeyboardProps) {
}}
>
<Keyboard
layoutName="default"
layout={{
// eslint-disable-next-line prettier/prettier
default: [
'+ 1 2 3',
'- 4 5 6',
'× 7 8 9',
. 0 {bksp}',
{clear} 0 {bksp}',
'{space} , . {enter}',
],
}}
display={{
'{bksp}': '⌫',
'{enter}': '↵',
'{space}': '␣',
'{clear}': 'C',
}}
useButtonTag
stopMouseUpPropagation
stopMouseDownPropagation
autoUseTouchEvents
{...props}
keyboardRef={r => {
keyboardRef.current = r;
props.keyboardRef?.(r);
}}
onKeyPress={key => {
if (key === '{clear}') {
props.onChange?.('');
} else if (key === '{enter}') {
props.onEnter?.(keyboardRef.current?.getInput() || '');
}
props.onKeyPress?.(key);
}}
theme={layoutClassName}
/>
</View>

View File

@@ -1,17 +1,29 @@
// @ts-strict-ignore
import { currencyToAmount } from './util';
function fail(state, msg) {
// These operators go from high to low order of precedence
const operators = ['^', '/', '÷', '*', '×', '-', '+'] as const;
type ArithmeticOp = (typeof operators)[number];
type ArithmeticAst =
| number
| { op: ArithmeticOp; left: ArithmeticAst; right: ArithmeticAst };
type ArithmeticState = { str: string; index: number };
const parseOperator = makeOperatorParser(...operators);
function fail(state: ArithmeticState, msg: string) {
throw new Error(
msg + ': ' + JSON.stringify(state.str.slice(state.index, 10)),
);
}
function char(state) {
function char(state: ArithmeticState): string {
return state.str[state.index];
}
function next(state) {
function next(state: ArithmeticState): string {
if (state.index >= state.str.length) {
return null;
}
@@ -21,7 +33,7 @@ function next(state) {
return ch;
}
function nextOperator(state, op) {
function nextOperator(state: ArithmeticState, op: ArithmeticOp) {
if (char(state) === op) {
next(state);
return true;
@@ -30,7 +42,7 @@ function nextOperator(state, op) {
return false;
}
function parsePrimary(state) {
function parsePrimary(state: ArithmeticState): number {
// We only support numbers
const isNegative = char(state) === '-';
if (isNegative) {
@@ -50,7 +62,7 @@ function parsePrimary(state) {
return isNegative ? -number : number;
}
function parseParens(state) {
function parseParens(state: ArithmeticState): ArithmeticAst {
if (char(state) === '(') {
next(state);
const expr = parseOperator(state);
@@ -66,7 +78,7 @@ function parseParens(state) {
return parsePrimary(state);
}
function makeOperatorParser(...ops) {
function makeOperatorParser(...ops: ArithmeticOp[]) {
return ops.reduce((prevParser, op) => {
return state => {
let node = prevParser(state);
@@ -78,15 +90,15 @@ function makeOperatorParser(...ops) {
}, parseParens);
}
// These operators go from high to low order of precedence
const parseOperator = makeOperatorParser('^', '/', '*', '-', '+');
function parse(expression: string) {
const state = { str: expression.replace(/\s/g, ''), index: 0 };
function parse(expression: string): ArithmeticAst {
const state: ArithmeticState = {
str: expression.replace(/\s/g, ''),
index: 0,
};
return parseOperator(state);
}
function evaluate(ast): number {
function evaluate(ast: ArithmeticAst): number {
if (typeof ast === 'number') {
return ast;
}
@@ -99,8 +111,10 @@ function evaluate(ast): number {
case '-':
return evaluate(left) - evaluate(right);
case '*':
case '×':
return evaluate(left) * evaluate(right);
case '/':
case '÷':
return evaluate(left) / evaluate(right);
case '^':
return Math.pow(evaluate(left), evaluate(right));
@@ -129,3 +143,14 @@ export function evalArithmetic(
// Never return NaN
return isNaN(result) ? defaultValue : result;
}
export function hasArithmeticOperator(expression: string): boolean {
return operators.some(op => expression.includes(op));
}
export function lastIndexOfArithmeticOperator(expression: string): number {
return operators.reduce((max, op) => {
const index = expression.lastIndexOf(op);
return index > max ? index : max;
}, -1);
}