gralculator/internal/ui/ui.go

201 lines
5.2 KiB
Go
Raw Normal View History

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
}