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