feat(tui): focused rendering spike for galculator-inspired two-row display + single BASE (phase 3)

- internal/ui/ui.go: full Bubble Tea App
  - Large tall LCD-style number area + small current-base row (only the active label highlighted)
  - CERR flash (~600ms color 63) on BASE when value is non-integer (exact policy from spec)
  - 140ms key action flashes (same style as gostations volume/skip/stop)
  - Minimal usable keypad grid (digits, + - * / = . +/- MOD C AC, prominent BASE button)
  - Tab (and button) drives engine.CycleBase()
  - Content-sized centered card (lipgloss.Place + rounded 63 border), subtle 238 inners
  - Minimal non-wrapping hint row
  - Reuses gostations lipgloss idioms (Join*, Width/Align/Center, flashStyle, NormalBorder, etc.)
- main.go: now actually launches tea.NewProgram (with AltScreen)
- Binary: build/gralculator is runnable
- Demonstrates: enter 23/6 = (or 1/3), press Tab → CERR blink, base stays DEC; integer values cycle cleanly and reformat (HEX etc.)

All three phases complete. Architecture + design notes live in docs/. Full commit history for backtracking.
This commit is contained in:
Grok 2026-06-06 14:30:08 +01:00
parent 0af68d45eb
commit 316ce708ac
4 changed files with 230 additions and 31 deletions

21
go.mod
View File

@ -6,3 +6,24 @@ require (
github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.11.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

45
go.sum Normal file
View File

@ -0,0 +1,45 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@ -1,20 +1,35 @@
package ui
import (
"time"
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.
// flashMsg and clearFlashMsg for timed visual feedback (140ms style from gostations).
type flashMsg struct{ which string }
type clearFlashMsg struct{ which string }
// tick returns a command that sends a flash clear after the given duration.
func tick(d time.Duration, which string) tea.Cmd {
return tea.Tick(d, func(_ time.Time) tea.Msg {
return clearFlashMsg{which: which}
})
}
// App is the root Bubble Tea model.
// This is the focused rendering spike for the two-row display + single BASE keypad.
type App struct {
engine *calc.Engine
width int
height int
// flash state etc. will be added during spike (see gostations patterns)
// flash state for key actions and CERR
flash string // "base", "cerr", "op", etc.
cerrFlash bool
}
func NewApp() *App {
@ -23,45 +38,163 @@ func NewApp() *App {
}
}
func (a *App) Init() tea.Cmd {
return nil
}
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 flashMsg:
a.flash = msg.which
if msg.which == "cerr" {
a.cerrFlash = true
}
return a, tick(140*time.Millisecond, msg.which)
case clearFlashMsg:
if msg.which == "cerr" {
a.cerrFlash = false
}
if a.flash == msg.which {
a.flash = ""
}
return a, nil
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
if err := a.engine.CycleBase(); err == calc.ErrConversionNotPossible {
a.flash = "cerr"
a.cerrFlash = true
return a, tick(600*time.Millisecond, "cerr")
}
a.flash = "base"
return a, tick(140*time.Millisecond, "base")
case "c", "C":
a.engine.ClearEntry()
a.flash = "clear"
return a, tick(140*time.Millisecond, "clear")
case "backspace":
a.engine.Backspace()
case ".":
a.engine.EnterDecimalPoint()
case "+", "-", "*", "/":
a.engine.SetOperator(msg.String())
a.flash = "op"
return a, tick(140*time.Millisecond, "op")
case "m", "M": // MOD as immediate for convenience in spike
a.engine.Mod()
a.flash = "op"
return a, tick(140*time.Millisecond, "op")
case "=", "enter":
a.engine.Equals()
a.flash = "eq"
return a, tick(140*time.Millisecond, "eq")
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
a.engine.EnterDigit(rune(msg.String()[0]))
case "ac", "ctrl+l": // All Clear
a.engine.AllClear()
a.flash = "clear"
return a, tick(140*time.Millisecond, "clear")
}
}
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",
// Styles (reusing gostations idioms: 63 for accents/flashes, 238 subtle borders, 46 green LCD, 235 bg)
outer := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2)
lcd := lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")).
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(1, 2).
Align(lipgloss.Right)
smallRow := lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Align(lipgloss.Center)
flashStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("63")).
Bold(true).
Align(lipgloss.Center)
// Large number area (tall for "big font" weight)
num := a.engine.FormatForDisplay()
if a.cerrFlash {
num = "CERR"
}
large := lcd.Height(5).Render(num)
// Small bottom row — only the current base
curBase := string(a.engine.CurrentBase())
if a.flash == "base" {
curBase = flashStyle.Render(curBase)
} else {
curBase = smallRow.Render(curBase)
}
small := lcd.Height(1).Render(curBase)
display := lipgloss.JoinVertical(lipgloss.Center, large, small)
// Minimal keypad grid (text cells for the spike; real button cells can be expanded)
btn := func(s string) string {
st := lipgloss.NewStyle().Width(5).Align(lipgloss.Center).Padding(0, 1)
if a.flash != "" && s == "BASE" {
return flashStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render(s)
}
return st.Render(s)
}
row1 := lipgloss.JoinHorizontal(lipgloss.Top, btn("7"), btn("8"), btn("9"), btn("/"), btn("MOD"))
row2 := lipgloss.JoinHorizontal(lipgloss.Top, btn("4"), btn("5"), btn("6"), btn("*"), btn("C"))
row3 := lipgloss.JoinHorizontal(lipgloss.Top, btn("1"), btn("2"), btn("3"), btn("-"), btn("AC"))
row4 := lipgloss.JoinHorizontal(lipgloss.Top, btn("0"), btn("."), btn("+/-"), btn("+"), btn("="))
rowBase := lipgloss.JoinHorizontal(lipgloss.Top, " ", btn("BASE"), " ")
grid := lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4, rowBase)
// Hint (minimal, non-wrapping, like gostations final player)
hint := lipgloss.NewStyle().Faint(true).Render("Tab:BASE 0-9 . = + - * / m:MOD c:C ac:AC q:quit")
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("gralculator"),
"",
display,
"",
grid,
"",
hint,
)
// Center the whole card (content-sized, not stretched)
card := outer.Render(inner)
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, card)
}
// TODO (phase 3): full model with flashes, proper display rendering,
// keypad grid, Tab + other key handling wired to engine, resize/centering.
// Run is a convenience for main.go (spike).
func Run() error {
p := tea.NewProgram(NewApp(), tea.WithAltScreen())
_, err := p.Run()
return err
}

12
main.go
View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"github.com/gmgauthier/gralculator/internal/ui"
"github.com/gmgauthier/gralculator/internal/version"
)
@ -23,10 +24,9 @@ func main() {
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)
// Phase 3 spike: launch the real TUI.
if err := ui.Run(); err != nil {
fmt.Fprintf(os.Stderr, "gralculator: %v\n", err)
os.Exit(1)
}
}