Compare commits

...

4 Commits

Author SHA1 Message Date
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
4 changed files with 159 additions and 43 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# State # State
- Core engine: `src/rpn-calculator.js` - 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 - 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` - Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands` - 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 - 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` - `Escape`
- `ArrowUp`, `ArrowDown`, `ArrowRight` - `ArrowUp`, `ArrowDown`, `ArrowRight`
- `+`, `-`, `*`, `/`, `%`, `^` - `+`, `-`, `*`, `/`, `%`, `^`
- `q`, `n`, `r`, `i`, `g`, `l`, `s`, `c`, `S`, `C` - `s`, `S`, `r`, `R`, `v`, `u`
- `x`, `y`, `z`, `t` - `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. 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. 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 ## Exports
`RpnCalculator` is exposed in both environments: `RpnCalculator` is exposed in both environments:
+35
View File
@@ -137,6 +137,10 @@ body {
justify-self: end; justify-self: end;
font-size: 20px; font-size: 20px;
} }
.stack-value.is-editing {
letter-spacing: 0.02em;
}
.display-buttons-panel { .display-buttons-panel {
padding: 8px; padding: 8px;
min-height: 0; min-height: 0;
@@ -379,6 +383,37 @@ button:active {
outline-offset: 2px; 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 { .key-default {
background: linear-gradient(180deg, var(--btnTop), var(--btnBottom)); background: linear-gradient(180deg, var(--btnTop), var(--btnBottom));
color: #eef2f7; color: #eef2f7;
+117 -40
View File
@@ -94,6 +94,7 @@ function focusInput() {
} }
let statusTimer = null; let statusTimer = null;
let editCursor = 0;
function setStatus(message, isError = false, timeoutMs = 1400) { function setStatus(message, isError = false, timeoutMs = 1400) {
if (!statusLine) return; if (!statusLine) return;
@@ -118,27 +119,17 @@ function clearStatus() {
statusLine.classList.remove('is-error'); statusLine.classList.remove('is-error');
} }
function normalizeStack() {
while (calc.stack.length > 4) {
calc.stack.shift();
}
}
function getStackLine(indexFromTop) { function getStackLine(indexFromTop) {
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : ''; return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
} }
function getStackDisplayValue(label) { function getStackDisplayValue(label) {
if (label === 'X') { if (label === 'X' && calc.isEditing) {
return calc.isEditing ? calc.inputValue : (calc.formatNumber(getStackLine(0)) || ''); return calc.inputValue;
} }
if (label === 'Y') { const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
return calc.isEditing ? (calc.formatNumber(getStackLine(0)) || '') : (calc.formatNumber(getStackLine(1)) || ''); const indexFromTop = calc.isEditing ? Math.max(0, indexMap[label] - 1) : indexMap[label];
} return calc.formatNumber(getStackLine(indexFromTop)) || '';
if (label === 'Z') {
return calc.isEditing ? (calc.formatNumber(getStackLine(1)) || '') : (calc.formatNumber(getStackLine(2)) || '');
}
return calc.isEditing ? (calc.formatNumber(getStackLine(2)) || '') : (calc.formatNumber(getStackLine(3)) || '');
} }
function updateCopyButtons() { function updateCopyButtons() {
@@ -152,19 +143,40 @@ 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() { function render() {
normalizeStack();
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860; const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
calculatorEl?.classList.toggle('portrait', isPortrait); calculatorEl?.classList.toggle('portrait', isPortrait);
calculatorEl?.classList.toggle('landscape', !isPortrait); calculatorEl?.classList.toggle('landscape', !isPortrait);
stackEls.X.textContent = getStackDisplayValue('X'); stackEls.X.textContent = calc.isEditing ? '' : getStackDisplayValue('X');
if (calc.isEditing) {
renderEditValue();
}
stackEls.Y.textContent = getStackDisplayValue('Y'); stackEls.Y.textContent = getStackDisplayValue('Y');
stackEls.Z.textContent = getStackDisplayValue('Z'); stackEls.Z.textContent = getStackDisplayValue('Z');
stackEls.T.textContent = getStackDisplayValue('T'); stackEls.T.textContent = getStackDisplayValue('T');
stackEls.X.classList.toggle('is-editing', calc.isEditing);
stackEls.X.classList.toggle('is-caret-visible', calc.isEditing);
updateCopyButtons(); updateCopyButtons();
modeButton.textContent = calc.angleMode; modeButton.textContent = calc.angleMode;
} }
function stopEditing(clearValue = false) {
if (clearValue) {
calc.inputValue = '';
}
calc.isEditing = false;
editCursor = 0;
}
function moveEditCursor(delta) {
editCursor = Math.max(0, Math.min(calc.inputValue.length, editCursor + delta));
}
function pushEditingValueIfNeeded() { function pushEditingValueIfNeeded() {
if (!calc.isEditing) return; if (!calc.isEditing) return;
if (calc.inputValue !== '') { if (calc.inputValue !== '') {
@@ -172,20 +184,36 @@ function pushEditingValueIfNeeded() {
} }
calc.inputValue = ''; calc.inputValue = '';
calc.isEditing = false; calc.isEditing = false;
editCursor = 0;
}
function startEditingFromStackTop() {
if (!calc.isValidIndex(0)) return false;
const value = calc.stack[0];
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
editCursor = calc.inputValue.length;
return true;
} }
function inputToX(value) { function inputToX(value) {
if (!calc.isEditing) { if (!calc.isEditing) {
calc.isEditing = true; calc.isEditing = true;
calc.inputValue = ''; calc.inputValue = '';
editCursor = 0;
} }
if (value === 'Backspace') { 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 { } else {
calc.inputValue += value; calc.inputValue = `${calc.inputValue.slice(0, editCursor)}${value}${calc.inputValue.slice(editCursor)}`;
editCursor += value.length;
} }
if (calc.inputValue === '') { if (calc.inputValue === '') {
calc.isEditing = false; stopEditing();
} }
} }
@@ -217,11 +245,9 @@ function execute(name) {
} }
} else if (name === 'clear') { } else if (name === 'clear') {
calc.clear(); calc.clear();
calc.inputValue = ''; stopEditing(true);
calc.isEditing = false;
} else if (name === 'escape') { } else if (name === 'escape') {
calc.inputValue = ''; stopEditing(true);
calc.isEditing = false;
} else if (name === 'backspace') { } else if (name === 'backspace') {
if (calc.isEditing) { if (calc.isEditing) {
inputToX('Backspace'); inputToX('Backspace');
@@ -235,7 +261,9 @@ function execute(name) {
} }
} else if (name === 'neg') { } else if (name === 'neg') {
if (calc.isEditing) { 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 { } else {
calc.command('neg'); calc.command('neg');
} }
@@ -243,6 +271,9 @@ function execute(name) {
pushEditingValueIfNeeded(); pushEditingValueIfNeeded();
calc.command(name); calc.command(name);
} }
if (!calc.isEditing) {
editCursor = 0;
}
render(); render();
} catch (error) { } catch (error) {
setStatus(error?.message || 'Operation error', true); setStatus(error?.message || 'Operation error', true);
@@ -305,16 +336,53 @@ function handleKeyboard(event) {
'/': 'div', '/': 'div',
'%': 'mod', '%': 'mod',
'^': 'pow', '^': 'pow',
s: 'sqr',
S: 'pow',
r: 'sqrt',
R: 'root',
v: 'recip',
u: '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 = { if (key === 'ArrowLeft') {
ArrowUp: upButton,
ArrowDown: downButton,
ArrowLeft: leftButton,
ArrowRight: rightButton,
};
if (arrowMap[key]) {
event.preventDefault(); 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();
downButton.click();
return; return;
} }
if (map[key]) { if (map[key]) {
@@ -420,7 +488,7 @@ const constantLabels = {
e: 'e', e: 'e',
phi: 'φ', phi: 'φ',
g: 'g', g: 'g',
c: 'c', c: 'C',
}; };
const constantOrder = ['pi', 'e', 'phi', 'g', 'c']; const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
@@ -459,19 +527,28 @@ constButton.addEventListener('click', (event) => {
toggleConstMenu(); toggleConstMenu();
}); });
leftButton.addEventListener('click', () => {}); leftButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(-1);
render();
focusInput();
}
});
downButton.addEventListener('click', () => { downButton.addEventListener('click', () => {
if (!calc.isEditing && calc.isValidIndex(0)) { if (!calc.isEditing && startEditingFromStackTop()) {
const value = calc.stack[0];
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
render(); render();
focusInput(); focusInput();
} }
}); });
rightButton.addEventListener('click', () => { rightButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(1);
render();
focusInput();
return;
}
execute('swap'); execute('swap');
}); });