diff --git a/internal/calc/calc.go b/internal/calc/calc.go index 47710a9..2cfdc57 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -5,6 +5,7 @@ import ( "math" "strconv" "strings" + "unicode" ) // Base represents the current display formatting mode. @@ -67,10 +68,27 @@ func (e *Engine) IsInteger() bool { // currentNumericValue returns the number that should be considered "current" // for operations and for BASE cycling / formatting. +// For active entry, parses according to the current base (tight scope supports +// decimal for DEC and base-16 for HEX). func (e *Engine) currentNumericValue() float64 { if e.entry != "" && e.entry != "0" { - if v, err := strconv.ParseFloat(e.entry, 64); err == nil { - return v + switch e.base { + case BaseDEC: + if v, err := strconv.ParseFloat(e.entry, 64); err == nil { + return v + } + case BaseHEX: + if i, err := strconv.ParseInt(e.entry, 16, 64); err == nil { + return float64(i) + } + case BaseBIN: + if i, err := strconv.ParseInt(e.entry, 2, 64); err == nil { + return float64(i) + } + case BaseOCT: + if i, err := strconv.ParseInt(e.entry, 8, 64); err == nil { + return float64(i) + } } } return e.accumulator @@ -79,10 +97,24 @@ func (e *Engine) currentNumericValue() float64 { // CycleBase advances to the next base in the cycle (DEC→HEX→BIN→OCT→DEC). // Returns ErrConversionNotPossible (and does not change base) if the current // numeric value has a fractional part. The UI should trigger a "CERR" flash. +// +// For tight-scope HEX entry: if the user is mid-entry, we parse the entry +// using the *old* base (committing its value), clear the entry, then change +// base. This keeps the entry string always valid for the current base indicator. func (e *Engine) CycleBase() error { if !e.IsInteger() { return ErrConversionNotPossible } + + // Commit any active entry under the *current* base before switching. + // This ensures "1A" typed in HEX becomes 26 (decimal) when you Tab away, + // and the new base's display formatting applies to the committed value. + if e.entry != "" && e.entry != "0" { + val := e.currentNumericValue() + e.accumulator = val + e.entry = "0" + } + for i, b := range basesCycle { if b == e.base { e.base = basesCycle[(i+1)%len(basesCycle)] @@ -96,15 +128,23 @@ func (e *Engine) CycleBase() error { // FormatForDisplay returns the string to render in the large number area // according to the current base and the current numeric value. func (e *Engine) FormatForDisplay() string { - val := e.currentNumericValue() - - // If we have an active entry string (user is typing), prefer showing the - // literal entry in DEC (classic calculator behavior). For other bases we - // still format the numeric value (after commit it will be consistent). - if e.base == BaseDEC { - if e.entry != "" { + // Tight scope for HEX entry: while the user is actively typing (entry + // buffer active) and the base indicator says HEX, show the raw hex digits + // they typed (uppercased). This is the key UX for "HEX entry via keyboard". + if e.entry != "" && e.entry != "0" { + if e.base == BaseHEX { + return strings.ToUpper(e.entry) + } + if e.base == BaseDEC { return e.entry } + // For other bases we fall through to numeric formatting below. + } + + val := e.currentNumericValue() + + // For committed values or DEC entry, use the previous logic. + if e.base == BaseDEC { // For DEC committed results, show a reasonable representation. if math.Abs(val) < 1e12 { return strconv.FormatFloat(val, 'f', -1, 64) @@ -112,13 +152,11 @@ func (e *Engine) FormatForDisplay() string { return strconv.FormatFloat(val, 'e', -1, 64) } - // Non-DEC bases: only make sense for integers. The caller should have - // already guarded via CycleBase / IsInteger, but we coerce to int64 here. - // Negative numbers in hex/bin/oct are shown as two's complement style for - // programmer feel is out of MVP scope — we just show the positive integer part. + // Non-DEC bases: only make sense for integers (CERR guards this). + // We coerce to int64 here. Negative numbers shown as positive for MVP. i := int64(val) if i < 0 { - i = 0 // MVP: treat negative as 0 for non-DEC display + i = 0 } switch e.base { @@ -133,17 +171,39 @@ func (e *Engine) FormatForDisplay() string { } } -// EnterDigit appends a decimal digit to the current entry buffer. +// EnterDigit appends a digit to the current entry buffer. +// For HEX base, accepts 0-9 and A-F (case-insensitive). +// For other bases (tight scope: only DEC for now), accepts 0-9. func (e *Engine) EnterDigit(d rune) { - if e.entry == "0" || e.entry == "" { - e.entry = string(d) - } else { - e.entry += string(d) + d = unicode.ToUpper(d) + + if e.base == BaseHEX { + if (d >= '0' && d <= '9') || (d >= 'A' && d <= 'F') { + if e.entry == "0" || e.entry == "" { + e.entry = string(d) + } else { + e.entry += string(d) + } + } + return + } + + // Default (DEC): decimal digits only (validate to keep entry clean) + if (d >= '0' && d <= '9') { + if e.entry == "0" || e.entry == "" { + e.entry = string(d) + } else { + e.entry += string(d) + } } } // EnterDecimalPoint adds a '.' to the entry if one is not already present. +// Disabled for non-DEC bases in tight scope (HEX entry is integer-only). func (e *Engine) EnterDecimalPoint() { + if e.base != BaseDEC { + return + } if !strings.Contains(e.entry, ".") { if e.entry == "" { e.entry = "0." diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index 6f7e294..3d5de27 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -130,3 +130,80 @@ func TestChangeSign(t *testing.T) { // More tests (entry buffer, multi-op, decimal point, etc.) will be added as the // TUI spike exercises real usage. The CERR path is the critical one for BASE. + +// === Tight-scope HEX entry tests === + +func TestEnterDigit_HEX(t *testing.T) { + e := NewEngine() + e.base = BaseHEX + e.EnterDigit('1') + e.EnterDigit('a') + e.EnterDigit('F') + if got := e.FormatForDisplay(); got != "1AF" { + t.Errorf("HEX entry display: want 1AF, got %s", got) + } + // Commit and check value (1AF hex = 431 decimal) + e.SetOperator("+") + e.EnterDigit('1') // simple commit + e.Equals() + if got := e.FormatForDisplay(); got != "432" { + t.Errorf("after hex commit +1: want 432, got %s", got) + } +} + +func TestEnterDigit_HEX_IgnoresDecimal(t *testing.T) { + e := NewEngine() + e.base = BaseHEX + e.EnterDigit('1') + e.EnterDecimalPoint() // should be ignored + e.EnterDigit('A') + if got := e.FormatForDisplay(); got != "1A" { + t.Errorf("HEX should ignore dot: want 1A, got %s", got) + } +} + +func TestEnterDigit_HEX_OnlyInHEXMode(t *testing.T) { + e := NewEngine() // starts in DEC + e.EnterDigit('1') + e.EnterDigit('A') // 'A' ignored in DEC (now validated) + if got := e.FormatForDisplay(); got != "1" { + t.Errorf("A ignored in DEC: want 1, got %s", got) + } + + e.ClearEntry() + e.base = BaseHEX + e.EnterDigit('A') + if got := e.FormatForDisplay(); got != "A" { + t.Errorf("A accepted in HEX: want A, got %s", got) + } +} + +func TestCycleBase_DuringHEXEntry(t *testing.T) { + e := NewEngine() + e.base = BaseHEX + e.EnterDigit('1') + e.EnterDigit('0') // 16 decimal + + // Tab while in entry (from HEX): commit the hex value (16) then switch to next (BIN in this case) + err := e.CycleBase() + if err != nil { + t.Fatalf("unexpected CERR during integer hex entry: %v", err) + } + if e.CurrentBase() != BaseBIN { + t.Errorf("expected BIN after one cycle from HEX, got %s", e.CurrentBase()) + } + // Display shows the committed value in the new base + if got := e.FormatForDisplay(); got != "10000" { + t.Errorf("committed hex value (16) shown in BIN: want 10000, got %s", got) + } + + // Verify the *numeric* value was correctly parsed from hex entry (16 decimal) + // by doing arithmetic and checking (even though display is in BIN) + e.SetOperator("+") + e.EnterDigit('1') + e.Equals() + // 16 + 1 = 17. In BIN that's 10001 + if got := e.FormatForDisplay(); got != "10001" { + t.Errorf("after hex-committed +1 (display in BIN): want 10001, got %s", got) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5e6f897..08586e7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,6 +1,7 @@ package ui import ( + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -118,6 +119,17 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.pressedKey = msg.String() return a, tick(140*time.Millisecond, "key") + // Tight-scope HEX entry: A-F (and a-f) only accepted while the base + // indicator says HEX. We pass the rune (uppercased inside engine). + // No visual buttons for A-F in this scope — keyboard only. + case "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F": + if a.engine.CurrentBase() == calc.BaseHEX { + a.engine.EnterDigit(rune(msg.String()[0])) + a.pressedKey = strings.ToUpper(msg.String()) + return a, tick(140*time.Millisecond, "key") + } + // In other bases: ignore (A-F reserved for future HEX entry) + case "delete", "del": // All Clear a.engine.AllClear() a.flash = "clear"