package calc import ( "errors" "fmt" "math" "strconv" "strings" ) // Base represents the current display formatting mode. type Base string const ( BaseDEC Base = "DEC" BaseHEX Base = "HEX" BaseBIN Base = "BIN" BaseOCT Base = "OCT" ) var basesCycle = []Base{BaseDEC, BaseHEX, BaseBIN, BaseOCT} // ErrConversionNotPossible is returned by CycleBase when the current value // has a fractional part and cannot be cleanly displayed in a non-DEC base. var ErrConversionNotPossible = errors.New("conversion not possible (CERR)") // Engine holds the calculator state. All math is decimal (float64). // Bases affect only formatting via FormatForDisplay and the small row indicator. // Entry model: classic "in-progress entry buffer" (you see what you are typing). type Engine struct { // committed accumulator (result of previous operations) accumulator float64 // current in-progress entry as string (for display and for next operand) entry string // pending operator to apply when next operand is committed ("", "+", "-", "*", "/", "mod") pendingOp string // the left-hand value for the pending operation pendingLeft float64 base Base } // NewEngine creates a fresh calculator engine starting in DEC with 0. func NewEngine() *Engine { return &Engine{ accumulator: 0, entry: "0", base: BaseDEC, } } // CurrentBase returns the active display base for the small row. func (e *Engine) CurrentBase() Base { return e.base } // IsInteger reports whether the current *displayed* numeric value is effectively an integer // (within epsilon to tolerate fp noise from operations like 1/3 or 0.1+0.2). // We use the committed accumulator when there is no active entry, otherwise the parsed entry. func (e *Engine) IsInteger() bool { val := e.currentNumericValue() _, frac := math.Modf(val) return math.Abs(frac) < 1e-10 } // currentNumericValue returns the number that should be considered "current" // for operations and for BASE cycling / formatting. func (e *Engine) currentNumericValue() float64 { if e.entry != "" && e.entry != "0" { if v, err := strconv.ParseFloat(e.entry, 64); err == nil { return v } } return e.accumulator } // 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. func (e *Engine) CycleBase() error { if !e.IsInteger() { return ErrConversionNotPossible } for i, b := range basesCycle { if b == e.base { e.base = basesCycle[(i+1)%len(basesCycle)] return nil } } e.base = BaseDEC return nil } // 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 != "" { return e.entry } // For DEC committed results, show a reasonable representation. if math.Abs(val) < 1e12 { return strconv.FormatFloat(val, 'f', -1, 64) } 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. i := int64(val) if i < 0 { i = 0 // MVP: treat negative as 0 for non-DEC display } switch e.base { case BaseHEX: return strings.ToUpper(strconv.FormatInt(i, 16)) case BaseBIN: return strconv.FormatInt(i, 2) case BaseOCT: return strconv.FormatInt(i, 8) default: return strconv.FormatFloat(val, 'f', -1, 64) } } // EnterDigit appends a decimal digit to the current entry buffer. func (e *Engine) EnterDigit(d rune) { 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. func (e *Engine) EnterDecimalPoint() { if !strings.Contains(e.entry, ".") { if e.entry == "" { e.entry = "0." } else { e.entry += "." } } } // SetOperator commits the current entry (if any) as the left operand and // records the operator for the next Equals / next operator. func (e *Engine) SetOperator(op string) { // Commit whatever is in the entry as the left side for this op. left := e.currentNumericValue() e.pendingLeft = left e.pendingOp = op e.entry = "0" // prepare for right-hand side entry e.accumulator = left } // Equals / commit the pending operation using the current entry as right-hand side. func (e *Engine) Equals() { right := e.currentNumericValue() result := e.applyPending(e.pendingLeft, right, e.pendingOp) e.accumulator = result e.entry = formatResultEntry(result) e.pendingOp = "" e.pendingLeft = 0 } // applyPending performs the arithmetic for the given operator. func (e *Engine) applyPending(left, right float64, op string) float64 { switch op { case "+": return left + right case "-": return left - right case "*": return left * right case "/": if right == 0 { return 0 // MVP: avoid panic, could later surface "ERR" } return left / right case "mod", "MOD": return math.Mod(left, right) default: return right } } // formatResultEntry produces a clean entry string for a committed result. func formatResultEntry(v float64) string { if math.IsInf(v, 0) || math.IsNaN(v) { return "0" } s := strconv.FormatFloat(v, 'f', -1, 64) // Trim trailing .000 etc for cleanliness while keeping decimal when needed. if strings.Contains(s, ".") { s = strings.TrimRight(s, "0") s = strings.TrimRight(s, ".") } if s == "" { s = "0" } return s } // Mod performs immediate modulo using the current entry as the right operand // (useful for a dedicated MOD button that acts like = for modulo). func (e *Engine) Mod() { right := e.currentNumericValue() result := math.Mod(e.accumulator, right) e.accumulator = result e.entry = formatResultEntry(result) e.pendingOp = "" } // ClearEntry clears only the current entry buffer (C button). func (e *Engine) ClearEntry() { e.entry = "0" } // AllClear resets everything (AC button). func (e *Engine) AllClear() { e.accumulator = 0 e.entry = "0" e.pendingOp = "" e.pendingLeft = 0 e.base = BaseDEC // or keep current base? MVP resets for simplicity; can relax later } // ChangeSign toggles the sign of the current entry (or accumulator if no entry). func (e *Engine) ChangeSign() { if e.entry != "" && e.entry != "0" { if strings.HasPrefix(e.entry, "-") { e.entry = strings.TrimPrefix(e.entry, "-") } else { e.entry = "-" + e.entry } return } e.accumulator = -e.accumulator e.entry = formatResultEntry(e.accumulator) } // Backspace removes the last character from the current entry. func (e *Engine) Backspace() { if len(e.entry) <= 1 { e.entry = "0" return } e.entry = e.entry[:len(e.entry)-1] if e.entry == "" || e.entry == "-" { e.entry = "0" } } // TODO for future: support for the exact "pending on display" formatting // when a base change happens mid-operation (reformat the left operand).