2026-06-06 13:28:59 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-06 13:30:08 +00:00
|
|
|
"time"
|
|
|
|
|
|
2026-06-06 13:28:59 +00:00
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
|
|
|
|
|
"github.com/gmgauthier/gralculator/internal/calc"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-06 13:30:08 +00:00
|
|
|
// 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.
|
2026-06-06 13:28:59 +00:00
|
|
|
type App struct {
|
|
|
|
|
engine *calc.Engine
|
|
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
|
2026-06-06 13:30:08 +00:00
|
|
|
// flash state for key actions and CERR
|
|
|
|
|
flash string // "base", "cerr", "op", etc.
|
|
|
|
|
cerrFlash bool
|
2026-06-06 13:28:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewApp() *App {
|
|
|
|
|
return &App{
|
|
|
|
|
engine: calc.NewEngine(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 13:30:08 +00:00
|
|
|
func (a *App) Init() tea.Cmd { return nil }
|
2026-06-06 13:28:59 +00:00
|
|
|
|
|
|
|
|
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
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-06-06 13:28:59 +00:00
|
|
|
case tea.KeyMsg:
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "q", "ctrl+c", "esc":
|
|
|
|
|
return a, tea.Quit
|
2026-06-06 13:30:08 +00:00
|
|
|
|
2026-06-06 13:28:59 +00:00
|
|
|
case "tab":
|
2026-06-06 13:30:08 +00:00
|
|
|
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")
|
2026-06-06 13:28:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return a, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) View() string {
|
2026-06-06 13:30:08 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-06-06 13:43:08 +00:00
|
|
|
// 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.
|
2026-06-06 13:30:08 +00:00
|
|
|
num := a.engine.FormatForDisplay()
|
|
|
|
|
if a.cerrFlash {
|
|
|
|
|
num = "CERR"
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 13:43:08 +00:00
|
|
|
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")
|
2026-06-06 13:30:08 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 13:43:08 +00:00
|
|
|
// 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)
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
// 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"), " ")
|
|
|
|
|
|
2026-06-06 13:43:08 +00:00
|
|
|
rawGrid := lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4, rowBase)
|
|
|
|
|
// Center the keypad grid under the (now wide) display
|
|
|
|
|
grid := lipgloss.NewStyle().Width(dispW).Align(lipgloss.Center).Render(rawGrid)
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
// 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")
|
2026-06-06 13:43:08 +00:00
|
|
|
centeredHint := lipgloss.NewStyle().Width(dispW).Align(lipgloss.Center).Render(hint)
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
|
|
|
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("gralculator"),
|
|
|
|
|
"",
|
|
|
|
|
display,
|
|
|
|
|
"",
|
|
|
|
|
grid,
|
|
|
|
|
"",
|
2026-06-06 13:43:08 +00:00
|
|
|
centeredHint,
|
2026-06-06 13:28:59 +00:00
|
|
|
)
|
2026-06-06 13:30:08 +00:00
|
|
|
|
2026-06-06 13:43:08 +00:00
|
|
|
// Center the whole card (content-sized, not stretched). The display inside now uses full width.
|
2026-06-06 13:30:08 +00:00
|
|
|
card := outer.Render(inner)
|
|
|
|
|
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, card)
|
2026-06-06 13:28:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 13:30:08 +00:00
|
|
|
// Run is a convenience for main.go (spike).
|
|
|
|
|
func Run() error {
|
|
|
|
|
p := tea.NewProgram(NewApp(), tea.WithAltScreen())
|
|
|
|
|
_, err := p.Run()
|
|
|
|
|
return err
|
|
|
|
|
}
|