package ui import ( "strings" "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", "key", etc. cerrFlash bool // pressedKey tracks the last pressed keypad button for tactile "pressed" visual feedback pressedKey string } 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 msg.which == "key" { a.pressedKey = "" } 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" // BASE is Tab-only now (no visual button in the grid). // The "base" flash will highlight the base label in the display row. return a, tick(140*time.Millisecond, "base") case "backspace": a.engine.ClearEntry() a.flash = "clear" a.pressedKey = "C" return a, tick(140*time.Millisecond, "key") case ".": a.engine.EnterDecimalPoint() a.pressedKey = "." return a, tick(140*time.Millisecond, "key") case "+", "-", "*", "/": a.engine.SetOperator(msg.String()) a.flash = "op" a.pressedKey = msg.String() return a, tick(140*time.Millisecond, "key") case "m", "M": // MOD as immediate for convenience in spike a.engine.Mod() a.flash = "op" a.pressedKey = "MOD" return a, tick(140*time.Millisecond, "key") case "=", "enter": a.engine.Equals() a.flash = "eq" a.pressedKey = "=" return a, tick(140*time.Millisecond, "key") case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9": a.engine.EnterDigit(rune(msg.String()[0])) a.pressedKey = msg.String() return a, tick(140*time.Millisecond, "key") // Tight-scope HEX entry: A-F (and a-f) only accepted while the base // indicator says HEX. We pass the rune (uppercased inside engine). // No visual buttons for A-F in this scope — keyboard only. case "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F": if a.engine.CurrentBase() == calc.BaseHEX { a.engine.EnterDigit(rune(msg.String()[0])) a.pressedKey = strings.ToUpper(msg.String()) return a, tick(140*time.Millisecond, "key") } // In other bases: ignore (A-F reserved for future HEX entry) case "delete", "del": // All Clear a.engine.AllClear() a.flash = "clear" a.pressedKey = "AC" return a, tick(140*time.Millisecond, "key") case "+/-": a.engine.ChangeSign() a.pressedKey = "+/-" return a, tick(140*time.Millisecond, "key") } } 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) flashStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Background(lipgloss.Color("63")). Bold(true). Align(lipgloss.Center) // Compute maximal width for the main display (full span inside the card) dispW := a.width - 8 // outer border + padding + breathing room if dispW < 40 { dispW = 50 } // Base indicator at the *start* of the main display row, e.g. [BIN] baseLabel := "[" + string(a.engine.CurrentBase()) + "]" baseSt := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Bold(true) if a.flash == "base" { baseSt = flashStyle } basePart := baseSt.Render(baseLabel) // Number (or CERR during flash). Right-aligned in the remaining wide space. num := a.engine.FormatForDisplay() if a.cerrFlash { num = "CERR" } numW := dispW - lipgloss.Width(basePart) - 4 // gap + padding if numW < 10 { numW = 10 } numSt := lipgloss.NewStyle(). Background(lipgloss.Color("235")). Foreground(lipgloss.Color("46")). Width(numW). Align(lipgloss.Right). Padding(0, 1) numPart := numSt.Render(num) if a.cerrFlash { numPart = flashStyle.Width(numW).Align(lipgloss.Right).Padding(0, 1).Render("CERR") } // Single main display row: base on left + wide right-aligned number. // This spans the full maximal width of the panel. displayRow := lipgloss.JoinHorizontal(lipgloss.Left, basePart, " ", numPart) // The LCD container provides the bordered panel with vertical padding // for "large" visual weight. The row inside uses the full width. lcd := lipgloss.NewStyle(). Background(lipgloss.Color("235")). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("238")). Padding(1, 1). // vertical padding gives the row height/weight Width(dispW) display := lcd.Render(displayRow) // --- Tactile keypad decoration --- // Base style for a "key": bordered, slightly raised look with dark background. // This gives each button a physical, tactile calculator key appearance. keyStyle := lipgloss.NewStyle(). Width(5). Height(2). Align(lipgloss.Center). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("238")). Background(lipgloss.Color("236")). Foreground(lipgloss.Color("250")) // Pressed/activated style: bright inversion for that "key is being pressed" feel. pressedStyle := flashStyle. Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("63")). Width(5). Height(2). Align(lipgloss.Center) // Specialized key styles for visual grouping (like real calculators). numKey := keyStyle opKey := keyStyle.Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235")) clearKey := keyStyle.Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52")) modKey := keyStyle.Foreground(lipgloss.Color("214")) // orange-ish for MOD hexKey := keyStyle.Foreground(lipgloss.Color("214")).Background(lipgloss.Color("235")) // for A-F, only shown in HEX mode makeKey := func(label string) string { var st lipgloss.Style switch label { case "+", "-", "*", "/", "=": st = opKey case "C", "AC": st = clearKey case "MOD": st = modKey case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ".": st = numKey case "+/-": st = opKey default: st = keyStyle } if a.pressedKey == label { st = pressedStyle } return st.Render(label) } spacer := " " // gap between keys for tactile separation row1 := lipgloss.JoinHorizontal(lipgloss.Top, makeKey("7"), spacer, makeKey("8"), spacer, makeKey("9"), spacer, makeKey("/"), spacer, makeKey("MOD")) row2 := lipgloss.JoinHorizontal(lipgloss.Top, makeKey("4"), spacer, makeKey("5"), spacer, makeKey("6"), spacer, makeKey("*"), spacer, makeKey("C")) row3 := lipgloss.JoinHorizontal(lipgloss.Top, makeKey("1"), spacer, makeKey("2"), spacer, makeKey("3"), spacer, makeKey("-"), spacer, makeKey("AC")) row4 := lipgloss.JoinHorizontal(lipgloss.Top, makeKey("0"), spacer, makeKey("."), spacer, makeKey("+/-"), spacer, makeKey("+"), spacer, makeKey("=")) var rawGrid string if a.engine.CurrentBase() == calc.BaseHEX { // HEX mode only: A-F row appears at the top of the keypad (only after Tab into HEX). // Use dedicated hexBtn to force hexKey style (avoids label conflict with "C" clear button). hexBtn := func(label string) string { st := hexKey if a.pressedKey == label { st = pressedStyle } return st.Render(label) } hexRow := lipgloss.JoinHorizontal(lipgloss.Top, hexBtn("A"), spacer, hexBtn("B"), spacer, hexBtn("C"), spacer, hexBtn("D"), spacer, hexBtn("E"), spacer, hexBtn("F"), ) hexW := lipgloss.Width(hexRow) // Robust per-line centering of the pre-joined 5-key rows under the 6-key hex row. // We pad each individual line of the row block explicitly. This avoids any subtle // artifacts from Style.Width+Align or PlaceHorizontal when the input is already a // multi-line string containing ANSI border sequences. Ensures every line (including // the bottom border lines of the last row) has exactly the same width so vertical // borders and bottom edges line up cleanly with no dangling fragments or splits. centerUnderHex := func(block string) string { if block == "" { return block } lines := strings.Split(block, "\n") for i, line := range lines { cw := lipgloss.Width(line) if cw >= hexW { continue } pad := hexW - cw left := pad / 2 right := pad - left lines[i] = strings.Repeat(" ", left) + line + strings.Repeat(" ", right) } return strings.Join(lines, "\n") } row1 = centerUnderHex(row1) row2 = centerUnderHex(row2) row3 = centerUnderHex(row3) row4 = centerUnderHex(row4) rawGrid = lipgloss.JoinVertical(lipgloss.Left, hexRow, row1, row2, row3, row4) } else { // Normal mode: standard 4-row grid (no A-F) rawGrid = lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4) } // Center the key grid directly under the display. // No enclosing container — just the individually styled keys. // When in HEX the A-F row appears at the top of the keypad. // Explicit per-line centering of the entire rawGrid (the stacked hex+arith rows, // where arith rows are already internally padded to hexW) into dispW. // We do this manually instead of lipgloss.NewStyle().Width(dispW).Align(Center).Render // because the latter was producing inconsistent left padding on certain lines // (especially bottom border lines containing box-drawing chars + heavy ANSI), // causing the bottoms of the last row's keys to shift 1-2 columns relative to // their own tops and sides. kpLines := strings.Split(rawGrid, "\n") for j := range kpLines { cw := lipgloss.Width(kpLines[j]) if cw < dispW { p := dispW - cw l := p / 2 r := p - l kpLines[j] = strings.Repeat(" ", l) + kpLines[j] + strings.Repeat(" ", r) } } keypad := strings.Join(kpLines, "\n") // Hint (minimal, non-wrapping, like gostations final player) hint := lipgloss.NewStyle().Faint(true).Render("Tab:BASE m:MOD BackSpace:C Del:AC") centeredHint := lipgloss.NewStyle().Width(dispW).Align(lipgloss.Center).Render(hint) inner := lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("gralculator"), "", display, "", keypad, "", centeredHint, ) // Center the whole card (content-sized, not stretched). The display inside now uses full width. 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 }