diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d89fdd1 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +.PHONY: test test-short test-cover build install clean help + +SHELL := /bin/bash + +# Versioning (override on command line or via env for releases) +VERSION ?= dev-$(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) + +MODULE = github.com/gmgauthier/gralculator +LDFLAGS = -s -w \ + -X '$(MODULE)/internal/version.Version=$(VERSION)' \ + -X '$(MODULE)/internal/version.Commit=$(COMMIT)' \ + -X '$(MODULE)/internal/version.BuildDate=$(DATE)' \ + -X 'main.version=$(VERSION)' + +test: deps + go test ./... -v -race + +test-short: deps + go test -short ./... -v -race + +test-cover: deps + @mkdir -p build + go test ./... -coverprofile=build/coverage.out + go tool cover -html=build/coverage.out -o build/coverage.html + @echo "✅ Coverage report: open build/coverage.html in your browser" + +deps: + go mod download + +build: deps + @mkdir -p build + go build -trimpath -ldflags "$(LDFLAGS)" -o build/gralculator . + @echo "✅ Dev build: VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE)" + @build/gralculator -v || true + +install: build + mkdir -p ~/.local/bin + cp build/gralculator ~/.local/bin/gralculator + chmod +x ~/.local/bin/gralculator + @echo "✅ gralculator installed to ~/.local/bin/gralculator" + +clean: + rm -rf build/ + +help: + @echo "Common targets:" + @echo " make build - build dev binary to build/gralculator" + @echo " make install - build and install to ~/.local/bin/gralculator" + @echo " make test - run all tests with race detector" + @echo " make test-short - short tests with race" + @echo " make clean - remove build/" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0ec1a3d --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/gmgauthier/gralculator + +go 1.24.2 + +require ( + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/lipgloss v0.11.0 +) diff --git a/internal/calc/calc.go b/internal/calc/calc.go new file mode 100644 index 0000000..2f0099c --- /dev/null +++ b/internal/calc/calc.go @@ -0,0 +1,87 @@ +package calc + +import ( + "errors" + "math" +) + +// 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. +type Engine struct { + value float64 + base Base + pendingOp string // "+", "-", "*", "/", "mod", or "" + pendingVal float64 + // entry buffer / more state will be added in engine implementation +} + +// NewEngine creates a fresh calculator engine starting in DEC. +func NewEngine() *Engine { + return &Engine{ + value: 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 value is effectively an integer +// (within epsilon to tolerate fp noise from operations like 1/3). +func (e *Engine) IsInteger() bool { + _, frac := math.Modf(e.value) + return math.Abs(frac) < 1e-10 +} + +// CycleBase advances to the next base in the cycle (DEC→HEX→BIN→OCT→DEC). +// Returns ErrConversionNotPossible (and does not change base) if the value +// has a fractional part. The caller (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 + } + } + // fallback + e.base = BaseDEC + return nil +} + +// FormatForDisplay returns the string to show in the large number area +// according to the current base. For MVP only integer values are expected +// in non-DEC bases (callers should have checked via CycleBase or IsInteger). +func (e *Engine) FormatForDisplay() string { + // Placeholder implementation. Real version in phase 2 will handle + // proper integer formatting for hex/bin/oct (no 0x prefix, etc.) + // and fall back gracefully. + if e.base == BaseDEC { + // simple for skeleton + return "0" + } + return "0" +} + +// TODO (phase 2): Add EnterDigit, SetOperator, Equals, Mod, ClearEntry, +// AllClear, ChangeSign, Backspace, etc. + full FormatForDisplay. diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go new file mode 100644 index 0000000..75da8ab --- /dev/null +++ b/internal/calc/calc_test.go @@ -0,0 +1,44 @@ +package calc + +import ( + "errors" + "testing" +) + +func TestNewEngine_DefaultsToDEC(t *testing.T) { + e := NewEngine() + if e.CurrentBase() != BaseDEC { + t.Errorf("expected DEC, got %s", e.CurrentBase()) + } + if !e.IsInteger() { + t.Error("0 should be integer") + } +} + +func TestCycleBase_IntegerOK(t *testing.T) { + e := NewEngine() + e.value = 42 // integer + + if err := e.CycleBase(); err != nil { + t.Fatalf("unexpected error cycling from integer: %v", err) + } + if e.CurrentBase() != BaseHEX { + t.Errorf("expected HEX after one cycle, got %s", e.CurrentBase()) + } +} + +func TestCycleBase_FractionalCERR(t *testing.T) { + e := NewEngine() + e.value = 23.0 / 6.0 // 3.8333... + + err := e.CycleBase() + if !errors.Is(err, ErrConversionNotPossible) { + t.Fatalf("expected ErrConversionNotPossible, got %v", err) + } + if e.CurrentBase() != BaseDEC { + t.Error("base must not change on CERR") + } +} + +// TODO (phase 2): more tests for FormatForDisplay (hex/bin/oct of integers), +// ops, clears, entry, combined fractional + multiple BASE presses, etc. diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..551a352 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,67 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gmgauthier/gralculator/internal/calc" +) + +// App is the root Bubble Tea model for the gralculator TUI. +// Phase 1/3 skeleton only — real implementation in phase 3. +type App struct { + engine *calc.Engine + width int + height int + + // flash state etc. will be added during spike (see gostations patterns) +} + +func NewApp() *App { + return &App{ + engine: calc.NewEngine(), + } +} + +func (a *App) Init() tea.Cmd { + return nil +} + +func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return a, tea.Quit + case "tab": + // Will drive engine.CycleBase() + possible CERR flash + _ = a.engine.CycleBase() + // TODO (phase 3): trigger flash on error, re-render small row + } + } + return a, nil +} + +func (a *App) View() string { + // Placeholder view. Real two-row display + keypad grid in phase 3. + // Will use lipgloss heavily for: + // - large number area (tall + styled) + // - small current-base row (highlighted label) + // - centered content card (lipgloss.Place) + // - button cells for keypad (including single "BASE") + // - flash styles (140ms color 63) + // - minimal hint row + base := a.engine.CurrentBase() + return lipgloss.NewStyle().Padding(1, 2).Render( + "gralculator skeleton\n\n" + + "Large number area (TODO)\n" + + "Current base: " + string(base) + " (press Tab to cycle)\n\n" + + "See docs/UI_DESIGN.md and spec.md", + ) +} + +// TODO (phase 3): full model with flashes, proper display rendering, +// keypad grid, Tab + other key handling wired to engine, resize/centering. diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..4fcca69 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,17 @@ +package version + +// These vars are set at build time via -ldflags (see Makefile), e.g. +// -ldflags "-X github.com/gmgauthier/gralculator/internal/version.Version=0.1.0 -X .../Commit=... -X .../BuildDate=..." +var ( + Version = "dev" + Commit = "" + BuildDate = "" +) + +// String returns a human-friendly version string. +func String() string { + if Commit != "" { + return Version + "-" + Commit + } + return Version +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..56014ce --- /dev/null +++ b/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/gmgauthier/gralculator/internal/version" +) + +var versionFlag bool + +func init() { + flag.BoolVar(&versionFlag, "v", false, "print version and exit") + flag.BoolVar(&versionFlag, "version", false, "print version and exit") +} + +func main() { + flag.Parse() + + if versionFlag { + fmt.Println(version.String()) + return + } + + // TODO (phase 3): wire up tea.NewProgram with ui model + fmt.Println("gralculator (skeleton)") + fmt.Println("Version:", version.String()) + fmt.Println("Tab will eventually cycle bases (DEC/HEX/BIN/OCT).") + fmt.Println("See docs/ and spec.md for design.") + os.Exit(0) +}