- Add formatResultEntryForBase + update Equals/Mod/ChangeSign so committed results format in the active display base (e.g. HEX shows "C8", "1B0" not decimal). - Robust per-line centering for dynamic A-F hexRow and keypad rows under display (prevents split key borders/decorations on bottom row). - Updated test expectation for HEX result digits. - Enables validated cross-base flow: DEC entry, Tab to HEX, continue op, see result in active base, Tab back converts displayed value correctly. - style: gofmt -s all touched sources (ui_test.go, version_test.go etc.). - ci: add lint and cross targets to Makefile. - cross produces the 6 platform binaries expected by release.yml on v* tags. - lint target for local parity (CI continues to use golangci-lint-action). This is the v0.3.0 release-ready state (user-validated: 100 DEC -> Tab HEX +64 = C8 -> Tab DEC 200).
354 lines
9.7 KiB
Go
354 lines
9.7 KiB
Go
package calc
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// 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.
|
|
// 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" {
|
|
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
|
|
}
|
|
|
|
// 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)]
|
|
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 {
|
|
// 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)
|
|
}
|
|
return strconv.FormatFloat(val, 'e', -1, 64)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 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) {
|
|
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."
|
|
} 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 = formatResultEntryForBase(result, e.base)
|
|
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 (DEC).
|
|
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
|
|
}
|
|
|
|
// formatResultEntryForBase produces the result string formatted for the current display base.
|
|
// For HEX/BIN/OCT this yields the appropriate integer digit string (so that FormatForDisplay's
|
|
// raw-entry short-circuit for HEX and currentNumericValue's base-aware parsing both see correct
|
|
// digits for the result). This makes results appear in the active base (e.g. 1FFE not 8190 in HEX).
|
|
func formatResultEntryForBase(v float64, b Base) string {
|
|
if b == BaseDEC {
|
|
return formatResultEntry(v)
|
|
}
|
|
if math.IsInf(v, 0) || math.IsNaN(v) {
|
|
return "0"
|
|
}
|
|
i := int64(v)
|
|
if i < 0 {
|
|
i = 0
|
|
}
|
|
switch b {
|
|
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 formatResultEntry(v)
|
|
}
|
|
}
|
|
|
|
// 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 = formatResultEntryForBase(result, e.base)
|
|
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 = formatResultEntryForBase(e.accumulator, e.base)
|
|
}
|
|
|
|
// 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).
|