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:
parent
0c7473943d
commit
2a26148c8a
53
Makefile
Normal file
53
Makefile
Normal 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
8
go.mod
Normal 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
87
internal/calc/calc.go
Normal 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.
|
||||||
44
internal/calc/calc_test.go
Normal file
44
internal/calc/calc_test.go
Normal 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
67
internal/ui/ui.go
Normal 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.
|
||||||
17
internal/version/version.go
Normal file
17
internal/version/version.go
Normal 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
32
main.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user