Compare commits

...

10 Commits

Author SHA1 Message Date
matmoul 9abdc33713 fix(calc-02): update stack labels for navigation and move modes 2026-05-17 05:34:06 +02:00
matmoul 6dd9550890 feat: add stack navigation and reordering controls 2026-05-17 04:24:43 +02:00
matmoul f44fb8c252 docs: align calculator visual portrait table cell 2026-05-17 03:23:47 +02:00
matmoul 5cc97f754d fix(calc-02): restore edited value on cancel
Canceling stack-top editing now pushes the original value back before
leaving edit mode, including Escape and ArrowDown handling.
2026-05-17 01:31:43 +02:00
matmoul 736154110d fix: correct calc-02 function key shortcuts 2026-05-17 00:38:32 +02:00
matmoul 62a0f447c5 feat: update calculator function key labels and shortcuts
Add titles for function and trig buttons to expose keyboard hints, and remap reciprocal/power10 shortcuts to match the new key layout.
Update the portrait visual spec to reflect the revised keypad and function ordering.
2026-05-17 00:35:50 +02:00
matmoul 16b567731f docs: update calc-02 shortcut reference and key mappings 2026-05-17 00:14:24 +02:00
matmoul b710d5f0eb refactor(calc-02): simplify stack display and edit cursor handling 2026-05-16 23:43:49 +02:00
matmoul 07a4c533fb fix: preserve full calc-02 stack while limiting display to top 4
Keep the calculator stack unlimited in the demo and only constrain the rendered stack view. Also restore the edit cursor position when pulling a value into the input from the stack.
2026-05-16 23:40:13 +02:00
matmoul acc075d30c feat(calc-02): add cursor-aware editing in the X stack display 2026-05-16 23:30:50 +02:00
5 changed files with 404 additions and 73 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# State
- Core engine: `src/rpn-calculator.js`
- Reference demo: `samples/calc-02/` (portrait-first HP48GX layout, compact mode/constants popups; Const button comes before Mode in the display row)
- Reference demo: `samples/calc-02/` (portrait-first HP48GX layout; calc-02 keyboard shortcuts are the reference; compact mode/constants popups; Const button comes before Mode in the display row)
- Important UI behavior: mode button shows the current angle mode; keyboard focus stays on the hidden input on desktop; clipboard paste is supported
- Note: keep scrolling behavior in mind for the calc-02 demo when changing the stack/UI layout
- Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands`
- Commands: arithmetic, stack, trigonometry, constants `pi` and `e`; arithmetic includes `root`; constants can be added or removed dynamically through the core API
+5 -2
View File
@@ -275,12 +275,15 @@ The current demo supports:
- `Escape`
- `ArrowUp`, `ArrowDown`, `ArrowRight`
- `+`, `-`, `*`, `/`, `%`, `^`
- `q`, `n`, `r`, `i`, `g`, `l`, `s`, `c`, `S`, `C`
- `x`, `y`, `z`, `t`
- `s`, `S`, `r`, `R`, `v`, `u`
- `l`, `L`, `n`, `N`, `e`, `E`
- `i`, `o`, `a`, `I`, `O`, `A`
The demo also implements stack selection and stack-item move mode in its UI layer using the public calculator methods.
It keeps the calculator screen focused and updates the visible stack window as the selection moves.
Note: `calc-02` keyboard shortcuts are the reference for this project.
## Exports
`RpnCalculator` is exposed in both environments:
+48 -4
View File
@@ -123,10 +123,7 @@ body {
padding-block: 0;
}
.stack-label {
text-align: right;
opacity: 0.78;
}
/* removed extra first-column styling */
.stack-value {
min-height: 0;
@@ -137,6 +134,22 @@ body {
justify-self: end;
font-size: 20px;
}
.stack-value.is-editing {
letter-spacing: 0.02em;
}
.stack-cell.is-selected {
background: transparent;
border-radius: 6px;
outline: 2px solid rgba(31, 42, 18, 0.2);
outline-offset: -2px;
}
.stack-cell.is-moving {
background: rgba(31, 42, 18, 0.18);
}
.display-buttons-panel {
padding: 8px;
min-height: 0;
@@ -379,6 +392,37 @@ button:active {
outline-offset: 2px;
}
.stack-value.is-editing {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
}
.edit-text {
display: inline-block;
white-space: pre;
}
.edit-caret {
display: inline-block;
width: 1px;
height: 1em;
margin: 0 0.12ch;
background: currentColor;
animation: caret-blink 1s steps(1, end) infinite;
transform: translateY(0.02em);
}
@keyframes caret-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0.15;
}
}
.key-default {
background: linear-gradient(180deg, var(--btnTop), var(--btnBottom));
color: #eef2f7;
+341 -58
View File
@@ -53,28 +53,28 @@ const keypadKeys = [
];
const functionKeys = [
{ label: 'x²', action: 'sqr', className: 'key-default' },
{ label: '', action: 'pow', className: 'key-default' },
{ label: '1/x', action: 'recip', className: 'key-default' },
{ label: 'x²', action: 'sqr', className: 'key-default', title: 's' },
{ label: '√x', action: 'sqrt', className: 'key-default', title: 'r' },
{ label: '1/x', action: 'recip', className: 'key-default', title: 'x' },
{ label: '%', action: 'mod', className: 'key-default' },
{ label: '√x', action: 'sqrt', className: 'key-default' },
{ label: 'y√x', action: 'root', className: 'key-default' },
{ label: '10ˣ', action: 'pow10', className: 'key-default' },
{ label: '', action: 'pow', className: 'key-default', title: 'S' },
{ label: 'y√x', action: 'root', className: 'key-default', title: 'R' },
{ label: '10ˣ', action: 'pow10', className: 'key-default', title: 'd' },
{ label: '', spacer: true },
{ label: 'log', action: 'log', className: 'key-default' },
{ label: 'ln', action: 'ln', className: 'key-default' },
{ label: 'eˣ', action: 'exp', className: 'key-default' },
{ label: 'log', action: 'log', className: 'key-default', title: 'l / L' },
{ label: 'ln', action: 'ln', className: 'key-default', title: 'n / N' },
{ label: 'eˣ', action: 'exp', className: 'key-default', title: 'e / E' },
{ label: '', spacer: true },
];
const trigoKeys = [
{ label: 'sin', action: 'sin', className: 'key-default' },
{ label: 'cos', action: 'cos', className: 'key-default' },
{ label: 'tan', action: 'tan', className: 'key-default' },
{ label: 'sin', action: 'sin', className: 'key-default', title: 'i' },
{ label: 'cos', action: 'cos', className: 'key-default', title: 'o' },
{ label: 'tan', action: 'tan', className: 'key-default', title: 'a' },
{ label: '', spacer: true },
{ label: 'asin', action: 'asin', className: 'key-default' },
{ label: 'acos', action: 'acos', className: 'key-default' },
{ label: 'atan', action: 'atan', className: 'key-default' },
{ label: 'asin', action: 'asin', className: 'key-default', title: 'I' },
{ label: 'acos', action: 'acos', className: 'key-default', title: 'O' },
{ label: 'atan', action: 'atan', className: 'key-default', title: 'A' },
{ label: '', spacer: true },
];
@@ -94,6 +94,11 @@ function focusInput() {
}
let statusTimer = null;
let editCursor = 0;
let editRestoreValue = null;
let stackMode = 'normal';
let stackSelection = 0;
let stackMoveSnapshot = null;
function setStatus(message, isError = false, timeoutMs = 1400) {
if (!statusLine) return;
@@ -118,27 +123,41 @@ function clearStatus() {
statusLine.classList.remove('is-error');
}
function normalizeStack() {
while (calc.stack.length > 4) {
calc.stack.shift();
}
}
function getStackLine(indexFromTop) {
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
}
function getStackDisplayValue(label) {
if (label === 'X') {
return calc.isEditing ? calc.inputValue : (calc.formatNumber(getStackLine(0)) || '');
if (label === 'X' && calc.isEditing) {
return calc.inputValue;
}
if (label === 'Y') {
return calc.isEditing ? (calc.formatNumber(getStackLine(0)) || '') : (calc.formatNumber(getStackLine(1)) || '');
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
const indexFromTop = calc.isEditing ? Math.max(0, indexMap[label] - 1) : indexMap[label];
return calc.formatNumber(getStackLine(indexFromTop)) || '';
}
function getVisibleStackLabel(label) {
if (stackMode === 'navigation' || stackMode === 'move') {
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
return String(indexMap[label]);
}
if (label === 'Z') {
return calc.isEditing ? (calc.formatNumber(getStackLine(1)) || '') : (calc.formatNumber(getStackLine(2)) || '');
return label;
}
function updateStackLabels() {
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const stackCell = stackEls[label].parentElement;
if (!stackCell) continue;
const labelEl = stackCell.querySelector('.stack-label');
if (labelEl) {
labelEl.textContent = `${getVisibleStackLabel(label)}:`;
}
}
return calc.isEditing ? (calc.formatNumber(getStackLine(2)) || '') : (calc.formatNumber(getStackLine(3)) || '');
}
function stackModeToLabel(index) {
return ['X', 'Y', 'Z', 'T'][Math.max(0, Math.min(3, index))] ?? 'X';
}
function updateCopyButtons() {
@@ -152,40 +171,191 @@ function updateCopyButtons() {
}
}
function renderEditValue() {
const cursor = Math.max(0, Math.min(editCursor, calc.inputValue.length));
stackEls.X.innerHTML = `<span class="edit-text">${calc.inputValue.slice(0, cursor)}</span><span class="edit-caret" aria-hidden="true"></span><span class="edit-text">${calc.inputValue.slice(cursor)}</span>`;
}
function render() {
normalizeStack();
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
calculatorEl?.classList.toggle('portrait', isPortrait);
calculatorEl?.classList.toggle('landscape', !isPortrait);
stackEls.X.textContent = getStackDisplayValue('X');
stackEls.Y.textContent = getStackDisplayValue('Y');
stackEls.Z.textContent = getStackDisplayValue('Z');
stackEls.T.textContent = getStackDisplayValue('T');
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const isSelected = stackMode !== 'normal' && stackModeToLabel(stackSelection) === label;
const value = label === 'X' && calc.isEditing ? '' : getStackDisplayValue(label);
stackEls[label].textContent = value;
stackEls[label].classList.toggle('is-editing', label === 'X' && calc.isEditing);
stackEls[label].classList.toggle('is-caret-visible', label === 'X' && calc.isEditing);
stackEls[label].parentElement?.classList.toggle('is-selected', isSelected);
stackEls[label].parentElement?.classList.toggle('is-moving', stackMode === 'move' && stackModeToLabel(stackSelection) === label);
}
if (calc.isEditing) {
renderEditValue();
}
updateStackLabels();
updateCopyButtons();
modeButton.textContent = calc.angleMode;
}
function stopEditing(clearValue = false) {
if (clearValue) {
calc.inputValue = '';
}
calc.isEditing = false;
editCursor = 0;
}
function cancelEditing() {
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
}
editRestoreValue = null;
stopEditing(true);
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function moveEditCursor(delta) {
editCursor = Math.max(0, Math.min(calc.inputValue.length, editCursor + delta));
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
calc.push(calc.parseInputValue(calc.inputValue));
}
editRestoreValue = null;
calc.inputValue = '';
calc.isEditing = false;
editCursor = 0;
}
function startEditingFromStackTop() {
if (!calc.isValidIndex(0)) return false;
const value = calc.stack[0];
editRestoreValue = value;
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
editCursor = calc.inputValue.length;
return true;
}
function inputToX(value) {
if (!calc.isEditing) {
calc.isEditing = true;
calc.inputValue = '';
editCursor = 0;
}
if (value === 'Backspace') {
calc.inputValue = calc.inputValue.slice(0, -1);
if (editCursor > 0) {
calc.inputValue = `${calc.inputValue.slice(0, editCursor - 1)}${calc.inputValue.slice(editCursor)}`;
editCursor -= 1;
}
} else {
calc.inputValue += value;
calc.inputValue = `${calc.inputValue.slice(0, editCursor)}${value}${calc.inputValue.slice(editCursor)}`;
editCursor += value.length;
}
if (calc.inputValue === '') {
calc.isEditing = false;
stopEditing();
}
}
@@ -217,11 +387,9 @@ function execute(name) {
}
} else if (name === 'clear') {
calc.clear();
calc.inputValue = '';
calc.isEditing = false;
stopEditing(true);
} else if (name === 'escape') {
calc.inputValue = '';
calc.isEditing = false;
cancelEditing();
} else if (name === 'backspace') {
if (calc.isEditing) {
inputToX('Backspace');
@@ -235,7 +403,9 @@ function execute(name) {
}
} else if (name === 'neg') {
if (calc.isEditing) {
calc.inputValue = calc.inputValue.startsWith('-') ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
const hasSign = calc.inputValue.startsWith('-');
calc.inputValue = hasSign ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
moveEditCursor(hasSign ? -1 : 1);
} else {
calc.command('neg');
}
@@ -243,13 +413,16 @@ function execute(name) {
pushEditingValueIfNeeded();
calc.command(name);
}
if (!calc.isEditing) {
editCursor = 0;
}
render();
} catch (error) {
setStatus(error?.message || 'Operation error', true);
}
}
function createKeyButton({ label, input, action, spacer, className }) {
function createKeyButton({ label, input, action, spacer, className, title }) {
if (spacer) {
const div = document.createElement('div');
return div;
@@ -258,6 +431,7 @@ function createKeyButton({ label, input, action, spacer, className }) {
button.type = 'button';
button.textContent = label;
button.className = className;
if (title) button.title = title;
button.addEventListener('click', () => {
if (input) {
inputToX(input);
@@ -288,6 +462,53 @@ async function copyStackValue(label) {
function handleKeyboard(event) {
if (event.defaultPrevented) return;
const key = event.key;
if (stackMode === 'navigation') {
if (key === 'ArrowUp') {
event.preventDefault();
moveNavigationSelection(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveNavigationSelection(-1);
return;
}
if (key === 'Escape' || key === 'ArrowLeft') {
event.preventDefault();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
beginStackMove();
return;
}
return;
}
if (stackMode === 'move') {
if (key === 'ArrowUp') {
event.preventDefault();
moveSelectedStackValue(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveSelectedStackValue(-1);
return;
}
if (key === 'Escape') {
event.preventDefault();
restoreStackMoveSnapshot();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
validateStackMove();
return;
}
return;
}
if (/^[0-9.]$/.test(key)) {
event.preventDefault();
inputToX(key);
@@ -305,16 +526,58 @@ function handleKeyboard(event) {
'/': 'div',
'%': 'mod',
'^': 'pow',
s: 'sqr',
S: 'pow',
r: 'sqrt',
R: 'root',
x: 'recip',
d: 'pow10',
l: 'log',
L: 'log',
n: 'ln',
N: 'ln',
e: 'exp',
E: 'exp',
i: 'sin',
o: 'cos',
a: 'tan',
I: 'asin',
O: 'acos',
A: 'atan',
};
const arrowMap = {
ArrowUp: upButton,
ArrowDown: downButton,
ArrowLeft: leftButton,
ArrowRight: rightButton,
};
if (arrowMap[key]) {
if (key === 'ArrowLeft') {
event.preventDefault();
arrowMap[key].click();
if (calc.isEditing) {
moveEditCursor(-1);
render();
return;
}
leftButton.click();
return;
}
if (key === 'ArrowRight') {
event.preventDefault();
if (calc.isEditing) {
moveEditCursor(1);
render();
return;
}
rightButton.click();
return;
}
if (key === 'ArrowUp') {
event.preventDefault();
upButton.click();
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
if (calc.isEditing) {
cancelEditing();
render();
return;
}
downButton.click();
return;
}
if (map[key]) {
@@ -413,14 +676,19 @@ hiddenInput.addEventListener('paste', (event) => {
pasteTextIntoStack(text);
});
upButton.addEventListener('click', () => {});
upButton.addEventListener('click', () => {
if (!calc.isEditing && stackMode === 'normal') {
enterNavigationMode();
focusInput();
}
});
const constantLabels = {
pi: 'π',
e: 'e',
phi: 'φ',
g: 'g',
c: 'c',
c: 'C',
};
const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
@@ -459,19 +727,34 @@ constButton.addEventListener('click', (event) => {
toggleConstMenu();
});
leftButton.addEventListener('click', () => {});
leftButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(-1);
render();
focusInput();
return;
}
if (stackMode === 'navigation') {
exitStackMode();
focusInput();
}
});
downButton.addEventListener('click', () => {
if (!calc.isEditing && calc.isValidIndex(0)) {
const value = calc.stack[0];
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
if (!calc.isEditing && startEditingFromStackTop()) {
render();
focusInput();
}
});
rightButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(1);
render();
focusInput();
return;
}
execute('swap');
});
+8 -8
View File
@@ -24,11 +24,11 @@
```
┌──────────── Keypad ─────────────┐
| +/- | Clear | Esc | backspace |
| 7 | 8 | 9 | / |
| 4 | 5 | 6 | * |
| 1 | 2 | 3 | - |
| 0 | . | Enter | + |
| Clear | Backspace | Esc | Enter |
| 7 | 8 | 9 | / |
| 4 | 5 | 6 | * |
| 1 | 2 | 3 | - |
| 0 | . | +/- | + |
└─────────────────────────────────┘
```
@@ -36,9 +36,9 @@
```
┌──────────── Functions ──────────┐
| x^2 | y^x | 1/x | % |
| √x | y√x | 10^x | |
| log | ln | | |
| x^2 | √x | 1/x | % |
| y^x | y√x | 10^x | |
| log | ln | e^x | |
└─────────────────────────────────┘
```