- BASE is now Tab-only (still fully functional, with clear hint: 'Tab:BASE'). - Deleted the special baseRow / fullRowWidth centering logic and the single-button bottom row. - The keypad grid is now a clean, regular 4-row block of keys. - This eliminates the persistent bottom-border alignment bug on the BASE key (chopped + shifted). - Updated Tab handler to use the 'base' flash on the display label instead of trying to highlight a non-existent button. - Cleaned up unused baseKey style and related comments. - Updated docs/UI_DESIGN.md to document that BASE is keyboard-only via Tab. As the user noted: with a good hint, the visual key was unnecessary and was causing layout friction. The keypad now looks much more balanced.
292 lines
8.4 KiB
Go
292 lines
8.4 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"
|
|
// 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")
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
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("="))
|
|
|
|
// Compact 4-row keypad grid (BASE action is Tab-only; see hint).
|
|
rawGrid := lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4)
|
|
|
|
// Center the key grid directly under the display.
|
|
// No enclosing container — just the individually styled keys.
|
|
keypad := lipgloss.NewStyle().
|
|
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
|
|
}
|