ui: integrate base indicator into main wide display row + full width span

- Refactored View() display to a single wide row inside the LCD panel:
  - Leading base indicator on the left: [BIN], [HEX], etc. (flashes on successful cycle)
  - Number (or CERR) right-aligned in the remaining maximal width (spans full available inside the card)
  - LCD container uses full dispW (based on terminal width) + vertical padding for visual weight
  - CERR still flashes the number slot (base label remains visible on left)
- Keypad grid and hint now also respect the wide dispW and are centered under the display
- Updated docs/UI_DESIGN.md and ARCHITECTURE.md with the new integrated layout (replaced old separate two-row description and ASCII)
- Matches user request: main display spans full maximal width; base begins the main display row (e.g. [ [BIN] [              3465... ] ] inside the panel)

This is iterative polish on the phase 3 spike. Paper trail updated.
This commit is contained in:
Grok 2026-06-06 14:43:08 +01:00
parent 316ce708ac
commit 4f58ee9f2d
3 changed files with 65 additions and 42 deletions

View File

@ -12,7 +12,7 @@ Core philosophy for v0.1 (MVP):
## Package Structure ## Package Structure
- `internal/calc` — Pure Go calculation engine. No TUI dependencies. Handles value state, pending operations, base cycling with integer checks, formatting, and arithmetic (+ - * / MOD). Fully unit-testable. - `internal/calc` — Pure Go calculation engine. No TUI dependencies. Handles value state, pending operations, base cycling with integer checks, formatting, and arithmetic (+ - * / MOD). Fully unit-testable.
- `internal/ui` — Bubble Tea model, view, and update logic. Renders the two-row display (large number + small current-base indicator) and keypad grid. Reuses lipgloss patterns, centering (`Place`), subtle borders, flash animations (tea.Tick + 140ms color 63 style), and content-sized card layout refined in gostations. - `internal/ui` — Bubble Tea model, view, and update logic. Renders the integrated wide display row (base indicator at start of the row like `[BIN]`, followed by maximal-width right-aligned number) + keypad grid. Reuses lipgloss patterns, centering (`Place`), subtle borders, flash animations (tea.Tick + 140ms color 63 style), and content-sized card layout refined in gostations. The display spans full available width inside the card.
- `internal/version` — Version string injection (ldflags), matching gostations. - `internal/version` — Version string injection (ldflags), matching gostations.
- `main.go` — Entry point, flags (-v), tea.NewProgram wiring, pre-flight checks if any. - `main.go` — Entry point, flags (-v), tea.NewProgram wiring, pre-flight checks if any.
- Root: `spec.md` (living high-level spec), `docs/` (detailed architecture and design notes), `Makefile`, `.gitignore`. - Root: `spec.md` (living high-level spec), `docs/` (detailed architecture and design notes), `Makefile`, `.gitignore`.
@ -23,7 +23,7 @@ Core philosophy for v0.1 (MVP):
- **Error on conversion**: When value has fractional part and BASE is pressed, large display shows "CERR" briefly (~600ms with flash styling), base does not change, then reverts to previous formatted value. - **Error on conversion**: When value has fractional part and BASE is pressed, large display shows "CERR" briefly (~600ms with flash styling), base does not change, then reverts to previous formatted value.
- **Single BASE key**: Reduces rendered keys. On-screen button labeled "BASE". Direct key is `Tab` (chosen to leave A-F free for future hex digits). - **Single BASE key**: Reduces rendered keys. On-screen button labeled "BASE". Direct key is `Tab` (chosen to leave A-F free for future hex digits).
- **Display layout**: - **Display layout**:
- Large top area inside bordered panel for the number (tall padding + bold/high-visibility styling for "large font" weight). - Integrated main display row inside bordered LCD panel: base indicator (e.g. [BIN]) at the left of the row, followed by the number right-aligned and spanning the maximal remaining width. Vertical padding on the container gives visual weight. (Updated from earlier separate two-row design.)
- Small bottom row inside the same panel showing only the current base ("DEC", "HEX", "BIN", or "OCT"), highlighted when active. - Small bottom row inside the same panel showing only the current base ("DEC", "HEX", "BIN", or "OCT"), highlighted when active.
- **Keypad**: Sparse 4-5 column grid for MVP (digits, operators, MOD, C, AC, single BASE button). Uses the same `makeButton` + `Width().Align(Center)` lipgloss idiom as gostations playback controls. - **Keypad**: Sparse 4-5 column grid for MVP (digits, operators, MOD, C, AC, single BASE button). Uses the same `makeButton` + `Width().Align(Center)` lipgloss idiom as gostations playback controls.
- **Card layout**: Entire UI is a content-sized, centered card (lipgloss.Place + rounded outer border color 63). Matches gostations Winamp-style player polish (subtle inner 238 borders, no stretching). - **Card layout**: Entire UI is a content-sized, centered card (lipgloss.Place + rounded outer border color 63). Matches gostations Winamp-style player polish (subtle inner 238 borders, no stretching).
@ -48,7 +48,7 @@ Core philosophy for v0.1 (MVP):
- galculator GTK screenshot — primary visual reference for the "large number + small mode row" display density. - galculator GTK screenshot — primary visual reference for the "large number + small mode row" display density.
See also: See also:
- `docs/UI_DESIGN.md` (detailed two-row display and keypad rendering notes) - `docs/UI_DESIGN.md` (detailed integrated wide display row with leading base indicator + maximal width number, and keypad rendering notes)
- `docs/KEYBOARD.md` (key binding rationale and future hex entry considerations) - `docs/KEYBOARD.md` (key binding rationale and future hex entry considerations)
--- ---

View File

