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.
This commit is contained in:
Grok 2026-06-06 14:50:03 +01:00
parent 4f58ee9f2d
commit b0dfc31767
2 changed files with 113 additions and 28 deletions

View File

@ -21,16 +21,25 @@ When a non-integer conversion is attempted (e.g. 3.833... + BASE / Tab), the num
This layout keeps the galculator spirit (base visible at a glance next to the big number) while being practical in a TUI and reducing vertical space. The entire row is one logical "large display".
## Keypad Grid
## Keypad Grid (Tactile Decoration)
Sparse for MVP (no memory, no %, no bitwise, no scientific, no A-F yet).
Uses the same button-cell rendering idiom as gostations `renderPlayback` / `makeButton`:
The keypad is now decorated to evoke a physical, tactile calculator:
- `lipgloss.NewStyle().Width(5).Align(lipgloss.Center).Padding(0,1).Render(label)`
- Active/pressed state uses green accent or the flashStyle (white on 63 bg, bold).
- Flash on every key action using `tea.Tick` + clearFlashMsg (140ms), exactly as tuned in gostations player.
- Each key is a bordered mini-panel (NormalBorder color 238) with its own background and foreground.
- Base key style: dark bg 236, light fg 250, subtle border — gives a "raised key" appearance.
- Specialized variants:
- Number keys (0-9 .): neutral.
- Operators (+ - * / =): accent color 63 for quick recognition.
- Clears (C, AC): red-tinted bg 52 + fg 203 (warning/danger).
- MOD: highlighted (214 orange).
- BASE: prominent border color 63 + bold (the special display function).
- Pressed state: the key inverts to the bright flashStyle (white fg on 63 bg, bold border) for ~140ms on every action. This provides immediate "tactile click" visual feedback (modeled directly on gostations volume/skip/stop flashes).
- Keys use Height(2) for chunkier, more button-like presence.
- Spacing (" " gaps) between keys and a backing panel (bg 234 + its own border) give the whole keypad a "faceplate" or "keyboard tray" look, making the individual keys pop as separate physical buttons.
- The entire keypad backing is full-width (matching the display) and centered.
Layout sketch (subject to refinement during spike; 4-5 columns typical):
Layout sketch (subject to refinement; 4-5 columns):
```
7 8 9 / MOD
@ -40,10 +49,12 @@ Layout sketch (subject to refinement during spike; 4-5 columns typical):
BASE
```
(Or tighter grouping. BASE is prominent because it is the distinctive "programmer display" feature.)
BASE is prominent because it is the distinctive "programmer display" feature.
All important actions have direct keys (digits, operators, =/Enter, Backspace, Tab for BASE, c/C, etc.). The on-screen buttons are primarily visual + mouse targets.
This decoration keeps the UI lightweight while making the keypad feel far more "real" than plain text on background. Further polish (icons, better color themes, mouse hover if desired) can be added later.
## Overall Card & Polish
- The entire calculator is wrapped in a **content-sized centered card**:
- Outer rounded border (color 63, matching gostations label/border).

View File

@ -28,8 +28,11 @@ type App struct {
height int
// flash state for key actions and CERR
flash string // "base", "cerr", "op", etc.
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 {
@ -57,6 +60,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.which == "cerr" {
a.cerrFlash = false
}
if msg.which == "key" {
a.pressedKey = ""
}
if a.flash == msg.which {
a.flash = ""
}
@ -74,41 +80,56 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tick(600*time.Millisecond, "cerr")
}
a.flash = "base"
return a, tick(140*time.Millisecond, "base")
a.pressedKey = "BASE"
return a, tick(140*time.Millisecond, "key")
case "c", "C":
a.engine.ClearEntry()
a.flash = "clear"
return a, tick(140*time.Millisecond, "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"
return a, tick(140*time.Millisecond, "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"
return a, tick(140*time.Millisecond, "op")
a.pressedKey = "MOD"
return a, tick(140*time.Millisecond, "key")
case "=", "enter":
a.engine.Equals()
a.flash = "eq"
return a, tick(140*time.Millisecond, "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"
return a, tick(140*time.Millisecond, "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
@ -178,24 +199,77 @@ func (a *App) View() string {
display := lcd.Render(displayRow)
// 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)
// --- 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
}
return st.Render(s)
if a.pressedKey == label {
st = pressedStyle
}
return st.Render(label)
}
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"), " ")
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)
// Center the keypad grid under the (now wide) display
grid := lipgloss.NewStyle().Width(dispW).Align(lipgloss.Center).Render(rawGrid)
// 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")
@ -206,7 +280,7 @@ func (a *App) View() string {
"",
display,
"",
grid,
keypad,
"",
centeredHint,
)