gralculator/internal/ui/ui.go
Grok b0dfc31767 ui: decorate keypad grid for tactile calculator look
- Individual keys now use bordered mini-panels (NormalBorder 238) + dedicated bg/fg per type:
  - Numbers: neutral dark
  - Operators: 63 accent
  - Clears (C/AC): red-tinted warning
  - MOD: orange highlight
  - BASE: 63 border + bold (stands out)
- Height(2) + padding for chunkier physical-button presence.
- Pressed state inverts to flashStyle (bright 63 bg + white bold) for 140ms on every key action — direct 'click' feedback.
- Gaps between keys + full-width backing panel (bg 234 + border) create a 'faceplate' effect so keys look like they sit on a real calculator keyboard.
- Updated per-key tracking (pressedKey) in model + Update so flash is applied to the correct button.
- Documented the new tactile decoration in docs/UI_DESIGN.md (replaced old plain-text description).
- Matches the wide integrated display row from previous iteration.

The keypad now feels much more tactile and calculator-like while staying true to the lipgloss + gostations patterns.
2026-06-06 14:50:03 +01:00

299 lines
8.6 KiB
Go

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", "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"
a.pressedKey = "BASE"
return a, tick(140*time.Millisecond, "key")
case "c", "C":
a.engine.ClearEntry()
a.flash = "clear"
a.pressedKey = "C"
return a, tick(140*time.Millisecond, "key")
case "backspace":
a.engine.Backspace()
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")
case "ac", "ctrl+l": // 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.Copy()
opKey := keyStyle.Copy().Foreground(lipgloss.Color("63")).Background(lipgloss.Color("235"))
clearKey := keyStyle.Copy().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("52"))
modKey := keyStyle.Copy().Foreground(lipgloss.Color("214")) // orange-ish for MOD
baseKey := keyStyle.Copy().BorderForeground(lipgloss.Color("63")).Bold(true)
makeKey := func(label string) string {
var st lipgloss.Style
switch label {
case "+", "-", "*", "/", "=":
st = opKey
case "C", "AC":
st = clearKey
case "MOD":
st = modKey
case "BASE":
st = baseKey
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("="))
rowBase := lipgloss.JoinHorizontal(lipgloss.Top, makeKey("BASE"))
// Compact grid of keys
rawGrid := lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4, rowBase)
// Keypad backing panel: gives the whole grid a "calculator faceplate" look.
// Darker background, subtle border, padding. Centered under the wide display.
keypad := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Background(lipgloss.Color("234")).
Padding(1, 2).
Width(dispW).
Align(lipgloss.Center).
Render(rawGrid)
// 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")
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
}