Compare commits
No commits in common. "master" and "v0.2.0" have entirely different histories.
22
Makefile
22
Makefile
@ -1,4 +1,4 @@
|
|||||||
.PHONY: test test-short test-cover build install clean lint cross help
|
.PHONY: test test-short test-cover build install clean help
|
||||||
|
|
||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
|
|
||||||
@ -51,23 +51,3 @@ help:
|
|||||||
@echo " make test - run all tests with race detector"
|
@echo " make test - run all tests with race detector"
|
||||||
@echo " make test-short - short tests with race"
|
@echo " make test-short - short tests with race"
|
||||||
@echo " make clean - remove build/"
|
@echo " make clean - remove build/"
|
||||||
@echo " make lint - run golangci-lint (requires golangci-lint in PATH; CI uses action)"
|
|
||||||
@echo " make cross - cross-compile for linux/darwin/windows (amd64/arm64) for releases"
|
|
||||||
|
|
||||||
lint:
|
|
||||||
@command -v golangci-lint >/dev/null 2>&1 || { echo >&2 "golangci-lint not found in PATH (install via 'go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest' or rely on CI)"; exit 1; }
|
|
||||||
golangci-lint run ./...
|
|
||||||
|
|
||||||
cross: deps
|
|
||||||
@mkdir -p build
|
|
||||||
@echo "Cross-compiling gralculator (VERSION=$(VERSION) COMMIT=$(COMMIT))..."
|
|
||||||
@for os in linux darwin windows; do \
|
|
||||||
for arch in amd64 arm64; do \
|
|
||||||
ext=""; if [ "$$os" = "windows" ]; then ext=".exe"; fi; \
|
|
||||||
out="build/gralculator-$$os-$$arch$$ext"; \
|
|
||||||
echo " $$os/$$arch -> $$out"; \
|
|
||||||
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o "$$out" . || exit 1; \
|
|
||||||
done; \
|
|
||||||
done
|
|
||||||
@echo "✅ Cross builds complete:"
|
|
||||||
@ls -lh build/gralculator-* 2>/dev/null | cat || true
|
|
||||||
|
|||||||
@ -189,7 +189,7 @@ func (e *Engine) EnterDigit(d rune) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default (DEC): decimal digits only (validate to keep entry clean)
|
// Default (DEC): decimal digits only (validate to keep entry clean)
|
||||||
if d >= '0' && d <= '9' {
|
if (d >= '0' && d <= '9') {
|
||||||
if e.entry == "0" || e.entry == "" {
|
if e.entry == "0" || e.entry == "" {
|
||||||
e.entry = string(d)
|
e.entry = string(d)
|
||||||
} else {
|
} else {
|
||||||
@ -229,7 +229,7 @@ func (e *Engine) Equals() {
|
|||||||
right := e.currentNumericValue()
|
right := e.currentNumericValue()
|
||||||
result := e.applyPending(e.pendingLeft, right, e.pendingOp)
|
result := e.applyPending(e.pendingLeft, right, e.pendingOp)
|
||||||
e.accumulator = result
|
e.accumulator = result
|
||||||
e.entry = formatResultEntryForBase(result, e.base)
|
e.entry = formatResultEntry(result)
|
||||||
e.pendingOp = ""
|
e.pendingOp = ""
|
||||||
e.pendingLeft = 0
|
e.pendingLeft = 0
|
||||||
}
|
}
|
||||||
@ -255,7 +255,7 @@ func (e *Engine) applyPending(left, right float64, op string) float64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatResultEntry produces a clean entry string for a committed result (DEC).
|
// formatResultEntry produces a clean entry string for a committed result.
|
||||||
func formatResultEntry(v float64) string {
|
func formatResultEntry(v float64) string {
|
||||||
if math.IsInf(v, 0) || math.IsNaN(v) {
|
if math.IsInf(v, 0) || math.IsNaN(v) {
|
||||||
return "0"
|
return "0"
|
||||||
@ -272,40 +272,13 @@ func formatResultEntry(v float64) string {
|
|||||||
return s
|
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
|
// Mod performs immediate modulo using the current entry as the right operand
|
||||||
// (useful for a dedicated MOD button that acts like = for modulo).
|
// (useful for a dedicated MOD button that acts like = for modulo).
|
||||||
func (e *Engine) Mod() {
|
func (e *Engine) Mod() {
|
||||||
right := e.currentNumericValue()
|
right := e.currentNumericValue()
|
||||||
result := math.Mod(e.accumulator, right)
|
result := math.Mod(e.accumulator, right)
|
||||||
e.accumulator = result
|
e.accumulator = result
|
||||||
e.entry = formatResultEntryForBase(result, e.base)
|
e.entry = formatResultEntry(result)
|
||||||
e.pendingOp = ""
|
e.pendingOp = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,7 +307,7 @@ func (e *Engine) ChangeSign() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.accumulator = -e.accumulator
|
e.accumulator = -e.accumulator
|
||||||
e.entry = formatResultEntryForBase(e.accumulator, e.base)
|
e.entry = formatResultEntry(e.accumulator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backspace removes the last character from the current entry.
|
// Backspace removes the last character from the current entry.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package calc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -239,11 +240,8 @@ func TestNegativeInBases(t *testing.T) {
|
|||||||
func TestFormatLargeAndScientific(t *testing.T) {
|
func TestFormatLargeAndScientific(t *testing.T) {
|
||||||
e := NewEngine()
|
e := NewEngine()
|
||||||
e.accumulator = 1e15
|
e.accumulator = 1e15
|
||||||
got := e.FormatForDisplay()
|
if got := e.FormatForDisplay(); !strings.Contains(got, "e") && !strings.Contains(got, "E") {
|
||||||
// Large values may or may not use scientific notation (depending on FormatForDisplay
|
// may or not, but check no panic
|
||||||
// rules and current base); the primary purpose of this test is to ensure no panic.
|
|
||||||
if got == "" {
|
|
||||||
t.Error("FormatForDisplay returned empty for large accumulator value")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,9 +274,8 @@ func TestEnterDigit_HEX(t *testing.T) {
|
|||||||
e.SetOperator("+")
|
e.SetOperator("+")
|
||||||
e.EnterDigit('1') // simple commit
|
e.EnterDigit('1') // simple commit
|
||||||
e.Equals()
|
e.Equals()
|
||||||
// Result is formatted according to the current base (HEX), so 431+1 = 1B0 hex not decimal 432.
|
if got := e.FormatForDisplay(); got != "432" {
|
||||||
if got := e.FormatForDisplay(); got != "1B0" {
|
t.Errorf("after hex commit +1: want 432, got %s", got)
|
||||||
t.Errorf("after hex commit +1: want 1B0, got %s", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -230,11 +230,11 @@ func (a *App) View() string {
|
|||||||
Align(lipgloss.Center)
|
Align(lipgloss.Center)
|
||||||
|
|
||||||
// Specialized key styles for visual grouping (like real calculators).
|
// Specialized key styles for visual grouping (like real calculators).
|
||||||
numKey := keyStyle
|
numKey := keyStyle.Copy()
|
||||||
opKey := keyStyle.Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235"))
|
opKey := keyStyle.Copy().Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235"))
|
||||||
clearKey := keyStyle.Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52"))
|
clearKey := keyStyle.Copy().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52"))
|
||||||
modKey := keyStyle.Foreground(lipgloss.Color("214")) // orange-ish for MOD
|
modKey := keyStyle.Copy().Foreground(lipgloss.Color("214")) // orange-ish for MOD
|
||||||
hexKey := keyStyle.Foreground(lipgloss.Color("214")).Background(lipgloss.Color("235")) // for A-F, only shown in HEX mode
|
hexKey := keyStyle.Copy().Foreground(lipgloss.Color("214")).Background(lipgloss.Color("235")) // for A-F, only shown in HEX mode
|
||||||
|
|
||||||
makeKey := func(label string) string {
|
makeKey := func(label string) string {
|
||||||
var st lipgloss.Style
|
var st lipgloss.Style
|
||||||
@ -278,34 +278,6 @@ func (a *App) View() string {
|
|||||||
hexRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
hexRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
hexBtn("A"), spacer, hexBtn("B"), spacer, hexBtn("C"), spacer, hexBtn("D"), spacer, hexBtn("E"), spacer, hexBtn("F"),
|
hexBtn("A"), spacer, hexBtn("B"), spacer, hexBtn("C"), spacer, hexBtn("D"), spacer, hexBtn("E"), spacer, hexBtn("F"),
|
||||||
)
|
)
|
||||||
hexW := lipgloss.Width(hexRow)
|
|
||||||
// Robust per-line centering of the pre-joined 5-key rows under the 6-key hex row.
|
|
||||||
// We pad each individual line of the row block explicitly. This avoids any subtle
|
|
||||||
// artifacts from Style.Width+Align or PlaceHorizontal when the input is already a
|
|
||||||
// multi-line string containing ANSI border sequences. Ensures every line (including
|
|
||||||
// the bottom border lines of the last row) has exactly the same width so vertical
|
|
||||||
// borders and bottom edges line up cleanly with no dangling fragments or splits.
|
|
||||||
centerUnderHex := func(block string) string {
|
|
||||||
if block == "" {
|
|
||||||
return block
|
|
||||||
}
|
|
||||||
lines := strings.Split(block, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
cw := lipgloss.Width(line)
|
|
||||||
if cw >= hexW {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pad := hexW - cw
|
|
||||||
left := pad / 2
|
|
||||||
right := pad - left
|
|
||||||
lines[i] = strings.Repeat(" ", left) + line + strings.Repeat(" ", right)
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
row1 = centerUnderHex(row1)
|
|
||||||
row2 = centerUnderHex(row2)
|
|
||||||
row3 = centerUnderHex(row3)
|
|
||||||
row4 = centerUnderHex(row4)
|
|
||||||
rawGrid = lipgloss.JoinVertical(lipgloss.Left, hexRow, row1, row2, row3, row4)
|
rawGrid = lipgloss.JoinVertical(lipgloss.Left, hexRow, row1, row2, row3, row4)
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: standard 4-row grid (no A-F)
|
// Normal mode: standard 4-row grid (no A-F)
|
||||||
@ -315,24 +287,10 @@ func (a *App) View() string {
|
|||||||
// Center the key grid directly under the display.
|
// Center the key grid directly under the display.
|
||||||
// No enclosing container — just the individually styled keys.
|
// No enclosing container — just the individually styled keys.
|
||||||
// When in HEX the A-F row appears at the top of the keypad.
|
// When in HEX the A-F row appears at the top of the keypad.
|
||||||
// Explicit per-line centering of the entire rawGrid (the stacked hex+arith rows,
|
keypad := lipgloss.NewStyle().
|
||||||
// where arith rows are already internally padded to hexW) into dispW.
|
Width(dispW).
|
||||||
// We do this manually instead of lipgloss.NewStyle().Width(dispW).Align(Center).Render
|
Align(lipgloss.Center).
|
||||||
// because the latter was producing inconsistent left padding on certain lines
|
Render(rawGrid)
|
||||||
// (especially bottom border lines containing box-drawing chars + heavy ANSI),
|
|
||||||
// causing the bottoms of the last row's keys to shift 1-2 columns relative to
|
|
||||||
// their own tops and sides.
|
|
||||||
kpLines := strings.Split(rawGrid, "\n")
|
|
||||||
for j := range kpLines {
|
|
||||||
cw := lipgloss.Width(kpLines[j])
|
|
||||||
if cw < dispW {
|
|
||||||
p := dispW - cw
|
|
||||||
l := p / 2
|
|
||||||
r := p - l
|
|
||||||
kpLines[j] = strings.Repeat(" ", l) + kpLines[j] + strings.Repeat(" ", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keypad := strings.Join(kpLines, "\n")
|
|
||||||
|
|
||||||
// Hint (minimal, non-wrapping, like gostations final player)
|
// Hint (minimal, non-wrapping, like gostations final player)
|
||||||
hint := lipgloss.NewStyle().Faint(true).Render("Tab:BASE m:MOD BackSpace:C Del:AC")
|
hint := lipgloss.NewStyle().Faint(true).Render("Tab:BASE m:MOD BackSpace:C Del:AC")
|
||||||
|
|||||||
@ -165,4 +165,4 @@ func contains(s, substr string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: clearFlashMsg is defined in ui.go (same package), so we can send it directly in tests.
|
// Note: clearFlashMsg is defined in ui.go (same package), so we can send it directly in tests.
|
||||||
// The tick func is also package-private but we test via direct messages where possible.
|
// The tick func is also package-private but we test via direct messages where possible.
|
||||||
@ -21,4 +21,4 @@ func TestString(t *testing.T) {
|
|||||||
// reset
|
// reset
|
||||||
Version = "dev"
|
Version = "dev"
|
||||||
Commit = ""
|
Commit = ""
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user