Compare commits

..

2 Commits

Author SHA1 Message Date
Grok
700e70a355 fix: resolve golangci-lint v2.1.6 staticcheck failures
All checks were successful
CI / Test (push) Successful in 17s
Release / Create Release (push) Successful in 1m33s
CI / Lint (push) Successful in 22s
CI / Build (push) Successful in 20s
- SA9003 (empty branch): rewrote TestFormatLargeAndScientific to actually
  call FormatForDisplay and perform a minimal non-empty assertion instead of
  an if-with-no-body.
- SA1019 (deprecated keyStyle.Copy()): replaced all .Copy() with direct
  assignment or method chaining on the base keyStyle. lipgloss styles are
  immutable and their builder methods already return new instances.
- Removed now-unused "strings" import from calc_test.go.
- All changes pass go test -short, go build, go vet, and gofmt.

This makes the Lint job (which runs golangci-lint-action + staticcheck) green.
The v0.3.0 tag was already pushed from the prior commit; this is master
hygiene so future CI + any follow-up tags are clean.
2026-06-06 17:09:43 +01:00
Grok
17303d1446 feat(calc,ui): complete tight-mode HEX entry with cross-base chaining and result formatting
Some checks failed
CI / Test (push) Successful in 16s
Release / Create Release (push) Failing after 2m1s
CI / Build (push) Has been skipped
CI / Lint (push) Failing after 26s
- 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).
2026-06-06 17:04:11 +01:00
6 changed files with 114 additions and 22 deletions

View File

@ -1,4 +1,4 @@
.PHONY: test test-short test-cover build install clean help .PHONY: test test-short test-cover build install clean lint cross help
SHELL := /bin/bash SHELL := /bin/bash
@ -51,3 +51,23 @@ 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

View File

@ -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 = formatResultEntry(result) e.entry = formatResultEntryForBase(result, e.base)
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. // formatResultEntry produces a clean entry string for a committed result (DEC).
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,13 +272,40 @@ 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 = formatResultEntry(result) e.entry = formatResultEntryForBase(result, e.base)
e.pendingOp = "" e.pendingOp = ""
} }
@ -307,7 +334,7 @@ func (e *Engine) ChangeSign() {
return return
} }
e.accumulator = -e.accumulator e.accumulator = -e.accumulator
e.entry = formatResultEntry(e.accumulator) e.entry = formatResultEntryForBase(e.accumulator, e.base)
} }
// Backspace removes the last character from the current entry. // Backspace removes the last character from the current entry.

View File

@ -2,7 +2,6 @@ package calc
import ( import (
"errors" "errors"
"strings"
"testing" "testing"
) )
@ -240,8 +239,11 @@ 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
if got := e.FormatForDisplay(); !strings.Contains(got, "e") && !strings.Contains(got, "E") { got := e.FormatForDisplay()
// may or not, but check no panic // Large values may or may not use scientific notation (depending on FormatForDisplay
// 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")
} }
} }
@ -274,8 +276,9 @@ func TestEnterDigit_HEX(t *testing.T) {
e.SetOperator("+") e.SetOperator("+")
e.EnterDigit('1') // simple commit e.EnterDigit('1') // simple commit
e.Equals() e.Equals()
if got := e.FormatForDisplay(); got != "432" { // Result is formatted according to the current base (HEX), so 431+1 = 1B0 hex not decimal 432.
t.Errorf("after hex commit +1: want 432, got %s", got) if got := e.FormatForDisplay(); got != "1B0" {
t.Errorf("after hex commit +1: want 1B0, got %s", got)
} }
} }

View File

@ -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.Copy() numKey := keyStyle
opKey := keyStyle.Copy().Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235")) opKey := keyStyle.Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235"))
clearKey := keyStyle.Copy().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52")) clearKey := keyStyle.Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52"))
modKey := keyStyle.Copy().Foreground(lipgloss.Color("214")) // orange-ish for MOD modKey := keyStyle.Foreground(lipgloss.Color("214")) // orange-ish for MOD
hexKey := keyStyle.Copy().Foreground(lipgloss.Color("214")).Background(lipgloss.Color("235")) // for A-F, only shown in HEX mode hexKey := keyStyle.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,6 +278,34 @@ 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)
@ -287,10 +315,24 @@ 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.
keypad := lipgloss.NewStyle(). // Explicit per-line centering of the entire rawGrid (the stacked hex+arith rows,
Width(dispW). // where arith rows are already internally padded to hexW) into dispW.
Align(lipgloss.Center). // We do this manually instead of lipgloss.NewStyle().Width(dispW).Align(Center).Render
Render(rawGrid) // because the latter was producing inconsistent left padding on certain lines
// (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")

View File

@ -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.

View File

@ -21,4 +21,4 @@ func TestString(t *testing.T) {
// reset // reset
Version = "dev" Version = "dev"
Commit = "" Commit = ""
} }