2026-06-06 13:28:59 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-06 14:55:02 +00:00
|
|
|
"strings"
|
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
|
2026-06-06 13:50:03 +00:00
|
|
|
flash string // "base", "cerr", "op", "key", etc.
|
2026-06-06 13:30:08 +00:00
|
|
|
cerrFlash bool
|
2026-06-06 13:50:03 +00:00
|
|
|
|
|
|
|
|
// pressedKey tracks the last pressed keypad button for tactile "pressed" visual feedback
|
|
|
|
|
pressedKey string
|
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
|
|
|
|
|
}
|
2026-06-06 13:50:03 +00:00
|
|
|
if msg.which == "key" {
|
|
|
|
|
a.pressedKey = ""
|
|
|
|
|
}
|
2026-06-06 13:30:08 +00:00
|
|
|
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"
|
2026-06-06 14:05:53 +00:00
|
|
|
// 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")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
2026-06-06 14:11:14 +00:00
|
|
|
case "backspace":
|
2026-06-06 13:30:08 +00:00
|
|
|
a.engine.ClearEntry()
|
|
|
|
|
a.flash = "clear"
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = "C"
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
case ".":
|
|
|
|
|
a.engine.EnterDecimalPoint()
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = "."
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
case "+", "-", "*", "/":
|
|
|
|
|
a.engine.SetOperator(msg.String())
|
|
|
|
|
a.flash = "op"
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = msg.String()
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
case "m", "M": // MOD as immediate for convenience in spike
|
|
|
|
|
a.engine.Mod()
|
|
|
|
|
a.flash = "op"
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = "MOD"
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
case "=", "enter":
|
|
|
|
|
a.engine.Equals()
|
|
|
|
|
a.flash = "eq"
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = "="
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
|
|
|
|
a.engine.EnterDigit(rune(msg.String()[0]))
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = msg.String()
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
2026-06-06 14:55:02 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-06-06 14:11:14 +00:00
|
|
|
case "delete", "del": // All Clear
|
2026-06-06 13:30:08 +00:00
|
|
|
a.engine.AllClear()
|
|
|
|
|
a.flash = "clear"
|
2026-06-06 13:50:03 +00:00
|
|
|
a.pressedKey = "AC"
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
|
|
|
|
|
|
|
|
|
case "+/-":
|
|
|
|
|
a.engine.ChangeSign()
|
|
|
|
|
a.pressedKey = "+/-"
|
|
|
|
|
return a, tick(140*time.Millisecond, "key")
|
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
|
|
|
|
2026-06-06 13:50:03 +00:00
|
|
|
// --- 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.Copy()
|
|
|
|
|
opKey := keyStyle.Copy().Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235"))
|
|
|
|
|
clearKey := keyStyle.Copy().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52"))
|
2026-06-06 16:04:11 +00:00
|
|
|
modKey := keyStyle.Copy().Foreground(lipgloss.Color("214")) // orange-ish for MOD
|
2026-06-06 14:59:22 +00:00
|
|
|
hexKey := keyStyle.Copy().Foreground(lipgloss.Color("214")).Background(lipgloss.Color("235")) // for A-F, only shown in HEX mode
|
2026-06-06 13:50:03 +00:00
|
|
|
|
|
|
|
|
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
|
2026-06-06 13:30:08 +00:00
|
|
|
}
|
2026-06-06 13:50:03 +00:00
|
|
|
return st.Render(label)
|
2026-06-06 13:30:08 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-06 13:50:03 +00:00
|
|
|
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("="))
|
2026-06-06 13:30:08 +00:00
|
|
|
|
2026-06-06 14:59:22 +00:00
|
|
|
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"),
|
|
|
|
|
)
|
2026-06-06 16:04:11 +00:00
|
|
|
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)
|
2026-06-06 14:59:22 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-06 13:50:03 +00:00
|
|
|
|
2026-06-06 14:01:40 +00:00
|
|
|
// Center the key grid directly under the display.
|
2026-06-06 14:05:53 +00:00
|
|
|
// No enclosing container — just the individually styled keys.
|
2026-06-06 14:59:22 +00:00
|
|
|
// When in HEX the A-F row appears at the top of the keypad.
|
2026-06-06 16:04:11 +00:00
|
|
|
// 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")
|
2026-06-06 13:30:08 +00:00
|
|
|
|
|
|
|
|
// Hint (minimal, non-wrapping, like gostations final player)
|
2026-06-06 14:11:14 +00:00
|
|
|
hint := lipgloss.NewStyle().Faint(true).Render("Tab:BASE m:MOD BackSpace:C Del:AC")
|
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,
|
|
|
|
|
"",
|
2026-06-06 13:50:03 +00:00
|
|
|
keypad,
|
2026-06-06 13:30:08 +00:00
|
|
|
"",
|
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
|
|
|
|
|
}
|