From 50f4c50b3aa11302fdb43f6cfef83d7fb7473040 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 18 Sep 2026 12:28:31 -0400 Subject: [PATCH] Group the digits as you type in the ledger -2000000 and -20000000 are the same shape at a glance, and the ledger deals in both. Display only: what leaves the input is always the raw string, so nothing upstream ever sees a comma. Partly-typed numbers survive intact -- "1." and "-" and "1.50" are all states on the way to a value, and reformatting them into something else mid-keystroke makes the field unusable. The caret is restored by counting digits rather than remembering an offset, since inserting a comma shifts every character after it and a remembered position lands one place off for the rest of the number. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/components/OperationPanel.jsx | 52 ++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 917bd5e..c17d057 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -11,7 +11,7 @@ // flex-1 buttons grew to absurd sizes; everything here is fixed-width and // left-aligned instead. -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useLayoutEffect } from 'react' const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums' const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono' @@ -259,11 +259,59 @@ function derive(current, edit, dp = 2) { return out } +// Grouped while you type, because -2000000 and -20000000 are the same shape at +// a glance and the ledger deals in both. +// +// Formats for display only: what leaves here is always the raw string, so the +// arithmetic upstream never sees a comma. A partly-typed number has to survive +// intact -- "1." and "-" and "1.50" are all states on the way to a value, and +// reformatting them into something else as you type makes the field unusable. +function groupDigits(raw) { + const str = String(raw ?? '') + if (str === '' || str === '-') return str + const neg = str.startsWith('-') + const body = neg ? str.slice(1) : str + const dot = body.indexOf('.') + const whole = (dot === -1 ? body : body.slice(0, dot)).replace(/\D/g, '') + const frac = dot === -1 ? null : body.slice(dot + 1).replace(/\D/g, '') + if (whole === '' && frac === null) return neg ? '-' : '' + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}` +} + function LedgerInput({ value, active, onChange, onFocus, suffix }) { + const ref = useRef(null) + const caret = useRef(null) + const display = groupDigits(value) + + // The commas shift every character after them, so a remembered offset lands + // in the wrong place. Count digits instead -- those are what the caret is + // actually sitting between -- and find that many digits into the new text. + useLayoutEffect(() => { + const el = ref.current + if (!el || caret.current == null) return + const wanted = caret.current + caret.current = null + let seen = 0, pos = display.length + for (let i = 0; i < display.length; i++) { + if (/[\d.-]/.test(display[i])) seen++ + if (seen === wanted) { pos = i + 1; break } + } + if (wanted === 0) pos = 0 + try { el.setSelectionRange(pos, pos) } catch {} + }, [display]) + return ( onChange(e.target.value)} + ref={ref} + type="text" inputMode="decimal" value={display} + onChange={e => { + const el = e.target + const upto = el.value.slice(0, el.selectionStart ?? el.value.length) + caret.current = (upto.match(/[\d.-]/g) || []).length + onChange(el.value.replace(/,/g, '')) + }} onFocus={onFocus} placeholder="—" className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums ${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />