package ui import ( "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gmgauthier/gralculator/internal/calc" ) // 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 for key actions and CERR flash string // "base", "cerr", "op", etc. cerrFlash bool } 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 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": 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 { // 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) } // Run is a convenience for main.go (spike). func Run() error { p := tea.NewProgram(NewApp(), tea.WithAltScreen()) _, err := p.Run() return err }