Compare commits

...

113 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
matmoul 6a37c7c1e8 feat: add pow10 and exp arithmetic operations 2026-05-16 23:15:22 +02:00
matmoul 5f2d18df99 fix(calc-02): constrain keypad area with internal scrolling
Wrap the keypad, function, and trig panels in a dedicated scroll area so the calculator shell can use a fixed grid layout without overflowing the viewport. Also update panel styling to preserve spacing and scrollbar behavior.
2026-05-16 22:40:23 +02:00
matmoul c69380d057 refactor(samples): reorder calculator keypad keys 2026-05-16 21:58:51 +02:00
matmoul c11a75d7f7 fix: bust calc sample stylesheet cache 2026-05-16 21:52:05 +02:00
matmoul 813fae1b61 refactor(calc-02): simplify calculator layout structure
Move the display and button panels into a single wrapper and rely on
viewport-height sizing with hidden page overflow to keep the calculator
contained. Also trim redundant panel-specific layout rules and styling.
2026-05-16 21:41:47 +02:00
matmoul 208db58fe7 fix: make calc-02 panels stretch and scroll within container 2026-05-16 20:24:43 +02:00
matmoul ea6401a006 fix: constrain calculator width on calc-02 sample 2026-05-16 20:17:19 +02:00
matmoul ea61ec17d5 fix: remove portrait class from calc-02 calculator shell 2026-05-16 19:44:09 +02:00
matmoul a4bbffe65b feat: replace calculator enter key label with symbol 2026-05-16 18:41:03 +02:00
matmoul a24142cd72 docs: remove obsolete temporary README file 2026-05-16 05:23:42 +02:00
matmoul 4df99d0738 docs: update README for default demo and command aliases 2026-05-16 05:01:17 +02:00
matmoul 1cef2d2d6a fix(calc-02): reorder display buttons in portrait layout 2026-05-16 04:44:18 +02:00
matmoul 442a97d573 fix: reorder calc display buttons in portrait layout 2026-05-16 04:34:20 +02:00
matmoul 44ce67c5cc docs: align calc-02 visual portrait tables 2026-05-16 04:20:22 +02:00
matmoul a7411243de fix: reorder display buttons in calc-02 sample 2026-05-16 04:14:28 +02:00
matmoul f8de4e1709 docs: update project memory for calc-02 UI and command notes 2026-05-16 04:10:19 +02:00
matmoul 003d4fde1b feat(calc-02): add shared popup menus for mode and constants
Refactor the calc-02 demo to use a single popup menu component for angle mode and constants, align the menus to their trigger buttons, and update the README/project notes to reflect the portrait-first demo layout and constant API.
2026-05-16 04:04:01 +02:00
matmoul e5f50aee0a fix: reorder calc-02 display buttons 2026-05-16 03:44:20 +02:00
matmoul 6a7a60a9bc fix: align calc menus with trigger width 2026-05-16 03:41:25 +02:00
matmoul a37ed59b40 fix: reorder calc-02 display buttons 2026-05-16 03:37:04 +02:00
matmoul c09fdc7e0f refactor(calc-02): simplify display and button styling
Remove layered shadows and pressed-state transforms from the calculator UI, and update the stack copy icon markup and active feedback to match the new flat design.
2026-05-16 03:21:49 +02:00
matmoul 849170ea49 docs: update calc-02 portrait visual reference
Move the portrait layout documentation to samples/calc-02/visual-portrait.md and refresh the project memory note to point to the new reference.
2026-05-16 03:11:00 +02:00
matmoul 0100da80bb fix(calc-02): adjust keypad grid sizing 2026-05-16 03:02:43 +02:00
matmoul e1fd730db5 fix: remove panel border from calc sample 2026-05-16 02:48:59 +02:00
matmoul ba53f51bf7 refactor(samples): simplify calc-02 background styling 2026-05-16 02:48:00 +02:00
matmoul 534bbc0afb feat: add dynamic constant management to the calculator core 2026-05-16 02:23:20 +02:00
matmoul 2504716c64 fix: tighten calc stack display layout
Reorder the stack cell grid so the copy button sits beside the label and reduce spacing to better align the value column.
2026-05-16 02:04:47 +02:00
matmoul 6c8c032f7a fix: stabilize calculator display sizing 2026-05-16 02:02:45 +02:00
matmoul d8d0556822 style: tighten calculator sample layout spacing 2026-05-16 01:48:52 +02:00
matmoul 426fd326a5 fix(calc-02): simplify button spacing and menu background 2026-05-16 01:43:00 +02:00
matmoul 5364208491 feat(calc-02): refine display button layout and icon styling 2026-05-16 01:38:53 +02:00
matmoul 1e703bebe8 fix(calc-02): adjust display button layout and enter key spacing 2026-05-16 01:28:30 +02:00
matmoul 54797f9dd9 refactor(samples/calc-02): simplify layout to portrait-only single column
Remove the responsive two-column desktop arrangement and make the calculator stack vertically with a 4-column display button row that preserves alignment. Also align the display buttons with the shared base button styling.
2026-05-16 01:16:47 +02:00
matmoul 75bf6d69df fix(calc-02): align display buttons in a grid
Wrap the display controls in a dedicated grid container, add a spacer for the missing cell, and simplify the button styling so the layout stays consistent across sizes.
2026-05-16 01:08:11 +02:00
matmoul 256e9f2b33 refactor(calc-02): move display buttons into their own grid area 2026-05-16 01:01:14 +02:00
matmoul 77fb671dcf fix: use typographic operator symbols in calculator keypad 2026-05-16 00:37:16 +02:00
matmoul ae11cb8007 fix: tighten calculator layout for full-screen mobile sizing 2026-05-16 00:33:58 +02:00
matmoul ba7fc8b4d6 fix: right-align calculator display text 2026-05-16 00:02:10 +02:00
matmoul b45cfe8091 feat: add root command and fix calc-02 exponent shortcuts 2026-05-15 23:05:43 +02:00
matmoul cb45efff43 feat: add root operation to RPN calculator 2026-05-15 22:58:21 +02:00
matmoul 4e8155b5f0 fix(calc-02): tighten calculator layout and panel styling
Wrap the display and control buttons in a shared display block so the
stack and button panels align as a single unit. Reduce corner radii and
adjust gaps/padding across the calculator to better fit the updated
portrait and desktop layouts.
2026-05-15 22:35:19 +02:00
matmoul 432523c23f fix: toggle calc sample menus on repeated clicks 2026-05-15 21:50:36 +02:00
matmoul 9cbddfa0c2 fix: align constant menu to the right edge 2026-05-15 21:46:49 +02:00
matmoul db3bee6e89 feat(calc-02): restyle enter key label and colors 2026-05-15 21:44:41 +02:00
matmoul 62221a9baa feat: add arrow key navigation to calculator sample 2026-05-15 21:38:14 +02:00
matmoul c47c46ad64 fix: clear status after successful clipboard and constant actions 2026-05-15 21:33:38 +02:00
matmoul 80bcdac320 feat: add stack copy buttons to calculator display 2026-05-15 21:32:43 +02:00
matmoul 39659745a6 feat(calc-02): reorder keypad actions for safer input flow 2026-05-15 21:18:47 +02:00
matmoul 75fe72412e fix(samples): keep hidden input focused for keyboard input 2026-05-15 21:03:16 +02:00
matmoul ef0e0c8dd2 feat: show calculator status messages as overlay bar 2026-05-15 20:56:05 +02:00
matmoul 6444357444 fix: ignore backspace when the stack is empty 2026-05-15 20:47:19 +02:00
matmoul d1a1d44577 feat: support pasting numbers into the calculator stack
Add clipboard paste handling for the hidden input and the paste button so pasted text is parsed as a numeric value before being pushed. Also add the eˣ function key in the sample calculator and keep the hidden input selected on focus for Ctrl+V support.
2026-05-15 20:45:30 +02:00
matmoul 02b3b280f8 feat: enable editing from the down button in calc sample 2026-05-15 20:24:21 +02:00
matmoul 40e1043a03 feat: add swap action and HP48-style stack editing 2026-05-15 20:19:39 +02:00
matmoul f679b0d952 feat: add constants popup to calculator sample 2026-05-15 19:12:36 +02:00
matmoul 2505a102df feat(calc-02): replace display button labels with symbols 2026-05-15 19:09:24 +02:00
matmoul 9bca077347 fix: change default serve port to 3000 2026-05-15 19:00:50 +02:00
matmoul d88722030a Merge branch 'dev' into calc-02 2026-05-15 18:56:57 +02:00
matmoul 324f203d23 chore: add portable local static file server script 2026-05-15 18:56:22 +02:00
matmoul 48a262eb87 feat: add responsive calc-02 HP48GX demo 2026-05-15 18:45:44 +02:00
matmoul 95eb1d265f docs: update README and memory notes 2026-05-02 00:23:11 +02:00
matmoul 197cbb161c docs: refresh project rules and README for current engine API 2026-04-25 04:36:53 +02:00
matmoul fbbc9455fb fix: improve calc topbar responsiveness on smaller screens 2026-04-25 04:06:45 +02:00
matmoul 6beefe82bc refactor(calc-01): simplify keyboard layout and consts label 2026-04-25 04:03:48 +02:00
matmoul e1c87de626 docs: add vertical calculator visual sample 2026-04-25 03:57:49 +02:00
matmoul 69c4bfbfab fix: preserve stack until operation completes 2026-04-25 03:41:01 +02:00
matmoul eaedcb6b74 refactor(calc-01): remove redundant keyboard group titles 2026-04-25 03:32:26 +02:00
matmoul 0e72f64c3b fix(calc-01): show errors in the display area 2026-04-25 03:24:07 +02:00
matmoul c9be42f252 docs: update README sample demo paths 2026-04-25 03:18:54 +02:00
matmoul 86617e1048 feat: make calc enter key label vertical 2026-04-25 03:13:04 +02:00
matmoul 1396a16de6 fix: align calc mode menu popup styling 2026-04-25 03:09:56 +02:00
matmoul 84451d0abc feat(calc-01): add distinct styles for backspace and escape keys 2026-04-25 02:54:23 +02:00
matmoul 223bf56339 feat: refresh calculator keypad labels and layout
Update the sample calculator UI to use compact math symbols, uppercase action labels, and a revised top bar layout. Also uppercase the angle mode button text and remove the backspace-specific width override.
2026-04-25 02:47:41 +02:00
matmoul 144c334fe5 fix: tighten calc-01 topbar layout 2026-04-25 02:32:16 +02:00
matmoul 3b7f35a00d refactor(samples): simplify calc-01 topbar layout and remove debug labels 2026-04-25 02:27:25 +02:00
matmoul 3d58309e0d feat: replace calc topbar controls with popup menus 2026-04-25 02:14:40 +02:00
matmoul 784c470b67 feat(calc-01): move controls into a compact top bar 2026-04-25 02:10:04 +02:00
matmoul 6a28aaaac6 feat: add HP48-style RPN calculator sample 2026-04-25 01:43:05 +02:00
matmoul 4ef19b1339 docs: update project rules for demo angle modes and UI structure 2026-04-25 01:23:51 +02:00
matmoul f30bdb9946 fix: restore edited x value on escape 2026-04-25 01:17:32 +02:00
matmoul 2857df2c6f feat: add scroll offset for stack view in dev sample 2026-04-25 01:14:15 +02:00
matmoul 06f915f3e1 feat: show stack indexes during selection and move mode 2026-04-25 01:10:43 +02:00
matmoul 98bc887a6e fix: tighten stack selection keyboard handling 2026-04-25 01:06:44 +02:00
matmoul 09e4d94908 fix: skip stack arrow handling while editing 2026-04-25 00:57:01 +02:00
matmoul 679ecbef7d chore: remove hp48 sample calculator page 2026-04-25 00:53:15 +02:00
matmoul 11d3c1da1f refactor(samples/dev): extract calculator styles and script files 2026-04-25 00:51:48 +02:00
matmoul 202edb47f4 fix: improve hp48 keypad layout on narrow screens 2026-04-25 00:13:47 +02:00
matmoul 26814bee3c feat(hp48): color-code keypad and inline error display 2026-04-25 00:04:33 +02:00
matmoul e9a6ad49d1 refactor(samples/hp48): unify popup menu handling and move angle mode into keypad 2026-04-24 23:55:00 +02:00
matmoul 613e688608 refactor: simplify hp48 sample UI 2026-04-24 23:53:25 +02:00
matmoul 6dcf1d603c feat: redesign hp48 keypad and add keyboard navigation 2026-04-24 23:39:02 +02:00
matmoul 4b684912f7 fix: preserve x value when re-entering edit mode 2026-04-24 22:34:43 +02:00
matmoul f8d2fc94d6 feat: add stack selection and move controls to dev sample 2026-04-24 22:31:01 +02:00
matmoul 277c4689d5 feat: add keyboard shortcuts for calculator commands 2026-04-24 22:08:24 +02:00
matmoul dbe046a194 feat: add keyboard shortcuts for delete, escape, and swap 2026-04-24 22:01:22 +02:00
matmoul 02e68dc1c7 refactor(samples): use stack helpers in dev calculator UI 2026-04-24 21:55:58 +02:00
matmoul 9c61531820 feat: add enter key handling to dev sample calculator 2026-04-24 21:43:11 +02:00
matmoul 67bac6f486 feat: support global keyboard input in the dev sample 2026-04-24 21:35:10 +02:00
matmoul 30714a6c4e docs: refresh README and add HP48-style browser demo 2026-04-24 21:16:40 +02:00
matmoul b6ca7d6b75 fix(hp48): render edited stack lines while editing
Use the input value for the first line and shift stack lookups down for
subsequent lines during edit mode. Also route Enter through execute() so
editing state is handled consistently before re-rendering.
2026-04-24 21:08:50 +02:00
matmoul 236df5db14 fix: preserve editing value in hp48 stack rendering 2026-04-22 22:32:13 +02:00
matmoul 13acd6ee96 feat: add HP48-style RPN calculator sample 2026-04-22 22:25:50 +02:00
matmoul 2d1bb407e1 chore: make publish script executable 2026-04-22 22:16:51 +02:00
23 changed files with 3805 additions and 363 deletions
+71 -20
View File
@@ -1,20 +1,24 @@
# Project rules — RPN Virtual Calculator
- Build a browser-friendly RPN calculator as a JavaScript class, preferably in a single file.
- Build a browser-friendly RPN calculator as a JavaScript class, currently implemented in `src/rpn-calculator.js`.
- Keep code names, categories, and API identifiers in English.
- Use only read-only / generic public API methods: `push`, `pop`, `clear`, `swap(index1, index2)`, `remove(index)`, `edit(index)`, `isValidIndex(index)`, `input(command)`, and `command(name, ...args)`.
- Expose `inputValue` as a string and `isEditing` as a boolean.
- Constructor options:
- Keep the public calculator API centered on the generic methods `push`, `pop`, `clear`, `swap(index1, index2)`, `remove(index)`, `edit(index)`, `isValidIndex(index)`, `input(command)`, and `command(name, ...args)`.
- `inputValue` must remain a string and `isEditing` must remain a boolean.
- Keep constructor options aligned with the current engine:
- `maxSize` (default `2048`)
- `base` (default `10`)
- `angleMode` (`deg` default; also `rad` and `grad`)
- `base` (default `10`, accepted range `2..16`)
- `angleMode` (`deg` by default; also `rad` and `grad`)
- `enabledCommands`
- Available constants: `pi`, `e`.
- Supported operations must be centralized in one dictionary containing at least:
- Available constants are `pi` and `e`.
- Supported operations must stay centralized in one dictionary containing at least:
- `argCount`
- `category`
- `aliases`
- Allowed categories are limited to: `Stack`, `Arithmetic`, and `Trigonometry`.
- `execute`
- Allowed operation categories are limited to `Stack`, `Arithmetic`, and `Trigonometry`.
- The engine currently exposes static helpers for category discovery: `getOperationCategories()` and `getOperationsByCategory()`.
- The instance currently exposes `getOperationsByCategory()` and `getConstants()` helpers in addition to the generic API.
- Preserve browser and CommonJS exports for `RpnCalculator`.
## Supported commands
@@ -22,23 +26,70 @@
- `add`, `sub`, `mul`, `div`, `mod`, `pow`, `sqr`, `neg`, `sqrt`, `recip`,
`sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log`, `ln`,
`dup`, `drop`, `swap`, `clear`, `enter`
- Aliases:
- Current aliases include:
- `+`, `-`, `*`, `/`, `%`, `^`, `y^x`, `1/x`
- Existing extra alias also present in code:
- `sqrt(x)` for `sqrt`
## Behavior rules
- `mod` is the percentage operator: `a b % => (a * b) / 100`
- `sqrt`, `asin`, `acos`, `log`, and `ln` must throw clear, explicit domain errors
- `log` uses `Math.log10`
- `ln` uses `Math.log`
- Trigonometric functions use degrees in the demo:
- `sin`, `cos`, `tan` convert degrees to radians
- inverse trig functions return degrees
- `inputValue` must remain a string to preserve future hexadecimal input support
- The example HTML must group buttons by `Stack`, `Arithmetic`, and `Trigonometry`
- The example HTML must call `command(...)` for actions
- `mod` is the percentage operator: `a b % => (a * b) / 100`.
- `div` and `recip` must throw `Division by zero` on zero divisors.
- `sqrt`, `asin`, `acos`, `log`, and `ln` must throw explicit domain errors.
- `log` uses `Math.log10`.
- `ln` uses `Math.log`.
- Trigonometric functions must support `deg`, `rad`, and `grad`.
- Direct trigonometric functions convert input angles with `toRadians(...)`.
- Inverse trigonometric functions convert results back using the current angle mode via `toDegrees(...)`.
- The engine rounds formatted numeric results to 12 decimal places and normalizes `-0` to `0`.
- `inputValue` must remain a string to preserve future hexadecimal-style input support.
- `parseInputValue(...)` currently uses `Number(...)` in base 10 and `parseInt(..., base)` for other bases.
- `input(command)` currently accepts single-character numeric editing input, including `0-9`, `A-F`, `a-f`, `+`, `-`, and `.`.
- `command(name, ...args)` currently resolves aliases, supports constants, commits pending input before execution, checks `enabledCommands`, and throws clear `Unknown command`, `Command disabled`, `Stack overflow`, `Stack underflow`, `Invalid stack index`, `Invalid number`, and `Invalid input value` errors where appropriate.
## Demo rules
- The active browser demo lives under `samples/dev/`.
- `samples/dev/index.html` is the demo entry page.
- `samples/dev/index.css` contains the calculator visual theme.
- `samples/dev/index.js` contains demo-side presentation helpers and UI logic.
- The demo currently exposes:
- a stack display with four visible lines
- a main display/status area
- a visible angle mode indicator
- an angle mode selector for `deg`, `rad`, and `grad`
- status pills showing `inputValue` and `isEditing`
- grouped command panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants`
- an error display area
- The demo may present user-facing labels such as `+`, ``, `×`, `÷`, `y^x`, `1/x`, and `x²` while still using English command identifiers internally.
- The example HTML groups buttons by `Stack`, `Arithmetic`, and `Trigonometry`, plus a dedicated `Constants` section.
- Demo buttons are wired through demo helpers that eventually call `command(...)` for calculator commands.
- The demo default angle mode is degrees.
- Keyboard support in the demo must remain consistent with the displayed help text.
- The current demo keyboard behavior supports:
- digits and decimal point
- numpad digits and numpad arithmetic keys
- `Enter` to validate input or toggle stack-item move mode
- `Backspace` to edit input or drop from the stack
- `Delete` to clear
- `Escape` to cancel editing, cancel move mode, or clear selection
- `ArrowUp` / `ArrowDown` to navigate or move selected stack items
- `ArrowRight` for swap
- `+`, `-`, `*`, `/`, `%`, `^`
- `q`, `n`, `r`, `i`, `g`, `l`, `s`, `c`, `S`, `C`
- `x`, `y`, `z`, `t` to select visible stack registers
- The demo currently implements stack selection and stack-item move mode in its own UI logic using the public stack methods.
- The demo currently duplicates X on `enter` when not editing, matching classic RPN-style behavior in the UI layer.
## File references
- `samples/dev/index.html` references `../../src/rpn-calculator.js` as the calculator engine used by the demo.
- Keep the demo aligned with the public calculator API exposed by `src/rpn-calculator.js`.
- `README.md` is currently outdated and duplicated in places; if documentation work is done later, align it with the actual engine and demo behavior.
## Maintenance
- Re-read `src/rpn-calculator.js` and `samples/dev/` before updating these rules after project changes.
- Keep this file updated after each project change using the provided editing tools.
- When changing the demo UI, keyboard help, exported API, command metadata, or calculator behavior, update these rules so they continue to match the current project behavior.
+3
View File
@@ -0,0 +1,3 @@
- README is more detailed than memory files; keep memory compact.
- Use `state.md` for evolving engine/demo/API details.
- `project.md` should stay as a short pointer file.
+9
View File
@@ -0,0 +1,9 @@
# State
- Core engine: `src/rpn-calculator.js`
- 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
- Exports: browser `window.RpnCalculator`, CommonJS `module.exports`
+8
View File
@@ -0,0 +1,8 @@
# State
- Core engine: `src/rpn-calculator.js`
- Default demo: `samples/dev/` classic browser demo; `samples/calc-02/` remains the portrait-first HP48GX-inspired reference
- 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`, `e`, `phi`, `g`, and `c`; arithmetic includes `root` with `y√x`, `yroot`, and `nroot` aliases; constants can be added or removed dynamically through the core API
- Demo behavior: `samples/dev/` keeps hidden input focus on desktop and supports clipboard paste; `samples/calc-02/` uses compact mode/constants popups, a 4-column display-adjacent row, stack selection/move mode, and paste through the hidden input
- Exports: browser `window.RpnCalculator`, CommonJS `module.exports`
+4
View File
@@ -0,0 +1,4 @@
- Keep `.memory/project.md` short.
- Update `.memory/state.md` after project changes.
- Reconcile README and demo docs if behavior changes.
- Track future work around keyboard UX, stack selection, and command docs.
+280 -33
View File
@@ -1,45 +1,292 @@
# RPN Virtual Calculator
A browser-friendly RPN calculator built around a small, generic JavaScript API.
A browser-friendly RPN calculator built around a small JavaScript API.
## Goal
## Overview
This project defines a reusable RPN calculator engine with:
This project provides:
- a simple stack-based public API
- configurable numeric and UI behavior
- centralized command metadata
- a browser demo that uses the same public API as any consumer code
- a reusable JavaScript RPN engine in `src/rpn-calculator.js`
- a classic browser demo in `samples/dev/`
- a portrait-first HP48GX-inspired demo in `samples/calc-02/`
- a centralized command system with aliases
- a compact public API focused on stack operations, editing, and command dispatch
## Package contents
- `rpn-calculator.js`: calculator engine
- `rpn-example.html`: example browser interface
The main class is `RpnCalculator`.
## Main features
- Single JavaScript class
- Configurable stack size (`maxSize`, default: `2048`)
- Configurable numeric base (`base`, default: `10`)
- Configurable angle mode (`angleMode`, default: `deg`)
- Optional command filtering through `enabledCommands`
- Generic public API centered on:
- `push`
- `pop`
## Project structure
- `src/rpn-calculator.js`: calculator engine
- `samples/dev/index.html`: browser demo entry point
- `samples/dev/index.css`: demo styles
- `samples/dev/index.js`: demo UI and keyboard logic
- `samples/calc-02/index.html`: portrait-first HP48GX-style demo entry point
- `samples/calc-02/index.css`: portrait-first demo styles
- `samples/calc-02/index.js`: portrait-first HP48GX-style demo UI and keyboard logic
- `samples/calc-XX/`: placeholder name for future demo variants
## Public API
The calculator API is centered on these methods:
- `push(value)`
- `pop()`
- `clear()`
- `swap(index1, index2)`
- `remove(index)`
- `edit(index)`
- `isValidIndex(index)`
- `input(command)`
- `command(name, ...args)`
Instance helpers also available:
- `getOperationsByCategory()`
- `getConstants()`
- `listConstants()`
- `setConstant(name, value)`
- `removeConstant(name)`
- `hasConstant(name)`
Static helpers also available:
- `RpnCalculator.getOperationCategories()`
- `RpnCalculator.getOperationsByCategory()`
State exposed on instances:
- `inputValue` as a string
- `isEditing` as a boolean
- `stack` as the current internal stack array used by the demo
- `angleMode`
- `base`
- `maxSize`
## Constructor options
```js README.md
const calc = new RpnCalculator({
maxSize: 2048,
base: 10,
angleMode: 'deg',
enabledCommands: ['add', 'sub', 'mul', 'div']
});
```
Supported options:
- `maxSize`: maximum stack size, default `2048`
- `base`: numeric base, default `10`, accepted range `2..16`
- `angleMode`: `deg`, `rad`, or `grad`, default `deg`
- `enabledCommands`: optional whitelist of enabled commands and aliases
## Constants
Available constants:
- `pi`
- `e`
- `phi`
- `g`
- `c`
- plus any user-defined constants added through the engine API
They can be used through `command(...)`:
```js README.md
calc.command('pi');
calc.command('e');
```
## Supported commands
### Stack
- `enter`
- `dup`
- `drop`
- `swap`
- `clear`
- `swap(index1, index2)`
- `remove(index)`
- `edit(index)`
- `isValidIndex(index)`
- `input(command)`
- `command(name, ...args)`
- `inputValue` is kept as a string to preserve future input formats
- `isEditing` is exposed as a boolean state
- All supported commands are described in one centralized dictionary
- Supported categories are limited to:
- `Stack`
- `Arithmetic`
- `Trigonometry`
### Arithmetic
- `add` alias: `+`
- `sub` alias: `-`
- `mul` alias: `*`
- `div` alias: `/`
- `mod` alias: `%`
- `pow` aliases: `^`, `y^x`
- `root` aliases: `y√x`, `yroot`, `nroot`
- `sqr`
- `neg`
- `sqrt` alias: `sqrt(x)`
- `recip` alias: `1/x`
- `log`
- `ln`
### Trigonometry
- `sin`
- `cos`
- `tan`
- `asin`
- `acos`
- `atan`
## Behavior notes
- `mod` is a percentage operator:
- `a b % => (a * b) / 100`
- `pow` accepts aliases `^` and `y^x`
- `sqrt` accepts alias `sqrt(x)`
- `recip` accepts alias `1/x`
- `div` and `recip` throw `Division by zero` when needed
- `root` computes the y-th root as `x^(1/y)` and throws `Invalid input for root` for invalid inputs
- `sqrt` throws `Invalid input for sqrt` for negative values
- `asin` and `acos` throw explicit domain errors outside `[-1, 1]`
- `log` throws `Invalid input for log` for values `<= 0`
- `ln` throws `Invalid input for ln` for values `<= 0`
- `log` uses `Math.log10`
- `ln` uses `Math.log`
- direct trigonometric functions convert input using `toRadians(...)`
- inverse trigonometric functions convert results back using the current angle mode
- `angleMode` supports `deg`, `rad`, and `grad`
- formatted numeric values are rounded to 12 decimal places
- `-0` is normalized to `0`
- `inputValue` remains a string to preserve future non-decimal input support
- base 10 input is parsed with `Number(...)`
- non-decimal input currently uses `parseInt(..., base)`
## Input handling
`input(command)` supports two modes:
- single-character editing input
- command dispatch
Accepted single-character editing input currently includes:
- `0-9`
- `A-F`
- `a-f`
- `+`
- `-`
- `.`
Everything else is forwarded to `command(...)`.
## Basic usage
### In a browser
### CommonJS
```js README.md
const RpnCalculator = require('./src/rpn-calculator');
const calc = new RpnCalculator();
calc.push(2);
calc.push(3);
calc.command('add');
console.log(calc.pop()); // 5
```
### Browser
```html README.md
<script src="./src/rpn-calculator.js"></script>
<script>
const calc = new RpnCalculator({ angleMode: 'deg' });
calc.push(9);
calc.command('sqrt');
console.log(calc.pop());
</script>
```
### Editing values through `input(...)`
```js README.md
const calc = new RpnCalculator();
calc.input('1');
calc.input('2');
calc.input('.');
calc.input('5');
calc.command('enter');
console.log(calc.pop()); // 12.5
```
### Aliases
```js README.md
const calc = new RpnCalculator();
calc.push(6);
calc.push(7);
calc.command('+');
console.log(calc.pop()); // 13
```
### Constants and trigonometry
```js README.md
const calc = new RpnCalculator({ angleMode: 'deg' });
calc.command('pi');
console.log(calc.pop());
calc.push(30);
calc.command('sin');
console.log(calc.pop()); // 0.5
```
## Demo
The default demo lives in `samples/dev/`.
Main UI features:
- four visible stack lines
- main display/status area
- visible angle mode indicator
- angle mode selector for `deg`, `rad`, and `grad`
- status pills for `inputValue` and `isEditing`
- grouped panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants`
- keyboard-friendly hidden input on desktop
## Calc 02 demo
`samples/calc-02/` is a portrait-first HP48GX-inspired demo.
It keeps the display-adjacent button row aligned in four columns, uses compact popup menus for mode and constants, and supports clipboard paste plus the `y√x` root operation.
The demo loads the engine from:
```html README.md
<script src="../../src/rpn-calculator.js"></script>
```
### Demo keyboard support
The current demo supports:
- digits and decimal point
- numpad digits and numpad arithmetic keys
- `Enter`
- `Backspace`
- `Delete`
- `Escape`
- `ArrowUp`, `ArrowDown`, `ArrowRight`
- `+`, `-`, `*`, `/`, `%`, `^`
- `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:
- browser: `window.RpnCalculator`
- CommonJS: `module.exports = RpnCalculator`
Regular → Executable
View File
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${1:-3000}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
if command -v python3 >/dev/null 2>&1; then
exec python3 -m http.server "$PORT" --directory "$ROOT_DIR"
elif command -v python >/dev/null 2>&1; then
exec python -m SimpleHTTPServer "$PORT"
elif command -v node >/dev/null 2>&1; then
exec node -e "const http=require('http'); const fs=require('fs'); const path=require('path'); const root=process.argv[1]; const port=Number(process.argv[2]); const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.svg':'image/svg+xml','.ico':'image/x-icon'}; http.createServer((req,res)=>{let url=decodeURIComponent(req.url.split('?')[0]); if(url==='/' ) url='/'; let filePath=path.join(root,url); fs.stat(filePath,(err,stat)=>{if(!err&&stat.isDirectory()) filePath=path.join(filePath,'index.html'); fs.readFile(filePath,(err,data)=>{if(err){res.statusCode=404; return res.end('Not Found');} res.setHeader('Content-Type', mime[path.extname(filePath)]||'application/octet-stream'); res.end(data);});});}).listen(port,()=>console.log('Serving '+root+' on http://localhost:'+port));" "$ROOT_DIR" "$PORT"
else
echo "Error: python3, python, or node is required to serve the project root." >&2
exit 1
fi
+399
View File
@@ -0,0 +1,399 @@
:root {
--body-top: #1d2430;
--body-bottom: #0c1017;
--shell-top: #4b5567;
--shell-bottom: #252d39;
--shell-edge: #141922;
--screen: #dce7b3;
--screen-top: #e8f0c3;
--screen-text: #1c2910;
--screen-dim: #60714a;
--key-text: #f4f7fb;
--key-fn-top: #677287;
--key-fn-bottom: #424b5d;
--key-num-top: #5d6470;
--key-num-bottom: #383f49;
--key-op-top: #4f6b95;
--key-op-bottom: #304661;
--key-danger-top: #7a5050;
--key-danger-bottom: #553636;
--key-soft-danger-top: #86605a;
--key-soft-danger-bottom: #61433e;
--key-cancel-top: #5f6b7c;
--key-cancel-bottom: #3f4857;
--key-enter-top: #7d9079;
--key-enter-bottom: #4d614b;
--border: #11151c;
--shadow: rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Arial, sans-serif;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, var(--body-top), var(--body-bottom));
color: #f3f6fb;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 28px 18px 40px;
}
.calc {
background: linear-gradient(180deg, var(--shell-top), var(--shell-bottom));
border: 1px solid var(--shell-edge);
border-radius: 28px;
padding: 18px;
box-shadow:
0 24px 60px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.28);
}
.screen {
background: linear-gradient(180deg, var(--screen-top), var(--screen));
color: var(--screen-text);
border: 3px solid #829366;
border-radius: 14px;
padding: 14px 16px;
min-height: 196px;
font-family: "Courier New", monospace;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
box-shadow:
inset 0 2px 8px rgba(60, 80, 28, 0.18),
0 8px 16px rgba(0, 0, 0, 0.18);
}
.screen-top {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--screen-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stack {
border-top: 1px solid rgba(28, 41, 16, 0.24);
padding-top: 10px;
line-height: 1.5;
font-size: 18px;
white-space: pre-wrap;
}
.stack-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
padding: 2px 4px;
border-radius: 4px;
}
.stack-line.selected {
background: rgba(28, 41, 16, 0.12);
outline: 1px dashed rgba(28, 41, 16, 0.4);
}
.stack-line.moving {
background: rgba(117, 160, 90, 0.2);
outline: 1px solid rgba(28, 41, 16, 0.5);
}
.stack-line .label {
text-align: right;
color: var(--screen-dim);
}
.stack-line.selected .label,
.stack-line.moving .label {
color: var(--screen-text);
font-weight: bold;
}
#display {
font-size: 18px;
font-weight: bold;
letter-spacing: 0.04em;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.topbar {
margin-top: 14px;
background: rgba(6, 10, 16, 0.18);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.topbar-group {
display: grid;
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
gap: 6px;
align-items: stretch;
}
.menu-cell {
position: relative;
}
.topbar-spacer {
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.menu-trigger {
width: 100%;
min-height: 26px;
padding: 4px 8px;
border-radius: 10px;
font-size: 12px;
}
.popup-menu {
position: absolute;
top: calc(100% + 6px);
left: 10px;
right: 10px;
z-index: 20;
display: grid;
gap: 8px;
padding: 10px;
border-radius: 14px;
background: rgba(18, 24, 34, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
#modeMenu {
left: 0;
right: auto;
min-width: 100%;
margin-right: 0;
}
.popup-menu {
text-align: left;
}
.popup-menu button {
text-align: left;
justify-content: flex-start;
}
.popup-menu[hidden] {
display: none;
}
.popup-menu-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hidden-select {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.action-cell {
display: flex;
align-items: stretch;
}
.action-cell > button {
width: 100%;
min-height: 26px;
padding: 4px 8px;
border-radius: 10px;
font-size: 12px;
}
select,
button {
border-radius: 12px;
border: 1px solid var(--border);
font: inherit;
}
select {
padding: 10px 12px;
background: #edf1f7;
color: #111;
}
.keyboard-layout {
margin-top: 18px;
display: grid;
grid-template-columns: 4fr 3fr 2fr;
grid-template-areas: "functions numbers operators";
gap: 12px;
}
.key-group {
background: rgba(6, 10, 16, 0.18);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.key-group-functions {
grid-area: functions;
}
.key-group-numbers {
grid-area: numbers;
}
.key-group-operators {
grid-area: operators;
}
.key-grid {
display: grid;
gap: 10px;
}
.functions-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.numbers-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.operators-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.key-spacer {
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.04);
}
button {
padding: 10px 8px;
color: var(--key-text);
cursor: pointer;
font-weight: bold;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 3px 0 rgba(0, 0, 0, 0.25);
transition: filter 120ms ease, transform 120ms ease;
}
button:hover { filter: brightness(1.08); }
button:active {
transform: translateY(2px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 1px 0 rgba(0, 0, 0, 0.25);
}
.key-function {
background: linear-gradient(180deg, var(--key-fn-top), var(--key-fn-bottom));
}
.key-number {
background: linear-gradient(180deg, var(--key-num-top), var(--key-num-bottom));
}
.key-operator {
background: linear-gradient(180deg, var(--key-op-top), var(--key-op-bottom));
}
.key-danger {
background: linear-gradient(180deg, var(--key-danger-top), var(--key-danger-bottom));
}
.key-soft-danger {
background: linear-gradient(180deg, var(--key-soft-danger-top), var(--key-soft-danger-bottom));
}
.key-cancel {
background: linear-gradient(180deg, var(--key-cancel-top), var(--key-cancel-bottom));
}
.key-enter {
background: linear-gradient(180deg, var(--key-enter-top), var(--key-enter-bottom));
grid-row: span 2;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 0.08em;
}
.hint {
color: #cfd7e3;
margin-top: 10px;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 980px) {
.topbar-group {
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
}
}
@media (max-width: 860px) {
.keyboard-layout {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-areas:
"numbers operators"
"functions functions";
}
}
@media (max-width: 640px) {
.wrap {
padding: 14px;
}
.calc {
padding: 14px;
border-radius: 22px;
}
.topbar {
overflow-x: auto;
}
.topbar-group {
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
min-width: 520px;
}
.functions-grid,
.numbers-grid,
.operators-grid {
grid-template-rows: repeat(4, 52px);
}
}
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48-style RPN Calculator</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div class="wrap">
<div class="calc">
<div class="screen" id="screen" tabindex="0" role="application" aria-label="HP48 style calculator screen">
<div class="screen-top">
<div>RPN stack</div>
<div id="modeLabel">deg</div>
</div>
<div id="stack" class="stack"></div>
<div id="display"></div>
</div>
<input id="input" class="hidden-input" type="text" autocomplete="off" aria-hidden="true" tabindex="-1">
<div class="topbar">
<div class="topbar-group">
<div class="menu-cell" id="modeMenuWrap">
<button type="button" class="menu-trigger key-function" id="modeMenuButton" aria-haspopup="true" aria-expanded="false">mode</button>
<div class="popup-menu" id="modeMenu" hidden></div>
<select id="angleMode" class="hidden-select" aria-hidden="true" tabindex="-1">
<option value="deg">Degrees</option>
<option value="rad">Radians</option>
<option value="grad">Grads</option>
</select>
</div>
<div class="menu-cell" id="constsMenuWrap">
<button type="button" class="menu-trigger key-function" id="constsMenuButton" aria-haspopup="true" aria-expanded="false">consts</button>
<div class="popup-menu popup-menu-compact" id="constsMenu" hidden></div>
</div>
<div class="topbar-spacer" aria-hidden="true"></div>
<div class="action-cell" id="deleteButton"></div>
<div class="action-cell" id="backspaceButton"></div>
<div class="action-cell" id="escapeButton"></div>
</div>
</div>
<div class="keyboard-layout">
<section class="key-group key-group-functions" aria-label="Functions">
<div class="key-grid functions-grid" id="functionsButtons"></div>
</section>
<section class="key-group key-group-numbers" aria-label="Numbers">
<div class="key-grid numbers-grid" id="numbersButtons"></div>
</section>
<section class="key-group key-group-operators" aria-label="Operators">
<div class="key-grid operators-grid" id="operatorsButtons"></div>
</section>
</div>
</div>
</div>
<script src="../../src/rpn-calculator.js"></script>
<script src="./index.js"></script>
</body>
</html>
+673
View File
@@ -0,0 +1,673 @@
const calc = new RpnCalculator({ angleMode: 'deg' });
const input = document.getElementById('input');
const screen = document.getElementById('screen');
const stackEl = document.getElementById('stack');
const displayEl = document.getElementById('display');
const modeLabel = document.getElementById('modeLabel');
const angleMode = document.getElementById('angleMode');
const modeMenuButton = document.getElementById('modeMenuButton');
const modeMenu = document.getElementById('modeMenu');
const constsMenuButton = document.getElementById('constsMenuButton');
const constsMenu = document.getElementById('constsMenu');
const keyLayouts = {
functions: [
[
{ type: 'command', value: 'sqrt', label: '√x', className: 'key-function' },
{ type: 'command', value: 'pow', label: 'yˣ', className: 'key-function' },
{ type: 'command', value: 'sqr', label: 'x²', className: 'key-function' },
{ type: 'command', value: 'recip', label: '1/x', className: 'key-function' },
],
[
{ type: 'command', value: 'log', label: 'log', className: 'key-function' },
{ type: 'command', value: 'ln', label: 'ln', className: 'key-function' },
null,
{ type: 'command', value: 'mod', label: '%', className: 'key-function' },
],
[
{ type: 'command', value: 'sin', label: 'sin', className: 'key-function' },
{ type: 'command', value: 'cos', label: 'cos', className: 'key-function' },
{ type: 'command', value: 'tan', label: 'tan', className: 'key-function' },
null,
],
[
{ type: 'command', value: 'asin', label: 'asin', className: 'key-function' },
{ type: 'command', value: 'acos', label: 'acos', className: 'key-function' },
{ type: 'command', value: 'atan', label: 'atan', className: 'key-function' },
null,
],
],
numbers: [
[
{ type: 'input', value: '7', label: '7', className: 'key-number' },
{ type: 'input', value: '8', label: '8', className: 'key-number' },
{ type: 'input', value: '9', label: '9', className: 'key-number' },
],
[
{ type: 'input', value: '4', label: '4', className: 'key-number' },
{ type: 'input', value: '5', label: '5', className: 'key-number' },
{ type: 'input', value: '6', label: '6', className: 'key-number' },
],
[
{ type: 'input', value: '1', label: '1', className: 'key-number' },
{ type: 'input', value: '2', label: '2', className: 'key-number' },
{ type: 'input', value: '3', label: '3', className: 'key-number' },
],
[
{ type: 'input', value: '0', label: '0', className: 'key-number' },
{ type: 'input', value: '.', label: '.', className: 'key-number' },
{ type: 'command', value: 'neg', label: '±', className: 'key-number' },
],
],
operators: [
[
{ type: 'command', value: 'div', label: '÷', className: 'key-operator' },
null,
],
[
{ type: 'command', value: 'mul', label: '×', className: 'key-operator' },
null,
],
[
{ type: 'command', value: 'sub', label: '', className: 'key-operator' },
{ type: 'command', value: 'enter', label: 'ENTER', className: 'key-enter' },
],
[
{ type: 'command', value: 'add', label: '+', className: 'key-operator' },
null,
],
],
};
const topButtons = {
consts: [
{ type: 'command', value: 'pi', label: 'π', className: 'key-function' },
{ type: 'command', value: 'e', label: 'e', className: 'key-function' },
],
del: { type: 'command', value: 'clear', label: 'DEL', className: 'key-danger' },
backspace: { type: 'action', value: 'backspace', label: '⌫', className: 'key-soft-danger' },
escape: { type: 'action', value: 'escape', label: 'ESC', className: 'key-cancel' },
};
let stackCursor = null;
let isMovingStackItem = false;
let stackSnapshotBeforeMove = null;
let stackViewOffset = 0;
let editRestoreValue = null;
let statusMessage = '';
function handleEscapeAction() {
if (calc.isEditing) {
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
editRestoreValue = null;
}
calc.inputValue = '';
calc.isEditing = false;
syncInputFromState();
render();
return;
}
if (isMovingStackItem) {
cancelMoveMode();
clearStackSelection();
render();
return;
}
if (hasStackSelection()) {
clearStackSelection();
render();
}
}
function pressKey(key) {
statusMessage = '';
clearStackSelection();
editXWithKey(key);
render();
}
function handleBackspaceAction() {
if (calc.isEditing) {
editXWithKey('Backspace');
render();
return;
}
execute('drop');
}
function closePopupMenus() {
modeMenu.hidden = true;
constsMenu.hidden = true;
modeMenuButton.setAttribute('aria-expanded', 'false');
constsMenuButton.setAttribute('aria-expanded', 'false');
}
function togglePopupMenu(menuName) {
const isModeMenu = menuName === 'mode';
const targetMenu = isModeMenu ? modeMenu : constsMenu;
const targetButton = isModeMenu ? modeMenuButton : constsMenuButton;
const otherMenu = isModeMenu ? constsMenu : modeMenu;
const otherButton = isModeMenu ? constsMenuButton : modeMenuButton;
const willOpen = targetMenu.hidden;
otherMenu.hidden = true;
otherButton.setAttribute('aria-expanded', 'false');
targetMenu.hidden = !willOpen;
targetButton.setAttribute('aria-expanded', String(willOpen));
}
function createButton(cell) {
if (!cell) {
const spacer = document.createElement('div');
spacer.className = 'key-spacer';
spacer.setAttribute('aria-hidden', 'true');
return spacer;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = cell.label;
button.className = cell.className;
button.addEventListener('click', () => {
focusScreen();
closePopupMenus();
if (cell.type === 'input') {
pressKey(cell.value);
return;
}
if (cell.type === 'action') {
if (cell.value === 'escape') {
handleEscapeAction();
return;
}
if (cell.value === 'backspace') {
handleBackspaceAction();
return;
}
if (cell.value === 'setModeDeg' || cell.value === 'setModeRad' || cell.value === 'setModeGrad') {
angleMode.value = cell.value === 'setModeDeg' ? 'deg' : (cell.value === 'setModeRad' ? 'rad' : 'grad');
angleMode.dispatchEvent(new Event('change'));
return;
}
}
execute(cell.value);
});
return button;
}
function renderKeyLayout(container, rows) {
container.innerHTML = '';
rows.flat().forEach((cell) => {
container.appendChild(createButton(cell));
});
}
function getStackValue(index) {
return calc.isValidIndex(index) ? calc.stack[index] : undefined;
}
function getDisplayValue(index) {
if (calc.isEditing) {
if (index === 0) {
return calc.inputValue;
}
return getStackValue(index - 1);
}
return getStackValue(index);
}
function hasStackSelection() {
return stackCursor !== null && calc.isValidIndex(stackCursor);
}
function clearStackSelection() {
stackCursor = null;
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackViewOffset = 0;
}
function ensureValidSelection() {
if (hasStackSelection()) {
return;
}
stackCursor = calc.isValidIndex(0) ? 0 : null;
}
function beginMoveMode() {
if (!hasStackSelection()) {
return;
}
isMovingStackItem = true;
stackSnapshotBeforeMove = calc.stack.slice();
}
function commitMoveMode() {
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
}
function cancelMoveMode() {
if (!isMovingStackItem || !stackSnapshotBeforeMove) {
return;
}
const snapshot = stackSnapshotBeforeMove.slice();
calc.clear();
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
calc.push(snapshot[index]);
}
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(0) ? 0 : null);
syncInputFromState();
}
function reactivateEditOnX() {
clearStackSelection();
if (calc.isValidIndex(0)) {
const value = getStackValue(0);
calc.remove(0);
calc.inputValue = calc.formatNumber(value);
calc.isEditing = true;
editRestoreValue = value;
} else {
calc.inputValue = '';
calc.isEditing = true;
editRestoreValue = null;
}
syncInputFromState();
}
function moveStackSelection(direction) {
if (!hasStackSelection()) {
if (direction === 'up') {
ensureValidSelection();
} else if (direction === 'down') {
reactivateEditOnX();
}
return;
}
const nextIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (calc.isValidIndex(nextIndex)) {
stackCursor = nextIndex;
}
}
function moveStackItem(direction) {
if (!hasStackSelection()) {
return;
}
const targetIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (!calc.isValidIndex(targetIndex)) {
return;
}
calc.swap(stackCursor, targetIndex);
stackCursor = targetIndex;
}
function getVisibleStackIndex(visualLine) {
return stackViewOffset + visualLine;
}
function clampStackViewOffset() {
const maxOffset = Math.max(0, calc.stack.length - 4);
if (stackViewOffset < 0) {
stackViewOffset = 0;
} else if (stackViewOffset > maxOffset) {
stackViewOffset = maxOffset;
}
}
function ensureSelectionVisible() {
if (!hasStackSelection()) {
stackViewOffset = 0;
return;
}
if (stackCursor < stackViewOffset) {
stackViewOffset = stackCursor;
} else if (stackCursor > stackViewOffset + 3) {
stackViewOffset = stackCursor - 3;
}
clampStackViewOffset();
}
function render() {
const names = ['T', 'Z', 'Y', 'X'];
const lines = [];
const showStackIndexes = hasStackSelection() || isMovingStackItem;
clampStackViewOffset();
ensureSelectionVisible();
for (let visualLine = 3; visualLine >= 0; visualLine -= 1) {
const stackIndex = getVisibleStackIndex(visualLine);
const value = getDisplayValue(stackIndex);
const isSelected = stackCursor === stackIndex;
const classes = ['stack-line'];
const label = showStackIndexes ? String(stackIndex) : names[3 - visualLine];
if (isSelected) {
classes.push(isMovingStackItem ? 'moving' : 'selected');
}
lines.push(`<div class="${classes.join(' ')}"><div class="label">${label}</div><div>${value !== undefined && value !== '' ? calc.formatNumber(value) : ''}</div></div>`);
}
stackEl.innerHTML = lines.join('');
if (calc.isEditing) {
displayEl.textContent = `ENTERING: ${calc.inputValue}`;
} else if (isMovingStackItem && hasStackSelection()) {
displayEl.textContent = `MOVING: ${stackCursor}`;
} else if (hasStackSelection()) {
displayEl.textContent = `SELECTED: ${stackCursor}`;
} else if (statusMessage) {
displayEl.textContent = statusMessage;
} else {
displayEl.textContent = 'READY';
}
modeLabel.textContent = calc.angleMode;
modeMenuButton.textContent = calc.angleMode.toUpperCase();
angleMode.value = calc.angleMode;
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
const value = calc.parseInputValue(calc.inputValue);
calc.push(value);
}
calc.inputValue = '';
calc.isEditing = false;
editRestoreValue = null;
syncInputFromState();
}
function execute(name) {
try {
statusMessage = '';
if (name === 'enter') {
if (calc.isEditing) {
pushEditingValueIfNeeded();
} else if (calc.isValidIndex(0)) {
calc.push(getStackValue(0));
}
} else if (name === 'swap') {
pushEditingValueIfNeeded();
clearStackSelection();
if (calc.isValidIndex(1)) calc.swap(0, 1);
} else if (name === 'drop') {
pushEditingValueIfNeeded();
if (hasStackSelection()) {
calc.remove(stackCursor);
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(stackCursor - 1) ? stackCursor - 1 : null);
} else if (calc.isValidIndex(0)) {
calc.remove(0);
}
commitMoveMode();
} else if (name === 'clear') {
calc.clear();
clearStackSelection();
} else {
pushEditingValueIfNeeded();
clearStackSelection();
calc.command(name);
}
syncInputFromState();
render();
} catch (error) {
statusMessage = error.message;
render();
}
}
function isInputChar(key) {
return /^[0-9a-fA-F.]$/.test(key);
}
function shouldIgnoreKeyboardEvent(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName;
return (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT' ||
target.isContentEditable
);
}
function getKeyboardAction(event) {
const numpadMap = {
Numpad0: { type: 'input', value: '0' },
Numpad1: { type: 'input', value: '1' },
Numpad2: { type: 'input', value: '2' },
Numpad3: { type: 'input', value: '3' },
Numpad4: { type: 'input', value: '4' },
Numpad5: { type: 'input', value: '5' },
Numpad6: { type: 'input', value: '6' },
Numpad7: { type: 'input', value: '7' },
Numpad8: { type: 'input', value: '8' },
Numpad9: { type: 'input', value: '9' },
NumpadDecimal: { type: 'input', value: '.' },
NumpadAdd: { type: 'command', value: 'add' },
NumpadSubtract: { type: 'command', value: 'sub' },
NumpadMultiply: { type: 'command', value: 'mul' },
NumpadDivide: { type: 'command', value: 'div' },
NumpadEnter: { type: 'command', value: 'enter' },
};
if (numpadMap[event.code]) {
return numpadMap[event.code];
}
if (isInputChar(event.key)) {
return { type: 'input', value: event.key };
}
const keyMap = {
Enter: { type: 'enterKey' },
Backspace: { type: 'stackOrEdit', value: 'drop' },
Delete: { type: 'command', value: 'clear' },
Escape: { type: 'escapeKey' },
ArrowUp: { type: 'stackArrow', value: 'up' },
ArrowDown: { type: 'stackArrow', value: 'down' },
ArrowRight: { type: 'command', value: 'swap' },
'+': { type: 'command', value: 'add' },
'-': { type: 'command', value: 'sub' },
'*': { type: 'command', value: 'mul' },
'/': { type: 'command', value: 'div' },
'%': { type: 'command', value: 'mod' },
'^': { type: 'command', value: 'pow' },
q: { type: 'command', value: 'sqr' },
n: { type: 'command', value: 'neg' },
r: { type: 'command', value: 'sqrt' },
i: { type: 'command', value: 'recip' },
g: { type: 'command', value: 'log' },
l: { type: 'command', value: 'ln' },
s: { type: 'command', value: 'sin' },
c: { type: 'command', value: 'cos' },
S: { type: 'command', value: 'asin' },
C: { type: 'command', value: 'acos' },
x: { type: 'stackSelect', value: 0 },
y: { type: 'stackSelect', value: 1 },
z: { type: 'stackSelect', value: 2 },
t: { type: 'stackSelect', value: 3 },
X: { type: 'stackSelect', value: 0 },
Y: { type: 'stackSelect', value: 1 },
Z: { type: 'stackSelect', value: 2 },
T: { type: 'stackSelect', value: 3 },
};
return keyMap[event.key] || null;
}
function focusScreen() {
screen.focus();
}
function syncInputFromState() {
input.value = calc.inputValue;
}
function editXWithKey(key) {
if (!calc.isEditing) {
pushEditingValueIfNeeded();
calc.isEditing = true;
calc.inputValue = '';
editRestoreValue = null;
}
if (key === 'Backspace') {
calc.inputValue = calc.inputValue.slice(0, -1);
} else {
calc.inputValue += key;
}
if (calc.inputValue === '') {
calc.isEditing = false;
editRestoreValue = null;
}
syncInputFromState();
}
function handleKeydown(event) {
if (shouldIgnoreKeyboardEvent(event)) {
return;
}
const action = getKeyboardAction(event);
if (!action) {
return;
}
try {
statusMessage = '';
if (action.type === 'escapeKey') {
event.preventDefault();
handleEscapeAction();
return;
}
event.preventDefault();
if (action.type === 'input') {
clearStackSelection();
editXWithKey(action.value);
render();
return;
}
if (action.type === 'stackSelect') {
if (calc.isEditing || isMovingStackItem) {
return;
}
stackCursor = calc.isValidIndex(action.value) ? action.value : null;
render();
return;
}
if (action.type === 'stackArrow') {
if (calc.isEditing) {
render();
return;
}
if (isMovingStackItem) {
moveStackItem(action.value);
} else {
moveStackSelection(action.value);
}
render();
return;
}
if (action.type === 'stackOrEdit') {
if (calc.isEditing) {
editXWithKey('Backspace');
render();
} else {
execute(action.value);
}
return;
}
if (action.type === 'enterKey') {
if (hasStackSelection()) {
if (isMovingStackItem) {
commitMoveMode();
} else {
beginMoveMode();
}
render();
return;
}
execute('enter');
return;
}
if (action.type === 'command') {
execute(action.value);
}
} catch (error) {
statusMessage = error.message;
render();
}
}
window.addEventListener('keydown', handleKeydown);
screen.addEventListener('click', () => {
closePopupMenus();
focusScreen();
});
window.addEventListener('load', focusScreen);
document.addEventListener('click', (event) => {
if (!event.target.closest('#modeMenuWrap') && !event.target.closest('#constsMenuWrap')) {
closePopupMenus();
}
});
modeMenuButton.addEventListener('click', (event) => {
event.stopPropagation();
togglePopupMenu('mode');
});
constsMenuButton.addEventListener('click', (event) => {
event.stopPropagation();
togglePopupMenu('consts');
});
angleMode.addEventListener('change', (event) => {
calc.angleMode = event.target.value;
closePopupMenus();
render();
});
renderKeyLayout(document.getElementById('functionsButtons'), keyLayouts.functions);
renderKeyLayout(document.getElementById('numbersButtons'), keyLayouts.numbers);
renderKeyLayout(document.getElementById('operatorsButtons'), keyLayouts.operators);
renderKeyLayout(modeMenu, [[
{ type: 'action', value: 'setModeDeg', label: 'Degrees', className: 'key-function' },
{ type: 'action', value: 'setModeRad', label: 'Radians', className: 'key-function' },
{ type: 'action', value: 'setModeGrad', label: 'Grads', className: 'key-function' },
]]);
renderKeyLayout(constsMenu, [topButtons.consts]);
document.getElementById('deleteButton').appendChild(createButton(topButtons.del));
document.getElementById('backspaceButton').appendChild(createButton(topButtons.backspace));
document.getElementById('escapeButton').appendChild(createButton(topButtons.escape));
render();
focusScreen();
+21
View File
@@ -0,0 +1,21 @@
┌─────────────────────────────────────────────┐
| T: |
| Z: |
| Y: |
| X: |
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
| mode | consts | | del | backspace | esc |
└─────────────────────────────────────────────┘
┌─────────── Numbers ──────────┬─ Operators ─┐
| 7 | 8 | 9 | / | |
| 4 | 5 | 6 | * | |
| 1 | 2 | 3 | - | Enter |
| 0 | . | +/- | + | Enter |
└──────────────────────────────┴──────────────┘
┌───────────────── Functions ─────────────────┐
| sqrt | y^x | x² | 1/x |
| log | ln | | % |
| sin | cos | tan | |
| asin | acos | atan | |
└─────────────────────────────────────────────┘
+15
View File
@@ -0,0 +1,15 @@
┌──────────────────────────────────────────────────────────────────────────────┐
| T: |
| Z: |
| Y: |
| X: |
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
| mode | consts | | del | backspace | esc |
└──────────────────────────────────────────────────────────────────────────────┘
┌───────────── Functions ───────────────────┬───── Numbers ─────┬─ Operators ─┐
| sqrt | y^x | x² | 1/x | 7 | 8 | 9 | / | |
| log | ln | | % | 4 | 5 | 6 | * | |
| sin | cos | tan | | 1 | 2 | 3 | - | Enter |
| asin | acos | atan | | 0 | . | +/- | + | Enter |
└───────────────────────────────────────────┴───────────────────┴──────────────┘
+473
View File
@@ -0,0 +1,473 @@
:root {
--bg0: #10151e;
--bg1: #1b2432;
--panel: #2c3442;
--panel2: #394354;
--edge: #0c1118;
--display: #cfe0ae;
--display2: #b9cd8a;
--displayText: #1f2a12;
--buttonText: #f4f7fb;
--shadow: rgba(0, 0, 0, 0.35);
--btnTop: #444c58;
--btnBottom: #2f3640;
--btnAccentTop: #3f526b;
--btnAccentBottom: #2b394c;
--btnAltTop: #525c69;
--btnAltBottom: #3a434f;
--btnDangerTop: #584042;
--btnDangerBottom: #402d2f;
--btnEscapeTop: #6a4a2a;
--btnEscapeBottom: #4a331d;
--btnEnterTop: #4f7f4d;
--btnEnterBottom: #355a34;
--btnText: #eef2f7;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
overflow-y: hidden;
}
body {
min-height: 100vh;
font-family: Arial, sans-serif;
color: var(--buttonText);
background: var(--bg0);
}
.app-shell {
min-height: 100vh;
display: grid;
align-items: start;
justify-items: center;
padding: 0;
}
.calculator {
width: 100%;
max-width: 460px;
height: 100vh;
max-height: 100vh;
padding: 8px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
border-radius: 8px;
background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
justify-content: stretch;
align-content: start;
overflow: hidden;
}
.display-block,
.display-panel,
.display-buttons-panel,
.keypad-panel,
.functions-panel,
.trigo-panel {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(6, 10, 16, 0.16);
box-shadow: none;
}
.display-block {
display: grid;
gap: 0;
align-self: start;
justify-items: stretch;
grid-template-rows: auto auto;
row-gap: 0;
}
.display-panel {
position: relative;
width: 100%;
padding: 16px;
padding-bottom: 8px;
background: var(--display);
color: var(--displayText);
font-family: "Courier New", monospace;
overflow: hidden;
box-sizing: border-box;
height: 138px;
min-height: 138px;
max-height: 138px;
margin-bottom: 0;
}
.display-grid {
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(4, minmax(0, auto));
align-content: start;
gap: 2px;
}
.stack-cell {
display: grid;
grid-template-columns: 2.2ch auto minmax(0, 1fr);
align-items: center;
gap: 8px;
font-size: 20px;
line-height: 1;
min-height: 0;
padding-block: 0;
}
/* removed extra first-column styling */
.stack-value {
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
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;
background: rgba(6, 10, 16, 0.16);
}
.display-buttons-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
align-content: start;
align-items: stretch;
}
.display-button-spacer {
pointer-events: none;
visibility: hidden;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.display-button {
background: linear-gradient(180deg, var(--btnTop), var(--btnBottom));
color: #eef2f7;
box-shadow: none;
}
.display-button-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2em;
font-size: 1.05em;
line-height: 1;
font-weight: 800;
transform: translateY(-0.01em);
}
.paste-symbol {
font-size: 1em;
}
.display-buttons-grid > button {
padding: 6px 8px;
}
.menu-popup {
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border-radius: 6px;
background: rgba(16, 21, 30, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(2px);
}
.menu-popup-item {
width: 100%;
min-width: 0;
padding: 6px 10px;
border-radius: 5px;
background: linear-gradient(180deg, var(--btnAltTop), var(--btnAltBottom));
text-align: left;
font-weight: 700;
}
.menu-popup-item.is-active {
outline: 1px solid rgba(207, 224, 174, 0.7);
outline-offset: 0;
}
.key-escape {
background: linear-gradient(180deg, var(--btnEscapeTop), var(--btnEscapeBottom));
color: #eef2f7;
}
.keypad-panel,
.functions-panel,
.trigo-panel {
padding: 8px;
min-height: 0;
}
.calculator > .display-block,
.calculator > .display-buttons-panel,
.calculator > .keypad-scroll-area {
justify-self: stretch;
}
.keypad-scroll-area {
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(46, 105, 135, 1);
}
.status-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
padding: 8px 12px;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(31, 42, 18, 0.96);
background: rgba(255, 246, 170, 0.92);
border-bottom: 1px solid rgba(31, 42, 18, 0.2);
transform: translateY(-100%);
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
pointer-events: none;
}
.status-bar.is-visible {
transform: translateY(0);
opacity: 1;
}
.status-bar.is-error {
color: #fff;
background: rgba(72, 14, 14, 0.98);
border-bottom-color: rgba(255, 255, 255, 0.12);
}
.keypad-grid,
.functions-grid,
.trigo-grid {
display: grid;
gap: 8px;
}
.keypad-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(5, minmax(0, 1.7fr));
}
.functions-grid,
.trigo-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
}
button {
border: 1px solid rgba(14, 18, 25, 0.85);
border-radius: 8px;
padding: 8px 8px;
font: inherit;
font-weight: 700;
color: var(--btnText);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
cursor: pointer;
box-shadow: none;
transition: filter 120ms ease, opacity 120ms ease;
line-height: 1;
}
button:hover {
filter: brightness(1.06);
}
button:active {
transform: none;
box-shadow: none;
}
.stack-copy-button:active {
animation: stack-copy-flash 140ms ease-out;
}
@keyframes stack-copy-flash {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
.stack-copy-button {
padding: 4px;
min-width: 24px;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
opacity: 0;
pointer-events: none;
transform: scale(1);
background: transparent;
border: none;
box-shadow: none;
color: rgba(31, 42, 18, 0.58);
margin-left: 0;
}
.stack-cell:last-child {
margin-bottom: 4px;
}
.stack-copy-button svg {
width: 15px;
height: 15px;
fill: #0a0a0a;
display: block;
}
.stack-copy-button.is-visible {
opacity: 0.7;
pointer-events: auto;
transform: scale(1);
}
.stack-copy-button:hover {
opacity: 1;
filter: none;
color: rgba(31, 42, 18, 0.85);
}
.stack-copy-button:active {
transform: scale(1);
color: rgba(31, 42, 18, 0.95);
}
.stack-copy-button:focus-visible {
outline: 1px solid rgba(31, 42, 18, 0.35);
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;
}
.keypad-grid > button {
min-height: 1.7em;
}
#keypadGrid > button {
min-height: calc(1.7em * 1.7);
padding-top: calc(8px * 1.7);
padding-bottom: calc(8px * 1.7);
}
#keypadGrid > button.key-default,
#keypadGrid > button.key-accent,
#keypadGrid > button.key-danger,
#keypadGrid > button.key-enter {
min-height: calc(1.7em * 1.7);
}
.key-accent {
background: linear-gradient(180deg, var(--btnAccentTop), var(--btnAccentBottom));
color: #eef2f7;
}
.key-danger {
background: linear-gradient(180deg, var(--btnDangerTop), var(--btnDangerBottom));
color: #eef2f7;
}
.key-enter {
background: linear-gradient(180deg, var(--btnEnterTop), var(--btnEnterBottom));
color: #eef2f7;
padding-inline: 6px;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48GX RPN Calculator</title>
<link rel="stylesheet" href="./index.css?ver=1">
</head>
<body>
<main class="app-shell">
<section class="calculator" aria-label="HP48GX style RPN calculator">
<div class="display-block">
<div class="display-panel">
<div class="status-bar" id="statusLine" aria-live="polite"></div>
<div class="display-frame">
<div class="display-grid">
<div class="stack-cell"><span class="stack-label">T:</span><button type="button" class="stack-copy-button" data-copy-stack="T" aria-label="Copy T value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackT" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Z:</span><button type="button" class="stack-copy-button" data-copy-stack="Z" aria-label="Copy Z value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackZ" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Y:</span><button type="button" class="stack-copy-button" data-copy-stack="Y" aria-label="Copy Y value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackY" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">X:</span><button type="button" class="stack-copy-button" data-copy-stack="X" aria-label="Copy X value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackX" class="stack-value"></span></div>
</div>
</div>
</div>
</div>
<div class="display-buttons-panel">
<div class="display-buttons-grid">
<button id="constButton" class="display-button">π</button>
<button id="pasteButton" class="display-button"><span class="display-button-symbol paste-symbol"></span></button>
<button id="upButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="modeButton" class="display-button">Mode</button>
<div class="display-button-spacer"></div>
<button id="leftButton" class="display-button display-button-offset"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="downButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="rightButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
</div>
</div>
<div class="keypad-scroll-area">
<div class="keypad-panel">
<div class="keypad-grid" id="keypadGrid"></div>
</div>
<div class="functions-panel">
<div class="functions-grid" id="functionsGrid"></div>
</div>
<div class="trigo-panel">
<div class="trigo-grid" id="trigoGrid"></div>
</div>
</div>
<input id="hiddenInput" class="hidden-input" type="text" autocomplete="off" aria-hidden="true" tabindex="-1">
</section>
</main>
<script src="../../src/rpn-calculator.js"></script>
<script src="./index.js"></script>
</body>
</html>
+790
View File
@@ -0,0 +1,790 @@
const calc = new RpnCalculator({ angleMode: 'deg' });
const hiddenInput = document.getElementById('hiddenInput');
const modeButton = document.getElementById('modeButton');
const pasteButton = document.getElementById('pasteButton');
const upButton = document.getElementById('upButton');
const constButton = document.getElementById('constButton');
const leftButton = document.getElementById('leftButton');
const downButton = document.getElementById('downButton');
const rightButton = document.getElementById('rightButton');
const stackEls = {
T: document.getElementById('stackT'),
Z: document.getElementById('stackZ'),
Y: document.getElementById('stackY'),
X: document.getElementById('stackX'),
};
const stackCopyButtons = {
T: document.querySelector('[data-copy-stack="T"]'),
Z: document.querySelector('[data-copy-stack="Z"]'),
Y: document.querySelector('[data-copy-stack="Y"]'),
X: document.querySelector('[data-copy-stack="X"]'),
};
const keypadGrid = document.getElementById('keypadGrid');
const functionsGrid = document.getElementById('functionsGrid');
const trigoGrid = document.getElementById('trigoGrid');
const calculatorEl = document.querySelector('.calculator');
const statusLine = document.getElementById('statusLine');
const keypadKeys = [
{ label: 'C', action: 'clear', className: 'key-danger' },
{ label: '⌫', action: 'backspace', className: 'key-danger' },
{ label: '⎋', action: 'escape', className: 'key-escape' },
{ label: '⏎', action: 'enter', className: 'key-enter' },
{ label: '7', input: '7', className: 'key-default' },
{ label: '8', input: '8', className: 'key-default' },
{ label: '9', input: '9', className: 'key-default' },
{ label: '÷', action: 'div', className: 'key-accent' },
{ label: '4', input: '4', className: 'key-default' },
{ label: '5', input: '5', className: 'key-default' },
{ label: '6', input: '6', className: 'key-default' },
{ label: '×', action: 'mul', className: 'key-accent' },
{ label: '1', input: '1', className: 'key-default' },
{ label: '2', input: '2', className: 'key-default' },
{ label: '3', input: '3', className: 'key-default' },
{ label: '', action: 'sub', className: 'key-accent' },
{ label: '0', input: '0', className: 'key-default' },
{ label: '.', input: '.', className: 'key-default' },
{ label: '±', action: 'neg', className: 'key-default' },
{ label: '+', action: 'add', className: 'key-accent' },
];
const functionKeys = [
{ 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: 'yˣ', 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', 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', 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', title: 'I' },
{ label: 'acos', action: 'acos', className: 'key-default', title: 'O' },
{ label: 'atan', action: 'atan', className: 'key-default', title: 'A' },
{ label: '', spacer: true },
];
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
function focusInput() {
if (!hiddenInput || isTouchDevice) return;
hiddenInput.focus({ preventScroll: true });
window.requestAnimationFrame(() => {
if (document.activeElement !== hiddenInput) {
hiddenInput.focus({ preventScroll: true });
}
if (typeof hiddenInput.select === 'function') {
hiddenInput.select();
}
});
}
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;
clearTimeout(statusTimer);
statusLine.textContent = message;
statusLine.classList.toggle('is-error', isError);
statusLine.classList.toggle('is-visible', Boolean(message));
if (!message || timeoutMs <= 0) return;
statusTimer = window.setTimeout(() => {
statusLine.textContent = '';
statusLine.classList.remove('is-visible');
statusLine.classList.remove('is-error');
}, timeoutMs);
}
function clearStatus() {
clearTimeout(statusTimer);
statusTimer = null;
if (!statusLine) return;
statusLine.textContent = '';
statusLine.classList.remove('is-visible');
statusLine.classList.remove('is-error');
}
function getStackLine(indexFromTop) {
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
}
function getStackDisplayValue(label) {
if (label === 'X' && calc.isEditing) {
return calc.inputValue;
}
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]);
}
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)}:`;
}
}
}
function stackModeToLabel(index) {
return ['X', 'Y', 'Z', 'T'][Math.max(0, Math.min(3, index))] ?? 'X';
}
function updateCopyButtons() {
for (const label of ['T', 'Z', 'Y', 'X']) {
const value = getStackDisplayValue(label);
const button = stackCopyButtons[label];
if (!button) continue;
button.classList.toggle('is-visible', Boolean(value));
button.disabled = !value;
button.setAttribute('aria-hidden', value ? 'false' : 'true');
}
}
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() {
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
calculatorEl?.classList.toggle('portrait', isPortrait);
calculatorEl?.classList.toggle('landscape', !isPortrait);
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') {
if (editCursor > 0) {
calc.inputValue = `${calc.inputValue.slice(0, editCursor - 1)}${calc.inputValue.slice(editCursor)}`;
editCursor -= 1;
}
} else {
calc.inputValue = `${calc.inputValue.slice(0, editCursor)}${value}${calc.inputValue.slice(editCursor)}`;
editCursor += value.length;
}
if (calc.inputValue === '') {
stopEditing();
}
}
function pasteTextIntoStack(text) {
if (!text) {
setStatus('Clipboard empty');
return;
}
const value = calc.parseInputValue(text);
if (!Number.isFinite(value)) {
setStatus('Clipboard is not a number');
return;
}
pushEditingValueIfNeeded();
calc.push(value);
calc.isEditing = false;
calc.inputValue = '';
setStatus('Pasted');
render();
}
function execute(name) {
try {
if (name === 'enter') {
if (calc.isEditing) {
pushEditingValueIfNeeded();
} else if (calc.isValidIndex(0)) {
calc.push(calc.stack[0]);
}
} else if (name === 'clear') {
calc.clear();
stopEditing(true);
} else if (name === 'escape') {
cancelEditing();
} else if (name === 'backspace') {
if (calc.isEditing) {
inputToX('Backspace');
} else if (calc.isValidIndex(0)) {
calc.remove(0);
}
} else if (name === 'swap') {
pushEditingValueIfNeeded();
if (calc.isValidIndex(1)) {
calc.swap(0, 1);
}
} else if (name === 'neg') {
if (calc.isEditing) {
const hasSign = calc.inputValue.startsWith('-');
calc.inputValue = hasSign ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
moveEditCursor(hasSign ? -1 : 1);
} else {
calc.command('neg');
}
} else {
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, title }) {
if (spacer) {
const div = document.createElement('div');
return div;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = className;
if (title) button.title = title;
button.addEventListener('click', () => {
if (input) {
inputToX(input);
render();
return;
}
execute(action);
});
return button;
}
function buildGrid(container, keys) {
container.innerHTML = '';
keys.forEach((key) => container.appendChild(createKeyButton(key)));
}
async function copyStackValue(label) {
const value = getStackDisplayValue(label);
if (!value) return;
try {
await navigator.clipboard.writeText(value);
clearStatus();
} catch (error) {
setStatus('Copy unavailable', true);
}
}
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);
render();
return;
}
const map = {
Enter: 'enter',
Backspace: 'backspace',
Escape: 'escape',
Delete: 'clear',
'+': 'add',
'-': 'sub',
'*': 'mul',
'/': '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',
};
if (key === 'ArrowLeft') {
event.preventDefault();
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]) {
event.preventDefault();
execute(map[key]);
}
}
const modeOptions = ['deg', 'rad', 'grad'];
let activeMenuEl = null;
function closeModeMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function openMenu(anchorButton, items, onSelect) {
const rect = anchorButton.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'menu-popup';
menu.style.top = `${rect.bottom + 6 + window.scrollY}px`;
menu.style.left = `${rect.left + window.scrollX}px`;
menu.style.minWidth = `${rect.width}px`;
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = `menu-popup-item${item.active ? ' is-active' : ''}`;
button.textContent = item.label;
button.addEventListener('click', () => onSelect(item.value));
menu.appendChild(button);
}
document.body.appendChild(menu);
return menu;
}
function toggleModeMenu() {
if (activeMenuEl) {
closeModeMenu();
return;
}
closeConstMenu();
activeMenuEl = openMenu(modeButton, modeOptions.map((mode) => ({
label: mode,
value: mode,
active: mode === calc.angleMode,
})), (mode) => {
calc.angleMode = mode;
render();
closeModeMenu();
});
}
modeButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleModeMenu();
});
window.addEventListener('resize', () => {
closeModeMenu();
closeConstMenu();
render();
});
window.addEventListener('scroll', () => {
closeModeMenu();
closeConstMenu();
}, true);
window.addEventListener('click', (event) => {
const stackCopyButton = event.target.closest('.stack-copy-button');
if (stackCopyButton) {
const label = stackCopyButton.dataset.copyStack;
if (label) copyStackValue(label);
return;
}
if (activeMenuEl && !event.target.closest('.menu-popup') && event.target !== modeButton && event.target !== constButton) {
closeModeMenu();
closeConstMenu();
}
});
pasteButton.addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
pasteTextIntoStack(text);
clearStatus();
} catch (error) {
setStatus('Paste unavailable', true);
}
});
hiddenInput.addEventListener('paste', (event) => {
event.preventDefault();
const text = event.clipboardData?.getData('text') ?? '';
pasteTextIntoStack(text);
});
upButton.addEventListener('click', () => {
if (!calc.isEditing && stackMode === 'normal') {
enterNavigationMode();
focusInput();
}
});
const constantLabels = {
pi: 'π',
e: 'e',
phi: 'φ',
g: 'g',
c: 'C',
};
const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
function closeConstMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function toggleConstMenu() {
if (activeMenuEl) {
closeConstMenu();
return;
}
closeModeMenu();
const availableConstants = calc.listConstants();
const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]
.filter((name) => Object.prototype.hasOwnProperty.call(availableConstants, name));
activeMenuEl = openMenu(constButton, keys.map((name) => ({
label: constantLabels[name] ?? name,
value: name,
})), (name) => {
pushEditingValueIfNeeded();
calc.push(availableConstants[name]);
render();
clearStatus();
closeConstMenu();
focusInput();
});
}
constButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleConstMenu();
});
leftButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(-1);
render();
focusInput();
return;
}
if (stackMode === 'navigation') {
exitStackMode();
focusInput();
}
});
downButton.addEventListener('click', () => {
if (!calc.isEditing && startEditingFromStackTop()) {
render();
focusInput();
}
});
rightButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(1);
render();
focusInput();
return;
}
execute('swap');
});
window.addEventListener('keydown', handleKeyboard, { capture: true });
window.addEventListener('load', focusInput);
window.addEventListener('pageshow', focusInput);
window.addEventListener('focus', focusInput);
window.addEventListener('pointerdown', focusInput, true);
window.addEventListener('mousedown', focusInput, true);
hiddenInput.setAttribute('inputmode', 'none');
hiddenInput.setAttribute('readonly', 'readonly');
hiddenInput.addEventListener('focus', () => {
if (isTouchDevice) {
hiddenInput.blur();
return;
}
window.requestAnimationFrame(() => {
hiddenInput.select();
});
});
document.addEventListener('click', (event) => {
if (!isTouchDevice && !event.target.closest('.menu-popup')) {
focusInput();
}
});
buildGrid(keypadGrid, keypadKeys);
buildGrid(functionsGrid, functionKeys);
buildGrid(trigoGrid, trigoKeys);
render();
focusInput();
+52
View File
@@ -0,0 +1,52 @@
# Calc 02 Visual Portrait
## Display
```
┌──────────── Display ────────────┐
| T: |
| Z: |
| Y: |
| X: |
└─────────────────────────────────┘
```
## Display Buttons
```
┌──────── Display Buttons ────────┐
| Const | Paste | Up | Mode |
| | Left | Down | Right |
└─────────────────────────────────┘
```
## Keypad
```
┌──────────── Keypad ─────────────┐
| Clear | Backspace | Esc | Enter |
| 7 | 8 | 9 | / |
| 4 | 5 | 6 | * |
| 1 | 2 | 3 | - |
| 0 | . | +/- | + |
└─────────────────────────────────┘
```
## Functions
```
┌──────────── Functions ──────────┐
| x^2 | √x | 1/x | % |
| y^x | y√x | 10^x | |
| log | ln | e^x | |
└─────────────────────────────────┘
```
## Trigo
```
┌──────────── Trigo ──────────────┐
| sin | cos | tan | |
| asin | acos | atan | |
└─────────────────────────────────┘
```
+204
View File
@@ -0,0 +1,204 @@
:root {
--body: #d8d8d8;
--panel: #202020;
--panel-2: #2b2b2b;
--screen: #d8e7b8;
--screen-text: #1b2a12;
--screen-dim: #5b6f45;
--key: #3a3a3a;
--key-text: #f2f2f2;
--accent: #8cff6d;
--border: #111;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #efefef, var(--body));
color: #111;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 24px;
}
.calc {
background: linear-gradient(180deg, #2f2f2f, #1f1f1f);
border: 1px solid #111;
border-radius: 20px;
padding: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.25);
}
.brand {
color: #fafafa;
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
gap: 12px;
}
.brand h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0.06em;
}
.brand small {
color: #c9c9c9;
}
.screen {
background: linear-gradient(180deg, #dbe8b8, var(--screen));
color: var(--screen-text);
border: 2px inset #8aa36b;
border-radius: 10px;
padding: 14px;
min-height: 190px;
font-family: "Courier New", monospace;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
}
.screen-top {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--screen-dim);
}
.stack {
border-top: 1px solid rgba(27, 42, 18, 0.35);
padding-top: 10px;
line-height: 1.5;
font-size: 18px;
white-space: pre-wrap;
}
.stack-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
padding: 1px 4px;
border-radius: 4px;
}
.stack-line.selected {
background: rgba(27, 42, 18, 0.14);
outline: 1px dashed rgba(27, 42, 18, 0.45);
}
.stack-line.moving {
background: rgba(140, 255, 109, 0.18);
outline: 1px solid rgba(27, 42, 18, 0.55);
}
.stack-line .label {
text-align: right;
color: var(--screen-dim);
}
.stack-line.selected .label,
.stack-line.moving .label {
color: var(--screen-text);
font-weight: bold;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.input-row {
display: grid;
grid-template-columns: 1fr 150px;
gap: 12px;
margin-top: 14px;
}
input, select, button {
border-radius: 10px;
border: 1px solid #000;
font: inherit;
}
input, select {
padding: 12px 14px;
background: #f7f7f7;
color: #111;
}
.panel {
margin-top: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
}
.title {
color: #fff;
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.buttons {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
button {
padding: 12px 10px;
background: linear-gradient(180deg, #4a4a4a, var(--key));
color: var(--key-text);
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
button:hover { filter: brightness(1.08); }
button:active { transform: translateY(1px); }
.status {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #ececec;
font-size: 13px;
}
.pill {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.05);
}
.error {
margin-top: 10px;
min-height: 20px;
color: #ff8a8a;
font-family: "Courier New", monospace;
font-size: 13px;
}
.hint {
color: #ddd;
margin-top: 10px;
font-size: 13px;
line-height: 1.5;
}
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48-style RPN Calculator</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div class="wrap">
<div class="calc">
<div class="brand">
<h1>HP48-style RPN</h1>
<small>powered by src/rpn-calculator.js</small>
</div>
<div class="screen" id="screen" tabindex="0" role="application" aria-label="HP48 style calculator screen">
<div class="screen-top">
<div>RPN stack</div>
<div id="modeLabel">deg</div>
</div>
<div id="stack" class="stack"></div>
<div id="display"></div>
</div>
<input id="input" class="hidden-input" type="text" autocomplete="off" aria-hidden="true" tabindex="-1">
<div class="input-row">
<div class="hint">Keyboard works globally: digits, numpad, Enter, Backspace, Delete, Esc, ↑, ↓, →, +, -, *, /, %, ^, q, n, r, i, g, l, s, c, S, C, x, y, z, t</div>
<select id="angleMode">
<option value="deg">Degrees</option>
<option value="rad">Radians</option>
<option value="grad">Grads</option>
</select>
</div>
<div class="status">
<div class="pill">inputValue: <span id="inputValueLabel"></span></div>
<div class="pill">isEditing: <span id="editingLabel"></span></div>
</div>
<div class="panel">
<div class="title">Stack</div>
<div class="buttons" id="stackButtons"></div>
</div>
<div class="panel">
<div class="title">Arithmetic</div>
<div class="buttons" id="arithButtons"></div>
</div>
<div class="panel">
<div class="title">Trigonometry</div>
<div class="buttons" id="trigButtons"></div>
</div>
<div class="panel">
<div class="title">Constants</div>
<div class="buttons" id="constButtons"></div>
</div>
<div id="error" class="error"></div>
<div class="hint">Use Enter to commit the current value. Buttons call <code>command(...)</code> directly, like a real RPN demo.</div>
</div>
</div>
<script src="../../src/rpn-calculator.js"></script>
<script src="./index.js"></script>
</body>
</html>
+492
View File
@@ -0,0 +1,492 @@
const calc = new RpnCalculator({ angleMode: 'deg' });
const input = document.getElementById('input');
const screen = document.getElementById('screen');
const stackEl = document.getElementById('stack');
const displayEl = document.getElementById('display');
const errorEl = document.getElementById('error');
const inputValueLabel = document.getElementById('inputValueLabel');
const editingLabel = document.getElementById('editingLabel');
const modeLabel = document.getElementById('modeLabel');
const angleMode = document.getElementById('angleMode');
const groups = {
stack: ['enter', 'dup', 'drop', 'swap', 'clear'],
arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
trig: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
const: ['pi', 'e'],
};
let stackCursor = null;
let isMovingStackItem = false;
let stackSnapshotBeforeMove = null;
let stackViewOffset = 0;
let editRestoreValue = null;
function labelFor(command) {
return ({ add: '+', sub: '', mul: '×', div: '÷', pow: 'y^x', recip: '1/x', sqr: 'x²' }[command] || command);
}
function addButtons(container, commands) {
container.innerHTML = '';
commands.forEach((commandName) => {
const button = document.createElement('button');
button.textContent = labelFor(commandName);
button.addEventListener('click', () => execute(commandName));
container.appendChild(button);
});
}
function getStackValue(index) {
return calc.isValidIndex(index) ? calc.stack[index] : undefined;
}
function getDisplayValue(index) {
if (calc.isEditing) {
if (index === 0) {
return calc.inputValue;
}
return getStackValue(index - 1);
}
return getStackValue(index);
}
function hasStackSelection() {
return stackCursor !== null && calc.isValidIndex(stackCursor);
}
function clearStackSelection() {
stackCursor = null;
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackViewOffset = 0;
}
function ensureValidSelection() {
if (hasStackSelection()) {
return;
}
stackCursor = calc.isValidIndex(0) ? 0 : null;
}
function beginMoveMode() {
if (!hasStackSelection()) {
return;
}
isMovingStackItem = true;
stackSnapshotBeforeMove = calc.stack.slice();
}
function commitMoveMode() {
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
}
function cancelMoveMode() {
if (!isMovingStackItem || !stackSnapshotBeforeMove) {
return;
}
const snapshot = stackSnapshotBeforeMove.slice();
calc.clear();
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
calc.push(snapshot[index]);
}
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(0) ? 0 : null);
syncInputFromState();
}
function reactivateEditOnX() {
clearStackSelection();
if (calc.isValidIndex(0)) {
const value = getStackValue(0);
calc.remove(0);
calc.inputValue = calc.formatNumber(value);
calc.isEditing = true;
editRestoreValue = value;
} else {
calc.inputValue = '';
calc.isEditing = true;
editRestoreValue = null;
}
syncInputFromState();
}
function moveStackSelection(direction) {
if (!hasStackSelection()) {
if (direction === 'up') {
ensureValidSelection();
} else if (direction === 'down') {
reactivateEditOnX();
}
return;
}
const nextIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (calc.isValidIndex(nextIndex)) {
stackCursor = nextIndex;
}
}
function moveStackItem(direction) {
if (!hasStackSelection()) {
return;
}
const targetIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (!calc.isValidIndex(targetIndex)) {
return;
}
calc.swap(stackCursor, targetIndex);
stackCursor = targetIndex;
}
function getVisibleStackIndex(visualLine) {
return stackViewOffset + visualLine;
}
function clampStackViewOffset() {
const maxOffset = Math.max(0, calc.stack.length - 4);
if (stackViewOffset < 0) {
stackViewOffset = 0;
} else if (stackViewOffset > maxOffset) {
stackViewOffset = maxOffset;
}
}
function ensureSelectionVisible() {
if (!hasStackSelection()) {
stackViewOffset = 0;
return;
}
if (stackCursor < stackViewOffset) {
stackViewOffset = stackCursor;
} else if (stackCursor > stackViewOffset + 3) {
stackViewOffset = stackCursor - 3;
}
clampStackViewOffset();
}
function render() {
const names = ['T', 'Z', 'Y', 'X'];
const lines = [];
const showStackIndexes = hasStackSelection() || isMovingStackItem;
clampStackViewOffset();
ensureSelectionVisible();
for (let visualLine = 3; visualLine >= 0; visualLine -= 1) {
const stackIndex = getVisibleStackIndex(visualLine);
const value = getDisplayValue(stackIndex);
const isSelected = stackCursor === stackIndex;
const classes = ['stack-line'];
const label = showStackIndexes ? String(stackIndex) : names[3 - visualLine];
if (isSelected) {
classes.push(isMovingStackItem ? 'moving' : 'selected');
}
lines.push(`<div class="${classes.join(' ')}"><div class="label">${label}</div><div>${value !== undefined && value !== '' ? calc.formatNumber(value) : ''}</div></div>`);
}
stackEl.innerHTML = lines.join('');
if (calc.isEditing) {
displayEl.textContent = `ENTERING: ${calc.inputValue}`;
} else if (isMovingStackItem && hasStackSelection()) {
displayEl.textContent = `MOVING: ${stackCursor}`;
} else if (hasStackSelection()) {
displayEl.textContent = `SELECTED: ${stackCursor}`;
} else {
displayEl.textContent = 'READY';
}
inputValueLabel.textContent = calc.inputValue || '∅';
editingLabel.textContent = String(calc.isEditing);
modeLabel.textContent = calc.angleMode;
angleMode.value = calc.angleMode;
errorEl.textContent = '';
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
const value = calc.parseInputValue(calc.inputValue);
calc.push(value);
}
calc.inputValue = '';
calc.isEditing = false;
editRestoreValue = null;
syncInputFromState();
}
function execute(name) {
try {
if (name === 'enter') {
if (calc.isEditing) {
pushEditingValueIfNeeded();
} else if (calc.isValidIndex(0)) {
calc.push(getStackValue(0));
}
} else if (name === 'swap') {
pushEditingValueIfNeeded();
clearStackSelection();
if (calc.isValidIndex(1)) calc.swap(0, 1);
} else if (name === 'drop') {
pushEditingValueIfNeeded();
if (hasStackSelection()) {
calc.remove(stackCursor);
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(stackCursor - 1) ? stackCursor - 1 : null);
} else if (calc.isValidIndex(0)) {
calc.remove(0);
}
commitMoveMode();
} else if (name === 'clear') {
calc.clear();
clearStackSelection();
} else {
pushEditingValueIfNeeded();
clearStackSelection();
calc.command(name);
}
syncInputFromState();
render();
} catch (error) {
errorEl.textContent = error.message;
}
}
function isInputChar(key) {
return /^[0-9a-fA-F.]$/.test(key);
}
function shouldIgnoreKeyboardEvent(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName;
return (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT' ||
target.isContentEditable
);
}
function getKeyboardAction(event) {
const numpadMap = {
Numpad0: { type: 'input', value: '0' },
Numpad1: { type: 'input', value: '1' },
Numpad2: { type: 'input', value: '2' },
Numpad3: { type: 'input', value: '3' },
Numpad4: { type: 'input', value: '4' },
Numpad5: { type: 'input', value: '5' },
Numpad6: { type: 'input', value: '6' },
Numpad7: { type: 'input', value: '7' },
Numpad8: { type: 'input', value: '8' },
Numpad9: { type: 'input', value: '9' },
NumpadDecimal: { type: 'input', value: '.' },
NumpadAdd: { type: 'command', value: 'add' },
NumpadSubtract: { type: 'command', value: 'sub' },
NumpadMultiply: { type: 'command', value: 'mul' },
NumpadDivide: { type: 'command', value: 'div' },
NumpadEnter: { type: 'command', value: 'enter' },
};
if (numpadMap[event.code]) {
return numpadMap[event.code];
}
if (isInputChar(event.key)) {
return { type: 'input', value: event.key };
}
const keyMap = {
Enter: { type: 'enterKey' },
Backspace: { type: 'stackOrEdit', value: 'drop' },
Delete: { type: 'command', value: 'clear' },
Escape: { type: 'escapeKey' },
ArrowUp: { type: 'stackArrow', value: 'up' },
ArrowDown: { type: 'stackArrow', value: 'down' },
ArrowRight: { type: 'command', value: 'swap' },
'+': { type: 'command', value: 'add' },
'-': { type: 'command', value: 'sub' },
'*': { type: 'command', value: 'mul' },
'/': { type: 'command', value: 'div' },
'%': { type: 'command', value: 'mod' },
'^': { type: 'command', value: 'pow' },
q: { type: 'command', value: 'sqr' },
n: { type: 'command', value: 'neg' },
r: { type: 'command', value: 'sqrt' },
i: { type: 'command', value: 'recip' },
g: { type: 'command', value: 'log' },
l: { type: 'command', value: 'ln' },
s: { type: 'command', value: 'sin' },
c: { type: 'command', value: 'cos' },
S: { type: 'command', value: 'asin' },
C: { type: 'command', value: 'acos' },
x: { type: 'stackSelect', value: 0 },
y: { type: 'stackSelect', value: 1 },
z: { type: 'stackSelect', value: 2 },
t: { type: 'stackSelect', value: 3 },
X: { type: 'stackSelect', value: 0 },
Y: { type: 'stackSelect', value: 1 },
Z: { type: 'stackSelect', value: 2 },
T: { type: 'stackSelect', value: 3 },
};
return keyMap[event.key] || null;
}
function focusScreen() {
screen.focus();
}
function syncInputFromState() {
input.value = calc.inputValue;
}
function editXWithKey(key) {
if (!calc.isEditing) {
pushEditingValueIfNeeded();
calc.isEditing = true;
calc.inputValue = '';
editRestoreValue = null;
}
if (key === 'Backspace') {
calc.inputValue = calc.inputValue.slice(0, -1);
} else {
calc.inputValue += key;
}
if (calc.inputValue === '') {
calc.isEditing = false;
editRestoreValue = null;
}
syncInputFromState();
}
function handleKeydown(event) {
if (shouldIgnoreKeyboardEvent(event)) {
return;
}
const action = getKeyboardAction(event);
if (!action) {
return;
}
try {
if (action.type === 'escapeKey') {
if (calc.isEditing) {
event.preventDefault();
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
editRestoreValue = null;
}
calc.inputValue = '';
calc.isEditing = false;
syncInputFromState();
render();
return;
}
if (isMovingStackItem) {
event.preventDefault();
cancelMoveMode();
clearStackSelection();
render();
return;
}
if (hasStackSelection()) {
event.preventDefault();
clearStackSelection();
render();
}
return;
}
event.preventDefault();
if (action.type === 'input') {
clearStackSelection();
editXWithKey(action.value);
render();
return;
}
if (action.type === 'stackSelect') {
if (calc.isEditing || isMovingStackItem) {
return;
}
stackCursor = calc.isValidIndex(action.value) ? action.value : null;
render();
return;
}
if (action.type === 'stackArrow') {
if (calc.isEditing) {
render();
return;
}
if (isMovingStackItem) {
moveStackItem(action.value);
} else {
moveStackSelection(action.value);
}
render();
return;
}
if (action.type === 'stackOrEdit') {
if (calc.isEditing) {
editXWithKey('Backspace');
render();
} else {
execute(action.value);
}
return;
}
if (action.type === 'enterKey') {
if (hasStackSelection()) {
if (isMovingStackItem) {
commitMoveMode();
} else {
beginMoveMode();
}
render();
return;
}
execute('enter');
return;
}
if (action.type === 'command') {
execute(action.value);
}
} catch (error) {
errorEl.textContent = error.message;
}
}
window.addEventListener('keydown', handleKeydown);
screen.addEventListener('click', focusScreen);
window.addEventListener('load', focusScreen);
angleMode.addEventListener('change', (event) => {
calc.angleMode = event.target.value;
render();
});
addButtons(document.getElementById('stackButtons'), groups.stack);
addButtons(document.getElementById('arithButtons'), groups.arithmetic);
addButtons(document.getElementById('trigButtons'), groups.trig);
addButtons(document.getElementById('constButtons'), groups.const);
render();
focusScreen();
-308
View File
@@ -1,308 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RPN Calculator Demo</title>
<style>
:root {
color-scheme: light dark;
--bg: #f4f4f4;
--panel: #ffffff;
--text: #111;
--muted: #666;
--button: #e9e9e9;
--button-text: #111;
--border: #d0d0d0;
--accent: #0a7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--panel: #1a1a1a;
--text: #f3f3f3;
--muted: #aaa;
--button: #2a2a2a;
--button-text: #f3f3f3;
--border: #333;
--accent: #3dc;
}
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.app {
max-width: 860px;
margin: 24px auto;
padding: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.display {
background: #000;
color: #0f0;
border-radius: 10px;
padding: 12px;
font-family: monospace;
min-height: 72px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: 8px;
margin-top: 12px;
}
button, select, input {
border: 1px solid var(--border);
background: var(--button);
color: var(--button-text);
border-radius: 8px;
padding: 10px 12px;
font-size: 15px;
}
button {
cursor: pointer;
}
input, select {
width: 100%;
box-sizing: border-box;
background: var(--panel);
color: var(--text);
font-size: 16px;
}
.row {
display: grid;
grid-template-columns: 1fr 180px;
gap: 12px;
margin-top: 12px;
align-items: end;
}
.stack {
margin-top: 12px;
font-family: monospace;
line-height: 1.5;
}
.muted {
color: var(--muted);
font-size: 14px;
}
.section-title {
margin: 16px 0 8px;
font-size: 14px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
color: var(--muted);
font-size: 14px;
}
.badge {
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.04);
}
.accent {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app">
<div class="card">
<h1>RPN Calculator Demo</h1>
<div id="display" class="display"></div>
<div id="stack" class="stack"></div>
<div class="row">
<label>
<div class="section-title">Input</div>
<input id="input" type="text" placeholder="Type a number, pi, e, or a command, then press Enter" autocomplete="off">
</label>
<label>
<div class="section-title">Angle mode</div>
<select id="angleMode">
<option value="deg">Degrees</option>
<option value="rad">Radians</option>
<option value="grad">Grads</option>
</select>
</label>
</div>
<div class="status">
<div class="badge">Mode: <span id="angleModeLabel" class="accent"></span></div>
<div class="badge">Base: <span id="baseLabel" class="accent"></span></div>
</div>
<div class="section-title">Constants</div>
<div class="grid">
<button data-const="pi">pi</button>
<button data-const="e">e</button>
</div>
<div class="section-title">Stack</div>
<div class="grid">
<button data-cmd="enter">Enter</button>
<button data-cmd="dup">Dup</button>
<button data-cmd="swap">Swap top 2</button>
<button data-cmd="drop">Drop</button>
<button data-cmd="clear">Clear</button>
</div>
<div class="section-title">Arithmetic</div>
<div class="grid">
<button data-cmd="add">+</button>
<button data-cmd="sub"></button>
<button data-cmd="mul">×</button>
<button data-cmd="div">÷</button>
<button data-cmd="mod">%</button>
<button data-cmd="pow">y^x</button>
<button data-cmd="sqr"></button>
<button data-cmd="neg">±</button>
<button data-cmd="sqrt">sqrt</button>
<button data-cmd="recip">1/x</button>
<button data-cmd="log">log</button>
<button data-cmd="ln">ln</button>
</div>
<div class="section-title">Trigonometry</div>
<div class="grid">
<button data-cmd="sin">sin</button>
<button data-cmd="cos">cos</button>
<button data-cmd="tan">tan</button>
<button data-cmd="asin">asin</button>
<button data-cmd="acos">acos</button>
<button data-cmd="atan">atan</button>
</div>
<p class="muted">Tip: trig functions follow the selected angle mode. Domain errors are reported with clear messages. sqrt computes the square root of the top stack value.</p>
</div>
</div>
<script src="../../src/rpn-calculator.js"></script>
<script>
const enabledCommands = ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'log', 'ln', 'dup', 'swap', 'drop', 'clear', 'enter'];
const calc = new RpnCalculator({ angleMode: 'deg', enabledCommands });
const display = document.getElementById('display');
const stack = document.getElementById('stack');
const input = document.getElementById('input');
const angleMode = document.getElementById('angleMode');
const angleModeLabel = document.getElementById('angleModeLabel');
const baseLabel = document.getElementById('baseLabel');
function render() {
display.textContent = calc.isEditing ? `Editing: ${calc.inputValue}` : 'Ready';
stack.innerHTML = calc.stack.length
? calc.stack.map((value, index) => `<div>${index}: ${value}</div>`).join('')
: '<span class="muted">Stack empty</span>';
angleMode.value = calc.angleMode;
angleModeLabel.textContent = calc.angleMode;
baseLabel.textContent = String(calc.base);
}
function commitInput() {
if (input.value.trim() !== '') {
calc.inputValue = input.value;
calc.isEditing = true;
calc.command('enter');
input.value = '';
}
}
function runCommand(name) {
if (name === 'swap') {
if (calc.stack.length >= 2) calc.swap(0, 1);
return;
}
if (name === 'enter') {
commitInput();
return;
}
calc.command(name);
}
input.addEventListener('input', (event) => {
calc.inputValue = event.target.value;
calc.isEditing = event.target.value.length > 0;
render();
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
try {
commitInput();
render();
} catch (error) {
alert(error.message);
}
}
});
angleMode.addEventListener('change', (event) => {
calc.angleMode = ['deg', 'rad', 'grad'].includes(event.target.value) ? event.target.value : 'deg';
render();
});
document.querySelectorAll('button[data-cmd]').forEach((button) => {
button.addEventListener('click', () => {
try {
runCommand(button.dataset.cmd);
render();
} catch (error) {
alert(error.message);
}
});
});
document.querySelectorAll('button[data-const]').forEach((button) => {
button.addEventListener('click', () => {
try {
calc.inputValue = '';
calc.isEditing = false;
calc.command(button.dataset.const);
input.value = '';
render();
} catch (error) {
alert(error.message);
}
});
});
render();
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
┌───────────── Functions ────────────┬──── Numbers ────┬─── Operators ───┐
| consts | | | | | | | del | esc |
| sqrt | y^x | x² | 1/x | 7 | 8 | 9 | / | backspace |
| log | ln | | % | 4 | 5 | 6 | * | |
| sin | cos | tan | | 1 | 2 | 3 | - | Enter |
| asin | acos | atan | | 0 | . | +/- | + | Enter |
└────────────────────────────────────┴─────────────────┴─────────────────┘
+87 -2
View File
@@ -2,6 +2,9 @@ class RpnCalculator {
static CONSTANTS = {
pi: Math.PI,
e: Math.E,
phi: (1 + Math.sqrt(5)) / 2,
g: 9.80665,
c: 299792458,
};
static OPERATIONS = {
@@ -46,6 +49,32 @@ class RpnCalculator {
aliases: ['^', 'y^x'],
execute: (calc, a, b) => Math.pow(a, b),
},
pow10: {
category: 'Arithmetic',
argCount: 1,
aliases: ['10^x'],
execute: (calc, a) => Math.pow(10, a),
},
exp: {
category: 'Arithmetic',
argCount: 1,
aliases: ['e^x'],
execute: (calc, a) => Math.exp(a),
},
root: {
category: 'Arithmetic',
argCount: 2,
aliases: ['y√x', 'yroot', 'nroot'],
execute: (calc, a, b) => {
if (b === 0) {
throw new Error('Invalid input for root');
}
if (a < 0 && b % 2 === 0) {
throw new Error('Invalid input for root');
}
return Math.pow(a, 1 / b);
},
},
sqr: {
category: 'Arithmetic',
argCount: 1,
@@ -178,7 +207,7 @@ class RpnCalculator {
static getOperationsByCategory() {
return {
Stack: ['dup', 'drop', 'swap', 'clear', 'enter'],
Arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
Arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'pow10', 'exp', 'root', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
Trigonometry: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
};
}
@@ -198,6 +227,15 @@ class RpnCalculator {
this.enabledCommands = new Set(selectedCommands.map((name) => this.normalizeCommandName(name)).filter((name) => RpnCalculator.OPERATIONS[name]));
}
static isValidConstantName(name) {
return typeof name === 'string' && name.trim() !== '';
}
static isReservedName(name) {
const normalized = typeof name === 'string' ? name.toLowerCase() : '';
return Boolean(RpnCalculator.OPERATIONS[normalized]);
}
toRadians(value) {
if (this.angleMode === 'grad') {
return (value * Math.PI) / 200;
@@ -243,6 +281,45 @@ class RpnCalculator {
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(this.constants, name.toLowerCase());
}
setConstant(name, value) {
if (!RpnCalculator.isValidConstantName(name)) {
throw new Error('Invalid constant name');
}
if (!Number.isFinite(value)) {
throw new Error('Invalid constant value');
}
const normalized = name.trim().toLowerCase();
if (RpnCalculator.isReservedName(normalized)) {
throw new Error(`Constant name conflicts with a command: ${name}`);
}
this.constants[normalized] = value;
return value;
}
removeConstant(name) {
if (!RpnCalculator.isValidConstantName(name)) {
throw new Error('Invalid constant name');
}
const normalized = name.trim().toLowerCase();
if (!this.isConstantName(normalized)) {
return false;
}
delete this.constants[normalized];
return true;
}
hasConstant(name) {
if (!RpnCalculator.isValidConstantName(name)) {
return false;
}
return this.isConstantName(name.trim().toLowerCase());
}
push(value) {
if (this.stack.length >= this.maxSize) {
throw new Error('Stack overflow');
@@ -369,9 +446,13 @@ class RpnCalculator {
throw new Error('Stack underflow');
}
const operands = argCount > 0 ? this.stack.splice(0, argCount).reverse() : [];
const operands = argCount > 0 ? this.stack.slice(0, argCount).reverse() : [];
const result = operation.execute(this, ...operands, ...args);
if (argCount > 0) {
this.stack.splice(0, argCount);
}
if (Array.isArray(result)) {
for (let i = result.length - 1; i >= 0; i -= 1) {
this.push(result[i]);
@@ -390,6 +471,10 @@ class RpnCalculator {
getConstants() {
return { ...this.constants };
}
listConstants() {
return this.getConstants();
}
}
if (typeof window !== 'undefined') {