chore(skeleton): minimal Go project layout + build system

- go.mod (1.24.2 + initial bubbletea/lipgloss)
- Makefile (build, install to ~/.local/bin/gralculator, test, clean; ldflags version injection)
- internal/version (ldflags-compatible String())
- main.go stub (version flag + placeholder)
- internal/calc/ (Engine skeleton with Base, CycleBase, IsInteger, ErrConversionNotPossible, FormatForDisplay stub + basic tests for CERR path)
- internal/ui/ (App model stub with Tab handling placeholder + lipgloss import for future rendering)

This establishes the three-phase foundation. Next: flesh out engine (phase 2), then TUI spike (phase 3). Paper trail continues.
This commit is contained in:
Grok 2026-06-06 14:28:59 +01:00
parent 0c7473943d
commit 2a26148c8a
7 changed files with 308 additions and 0 deletions

53
Makefile Normal file
View File

@ -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/"

8
go.mod Normal file
View File

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

87
internal/calc/calc.go Normal file
View File

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

View File

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

67
internal/ui/ui.go Normal file
View File

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

View File

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

32
main.go Normal file
View File

@ -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)
}