diff --git a/Makefile b/Makefile index d89fdd1..58267c3 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -51,3 +51,23 @@ help: @echo " make test - run all tests with race detector" @echo " make test-short - short tests with race" @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 diff --git a/internal/calc/calc.go b/internal/calc/calc.go index 2cfdc57..e3a2cfc 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -189,7 +189,7 @@ func (e *Engine) EnterDigit(d rune) { } // 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 == "" { e.entry = string(d) } else { @@ -229,7 +229,7 @@ func (e *Engine) Equals() { right := e.currentNumericValue() result := e.applyPending(e.pendingLeft, right, e.pendingOp) e.accumulator = result - e.entry = formatResultEntry(result) + e.entry = formatResultEntryForBase(result, e.base) e.pendingOp = "" 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 { if math.IsInf(v, 0) || math.IsNaN(v) { return "0" @@ -272,13 +272,40 @@ func formatResultEntry(v float64) string { 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 = formatResultEntry(result) + e.entry = formatResultEntryForBase(result, e.base) e.pendingOp = "" } @@ -307,7 +334,7 @@ func (e *Engine) ChangeSign() { return } 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. diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index 8bebd1e..9f0d86a 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -274,8 +274,9 @@ func TestEnterDigit_HEX(t *testing.T) { 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) + // Result is formatted according to the current base (HEX), so 431+1 = 1B0 hex not decimal 432. + if got := e.FormatForDisplay(); got != "1B0" { + t.Errorf("after hex commit +1: want 1B0, got %s", got) } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6f94c5c..36cc06b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -233,7 +233,7 @@ func (a *App) View() string { numKey := keyStyle.Copy() opKey := keyStyle.Copy().Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235")) clearKey := keyStyle.Copy().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52")) - modKey := keyStyle.Copy().Foreground(lipgloss.Color("214")) // orange-ish for MOD + modKey := keyStyle.Copy().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 makeKey := func(label string) string { @@ -278,6 +278,34 @@ func (a *App) View() string { hexRow := lipgloss.JoinHorizontal(lipgloss.Top, 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) } else { // 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. // No enclosing container — just the individually styled keys. // When in HEX the A-F row appears at the top of the keypad. - keypad := lipgloss.NewStyle(). - Width(dispW). - Align(lipgloss.Center). - Render(rawGrid) + // Explicit per-line centering of the entire rawGrid (the stacked hex+arith rows, + // where arith rows are already internally padded to hexW) into dispW. + // We do this manually instead of lipgloss.NewStyle().Width(dispW).Align(Center).Render + // 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 := lipgloss.NewStyle().Faint(true).Render("Tab:BASE m:MOD BackSpace:C Del:AC") diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 49c4373..5ee6691 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -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. -// The tick func is also package-private but we test via direct messages where possible. \ No newline at end of file +// The tick func is also package-private but we test via direct messages where possible. diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 3637a94..3e44d91 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -21,4 +21,4 @@ func TestString(t *testing.T) { // reset Version = "dev" Commit = "" -} \ No newline at end of file +}