@ -1,26 +1,25 @@
# Gralculator UI Design # Gralculator UI Design
## Display (Two-Row Layout) ## Display (Integrated Base + Wide Number Row)
Directly inspired by the galculator GTK reference screenshot, simplified for terminal realities and single-BASE cycling. The main display now uses a single wide row inside the bordered LCD panel (dark background ~235, green/high-visibility foreground ~46 for the number, subtle inner NormalBorder color 238). This addresses the request for the main display area to span the full maximal possible width, with the base indicator beginning the row rather than a separate row below.
Inside a single bordered "LCD" panel (dark background ~235, green/high-visibility foreground ~46 for the number, subtle inner NormalBorder color 238): - **Main display row** (spans full width of the panel): Begins with the base indicator in brackets on the left (e.g. `[BIN]`), followed by generous spacing and then the number right-aligned in the remaining wide space. The number area uses the LCD green styling and takes as much width as available inside the card (maximal possible). Vertical padding on the container gives the row "large" visual weight without needing a separate tall block.
- **Large top area**: The current numeric value. Strong visual weight via extra vertical padding/height (e.g. 3-5 lines effective), bold styling, generous font-like sizing through lipgloss. Right-aligned or centered as appropriate for the base (DEC often right, hex/bin may vary). Large enough to feel like the "big number" in galculator. - The base uses a dim style normally; successful BASE cycles flash the base badge using the color-63 style. On CERR (non-integer), the number slot flashes "CERR" (the base label stays visible on the left).
- **Small bottom row**: Directly under the number, still within the same panel. Shows only the *current* base label: `DEC`, `HEX`, `BIN`, or `OCT`. The active one is highlighted (bold + background color 63 or accent green, matching gostations key badges and outer border color). No full row of four selectable bases — the single BASE action handles cycling. Example (ASCII approximation of rendered view, showing the integrated row):
Example (ASCII approximation of rendered view):
``` ```
+--------------------------------------------------+ +------------------------------------------------------------------+
| | | [BIN] [ 34654563566564356636565 ] |
| 1234.56 | <-- large number area +------------------------------------------------------------------+
| |
| DEC | <-- small current-base row (highlighted)
+--------------------------------------------------+
``` ```
When a non-integer conversion is attempted (e.g. 3.833... + BASE), the *large number area* temporarily renders "CERR" (with the 140ms + color 63 flash/blink style used for volume/skip/stop feedback in gostations). After the flash duration (~600ms), it reverts to the previous value in the unchanged base. The small row never changes on error. (The outer +-- represent the LCD panel border + padding. The number fills the maximal remaining width and is right-aligned.)
When a non-integer conversion is attempted (e.g. 3.833... + BASE / Tab), the number portion of the row temporarily renders "CERR" (with the 140ms + color 63 flash/blink style used for volume/skip/stop feedback in gostations, held longer ~600ms for the error case). After the flash, it reverts to the previous value in the unchanged base. The base label on the left does not change on error.
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
Sparse for MVP (no memory, no %, no bitwise, no scientific, no A-F yet). Sparse for MVP (no memory, no %, no bitwise, no scientific, no A-F yet).

View File

@ -121,41 +121,62 @@ func (a *App) View() string {
BorderForeground(lipgloss.Color("63")). BorderForeground(lipgloss.Color("63")).
Padding(1, 2) Padding(1, 2)
lcd := lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")).
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(1, 2).
Align(lipgloss.Right)
smallRow := lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
Align(lipgloss.Center)
flashStyle := lipgloss.NewStyle(). flashStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")). Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("63")). Background(lipgloss.Color("63")).
Bold(true). Bold(true).
Align(lipgloss.Center) Align(lipgloss.Center)
// Large number area (tall for "big font" weight) // 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() num := a.engine.FormatForDisplay()
if a.cerrFlash { if a.cerrFlash {
num = "CERR" num = "CERR"
} }
large := lcd.Height(5).Render(num)
// Small bottom row — only the current base numW := dispW - lipgloss.Width(basePart) - 4 // gap + padding
curBase := string(a.engine.CurrentBase()) if numW < 10 {
if a.flash == "base" { numW = 10
curBase = flashStyle.Render(curBase)
} else {
curBase = smallRow.Render(curBase)
} }
small := lcd.Height(1).Render(curBase) numSt := lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")).
Width(numW).
Align(lipgloss.Right).
Padding(0, 1)
display := lipgloss.JoinVertical(lipgloss.Center, large, small) 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)
// Minimal keypad grid (text cells for the spike; real button cells can be expanded) // Minimal keypad grid (text cells for the spike; real button cells can be expanded)
btn := func(s string) string { btn := func(s string) string {
@ -172,10 +193,13 @@ func (a *App) View() string {
row4 := lipgloss.JoinHorizontal(lipgloss.Top, btn("0"), btn("."), btn("+/-"), btn("+"), btn("=")) row4 := lipgloss.JoinHorizontal(lipgloss.Top, btn("0"), btn("."), btn("+/-"), btn("+"), btn("="))
rowBase := lipgloss.JoinHorizontal(lipgloss.Top, " ", btn("BASE"), " ") rowBase := lipgloss.JoinHorizontal(lipgloss.Top, " ", btn("BASE"), " ")
grid := lipgloss.JoinVertical(lipgloss.Left, row1, row2, row3, row4, rowBase) 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)
// Hint (minimal, non-wrapping, like gostations final player) // 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") 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, inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("gralculator"), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("gralculator"),
@ -184,10 +208,10 @@ func (a *App) View() string {
"", "",
grid, grid,
"", "",
hint, centeredHint,
) )
// Center the whole card (content-sized, not stretched) // Center the whole card (content-sized, not stretched). The display inside now uses full width.
card := outer.Render(inner) card := outer.Render(inner)
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, card) return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, card)
} }